mirror of
https://github.com/penpot/penpot.git
synced 2026-05-13 20:13:58 +00:00
Compare commits
No commits in common. "develop" and "2.14.0-RC5" have entirely different histories.
develop
...
2.14.0-RC5
21
.github/ISSUE_TEMPLATE/feature_request.md
vendored
21
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
type: Feature
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
@ -1,17 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: New Render Bug Report
|
||||||
about: Create a report to help us improve
|
about: Create a report about the bugs you have found in the new render
|
||||||
title: ''
|
title: ''
|
||||||
labels: ''
|
labels: new render
|
||||||
assignees: ''
|
assignees: claragvinola
|
||||||
type: Bug
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
**To Reproduce**
|
**Steps to Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
@ -21,8 +20,8 @@ Steps to reproduce the behavior:
|
|||||||
**Expected behavior**
|
**Expected behavior**
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
**Screen recordings and screenshots**
|
**Screenshots or screen recordings**
|
||||||
If possible, add screen recordings or screenshots to help explain your problem.
|
If applicable, add screenshots or screen recording to help illustrate your problem.
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
**Desktop (please complete the following information):**
|
||||||
- OS: [e.g. iOS]
|
- OS: [e.g. iOS]
|
||||||
2
.github/workflows/build-bundle.yml
vendored
2
.github/workflows/build-bundle.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.gh_ref }}
|
ref: ${{ inputs.gh_ref }}
|
||||||
|
|||||||
1
.github/workflows/build-develop.yml
vendored
1
.github/workflows/build-develop.yml
vendored
@ -1,7 +1,6 @@
|
|||||||
name: _DEVELOP
|
name: _DEVELOP
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '16 5-20 * * 1-5'
|
- cron: '16 5-20 * * 1-5'
|
||||||
|
|
||||||
|
|||||||
18
.github/workflows/build-docker-devenv.yml
vendored
18
.github/workflows/build-docker-devenv.yml
vendored
@ -16,19 +16,19 @@ jobs:
|
|||||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push DevEnv Docker image
|
- name: Build and push DevEnv Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'penpotapp/devenv'
|
DOCKER_IMAGE: 'penpotapp/devenv'
|
||||||
with:
|
with:
|
||||||
@ -39,13 +39,3 @@ jobs:
|
|||||||
tags: ${{ env.DOCKER_IMAGE }}:latest
|
tags: ${{ env.DOCKER_IMAGE }}:latest
|
||||||
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
|
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Notify Mattermost
|
|
||||||
uses: mattermost/action-mattermost-notify@master
|
|
||||||
with:
|
|
||||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
|
||||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
|
||||||
TEXT: |
|
|
||||||
🚀 *[PENPOT] New devenv available*
|
|
||||||
📄 You may want to update your devenv.
|
|
||||||
@alvaro
|
|
||||||
|
|||||||
20
.github/workflows/build-docker.yml
vendored
20
.github/workflows/build-docker.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
|||||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.gh_ref }}
|
ref: ${{ inputs.gh_ref }}
|
||||||
@ -63,10 +63,10 @@ jobs:
|
|||||||
popd
|
popd
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
@ -76,14 +76,14 @@ jobs:
|
|||||||
# images from DockerHub for unregistered users.
|
# images from DockerHub for unregistered users.
|
||||||
# https://docs.docker.com/docker-hub/usage/
|
# https://docs.docker.com/docker-hub/usage/
|
||||||
- name: Login to DockerHub Registry
|
- name: Login to DockerHub Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels)
|
- name: Extract metadata (tags, labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images:
|
images:
|
||||||
frontend
|
frontend
|
||||||
@ -95,7 +95,7 @@ jobs:
|
|||||||
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
||||||
|
|
||||||
- name: Build and push Backend Docker image
|
- name: Build and push Backend Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'backend'
|
DOCKER_IMAGE: 'backend'
|
||||||
BUNDLE_PATH: './bundle-backend'
|
BUNDLE_PATH: './bundle-backend'
|
||||||
@ -110,7 +110,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push Frontend Docker image
|
- name: Build and push Frontend Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'frontend'
|
DOCKER_IMAGE: 'frontend'
|
||||||
BUNDLE_PATH: './bundle-frontend'
|
BUNDLE_PATH: './bundle-frontend'
|
||||||
@ -125,7 +125,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push Exporter Docker image
|
- name: Build and push Exporter Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'exporter'
|
DOCKER_IMAGE: 'exporter'
|
||||||
BUNDLE_PATH: './bundle-exporter'
|
BUNDLE_PATH: './bundle-exporter'
|
||||||
@ -140,7 +140,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push Storybook Docker image
|
- name: Build and push Storybook Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'storybook'
|
DOCKER_IMAGE: 'storybook'
|
||||||
BUNDLE_PATH: './bundle-storybook'
|
BUNDLE_PATH: './bundle-storybook'
|
||||||
@ -155,7 +155,7 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push MCP Docker image
|
- name: Build and push MCP Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'mcp'
|
DOCKER_IMAGE: 'mcp'
|
||||||
BUNDLE_PATH: './bundle-mcp'
|
BUNDLE_PATH: './bundle-mcp'
|
||||||
|
|||||||
22
.github/workflows/build-main-staging.yml
vendored
22
.github/workflows/build-main-staging.yml
vendored
@ -1,22 +0,0 @@
|
|||||||
name: _MAIN-STAGING
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: '26 5-20 * * 1-5'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-bundle:
|
|
||||||
uses: ./.github/workflows/build-bundle.yml
|
|
||||||
secrets: inherit
|
|
||||||
with:
|
|
||||||
gh_ref: "main-staging"
|
|
||||||
build_wasm: "yes"
|
|
||||||
build_storybook: "yes"
|
|
||||||
|
|
||||||
build-docker:
|
|
||||||
needs: build-bundle
|
|
||||||
uses: ./.github/workflows/build-docker.yml
|
|
||||||
secrets: inherit
|
|
||||||
with:
|
|
||||||
gh_ref: "main-staging"
|
|
||||||
14
.github/workflows/build-staging-render.yml
vendored
Normal file
14
.github/workflows/build-staging-render.yml
vendored
Normal 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"
|
||||||
1
.github/workflows/build-staging.yml
vendored
1
.github/workflows/build-staging.yml
vendored
@ -1,7 +1,6 @@
|
|||||||
name: _STAGING
|
name: _STAGING
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '36 5-20 * * 1-5'
|
- cron: '36 5-20 * * 1-5'
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/build-tag.yml
vendored
1
.github/workflows/build-tag.yml
vendored
@ -1,7 +1,6 @@
|
|||||||
name: _TAG
|
name: _TAG
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- '*'
|
||||||
|
|||||||
3
.github/workflows/commit-checker.yml
vendored
3
.github/workflows/commit-checker.yml
vendored
@ -6,14 +6,12 @@ on:
|
|||||||
- edited
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
- ready_for_review
|
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- edited
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
- ready_for_review
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@ -22,7 +20,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-commit-message:
|
check-commit-message:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: Check Commit Message
|
name: Check Commit Message
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
4
.github/workflows/plugins-deploy-api-doc.yml
vendored
4
.github/workflows/plugins-deploy-api-doc.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
|||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
@ -62,7 +62,7 @@ jobs:
|
|||||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache pnpm store
|
- name: Cache pnpm store
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||||
|
|||||||
4
.github/workflows/plugins-deploy-package.yml
vendored
4
.github/workflows/plugins-deploy-package.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
|||||||
runs-on: penpot-runner-01
|
runs-on: penpot-runner-01
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.gh_ref }}
|
ref: ${{ inputs.gh_ref }}
|
||||||
@ -62,7 +62,7 @@ jobs:
|
|||||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache pnpm store
|
- name: Cache pnpm store
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||||
|
|||||||
@ -36,9 +36,9 @@ jobs:
|
|||||||
# [For new plugins]
|
# [For new plugins]
|
||||||
# Add more outputs here
|
# Add more outputs here
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- id: filter
|
- id: filter
|
||||||
uses: dorny/paths-filter@v4
|
uses: dorny/paths-filter@v3
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
colors_to_tokens:
|
colors_to_tokens:
|
||||||
|
|||||||
@ -35,7 +35,7 @@ jobs:
|
|||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
@ -60,7 +60,7 @@ jobs:
|
|||||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache pnpm store
|
- name: Cache pnpm store
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||||
|
|||||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
|||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
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
|
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
|
||||||
|
|
||||||
IMAGES=("frontend" "backend" "exporter" "storybook")
|
IMAGES=("frontend" "backend" "exporter" "storybook")
|
||||||
SHORT_TAG=${TAG%.*}
|
|
||||||
|
|
||||||
for image in "${IMAGES[@]}"; do
|
for image in "${IMAGES[@]}"; do
|
||||||
skopeo copy --all \
|
skopeo copy --all \
|
||||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||||
docker://docker.io/penpotapp/$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 \
|
skopeo copy --all \
|
||||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||||
docker://docker.io/penpotapp/$image:$alias
|
docker://docker.io/penpotapp/$image:$alias
|
||||||
@ -94,7 +93,7 @@ jobs:
|
|||||||
|
|
||||||
# --- Create GitHub release ---
|
# --- Create GitHub release ---
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
8
.github/workflows/tests-mcp.yml
vendored
8
.github/workflows/tests-mcp.yml
vendored
@ -10,7 +10,6 @@ on:
|
|||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
- 'mcp/**'
|
- 'mcp/**'
|
||||||
@ -25,15 +24,14 @@ on:
|
|||||||
- 'mcp/**'
|
- 'mcp/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-mcp:
|
test:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
name: "Test"
|
||||||
name: "Test MCP"
|
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup
|
||||||
working-directory: ./mcp
|
working-directory: ./mcp
|
||||||
|
|||||||
93
.github/workflows/tests.yml
vendored
93
.github/workflows/tests.yml
vendored
@ -9,7 +9,6 @@ on:
|
|||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
- ready_for_review
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
@ -21,14 +20,13 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: "Linter"
|
name: "Linter"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Lint Common
|
- name: Lint Common
|
||||||
working-directory: ./common
|
working-directory: ./common
|
||||||
@ -81,14 +79,13 @@ jobs:
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
|
||||||
test-common:
|
test-common:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: "Common Tests"
|
name: "Common Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./common
|
working-directory: ./common
|
||||||
@ -96,13 +93,12 @@ jobs:
|
|||||||
./scripts/test
|
./scripts/test
|
||||||
|
|
||||||
test-plugins:
|
test-plugins:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: Plugins Runtime Linter & Tests
|
name: Plugins Runtime Linter & Tests
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
id: setup-node
|
id: setup-node
|
||||||
@ -147,14 +143,13 @@ jobs:
|
|||||||
run: pnpm run build:styles-example
|
run: pnpm run build:styles-example
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: "Frontend Tests"
|
name: "Frontend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
@ -169,14 +164,13 @@ jobs:
|
|||||||
./scripts/test-components
|
./scripts/test-components
|
||||||
|
|
||||||
test-render-wasm:
|
test-render-wasm:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: "Render WASM Tests"
|
name: "Render WASM Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
working-directory: ./render-wasm
|
working-directory: ./render-wasm
|
||||||
@ -194,7 +188,6 @@ jobs:
|
|||||||
./test
|
./test
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: "Backend Tests"
|
name: "Backend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
@ -220,7 +213,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
@ -234,14 +227,13 @@ jobs:
|
|||||||
clojure -M:dev:test --reporter kaocha.report/documentation
|
clojure -M:dev:test --reporter kaocha.report/documentation
|
||||||
|
|
||||||
test-library:
|
test-library:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: "Library Tests"
|
name: "Library Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./library
|
working-directory: ./library
|
||||||
@ -249,39 +241,38 @@ jobs:
|
|||||||
./scripts/test
|
./scripts/test
|
||||||
|
|
||||||
build-integration:
|
build-integration:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
|
||||||
name: "Build Integration Bundle"
|
name: "Build Integration Bundle"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build Bundle
|
- name: Build Bundle
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/build
|
./scripts/build 0.0.0
|
||||||
|
|
||||||
- name: Store Bundle Cache
|
- name: Store Bundle Cache
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
|
|
||||||
|
|
||||||
test-integration-1:
|
test-integration-1:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
name: "Integration Tests 1/4"
|
||||||
name: "Integration Tests 1/3"
|
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
needs: build-integration
|
needs: build-integration
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore Cache
|
- name: Restore Cache
|
||||||
uses: actions/cache/restore@v5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
@ -289,10 +280,10 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/test-e2e --shard="1/3";
|
./scripts/test-e2e --shard="1/4";
|
||||||
|
|
||||||
- name: Upload test result
|
- name: Upload test result
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: integration-tests-result-1
|
name: integration-tests-result-1
|
||||||
@ -301,18 +292,17 @@ jobs:
|
|||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
test-integration-2:
|
test-integration-2:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
name: "Integration Tests 2/4"
|
||||||
name: "Integration Tests 2/3"
|
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
needs: build-integration
|
needs: build-integration
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore Cache
|
- name: Restore Cache
|
||||||
uses: actions/cache/restore@v5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
@ -320,10 +310,10 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/test-e2e --shard="2/3";
|
./scripts/test-e2e --shard="2/4";
|
||||||
|
|
||||||
- name: Upload test result
|
- name: Upload test result
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: integration-tests-result-2
|
name: integration-tests-result-2
|
||||||
@ -332,18 +322,17 @@ jobs:
|
|||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
test-integration-3:
|
test-integration-3:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
name: "Integration Tests 3/4"
|
||||||
name: "Integration Tests 3/3"
|
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
needs: build-integration
|
needs: build-integration
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore Cache
|
- name: Restore Cache
|
||||||
uses: actions/cache/restore@v5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: "integration-bundle-${{ github.sha }}"
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
@ -351,13 +340,43 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/test-e2e --shard="3/3";
|
./scripts/test-e2e --shard="3/4";
|
||||||
|
|
||||||
- name: Upload test result
|
- name: Upload test result
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: integration-tests-result-3
|
name: integration-tests-result-3
|
||||||
path: frontend/test-results/
|
path: frontend/test-results/
|
||||||
overwrite: true
|
overwrite: true
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
test-integration-4:
|
||||||
|
name: "Integration Tests 4/4"
|
||||||
|
runs-on: penpot-runner-02
|
||||||
|
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
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@ -15,12 +15,6 @@
|
|||||||
.repl
|
.repl
|
||||||
/*.jpg
|
/*.jpg
|
||||||
/*.md
|
/*.md
|
||||||
!CHANGES.md
|
|
||||||
!CONTRIBUTING.md
|
|
||||||
!README.md
|
|
||||||
!AGENTS.md
|
|
||||||
!CODE_OF_CONDUCT.md
|
|
||||||
!SECURITY.md
|
|
||||||
/*.png
|
/*.png
|
||||||
/*.svg
|
/*.svg
|
||||||
/*.sql
|
/*.sql
|
||||||
@ -30,9 +24,6 @@
|
|||||||
/.clj-kondo/.cache
|
/.clj-kondo/.cache
|
||||||
/_dump
|
/_dump
|
||||||
/notes
|
/notes
|
||||||
/.opencode/package-lock.json
|
|
||||||
/plans
|
|
||||||
/prompts
|
|
||||||
/playground/
|
/playground/
|
||||||
/backend/*.md
|
/backend/*.md
|
||||||
!/backend/AGENTS.md
|
!/backend/AGENTS.md
|
||||||
@ -59,7 +50,6 @@
|
|||||||
/frontend/.storybook/preview-body.html
|
/frontend/.storybook/preview-body.html
|
||||||
/frontend/.storybook/preview-head.html
|
/frontend/.storybook/preview-head.html
|
||||||
/frontend/playwright-report/
|
/frontend/playwright-report/
|
||||||
/frontend/playwright/ui/visual-specs/
|
|
||||||
/frontend/text-editor/src/wasm/
|
/frontend/text-editor/src/wasm/
|
||||||
/frontend/dist/
|
/frontend/dist/
|
||||||
/frontend/npm-debug.log
|
/frontend/npm-debug.log
|
||||||
@ -73,7 +63,6 @@
|
|||||||
/frontend/test-results/
|
/frontend/test-results/
|
||||||
/frontend/.shadow-cljs
|
/frontend/.shadow-cljs
|
||||||
/other/
|
/other/
|
||||||
/scripts/
|
|
||||||
/nexus/
|
/nexus/
|
||||||
/tmp/
|
/tmp/
|
||||||
/vendor/**/target
|
/vendor/**/target
|
||||||
@ -91,5 +80,3 @@
|
|||||||
/**/node_modules
|
/**/node_modules
|
||||||
/**/.yarn/*
|
/**/.yarn/*
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
/.vscode
|
|
||||||
/.idea
|
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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`.
|
|
||||||
@ -1,64 +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.
|
|
||||||
|
|
||||||
Do **not** suggest commit messages or commit names anywhere in your plans or
|
|
||||||
responses — committing is the developer's responsibility.
|
|
||||||
|
|
||||||
Assume they are a skilled developer, but know almost nothing about our toolset
|
|
||||||
or problem domain. Assume they don't know good test design very well.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
* Analyze the codebase architecture and identify affected modules.
|
|
||||||
* Read `AGENTS.md` files (root and per-module) to understand structure and
|
|
||||||
conventions.
|
|
||||||
* Search code using `ripgrep` skill (`rg`) to trace dependencies, find patterns,
|
|
||||||
and understand existing implementations.
|
|
||||||
* Break down complex features or bugs into atomic, actionable steps.
|
|
||||||
* Propose solutions with clear rationale, trade-offs, and sequencing.
|
|
||||||
* Identify risks, edge cases, and testing considerations.
|
|
||||||
|
|
||||||
Save plans to: plans/YYYY-MM-DD-<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.
|
|
||||||
|
|
||||||
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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`
|
|
||||||
@ -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/
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
name: nrepl-eval
|
|
||||||
description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool.
|
|
||||||
---
|
|
||||||
|
|
||||||
# nREPL Eval
|
|
||||||
|
|
||||||
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
|
||||||
`tools/nrepl-eval.mjs` — a standalone CLI application.
|
|
||||||
|
|
||||||
Session state (defs, in-ns, etc.) persists across invocations via a stored
|
|
||||||
session ID, so you can build up state incrementally.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node tools/nrepl-eval.mjs [options] [<code>]
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool is also executable directly:
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs [options] [<code>]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
| Flag | Description | Default |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `-p, --port PORT` | nREPL server port | `6064` |
|
|
||||||
| `-H, --host HOST` | nREPL server host | `127.0.0.1` |
|
|
||||||
| `-t, --timeout MS` | Timeout in milliseconds | `120000` |
|
|
||||||
| `--reset-session` | Discard stored session and start fresh | — |
|
|
||||||
| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — |
|
|
||||||
| `-h, --help` | Show help message | — |
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
Use this tool when you need to:
|
|
||||||
|
|
||||||
1. **Evaluate Clojure code** during development — test functions, inspect
|
|
||||||
state, or run experiments against a running Clojure process.
|
|
||||||
2. **Verify that edited files compile** — require namespaces with `:reload`
|
|
||||||
to pick up changes.
|
|
||||||
3. **Inspect the last exception** after a failed evaluation — use `-e` to
|
|
||||||
print the error stored in `*e`.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
### 1. Session management
|
|
||||||
|
|
||||||
Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
|
|
||||||
carries across calls automatically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs '(def x 42)'
|
|
||||||
./tools/nrepl-eval.mjs 'x'
|
|
||||||
# => 42
|
|
||||||
```
|
|
||||||
|
|
||||||
Reset the session to start fresh:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Evaluate code
|
|
||||||
|
|
||||||
**Single expression (inline) — uses default port 6064:**
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs '(+ 1 2 3)'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs <<'EOF'
|
|
||||||
(def x 10)
|
|
||||||
(+ x 20)
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
**Override with a different port:**
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Inspect last exception
|
|
||||||
|
|
||||||
After code throws an error, retrieve the full exception details:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs -e
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
**Require a namespace with reload:**
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test a function:**
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Long-running operation with custom timeout:**
|
|
||||||
```bash
|
|
||||||
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Principles
|
|
||||||
|
|
||||||
- **Default port is 6064** — just pass code directly, no `-p` needed when
|
|
||||||
your nREPL server is on 6064. Use `-p <PORT>` for a different port.
|
|
||||||
- **Always use `:reload`** when requiring namespaces to pick up file changes.
|
|
||||||
- **Session is reused** across invocations — defs, in-ns, and var bindings
|
|
||||||
persist. Use `--reset-session` to clear.
|
|
||||||
- **Do not start any server** — the tool connects to an existing nREPL
|
|
||||||
server, it is not the agent's responsibility to start the nREPL server
|
|
||||||
(assume the server is already running on the specified port).
|
|
||||||
@ -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
|
|
||||||
2
.serena/.gitignore
vendored
2
.serena/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
/cache
|
|
||||||
/project.local.yml
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
# Creating Commits
|
|
||||||
|
|
||||||
## Message Format
|
|
||||||
|
|
||||||
```
|
|
||||||
:emoji: Subject line (imperative, capitalized, no period, ≤70 chars)
|
|
||||||
|
|
||||||
Body (clear, concise description)
|
|
||||||
|
|
||||||
Co-authored-by: <You (the LLM)>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commit Type Emojis
|
|
||||||
|
|
||||||
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
|
|
||||||
|
|
||||||
## Changelog (CHANGES.md)
|
|
||||||
|
|
||||||
Update `CHANGES.md` for user-facing or notable changes. Add entry under the current unreleased version in the matching section (`### :boom:`, `### :sparkles:`, `### :bug:`, etc.).
|
|
||||||
|
|
||||||
Entry format:
|
|
||||||
```
|
|
||||||
- Description of change [Taiga #NNNN](https://tree.taiga.io/project/penpot/us/NNNN)
|
|
||||||
```
|
|
||||||
or for GitHub issues/PRs:
|
|
||||||
```
|
|
||||||
- Description of change [Github #NNNN](https://github.com/penpot/penpot/issues/NNNN)
|
|
||||||
```
|
|
||||||
|
|
||||||
Changes that affect the JavaScript plugin API must additionally be documented in `plugins/CHANGELOG.md`:
|
|
||||||
* Add an entry at the top of the file (unreleased section)
|
|
||||||
* Prefix entries that change the types/signatures in the API with `**plugin-types:**` and changes affecting the runtime with `**plugin-runtime:**`.
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# Creating Pull Requests
|
|
||||||
|
|
||||||
Important: Before creating a PR, ensure that you are on a branch that is specific to the
|
|
||||||
issue or feature you are working on. If necessary, create a new branch.
|
|
||||||
|
|
||||||
## Title Format
|
|
||||||
|
|
||||||
PR titles follow the same convention as commit titles:
|
|
||||||
|
|
||||||
```
|
|
||||||
:emoji: Subject line (imperative, capitalized, no period, ≤70 chars)
|
|
||||||
```
|
|
||||||
|
|
||||||
See the `creating-commits` memory for the list of emoji codes.
|
|
||||||
|
|
||||||
## Description Format
|
|
||||||
|
|
||||||
The PR description must start with the following notice:
|
|
||||||
|
|
||||||
> **Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
|
|
||||||
|
|
||||||
**Related Issues** section with a bullet list of linked issues:
|
|
||||||
|
|
||||||
```
|
|
||||||
In addition to sections summarising and explaining the changes in the PR, it should contain a section 'Relevant Issues' with a bullet list:
|
|
||||||
|
|
||||||
- Fixes #NNNN
|
|
||||||
- Resolves #NNNN
|
|
||||||
- Relates to #NNNN
|
|
||||||
```
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
You are working on the GitHub project penpot/penpot.
|
|
||||||
|
|
||||||
# Working with Penpot Designs via the JavaScript API
|
|
||||||
|
|
||||||
Before working with Penpot designs, call the `high_level_overview` tool of the Penpot MCP server.
|
|
||||||
It explains the API, which you can use to automate tasks via the `execute_code` tool.
|
|
||||||
|
|
||||||
# Dev Workflow
|
|
||||||
|
|
||||||
Memories:
|
|
||||||
- before creating a commit, read `creating-commits`.
|
|
||||||
- before creating a PR, read `creating-prs`.
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
|
|
||||||
Read the file `frontend/AGENTS.md` for an overview.
|
|
||||||
Memories:
|
|
||||||
- connection between the JavaScript API and the ClojureScript code: `frontend/js-api-to-cljs-binding`.
|
|
||||||
- executing ClojureScript code in the frontend: `frontend/cljs-repl`.
|
|
||||||
- programmatically navigating to a file in the workspace: `frontend/navigation`.
|
|
||||||
- handling Clojure compiler errors, runtime patching and debug helpers: `frontend/handling-errors-and-debugging`.
|
|
||||||
|
|
||||||
## Detecting Crashes
|
|
||||||
|
|
||||||
The Penpot frontend can crash silently from the JS API's perspective: `execute_code` calls return successfully, but 1-2s later the workspace becomes unusable (Internal Error page).
|
|
||||||
The `execute_code` tool then stops working, but `cljs_repl` still works. Use it to detect a crash via `(some? (:exception @app.main.store/state))`.
|
|
||||||
For details on handling crashes, read memory `frontend/handling-crashes`.
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
# ClojureScript REPL Access via shadow-cljs
|
|
||||||
|
|
||||||
Execute code in the REPL via the Penpot MCP's `cljs_repl` tool.
|
|
||||||
|
|
||||||
## Accessing App State
|
|
||||||
|
|
||||||
The main store is `app.main.store/state`. It contains workspace metadata, selection, UI state, etc.
|
|
||||||
However, **page objects are NOT in the main store atom**. They live behind derived refs.
|
|
||||||
|
|
||||||
### Top-level store keys (subset)
|
|
||||||
`:current-page-id`, `:current-file-id`, `:workspace-local`, `:workspace-global`,
|
|
||||||
`:workspace-trimmed-page`, `:workspace-undo`, `:workspace-guides`, `:workspace-layout`,
|
|
||||||
`:workspace-presence`, `:workspace-ready`, `:profile`, `:route`, etc.
|
|
||||||
|
|
||||||
**Notable absence:** There is no `:workspace-data` key in the store. The old path
|
|
||||||
`(get-in state [:workspace-data :pages-index page-id :objects])` does NOT work.
|
|
||||||
|
|
||||||
### Getting page objects — use `app.main.refs/workspace-page-objects`
|
|
||||||
```clojure
|
|
||||||
;; This is a derived ref (reactive lens). Deref it directly:
|
|
||||||
(let [objects @app.main.refs/workspace-page-objects
|
|
||||||
shape (get objects (parse-uuid "some-uuid-here"))]
|
|
||||||
(select-keys shape [:name :type :x :y :width :height :fills :strokes :rotation :opacity :frame-id :parent-id]))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Getting the current selection
|
|
||||||
```clojure
|
|
||||||
;; Selection is in the main store under :workspace-local :selected
|
|
||||||
(let [state @app.main.store/state
|
|
||||||
selected (get-in state [:workspace-local :selected])]
|
|
||||||
(mapv str selected))
|
|
||||||
;; Returns vector of UUID strings for selected shapes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Other useful store access
|
|
||||||
```clojure
|
|
||||||
;; Current page id
|
|
||||||
(:current-page-id @app.main.store/state)
|
|
||||||
|
|
||||||
;; Verify state is accessible
|
|
||||||
(some? @app.main.store/state) ;; should be true
|
|
||||||
|
|
||||||
;; workspace-local keys: :zoom :selected :hide-toolbar :last-selected :vbox
|
|
||||||
;; :highlighted :vport :expanded :selrect :zoom-inverse
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shape data structure (internal ClojureScript representation)
|
|
||||||
Shape keys use kebab-case keywords (`:fill-color`, `:fill-opacity`, `:parent-id`, `:frame-id`).
|
|
||||||
The shape `:type` is a keyword like `:rect`, `:path`, `:text`, `:ellipse`, `:image`, `:bool`, `:svg-raw`, `:frame`, `:group`.
|
|
||||||
Note `:rect` in CLJS corresponds to "rectangle" in the JS Plugin API, and `:frame` corresponds to "board".
|
|
||||||
|
|
||||||
Component instance shapes additionally carry `:component-id` and `:component-file` directly, and `:component-root` flags the root of an instance. To navigate from a shape to its component, use `app.common.types.container/get-head-shape` (nearest head) or `get-instance-root` (outermost root) — these differ when instances are nested.
|
|
||||||
|
|
||||||
### Helper utilities (`app.plugins.utils`)
|
|
||||||
Despite living under `plugins/`, these are general-purpose lookup helpers usable from any CLJS:
|
|
||||||
- `locate-shape` — find a shape by file-id, page-id, id
|
|
||||||
- `locate-objects` — get the object tree for a page
|
|
||||||
- `locate-component` — resolve the component for a shape (walks to **outermost** instance root, not nearest head — beware when instances are nested)
|
|
||||||
- `locate-library-component` — direct lookup by file-id and component-id
|
|
||||||
- `locate-file` — look up a file by id from state
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- The `:main` build has multiple modules: shared, main, main-workspace, rasterizer, etc.
|
|
||||||
- `app.main.store/state` is a potok store (wrapping an okulary atom) created via `defonce`
|
|
||||||
- Use `timeout` to avoid hanging if the browser is disconnected
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
`cljs_repl` may not connect to the right runtime when several are attached (e.g. workspace tab + rasterizer). Verify with `(.-title js/document)` — it should show your file name, not "Penpot - Rasterizer".
|
|
||||||
|
|
||||||
To list runtimes or target one by client-id, use `npx shadow-cljs clj-eval` from `/home/penpot/penpot/frontend`. It talks to the shadow-cljs JVM process, so unlike `cljs_repl` it has access to `shadow.cljs.devtools.api`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '(shadow.cljs.devtools.api/repl-runtimes :main)\n' | timeout 10 npx shadow-cljs clj-eval --stdin
|
|
||||||
printf '(shadow.cljs.devtools.api/cljs-eval :main "<cljs-code>" {:client-id 5})\n' | timeout 10 npx shadow-cljs clj-eval --stdin
|
|
||||||
```
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# Handling Penpot Frontend Crashes
|
|
||||||
|
|
||||||
When the Penpot frontend crashes, it usually shows the **Internal Error** page (title text "Something bad happened", class `main_ui_static__download-link`).
|
|
||||||
|
|
||||||
A typical error pattern is: Changes go through (JS API, `execute_code`), but about 1-2s later, an `update-file` request hits the backend with the change and gets rejected.
|
|
||||||
So be sure to check the status for a crash.
|
|
||||||
|
|
||||||
After a crash, `execute_code` is unusable (no instances connected), and any data in `storage` is lost, but `cljs_repl` keeps working!
|
|
||||||
|
|
||||||
## 1. Detect the crash
|
|
||||||
|
|
||||||
cljs REPL `(some? (:exception @app.main.store/state))` returns `true` when the Internal Error page is showing,
|
|
||||||
`false` on a healthy workspace (and after a successful reload).
|
|
||||||
|
|
||||||
## 2. Read the cause
|
|
||||||
|
|
||||||
The exception is stored at `(:exception @app.main.store/state)`. Useful keys:
|
|
||||||
|
|
||||||
- `:type`, `:code`, `:status` — error class (e.g. `:validation` / `:referential-integrity` / `400`)
|
|
||||||
- `:hint`, `:details` — human-readable explanation; `:details` typically contains a vector of validation problems with `:shape-id`, `:page-id`, `:args`, etc.
|
|
||||||
- `:uri` — the API endpoint that returned the error (e.g. `update-file`)
|
|
||||||
- `:app.main.errors/instance` — the underlying JS Error object
|
|
||||||
- `:app.main.errors/trace` — JS stack trace string (only shows the response-handling path, not the dispatch site that produced the bad change)
|
|
||||||
|
|
||||||
```
|
|
||||||
(let [ex (:exception @app.main.store/state)]
|
|
||||||
(select-keys ex [:type :code :status :hint :details :uri]))
|
|
||||||
```
|
|
||||||
|
|
||||||
For backend validation errors (`:type :validation`), `:details` is the most informative field — it tells you exactly which shape and which invariant was violated.
|
|
||||||
|
|
||||||
## 3. Recover and continue testing
|
|
||||||
|
|
||||||
Reload steps:
|
|
||||||
1. List tabs with `playwright:browser_tabs` (`action: list`) and find the Penpot workspace tab (URL contains `/#/workspace`, title ends in `- Penpot`).
|
|
||||||
2. If it isn't the current tab, select it via `playwright:browser_tabs` (`action: select`, `index: <n>`). The selected tab's URL then appears as "Page URL" in the result.
|
|
||||||
3. Reload by calling `playwright:browser_navigate` with that same URL.
|
|
||||||
4. Confirm recovery: `(some? (:exception @app.main.store/state))` should now return `false`.
|
|
||||||
|
|
||||||
Whether the offending change persists depends on the crash type:
|
|
||||||
For **backend-rejected changes** (e.g. `:type :validation`, 4xx on `update-file`), changes are NOT persisted. Reload restores the pre-crash state — safe to retry.
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
# Handling Errors and Debugging
|
|
||||||
|
|
||||||
## Finding source errors
|
|
||||||
|
|
||||||
You have access to two tools for finding errors in Clojure source code (which you may introduce yourself through edits):
|
|
||||||
|
|
||||||
1. cljs_compiler_output
|
|
||||||
2. clj_check_parentheses
|
|
||||||
|
|
||||||
The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second
|
|
||||||
tool can often find the exact location of such errors.
|
|
||||||
|
|
||||||
## Runtime patching with `set!`
|
|
||||||
|
|
||||||
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching.
|
|
||||||
From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as
|
|
||||||
`app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`,
|
|
||||||
`app.main.errors/last-report`, or `app.main.errors/last-exception`.
|
|
||||||
These patches affect only the live browser runtime and disappear on reload or recompilation.
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
;; Log non-noisy Potok events temporarily.
|
|
||||||
(set! app.main.store/on-event
|
|
||||||
(fn [event]
|
|
||||||
(when (potok.v2.core/event? event)
|
|
||||||
(.log js/console (potok.v2.core/repr-event event)))))
|
|
||||||
```
|
|
||||||
|
|
||||||
Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure;
|
|
||||||
it is not the normal way to patch live CLJS browser vars.
|
|
||||||
|
|
||||||
## Browser-console debug namespace
|
|
||||||
|
|
||||||
In development, the JS console exposes `debug` helpers from `frontend/src/debug.cljs`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
debug.set_logging("namespace", "debug");
|
|
||||||
debug.dump_state();
|
|
||||||
debug.dump_buffer();
|
|
||||||
debug.get_state(":workspace-local :selected");
|
|
||||||
debug.dump_objects();
|
|
||||||
debug.dump_object("Rect-1");
|
|
||||||
debug.dump_selected();
|
|
||||||
debug.dump_tree(true, true);
|
|
||||||
```
|
|
||||||
|
|
||||||
Visual workspace debug overlays can be toggled with `debug.toggle_debug("bounding-boxes")`, `"group"`, `"events"`, or `"rotation-handler"`; `debug.debug_all()` and `debug.debug_none()` toggle all visual aids.
|
|
||||||
|
|
||||||
For temporary source traces, prefer existing logging (`app.common.logging` / `app.util.logging`) or short-lived `prn`, `app.common.pprint/pprint`, `js/console.log`, or `js-debugger` calls. Remove temporary source instrumentation before committing.
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# How the Plugin JS API connects to ClojureScript
|
|
||||||
|
|
||||||
## Type Definitions
|
|
||||||
- `plugins/libs/plugin-types/index.d.ts` contains TypeScript type declarations (e.g. `ShapeBase`, `LibraryComponent`).
|
|
||||||
- These are **type-only** — no runtime code. The actual objects are constructed in ClojureScript.
|
|
||||||
|
|
||||||
## Runtime Shape Proxy
|
|
||||||
- `frontend/src/app/plugins/shape.cljs` builds the JS shape proxy via `obj/reify`.
|
|
||||||
- Each method/property from the TS interface (e.g. `:component`, `:isComponentRoot`, `:componentHead`) is defined as a keyword entry in the `obj/reify` form, with a ClojureScript function as the implementation.
|
|
||||||
- The proxy is created by the `shape-proxy` function, which takes `plugin-id`, `file-id`, `page-id`, and shape `id`, and closes over them.
|
|
||||||
|
|
||||||
## Library Proxies
|
|
||||||
- `frontend/src/app/plugins/library.cljs` defines proxies for library types like `LibraryComponentProxy` (via `lib-component-proxy`), also using `obj/reify`.
|
|
||||||
- The proxy satisfies the `LibraryComponent` TS interface, exposing `.id`, `.name`, `.path`, etc.
|
|
||||||
|
|
||||||
## Circular Dependency Resolution
|
|
||||||
- `shape.cljs` and `library.cljs` have circular dependencies (shapes reference library component proxies and vice versa).
|
|
||||||
- `shape.cljs` declares forward references as mutable `def nil` vars (e.g. `(def lib-component-proxy nil)`, line 144).
|
|
||||||
- `frontend/src/app/plugins.cljs` patches them at load time: `(set! shape/lib-component-proxy library/lib-component-proxy)`.
|
|
||||||
- Same pattern for `lib-typography-proxy?` and `variant-proxy`.
|
|
||||||
|
|
||||||
## Key Domain Namespaces
|
|
||||||
- `app.common.types.component` (aliased `ctk`) — component predicates: `instance-root?`, `instance-head?`, `in-component-copy?`, `is-variant?`
|
|
||||||
- `app.common.types.container` (aliased `ctn`) — container/tree operations: `in-any-component?`, `get-instance-root`, `get-head-shape`, `inside-component-main?`
|
|
||||||
- `app.common.types.file` (aliased `ctf`) — file-level operations: `resolve-component`, `get-ref-shape`
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# Navigating to a File in the Workspace
|
|
||||||
|
|
||||||
To programmatically open a file in the workspace, use `cljs_repl` with:
|
|
||||||
```clojure
|
|
||||||
(do (require '[app.main.data.common :as dcm])
|
|
||||||
(app.main.store/emit! (dcm/go-to-workspace
|
|
||||||
:team-id (parse-uuid "<team-id>")
|
|
||||||
:file-id (parse-uuid "<file-id>")
|
|
||||||
:page-id (parse-uuid "<page-id>"))))
|
|
||||||
```
|
|
||||||
**All three IDs are required.** You can get:
|
|
||||||
- `team-id` from `(:current-team-id @app.main.store/state)`
|
|
||||||
- `file-id` from the dashboard files: `(vals (:files @app.main.store/state))`
|
|
||||||
- `page-id` by fetching the file: `(get-in file-data [:data :pages])` via `(rp/cmd! :get-file {:id file-id :features (get @app.main.store/state :features)})`
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
# the name by which the project can be referenced within Serena
|
|
||||||
project_name: "penpot"
|
|
||||||
|
|
||||||
|
|
||||||
# list of languages for which language servers are started; choose from:
|
|
||||||
# al ansible bash clojure cpp
|
|
||||||
# cpp_ccls crystal csharp csharp_omnisharp dart
|
|
||||||
# elixir elm erlang fortran fsharp
|
|
||||||
# go groovy haskell haxe hlsl
|
|
||||||
# java json julia kotlin lean4
|
|
||||||
# lua luau markdown matlab msl
|
|
||||||
# nix ocaml pascal perl php
|
|
||||||
# php_phpactor powershell python python_jedi python_ty
|
|
||||||
# r rego ruby ruby_solargraph rust
|
|
||||||
# scala solidity swift systemverilog terraform
|
|
||||||
# toml typescript typescript_vts vue yaml
|
|
||||||
# zig
|
|
||||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
|
||||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
|
||||||
# Note:
|
|
||||||
# - For C, use cpp
|
|
||||||
# - For JavaScript, use typescript
|
|
||||||
# - For Free Pascal/Lazarus, use pascal
|
|
||||||
# Special requirements:
|
|
||||||
# Some languages require additional setup/installations.
|
|
||||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
|
||||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
|
||||||
# The first language is the default language and the respective language server will be used as a fallback.
|
|
||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
||||||
languages:
|
|
||||||
- clojure
|
|
||||||
- typescript
|
|
||||||
- rust
|
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
||||||
encoding: "utf-8"
|
|
||||||
|
|
||||||
# line ending convention to use when writing source files.
|
|
||||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
|
||||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
|
||||||
line_ending:
|
|
||||||
|
|
||||||
# The language backend to use for this project.
|
|
||||||
# If not set, the global setting from serena_config.yml is used.
|
|
||||||
# Valid values: LSP, JetBrains
|
|
||||||
# Note: the backend is fixed at startup. If a project with a different backend
|
|
||||||
# is activated post-init, an error will be returned.
|
|
||||||
language_backend:
|
|
||||||
|
|
||||||
# whether to use project's .gitignore files to ignore files
|
|
||||||
ignore_all_files_in_gitignore: true
|
|
||||||
|
|
||||||
# advanced configuration option allowing to configure language server-specific options.
|
|
||||||
# Maps the language key to the options.
|
|
||||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
|
||||||
# No documentation on options means no options are available.
|
|
||||||
ls_specific_settings: {}
|
|
||||||
|
|
||||||
# list of additional paths to ignore in this project.
|
|
||||||
# Same syntax as gitignore, so you can use * and **.
|
|
||||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
|
||||||
ignored_paths: []
|
|
||||||
|
|
||||||
# whether the project is in read-only mode
|
|
||||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
||||||
# Added on 2025-04-18
|
|
||||||
read_only: false
|
|
||||||
|
|
||||||
# list of tool names to exclude.
|
|
||||||
# This extends the existing exclusions (e.g. from the global configuration)
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
excluded_tools: []
|
|
||||||
|
|
||||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
|
||||||
# This extends the existing inclusions (e.g. from the global configuration).
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
included_optional_tools: []
|
|
||||||
|
|
||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
fixed_tools: []
|
|
||||||
|
|
||||||
# list of mode names to that are always to be included in the set of active modes
|
|
||||||
# The full set of modes to be activated is base_modes + default_modes.
|
|
||||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
|
||||||
# Otherwise, this setting overrides the global configuration.
|
|
||||||
# Set this to [] to disable base modes for this project.
|
|
||||||
# Set this to a list of mode names to always include the respective modes for this project.
|
|
||||||
base_modes:
|
|
||||||
|
|
||||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
|
||||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
|
||||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
|
||||||
# for this project.
|
|
||||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
default_modes:
|
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
||||||
# (contrary to the memories, which are loaded on demand).
|
|
||||||
initial_prompt: |
|
|
||||||
CRITICAL: Always read the memory `critical-info` before you do anything else.
|
|
||||||
|
|
||||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
|
||||||
# such as docstrings or parameter information.
|
|
||||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
|
||||||
# If null or missing, use the setting from the global configuration.
|
|
||||||
symbol_info_budget:
|
|
||||||
|
|
||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
read_only_memory_patterns: []
|
|
||||||
|
|
||||||
# list of regex patterns for memories to completely ignore.
|
|
||||||
# Matching memories will not appear in list_memories or activate_project output
|
|
||||||
# and cannot be accessed via read_memory or write_memory.
|
|
||||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
# Example: ["_archive/.*", "_episodes/.*"]
|
|
||||||
ignored_memory_patterns: []
|
|
||||||
|
|
||||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
added_modes:
|
|
||||||
|
|
||||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
|
||||||
# Paths can be absolute or relative to the project root.
|
|
||||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
|
||||||
# symbols and references across package boundaries.
|
|
||||||
# Currently supported for: TypeScript.
|
|
||||||
# Example:
|
|
||||||
# additional_workspace_folders:
|
|
||||||
# - ../sibling-package
|
|
||||||
# - ../shared-lib
|
|
||||||
additional_workspace_folders: []
|
|
||||||
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"files.exclude": {
|
||||||
|
"**/.clj-kondo": true,
|
||||||
|
"**/.cpcache": true,
|
||||||
|
"**/.lsp": true,
|
||||||
|
"**/.shadow-cljs": true,
|
||||||
|
"**/node_modules": true
|
||||||
|
}
|
||||||
|
}
|
||||||
572
AGENTS.md
572
AGENTS.md
@ -1,104 +1,530 @@
|
|||||||
# AI Agent Guide
|
# IA Agent Guide for Penpot
|
||||||
|
|
||||||
This document provides the core context and operating guidelines for AI agents
|
This document provides comprehensive context and guidelines for AI agents working on this repository.
|
||||||
working in this repository.
|
|
||||||
|
|
||||||
## Before You Start
|
|
||||||
|
|
||||||
Before responding to any user request, you must:
|
## STOP - DO NOT PROCEED WITHOUT COMPLETING THESE STEPS
|
||||||
|
|
||||||
1. Read this file completely.
|
Before responding to ANY user request, you MUST:
|
||||||
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
|
1. **READ** the CONTRIBUTING.md file
|
||||||
|
2. **READ** this file and has special focus on your ROLE.
|
||||||
|
|
||||||
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
|
## ROLE: SENIOR SOFTWARE ENGINEER
|
||||||
|
|
||||||
1. Before writing code, describe your plan. If the task is complex, break it
|
You are a high-autonomy Senior Software Engineer. You have full
|
||||||
down into atomic steps.
|
permission to navigate the codebase, modify files, and execute
|
||||||
2. Be concise and autonomous.
|
commands to fulfill your tasks. Your goal is to solve complex
|
||||||
3. Do **not** touch unrelated modules unless the task explicitly requires it.
|
technical tasks with high precision, focusing on maintainability and
|
||||||
4. Commit only when explicitly asked. Follow the commit format rules in
|
performance.
|
||||||
`CONTRIBUTING.md`.
|
|
||||||
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
|
||||||
`.gitignore` by default.
|
|
||||||
|
|
||||||
## Changelogs
|
### OPERATIONAL GUIDELINES
|
||||||
|
|
||||||
The project has two changelogs:
|
1. Always begin by analyzing this document and understand the architecture and "Golden Rules".
|
||||||
|
2. Before writing code, describe your plan. If the task is complex, break it down into atomic steps.
|
||||||
|
3. Be concise and autonomous as possible in your task.
|
||||||
|
|
||||||
- **Main project changelog**: `CHANGES.md` (root of the repository). Tracks changes for the core Penpot application (backend, frontend, common, render-wasm, exporter, mcp).
|
### SEARCH STANDARDS
|
||||||
- **Plugins changelog**: `plugins/CHANGELOG.md`. Tracks changes for the plugins subproject only.
|
|
||||||
|
|
||||||
When making changes, add a changelog entry to the appropriate file under the
|
When searching code, always use `ripgrep` (rg) instead of grep if
|
||||||
`## <version> (Unreleased)` section in the correct category
|
available, as it respects `.gitignore` by default.
|
||||||
(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`).
|
|
||||||
|
|
||||||
## GitHub Operations
|
If using grep, try to exclude node_modules and .shadow-cljs directories
|
||||||
|
|
||||||
|
|
||||||
|
## ARCHITECTURE
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Penpot is a full-stack design tool composed of several distinct
|
||||||
|
components separated in modules and subdirectories:
|
||||||
|
|
||||||
|
| Component | Language | Role |
|
||||||
|
|-----------|----------|------|
|
||||||
|
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) |
|
||||||
|
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis |
|
||||||
|
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities |
|
||||||
|
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) |
|
||||||
|
| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia |
|
||||||
|
| `mcp/` | TypeScript | Model Context Protocol integration |
|
||||||
|
| `plugins/` | TypeScript | Plugin runtime and example plugins |
|
||||||
|
|
||||||
|
The monorepo is managed with `pnpm` workspaces. The `manage.sh`
|
||||||
|
orchestrates cross-component builds. `run-ci.sh` defines the CI
|
||||||
|
pipeline.
|
||||||
|
|
||||||
|
### Namespace Structure
|
||||||
|
|
||||||
|
The backend, frontend and exporter are developed using clojure and
|
||||||
|
clojurescript and code is organized in namespaces. This is a general
|
||||||
|
overview of the available namespaces.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `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) (do not be confused with `app.common.loggin`)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `app.main.ui.*` – React UI components (`workspace`, `dashboard`, `viewer`)
|
||||||
|
- `app.main.data.*` – Potok event handlers (state mutations + side effects)
|
||||||
|
- `app.main.refs` – Reactive subscriptions (okulary lenses)
|
||||||
|
- `app.main.store` – Potok event store
|
||||||
|
- `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts)
|
||||||
|
|
||||||
|
**Common:**
|
||||||
|
- `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas
|
||||||
|
- `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli
|
||||||
|
- `app.common.geom.*` – Geometry and shape transformation helpers
|
||||||
|
- `app.common.data` – Generic helpers used around all application
|
||||||
|
- `app.common.math` – Generic math helpers used around all aplication
|
||||||
|
- `app.common.json` – Generic JSON encoding/decoding helpers
|
||||||
|
- `app.common.data.macros` – Performance macros used everywhere
|
||||||
|
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### Backend RPC
|
||||||
|
|
||||||
|
The PRC methods are implement in a some kind of multimethod structure using
|
||||||
|
`app.util.serivices` namespace. All RPC methods are collected under `app.rpc`
|
||||||
|
namespace and exposed under `/api/rpc/command/<cmd-name>`. The RPC method
|
||||||
|
accepts POST and GET requests indistinctly and uses `Accept` header for
|
||||||
|
negotiate the response encoding (which can be transit, the defaut or plain
|
||||||
|
json). It also accepts transit (defaut) or json as input, which should be
|
||||||
|
indicated using `Content-Type` header.
|
||||||
|
|
||||||
|
This is an example:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
|
||||||
|
### Frontend State Management (Potok)
|
||||||
|
|
||||||
|
State is a single atom managed by a Potok store. Events implement protocols
|
||||||
|
(funcool/potok library):
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn my-event
|
||||||
|
"doc string"
|
||||||
|
[data]
|
||||||
|
(ptk/reify ::my-event
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state] ;; synchronous state transition
|
||||||
|
(assoc state :key data))
|
||||||
|
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state stream] ;; async: returns an observable
|
||||||
|
(->> (rp/cmd! :some-rpc-command params)
|
||||||
|
(rx/map success-event)
|
||||||
|
(rx/catch error-handler)))
|
||||||
|
|
||||||
|
ptk/EffectEvent
|
||||||
|
(effect [_ state _] ;; pure side effects (DOM, logging)
|
||||||
|
(dom/focus (dom/get-element "id")))))
|
||||||
|
```
|
||||||
|
|
||||||
|
The state is located under `app.main.store` namespace where we have
|
||||||
|
the `emit!` function responsible of emiting events.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```cljs
|
||||||
|
(ns some.ns
|
||||||
|
(:require
|
||||||
|
[app.main.data.my-events :refer [my-event]]
|
||||||
|
[app.main.store :as st]))
|
||||||
|
|
||||||
|
(defn on-click
|
||||||
|
[event]
|
||||||
|
(st/emit! (my-event)))
|
||||||
|
```
|
||||||
|
|
||||||
|
On `app.main.refs` we have reactive references which lookup into the main state
|
||||||
|
for just inner data or precalculated data. That references are very usefull but
|
||||||
|
should be used with care because, per example if we have complex operation, this
|
||||||
|
operation will be executed on each state change, and sometimes is better to have
|
||||||
|
simple references and use react `use-memo` for more granular memoization.
|
||||||
|
|
||||||
|
Prefer helpers from `app.util.dom` instead of using direct dom calls, if no helper is
|
||||||
|
available, prefer adding a new helper for handling it and the use the
|
||||||
|
new helper.
|
||||||
|
|
||||||
|
|
||||||
|
### Integration Tests (Playwright)
|
||||||
|
|
||||||
|
Integration tests are developed under `frontend/playwright` directory, we use
|
||||||
|
mocks for remove communication with backend.
|
||||||
|
|
||||||
|
The tests should be executed under `./frontend` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend/
|
||||||
|
|
||||||
|
pnpm run test:e2e # Playwright e2e tests
|
||||||
|
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure everything installed with `./scripts/setup` script.
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Macros (`app.common.data.macros`)
|
||||||
|
|
||||||
|
Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys
|
||||||
|
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
|
||||||
|
(dm/str "a" "b" "c") ;; string concatenation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Code
|
||||||
|
|
||||||
|
Files in `common/src/app/common/` use reader conditionals to target both runtimes:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
#?(:clj (import java.util.UUID)
|
||||||
|
:cljs (:require [cljs.core :as core]))
|
||||||
|
```
|
||||||
|
|
||||||
|
Both frontend and backend depend on `common` as a local library (`penpot/common
|
||||||
|
{:local/root "../common"}`).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### UI Component Standards & Syntax (React & Rumext: mf/defc)
|
||||||
|
|
||||||
|
The codebase contains various component patterns. When creating or refactoring
|
||||||
|
components, follow the Modern Syntax rules outlined below.
|
||||||
|
|
||||||
|
1. The * Suffix Convention
|
||||||
|
|
||||||
|
The most recent syntax uses a * suffix in the component name (e.g.,
|
||||||
|
my-component*). This suffix signals the mf/defc macro to apply specific rules
|
||||||
|
for props handling and destructuring and optimization.
|
||||||
|
|
||||||
|
2. Component Definition
|
||||||
|
|
||||||
|
Modern components should use the following structure:
|
||||||
|
|
||||||
|
```clj
|
||||||
|
(mf/defc my-component*
|
||||||
|
{::mf/wrap [mf/memo]} ;; Equivalent to React.memo
|
||||||
|
[{:keys [name on-click]}] ;; Destructured props
|
||||||
|
[:div {:class (stl/css :root)
|
||||||
|
:on-click on-click}
|
||||||
|
name])
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Hooks
|
||||||
|
|
||||||
|
Use the mf namespace for hooks to maintain consistency with the macro's
|
||||||
|
lifecycle management. These are analogous to standard React hooks:
|
||||||
|
|
||||||
|
```clj
|
||||||
|
(mf/use-state) ;; analogous to React.useState adapted to cljs semantics
|
||||||
|
(mf/use-effect) ;; analogous to React.useEffect
|
||||||
|
(mf/use-memo) ;; analogous to React.useMemo
|
||||||
|
(mf/use-fn) ;; analogous to React.useCallback
|
||||||
|
```
|
||||||
|
|
||||||
|
The `mf/use-state` in difference with React.useState, returns an atom-like
|
||||||
|
object, where you can use `swap!` or `reset!` for to perform an update and
|
||||||
|
`deref` for get the current value.
|
||||||
|
|
||||||
|
You also has `mf/deref` hook (which does not follow the `use-` naming pattern)
|
||||||
|
and it's purpose is watch (subscribe to changes) on atom or derived atom (from
|
||||||
|
okulary) and get the current value. Is mainly used for subscribe to lenses
|
||||||
|
defined in `app.main.refs` or (private lenses defined in namespaces).
|
||||||
|
|
||||||
|
Rumext also comes with improved syntax macros as alternative to `mf/use-effect`
|
||||||
|
and `mf/use-memo` functions. Examples:
|
||||||
|
|
||||||
|
|
||||||
|
Example for `mf/with-memo` macro:
|
||||||
|
|
||||||
|
```clj
|
||||||
|
;; Using functions
|
||||||
|
(mf/use-effect
|
||||||
|
(mf/deps team-id)
|
||||||
|
(fn []
|
||||||
|
(st/emit! (dd/initialize team-id))
|
||||||
|
(fn []
|
||||||
|
(st/emit! (dd/finalize team-id)))))
|
||||||
|
|
||||||
|
;; The same effect but using mf/with-effect
|
||||||
|
(mf/with-effect [team-id]
|
||||||
|
(st/emit! (dd/initialize team-id))
|
||||||
|
(fn []
|
||||||
|
(st/emit! (dd/finalize team-id))))
|
||||||
|
```
|
||||||
|
|
||||||
|
Example for `mf/with-memo` macro:
|
||||||
|
|
||||||
|
```
|
||||||
|
;; Using functions
|
||||||
|
(mf/use-memo
|
||||||
|
(mf/deps projects team-id)
|
||||||
|
(fn []
|
||||||
|
(->> (vals projects)
|
||||||
|
(filterv #(= team-id (:team-id %))))))
|
||||||
|
|
||||||
|
;; Using the macro
|
||||||
|
(mf/with-memo [projects team-id]
|
||||||
|
(->> (vals projects)
|
||||||
|
(filterv #(= team-id (:team-id %)))))
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer using the macros for it syntax simplicity.
|
||||||
|
|
||||||
|
|
||||||
|
4. Component Usage (Hiccup Syntax)
|
||||||
|
|
||||||
|
When invoking a component within Hiccup, always use the [:> component* props]
|
||||||
|
pattern.
|
||||||
|
|
||||||
|
Requirements for props:
|
||||||
|
|
||||||
|
- Must be a map literal or a symbol pointing to a JavaScript props object.
|
||||||
|
- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```clj
|
||||||
|
;; Using object literal (no need of #js because macro already interprets it)
|
||||||
|
[:> my-component* {:data-foo "bar"}]
|
||||||
|
|
||||||
|
;; Using object literal (no need of #js because macro already interprets it)
|
||||||
|
(let [props #js {:data-foo "bar"
|
||||||
|
:className "myclass"}]
|
||||||
|
[:> my-component* props])
|
||||||
|
|
||||||
|
;; Using the spread helper
|
||||||
|
(let [props (mf/spread-object base-props {:extra "data"})]
|
||||||
|
[:> my-component* props])
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Checklist
|
||||||
|
|
||||||
|
- [ ] Does the component name end with *?
|
||||||
|
|
||||||
|
|
||||||
|
### Build, Test & Lint commands
|
||||||
|
|
||||||
|
#### Frontend (`cd frontend`)
|
||||||
|
|
||||||
|
Run `./scripts/setup` for setup all dependencies.
|
||||||
|
|
||||||
To obtain the list of repository members/collaborators:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login'
|
# Build (Producution)
|
||||||
|
./scripts/build
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
pnpm run test # Build ClojureScript tests + run node target/tests/test.js
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
pnpm run lint:js # Linter for JS/TS
|
||||||
|
pnpm run lint:clj # Linter for CLJ/CLJS/CLJC
|
||||||
|
pnpm run lint:scss # Linter for SCSS
|
||||||
|
|
||||||
|
# Check Code Formart
|
||||||
|
pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC
|
||||||
|
pnpm run check-fmt:js # Format JS/TS
|
||||||
|
pnpm run check-fmt:scss # Format SCSS
|
||||||
|
|
||||||
|
# Code Format (Automatic Formating)
|
||||||
|
pnpm run fmt:clj # Format CLJ/CLJS/CLJC
|
||||||
|
pnpm run fmt:js # Format JS/TS
|
||||||
|
pnpm run fmt:scss # Format SCSS
|
||||||
```
|
```
|
||||||
|
|
||||||
To obtain the list of open PRs authored by members:
|
To run a focused ClojureScript unit test: edit
|
||||||
|
`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm
|
||||||
|
run build:test && node target/tests/test.js`.
|
||||||
|
|
||||||
|
|
||||||
|
#### Backend (`cd backend`)
|
||||||
|
|
||||||
|
Run `pnpm install` for install all dependencies.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
# Run full test suite
|
||||||
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
pnpm run test
|
||||||
($members | split("|")) as $m |
|
|
||||||
.[] | select(.author.login as $a | $m | index($a)) |
|
# Run single namespace
|
||||||
"\(.number)\t\(.author.login)\t\(.title)"
|
pnpm run test --focus backend-tests.rpc-doc-test
|
||||||
'
|
|
||||||
|
# Check Code Format
|
||||||
|
pnpm run check-fmt
|
||||||
|
|
||||||
|
# Code Format (Automatic Formatting)
|
||||||
|
pnpm run fmt
|
||||||
|
|
||||||
|
# Code Linter
|
||||||
|
pnpm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
To obtain the list of open PRs from external contributors (non-members):
|
Test config is in `backend/tests.edn`; test namespaces match
|
||||||
|
`.*-test$` under `test/` directory. You should not touch this file,
|
||||||
|
just use it for reference.
|
||||||
|
|
||||||
|
|
||||||
|
#### Common (`cd common`)
|
||||||
|
|
||||||
|
This contains code that should compile and run under different runtimes: JVM & JS so the commands are
|
||||||
|
separarated for each runtime.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
clojure -M:dev:test # Run full test suite under JVM
|
||||||
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM
|
||||||
($members | split("|")) as $m |
|
|
||||||
.[] | select(.author.login as $a | $m | index($a) | not) |
|
# Run full test suite under JS or JVM runtimes
|
||||||
"\(.number)\t\(.author.login)\t\(.title)"
|
pnpm run test:js
|
||||||
'
|
pnpm run test:jvm
|
||||||
|
|
||||||
|
# Run single namespace (only on JVM)
|
||||||
|
pnpm run test:jvm --focus common-tests.my-ns-test
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
pnpm run lint:clj # Lint CLJ/CLJS/CLJC code
|
||||||
|
|
||||||
|
# Check Format
|
||||||
|
pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code
|
||||||
|
pnpm run check-fmt:js # Check JS/TS code
|
||||||
|
|
||||||
|
# Code Format (Automatic Formatting)
|
||||||
|
pnpm run fmt:clj # Check CLJ/CLJS/CLJS code
|
||||||
|
pnpm run fmt:js # Check JS/TS code
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture Overview
|
To run a focused ClojureScript unit test: edit
|
||||||
|
`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm
|
||||||
|
run build:test && node target/tests/test.js`.
|
||||||
|
|
||||||
Penpot is an open-source design tool composed of several modules:
|
|
||||||
|
|
||||||
| Directory | Language | Purpose | Has `AGENTS.md` |
|
#### Render-WASM (`cd render-wasm`)
|
||||||
|-----------|----------|---------|:----------------:|
|
|
||||||
| `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
|
```bash
|
||||||
`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`.
|
./test # Rust unit tests (cargo test)
|
||||||
|
./build # Compile to WASM (requires Emscripten)
|
||||||
### Module Dependency Graph
|
cargo fmt --check
|
||||||
|
./lint --debug
|
||||||
```
|
|
||||||
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.
|
|
||||||
|
### Commit Format Guidelines
|
||||||
|
|
||||||
|
Format: `<emoji-code> <subject>`
|
||||||
|
|
||||||
|
```
|
||||||
|
:bug: Fix unexpected error on launching modal
|
||||||
|
|
||||||
|
Optional body explaining the why.
|
||||||
|
|
||||||
|
Signed-off-by: Fullname <email>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Subject rules:** imperative mood, capitalize first letter, no
|
||||||
|
trailing period, ≤ 80 characters. Add an entry to `CHANGES.md` if
|
||||||
|
applicable.
|
||||||
|
|
||||||
|
**Code patches must include a DCO sign-off** (`git commit -s`).
|
||||||
|
|
||||||
|
| Emoji | Emoji-Code | Use for |
|
||||||
|
|-------|------|---------|
|
||||||
|
| 🐛 | `:bug:` | Bug fix |
|
||||||
|
| ✨ | `:sparkles:` | Improvement |
|
||||||
|
| 🎉 | `:tada:` | New feature |
|
||||||
|
| ♻️ | `:recycle:` | Refactor |
|
||||||
|
| 💄 | `:lipstick:` | Cosmetic changes |
|
||||||
|
| 🚑 | `:ambulance:` | Critical bug fix |
|
||||||
|
| 📚 | `:books:` | Docs |
|
||||||
|
| 🚧 | `:construction:` | WIP |
|
||||||
|
| 💥 | `:boom:` | Breaking change |
|
||||||
|
| 🔧 | `:wrench:` | Config update |
|
||||||
|
| ⚡ | `:zap:` | Performance |
|
||||||
|
| 🐳 | `:whale:` | Docker |
|
||||||
|
| 📎 | `:paperclip:` | Other non-relevant changes |
|
||||||
|
| ⬆️ | `:arrow_up:` | Dependency upgrade |
|
||||||
|
| ⬇️ | `:arrow_down:` | Dependency downgrade |
|
||||||
|
| 🔥 | `:fire:` | Remove files or code |
|
||||||
|
| 🌐 | `:globe_with_meridians:` | Translations |
|
||||||
|
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
#### Usage convention for components
|
||||||
|
|
||||||
|
Styles are co-located with components. Each `.cljs` file has a corresponding
|
||||||
|
`.scss` file:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; In the component namespace:
|
||||||
|
(require '[app.main.style :as stl])
|
||||||
|
|
||||||
|
;; In the render function:
|
||||||
|
[:div {:class (stl/css :container :active)}]
|
||||||
|
|
||||||
|
;; Conditional:
|
||||||
|
[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}]
|
||||||
|
|
||||||
|
;; When you need concat an existing class:
|
||||||
|
[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Styles rules & migration
|
||||||
|
##### General
|
||||||
|
|
||||||
|
- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss
|
||||||
|
variables and get the already defined properties from `_sizes.scss`. The SCSS
|
||||||
|
variables are allowed and still used, just prefer properties if they are
|
||||||
|
already defined.
|
||||||
|
- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss"
|
||||||
|
as *; padding: px2rem(23);`.
|
||||||
|
- Do **not** create new SCSS variables for one-off values.
|
||||||
|
- Use physical directions with logical ones to support RTL/LTR naturally.
|
||||||
|
- ❌ `margin-left`, `padding-right`, `left`, `right`.
|
||||||
|
- ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`.
|
||||||
|
- Always use the `use-typography` mixin from `ds/typography.scss`.
|
||||||
|
- ✅ `@include t.use-typography("title-small");`
|
||||||
|
- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`.
|
||||||
|
- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or
|
||||||
|
legacy color variables.
|
||||||
|
- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like
|
||||||
|
`@include flexCenter;`. Write standard CSS (flex/grid) instead.
|
||||||
|
|
||||||
|
##### Syntax & Structure
|
||||||
|
|
||||||
|
- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file,
|
||||||
|
try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as
|
||||||
|
*;` (Use `as *` to expose variables directly).
|
||||||
|
- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors:
|
||||||
|
- ❌ `.card { .title { ... } }`
|
||||||
|
- ✅ `.card-title { ... }`
|
||||||
|
- Leverage component-level CSS variables for state changes (hover/focus) instead
|
||||||
|
of rewriting properties.
|
||||||
|
|
||||||
|
##### Checklist
|
||||||
|
|
||||||
|
- [ ] No references to `common/refactor/`
|
||||||
|
- [ ] All `@import` converted to `@use` (only if refactoring)
|
||||||
|
- [ ] Physical properties (left/right) using logical properties (inline-start/end).
|
||||||
|
- [ ] Typography implemented via `use-typography()` mixin.
|
||||||
|
- [ ] Hardcoded pixel values wrapped in `px2rem()`.
|
||||||
|
- [ ] Selectors are flat (no deep nesting).
|
||||||
|
|||||||
282
CHANGES.md
282
CHANGES.md
@ -1,276 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
## 2.17.0 (Unreleased)
|
## 2.14.0 (Unreleased)
|
||||||
|
|
||||||
### :boom: Breaking changes & Deprecations
|
|
||||||
|
|
||||||
### :rocket: Epics and highlights
|
|
||||||
|
|
||||||
### :sparkles: New features & Enhancements
|
|
||||||
|
|
||||||
- Show a read-only W × H size badge below the bounding box of the current selection (by @bittoby) [Github #9205](https://github.com/penpot/penpot/issues/9205)
|
|
||||||
- Expose `variants` retrieval on `LibraryComponent` via `isVariant()` type guard in plugin API [Github #9185](https://github.com/penpot/penpot/issues/9185)
|
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
|
||||||
|
|
||||||
- Fix plugin API `LibraryTypography.remove()` failing with a UUID assertion error [Github #8223](https://github.com/penpot/penpot/issues/8223)
|
|
||||||
- Fix MCP SSE sessions leaking memory on zombie connections by adding inactivity timeout parity with Streamable HTTP sessions (by @bitloi) [Github #9432](https://github.com/penpot/penpot/issues/9432)
|
|
||||||
- Fix missing `labels.open` translation (by @MilosM348) [Github #9320](https://github.com/penpot/penpot/pull/9320)
|
|
||||||
- Fix two plugin error i18n keys broken by leading whitespace before `msgid` in `en.po` (by @MilosM348)
|
|
||||||
- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers
|
|
||||||
- Expose Source Sans Pro semibold (weight 600) variants in the builtin fonts list, matching the bundled font assets and CSS @font-face declarations [Github #7378](https://github.com/penpot/penpot/issues/7378)
|
|
||||||
- Fix plugin API `shape.fills` and `shape.strokes` arrays being read-only [Github #8357](https://github.com/penpot/penpot/issues/8357)
|
|
||||||
- Fix `get-profile` masking transient DB errors as anonymous user (by @jack-stormentswe) [Github #9253](https://github.com/penpot/penpot/issues/9253)
|
|
||||||
- Fix `Ctrl+'` "Show guides" shortcut on non-US keyboard layouts by matching the physical key location (by @RenzoMXD) [Github #8423](https://github.com/penpot/penpot/issues/8423)
|
|
||||||
- Fix lost-update race on `team.features` during concurrent file creation (by @web-dev0521) [Github #9197](https://github.com/penpot/penpot/issues/9197)
|
|
||||||
- Fix copy and paste actions crashing the workspace on insecure origins (plain HTTP / non-`localhost`) where the Clipboard API is unavailable (by @MilosM348) [Github #6514](https://github.com/penpot/penpot/issues/6514)
|
|
||||||
- Fix blend-mode dropdown leaving the canvas rendered with the last hover-preview blend mode when dismissed without selecting an option; the WASM render is now reverted to the saved blend mode on pointer-leave (by @edwin-rivera-dev) [Github #XXXX](https://github.com/penpot/penpot/issues/XXXX)
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
- 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 relative timestamp and short identifier for each entry in the workspace Actions history sidebar (by @FairyPigDev) [Github #7660](https://github.com/penpot/penpot/issues/7660)
|
|
||||||
- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree in the Layers sidebar (by @MilosM348) [Github #9179](https://github.com/penpot/penpot/pull/9179)
|
|
||||||
- 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 (by @moorsecopers99) [Github #9024](https://github.com/penpot/penpot/pull/9024)
|
|
||||||
- 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 (by @RenzoMXD) [Github #8536](https://github.com/penpot/penpot/pull/8536)
|
|
||||||
- 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 SVG from external tools such as Inkscape (by @RenzoMXD) [Github #9182](https://github.com/penpot/penpot/pull/9182)
|
|
||||||
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts (by @RenzoMXD) [Github #9063](https://github.com/penpot/penpot/pull/9063)
|
|
||||||
- Add pixel grid color picker in viewport settings (by @Yakehira) [Github #7750](https://github.com/penpot/penpot/issues/7750)
|
|
||||||
- Add HEX, HSB and HSL support to the color picker with a model switcher that persists across sessions (by @edwin-rivera-dev) [Github #9133](https://github.com/penpot/penpot/issues/9133)
|
|
||||||
- Show specific invitation-link error messages for expired, email-mismatch and invalid token cases [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)
|
|
||||||
- Add new numeric inputs for token management on the right sidebar [Taiga #12109](https://tree.taiga.io/project/penpot/us/12109?milestone=513226)
|
|
||||||
- Restore deleted team files in bulk instead of per file (by @Dexterity104) [Github #9248](https://github.com/penpot/penpot/pull/9248)
|
|
||||||
- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [Github #9252](https://github.com/penpot/penpot/pull/9252)
|
|
||||||
|
|
||||||
### :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)
|
|
||||||
- Fix layers panel rename input showing the default type name instead of the saved layer name (by @jack-stormentswe) [Github #9231](https://github.com/penpot/penpot/pull/9231)
|
|
||||||
- Suppress browser context menu on right-click in workspace sidebars while preserving it on text inputs (by @sujyotraut) [Github #5127](https://github.com/penpot/penpot/issues/5127)
|
|
||||||
- Fix release notes modal appearing behind the dashboard sidebar (by @ciaokitty) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
|
||||||
- Fix plugin API `fileVersion.restore()` promise hanging indefinitely on restore failure (by @thomascolden585-svg) [Github #9092](https://github.com/penpot/penpot/issues/9092)
|
|
||||||
- Fix imported stroke-only SVG paths losing their rounded join when split into adjacent subpaths (by @Chrissi2812) [Github #5283](https://github.com/penpot/penpot/issues/5283)
|
|
||||||
- Fix plugin API `library.connectLibrary()` not returning a Promise when the plugin lacks `library:write` permission (by @boskodev790) [Github #9158](https://github.com/penpot/penpot/pull/9158)
|
|
||||||
- Fix LDAP provider schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration (by @boskodev790) [Github #9165](https://github.com/penpot/penpot/pull/9165)
|
|
||||||
- Fix `login-with-ldap` silently dropping the error message when LDAP is not initialized (typo `:hide` → `:hint`) (by @boskodev790) [Github #9159](https://github.com/penpot/penpot/pull/9159)
|
|
||||||
- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored in the OIDC callback (by @GeekClassy) [Github #9108](https://github.com/penpot/penpot/issues/9108)
|
|
||||||
- Fix crash in share-link viewer when a team member's email is missing `@` or has no domain TLD (by @boskodev790) [Github #9120](https://github.com/penpot/penpot/pull/9120)
|
|
||||||
- Fix crash when pasting a component with variants from an external shared library into a file that uses that library (by @FairyPigDev) [Github #8144](https://github.com/penpot/penpot/issues/8144)
|
|
||||||
- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled (by @TheAifam5) [Github #8877](https://github.com/penpot/penpot/issues/8877)
|
|
||||||
- Fix Copy as SVG to produce a valid document for multi-shape selections and use `image/svg+xml` MIME type (by @RenzoMXD) [Github #9066](https://github.com/penpot/penpot/pull/9066)
|
|
||||||
- Preserve OpenType variant name table for custom fonts in the dashboard (by @rutherfordcraze) [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 copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
|
|
||||||
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [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 "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` (by @axelseis) [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)
|
|
||||||
- Fix plugin API `ShapeBase.component()` returning the outermost component instead of the immediate component in case of nested component instances [Github #9183](https://github.com/penpot/penpot/issues/9183)
|
|
||||||
- Fix content attribute sync group resolution by shape type [Github #8724](https://github.com/penpot/penpot/pull/8724)
|
|
||||||
- Fix highlight on shape after rename [Github #8890](https://github.com/penpot/penpot/pull/8890)
|
|
||||||
- Fix plugin parse-point returning plain map instead of Point record (by @FairyPigDev) [Github #9129](https://github.com/penpot/penpot/pull/9129)
|
|
||||||
- Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [Github #9250](https://github.com/penpot/penpot/pull/9250)
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
## 2.15.0 (Unreleased)
|
|
||||||
|
|
||||||
### :sparkles: New features & Enhancements
|
|
||||||
|
|
||||||
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
|
|
||||||
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
|
|
||||||
- Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176)
|
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
|
||||||
|
|
||||||
- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238)
|
|
||||||
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
|
||||||
- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers
|
|
||||||
- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9435](https://github.com/penpot/penpot/pull/9435)
|
|
||||||
- Fix MCP "active in another tab" notification not clearing (by @Dexterity104) [Github #9321](https://github.com/penpot/penpot/pull/9321)
|
|
||||||
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322)
|
|
||||||
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9400] (https://github.com/penpot/penpot/pull/9400)
|
|
||||||
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
|
||||||
- Fix SSRF in media URL import and restrict unauthenticated asset access to public buckets only [Github #9390](https://github.com/penpot/penpot/pull/9390)
|
|
||||||
- Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346)
|
|
||||||
- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431)
|
|
||||||
- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056)
|
|
||||||
- Fix layer hierarchy to match old and new SCSS [Github #9126](https://github.com/penpot/penpot/pull/9126)
|
|
||||||
- Fix multiple selection on shapes with token applied to stroke color [Github #9110](https://github.com/penpot/penpot/pull/9110)
|
|
||||||
- Fix onboarding modals appearing behind libraries and templates panel [Github #9178](https://github.com/penpot/penpot/pull/9178)
|
|
||||||
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
|
||||||
- Fix maximum call stack size exceeded in SSE read-stream [Github #9470](https://github.com/penpot/penpot/issues/9470)
|
|
||||||
|
|
||||||
## 2.14.5
|
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
|
||||||
|
|
||||||
- Fix incorrect invitation token handling on register process [Github #9380](https://github.com/penpot/penpot/pull/9380)
|
|
||||||
|
|
||||||
## 2.14.4
|
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
|
||||||
|
|
||||||
- Fix email validation [Taiga #14006](https://tree.taiga.io/project/penpot/issue/14006)
|
|
||||||
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
|
|
||||||
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
|
|
||||||
|
|
||||||
|
|
||||||
## 2.14.3
|
|
||||||
|
|
||||||
### :sparkles: New features & Enhancements
|
|
||||||
|
|
||||||
- Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870)
|
|
||||||
- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957)
|
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
|
||||||
|
|
||||||
- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882)
|
|
||||||
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
|
|
||||||
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
|
|
||||||
- Fix highlight on frames after rename [Github #8938](https://github.com/penpot/penpot/pull/8938)
|
|
||||||
- Fix TypeError in sd-token-uuid when resolving tokens interactively [Github #8929](https://github.com/penpot/penpot/pull/8929)
|
|
||||||
- Fix path drawing preview passing shape instead of content to next-node
|
|
||||||
- Fix swapped arguments in CLJS PathData `-nth` with default
|
|
||||||
- Normalize PathData coordinates to safe integer bounds on read
|
|
||||||
- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962)
|
|
||||||
- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963)
|
|
||||||
- Fix "Move to" menu allowing same project as target when multiple files are selected
|
|
||||||
- Fix crash when index query param is duplicated in URL
|
|
||||||
- Fix wrong extremity point in path `calculate-extremities` for line-to segments
|
|
||||||
- Fix reversed args in DTCG shadow composite token conversion
|
|
||||||
- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?`
|
|
||||||
- Fix wrong `mapcat` call in `collect-main-shapes`
|
|
||||||
- Fix stale accumulator in `get-children-in-instance` recursion
|
|
||||||
- Fix typo `:podition` in swap-shapes grid cell
|
|
||||||
- Fix multiple selection on shapes with token applied to stroke color
|
|
||||||
|
|
||||||
|
|
||||||
## 2.14.2
|
|
||||||
|
|
||||||
### :sparkles: New features & Enhancements
|
|
||||||
|
|
||||||
- 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
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
@ -293,9 +23,11 @@
|
|||||||
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040)
|
- 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 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 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 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 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 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 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 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 viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
|
||||||
@ -356,8 +88,12 @@
|
|||||||
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
|
- 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 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 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 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)
|
- 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 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 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 several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
|
||||||
@ -450,8 +186,10 @@ example. It's still usable as before, we just removed the example.
|
|||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
- Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252)
|
- Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252)
|
||||||
|
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
|
||||||
- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985)
|
- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985)
|
||||||
- Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974)
|
- Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974)
|
||||||
|
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
|
||||||
- Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371)
|
- Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371)
|
||||||
- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437)
|
- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437)
|
||||||
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
|
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
|
||||||
@ -532,7 +270,7 @@ example. It's still usable as before, we just removed the example.
|
|||||||
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
|
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
|
||||||
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
|
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
|
||||||
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
|
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
|
||||||
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12385](https://tree.taiga.io/project/penpot/issue/12385)
|
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385)
|
||||||
- Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367)
|
- Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367)
|
||||||
- Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397)
|
- Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397)
|
||||||
- Fix remove flex button doesn’t work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314)
|
- Fix remove flex button doesn’t work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314)
|
||||||
|
|||||||
398
CONTRIBUTING.md
398
CONTRIBUTING.md
@ -1,305 +1,211 @@
|
|||||||
# Contributing Guide
|
# Contributing Guide #
|
||||||
|
|
||||||
Thank you for your interest in contributing to Penpot. This guide covers
|
Thank you for your interest in contributing to Penpot. This is a
|
||||||
how to propose changes, submit fixes, and follow project conventions.
|
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
|
## Reporting Bugs ##
|
||||||
instructions, see [AGENTS.md](AGENTS.md). For final user technical
|
|
||||||
documentation, see the `docs/` directory or the rendered [Help
|
|
||||||
Center](https://help.penpot.app/).
|
|
||||||
|
|
||||||
## 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)
|
If you found a bug, please report it, as far as possible, with:
|
||||||
- [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)
|
|
||||||
|
|
||||||
## 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
|
If you found a bug which you think is better to discuss in private (for
|
||||||
(frontend/exporter), and Rust (render-wasm). Familiarity with the Clojure
|
example, security bugs), consider first sending an email to
|
||||||
ecosystem is expected for most contributions.
|
`support@penpot.app`.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## 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.
|
If you want to propose a change or bug fix via a pull request (PR),
|
||||||
2. Browser and browser version used.
|
you should first carefully read the section **Developer's Certificate of
|
||||||
3. DevTools console exception stack trace (if available).
|
Origin**. You must also format your code and commits according to the
|
||||||
|
instructions below.
|
||||||
|
|
||||||
For security bugs or issues better discussed in private, email
|
If you intend to fix a bug, it's fine to submit a pull request right
|
||||||
`support@penpot.app` or report them on [Github Security
|
away, but we still recommend filing an issue detailing what you're
|
||||||
Advisories](https://github.com/penpot/penpot/security/advisories)
|
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
|
If you want to implement or start working on a new feature, please
|
||||||
> contributions are recognized in the changelog.
|
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)
|
## Commit Guidelines ##
|
||||||
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.
|
|
||||||
|
|
||||||
### 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>
|
<type> <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 `good first issue` label to mark issues appropriate for newcomers.
|
|
||||||
|
|
||||||
## Commit Guidelines
|
|
||||||
|
|
||||||
Commit messages must follow this format:
|
|
||||||
|
|
||||||
```
|
|
||||||
:emoji: <subject>
|
|
||||||
|
|
||||||
[body]
|
[body]
|
||||||
|
|
||||||
[footer]
|
[footer]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit types
|
Where type is:
|
||||||
|
|
||||||
| Emoji | Description |
|
- :bug: `:bug:` a commit that fixes a bug
|
||||||
| ---------------------- | -------------------------- |
|
- :sparkles: `:sparkles:` a commit that adds an improvement
|
||||||
| :bug: | Bug fix |
|
- :tada: `:tada:` a commit with a new feature
|
||||||
| :sparkles: | Improvement or enhancement |
|
- :recycle: `:recycle:` a commit that introduces a refactor
|
||||||
| :tada: | New feature |
|
- :lipstick: `:lipstick:` a commit with cosmetic changes
|
||||||
| :recycle: | Refactor |
|
- :ambulance: `:ambulance:` a commit that fixes a critical bug
|
||||||
| :lipstick: | Cosmetic changes |
|
- :books: `:books:` a commit that improves or adds documentation
|
||||||
| :ambulance: | Critical bug fix |
|
- :construction: `:construction:` a WIP commit
|
||||||
| :books: | Documentation |
|
- :boom: `:boom:` a commit with breaking changes
|
||||||
| :construction: | Work in progress |
|
- :wrench: `:wrench:` a commit for config updates
|
||||||
| :boom: | Breaking change |
|
- :zap: `:zap:` a commit with performance improvements
|
||||||
| :wrench: | Configuration update |
|
- :whale: `:whale:` a commit for Docker-related stuff
|
||||||
| :zap: | Performance improvement |
|
- :paperclip: `:paperclip:` a commit with other non-relevant changes
|
||||||
| :whale: | Docker-related change |
|
- :arrow_up: `:arrow_up:` a commit with dependency updates
|
||||||
| :paperclip: | Other non-relevant changes |
|
- :arrow_down: `:arrow_down:` a commit with dependency downgrades
|
||||||
| :arrow_up: | Dependency update |
|
- :fire: `:fire:` a commit that removes files or code
|
||||||
| :arrow_down: | Dependency downgrade |
|
- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates
|
||||||
| :fire: | Removal of code or files |
|
translations
|
||||||
| :globe_with_meridians: | Add or update translations |
|
|
||||||
| :rocket: | Epic or highlight |
|
|
||||||
|
|
||||||
### Rules
|
More info:
|
||||||
|
|
||||||
- Use the **imperative mood** in the subject (e.g. "Fix", not "Fixed")
|
- https://gist.github.com/parmentf/035de27d6ed1dce0b36a
|
||||||
- Capitalize the first letter of the subject
|
- https://gist.github.com/rxaviers/7360908
|
||||||
- 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**
|
|
||||||
|
|
||||||
### Examples
|
Each commit should have:
|
||||||
|
|
||||||
```
|
- A concise subject using the imperative mood.
|
||||||
:bug: Fix unexpected error on launching modal
|
- The subject should capitalize the first letter, omit the period
|
||||||
:sparkles: Enable new modal for profile
|
at the end, and be no longer than 65 characters.
|
||||||
:zap: Improve performance of dashboard navigation
|
- A blank line between the subject line and the body.
|
||||||
:ambulance: Fix critical bug on user registration process
|
- An entry in the CHANGES.md file if applicable, referencing the
|
||||||
:tada: Add new approach for user registration
|
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
|
- `:bug: Fix unexpected error on launching modal`
|
||||||
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
|
- `: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
|
```bash
|
||||||
# Check formatting (does not modify files)
|
# Check formatting
|
||||||
./scripts/check-fmt
|
|
||||||
|
|
||||||
# Fix formatting (modifies files in place)
|
|
||||||
./scripts/fmt
|
./scripts/fmt
|
||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
./scripts/lint
|
./scripts/lint
|
||||||
```
|
```
|
||||||
|
|
||||||
For frontend SCSS, we use `stylelint` for linting and
|
Ideally, you should run these commands as git pre-commit hooks. A convenient way
|
||||||
`Prettier` for formatting:
|
of defining them is to use [Husky](https://typicode.github.io/husky/#/).
|
||||||
|
|
||||||
```bash
|
## Code of Conduct ##
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# Lint SCSS
|
As contributors and maintainers of this project, we pledge to respect
|
||||||
pnpm run lint:scss (does not modify files)
|
all people who contribute through reporting issues, posting feature
|
||||||
|
requests, updating documentation, submitting pull requests or patches,
|
||||||
|
and other activities.
|
||||||
|
|
||||||
# Fix SCSS formatting (modifies files in place)
|
We are committed to making participation in this project a
|
||||||
pnpm run fmt:scss
|
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.
|
||||||
|
|
||||||
Ideally, run these as git pre-commit hooks.
|
Examples of unacceptable behavior by participants include the use of
|
||||||
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
sexual language or imagery, derogatory comments or personal attacks,
|
||||||
setting this up.
|
trolling, public or private harassment, insults, or other
|
||||||
|
unprofessional conduct.
|
||||||
|
|
||||||
## Changelog
|
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.
|
||||||
|
|
||||||
When your change is user-facing or otherwise notable, add an entry to
|
This Code of Conduct applies both within project spaces and in public
|
||||||
[CHANGES.md](CHANGES.md) following the same commit-type conventions. Reference
|
spaces when an individual is representing the project or its
|
||||||
the relevant GitHub issue or Taiga user story.
|
community.
|
||||||
|
|
||||||
## Code of Conduct
|
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 project follows the [Contributor Covenant](https://www.contributor-covenant.org/).
|
This Code of Conduct is adapted from the Contributor Covenant, version
|
||||||
The full Code of Conduct is available at
|
1.1.0, available from [http://contributor-covenant.org/version/1/1/0/](http://contributor-covenant.org/version/1/1/0/)
|
||||||
[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).
|
|
||||||
|
|
||||||
To report unacceptable behavior, open an issue or contact a project maintainer
|
|
||||||
directly.
|
|
||||||
|
|
||||||
## Developer's Certificate of Origin (DCO)
|
## Developer's Certificate of Origin (DCO)
|
||||||
|
|
||||||
By submitting code you agree to and can certify the following:
|
By submitting code you agree to and can certify the following:
|
||||||
|
|
||||||
> **Developer's Certificate of Origin 1.1**
|
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.
|
|
||||||
|
|
||||||
### 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
|
(a) The contribution was created in whole or in part by me and I
|
||||||
at the end of the commit body. Add it automatically with `git commit -s`.
|
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
|
Please, use your real name (sorry, no pseudonyms or anonymous
|
||||||
allowed.
|
contributions are allowed).
|
||||||
- The `Signed-off-by` line is **mandatory** and must match the commit author.
|
|
||||||
|
The commit Signed-off-by is mandatory and should match the commit author.
|
||||||
|
|
||||||
|
|||||||
116
README.md
116
README.md
@ -1,21 +1,18 @@
|
|||||||
<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]: https://www.mozilla.org/en-US/MPL/2.0
|
||||||
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
|
[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">
|
<p align="center">
|
||||||
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow">
|
<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>
|
||||||
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-blue.svg">
|
<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>
|
<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://community.penpot.app" rel="nofollow">
|
<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>
|
||||||
<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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@ -32,25 +29,25 @@
|
|||||||
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</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://bsky.app/profile/penpot.app"><b>Bluesky</b></a> •
|
||||||
<a href="https://twitter.com/penpotapp"><b>X</b></a>
|
<a href="https://twitter.com/penpotapp"><b>X</b></a>
|
||||||
|
|
||||||
</p>
|
</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
|
||||||
|
)
|
||||||
|
|
||||||
Penpot’s 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 it’s 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. It’s 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.
|
🎇 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.
|
||||||
|
|
||||||
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, we’re here to help. [Talk to us](https://penpot.app/talk-to-us)
|
|
||||||
|
|
||||||
## Table of contents ##
|
## Table of contents ##
|
||||||
|
|
||||||
@ -63,78 +60,101 @@ If your organization is scaling and needs extra support, we’re here to help. [
|
|||||||
|
|
||||||
## Why Penpot ##
|
## 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 what’s 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 ###
|
### 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.
|
[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 ###
|
### 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".
|
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 ###
|
### 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.
|
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.
|
||||||
|
|
||||||
### Integrations ###
|
### 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.
|
||||||
|
|
||||||
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.
|
### Integrations ###
|
||||||
|
Penpot offers integration 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 ###
|
### 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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
<img width="100%" alt="Penpot Design Systems" src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
|
<br />
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
## Getting started ##
|
## 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.
|
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).
|
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 ##
|
## Community ##
|
||||||
|
|
||||||
We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
|
We love the Open Source software community. Contributing is our passion and if it’s 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 you’d like to share your experience using Penpot or get inspired; if you’d rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)!
|
If you need help or have any questions; if you’d like to share your experience using Penpot or get inspired; if you’d 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)
|
- [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6)
|
||||||
- [Troubleshooting](https://community.penpot.app/c/technical/8)
|
- [Troubleshooting](https://community.penpot.app/c/technical/8)
|
||||||
- [Help us Improve Penpot](https://community.penpot.app/c/help-us-improve-penpot/7)
|
- [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)
|
- [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)
|
- [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 ###
|
### Code of Conduct ###
|
||||||
|
|
||||||
Anyone who contributes to Penpot, whether through code, in the community, or at an event, must adhere to the
|
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.
|
[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?
|
Any contribution will make a difference to improve Penpot. How can you get involved?
|
||||||
|
|
||||||
Choose your way:
|
Choose your way:
|
||||||
|
|
||||||
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community.
|
- 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).
|
- 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).
|
- 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.
|
- 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).
|
- 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).
|
- Become a [translator](https://help.penpot.app/contributing-guide/translations)
|
||||||
- Give feedback: [Email us](mailto:support@penpot.app).
|
- 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 Penpot’s repository and make changes in both front and back end.
|
- **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 Penpot’s 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/).
|
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 ##
|
## 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)
|
📚 [Dev Diaries](https://penpot.app/dev-diaries.html)
|
||||||
|
|
||||||
🧑🏫 [UI Design Course](https://penpot.app/courses/)
|
|
||||||
|
|
||||||
|
|
||||||
## License ##
|
## License ##
|
||||||
|
|
||||||
|
|||||||
@ -1,262 +1,87 @@
|
|||||||
# Penpot Backend – Agent Instructions
|
# backend – Agent Instructions
|
||||||
|
|
||||||
Clojure backend (RPC) service running on the JVM.
|
Clojure service running on the JVM. Uses Integrant for dependency injection, PostgreSQL for storage, and Redis for messaging/caching.
|
||||||
|
|
||||||
Uses Integrant for dependency injection, PostgreSQL for storage, and
|
## Commands
|
||||||
Redis for messaging/caching.
|
|
||||||
|
|
||||||
## General Guidelines
|
```bash
|
||||||
|
# REPL (primary dev workflow)
|
||||||
|
./scripts/repl # Start nREPL + load dev/user.clj utilities
|
||||||
|
|
||||||
To ensure consistency across the Penpot JVM stack, all contributions must adhere
|
# Tests (Kaocha)
|
||||||
to these criteria.
|
clojure -M:dev:test # Full suite
|
||||||
|
clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace
|
||||||
|
|
||||||
IMPORTANT: all CLI commands should be executed under backend/
|
# Lint / Format
|
||||||
subdirectory for make them work correctly.
|
pnpm run lint:clj
|
||||||
|
pnpm run fmt:clj
|
||||||
|
```
|
||||||
|
|
||||||
### 1. Testing & Validation
|
Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`.
|
||||||
|
|
||||||
* **Coverage:** If code is added or modified in `src/`, corresponding
|
## Integrant System
|
||||||
tests in `test/backend_tests/` must be added or updated.
|
|
||||||
|
|
||||||
* **Execution:**
|
`src/app/main.clj` declares the system map. Each key is a component;
|
||||||
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace.
|
values are config maps with `::ig/ref` for dependencies. Components
|
||||||
* **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas.
|
implement `ig/init-key` / `ig/halt-key!`.
|
||||||
|
|
||||||
### 2. Code Quality & Formatting
|
From the REPL (`dev/user.clj` is auto-loaded):
|
||||||
|
```clojure
|
||||||
|
(start!) ; boot the system
|
||||||
|
(stop!) ; halt the system
|
||||||
|
(restart!) ; stop + reload namespaces + start
|
||||||
|
```
|
||||||
|
|
||||||
* **Linting:** All code must pass linter checks (run `pnpm run lint:clj` or `pnpm run lint` on the repository root)
|
## RPC Commands
|
||||||
* **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
|
All API calls: `POST /api/rpc/command/<cmd-name>`.
|
||||||
|
|
||||||
### 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
|
```clojure
|
||||||
(sv/defmethod ::my-command
|
(sv/defmethod ::my-command
|
||||||
{::rpc/auth true ;; requires auth
|
{::rpc/auth true ;; requires authentication (default)
|
||||||
::doc/added "1.18"
|
::doc/added "1.18"
|
||||||
::sm/params [:map ...] ;; malli input schema
|
::sm/params [:map ...] ;; malli input schema
|
||||||
::sm/result [:map ...]} ;; malli output schema
|
::sm/result [:map ...]} ;; malli output schema
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||||
;; return a plain map or throw
|
;; return a plain map; throw via ex/raise for errors
|
||||||
{:id (uuid/next)})
|
{:id (uuid/next)})
|
||||||
```
|
```
|
||||||
|
|
||||||
Look under `src/app/rpc/commands/*.clj` to see more examples.
|
Add new commands in `src/app/rpc/commands/`.
|
||||||
|
|
||||||
### Tests
|
## Database
|
||||||
|
|
||||||
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.
|
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
;; Query helpers
|
;; Query helpers
|
||||||
(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing)
|
(db/get pool :table {:id id}) ; fetch one row (throws if missing)
|
||||||
(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil)
|
(db/get* pool :table {:id id}) ; fetch one row (returns nil)
|
||||||
(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows
|
(db/query pool :table {:team-id team-id}) ; fetch multiple rows
|
||||||
(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert
|
(db/insert! pool :table {:name "x" :team-id id}) ; insert
|
||||||
(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update
|
(db/update! pool :table {:name "y"} {:id id}) ; update
|
||||||
(db/delete! cfg-or-pool :table {:id id}) ; delete
|
(db/delete! 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
|
;; Transactions
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
(db/tx-run cfg (fn [{:keys [::db/conn]}]
|
||||||
(db/insert! conn :table row)))
|
(db/insert! conn :table row)))
|
||||||
```
|
```
|
||||||
|
|
||||||
Almost all methods in the `app.db` namespace accept `pool`, `conn`, or
|
Almost all methods on `app.db` namespace accepts `pool`, `conn` or
|
||||||
`cfg` as params.
|
`cfg` as params.
|
||||||
|
|
||||||
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
|
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
The exception helpers are defined on Common module, and are available under
|
|
||||||
`app.common.exceptions` namespace.
|
|
||||||
|
|
||||||
Example of raising an exception:
|
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
:code :object-not-found
|
:code :object-not-found
|
||||||
:hint "File does not exist"
|
:hint "File does not exist"
|
||||||
:file-id id)
|
:context {:id file-id})
|
||||||
```
|
```
|
||||||
|
|
||||||
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
|
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
### Performance Macros (`app.common.data.macros`)
|
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags :enable-smtp)`.
|
||||||
|
|
||||||
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"
|
|
||||||
```
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"author": "Kaleidos INC",
|
"author": "Kaleidos INC",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/penpot/penpot"
|
"url": "https://github.com/penpot/penpot"
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”
|
|
||||||
@ -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.
|
|
||||||
@ -186,8 +186,7 @@
|
|||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
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 %}
|
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
|
||||||
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
Hello!
|
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:
|
Accept invitation using this link:
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
||||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||||
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
|
||||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||||
|
|
||||||
# DEPRECATED: only used for subscriptions
|
# DEPRECATED: only used for subscriptions
|
||||||
@ -13,7 +12,7 @@ export PENPOT_PUBLIC_URI=https://localhost:3449
|
|||||||
|
|
||||||
export PENPOT_FLAGS="\
|
export PENPOT_FLAGS="\
|
||||||
$PENPOT_FLAGS \
|
$PENPOT_FLAGS \
|
||||||
enable-login-with-password \
|
enable-login-with-password
|
||||||
disable-login-with-ldap \
|
disable-login-with-ldap \
|
||||||
disable-login-with-oidc \
|
disable-login-with-oidc \
|
||||||
disable-login-with-google \
|
disable-login-with-google \
|
||||||
@ -45,10 +44,6 @@ export PENPOT_FLAGS="\
|
|||||||
enable-redis-cache \
|
enable-redis-cache \
|
||||||
enable-subscriptions";
|
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
|
# Default deletion delay for devenv
|
||||||
export PENPOT_DELETION_DELAY="24h"
|
export PENPOT_DELETION_DELAY="24h"
|
||||||
|
|
||||||
|
|||||||
@ -111,7 +111,7 @@
|
|||||||
[:host {:optional true} :string]
|
[:host {:optional true} :string]
|
||||||
[:port {:optional true} ::sm/int]
|
[:port {:optional true} ::sm/int]
|
||||||
[:bind-dn {:optional true} :string]
|
[:bind-dn {:optional true} :string]
|
||||||
[:bind-password {:optional true} :string]
|
[:bind-passwor {:optional true} :string]
|
||||||
[:query {:optional true} :string]
|
[:query {:optional true} :string]
|
||||||
[:base-dn {:optional true} :string]
|
[:base-dn {:optional true} :string]
|
||||||
[:attrs-email {:optional true} :string]
|
[:attrs-email {:optional true} :string]
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
(defn- discover-oidc-config
|
(defn- discover-oidc-config
|
||||||
[cfg {:keys [base-uri] :as provider}]
|
[cfg {:keys [base-uri] :as provider}]
|
||||||
(let [uri (u/join base-uri ".well-known/openid-configuration")
|
(let [uri (u/join base-uri ".well-known/openid-configuration")
|
||||||
rsp (http/req cfg {:method :get :uri (dm/str uri)})]
|
rsp (http/req! cfg {:method :get :uri (dm/str uri)})]
|
||||||
|
|
||||||
(if (= 200 (:status rsp))
|
(if (= 200 (:status rsp))
|
||||||
(let [data (-> rsp :body json/decode)
|
(let [data (-> rsp :body json/decode)
|
||||||
@ -105,7 +105,7 @@
|
|||||||
|
|
||||||
(defn- fetch-oidc-jwks
|
(defn- fetch-oidc-jwks
|
||||||
[cfg jwks-uri]
|
[cfg jwks-uri]
|
||||||
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
|
(let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri})]
|
||||||
(if (= 200 status)
|
(if (= 200 status)
|
||||||
(-> body json/decode :keys process-oidc-jwks)
|
(-> body json/decode :keys process-oidc-jwks)
|
||||||
(ex/raise :type ::internal
|
(ex/raise :type ::internal
|
||||||
@ -235,7 +235,7 @@
|
|||||||
:timeout 6000
|
:timeout 6000
|
||||||
:method :get}
|
:method :get}
|
||||||
|
|
||||||
{:keys [status body]} (http/req cfg params)]
|
{:keys [status body]} (http/req! cfg params)]
|
||||||
|
|
||||||
(when-not (int-in-range? status 200 300)
|
(when-not (int-in-range? status 200 300)
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
@ -401,9 +401,8 @@
|
|||||||
|
|
||||||
(defn- parse-attr-path
|
(defn- parse-attr-path
|
||||||
[provider path]
|
[provider path]
|
||||||
(let [separator (if (str/includes? path "__") "__" ".")
|
(let [[fitem & items] (str/split path "__")]
|
||||||
[fitem & items] (str/split path separator)]
|
(into [(keyword (:type provider) fitem)] (map keyword) items)))
|
||||||
(into [(keyword (:type provider) (str/kebab fitem))] (map keyword) items)))
|
|
||||||
|
|
||||||
(defn- build-redirect-uri
|
(defn- build-redirect-uri
|
||||||
[]
|
[]
|
||||||
@ -424,7 +423,7 @@
|
|||||||
|
|
||||||
(defn- qualify-prop-key
|
(defn- qualify-prop-key
|
||||||
[provider k]
|
[provider k]
|
||||||
(keyword (:type provider) (-> k name str/kebab)))
|
(keyword (:type provider) (name k)))
|
||||||
|
|
||||||
(defn- qualify-props
|
(defn- qualify-props
|
||||||
[provider props]
|
[provider props]
|
||||||
@ -453,7 +452,7 @@
|
|||||||
:grant-type (:grant_type params)
|
:grant-type (:grant_type params)
|
||||||
:redirect-uri (:redirect_uri params))
|
:redirect-uri (:redirect_uri params))
|
||||||
|
|
||||||
(let [{:keys [status body]} (http/req cfg req)]
|
(let [{:keys [status body]} (http/req! cfg req)]
|
||||||
(if (= status 200)
|
(if (= status 200)
|
||||||
(let [data (json/decode body)
|
(let [data (json/decode body)
|
||||||
data {:token/access (get data :access_token)
|
data {:token/access (get data :access_token)
|
||||||
@ -508,7 +507,7 @@
|
|||||||
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
||||||
:timeout 6000
|
:timeout 6000
|
||||||
:method :get}
|
:method :get}
|
||||||
response (http/req cfg params)]
|
response (http/req! cfg params)]
|
||||||
|
|
||||||
(l/trc :hint "user info response"
|
(l/trc :hint "user info response"
|
||||||
:status (:status response)
|
:status (:status response)
|
||||||
@ -548,28 +547,15 @@
|
|||||||
(def ^:private valid-info?
|
(def ^:private valid-info?
|
||||||
(sm/validator schema: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
|
(defn- get-info
|
||||||
[cfg provider state code]
|
[cfg provider state code]
|
||||||
(let [tdata (fetch-access-token cfg provider code)
|
(let [tdata (fetch-access-token cfg provider code)
|
||||||
claims (get-id-token-claims provider tdata)
|
claims (get-id-token-claims provider tdata)
|
||||||
|
|
||||||
info (case (select-user-info-source (get provider :user-info-source))
|
info (case (get provider :user-info-source)
|
||||||
:token (dissoc claims :exp :iss :iat :aud :sid)
|
:token (dissoc claims :exp :iss :iat :aud :sub :sid)
|
||||||
:userinfo (fetch-user-info cfg provider tdata)
|
:userinfo (fetch-user-info cfg provider tdata)
|
||||||
:auto (or (some-> claims (dissoc :exp :iss :iat :aud :sid))
|
(or (some-> claims (dissoc :exp :iss :iat :aud :sub :sid))
|
||||||
(fetch-user-info cfg provider tdata)))
|
(fetch-user-info cfg provider tdata)))
|
||||||
|
|
||||||
info (process-user-info provider tdata info)]
|
info (process-user-info provider tdata info)]
|
||||||
@ -818,12 +804,12 @@
|
|||||||
props (audit/profile->props profile)
|
props (audit/profile->props profile)
|
||||||
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
||||||
|
|
||||||
(audit/submit cfg {:type "action"
|
(audit/submit! cfg {::audit/type "action"
|
||||||
:name "login-with-oidc"
|
::audit/name "login-with-oidc"
|
||||||
:profile-id (:id profile)
|
::audit/profile-id (:id profile)
|
||||||
:ip-addr (inet/parse-request request)
|
::audit/ip-addr (inet/parse-request request)
|
||||||
:props props
|
::audit/props props
|
||||||
:context context})
|
::audit/context context})
|
||||||
|
|
||||||
(->> (redirect-to-verify-token token)
|
(->> (redirect-to-verify-token token)
|
||||||
(sxf request)))))
|
(sxf request)))))
|
||||||
|
|||||||
@ -315,8 +315,8 @@
|
|||||||
(defn get-file
|
(defn get-file
|
||||||
"Get file, resolve all features and apply migrations.
|
"Get file, resolve all features and apply migrations.
|
||||||
|
|
||||||
Useful when you have plan to apply massive or not surgical
|
Usefull when you have plan to apply massive or not cirurgical
|
||||||
operations on file, because it removes the overhead of lazy fetching
|
operations on file, because it removes the ovehead of lazy fetching
|
||||||
and decoding."
|
and decoding."
|
||||||
[cfg file-id & {:as opts}]
|
[cfg file-id & {:as opts}]
|
||||||
(db/run! cfg get-file* file-id opts))
|
(db/run! cfg get-file* file-id opts))
|
||||||
|
|||||||
@ -40,8 +40,8 @@
|
|||||||
[promesa.util :as pu]
|
[promesa.util :as pu]
|
||||||
[yetti.adapter :as yt])
|
[yetti.adapter :as yt])
|
||||||
(:import
|
(:import
|
||||||
com.github.luben.zstd.ZstdInputStream
|
|
||||||
com.github.luben.zstd.ZstdIOException
|
com.github.luben.zstd.ZstdIOException
|
||||||
|
com.github.luben.zstd.ZstdInputStream
|
||||||
com.github.luben.zstd.ZstdOutputStream
|
com.github.luben.zstd.ZstdOutputStream
|
||||||
java.io.DataInputStream
|
java.io.DataInputStream
|
||||||
java.io.DataOutputStream
|
java.io.DataOutputStream
|
||||||
|
|||||||
@ -281,7 +281,7 @@
|
|||||||
|
|
||||||
thumbnails (bfc/get-file-object-thumbnails cfg file-id)]
|
thumbnails (bfc/get-file-object-thumbnails cfg file-id)]
|
||||||
|
|
||||||
(events/tap :progress {:section :file :id file-id :name (:name file)})
|
(events/tap :progress {:section :file :id file-id})
|
||||||
|
|
||||||
(vswap! bfc/*state* update :files assoc file-id
|
(vswap! bfc/*state* update :files assoc file-id
|
||||||
{:id file-id
|
{:id file-id
|
||||||
@ -301,7 +301,6 @@
|
|||||||
(write-entry! output path file))
|
(write-entry! output path file))
|
||||||
|
|
||||||
(doseq [[index page-id] (d/enumerate pages)]
|
(doseq [[index page-id] (d/enumerate pages)]
|
||||||
|
|
||||||
(let [path (str "files/" file-id "/pages/" page-id ".json")
|
(let [path (str "files/" file-id "/pages/" page-id ".json")
|
||||||
page (get pages-index page-id)
|
page (get pages-index page-id)
|
||||||
objects (:objects page)
|
objects (:objects page)
|
||||||
@ -312,8 +311,6 @@
|
|||||||
|
|
||||||
(write-entry! output path page)
|
(write-entry! output path page)
|
||||||
|
|
||||||
(events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id})
|
|
||||||
|
|
||||||
(doseq [[shape-id shape] objects]
|
(doseq [[shape-id shape] objects]
|
||||||
(let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json")
|
(let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json")
|
||||||
shape (assoc shape :page-id page-id)
|
shape (assoc shape :page-id page-id)
|
||||||
@ -326,8 +323,6 @@
|
|||||||
(doseq [{:keys [id] :as media} media]
|
(doseq [{:keys [id] :as media} media]
|
||||||
(let [path (str "files/" file-id "/media/" id ".json")
|
(let [path (str "files/" file-id "/media/" id ".json")
|
||||||
media (encode-media media)]
|
media (encode-media media)]
|
||||||
|
|
||||||
(events/tap :progress {:section :media :id id :file-id file-id})
|
|
||||||
(write-entry! output path media)))
|
(write-entry! output path media)))
|
||||||
|
|
||||||
(doseq [thumbnail thumbnails]
|
(doseq [thumbnail thumbnails]
|
||||||
@ -337,13 +332,11 @@
|
|||||||
data (-> data
|
data (-> data
|
||||||
(assoc :media-id (:media-id thumbnail))
|
(assoc :media-id (:media-id thumbnail))
|
||||||
(encode-file-thumbnail))]
|
(encode-file-thumbnail))]
|
||||||
(events/tap :progress {:section :thumbnails :id (:object-id thumbnail) :file-id file-id})
|
|
||||||
(write-entry! output path data)))
|
(write-entry! output path data)))
|
||||||
|
|
||||||
(doseq [[id component] components]
|
(doseq [[id component] components]
|
||||||
(let [path (str "files/" file-id "/components/" id ".json")
|
(let [path (str "files/" file-id "/components/" id ".json")
|
||||||
component (encode-component component)]
|
component (encode-component component)]
|
||||||
(events/tap :progress {:section :component :id id :file-id file-id})
|
|
||||||
(write-entry! output path component)))
|
(write-entry! output path component)))
|
||||||
|
|
||||||
(doseq [[id color] colors]
|
(doseq [[id color] colors]
|
||||||
@ -354,20 +347,17 @@
|
|||||||
(and (contains? color :path)
|
(and (contains? color :path)
|
||||||
(str/empty? (:path color)))
|
(str/empty? (:path color)))
|
||||||
(dissoc :path))]
|
(dissoc :path))]
|
||||||
(events/tap :progress {:section :color :id id :file-id file-id})
|
|
||||||
(write-entry! output path color)))
|
(write-entry! output path color)))
|
||||||
|
|
||||||
(doseq [[id object] typographies]
|
(doseq [[id object] typographies]
|
||||||
(let [path (str "files/" file-id "/typographies/" id ".json")
|
(let [path (str "files/" file-id "/typographies/" id ".json")
|
||||||
typography (encode-typography object)]
|
typography (encode-typography object)]
|
||||||
(events/tap :progress {:section :typography :id id :file-id file-id})
|
|
||||||
(write-entry! output path typography)))
|
(write-entry! output path typography)))
|
||||||
|
|
||||||
(when (and tokens-lib
|
(when (and tokens-lib
|
||||||
(not (ctob/empty-lib? tokens-lib)))
|
(not (ctob/empty-lib? tokens-lib)))
|
||||||
(let [path (str "files/" file-id "/tokens.json")
|
(let [path (str "files/" file-id "/tokens.json")
|
||||||
encoded-tokens (encode-tokens-lib tokens-lib)]
|
encoded-tokens (encode-tokens-lib tokens-lib)]
|
||||||
(events/tap :progress {:section :tokens-lib :file-id file-id})
|
|
||||||
(write-entry! output path encoded-tokens)))))
|
(write-entry! output path encoded-tokens)))))
|
||||||
|
|
||||||
(defn- export-files
|
(defn- export-files
|
||||||
@ -610,7 +600,6 @@
|
|||||||
(let [object (->> (read-entry input entry)
|
(let [object (->> (read-entry input entry)
|
||||||
(decode-color)
|
(decode-color)
|
||||||
(validate-color))]
|
(validate-color))]
|
||||||
(events/tap :progress {:section :color :id id :file-id file-id})
|
|
||||||
(if (= id (:id object))
|
(if (= id (:id object))
|
||||||
(assoc result id object)
|
(assoc result id object)
|
||||||
result)))
|
result)))
|
||||||
@ -642,7 +631,6 @@
|
|||||||
(clean-component-pre-decode)
|
(clean-component-pre-decode)
|
||||||
(decode-component)
|
(decode-component)
|
||||||
(clean-component-post-decode))]
|
(clean-component-post-decode))]
|
||||||
(events/tap :progress {:section :component :id id :file-id file-id})
|
|
||||||
(if (= id (:id object))
|
(if (= id (:id object))
|
||||||
(assoc result id object)
|
(assoc result id object)
|
||||||
result)))
|
result)))
|
||||||
@ -656,7 +644,6 @@
|
|||||||
(let [object (->> (read-entry input entry)
|
(let [object (->> (read-entry input entry)
|
||||||
(decode-typography)
|
(decode-typography)
|
||||||
(validate-typography))]
|
(validate-typography))]
|
||||||
(events/tap :progress {:section :typography :id id :file-id file-id})
|
|
||||||
(if (= id (:id object))
|
(if (= id (:id object))
|
||||||
(assoc result id object)
|
(assoc result id object)
|
||||||
result)))
|
result)))
|
||||||
@ -666,7 +653,6 @@
|
|||||||
(defn- read-file-tokens-lib
|
(defn- read-file-tokens-lib
|
||||||
[{:keys [::bfc/input ::entries]} file-id]
|
[{:keys [::bfc/input ::entries]} file-id]
|
||||||
(when-let [entry (d/seek (match-tokens-lib-entry-fn file-id) entries)]
|
(when-let [entry (d/seek (match-tokens-lib-entry-fn file-id) entries)]
|
||||||
(events/tap :progress {:section :tokens-lib :file-id file-id})
|
|
||||||
(->> (read-plain-entry input entry)
|
(->> (read-plain-entry input entry)
|
||||||
(decode-tokens-lib)
|
(decode-tokens-lib)
|
||||||
(validate-tokens-lib))))
|
(validate-tokens-lib))))
|
||||||
@ -692,7 +678,6 @@
|
|||||||
(let [page (->> (read-entry input entry)
|
(let [page (->> (read-entry input entry)
|
||||||
(decode-page))
|
(decode-page))
|
||||||
page (dissoc page :options)]
|
page (dissoc page :options)]
|
||||||
(events/tap :progress {:section :page :id id :file-id file-id})
|
|
||||||
(when (= id (:id page))
|
(when (= id (:id page))
|
||||||
(let [objects (read-file-shapes cfg file-id id)]
|
(let [objects (read-file-shapes cfg file-id id)]
|
||||||
(assoc page :objects objects))))))
|
(assoc page :objects objects))))))
|
||||||
@ -708,7 +693,6 @@
|
|||||||
(let [object (->> (read-entry input entry)
|
(let [object (->> (read-entry input entry)
|
||||||
(decode-file-thumbnail)
|
(decode-file-thumbnail)
|
||||||
(validate-file-thumbnail))]
|
(validate-file-thumbnail))]
|
||||||
|
|
||||||
(if (and (= frame-id (:frame-id object))
|
(if (and (= frame-id (:frame-id object))
|
||||||
(= page-id (:page-id object))
|
(= page-id (:page-id object))
|
||||||
(= tag (:tag object)))
|
(= tag (:tag object)))
|
||||||
@ -749,6 +733,8 @@
|
|||||||
|
|
||||||
(vswap! bfc/*state* update :index bfc/update-index media :id)
|
(vswap! bfc/*state* update :index bfc/update-index media :id)
|
||||||
|
|
||||||
|
(events/tap :progress {:section :media :file-id file-id})
|
||||||
|
|
||||||
(doseq [item media]
|
(doseq [item media]
|
||||||
(let [params (-> item
|
(let [params (-> item
|
||||||
(update :id bfc/lookup-index)
|
(update :id bfc/lookup-index)
|
||||||
@ -756,8 +742,6 @@
|
|||||||
(d/update-when :media-id bfc/lookup-index)
|
(d/update-when :media-id bfc/lookup-index)
|
||||||
(d/update-when :thumbnail-id bfc/lookup-index))]
|
(d/update-when :thumbnail-id bfc/lookup-index))]
|
||||||
|
|
||||||
(events/tap :progress {:section :media :id (:id params) :file-id file-id})
|
|
||||||
|
|
||||||
(l/dbg :hint "inserting media object"
|
(l/dbg :hint "inserting media object"
|
||||||
:file-id (str file-id')
|
:file-id (str file-id')
|
||||||
:id (str (:id params))
|
:id (str (:id params))
|
||||||
@ -769,6 +753,8 @@
|
|||||||
(db/insert! conn :file-media-object params
|
(db/insert! conn :file-media-object params
|
||||||
::db/on-conflict-do-nothing? (::bfc/overwrite cfg))))
|
::db/on-conflict-do-nothing? (::bfc/overwrite cfg))))
|
||||||
|
|
||||||
|
(events/tap :progress {:section :thumbnails :file-id file-id})
|
||||||
|
|
||||||
(doseq [item thumbnails]
|
(doseq [item thumbnails]
|
||||||
(let [media-id (bfc/lookup-index (:media-id item))
|
(let [media-id (bfc/lookup-index (:media-id item))
|
||||||
object-id (-> (assoc item :file-id file-id')
|
object-id (-> (assoc item :file-id file-id')
|
||||||
@ -783,8 +769,6 @@
|
|||||||
:media-id (str media-id)
|
:media-id (str media-id)
|
||||||
::l/sync? true)
|
::l/sync? true)
|
||||||
|
|
||||||
(events/tap :progress {:section :thumbnail :file-id file-id :object-id object-id})
|
|
||||||
|
|
||||||
(db/insert! conn :file-tagged-object-thumbnail params
|
(db/insert! conn :file-tagged-object-thumbnail params
|
||||||
::db/on-conflict-do-nothing? true)))
|
::db/on-conflict-do-nothing? true)))
|
||||||
|
|
||||||
|
|||||||
@ -82,14 +82,7 @@
|
|||||||
:initial-project-skey "initial-project"
|
:initial-project-skey "initial-project"
|
||||||
|
|
||||||
;; time to avoid email sending after profile modification
|
;; time to avoid email sending after profile modification
|
||||||
:email-verify-threshold "15m"
|
:email-verify-threshold "15m"})
|
||||||
|
|
||||||
:quotes-upload-sessions-per-profile 5
|
|
||||||
:quotes-upload-chunks-per-session 20
|
|
||||||
|
|
||||||
;; SSRF protection
|
|
||||||
:ssrf-allowed-hosts #{}
|
|
||||||
:ssrf-extra-blocked-cidrs #{}})
|
|
||||||
|
|
||||||
(def schema:config
|
(def schema:config
|
||||||
(do #_sm/optional-keys
|
(do #_sm/optional-keys
|
||||||
@ -110,7 +103,6 @@
|
|||||||
|
|
||||||
[:exporter-shared-key {:optional true} :string]
|
[:exporter-shared-key {:optional true} :string]
|
||||||
[:nitrate-shared-key {:optional true} :string]
|
[:nitrate-shared-key {:optional true} :string]
|
||||||
[:nexus-shared-key {:optional true} :string]
|
|
||||||
[:management-api-key {:optional true} :string]
|
[:management-api-key {:optional true} :string]
|
||||||
|
|
||||||
[:telemetry-uri {:optional true} :string]
|
[:telemetry-uri {:optional true} :string]
|
||||||
@ -161,8 +153,6 @@
|
|||||||
[:quotes-snapshots-per-team {:optional true} ::sm/int]
|
[:quotes-snapshots-per-team {:optional true} ::sm/int]
|
||||||
[:quotes-team-access-requests-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-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-name {:optional true} :string]
|
||||||
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
||||||
@ -249,26 +239,17 @@
|
|||||||
[:objects-storage-fs-directory {:optional true} :string]
|
[:objects-storage-fs-directory {:optional true} :string]
|
||||||
[:objects-storage-s3-bucket {:optional true} :string]
|
[:objects-storage-s3-bucket {:optional true} :string]
|
||||||
[:objects-storage-s3-region {:optional true} :keyword]
|
[:objects-storage-s3-region {:optional true} :keyword]
|
||||||
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]
|
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]]))
|
||||||
|
|
||||||
;; SSRF protection
|
|
||||||
[:ssrf-allowed-hosts {:optional true} [::sm/set :string]]
|
|
||||||
[:ssrf-extra-blocked-cidrs {:optional true} [::sm/set :string]]]))
|
|
||||||
|
|
||||||
(defn- parse-flags
|
(defn- parse-flags
|
||||||
[config]
|
[config]
|
||||||
(let [public-uri (c/get config :public-uri)
|
(let [public-uri (c/get config :public-uri)
|
||||||
public-uri (some-> public-uri (u/uri))
|
public-uri (some-> public-uri (u/uri))
|
||||||
extra-flags (cond-> #{}
|
extra-flags (if (and public-uri
|
||||||
;; When public-uri is http (non-localhost), disable secure cookies
|
|
||||||
(and public-uri
|
|
||||||
(= (:scheme public-uri) "http")
|
(= (:scheme public-uri) "http")
|
||||||
(not= (:host public-uri) "localhost"))
|
(not= (:host public-uri) "localhost"))
|
||||||
(conj :disable-secure-session-cookies)
|
#{:disable-secure-session-cookies}
|
||||||
|
#{})]
|
||||||
;; When telemetry-enabled config is true, add :telemetry flag
|
|
||||||
(true? (c/get config :telemetry-enabled))
|
|
||||||
(conj :enable-telemetry))]
|
|
||||||
(flags/parse flags/default extra-flags (:flags config))))
|
(flags/parse flags/default extra-flags (:flags config))))
|
||||||
|
|
||||||
(defn read-env
|
(defn read-env
|
||||||
@ -293,7 +274,7 @@
|
|||||||
(sm/explainer schema:config))
|
(sm/explainer schema:config))
|
||||||
|
|
||||||
(defn read-config
|
(defn read-config
|
||||||
"Reads the configuration from environment variables and decodes all
|
"Reads the configuration from enviroment variables and decodes all
|
||||||
known values."
|
known values."
|
||||||
[& {:keys [prefix default] :or {prefix "penpot"}}]
|
[& {:keys [prefix default] :or {prefix "penpot"}}]
|
||||||
(->> (read-env prefix)
|
(->> (read-env prefix)
|
||||||
@ -345,7 +326,7 @@
|
|||||||
|
|
||||||
(defn logging-context
|
(defn logging-context
|
||||||
[]
|
[]
|
||||||
{:backend/version (:full version)})
|
{:version/backend (:full version)})
|
||||||
|
|
||||||
;; Set value for all new threads bindings.
|
;; Set value for all new threads bindings.
|
||||||
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))
|
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))
|
||||||
|
|||||||
@ -36,11 +36,11 @@
|
|||||||
java.sql.Connection
|
java.sql.Connection
|
||||||
java.sql.PreparedStatement
|
java.sql.PreparedStatement
|
||||||
java.sql.Savepoint
|
java.sql.Savepoint
|
||||||
|
org.postgresql.PGConnection
|
||||||
org.postgresql.geometric.PGpoint
|
org.postgresql.geometric.PGpoint
|
||||||
org.postgresql.jdbc.PgArray
|
org.postgresql.jdbc.PgArray
|
||||||
org.postgresql.largeobject.LargeObject
|
org.postgresql.largeobject.LargeObject
|
||||||
org.postgresql.largeobject.LargeObjectManager
|
org.postgresql.largeobject.LargeObjectManager
|
||||||
org.postgresql.PGConnection
|
|
||||||
org.postgresql.util.PGInterval
|
org.postgresql.util.PGInterval
|
||||||
org.postgresql.util.PGobject))
|
org.postgresql.util.PGobject))
|
||||||
|
|
||||||
|
|||||||
@ -22,34 +22,15 @@
|
|||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[integrant.core :as ig])
|
[integrant.core :as ig])
|
||||||
(:import
|
(:import
|
||||||
|
jakarta.mail.Message$RecipientType
|
||||||
|
jakarta.mail.Session
|
||||||
|
jakarta.mail.Transport
|
||||||
jakarta.mail.internet.InternetAddress
|
jakarta.mail.internet.InternetAddress
|
||||||
jakarta.mail.internet.MimeBodyPart
|
jakarta.mail.internet.MimeBodyPart
|
||||||
jakarta.mail.internet.MimeMessage
|
jakarta.mail.internet.MimeMessage
|
||||||
jakarta.mail.internet.MimeMultipart
|
jakarta.mail.internet.MimeMultipart
|
||||||
jakarta.mail.Message$RecipientType
|
|
||||||
jakarta.mail.Session
|
|
||||||
jakarta.mail.Transport
|
|
||||||
java.util.Properties))
|
java.util.Properties))
|
||||||
|
|
||||||
(defn clean
|
|
||||||
"Clean and normalizes email address string"
|
|
||||||
[email]
|
|
||||||
(let [email (str/lower email)
|
|
||||||
email (if (str/starts-with? email "mailto:")
|
|
||||||
(subs email 7)
|
|
||||||
email)
|
|
||||||
email (if (or (str/starts-with? email "<")
|
|
||||||
(str/ends-with? email ">"))
|
|
||||||
(str/trim email "<>")
|
|
||||||
email)]
|
|
||||||
email))
|
|
||||||
|
|
||||||
(defn get-domain
|
|
||||||
[email]
|
|
||||||
(let [email (clean email)
|
|
||||||
[_ domain] (str/split email "@" 2)]
|
|
||||||
domain))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; EMAIL IMPL
|
;; EMAIL IMPL
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@ -431,21 +412,6 @@
|
|||||||
:id ::invite-to-team
|
:id ::invite-to-team
|
||||||
:schema schema: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
|
(def ^:private schema:join-team
|
||||||
[:map
|
[:map
|
||||||
[:invited-by ::sm/text]
|
[:invited-by ::sm/text]
|
||||||
|
|||||||
@ -36,18 +36,10 @@
|
|||||||
:cause cause)))))
|
:cause cause)))))
|
||||||
|
|
||||||
(defn contains?
|
(defn contains?
|
||||||
"Check if email is in the blacklist. Also matches subdomains: if
|
"Check if email is in the blacklist."
|
||||||
'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also
|
|
||||||
be rejected."
|
|
||||||
[{:keys [::email/blacklist]} email]
|
[{:keys [::email/blacklist]} email]
|
||||||
(let [[_ domain] (str/split email "@" 2)
|
(let [[_ domain] (str/split email "@" 2)]
|
||||||
parts (str/split (str/lower domain) #"\.")]
|
(c/contains? blacklist (str/lower domain))))
|
||||||
(loop [parts parts]
|
|
||||||
(if (empty? parts)
|
|
||||||
false
|
|
||||||
(if (c/contains? blacklist (str/join "." parts))
|
|
||||||
true
|
|
||||||
(recur (rest parts)))))))
|
|
||||||
|
|
||||||
(defn enabled?
|
(defn enabled?
|
||||||
"Check if the blacklist is enabled"
|
"Check if the blacklist is enabled"
|
||||||
|
|||||||
@ -112,9 +112,8 @@
|
|||||||
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
|
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
|
||||||
END"))
|
END"))
|
||||||
|
|
||||||
(defn get-snapshot-data
|
(defn- get-snapshot
|
||||||
"Get a fully decoded snapshot for read-only preview or restoration.
|
"Get snapshot with decoded data"
|
||||||
Returns the snapshot map with decoded :data field."
|
|
||||||
[cfg file-id snapshot-id]
|
[cfg file-id snapshot-id]
|
||||||
(let [now (ct/now)]
|
(let [now (ct/now)]
|
||||||
(->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now]
|
(->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now]
|
||||||
@ -327,7 +326,7 @@
|
|||||||
(sto/resolve cfg {::db/reuse-conn true})
|
(sto/resolve cfg {::db/reuse-conn true})
|
||||||
|
|
||||||
snapshot
|
snapshot
|
||||||
(get-snapshot-data cfg file-id snapshot-id)]
|
(get-snapshot cfg file-id snapshot-id)]
|
||||||
|
|
||||||
(when-not snapshot
|
(when-not snapshot
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
|
|||||||
@ -12,56 +12,43 @@
|
|||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uri :as u]
|
[app.common.uri :as u]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.http.session :as session]
|
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[yetti.response :as-alias yres]))
|
[yetti.response :as-alias yres]))
|
||||||
|
|
||||||
(def ^:private default-cache-max-age
|
(def ^:private cache-max-age
|
||||||
(ct/duration {:hours 24}))
|
(ct/duration {:hours 24}))
|
||||||
|
|
||||||
(def ^:private default-signature-max-age
|
(def ^:private signature-max-age
|
||||||
(ct/duration {:hours 24 :minutes 15}))
|
(ct/duration {:hours 24 :minutes 15}))
|
||||||
|
|
||||||
;; Buckets that are legitimately public and do not require authentication.
|
|
||||||
;; These are used by public shared board viewing, profile photos in UI,
|
|
||||||
;; and embedded export/binfile flows.
|
|
||||||
(def ^:private public-buckets
|
|
||||||
#{"file-media-object"
|
|
||||||
"file-object-thumbnail"
|
|
||||||
"team-font-variant"
|
|
||||||
"file-data-fragment"})
|
|
||||||
|
|
||||||
(defn get-id
|
(defn get-id
|
||||||
[{:keys [path-params]}]
|
[{:keys [path-params]}]
|
||||||
(or (some-> path-params :id d/parse-uuid)
|
(or (some-> path-params :id d/parse-uuid)
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
:hint "object not found")))
|
:hunt "object not found")))
|
||||||
|
|
||||||
(defn- get-file-media-object
|
(defn- get-file-media-object
|
||||||
[pool id]
|
[pool id]
|
||||||
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||||
|
|
||||||
(defn- serve-object-from-s3
|
(defn- serve-object-from-s3
|
||||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
[{:keys [::sto/storage] :as cfg} obj]
|
||||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
(let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
|
||||||
cch-max-age (or cache-max-age default-cache-max-age)
|
|
||||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
|
|
||||||
{::yres/status 307
|
{::yres/status 307
|
||||||
::yres/headers {"location" (str url)
|
::yres/headers {"location" (str url)
|
||||||
"x-host" (cond-> host port (str ":" port))
|
"x-host" (cond-> host port (str ":" port))
|
||||||
"x-mtype" (-> obj meta :content-type)
|
"x-mtype" (-> obj meta :content-type)
|
||||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
"cache-control" (str "max-age=" (inst-ms cache-max-age))}}))
|
||||||
|
|
||||||
(defn- serve-object-from-fs
|
(defn- serve-object-from-fs
|
||||||
[{:keys [::path ::cache-max-age]} obj]
|
[{:keys [::path]} obj]
|
||||||
(let [cch-max-age (or cache-max-age default-cache-max-age)
|
(let [purl (u/join (u/uri path)
|
||||||
purl (u/join (u/uri path)
|
|
||||||
(sto/object->relative-path obj))
|
(sto/object->relative-path obj))
|
||||||
mdata (meta obj)
|
mdata (meta obj)
|
||||||
headers {"x-accel-redirect" (:path purl)
|
headers {"x-accel-redirect" (:path purl)
|
||||||
"content-type" (:content-type mdata)
|
"content-type" (:content-type mdata)
|
||||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
|
||||||
{::yres/status 204
|
{::yres/status 204
|
||||||
::yres/headers headers}))
|
::yres/headers headers}))
|
||||||
|
|
||||||
@ -73,28 +60,14 @@
|
|||||||
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
||||||
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
|
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
|
||||||
|
|
||||||
(defn- requires-auth?
|
|
||||||
"Check if the storage object requires authentication based on its bucket."
|
|
||||||
[obj]
|
|
||||||
(let [bucket (-> obj meta :bucket)]
|
|
||||||
(not (contains? public-buckets bucket))))
|
|
||||||
|
|
||||||
(defn objects-handler
|
(defn objects-handler
|
||||||
"Handler that serves storage objects by id.
|
"Handler that servers storage objects by id."
|
||||||
For non-public buckets (e.g. profile), requires an authenticated session."
|
|
||||||
[{:keys [::sto/storage] :as cfg} request]
|
[{:keys [::sto/storage] :as cfg} request]
|
||||||
(let [id (get-id request)
|
(let [id (get-id request)
|
||||||
obj (sto/get-object storage id)]
|
obj (sto/get-object storage id)]
|
||||||
(cond
|
(if obj
|
||||||
(nil? obj)
|
(serve-object cfg obj)
|
||||||
{::yres/status 404}
|
{::yres/status 404})))
|
||||||
|
|
||||||
(and (requires-auth? obj)
|
|
||||||
(nil? (::session/profile-id request)))
|
|
||||||
{::yres/status 401}
|
|
||||||
|
|
||||||
:else
|
|
||||||
(serve-object cfg obj))))
|
|
||||||
|
|
||||||
(defn- generic-handler
|
(defn- generic-handler
|
||||||
"A generic handler helper/common code for file-media based handlers."
|
"A generic handler helper/common code for file-media based handlers."
|
||||||
@ -123,12 +96,11 @@
|
|||||||
(defmethod ig/assert-key ::routes
|
(defmethod ig/assert-key ::routes
|
||||||
[_ params]
|
[_ params]
|
||||||
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
|
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
|
||||||
(assert (session/manager? (::session/manager params)) "expected valid session manager")
|
|
||||||
(assert (string? (::path params))))
|
(assert (string? (::path params))))
|
||||||
|
|
||||||
(defmethod ig/init-key ::routes
|
(defmethod ig/init-key ::routes
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
["/assets" {:middleware [[session/authz cfg]]}
|
["/assets"
|
||||||
["/by-id/:id" {:handler (partial objects-handler cfg)}]
|
["/by-id/:id" {:handler (partial objects-handler cfg)}]
|
||||||
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
|
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
|
||||||
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
|
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
(let [surl (get body "SubscribeURL")
|
(let [surl (get body "SubscribeURL")
|
||||||
stopic (get body "TopicArn")]
|
stopic (get body "TopicArn")]
|
||||||
(l/info :action "subscription received" :topic stopic :url surl)
|
(l/info :action "subscription received" :topic stopic :url surl)
|
||||||
(http/req cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
|
(http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
|
||||||
|
|
||||||
(= mtype "Notification")
|
(= mtype "Notification")
|
||||||
(when-let [message (parse-json (get body "Message"))]
|
(when-let [message (parse-json (get body "Message"))]
|
||||||
|
|||||||
@ -5,24 +5,13 @@
|
|||||||
;; Copyright (c) KALEIDOS INC
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
(ns app.http.client
|
(ns app.http.client
|
||||||
"Http client abstraction layer.
|
"Http client abstraction layer."
|
||||||
|
|
||||||
All outbound requests made through `req` and `req-with-redirects`
|
|
||||||
are validated against the SSRF blocklist by default. Pass
|
|
||||||
`:skip-ssrf-check? true` in the options map only when the target
|
|
||||||
is a well-known, operator-configured endpoint that cannot be
|
|
||||||
influenced by user input (e.g. internal telemetry, error webhooks)."
|
|
||||||
(:require
|
(:require
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.util.ssrf :as ssrf]
|
|
||||||
[cuerdas.core :as str]
|
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[java-http-clj.core :as http])
|
[java-http-clj.core :as http])
|
||||||
(:import
|
(:import
|
||||||
java.net.http.HttpClient
|
java.net.http.HttpClient))
|
||||||
java.net.URI))
|
|
||||||
|
|
||||||
(def default-max-redirects 5)
|
|
||||||
|
|
||||||
(defn client?
|
(defn client?
|
||||||
[o]
|
[o]
|
||||||
@ -34,8 +23,8 @@
|
|||||||
|
|
||||||
(defmethod ig/init-key ::client
|
(defmethod ig/init-key ::client
|
||||||
[_ _]
|
[_ _]
|
||||||
(http/build-client {:connect-timeout 30000
|
(http/build-client {:connect-timeout 30000 ;; 10s
|
||||||
:follow-redirects :never}))
|
:follow-redirects :always}))
|
||||||
|
|
||||||
(defn send!
|
(defn send!
|
||||||
([client req] (send! client req {}))
|
([client req] (send! client req {}))
|
||||||
@ -55,82 +44,14 @@
|
|||||||
:else
|
:else
|
||||||
(throw (UnsupportedOperationException. "invalid arguments"))))
|
(throw (UnsupportedOperationException. "invalid arguments"))))
|
||||||
|
|
||||||
(defn req
|
(defn req!
|
||||||
"Issue a single HTTP request. SSRF validation is applied to the
|
"A convencience toplevel function for gradual migration to a new API
|
||||||
target URI by default; pass `:skip-ssrf-check? true` in `options`
|
convention."
|
||||||
to bypass it for known-safe, operator-configured endpoints."
|
|
||||||
([cfg-or-client request]
|
([cfg-or-client request]
|
||||||
(req cfg-or-client request {}))
|
|
||||||
([cfg-or-client request {:keys [skip-ssrf-check?] :as options}]
|
|
||||||
(let [request (if skip-ssrf-check?
|
|
||||||
(update request :uri str)
|
|
||||||
(update request :uri ssrf/validate-uri))
|
|
||||||
client (resolve-client cfg-or-client)]
|
|
||||||
(send! client request (dissoc options :skip-ssrf-check?)))))
|
|
||||||
|
|
||||||
(defn- resolve-location
|
|
||||||
"Resolve a Location header value against the original request URI.
|
|
||||||
Handles:
|
|
||||||
- Absolute URLs (http:// or https://) — returned as-is.
|
|
||||||
- Protocol-relative URLs (//host/path) — inherit the scheme from base-uri.
|
|
||||||
- Path-absolute and relative URLs — resolved against base-uri via URI.resolve."
|
|
||||||
[^String base-uri ^String location]
|
|
||||||
(cond
|
|
||||||
(or (str/starts-with? location "http://")
|
|
||||||
(str/starts-with? location "https://"))
|
|
||||||
location
|
|
||||||
|
|
||||||
(str/starts-with? location "//")
|
|
||||||
(let [scheme (.getScheme (URI. base-uri))]
|
|
||||||
(str scheme ":" location))
|
|
||||||
|
|
||||||
:else
|
|
||||||
(str (.resolve (URI. base-uri) location))))
|
|
||||||
|
|
||||||
(defn- redirect-request
|
|
||||||
"Build the next request for a 3xx redirect.
|
|
||||||
Per RFC 7231 §6.4:
|
|
||||||
- 303 always issues GET (body dropped).
|
|
||||||
- 301/302 with non-GET/HEAD methods: downgrade to GET (body dropped).
|
|
||||||
- 307/308 preserve the original method and body.
|
|
||||||
The Location URI has already been resolved by the caller."
|
|
||||||
[orig-request ^String next-uri status]
|
|
||||||
(let [method (:method orig-request)]
|
|
||||||
(if (or (= status 303)
|
|
||||||
(and (contains? #{301 302} status)
|
|
||||||
(not (contains? #{:get :head} method))))
|
|
||||||
;; Downgrade to GET, drop body and content-type
|
|
||||||
(-> orig-request
|
|
||||||
(assoc :uri next-uri :method :get)
|
|
||||||
(dissoc :body)
|
|
||||||
(update :headers dissoc "content-type" "content-length"))
|
|
||||||
;; Preserve method/body (307, 308, or GET/HEAD 301/302)
|
|
||||||
(assoc orig-request :uri next-uri))))
|
|
||||||
|
|
||||||
(defn req-with-redirects
|
|
||||||
"Like `req`, but follows up to `max-redirects` HTTP 3xx redirects.
|
|
||||||
SSRF validation is applied before every hop (initial request and
|
|
||||||
each redirect target) unless `:skip-ssrf-check? true` is passed.
|
|
||||||
Redirect semantics follow RFC 7231 §6.4: 301/302 POST is downgraded
|
|
||||||
to GET; 303 always uses GET; 307/308 preserve the original method."
|
|
||||||
([cfg-or-client request]
|
|
||||||
(req-with-redirects cfg-or-client request {}))
|
|
||||||
([cfg-or-client request {:keys [max-redirects skip-ssrf-check?]
|
|
||||||
:or {max-redirects default-max-redirects}
|
|
||||||
:as opts}]
|
|
||||||
(let [send-opts (dissoc opts :max-redirects :skip-ssrf-check?)
|
|
||||||
uri-coerce (if skip-ssrf-check? str ssrf/validate-uri)]
|
|
||||||
(loop [current-req (update request :uri uri-coerce)
|
|
||||||
hops 0]
|
|
||||||
(let [client (resolve-client cfg-or-client)
|
(let [client (resolve-client cfg-or-client)
|
||||||
resp (send! client current-req send-opts)
|
request (update request :uri str)]
|
||||||
status (:status resp)]
|
(send! client request {})))
|
||||||
(if (and (<= 300 status 399)
|
([cfg-or-client request options]
|
||||||
(< hops max-redirects))
|
(let [client (resolve-client cfg-or-client)
|
||||||
(if-let [location (get-in resp [:headers "location"])]
|
request (update request :uri str)]
|
||||||
(let [next-uri (resolve-location (str (:uri current-req)) location)]
|
(send! client request options))))
|
||||||
(recur (update (redirect-request current-req next-uri status) :uri uri-coerce)
|
|
||||||
(inc hops)))
|
|
||||||
;; No Location header on a 3xx — return the response as-is
|
|
||||||
resp)
|
|
||||||
resp))))))
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
[app.srepl.main :as srepl]
|
[app.srepl.main :as srepl]
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.blob :as blob]
|
||||||
[app.util.template :as tmpl]
|
[app.util.template :as tmpl]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[datoteka.io :as io]
|
[datoteka.io :as io]
|
||||||
@ -70,7 +71,8 @@
|
|||||||
|
|
||||||
(defn- get-resolved-file
|
(defn- get-resolved-file
|
||||||
[cfg file-id]
|
[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
|
(defn prepare-download
|
||||||
[file filename]
|
[file filename]
|
||||||
|
|||||||
@ -220,14 +220,12 @@
|
|||||||
(assoc :hint (ex-message error)))}))))
|
(assoc :hint (ex-message error)))}))))
|
||||||
|
|
||||||
(defmethod handle-exception java.io.IOException
|
(defmethod handle-exception java.io.IOException
|
||||||
[cause request _]
|
[cause _ _]
|
||||||
(binding [l/*context* (request->context request)]
|
|
||||||
(l/wrn :hint "io exception" :cause cause)
|
(l/wrn :hint "io exception" :cause cause)
|
||||||
{::yres/status 500
|
{::yres/status 500
|
||||||
::yres/body {:type :server-error
|
::yres/body {:type :server-error
|
||||||
:code :io-exception
|
:code :io-exception
|
||||||
:hint (ex-message cause)
|
:hint (ex-message cause)}})
|
||||||
:path (:path request)}}))
|
|
||||||
|
|
||||||
(defmethod handle-exception java.util.concurrent.CompletionException
|
(defmethod handle-exception java.util.concurrent.CompletionException
|
||||||
[cause request _]
|
[cause request _]
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
(defn- write!
|
(defn- write!
|
||||||
[^OutputStream output ^bytes data]
|
[^OutputStream output ^bytes data]
|
||||||
(l/trc :hint "writing data" :data data :length (alength data))
|
(l/trc :hint "writting data" :data data :length (alength data))
|
||||||
(.write output data)
|
(.write output data)
|
||||||
(.flush output))
|
(.flush output))
|
||||||
|
|
||||||
@ -53,7 +53,6 @@
|
|||||||
::yres/status 200
|
::yres/status 200
|
||||||
::yres/body (yres/stream-body
|
::yres/body (yres/stream-body
|
||||||
(fn [_ output]
|
(fn [_ output]
|
||||||
|
|
||||||
(let [channel (sp/chan :buf buf :xf (keep encode))
|
(let [channel (sp/chan :buf buf :xf (keep encode))
|
||||||
listener (events/spawn-listener
|
listener (events/spawn-listener
|
||||||
channel
|
channel
|
||||||
|
|||||||
@ -16,12 +16,12 @@
|
|||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email :as email]
|
|
||||||
[app.http :as-alias http]
|
[app.http :as-alias http]
|
||||||
[app.http.access-token :as-alias actoken]
|
[app.http.access-token :as-alias actoken]
|
||||||
[app.loggers.audit.tasks :as-alias tasks]
|
[app.loggers.audit.tasks :as-alias tasks]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.retry :as rtry]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
[app.util.inet :as inet]
|
[app.util.inet :as inet]
|
||||||
[app.util.services :as-alias sv]
|
[app.util.services :as-alias sv]
|
||||||
@ -33,63 +33,6 @@
|
|||||||
;; HELPERS
|
;; HELPERS
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private filter-auth-events
|
|
||||||
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
|
|
||||||
|
|
||||||
(def ^:private safe-backend-context-keys
|
|
||||||
#{:version
|
|
||||||
:initiator
|
|
||||||
:client-version
|
|
||||||
:client-user-agent})
|
|
||||||
|
|
||||||
(def ^:private safe-frontend-context-keys
|
|
||||||
#{:version
|
|
||||||
:locale
|
|
||||||
:browser
|
|
||||||
:browser-version
|
|
||||||
:engine
|
|
||||||
:engine-version
|
|
||||||
:os
|
|
||||||
:os-version
|
|
||||||
:device-type
|
|
||||||
:device-arch
|
|
||||||
:screen-width
|
|
||||||
:screen-height
|
|
||||||
:screen-color-depth
|
|
||||||
:screen-orientation
|
|
||||||
:event-origin
|
|
||||||
:event-namespace
|
|
||||||
:event-symbol})
|
|
||||||
|
|
||||||
(def profile-props
|
|
||||||
[:id
|
|
||||||
:is-active
|
|
||||||
:is-muted
|
|
||||||
:auth-backend
|
|
||||||
:email
|
|
||||||
:default-team-id
|
|
||||||
:default-project-id
|
|
||||||
:fullname
|
|
||||||
:lang])
|
|
||||||
|
|
||||||
(def ^:private event-keys
|
|
||||||
#{:id
|
|
||||||
:name
|
|
||||||
:type
|
|
||||||
:profile-id
|
|
||||||
:ip-addr
|
|
||||||
:props
|
|
||||||
:context
|
|
||||||
:source
|
|
||||||
:tracked-at
|
|
||||||
:created-at})
|
|
||||||
|
|
||||||
(def reserved-props
|
|
||||||
#{:session-id
|
|
||||||
:password
|
|
||||||
:old-password
|
|
||||||
:token})
|
|
||||||
|
|
||||||
(defn extract-utm-params
|
(defn extract-utm-params
|
||||||
"Extracts additional data from params and namespace them under
|
"Extracts additional data from params and namespace them under
|
||||||
`penpot` ns."
|
`penpot` ns."
|
||||||
@ -104,6 +47,17 @@
|
|||||||
(assoc (->> sk str/kebab (keyword "penpot")) v))))]
|
(assoc (->> sk str/kebab (keyword "penpot")) v))))]
|
||||||
(reduce-kv process-param {} params)))
|
(reduce-kv process-param {} params)))
|
||||||
|
|
||||||
|
(def profile-props
|
||||||
|
[:id
|
||||||
|
:is-active
|
||||||
|
:is-muted
|
||||||
|
:auth-backend
|
||||||
|
:email
|
||||||
|
:default-team-id
|
||||||
|
:default-project-id
|
||||||
|
:fullname
|
||||||
|
:lang])
|
||||||
|
|
||||||
(defn profile->props
|
(defn profile->props
|
||||||
[profile]
|
[profile]
|
||||||
(-> profile
|
(-> profile
|
||||||
@ -111,6 +65,12 @@
|
|||||||
(merge (:props profile))
|
(merge (:props profile))
|
||||||
(d/without-nils)))
|
(d/without-nils)))
|
||||||
|
|
||||||
|
(def reserved-props
|
||||||
|
#{:session-id
|
||||||
|
:password
|
||||||
|
:old-password
|
||||||
|
:token})
|
||||||
|
|
||||||
(defn clean-props
|
(defn clean-props
|
||||||
[props]
|
[props]
|
||||||
(into {}
|
(into {}
|
||||||
@ -160,17 +120,16 @@
|
|||||||
;; an external storage and data cleared.
|
;; an external storage and data cleared.
|
||||||
|
|
||||||
(def ^:private schema:event
|
(def ^:private schema:event
|
||||||
[:map {:title "AuditEvent"}
|
[:map {:title "event"}
|
||||||
[:id {:optional true} ::sm/uuid]
|
[::type ::sm/text]
|
||||||
[:type ::sm/text]
|
[::name ::sm/text]
|
||||||
[:name ::sm/text]
|
[::profile-id ::sm/uuid]
|
||||||
[:profile-id ::sm/uuid]
|
[::ip-addr {:optional true} ::sm/text]
|
||||||
[:props [:map-of :keyword :any]]
|
[::props {:optional true} [:map-of :keyword :any]]
|
||||||
[:context [:map-of :keyword :any]]
|
[::context {:optional true} [:map-of :keyword :any]]
|
||||||
[:tracked-at ::ct/inst]
|
[::tracked-at {:optional true} ::ct/inst]
|
||||||
[:created-at ::ct/inst]
|
[::created-at {:optional true} ::ct/inst]
|
||||||
[:source ::sm/text]
|
[::source {:optional true} ::sm/text]
|
||||||
[:ip-addr {:optional true} ::sm/text]
|
|
||||||
[::webhooks/event? {:optional true} ::sm/boolean]
|
[::webhooks/event? {:optional true} ::sm/boolean]
|
||||||
[::webhooks/batch-timeout {:optional true} ::ct/duration]
|
[::webhooks/batch-timeout {:optional true} ::ct/duration]
|
||||||
[::webhooks/batch-key {:optional true}
|
[::webhooks/batch-key {:optional true}
|
||||||
@ -182,156 +141,7 @@
|
|||||||
(def valid-event?
|
(def valid-event?
|
||||||
(sm/validator schema:event))
|
(sm/validator schema:event))
|
||||||
|
|
||||||
(defn- prepare-context-from-request
|
(defn prepare-event
|
||||||
"Prepare backend event context from request"
|
|
||||||
[request]
|
|
||||||
(let [client-event-origin (get-client-event-origin request)
|
|
||||||
client-version (get-client-version request)
|
|
||||||
client-user-agent (get-client-user-agent request)
|
|
||||||
session-id (get-external-session-id request)
|
|
||||||
key-id (::http/auth-key-id request)
|
|
||||||
token-id (::actoken/id request)
|
|
||||||
token-type (::actoken/type request)]
|
|
||||||
{:external-session-id session-id
|
|
||||||
:initiator (or key-id "app")
|
|
||||||
:access-token-id (some-> token-id str)
|
|
||||||
:access-token-type (some-> token-type str)
|
|
||||||
:client-event-origin client-event-origin
|
|
||||||
:client-user-agent client-user-agent
|
|
||||||
:client-version client-version
|
|
||||||
:version (:full cf/version)}))
|
|
||||||
|
|
||||||
(defn- append-audit-entry
|
|
||||||
[cfg params]
|
|
||||||
(let [params (-> params
|
|
||||||
(assoc :id (uuid/next))
|
|
||||||
(update :props db/tjson)
|
|
||||||
(update :context db/tjson)
|
|
||||||
(update :ip-addr db/inet))
|
|
||||||
params (select-keys params event-keys)]
|
|
||||||
(db/insert! cfg :audit-log params)))
|
|
||||||
|
|
||||||
(def ^:private xf:filter-telemetry-props
|
|
||||||
"Transducer that keeps only map entries whose values are UUIDs,
|
|
||||||
booleans or numbers."
|
|
||||||
(filter (fn [[k v]]
|
|
||||||
(and (simple-keyword? k)
|
|
||||||
(or (uuid? v) (boolean? v) (number? v))))))
|
|
||||||
|
|
||||||
(declare filter-telemetry-props)
|
|
||||||
(declare filter-telemetry-context)
|
|
||||||
|
|
||||||
(defn- process-event
|
|
||||||
[cfg event]
|
|
||||||
(when (contains? cf/flags :audit-log-logger)
|
|
||||||
(l/log! ::l/logger "app.audit"
|
|
||||||
::l/level :info
|
|
||||||
:profile-id (str (:profile-id event))
|
|
||||||
:ip-addr (str (:ip-addr event))
|
|
||||||
:type (:type event)
|
|
||||||
:name (:name event)
|
|
||||||
:props (json/encode (:props event) :key-fn json/write-camel-key)
|
|
||||||
:context (json/encode (:context event) :key-fn json/write-camel-key)))
|
|
||||||
|
|
||||||
(when (contains? cf/flags :audit-log)
|
|
||||||
(append-audit-entry cfg event))
|
|
||||||
|
|
||||||
(when (contains? cf/flags :telemetry)
|
|
||||||
;; NOTE: when both audit-log and telemetry are enabled, events are stored
|
|
||||||
;; twice: once with full details (above) and once stripped of props and
|
|
||||||
;; ip-addr, tagged with source="telemetry" so the telemetry task can
|
|
||||||
;; collect and ship them. The profile-id is preserved (UUIDs are already
|
|
||||||
;; anonymous random identifiers). Only a safe subset of context fields
|
|
||||||
;; is kept: initiator, version, client-version and client-user-agent.
|
|
||||||
;; Timestamps are truncated to day precision to avoid leaking exact event
|
|
||||||
;; timing.
|
|
||||||
(let [event (-> event
|
|
||||||
(filter-telemetry-props)
|
|
||||||
(filter-telemetry-context)
|
|
||||||
(update :created-at ct/truncate :days)
|
|
||||||
(update :tracked-at ct/truncate :days)
|
|
||||||
(assoc :source "telemetry:backend")
|
|
||||||
(assoc :ip-addr "0.0.0.0"))]
|
|
||||||
(append-audit-entry cfg event)))
|
|
||||||
|
|
||||||
(when (and (contains? cf/flags :webhooks)
|
|
||||||
(::webhooks/event? event))
|
|
||||||
(let [batch-key (::webhooks/batch-key event)
|
|
||||||
batch-timeout (::webhooks/batch-timeout event)
|
|
||||||
label (dm/str "rpc:" (:name event))
|
|
||||||
label (cond
|
|
||||||
(ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event)))
|
|
||||||
(string? batch-key) (dm/str label ":" batch-key)
|
|
||||||
:else label)
|
|
||||||
dedupe? (boolean (and batch-key batch-timeout))]
|
|
||||||
|
|
||||||
(wrk/submit! (-> cfg
|
|
||||||
(assoc ::wrk/task :process-webhook-event)
|
|
||||||
(assoc ::wrk/queue :webhooks)
|
|
||||||
(assoc ::wrk/max-retries 0)
|
|
||||||
(assoc ::wrk/delay (or batch-timeout 0))
|
|
||||||
(assoc ::wrk/dedupe dedupe?)
|
|
||||||
(assoc ::wrk/label label)
|
|
||||||
(assoc ::wrk/params (-> event
|
|
||||||
(d/without-qualified)
|
|
||||||
(dissoc :source)
|
|
||||||
(dissoc :context)
|
|
||||||
(dissoc :ip-addr)
|
|
||||||
(dissoc :type)))))))
|
|
||||||
event)
|
|
||||||
|
|
||||||
(defn submit*
|
|
||||||
"A public API, lower-level than submit, assumes all required fields are filled"
|
|
||||||
[cfg event]
|
|
||||||
(try
|
|
||||||
(let [event (check-event event)]
|
|
||||||
(db/tx-run! cfg process-event event))
|
|
||||||
(catch Throwable cause
|
|
||||||
(l/error :hint "unexpected error processing event" :cause cause))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
;; PUBLIC API
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(defn filter-telemetry-props
|
|
||||||
[{:keys [source name props type] :as params}]
|
|
||||||
(cond
|
|
||||||
(or (and (= source "frontend")
|
|
||||||
(= type "identify"))
|
|
||||||
(and (= source "backend")
|
|
||||||
(filter-auth-events name)))
|
|
||||||
|
|
||||||
(let [props' (into {} xf:filter-telemetry-props props)
|
|
||||||
props' (-> props'
|
|
||||||
(assoc :lang (:lang props))
|
|
||||||
(assoc :auth-backend (:auth-backend props))
|
|
||||||
(assoc :email-domain (email/get-domain (:email props)))
|
|
||||||
(d/without-nils))]
|
|
||||||
(assoc params :props props'))
|
|
||||||
|
|
||||||
(and (= source "backend")
|
|
||||||
(= type "trigger")
|
|
||||||
(= name "instance-start"))
|
|
||||||
params
|
|
||||||
|
|
||||||
(and (= source "frontend")
|
|
||||||
(= type "action")
|
|
||||||
(= name "navigate"))
|
|
||||||
(assoc params :props (select-keys props [:route :file-id :team-id :page-id]))
|
|
||||||
|
|
||||||
:else
|
|
||||||
(let [props (into {} xf:filter-telemetry-props props)]
|
|
||||||
(assoc params :props props))))
|
|
||||||
|
|
||||||
(defn filter-telemetry-context
|
|
||||||
[{:keys [source context] :as params}]
|
|
||||||
(let [context (case source
|
|
||||||
"backend" (select-keys context safe-backend-context-keys)
|
|
||||||
"frontend" (select-keys context safe-frontend-context-keys)
|
|
||||||
{})]
|
|
||||||
(assoc params :context context)))
|
|
||||||
|
|
||||||
(defn prepare-rpc-event
|
|
||||||
[cfg mdata params result]
|
[cfg mdata params result]
|
||||||
(let [resultm (meta result)
|
(let [resultm (meta result)
|
||||||
request (-> params meta ::http/request)
|
request (-> params meta ::http/request)
|
||||||
@ -344,29 +154,23 @@
|
|||||||
(merge params (::props resultm)))
|
(merge params (::props resultm)))
|
||||||
(clean-props))
|
(clean-props))
|
||||||
|
|
||||||
context (-> (::context resultm)
|
context (merge (::context resultm)
|
||||||
(merge (prepare-context-from-request request))
|
(prepare-context-from-request request))
|
||||||
(assoc :request-id (::rpc/request-id params))
|
|
||||||
(d/without-nils))
|
|
||||||
|
|
||||||
ip-addr (inet/parse-request request)
|
ip-addr (inet/parse-request request)
|
||||||
module (get cfg ::rpc/module)]
|
module (get cfg ::rpc/module)]
|
||||||
|
|
||||||
{:type (or (::type resultm)
|
{::type (or (::type resultm)
|
||||||
(::rpc/type cfg))
|
(::rpc/type cfg))
|
||||||
:name (or (::name resultm)
|
::name (or (::name resultm)
|
||||||
(let [sname (::sv/name mdata)]
|
(let [sname (::sv/name mdata)]
|
||||||
(if (not= module "main")
|
(if (not= module "main")
|
||||||
(str module "-" sname)
|
(str module "-" sname)
|
||||||
sname)))
|
sname)))
|
||||||
|
|
||||||
:profile-id profile-id
|
::profile-id profile-id
|
||||||
:ip-addr ip-addr
|
::ip-addr ip-addr
|
||||||
:props props
|
::props props
|
||||||
:context context
|
::context context
|
||||||
|
|
||||||
:created-at (::rpc/request-at params)
|
|
||||||
:tracked-at (::rpc/request-at params)
|
|
||||||
|
|
||||||
;; NOTE: for batch-key lookup we need the params as-is
|
;; NOTE: for batch-key lookup we need the params as-is
|
||||||
;; because the rpc api does not need to know the
|
;; because the rpc api does not need to know the
|
||||||
@ -386,49 +190,148 @@
|
|||||||
(::webhooks/event? resultm)
|
(::webhooks/event? resultm)
|
||||||
false)}))
|
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
|
(defn event-from-rpc-params
|
||||||
"Create a base event skeleton with pre-filled some important
|
"Create a base event skeleton with pre-filled some important
|
||||||
data that can be extracted from RPC params object"
|
data that can be extracted from RPC params object"
|
||||||
[params]
|
[params]
|
||||||
(let [context (some-> params meta ::http/request prepare-context-from-request)
|
(let [context (some-> params meta ::http/request prepare-context-from-request)
|
||||||
context (assoc context :request-id (::rpc/request-id params))
|
event {::type "action"
|
||||||
request-at (::rpc/request-at params)]
|
::profile-id (or (::rpc/profile-id params) uuid/zero)
|
||||||
{:type "action"
|
::ip-addr (::rpc/ip-addr params)}]
|
||||||
:profile-id (::rpc/profile-id params)
|
(cond-> event
|
||||||
:created-at request-at
|
(some? context)
|
||||||
:tracked-at request-at
|
(assoc ::context context))))
|
||||||
:ip-addr (::rpc/ip-addr params)
|
|
||||||
:context (d/without-nils context)}))
|
|
||||||
|
|
||||||
(defn submit
|
(defn- event->params
|
||||||
"Submit an event to be registered under audit-log subsystem"
|
[event]
|
||||||
|
(let [params {:id (uuid/next)
|
||||||
|
:name (::name event)
|
||||||
|
:type (::type event)
|
||||||
|
:profile-id (::profile-id event)
|
||||||
|
:ip-addr (::ip-addr event)
|
||||||
|
:context (::context event {})
|
||||||
|
:props (::props event {})
|
||||||
|
:source "backend"}
|
||||||
|
tnow (::tracked-at event)]
|
||||||
|
|
||||||
|
(cond-> params
|
||||||
|
(some? tnow)
|
||||||
|
(assoc :tracked-at tnow))))
|
||||||
|
|
||||||
|
(defn- append-audit-entry
|
||||||
|
[cfg params]
|
||||||
|
(let [params (-> params
|
||||||
|
(update :props db/tjson)
|
||||||
|
(update :context db/tjson)
|
||||||
|
(update :ip-addr db/inet))]
|
||||||
|
(db/insert! cfg :audit-log params)))
|
||||||
|
|
||||||
|
(defn- handle-event!
|
||||||
[cfg event]
|
[cfg event]
|
||||||
(let [tnow (ct/now)
|
(let [tnow (ct/now)
|
||||||
event (-> event
|
params (-> (event->params event)
|
||||||
(assoc :created-at tnow)
|
(assoc :created-at tnow)
|
||||||
(update :profile-id d/nilv uuid/zero)
|
(update :tracked-at #(or % tnow)))]
|
||||||
(update :tracked-at d/nilv tnow)
|
|
||||||
(update :ip-addr d/nilv "0.0.0.0")
|
|
||||||
(update :props d/nilv {})
|
|
||||||
(update :context d/nilv {})
|
|
||||||
(assoc :source "backend")
|
|
||||||
(d/without-nils))]
|
|
||||||
(submit* cfg event)))
|
|
||||||
|
|
||||||
(defn insert
|
(when (contains? cf/flags :audit-log-logger)
|
||||||
|
(l/log! ::l/logger "app.audit"
|
||||||
|
::l/level :info
|
||||||
|
:profile-id (str (::profile-id event))
|
||||||
|
:ip-addr (str (::ip-addr event))
|
||||||
|
:type (::type event)
|
||||||
|
:name (::name event)
|
||||||
|
:props (json/encode (::props event) :key-fn json/write-camel-key)
|
||||||
|
:context (json/encode (::context event) :key-fn json/write-camel-key)))
|
||||||
|
|
||||||
|
(when (contains? cf/flags :audit-log)
|
||||||
|
;; NOTE: this operation may cause primary key conflicts on inserts
|
||||||
|
;; because of the timestamp precission (two concurrent requests), in
|
||||||
|
;; this case we just retry the operation.
|
||||||
|
(append-audit-entry cfg params))
|
||||||
|
|
||||||
|
(when (and (or (contains? cf/flags :telemetry)
|
||||||
|
(cf/get :telemetry-enabled))
|
||||||
|
(not (contains? cf/flags :audit-log)))
|
||||||
|
;; NOTE: this operation may cause primary key conflicts on inserts
|
||||||
|
;; because of the timestamp precission (two concurrent requests), in
|
||||||
|
;; this case we just retry the operation.
|
||||||
|
;;
|
||||||
|
;; NOTE: this is only executed when general audit log is disabled
|
||||||
|
(let [params (-> params
|
||||||
|
(assoc :props {})
|
||||||
|
(assoc :context {}))]
|
||||||
|
(append-audit-entry cfg params)))
|
||||||
|
|
||||||
|
(when (and (contains? cf/flags :webhooks)
|
||||||
|
(::webhooks/event? event))
|
||||||
|
(let [batch-key (::webhooks/batch-key event)
|
||||||
|
batch-timeout (::webhooks/batch-timeout event)
|
||||||
|
label (dm/str "rpc:" (:name params))
|
||||||
|
label (cond
|
||||||
|
(ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event)))
|
||||||
|
(string? batch-key) (dm/str label ":" batch-key)
|
||||||
|
:else label)
|
||||||
|
dedupe? (boolean (and batch-key batch-timeout))]
|
||||||
|
|
||||||
|
(wrk/submit! (-> cfg
|
||||||
|
(assoc ::wrk/task :process-webhook-event)
|
||||||
|
(assoc ::wrk/queue :webhooks)
|
||||||
|
(assoc ::wrk/max-retries 0)
|
||||||
|
(assoc ::wrk/delay (or batch-timeout 0))
|
||||||
|
(assoc ::wrk/dedupe dedupe?)
|
||||||
|
(assoc ::wrk/label label)
|
||||||
|
(assoc ::wrk/params (-> params
|
||||||
|
(dissoc :source)
|
||||||
|
(dissoc :context)
|
||||||
|
(dissoc :ip-addr)
|
||||||
|
(dissoc :type)))))))
|
||||||
|
params))
|
||||||
|
|
||||||
|
(defn submit!
|
||||||
|
"Submit audit event to the collector."
|
||||||
|
[cfg event]
|
||||||
|
(try
|
||||||
|
(let [event (-> (d/without-nils event)
|
||||||
|
(check-event))
|
||||||
|
cfg (-> cfg
|
||||||
|
(assoc ::rtry/when rtry/conflict-exception?)
|
||||||
|
(assoc ::rtry/max-retries 6)
|
||||||
|
(assoc ::rtry/label "persist-audit-log"))]
|
||||||
|
(rtry/invoke! cfg db/tx-run! handle-event! event))
|
||||||
|
(catch Throwable cause
|
||||||
|
(l/error :hint "unexpected error processing event" :cause cause))))
|
||||||
|
|
||||||
|
(defn insert!
|
||||||
"Submit audit event to the collector, intended to be used only from
|
"Submit audit event to the collector, intended to be used only from
|
||||||
command line helpers because this skips all webhooks and telemetry
|
command line helpers because this skips all webhooks and telemetry
|
||||||
logic."
|
logic."
|
||||||
[cfg event]
|
[cfg event]
|
||||||
(when (contains? cf/flags :audit-log)
|
(when (contains? cf/flags :audit-log)
|
||||||
(let [tnow (ct/now)
|
(let [event (-> (d/without-nils event)
|
||||||
event (-> event
|
|
||||||
(assoc :created-at tnow)
|
|
||||||
(update :tracked-at d/nilv tnow)
|
|
||||||
(update :profile-id d/nilv uuid/zero)
|
|
||||||
(update :props d/nilv {})
|
|
||||||
(update :context d/nilv {})
|
|
||||||
(assoc :source "backend")
|
|
||||||
(select-keys event-keys)
|
|
||||||
(check-event))]
|
(check-event))]
|
||||||
(db/run! cfg append-audit-entry event))))
|
(db/run! cfg (fn [cfg]
|
||||||
|
(let [tnow (ct/now)
|
||||||
|
params (-> (event->params event)
|
||||||
|
(assoc :created-at tnow)
|
||||||
|
(update :tracked-at #(or % tnow)))]
|
||||||
|
(append-audit-entry cfg params)))))))
|
||||||
|
|||||||
@ -10,11 +10,14 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.transit :as t]
|
[app.common.transit :as t]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
|
[app.tokens :as tokens]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
|
[lambdaisland.uri :as u]
|
||||||
[promesa.exec :as px]))
|
[promesa.exec :as px]))
|
||||||
|
|
||||||
;; This is a task responsible to send the accumulated events to
|
;; This is a task responsible to send the accumulated events to
|
||||||
@ -49,18 +52,19 @@
|
|||||||
|
|
||||||
(defn- send!
|
(defn- send!
|
||||||
[{:keys [::uri] :as cfg} events]
|
[{: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})
|
body (t/encode {:events events})
|
||||||
headers {"content-type" "application/transit+json"
|
headers {"content-type" "application/transit+json"
|
||||||
"origin" (str (cf/get :public-uri))
|
"origin" (str (cf/get :public-uri))
|
||||||
"x-shared-key" (str "nexus " skey)}
|
"cookie" (u/map->query-string {:auth-token token})}
|
||||||
params {:uri uri
|
params {:uri uri
|
||||||
:timeout 12000
|
:timeout 12000
|
||||||
:method :post
|
:method :post
|
||||||
:headers headers
|
:headers headers
|
||||||
:body body}
|
:body body}
|
||||||
resp (http/req cfg params {:skip-ssrf-check? true})]
|
resp (http/req! cfg params)]
|
||||||
|
|
||||||
(if (= (:status resp) 204)
|
(if (= (:status resp) 204)
|
||||||
true
|
true
|
||||||
(do
|
(do
|
||||||
@ -81,7 +85,7 @@
|
|||||||
(def ^:private sql:get-audit-log-chunk
|
(def ^:private sql:get-audit-log-chunk
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM audit_log
|
FROM audit_log
|
||||||
WHERE archived_at IS NULL
|
WHERE archived_at is null
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 128
|
LIMIT 128
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@ -105,7 +109,7 @@
|
|||||||
(def ^:private schema:handler-params
|
(def ^:private schema:handler-params
|
||||||
[:map
|
[:map
|
||||||
::db/pool
|
::db/pool
|
||||||
::setup/shared-keys
|
::setup/props
|
||||||
::http/client])
|
::http/client])
|
||||||
|
|
||||||
(defmethod ig/assert-key ::handler
|
(defmethod ig/assert-key ::handler
|
||||||
|
|||||||
@ -50,9 +50,9 @@
|
|||||||
(ex-data cause))
|
(ex-data cause))
|
||||||
|
|
||||||
ctx (-> context
|
ctx (-> context
|
||||||
(assoc :backend/tenant (cf/get :tenant))
|
(assoc :service/tenant (cf/get :tenant))
|
||||||
(assoc :backend/host (cf/get :host))
|
(assoc :service/host (cf/get :host))
|
||||||
(assoc :backend/public-uri (str (cf/get :public-uri)))
|
(assoc :service/public-uri (str (cf/get :public-uri)))
|
||||||
(assoc :backend/version (:full cf/version))
|
(assoc :backend/version (:full cf/version))
|
||||||
(assoc :logger/name logger)
|
(assoc :logger/name logger)
|
||||||
(assoc :logger/level level)
|
(assoc :logger/level level)
|
||||||
@ -97,7 +97,7 @@
|
|||||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||||
|
|
||||||
(defn- audit-event->report
|
(defn- audit-event->report
|
||||||
[{:keys [context props ip-addr] :as record}]
|
[{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}]
|
||||||
(let [context
|
(let [context
|
||||||
(reduce-kv (fn [context k v]
|
(reduce-kv (fn [context k v]
|
||||||
(let [k' (keyword "frontend" (name k))]
|
(let [k' (keyword "frontend" (name k))]
|
||||||
@ -117,14 +117,14 @@
|
|||||||
|
|
||||||
{:context (-> (into (sorted-map) context)
|
{:context (-> (into (sorted-map) context)
|
||||||
(pp/pprint-str :length 50))
|
(pp/pprint-str :length 50))
|
||||||
:origin (:name record)
|
:origin (::audit/name record)
|
||||||
:href (get props :href)
|
:href (get props :href)
|
||||||
:hint (get props :hint)
|
:hint (get props :hint)
|
||||||
:report (get props :report)}))
|
:report (get props :report)}))
|
||||||
|
|
||||||
(defn- handle-audit-event
|
(defn- handle-audit-event
|
||||||
"Convert the log record into a report object and persist it on the database"
|
"Convert the log record into a report object and persist it on the database"
|
||||||
[{:keys [::db/pool]} {:keys [id] :as event}]
|
[{:keys [::db/pool]} {:keys [::audit/id] :as event}]
|
||||||
(try
|
(try
|
||||||
(let [uri (cf/get :public-uri)
|
(let [uri (cf/get :public-uri)
|
||||||
report (-> event audit-event->report d/without-nils)]
|
report (-> event audit-event->report d/without-nils)]
|
||||||
@ -189,12 +189,12 @@
|
|||||||
(::l/id item)
|
(::l/id item)
|
||||||
(handle-log-record cfg item)
|
(handle-log-record cfg item)
|
||||||
|
|
||||||
|
(::audit/id item)
|
||||||
|
(handle-audit-event cfg item)
|
||||||
|
|
||||||
(::rlimit/id item)
|
(::rlimit/id item)
|
||||||
(handle-rlimit-event cfg item)
|
(handle-rlimit-event cfg item)
|
||||||
|
|
||||||
(-> item meta ::audit/event)
|
|
||||||
(handle-audit-event cfg item)
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(l/warn :hint "received unexpected item" :item item))
|
(l/warn :hint "received unexpected item" :item item))
|
||||||
|
|
||||||
@ -226,3 +226,4 @@
|
|||||||
[cfg event]
|
[cfg event]
|
||||||
(when-let [{:keys [::input]} (get cfg ::reporter)]
|
(when-let [{:keys [::input]} (get cfg ::reporter)]
|
||||||
(sp/put! input event)))
|
(sp/put! input event)))
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
trace
|
trace
|
||||||
"```")))
|
"```")))
|
||||||
|
|
||||||
resp (http/req cfg
|
resp (http/req! cfg
|
||||||
{:uri (cf/get :error-report-webhook)
|
{:uri (cf/get :error-report-webhook)
|
||||||
:method :post
|
:method :post
|
||||||
:headers {"content-type" "application/json"}
|
:headers {"content-type" "application/json"}
|
||||||
@ -83,7 +83,7 @@
|
|||||||
:trace (ex/format-throwable cause :detail? false :header? false)}))
|
:trace (ex/format-throwable cause :detail? false :header? false)}))
|
||||||
|
|
||||||
(defn- audit-event->report
|
(defn- audit-event->report
|
||||||
[{:keys [context props id] :as event}]
|
[{:keys [::audit/context ::audit/props ::audit/id] :as event}]
|
||||||
{:id id
|
{:id id
|
||||||
:type "exception"
|
:type "exception"
|
||||||
:origin "audit-log"
|
:origin "audit-log"
|
||||||
@ -92,7 +92,7 @@
|
|||||||
:host (cf/get :host)
|
:host (cf/get :host)
|
||||||
:backend-version (:full cf/version)
|
:backend-version (:full cf/version)
|
||||||
:frontend-version (:version context)
|
:frontend-version (:version context)
|
||||||
:profile-id (:profile-id event)
|
:profile-id (:audit/profile-id event)
|
||||||
:href (get props :href)})
|
:href (get props :href)})
|
||||||
|
|
||||||
(defn- rlimit-event->report
|
(defn- rlimit-event->report
|
||||||
@ -148,12 +148,12 @@
|
|||||||
(::l/id item)
|
(::l/id item)
|
||||||
(handle-event cfg item log-record->report)
|
(handle-event cfg item log-record->report)
|
||||||
|
|
||||||
|
(::audit/id item)
|
||||||
|
(handle-event cfg item audit-event->report)
|
||||||
|
|
||||||
(::rlimit/id item)
|
(::rlimit/id item)
|
||||||
(handle-event cfg item rlimit-event->report)
|
(handle-event cfg item rlimit-event->report)
|
||||||
|
|
||||||
(-> item meta ::audit/event)
|
|
||||||
(handle-event cfg item audit-event->report)
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(l/warn :hint "received unexpected item" :item item)))
|
(l/warn :hint "received unexpected item" :item item)))
|
||||||
|
|
||||||
|
|||||||
@ -70,14 +70,14 @@
|
|||||||
(fn [{:keys [props] :as task}]
|
(fn [{:keys [props] :as task}]
|
||||||
|
|
||||||
(let [items (lookup-webhooks cfg props)
|
(let [items (lookup-webhooks cfg props)
|
||||||
event {:profile-id (:profile-id props)
|
event {::audit/profile-id (:profile-id props)
|
||||||
:name "webhook"
|
::audit/name "webhook"
|
||||||
:type "trigger"
|
::audit/type "trigger"
|
||||||
:props {:name (get props :name)
|
::audit/props {:name (get props :name)
|
||||||
:event-id (get props :id)
|
:event-id (get props :id)
|
||||||
:total-affected (count items)}}]
|
:total-affected (count items)}}]
|
||||||
|
|
||||||
(audit/insert cfg event)
|
(audit/insert! cfg event)
|
||||||
|
|
||||||
(when items
|
(when items
|
||||||
(l/trc :hint "webhooks found for event" :total (count items))
|
(l/trc :hint "webhooks found for event" :total (count items))
|
||||||
@ -159,7 +159,7 @@
|
|||||||
:method :post
|
:method :post
|
||||||
:body body}]
|
:body body}]
|
||||||
(try
|
(try
|
||||||
(let [rsp (http/req cfg req {:response-type :input-stream :sync? true})
|
(let [rsp (http/req! cfg req {:response-type :input-stream :sync? true})
|
||||||
err (interpret-response rsp)]
|
err (interpret-response rsp)]
|
||||||
(report-delivery! whook req rsp err)
|
(report-delivery! whook req rsp err)
|
||||||
(update-webhook! whook err))
|
(update-webhook! whook err))
|
||||||
@ -190,11 +190,4 @@
|
|||||||
"invalid-uri"
|
"invalid-uri"
|
||||||
|
|
||||||
(instance? java.net.http.HttpConnectTimeoutException cause)
|
(instance? java.net.http.HttpConnectTimeoutException cause)
|
||||||
"timeout"
|
"timeout"))
|
||||||
|
|
||||||
:else
|
|
||||||
(let [data (ex-data cause)]
|
|
||||||
(if (and (= :validation (:type data))
|
|
||||||
(= :ssrf-blocked-target (:code data)))
|
|
||||||
(str "blocked-request:" (:hint data))
|
|
||||||
nil))))
|
|
||||||
|
|||||||
@ -306,9 +306,8 @@
|
|||||||
:app.http.assets/routes
|
:app.http.assets/routes
|
||||||
{::http.assets/path (cf/get :assets-path)
|
{::http.assets/path (cf/get :assets-path)
|
||||||
::http.assets/cache-max-age (ct/duration {:hours 24})
|
::http.assets/cache-max-age (ct/duration {:hours 24})
|
||||||
::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15})
|
::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5})
|
||||||
::sto/storage (ig/ref ::sto/storage)
|
::sto/storage (ig/ref ::sto/storage)}
|
||||||
::session/manager (ig/ref ::session/manager)}
|
|
||||||
|
|
||||||
::rpc/climit
|
::rpc/climit
|
||||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||||
@ -389,7 +388,6 @@
|
|||||||
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
|
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
|
||||||
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
||||||
:telemetry (ig/ref :app.tasks.telemetry/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-deleted (ig/ref ::sto.gc-deleted/handler)
|
||||||
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
|
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
|
||||||
:session-gc (ig/ref ::session.tasks/gc)
|
:session-gc (ig/ref ::session.tasks/gc)
|
||||||
@ -425,9 +423,6 @@
|
|||||||
:app.tasks.tasks-gc/handler
|
:app.tasks.tasks-gc/handler
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
|
|
||||||
:app.tasks.upload-session-gc/handler
|
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
|
||||||
|
|
||||||
:app.tasks.objects-gc/handler
|
:app.tasks.objects-gc/handler
|
||||||
{::db/pool (ig/ref ::db/pool)
|
{::db/pool (ig/ref ::db/pool)
|
||||||
::sto/storage (ig/ref ::sto/storage)}
|
::sto/storage (ig/ref ::sto/storage)}
|
||||||
@ -471,7 +466,6 @@
|
|||||||
|
|
||||||
::setup/shared-keys
|
::setup/shared-keys
|
||||||
{::setup/props (ig/ref ::setup/props)
|
{::setup/props (ig/ref ::setup/props)
|
||||||
:nexus (cf/get :nexus-shared-key)
|
|
||||||
:nitrate (cf/get :nitrate-shared-key)
|
:nitrate (cf/get :nitrate-shared-key)
|
||||||
:exporter (cf/get :exporter-shared-key)}
|
:exporter (cf/get :exporter-shared-key)}
|
||||||
|
|
||||||
@ -479,9 +473,9 @@
|
|||||||
{}
|
{}
|
||||||
|
|
||||||
:app.loggers.audit.archive-task/handler
|
:app.loggers.audit.archive-task/handler
|
||||||
{::setup/shared-keys (ig/ref ::setup/shared-keys)
|
{::setup/props (ig/ref ::setup/props)
|
||||||
::http.client/client (ig/ref ::http.client/client)
|
::db/pool (ig/ref ::db/pool)
|
||||||
::db/pool (ig/ref ::db/pool)}
|
::http.client/client (ig/ref ::http.client/client)}
|
||||||
|
|
||||||
:app.loggers.audit.gc-task/handler
|
:app.loggers.audit.gc-task/handler
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
@ -549,9 +543,6 @@
|
|||||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
||||||
:task :tasks-gc}
|
:task :tasks-gc}
|
||||||
|
|
||||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
|
||||||
:task :upload-session-gc}
|
|
||||||
|
|
||||||
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
|
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
|
||||||
:task :file-gc-scheduler}
|
:task :file-gc-scheduler}
|
||||||
|
|
||||||
@ -659,8 +650,9 @@
|
|||||||
[& _args]
|
[& _args]
|
||||||
(try
|
(try
|
||||||
(let [p (promise)]
|
(let [p (promise)]
|
||||||
|
(when (contains? cf/flags :nrepl-server)
|
||||||
(l/inf :hint "start nrepl server" :port 6064)
|
(l/inf :hint "start nrepl server" :port 6064)
|
||||||
(nrepl/start-server :bind "0.0.0.0" :port 6064)
|
(nrepl/start-server :bind "0.0.0.0" :port 6064))
|
||||||
|
|
||||||
(start)
|
(start)
|
||||||
(deref p))
|
(deref p))
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as-alias db]
|
[app.db :as-alias db]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
[app.media.sanitize :as sanitize]
|
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
[buddy.core.bytes :as bb]
|
[buddy.core.bytes :as bb]
|
||||||
@ -32,8 +31,8 @@
|
|||||||
(:import
|
(:import
|
||||||
clojure.lang.XMLHandler
|
clojure.lang.XMLHandler
|
||||||
java.io.InputStream
|
java.io.InputStream
|
||||||
javax.xml.parsers.SAXParserFactory
|
|
||||||
javax.xml.XMLConstants
|
javax.xml.XMLConstants
|
||||||
|
javax.xml.parsers.SAXParserFactory
|
||||||
org.apache.commons.io.IOUtils
|
org.apache.commons.io.IOUtils
|
||||||
org.im4java.core.ConvertCmd
|
org.im4java.core.ConvertCmd
|
||||||
org.im4java.core.IMOperation))
|
org.im4java.core.IMOperation))
|
||||||
@ -55,7 +54,7 @@
|
|||||||
[:path ::fs/path]
|
[:path ::fs/path]
|
||||||
[:mtype {:optional true} ::sm/text]])
|
[:mtype {:optional true} ::sm/text]])
|
||||||
|
|
||||||
(def check-input
|
(def ^:private check-input
|
||||||
(sm/check-fn schema:input))
|
(sm/check-fn schema:input))
|
||||||
|
|
||||||
(defn validate-media-type!
|
(defn validate-media-type!
|
||||||
@ -326,11 +325,9 @@
|
|||||||
|
|
||||||
(let [{:keys [body] :as response}
|
(let [{:keys [body] :as response}
|
||||||
(try
|
(try
|
||||||
(http/req-with-redirects
|
(http/req! client
|
||||||
client
|
|
||||||
{:method :get :uri uri}
|
{:method :get :uri uri}
|
||||||
{:response-type :input-stream
|
{:response-type :input-stream})
|
||||||
:max-redirects 3})
|
|
||||||
(catch java.net.ConnectException cause
|
(catch java.net.ConnectException cause
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :unable-to-download-image
|
:code :unable-to-download-image
|
||||||
@ -361,11 +358,9 @@
|
|||||||
:code :mismatch-write-size
|
:code :mismatch-write-size
|
||||||
:hint "unexpected state: unable to write to file"))
|
:hint "unexpected state: unable to write to file"))
|
||||||
|
|
||||||
;; Sanitize: strip trailing data after image EOF markers
|
{;; :size size
|
||||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
:path path
|
||||||
{:path path
|
:mtype mtype})))
|
||||||
:mtype mtype
|
|
||||||
:size new-size}))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; FONTS
|
;; FONTS
|
||||||
@ -414,22 +409,6 @@
|
|||||||
(when (zero? (:exit res))
|
(when (zero? (:exit res))
|
||||||
(:out 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:
|
;; Documented here:
|
||||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||||
(get-sfnt-type [data]
|
(get-sfnt-type [data]
|
||||||
@ -479,27 +458,4 @@
|
|||||||
|
|
||||||
(= stype :ttf)
|
(= stype :ttf)
|
||||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||||
(assoc "font/ttf" 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))))))))
|
|
||||||
|
|||||||
@ -1,191 +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.media.sanitize
|
|
||||||
"Image EOF truncation helpers — strips trailing data after image EOF
|
|
||||||
markers to prevent exfiltration of non-image bytes appended to
|
|
||||||
valid image files."
|
|
||||||
(:require
|
|
||||||
[app.common.buffer :as buf]
|
|
||||||
[app.common.exceptions :as ex]
|
|
||||||
[app.common.logging :as l]
|
|
||||||
[app.util.nio :as nio])
|
|
||||||
(:import
|
|
||||||
java.nio.ByteOrder
|
|
||||||
java.nio.channels.FileChannel))
|
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
|
||||||
|
|
||||||
(defn- scan-backwards
|
|
||||||
"Scan byte array `arr` backwards (from the end) for the byte pattern
|
|
||||||
`marker`. Returns the index in `arr` where the marker starts, or -1
|
|
||||||
if not found."
|
|
||||||
[^bytes arr ^bytes marker]
|
|
||||||
(let [arr-len (alength arr)
|
|
||||||
marker-len (alength marker)]
|
|
||||||
(loop [i (- arr-len marker-len)]
|
|
||||||
(if (< i 0)
|
|
||||||
-1
|
|
||||||
(if (loop [j 0]
|
|
||||||
(if (>= j marker-len)
|
|
||||||
true
|
|
||||||
(if (= (aget arr (+ i j)) (aget marker j))
|
|
||||||
(recur (inc j))
|
|
||||||
false)))
|
|
||||||
i
|
|
||||||
(recur (dec i)))))))
|
|
||||||
|
|
||||||
(defn- find-last-png-iend
|
|
||||||
"Find the byte offset of the end of the PNG IEND chunk (12 bytes:
|
|
||||||
4-byte length + 4-byte 'IEND' + 4-byte CRC32). Returns the offset
|
|
||||||
AFTER the CRC32, or nil if not found."
|
|
||||||
[^FileChannel channel]
|
|
||||||
(let [size (nio/channel-size channel)]
|
|
||||||
(when (> size 8)
|
|
||||||
(let [buf-size (min (int size) (* 1024 1024))
|
|
||||||
marker (byte-array [0x49 0x45 0x4E 0x44])] ;; "IEND"
|
|
||||||
(loop [pos (max 0 (- size buf-size))]
|
|
||||||
(when (< pos size)
|
|
||||||
(let [arr (nio/read-at channel pos buf-size)
|
|
||||||
idx (scan-backwards arr marker)]
|
|
||||||
(if (neg? idx)
|
|
||||||
;; Not found in this chunk, try earlier
|
|
||||||
(let [next-pos (max 0 (- pos (- buf-size 4)))]
|
|
||||||
(when (< next-pos pos)
|
|
||||||
(recur next-pos)))
|
|
||||||
;; Found "IEND" at idx. Chunk starts 4 bytes before.
|
|
||||||
(let [chunk-start (- (+ pos idx) 4)]
|
|
||||||
(when (>= chunk-start 0)
|
|
||||||
;; PNG chunk length is big-endian (network byte order).
|
|
||||||
;; buf/wrap defaults to little-endian, so set it to big-endian.
|
|
||||||
(let [len-arr (nio/read-at channel chunk-start 4)
|
|
||||||
len-buf (buf/set-order (buf/wrap len-arr) ByteOrder/BIG_ENDIAN)
|
|
||||||
chunk-len (buf/read-int len-buf 0)]
|
|
||||||
(when (zero? chunk-len)
|
|
||||||
(+ chunk-start 12)))))))))))))
|
|
||||||
|
|
||||||
(defn- find-last-jpeg-eoi
|
|
||||||
"Find the byte offset of the last JPEG EOI marker (0xFF 0xD9).
|
|
||||||
Returns the offset AFTER the marker, or nil if not found."
|
|
||||||
[^FileChannel channel]
|
|
||||||
(let [size (nio/channel-size channel)]
|
|
||||||
(when (> size 2)
|
|
||||||
(let [buf-size (min (int size) (* 1024 1024))
|
|
||||||
marker (byte-array [(unchecked-byte 0xFF) (unchecked-byte 0xD9)])]
|
|
||||||
(loop [pos (max 0 (- size buf-size))]
|
|
||||||
(when (< pos size)
|
|
||||||
(let [arr (nio/read-at channel pos buf-size)
|
|
||||||
idx (scan-backwards arr marker)]
|
|
||||||
(if (neg? idx)
|
|
||||||
(let [next-pos (max 0 (- pos (- buf-size 2)))]
|
|
||||||
(when (< next-pos pos)
|
|
||||||
(recur next-pos)))
|
|
||||||
(+ pos idx 2)))))))))
|
|
||||||
|
|
||||||
(defn- find-last-gif-trailer
|
|
||||||
"Find the byte offset immediately after the last GIF trailer byte (0x3B).
|
|
||||||
Scans backwards through the file so that appended data after the real
|
|
||||||
trailer is truncated even when it ends with 0x3B.
|
|
||||||
Returns the offset AFTER the trailer byte, or nil if 0x3B is not found."
|
|
||||||
[^FileChannel channel]
|
|
||||||
(let [size (nio/channel-size channel)]
|
|
||||||
(when (pos? size)
|
|
||||||
(let [buf-size (min (int size) (* 1024 1024))
|
|
||||||
marker (byte-array [(unchecked-byte 0x3B)])]
|
|
||||||
(loop [pos (max 0 (- size buf-size))]
|
|
||||||
(when (< pos size)
|
|
||||||
(let [arr (nio/read-at channel pos buf-size)
|
|
||||||
idx (scan-backwards arr marker)]
|
|
||||||
(if (neg? idx)
|
|
||||||
(let [next-pos (max 0 (- pos (- buf-size 1)))]
|
|
||||||
(when (< next-pos pos)
|
|
||||||
(recur next-pos)))
|
|
||||||
(+ pos idx 1)))))))))
|
|
||||||
|
|
||||||
(defn- find-webp-end
|
|
||||||
"Parse the WebP RIFF header to find the declared file size.
|
|
||||||
WebP format: 'RIFF' (4 bytes) + uint32 total-size (4 bytes, little-endian)
|
|
||||||
+ 'WEBP' (4 bytes). The total size is the offset of the end of the file.
|
|
||||||
Returns nil if the RIFF or WEBP magic bytes are missing."
|
|
||||||
[^FileChannel channel]
|
|
||||||
(let [size (nio/channel-size channel)]
|
|
||||||
(when (>= size 12)
|
|
||||||
(let [^bytes arr (nio/read-at channel 0 12)
|
|
||||||
buf (buf/wrap arr)]
|
|
||||||
;; Check RIFF magic (bytes 0-3) AND WEBP FourCC (bytes 8-11)
|
|
||||||
(when (and (= (aget arr 0) (byte 0x52)) ;; 'R'
|
|
||||||
(= (aget arr 1) (byte 0x49)) ;; 'I'
|
|
||||||
(= (aget arr 2) (byte 0x46)) ;; 'F'
|
|
||||||
(= (aget arr 3) (byte 0x46)) ;; 'F'
|
|
||||||
(= (aget arr 8) (byte 0x57)) ;; 'W'
|
|
||||||
(= (aget arr 9) (byte 0x45)) ;; 'E'
|
|
||||||
(= (aget arr 10) (byte 0x42)) ;; 'B'
|
|
||||||
(= (aget arr 11) (byte 0x50))) ;; 'P'
|
|
||||||
(let [riff-size (bit-and (buf/read-int buf 4) 0xFFFFFFFF)]
|
|
||||||
;; RIFF size field is the size of the file minus 8 bytes
|
|
||||||
(+ riff-size 8)))))))
|
|
||||||
|
|
||||||
(defn truncate-after-eof
|
|
||||||
"Given a `java.nio.file.Path` to a freshly-downloaded media file and a
|
|
||||||
declared MIME type, truncate the file in place to the position of the
|
|
||||||
format's EOF marker:
|
|
||||||
- image/png → end of the IEND chunk (12 bytes: 4-byte length + 4-byte type + 4-byte CRC32)
|
|
||||||
- image/jpeg → 2 bytes after FFD9
|
|
||||||
- image/gif → immediately after the last GIF trailer byte 0x3B
|
|
||||||
- image/webp → end of RIFF chunk declared in bytes 4..8
|
|
||||||
- image/svg+xml → no-op (text format; processed by SAX parser)
|
|
||||||
- other → no-op (return path unchanged)
|
|
||||||
Returns the new file size. Raises `:validation/:invalid-image` if no
|
|
||||||
EOF marker is found within the file."
|
|
||||||
[^java.nio.file.Path path ^String mtype]
|
|
||||||
(try
|
|
||||||
(with-open [channel (nio/open-channel path)]
|
|
||||||
(let [size (nio/channel-size channel)]
|
|
||||||
(if (zero? size)
|
|
||||||
0
|
|
||||||
(let [needs-eof-marker? (or (= mtype "image/png")
|
|
||||||
(= mtype "image/jpeg")
|
|
||||||
(= mtype "image/gif")
|
|
||||||
(= mtype "image/webp"))
|
|
||||||
|
|
||||||
eof-offset
|
|
||||||
(cond
|
|
||||||
(= mtype "image/png") (find-last-png-iend channel)
|
|
||||||
(= mtype "image/jpeg") (find-last-jpeg-eoi channel)
|
|
||||||
(= mtype "image/gif") (find-last-gif-trailer channel)
|
|
||||||
(= mtype "image/webp") (find-webp-end channel)
|
|
||||||
:else nil)]
|
|
||||||
|
|
||||||
(cond
|
|
||||||
;; No EOF marker applicable (SVG or other) — no-op
|
|
||||||
(nil? eof-offset)
|
|
||||||
(if needs-eof-marker?
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint "image format EOF marker not found")
|
|
||||||
size)
|
|
||||||
|
|
||||||
;; Truncate if needed
|
|
||||||
(< eof-offset size)
|
|
||||||
(do
|
|
||||||
(l/dbg :hint "truncating trailing data"
|
|
||||||
:path (str path)
|
|
||||||
:mtype mtype
|
|
||||||
:original-size size
|
|
||||||
:truncated-to eof-offset)
|
|
||||||
(nio/truncate channel eof-offset)
|
|
||||||
eof-offset)
|
|
||||||
|
|
||||||
;; Already at correct size or marker at end
|
|
||||||
:else
|
|
||||||
eof-offset)))))
|
|
||||||
(catch Exception e
|
|
||||||
(if (ex/exception? e)
|
|
||||||
(throw e)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint "failed to sanitize image"
|
|
||||||
:cause e)))))
|
|
||||||
@ -15,16 +15,16 @@
|
|||||||
io.prometheus.client.CollectorRegistry
|
io.prometheus.client.CollectorRegistry
|
||||||
io.prometheus.client.Counter
|
io.prometheus.client.Counter
|
||||||
io.prometheus.client.Counter$Child
|
io.prometheus.client.Counter$Child
|
||||||
io.prometheus.client.exporter.common.TextFormat
|
|
||||||
io.prometheus.client.Gauge
|
io.prometheus.client.Gauge
|
||||||
io.prometheus.client.Gauge$Child
|
io.prometheus.client.Gauge$Child
|
||||||
io.prometheus.client.Histogram
|
io.prometheus.client.Histogram
|
||||||
io.prometheus.client.Histogram$Child
|
io.prometheus.client.Histogram$Child
|
||||||
io.prometheus.client.hotspot.DefaultExports
|
|
||||||
io.prometheus.client.SimpleCollector
|
io.prometheus.client.SimpleCollector
|
||||||
io.prometheus.client.Summary
|
io.prometheus.client.Summary
|
||||||
io.prometheus.client.Summary$Builder
|
io.prometheus.client.Summary$Builder
|
||||||
io.prometheus.client.Summary$Child
|
io.prometheus.client.Summary$Child
|
||||||
|
io.prometheus.client.exporter.common.TextFormat
|
||||||
|
io.prometheus.client.hotspot.DefaultExports
|
||||||
java.io.StringWriter))
|
java.io.StringWriter))
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
(set! *warn-on-reflection* true)
|
||||||
|
|||||||
@ -463,25 +463,8 @@
|
|||||||
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
|
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
|
||||||
|
|
||||||
{:name "0145-fix-plugins-uri-on-profile"
|
{:name "0145-fix-plugins-uri-on-profile"
|
||||||
:fn mg0145/migrate}
|
:fn mg0145/migrate}])
|
||||||
|
|
||||||
{:name "0145-mod-audit-log-table"
|
|
||||||
:fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")}
|
|
||||||
|
|
||||||
{:name "0146-mod-audit-log-table"
|
|
||||||
:fn (mg/resource "app/migrations/sql/0146-mod-audit-log-table.sql")}
|
|
||||||
|
|
||||||
{:name "0146-mod-access-token-table"
|
|
||||||
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}
|
|
||||||
|
|
||||||
{:name "0147-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")}])
|
|
||||||
|
|
||||||
(defn apply-migrations!
|
(defn apply-migrations!
|
||||||
[pool name migrations]
|
[pool name migrations]
|
||||||
|
|||||||
@ -58,3 +58,4 @@
|
|||||||
(when (nil? (:data file))
|
(when (nil? (:data file))
|
||||||
(migrate-file conn file)))
|
(migrate-file conn file)))
|
||||||
(db/exec-one! conn ["drop table page cascade;"])))
|
(db/exec-one! conn ["drop table page cascade;"])))
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
-- Add index on audit_log (source, created_at) to support efficient
|
|
||||||
-- queries for the telemetry batch collection mode.
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS audit_log__source__created_at__idx
|
|
||||||
ON audit_log (source, created_at ASC);
|
|
||||||
@ -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;
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE access_token
|
|
||||||
ADD COLUMN type text NULL;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- Add index on audit_log (source, created_at) to support efficient
|
|
||||||
-- queries for the telemetry batch collection mode.
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS audit_log__source__created_at__idx
|
|
||||||
ON audit_log (source, created_at ASC);
|
|
||||||
@ -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);
|
|
||||||
@ -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;
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE team_font_variant
|
|
||||||
ADD COLUMN variant_name text NULL;
|
|
||||||
@ -1,24 +1,13 @@
|
|||||||
;; 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
|
(ns app.nitrate
|
||||||
"Module that make calls to the external nitrate aplication"
|
"Module that make calls to the external nitrate aplication"
|
||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
|
||||||
[app.common.exceptions :as ex]
|
|
||||||
[app.common.json :as json]
|
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[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.config :as cf]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
|
[app.util.json :as json]
|
||||||
[clojure.core :as c]
|
[clojure.core :as c]
|
||||||
[integrant.core :as ig]))
|
[integrant.core :as ig]))
|
||||||
|
|
||||||
@ -27,16 +16,16 @@
|
|||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(defn- request-builder
|
(defn- request-builder
|
||||||
[cfg method uri shared-key profile-id request-params]
|
[cfg method uri shared-key profile-id]
|
||||||
(fn []
|
(fn []
|
||||||
(http/req cfg (cond-> {:method method
|
(http/req! cfg {:method method
|
||||||
:headers {"content-type" "application/json"
|
:headers {"content-type" "application/json"
|
||||||
"accept" "application/json"
|
"accept" "application/json"
|
||||||
"x-shared-key" shared-key
|
"x-shared-key" shared-key
|
||||||
"x-profile-id" (str profile-id)}
|
"x-profile-id" (str profile-id)}
|
||||||
:uri uri
|
:uri uri
|
||||||
:version :http1.1}
|
:version :http1.1})))
|
||||||
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
|
|
||||||
|
|
||||||
(defn- with-retries
|
(defn- with-retries
|
||||||
[handler max-retries]
|
[handler max-retries]
|
||||||
@ -56,49 +45,24 @@
|
|||||||
result)))))
|
result)))))
|
||||||
|
|
||||||
|
|
||||||
(defn- with-validate [handler uri schema & {:keys [throw-on-error?]}]
|
(defn- with-validate [handler uri schema]
|
||||||
(fn []
|
(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
|
(let [coercer-http (sm/coercer schema
|
||||||
:type :validation
|
:type :validation
|
||||||
:hint (str "invalid data received calling " uri))
|
:hint (str "invalid data received calling " uri))]
|
||||||
data (-> response :body (json/decode :key-fn json/read-kebab-key))]
|
|
||||||
(try
|
(try
|
||||||
(coercer-http data)
|
(coercer-http (-> (handler) :body json/decode))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
;; TODO Error handling
|
;; TODO Error handling
|
||||||
(l/error :hint "error validating json response" :cause e)
|
(l/error :hint "error validating json response" :cause e)
|
||||||
nil)))))))
|
nil)))))
|
||||||
|
|
||||||
(defn- request-to-nitrate
|
(defn- request-to-nitrate
|
||||||
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
|
[cfg method uri schema {:keys [::rpc/profile-id] :as params}]
|
||||||
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
|
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
|
||||||
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
|
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
|
||||||
(with-retries 3)
|
(with-retries 3)
|
||||||
(with-validate uri schema :throw-on-error? throw-on-error?))]
|
(with-validate uri schema))]
|
||||||
(full-http-call)))
|
(full-http-call)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@ -114,263 +78,24 @@
|
|||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private schema:org-summary
|
(def ^:private schema:organization
|
||||||
[:map
|
[: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]
|
[:id ::sm/text]
|
||||||
[:customer-id ::sm/text]
|
[:name ::sm/text]])
|
||||||
[:type [:enum
|
|
||||||
"unlimited"
|
|
||||||
"professional"
|
|
||||||
"enterprise"
|
|
||||||
"nitrate"]]
|
|
||||||
[:status [:enum
|
|
||||||
"active"
|
|
||||||
"canceled"
|
|
||||||
"incomplete"
|
|
||||||
"incomplete_expired"
|
|
||||||
"past_due"
|
|
||||||
"paused"
|
|
||||||
"trialing"
|
|
||||||
"unpaid"]]
|
|
||||||
|
|
||||||
[:billing-period [:enum
|
(def ^:private schema:user
|
||||||
"month"
|
|
||||||
"day"
|
|
||||||
"week"
|
|
||||||
"year"]]
|
|
||||||
[:manual :boolean]
|
|
||||||
[: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
|
[:map
|
||||||
[:licenses ::sm/boolean]])
|
[:valid ::sm/boolean]])
|
||||||
|
|
||||||
(defn- get-team-org-api
|
(defn- get-team-org
|
||||||
[cfg {:keys [team-id] :as params}]
|
[cfg {:keys [team-id] :as params}]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params)))
|
||||||
(str baseuri
|
|
||||||
"/api/teams/"
|
|
||||||
team-id)
|
|
||||||
cto/schema:team-with-organization params)))
|
|
||||||
|
|
||||||
(defn- get-org-membership-api
|
(defn- is-valid-user
|
||||||
[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}]
|
[cfg {:keys [profile-id] :as params}]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
|
||||||
(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 (dm/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- get-org-permissions-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
|
|
||||||
"/permissions")
|
|
||||||
[:map
|
|
||||||
[:organization-id ::sm/uuid]
|
|
||||||
[:owner-id ::sm/uuid]
|
|
||||||
[:permissions [:map-of :keyword :string]]]
|
|
||||||
params)))
|
|
||||||
|
|
||||||
(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
|
;; INITIALIZATION
|
||||||
@ -379,21 +104,8 @@
|
|||||||
(defmethod ig/init-key ::client
|
(defmethod ig/init-key ::client
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :nitrate)
|
||||||
{:get-team-org (partial get-team-org-api cfg)
|
{:get-team-org (partial get-team-org cfg)
|
||||||
:set-team-org (partial set-team-org-api cfg)
|
:is-valid-user (partial is-valid-user 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)
|
|
||||||
:get-org-permissions (partial get-org-permissions-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
|
;; UTILS
|
||||||
@ -401,57 +113,18 @@
|
|||||||
|
|
||||||
|
|
||||||
(defn add-nitrate-licence-to-profile
|
(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]
|
[cfg profile]
|
||||||
(try
|
(try
|
||||||
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
|
(let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})]
|
||||||
(assoc profile :subscription subscription))
|
(assoc profile :nitrate-licence (:valid nitrate-licence)))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/error :hint "failed to get nitrate licence"
|
(l/error :hint "failed to get nitrate licence"
|
||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
:cause cause)
|
:cause cause)
|
||||||
profile)))
|
profile)))
|
||||||
|
|
||||||
(defn add-org-info-to-team
|
(defn add-org-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]
|
[cfg team params]
|
||||||
(try
|
|
||||||
(let [params (assoc (or params {}) :team-id (:id team))
|
(let [params (assoc (or params {}) :team-id (:id team))
|
||||||
team-with-org (call cfg :get-team-org params)
|
org (call cfg :get-team-org params)]
|
||||||
org (:organization team-with-org)]
|
(assoc team :organization-id (:id org) :organization-name (:name 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))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -24,28 +24,28 @@
|
|||||||
[integrant.core :as ig])
|
[integrant.core :as ig])
|
||||||
(:import
|
(:import
|
||||||
clojure.lang.MapEntry
|
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.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.RedisClient
|
||||||
io.lettuce.core.RedisCommandInterruptedException
|
io.lettuce.core.RedisCommandInterruptedException
|
||||||
io.lettuce.core.RedisCommandTimeoutException
|
io.lettuce.core.RedisCommandTimeoutException
|
||||||
io.lettuce.core.RedisException
|
io.lettuce.core.RedisException
|
||||||
io.lettuce.core.RedisURI
|
io.lettuce.core.RedisURI
|
||||||
io.lettuce.core.resource.ClientResources
|
|
||||||
io.lettuce.core.resource.DefaultClientResources
|
|
||||||
io.lettuce.core.ScriptOutputType
|
io.lettuce.core.ScriptOutputType
|
||||||
io.lettuce.core.SetArgs
|
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.channel.nio.NioEventLoopGroup
|
||||||
io.netty.util.concurrent.EventExecutorGroup
|
|
||||||
io.netty.util.HashedWheelTimer
|
io.netty.util.HashedWheelTimer
|
||||||
io.netty.util.Timer
|
io.netty.util.Timer
|
||||||
|
io.netty.util.concurrent.EventExecutorGroup
|
||||||
java.lang.AutoCloseable
|
java.lang.AutoCloseable
|
||||||
java.time.Duration))
|
java.time.Duration))
|
||||||
|
|
||||||
|
|||||||
@ -73,13 +73,9 @@
|
|||||||
(if (nil? result)
|
(if (nil? result)
|
||||||
204
|
204
|
||||||
200))
|
200))
|
||||||
|
headers (cond-> (::http/headers mdata {})
|
||||||
headers (::http/headers mdata {})
|
(yres/stream-body? result)
|
||||||
headers (cond-> headers
|
|
||||||
(and (yres/stream-body? result)
|
|
||||||
(not (contains? headers "content-type")))
|
|
||||||
(assoc "content-type" "application/octet-stream"))]
|
(assoc "content-type" "application/octet-stream"))]
|
||||||
|
|
||||||
{::yres/status status
|
{::yres/status status
|
||||||
::yres/headers headers
|
::yres/headers headers
|
||||||
::yres/body result}))]
|
::yres/body result}))]
|
||||||
@ -96,7 +92,6 @@
|
|||||||
(fn [{:keys [params path-params method] :as request}]
|
(fn [{:keys [params path-params method] :as request}]
|
||||||
(let [handler-name (:method-name path-params)
|
(let [handler-name (:method-name path-params)
|
||||||
etag (yreq/get-header request "if-none-match")
|
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)
|
key-id (get request ::http/auth-key-id)
|
||||||
profile-id (or (::session/profile-id request)
|
profile-id (or (::session/profile-id request)
|
||||||
@ -109,8 +104,6 @@
|
|||||||
(assoc ::handler-name handler-name)
|
(assoc ::handler-name handler-name)
|
||||||
(assoc ::ip-addr ip-addr)
|
(assoc ::ip-addr ip-addr)
|
||||||
(assoc ::request-at (ct/now))
|
(assoc ::request-at (ct/now))
|
||||||
(assoc ::request-id (uuid/next))
|
|
||||||
(assoc ::session-id (some-> session-id uuid/parse*))
|
|
||||||
(assoc ::cond/key etag)
|
(assoc ::cond/key etag)
|
||||||
(cond-> (uuid? profile-id)
|
(cond-> (uuid? profile-id)
|
||||||
(assoc ::profile-id profile-id)))
|
(assoc ::profile-id profile-id)))
|
||||||
@ -166,13 +159,12 @@
|
|||||||
(defn- wrap-audit
|
(defn- wrap-audit
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
(if (or (contains? cf/flags :webhooks)
|
(if (or (contains? cf/flags :webhooks)
|
||||||
(contains? cf/flags :audit-log)
|
(contains? cf/flags :audit-log))
|
||||||
(contains? cf/flags :telemetry))
|
|
||||||
(if-not (::audit/skip mdata)
|
(if-not (::audit/skip mdata)
|
||||||
(fn [cfg params]
|
(fn [cfg params]
|
||||||
(let [result (f cfg params)]
|
(let [result (f cfg params)]
|
||||||
(->> (audit/prepare-rpc-event cfg mdata params result)
|
(->> (audit/prepare-event cfg mdata params result)
|
||||||
(audit/submit cfg))
|
(audit/submit! cfg))
|
||||||
result))
|
result))
|
||||||
f)
|
f)
|
||||||
f))
|
f))
|
||||||
@ -266,7 +258,6 @@
|
|||||||
'app.rpc.commands.ldap
|
'app.rpc.commands.ldap
|
||||||
'app.rpc.commands.management
|
'app.rpc.commands.management
|
||||||
'app.rpc.commands.media
|
'app.rpc.commands.media
|
||||||
'app.rpc.commands.nitrate
|
|
||||||
'app.rpc.commands.profile
|
'app.rpc.commands.profile
|
||||||
'app.rpc.commands.projects
|
'app.rpc.commands.projects
|
||||||
'app.rpc.commands.search
|
'app.rpc.commands.search
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
(dissoc row :perms))
|
(dissoc row :perms))
|
||||||
|
|
||||||
(defn create-access-token
|
(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)
|
(let [token-id (uuid/next)
|
||||||
expires-at (some-> expiration (ct/in-future))
|
expires-at (some-> expiration (ct/in-future))
|
||||||
created-at (ct/now)
|
created-at (ct/now)
|
||||||
@ -36,7 +36,6 @@
|
|||||||
{:id token-id
|
{:id token-id
|
||||||
:name name
|
:name name
|
||||||
:token token
|
:token token
|
||||||
:type type
|
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:created-at created-at
|
:created-at created-at
|
||||||
:updated-at created-at
|
:updated-at created-at
|
||||||
@ -51,18 +50,17 @@
|
|||||||
(def ^:private schema:create-access-token
|
(def ^:private schema:create-access-token
|
||||||
[:map {:title "create-access-token"}
|
[:map {:title "create-access-token"}
|
||||||
[:name [:string {:max 250 :min 1}]]
|
[:name [:string {:max 250 :min 1}]]
|
||||||
[:expiration {:optional true} ::ct/duration]
|
[:expiration {:optional true} ::ct/duration]])
|
||||||
[:type {:optional true} :string]])
|
|
||||||
|
|
||||||
(sv/defmethod ::create-access-token
|
(sv/defmethod ::create-access-token
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
::sm/params schema:create-access-token}
|
::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/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
||||||
::quotes/profile-id profile-id})
|
::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
|
(def ^:private schema:delete-access-token
|
||||||
[:map {:title "delete-access-token"}
|
[:map {:title "delete-access-token"}
|
||||||
@ -85,22 +83,5 @@
|
|||||||
(->> (db/query pool :access-token
|
(->> (db/query pool :access-token
|
||||||
{:profile-id profile-id}
|
{:profile-id profile-id}
|
||||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
{: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)))
|
(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)))
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.http :as-alias http]
|
[app.http :as-alias http]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.database :as loggers.db]
|
[app.loggers.database :as loggers.db]
|
||||||
[app.loggers.mattermost :as loggers.mm]
|
[app.loggers.mattermost :as loggers.mm]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
@ -23,8 +23,7 @@
|
|||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.rpc.helpers :as rph]
|
[app.rpc.helpers :as rph]
|
||||||
[app.util.inet :as inet]
|
[app.util.inet :as inet]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]))
|
||||||
[clojure.set :as set]))
|
|
||||||
|
|
||||||
(def ^:private event-columns
|
(def ^:private event-columns
|
||||||
[:id
|
[:id
|
||||||
@ -39,31 +38,31 @@
|
|||||||
:context])
|
:context])
|
||||||
|
|
||||||
(defn- event->row [event]
|
(defn- event->row [event]
|
||||||
[(:id event)
|
[(::audit/id event)
|
||||||
(:name event)
|
(::audit/name event)
|
||||||
(:source event)
|
(::audit/source event)
|
||||||
(:type event)
|
(::audit/type event)
|
||||||
(:tracked-at event)
|
(::audit/tracked-at event)
|
||||||
(:created-at event)
|
(::audit/created-at event)
|
||||||
(:profile-id event)
|
(::audit/profile-id event)
|
||||||
(db/inet (:ip-addr event))
|
(db/inet (::audit/ip-addr event))
|
||||||
(db/tjson (:props event))
|
(db/tjson (::audit/props event))
|
||||||
(db/tjson (d/without-nils (:context event)))])
|
(db/tjson (d/without-nils (::audit/context event)))])
|
||||||
|
|
||||||
(defn- adjust-timestamp
|
(defn- adjust-timestamp
|
||||||
[{:keys [tracked-at created-at] :as event}]
|
[{:keys [::audit/tracked-at ::audit/created-at] :as event}]
|
||||||
(let [margin (inst-ms (ct/diff tracked-at created-at))]
|
(let [margin (inst-ms (ct/diff tracked-at created-at))]
|
||||||
(if (or (neg? margin)
|
(if (or (neg? margin)
|
||||||
(> margin 3600000))
|
(> margin 3600000))
|
||||||
;; If event is in future or lags more than 1 hour, we reasign
|
;; If event is in future or lags more than 1 hour, we reasign
|
||||||
;; tracked-at to the server creation date
|
;; tracked-at to the server creation date
|
||||||
(-> event
|
(-> event
|
||||||
(assoc :tracked-at created-at)
|
(assoc ::audit/tracked-at created-at)
|
||||||
(update :context assoc :original-tracked-at tracked-at))
|
(update ::audit/context assoc :original-tracked-at tracked-at))
|
||||||
event)))
|
event)))
|
||||||
|
|
||||||
(defn- exception-event?
|
(defn- exception-event?
|
||||||
[{:keys [type name] :as ev}]
|
[{:keys [::audit/type ::audit/name] :as ev}]
|
||||||
(and (= "action" type)
|
(and (= "action" type)
|
||||||
(or (= "unhandled-exception" name)
|
(or (= "unhandled-exception" name)
|
||||||
(= "exception-page" name))))
|
(= "exception-page" name))))
|
||||||
@ -73,44 +72,28 @@
|
|||||||
(map adjust-timestamp)
|
(map adjust-timestamp)
|
||||||
(map event->row)))
|
(map event->row)))
|
||||||
|
|
||||||
(defn- prepare-events
|
(defn- get-events
|
||||||
[{:keys [::rpc/request-at ::rpc/profile-id events] :as params}]
|
[{:keys [::rpc/request-at ::rpc/profile-id events] :as params}]
|
||||||
(let [request (-> params meta ::http/request)
|
(let [request (-> params meta ::http/request)
|
||||||
ip-addr (inet/parse-request request)
|
ip-addr (inet/parse-request request)
|
||||||
xform (comp
|
|
||||||
(map (fn [event]
|
xform (map (fn [event]
|
||||||
{:id (uuid/next)
|
{::audit/id (uuid/next)
|
||||||
:type (:type event)
|
::audit/type (:type event)
|
||||||
:name (:name event)
|
::audit/name (:name event)
|
||||||
:props (:props event)
|
::audit/props (:props event)
|
||||||
:context (:context event)
|
::audit/context (:context event)
|
||||||
:profile-id profile-id
|
::audit/profile-id profile-id
|
||||||
:ip-addr ip-addr
|
::audit/ip-addr ip-addr
|
||||||
:source "frontend"
|
::audit/source "frontend"
|
||||||
:tracked-at (:timestamp event)
|
::audit/tracked-at (:timestamp event)
|
||||||
:created-at request-at}))
|
::audit/created-at request-at}))]
|
||||||
(map (fn [item]
|
|
||||||
(with-meta item {::audit/event true}))))]
|
|
||||||
|
|
||||||
(sequence xform events)))
|
(sequence xform events)))
|
||||||
|
|
||||||
(def ^:private xf:map-telemetry-event-row
|
|
||||||
(comp
|
|
||||||
(map adjust-timestamp)
|
|
||||||
(map (fn [event]
|
|
||||||
(-> event
|
|
||||||
(assoc :id (uuid/next))
|
|
||||||
(update :created-at ct/truncate :days)
|
|
||||||
(update :tracked-at ct/truncate :days)
|
|
||||||
(audit/filter-telemetry-props)
|
|
||||||
(audit/filter-telemetry-context)
|
|
||||||
(assoc :ip-addr "0.0.0.0")
|
|
||||||
(assoc :source "telemetry:frontend"))))
|
|
||||||
(map event->row)))
|
|
||||||
|
|
||||||
(defn- handle-events
|
(defn- handle-events
|
||||||
[{:keys [::db/pool] :as cfg} params]
|
[{:keys [::db/pool] :as cfg} params]
|
||||||
(let [events (prepare-events params)]
|
(let [events (get-events params)]
|
||||||
|
|
||||||
;; Look for error reports and save them on internal reports table
|
;; Look for error reports and save them on internal reports table
|
||||||
(when-let [events (->> events
|
(when-let [events (->> events
|
||||||
@ -119,18 +102,9 @@
|
|||||||
(run! (partial loggers.db/emit cfg) events)
|
(run! (partial loggers.db/emit cfg) events)
|
||||||
(run! (partial loggers.mm/emit cfg) events))
|
(run! (partial loggers.mm/emit cfg) events))
|
||||||
|
|
||||||
(when (contains? cf/flags :audit-log)
|
;; Process and save events
|
||||||
;; Process and save full audit events when audit-log flag is active
|
(when (seq events)
|
||||||
(when-let [rows (-> (sequence xf:map-event-row events)
|
(let [rows (sequence xf:map-event-row events)]
|
||||||
(not-empty))]
|
|
||||||
(db/insert-many! pool :audit-log event-columns rows)))
|
|
||||||
|
|
||||||
(when (contains? cf/flags :telemetry)
|
|
||||||
;; Store anonymized frontend events so the telemetry task can ship them
|
|
||||||
;; in batches. Runs independently from the audit-log insert above so
|
|
||||||
;; both modes can be active simultaneously.
|
|
||||||
(when-let [rows (-> (sequence xf:map-telemetry-event-row events)
|
|
||||||
(not-empty))]
|
|
||||||
(db/insert-many! pool :audit-log event-columns rows)))))
|
(db/insert-many! pool :audit-log event-columns rows)))))
|
||||||
|
|
||||||
(def ^:private valid-event-types
|
(def ^:private valid-event-types
|
||||||
@ -164,26 +138,17 @@
|
|||||||
::doc/skip true
|
::doc/skip true
|
||||||
::doc/added "1.17"}
|
::doc/added "1.17"}
|
||||||
[{:keys [::db/pool] :as cfg} params]
|
[{:keys [::db/pool] :as cfg} params]
|
||||||
(let [telemetry? (contains? cf/flags :telemetry)
|
(if (or (db/read-only? pool)
|
||||||
audit-log? (contains? cf/flags :audit-log)
|
(not (contains? cf/flags :audit-log)))
|
||||||
enabled? (and (not (db/read-only? pool))
|
(do
|
||||||
(or audit-log? telemetry?))]
|
(l/warn :hint "audit: http handler disabled or db is read-only")
|
||||||
(when enabled?
|
(rph/wrap nil))
|
||||||
|
|
||||||
|
(do
|
||||||
(try
|
(try
|
||||||
(handle-events cfg params)
|
(handle-events cfg params)
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/error :hint "unexpected error on persisting audit events from frontend"
|
(l/error :hint "unexpected error on persisting audit events from frontend"
|
||||||
:cause cause))))
|
:cause cause)))
|
||||||
|
|
||||||
(rph/wrap nil)))
|
(rph/wrap nil))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
;; GET-ENABLED-FLAGS
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(sv/defmethod ::get-enabled-flags
|
|
||||||
{::audit/skip true
|
|
||||||
::doc/skip true
|
|
||||||
::doc/added "1.20"}
|
|
||||||
[_cfg _params]
|
|
||||||
(set/intersection cf/flags #{:audit-log :telemetry}))
|
|
||||||
|
|||||||
@ -253,49 +253,27 @@
|
|||||||
:hint "email has complaint reports")))
|
:hint "email has complaint reports")))
|
||||||
|
|
||||||
(defn prepare-register
|
(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)
|
(validate-register-attempt! cfg params)
|
||||||
|
|
||||||
(let [email (profile/clean-email email)
|
(let [email (profile/clean-email email)
|
||||||
profile (profile/get-profile-by-email pool email)]
|
profile (profile/get-profile-by-email pool email)
|
||||||
|
|
||||||
;; SECURITY: refuse to issue a prepared-register token when an active
|
|
||||||
;; profile already exists for this email.
|
|
||||||
;;
|
|
||||||
;; Active accounts must use the standard login flow; existing-but-
|
|
||||||
;; not-yet-active profiles fall through to the duplicate-detection branch in
|
|
||||||
;; `register-profile`, which never creates a session.
|
|
||||||
(when (and (some? profile)
|
|
||||||
(true? (:is-active profile)))
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :email-already-exists
|
|
||||||
:hint "email already exists"))
|
|
||||||
|
|
||||||
(let [props (-> (audit/extract-utm-params params)
|
|
||||||
(cond-> (:accept-newsletter-updates params)
|
|
||||||
(assoc :newsletter-updates true)))
|
|
||||||
;; SECURITY: do NOT embed `:profile-id` of an existing
|
|
||||||
;; profile into the prepared-register JWE. Doing so would
|
|
||||||
;; let an anonymous caller, in possession of a valid
|
|
||||||
;; team-invitation JWE, ask `register-profile` to load that
|
|
||||||
;; profile by id and mint a session for it without password
|
|
||||||
;; verification. `register-profile` independently re-detects
|
|
||||||
;; duplicates by email and handles them in the
|
|
||||||
;; "repeated-registry" branch.
|
|
||||||
params {:email email
|
params {:email email
|
||||||
:fullname fullname
|
:fullname fullname
|
||||||
:password (:password params)
|
:password (:password params)
|
||||||
:invitation-token (:invitation-token params)
|
:invitation-token (:invitation-token params)
|
||||||
:backend "penpot"
|
:backend "penpot"
|
||||||
:iss :prepared-register
|
:iss :prepared-register
|
||||||
|
:profile-id (:id profile)
|
||||||
:exp (ct/in-future {:days 7})
|
:exp (ct/in-future {:days 7})
|
||||||
:props props}
|
:props {:newsletter-updates (or accept-newsletter-updates false)}}
|
||||||
|
|
||||||
params (d/without-nils params)
|
params (d/without-nils params)
|
||||||
token (tokens/generate cfg params)]
|
token (tokens/generate cfg params)]
|
||||||
|
|
||||||
(-> {:token token}
|
(with-meta {:token token}
|
||||||
(with-meta {::audit/profile-id uuid/zero})))))
|
{::audit/profile-id uuid/zero})))
|
||||||
|
|
||||||
(def schema:prepare-register-profile
|
(def schema:prepare-register-profile
|
||||||
[:map {:title "prepare-register-profile"}
|
[:map {:title "prepare-register-profile"}
|
||||||
@ -303,7 +281,6 @@
|
|||||||
[:email ::sm/email]
|
[:email ::sm/email]
|
||||||
[:password schema:password]
|
[:password schema:password]
|
||||||
[:create-welcome-file {:optional true} :boolean]
|
[:create-welcome-file {:optional true} :boolean]
|
||||||
[:accept-newsletter-updates {:optional true} :boolean]
|
|
||||||
[:invitation-token {:optional true} schema:token]])
|
[:invitation-token {:optional true} schema:token]])
|
||||||
|
|
||||||
(sv/defmethod ::prepare-register-profile
|
(sv/defmethod ::prepare-register-profile
|
||||||
@ -340,7 +317,8 @@
|
|||||||
attrs (all the other attrs are filled with default values)."
|
attrs (all the other attrs are filled with default values)."
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
|
||||||
(let [id (or (:id params) (uuid/next))
|
(let [id (or (:id params) (uuid/next))
|
||||||
props (-> (:props params)
|
props (-> (audit/extract-utm-params params)
|
||||||
|
(merge (:props params))
|
||||||
(merge {:viewed-tutorial? false
|
(merge {:viewed-tutorial? false
|
||||||
:viewed-walkthrough? false
|
:viewed-walkthrough? false
|
||||||
:nudge {:big 10 :small 1}
|
:nudge {:big 10 :small 1}
|
||||||
@ -391,12 +369,11 @@
|
|||||||
:cause cause)
|
:cause cause)
|
||||||
(throw cause))))))
|
(throw cause))))))
|
||||||
|
|
||||||
|
|
||||||
(defn create-profile-rels
|
(defn create-profile-rels
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as profile}]
|
[conn {:keys [id] :as profile}]
|
||||||
(assert (db/connection-map? cfg)
|
|
||||||
"expected cfg with valid connection")
|
|
||||||
(let [features (cfeat/get-enabled-features cf/flags)
|
(let [features (cfeat/get-enabled-features cf/flags)
|
||||||
team (teams/create-team cfg
|
team (teams/create-team conn
|
||||||
{:profile-id id
|
{:profile-id id
|
||||||
:name "Default"
|
:name "Default"
|
||||||
:features features
|
:features features
|
||||||
@ -409,19 +386,12 @@
|
|||||||
(profile/decode-row))))
|
(profile/decode-row))))
|
||||||
|
|
||||||
(defn send-email-verification!
|
(defn send-email-verification!
|
||||||
([cfg profile] (send-email-verification! cfg profile nil))
|
[{:keys [::db/conn] :as cfg} profile]
|
||||||
([{:keys [::db/conn] :as cfg} profile invitation-token]
|
(let [vtoken (tokens/generate cfg
|
||||||
(let [vclaims (cond-> {:iss :verify-email
|
{:iss :verify-email
|
||||||
:exp (ct/in-future "72h")
|
:exp (ct/in-future "72h")
|
||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
:email (:email profile)}
|
:email (:email profile)})
|
||||||
;; If the user registered through a team-invitation flow but
|
|
||||||
;; their profile is not yet active, we carry the invitation
|
|
||||||
;; token inside the verify-email JWE so the team-invitation
|
|
||||||
;; flow can resume after the user clicks the email link.
|
|
||||||
(some? invitation-token)
|
|
||||||
(assoc :invitation-token invitation-token))
|
|
||||||
vtoken (tokens/generate cfg vclaims)
|
|
||||||
;; NOTE: this token is mainly used for possible complains
|
;; NOTE: this token is mainly used for possible complains
|
||||||
;; identification on the sns webhook
|
;; identification on the sns webhook
|
||||||
ptoken (tokens/generate cfg
|
ptoken (tokens/generate cfg
|
||||||
@ -434,16 +404,21 @@
|
|||||||
:to (:email profile)
|
:to (:email profile)
|
||||||
:name (:fullname profile)
|
:name (:fullname profile)
|
||||||
:token vtoken
|
:token vtoken
|
||||||
:extra-data ptoken}))))
|
:extra-data ptoken})))
|
||||||
|
|
||||||
(defn register-profile
|
(defn register-profile
|
||||||
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
|
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
|
||||||
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
|
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
|
||||||
params (cond-> claims
|
params (into claims params)
|
||||||
(:accept-newsletter-updates params)
|
|
||||||
(update :props assoc :newsletter-updates true))
|
|
||||||
|
|
||||||
profile (or (profile/get-profile-by-email conn (:email claims))
|
profile (if-let [profile-id (:profile-id claims)]
|
||||||
|
(profile/get-profile conn profile-id)
|
||||||
|
;; NOTE: we first try to match existing profile
|
||||||
|
;; by email, that in normal circumstances will
|
||||||
|
;; not return anything, but when a user tries to
|
||||||
|
;; reuse the same token multiple times, we need
|
||||||
|
;; to detect if the profile is already registered
|
||||||
|
(or (profile/get-profile-by-email conn (:email claims))
|
||||||
(let [is-active (or (boolean (:is-active claims))
|
(let [is-active (or (boolean (:is-active claims))
|
||||||
(boolean (:email-verified claims))
|
(boolean (:email-verified claims))
|
||||||
(not (contains? cf/flags :email-verification)))
|
(not (contains? cf/flags :email-verification)))
|
||||||
@ -451,8 +426,8 @@
|
|||||||
(assoc :is-active is-active)
|
(assoc :is-active is-active)
|
||||||
(update :password auth/derive-password))
|
(update :password auth/derive-password))
|
||||||
profile (->> (create-profile cfg params)
|
profile (->> (create-profile cfg params)
|
||||||
(create-profile-rels cfg))]
|
(create-profile-rels conn))]
|
||||||
(vary-meta profile assoc :created true)))
|
(vary-meta profile assoc :created true))))
|
||||||
|
|
||||||
created? (-> profile meta :created true?)
|
created? (-> profile meta :created true?)
|
||||||
|
|
||||||
@ -468,7 +443,6 @@
|
|||||||
(when (:create-welcome-file params)
|
(when (:create-welcome-file params)
|
||||||
(let [cfg (dissoc cfg ::db/conn)]
|
(let [cfg (dissoc cfg ::db/conn)]
|
||||||
(wrk/submit! executor (create-welcome-file cfg profile)))))]
|
(wrk/submit! executor (create-welcome-file cfg profile)))))]
|
||||||
|
|
||||||
(cond
|
(cond
|
||||||
;; When profile is blocked, we just ignore it and return plain data
|
;; When profile is blocked, we just ignore it and return plain data
|
||||||
(:is-blocked profile)
|
(:is-blocked profile)
|
||||||
@ -476,52 +450,33 @@
|
|||||||
(l/wrn :hint "register attempt for already blocked profile"
|
(l/wrn :hint "register attempt for already blocked profile"
|
||||||
:profile-id (str (:id profile))
|
:profile-id (str (:id profile))
|
||||||
:profile-email (:email profile))
|
:profile-email (:email profile))
|
||||||
(rph/with-meta {:id (:id profile)
|
(rph/with-meta {:email (:email profile)}
|
||||||
:email (:email profile)}
|
|
||||||
{::audit/replace-props props
|
{::audit/replace-props props
|
||||||
::audit/context {:action "ignore-because-blocked"}
|
::audit/context {:action "ignore-because-blocked"}
|
||||||
::audit/profile-id (:id profile)
|
::audit/profile-id (:id profile)
|
||||||
::audit/name "register-profile-retry"}))
|
::audit/name "register-profile-retry"}))
|
||||||
|
|
||||||
;; A profile was just created in this call. Invitation handling is a
|
;; If invitation token comes in params, this is because the user
|
||||||
;; sub-case of "newly created profile": we never honor invitations for
|
;; comes from team-invitation process; in this case, regenerate
|
||||||
;; pre-existing profiles via this anonymous RPC. The split below mirrors
|
;; token and send back to the user a new invitation token (and
|
||||||
;; the non-invitation branches but threads the invitation through the
|
;; mark current session as logged). This happens only if the
|
||||||
;; appropriate path:
|
;; invitation email matches with the register email.
|
||||||
;;
|
(and (some? invitation)
|
||||||
;; - active + matching invitation → mint session and
|
|
||||||
;; return :invitation-token. The frontend redirects to
|
|
||||||
;; :auth-verify-token, which immediately accepts the
|
|
||||||
;; invitation.
|
|
||||||
;; - active + no/mismatched invitation → mint session
|
|
||||||
;; ("login" action). New profile, no further action.
|
|
||||||
;; - not-active + matching invitation → send the
|
|
||||||
;; verify-email mail with the invitation token EMBEDDED
|
|
||||||
;; into the verify-email JWE. No session yet. When the
|
|
||||||
;; user clicks the link, verify-token activates the
|
|
||||||
;; profile, mints a session, and propagates the
|
|
||||||
;; invitation token to the frontend so it can complete
|
|
||||||
;; the team-invitation flow.
|
|
||||||
;; - not-active + no/mismatched invitation → standard
|
|
||||||
;; "check your email" verification flow.
|
|
||||||
created?
|
|
||||||
(let [accept-invitation? (and (some? invitation)
|
|
||||||
(= (:email profile)
|
(= (:email profile)
|
||||||
(:member-email invitation)))]
|
(:member-email invitation)))
|
||||||
(cond
|
|
||||||
(and (:is-active profile) accept-invitation?)
|
|
||||||
(let [invitation (assoc invitation :member-id (:id profile))
|
(let [invitation (assoc invitation :member-id (:id profile))
|
||||||
token (tokens/generate cfg invitation)]
|
token (tokens/generate cfg invitation)]
|
||||||
(-> {:id (:id profile)
|
(-> {:invitation-token token}
|
||||||
:email (:email profile)
|
|
||||||
:invitation-token token}
|
|
||||||
(rph/with-transform (session/create-fn cfg profile claims))
|
(rph/with-transform (session/create-fn cfg profile claims))
|
||||||
(rph/with-defer create-welcome-file-when-needed)
|
|
||||||
(rph/with-meta {::audit/replace-props props
|
(rph/with-meta {::audit/replace-props props
|
||||||
::audit/context {:action "accept-invitation"}
|
::audit/context {:action "accept-invitation"}
|
||||||
::audit/profile-id (:id profile)})))
|
::audit/profile-id (:id profile)})))
|
||||||
|
|
||||||
(:is-active profile)
|
;; When a new user is created and it is already activated by
|
||||||
|
;; configuration or specified by OIDC, we just mark the profile
|
||||||
|
;; as logged-in
|
||||||
|
created?
|
||||||
|
(if (:is-active profile)
|
||||||
(-> (profile/strip-private-attrs profile)
|
(-> (profile/strip-private-attrs profile)
|
||||||
(rph/with-transform (session/create-fn cfg profile claims))
|
(rph/with-transform (session/create-fn cfg profile claims))
|
||||||
(rph/with-defer create-welcome-file-when-needed)
|
(rph/with-defer create-welcome-file-when-needed)
|
||||||
@ -530,20 +485,16 @@
|
|||||||
::audit/context {:action "login"}
|
::audit/context {:action "login"}
|
||||||
::audit/profile-id (:id profile)}))
|
::audit/profile-id (:id profile)}))
|
||||||
|
|
||||||
:else
|
|
||||||
(do
|
(do
|
||||||
(when-not (eml/has-reports? conn (:email profile))
|
(when-not (eml/has-reports? conn (:email profile))
|
||||||
(send-email-verification! cfg profile
|
(send-email-verification! cfg profile))
|
||||||
(when accept-invitation?
|
|
||||||
(:invitation-token params))))
|
|
||||||
|
|
||||||
(-> {:id (:id profile)
|
(-> {:email (:email profile)}
|
||||||
:email (:email profile)}
|
|
||||||
(rph/with-defer create-welcome-file-when-needed)
|
(rph/with-defer create-welcome-file-when-needed)
|
||||||
(rph/with-meta
|
(rph/with-meta
|
||||||
{::audit/replace-props props
|
{::audit/replace-props props
|
||||||
::audit/context {:action "email-verification"}
|
::audit/context {:action "email-verification"}
|
||||||
::audit/profile-id (:id profile)})))))
|
::audit/profile-id (:id profile)}))))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(let [elapsed? (elapsed-verify-threshold? profile)
|
(let [elapsed? (elapsed-verify-threshold? profile)
|
||||||
@ -565,8 +516,7 @@
|
|||||||
{:id (:id profile)})
|
{:id (:id profile)})
|
||||||
(send-email-verification! cfg profile))
|
(send-email-verification! cfg profile))
|
||||||
|
|
||||||
(rph/with-meta {:email (:email profile)
|
(rph/with-meta {:email (:email profile)}
|
||||||
:id (:id profile)}
|
|
||||||
{::audit/replace-props (audit/profile->props profile)
|
{::audit/replace-props (audit/profile->props profile)
|
||||||
::audit/context {:action action}
|
::audit/context {:action action}
|
||||||
::audit/profile-id (:id profile)
|
::audit/profile-id (:id profile)
|
||||||
@ -574,8 +524,7 @@
|
|||||||
|
|
||||||
(def schema:register-profile
|
(def schema:register-profile
|
||||||
[:map {:title "register-profile"}
|
[:map {:title "register-profile"}
|
||||||
[:token schema:token]
|
[:token schema:token]])
|
||||||
[:accept-newsletter-updates {:optional true} :boolean]])
|
|
||||||
|
|
||||||
(sv/defmethod ::register-profile
|
(sv/defmethod ::register-profile
|
||||||
{::rpc/auth false
|
{::rpc/auth false
|
||||||
|
|||||||
@ -22,7 +22,6 @@
|
|||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
[app.rpc.commands.media :as media-cmd]
|
|
||||||
[app.rpc.commands.projects :as projects]
|
[app.rpc.commands.projects :as projects]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
@ -81,33 +80,20 @@
|
|||||||
;; --- Command: import-binfile
|
;; --- Command: import-binfile
|
||||||
|
|
||||||
(defn- import-binfile
|
(defn- import-binfile
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}]
|
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file]}]
|
||||||
(let [team
|
(let [team (teams/get-team pool
|
||||||
(teams/get-team pool
|
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:project-id project-id)
|
:project-id project-id)
|
||||||
|
cfg (-> cfg
|
||||||
cfg
|
|
||||||
(-> cfg
|
|
||||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||||
(assoc ::bfc/project-id project-id)
|
(assoc ::bfc/project-id project-id)
|
||||||
(assoc ::bfc/profile-id profile-id)
|
(assoc ::bfc/profile-id profile-id)
|
||||||
(assoc ::bfc/name name))
|
(assoc ::bfc/name name)
|
||||||
|
(assoc ::bfc/input (:path file)))
|
||||||
|
|
||||||
input-path (:path file)
|
result (case (int version)
|
||||||
owned? (some? upload-id)
|
|
||||||
|
|
||||||
cfg
|
|
||||||
(assoc cfg ::bfc/input input-path)
|
|
||||||
|
|
||||||
result
|
|
||||||
(try
|
|
||||||
(case (int version)
|
|
||||||
1 (bf.v1/import-files! cfg)
|
1 (bf.v1/import-files! cfg)
|
||||||
3 (bf.v3/import-files! cfg))
|
3 (bf.v3/import-files! cfg))]
|
||||||
(finally
|
|
||||||
(when owned?
|
|
||||||
(fs/delete input-path))))]
|
|
||||||
|
|
||||||
(db/update! pool :project
|
(db/update! pool :project
|
||||||
{:modified-at (ct/now)}
|
{:modified-at (ct/now)}
|
||||||
@ -117,18 +103,13 @@
|
|||||||
result))
|
result))
|
||||||
|
|
||||||
(def ^:private schema:import-binfile
|
(def ^:private schema:import-binfile
|
||||||
[:and
|
|
||||||
[:map {:title "import-binfile"}
|
[:map {:title "import-binfile"}
|
||||||
[:name [:or [:string {:max 250}]
|
[:name [:or [:string {:max 250}]
|
||||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:file-id {:optional true} ::sm/uuid]
|
[:file-id {:optional true} ::sm/uuid]
|
||||||
[:version {:optional true} ::sm/int]
|
[:version {:optional true} ::sm/int]
|
||||||
[:file {:optional true} media/schema:upload]
|
[:file media/schema:upload]])
|
||||||
[:upload-id {:optional true} ::sm/uuid]]
|
|
||||||
[:fn {:error/message "one of :file or :upload-id is required"}
|
|
||||||
(fn [{:keys [file upload-id]}]
|
|
||||||
(or (some? file) (some? upload-id)))]])
|
|
||||||
|
|
||||||
(sv/defmethod ::import-binfile
|
(sv/defmethod ::import-binfile
|
||||||
"Import a penpot file in a binary format. If `file-id` is provided,
|
"Import a penpot file in a binary format. If `file-id` is provided,
|
||||||
@ -136,20 +117,15 @@
|
|||||||
|
|
||||||
The in-place imports are only supported for binfile-v3 and when a
|
The in-place imports are only supported for binfile-v3 and when a
|
||||||
.penpot file only contains one penpot file.
|
.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/added "1.15"
|
||||||
::doc/changes ["1.20" "Add file-id param for in-place import"
|
::doc/changes ["1.20" "Add file-id param for in-place import"
|
||||||
"1.20" "Set default version to 3"
|
"1.20" "Set default version to 3"]
|
||||||
"2.15" "Add upload-id param for chunked upload support"]
|
|
||||||
|
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sse/stream? true
|
::sse/stream? true
|
||||||
::sm/params schema:import-binfile}
|
::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)
|
(projects/check-edition-permissions! pool profile-id project-id)
|
||||||
(let [version (or version 3)
|
(let [version (or version 3)
|
||||||
params (-> params
|
params (-> params
|
||||||
@ -160,16 +136,9 @@
|
|||||||
(uuid? file-id)
|
(uuid? file-id)
|
||||||
(assoc ::bfc/file-id file-id))
|
(assoc ::bfc/file-id file-id))
|
||||||
|
|
||||||
params
|
manifest (case (int version)
|
||||||
(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
|
1 nil
|
||||||
3 (bf.v3/get-manifest (-> params :file :path)))]
|
3 (bf.v3/get-manifest (:path file)))]
|
||||||
|
|
||||||
(with-meta
|
(with-meta
|
||||||
(sse/response (partial import-binfile cfg params))
|
(sse/response (partial import-binfile cfg params))
|
||||||
|
|||||||
@ -49,9 +49,9 @@
|
|||||||
:deleted-at (ct/in-future (cf/get-deletion-delay))
|
:deleted-at (ct/in-future (cf/get-deletion-delay))
|
||||||
:password (derive-password password)
|
:password (derive-password password)
|
||||||
:props {}}
|
: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 cfg params)
|
||||||
(auth/create-profile-rels cfg))))]
|
(auth/create-profile-rels conn))))]
|
||||||
(with-meta {:email email
|
(with-meta {:email email
|
||||||
:password password}
|
:password password}
|
||||||
{::audit/profile-id (:id profile)})))
|
{::audit/profile-id (:id profile)})))
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
[app.common.files.helpers :as cfh]
|
[app.common.files.helpers :as cfh]
|
||||||
[app.common.files.migrations :as fmg]
|
[app.common.files.migrations :as fmg]
|
||||||
[app.common.files.stats :as cfs]
|
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.schema.desc-js-like :as-alias smdj]
|
[app.common.schema.desc-js-like :as-alias smdj]
|
||||||
@ -607,76 +606,6 @@
|
|||||||
(get-file-summary cfg id))
|
(get-file-summary cfg id))
|
||||||
|
|
||||||
|
|
||||||
;; --- COMMAND QUERY: get-file-stats
|
|
||||||
|
|
||||||
(def ^:private sql:file-stats-library-counts
|
|
||||||
"SELECT
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM file_library_rel AS flr
|
|
||||||
JOIN file AS fl ON (fl.id = flr.library_file_id)
|
|
||||||
WHERE flr.file_id = ?::uuid
|
|
||||||
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS library_count,
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM file_library_rel AS flr
|
|
||||||
JOIN file AS fl ON (fl.id = flr.file_id)
|
|
||||||
WHERE flr.library_file_id = ?::uuid
|
|
||||||
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS referenced_by_count")
|
|
||||||
|
|
||||||
(defn- get-file-stats-library-counts
|
|
||||||
[conn file-id]
|
|
||||||
(let [row (db/exec-one! conn [sql:file-stats-library-counts file-id file-id])]
|
|
||||||
{:library-count (or (:library-count row) 0)
|
|
||||||
:referenced-by-count (or (:referenced-by-count row) 0)}))
|
|
||||||
|
|
||||||
(defn- get-file-stats
|
|
||||||
[{:keys [::db/conn] :as cfg} file-id]
|
|
||||||
(let [file (bfc/get-file cfg file-id)
|
|
||||||
base (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
|
|
||||||
(cfs/calc-file-stats (:data file)))
|
|
||||||
lib-cnt (get-file-stats-library-counts conn file-id)]
|
|
||||||
(-> base
|
|
||||||
(merge lib-cnt)
|
|
||||||
(assoc :file-id file-id
|
|
||||||
:revn (:revn file)
|
|
||||||
:updated-at (:modified-at file)))))
|
|
||||||
|
|
||||||
(def ^:private schema:shape-counts
|
|
||||||
[:map {:title "FileStatsShapeCounts"}
|
|
||||||
[:total [::sm/int {:min 0}]]
|
|
||||||
[:by-type [:map-of :keyword [::sm/int {:min 0}]]]])
|
|
||||||
|
|
||||||
(def ^:private schema:get-file-stats-result
|
|
||||||
[:map {:title "FileStats"}
|
|
||||||
[:file-id ::sm/uuid]
|
|
||||||
[:page-count [::sm/int {:min 0}]]
|
|
||||||
[:shape-counts schema:shape-counts]
|
|
||||||
[:component-count [::sm/int {:min 0}]]
|
|
||||||
[:deleted-component-count [::sm/int {:min 0}]]
|
|
||||||
[:color-count [::sm/int {:min 0}]]
|
|
||||||
[:typography-count [::sm/int {:min 0}]]
|
|
||||||
[:library-count [::sm/int {:min 0}]]
|
|
||||||
[:referenced-by-count [::sm/int {:min 0}]]
|
|
||||||
[:revn [::sm/int {:min 0}]]
|
|
||||||
[:updated-at ::ct/inst]])
|
|
||||||
|
|
||||||
(def ^:private schema:get-file-stats
|
|
||||||
[:map {:title "get-file-stats"}
|
|
||||||
[:id ::sm/uuid]])
|
|
||||||
|
|
||||||
(sv/defmethod ::get-file-stats
|
|
||||||
"Return aggregate statistics for a single file: page count, shape
|
|
||||||
counts by type, component/color/typography counts, and inbound and
|
|
||||||
outbound library reference counts. Cheap alternative to `get-file`
|
|
||||||
when only metrics are needed."
|
|
||||||
{::doc/added "2.17"
|
|
||||||
::sm/params schema:get-file-stats
|
|
||||||
::sm/result schema:get-file-stats-result
|
|
||||||
::db/transaction true}
|
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
|
|
||||||
(check-read-permissions! conn profile-id id)
|
|
||||||
(get-file-stats cfg id))
|
|
||||||
|
|
||||||
|
|
||||||
;; --- COMMAND QUERY: get-file-libraries
|
;; --- COMMAND QUERY: get-file-libraries
|
||||||
|
|
||||||
(def ^:private schema:get-file-libraries
|
(def ^:private schema:get-file-libraries
|
||||||
@ -1226,39 +1155,38 @@
|
|||||||
AND t.id = ?
|
AND t.id = ?
|
||||||
AND f.id = ANY(?::uuid[])")
|
AND f.id = ANY(?::uuid[])")
|
||||||
|
|
||||||
(def ^:private sql:restore-files
|
(defn- restore-file
|
||||||
"UPDATE file SET deleted_at = null, has_media_trimmed = false
|
[conn file-id]
|
||||||
WHERE id = ANY(?::uuid[])")
|
(db/update! conn :file
|
||||||
|
{:deleted-at nil
|
||||||
|
:has-media-trimmed false}
|
||||||
|
{:id file-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
(def ^:private sql:restore-file-media-objects
|
(db/update! conn :file-media-object
|
||||||
"UPDATE file_media_object SET deleted_at = null
|
{:deleted-at nil}
|
||||||
WHERE file_id = ANY(?::uuid[])")
|
{:file-id file-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
(def ^:private sql:restore-file-changes
|
(db/update! conn :file-change
|
||||||
"UPDATE file_change SET deleted_at = null
|
{:deleted-at nil}
|
||||||
WHERE file_id = ANY(?::uuid[])")
|
{:file-id file-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
(def ^:private sql:restore-file-data
|
(db/update! conn :file-data
|
||||||
"UPDATE file_data SET deleted_at = null
|
{:deleted-at nil}
|
||||||
WHERE file_id = ANY(?::uuid[])")
|
{:file-id file-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
(def ^:private sql:restore-file-thumbnails
|
(db/update! conn :file-thumbnail
|
||||||
"UPDATE file_thumbnail SET deleted_at = null
|
{:deleted-at nil}
|
||||||
WHERE file_id = ANY(?::uuid[])")
|
{:file-id file-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
(def ^:private sql:restore-file-tagged-object-thumbnails
|
(db/update! conn :file-tagged-object-thumbnail
|
||||||
"UPDATE file_tagged_object_thumbnail SET deleted_at = null
|
{:deleted-at nil}
|
||||||
WHERE file_id = ANY(?::uuid[])")
|
{:file-id file-id}
|
||||||
|
{::db/return-keys false}))
|
||||||
(defn- restore-files
|
|
||||||
[conn file-ids]
|
|
||||||
(let [file-ids (db/create-array conn "uuid" file-ids)]
|
|
||||||
(db/exec-one! conn [sql:restore-files file-ids])
|
|
||||||
(db/exec-one! conn [sql:restore-file-media-objects file-ids])
|
|
||||||
(db/exec-one! conn [sql:restore-file-changes file-ids])
|
|
||||||
(db/exec-one! conn [sql:restore-file-data file-ids])
|
|
||||||
(db/exec-one! conn [sql:restore-file-thumbnails file-ids])
|
|
||||||
(db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids])))
|
|
||||||
|
|
||||||
(def ^:private sql:restore-projects
|
(def ^:private sql:restore-projects
|
||||||
"UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])")
|
"UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])")
|
||||||
@ -1279,18 +1207,17 @@
|
|||||||
(reduce (fn [result {:keys [id project-id]}]
|
(reduce (fn [result {:keys [id project-id]}]
|
||||||
(let [index (-> result :files count)]
|
(let [index (-> result :files count)]
|
||||||
(events/tap :progress {:file-id id :index (inc index) :total total-files})
|
(events/tap :progress {:file-id id :index (inc index) :total total-files})
|
||||||
|
(restore-file conn id)
|
||||||
|
|
||||||
(-> result
|
(-> result
|
||||||
(update :files conj id)
|
(update :files conj id)
|
||||||
(update :projects conj project-id))))
|
(update :projects conj project-id))))
|
||||||
{:files #{} :projects #{}}
|
|
||||||
|
{:files #{} :projectes #{}}
|
||||||
(db/plan conn [sql:resolve-editable-files team-id
|
(db/plan conn [sql:resolve-editable-files team-id
|
||||||
(db/create-array conn "uuid" ids)]))]
|
(db/create-array conn "uuid" ids)]))]
|
||||||
|
|
||||||
(when (seq files)
|
(restore-projects conn projects)
|
||||||
(restore-files conn files))
|
|
||||||
|
|
||||||
(when (seq projects)
|
|
||||||
(restore-projects conn projects))
|
|
||||||
|
|
||||||
files))
|
files))
|
||||||
|
|
||||||
|
|||||||
@ -112,30 +112,22 @@
|
|||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/project-id project-id})
|
::quotes/project-id project-id})
|
||||||
|
|
||||||
;; Acquire a row-level lock on the team and re-read its features
|
;; FIXME: IMPORTANT: this code can have race conditions, because
|
||||||
;; inside the same transaction before the read-modify-write below.
|
;; we have no locks for updating team so, creating two files
|
||||||
;; Without the lock, two concurrent create-file calls on the same
|
;; concurrently can lead to lost team features updating
|
||||||
;; team can both observe the same team.features value, each
|
(when-let [features (-> features
|
||||||
;; compute a different union, and the second UPDATE silently
|
(set/difference (:features team))
|
||||||
;; overwrites the first (lost update under READ COMMITTED).
|
|
||||||
(let [team-features (-> (db/exec-one! conn
|
|
||||||
["SELECT features FROM team WHERE id = ? FOR UPDATE"
|
|
||||||
team-id])
|
|
||||||
:features
|
|
||||||
(db/decode-pgarray #{}))]
|
|
||||||
(when-let [new-features (-> features
|
|
||||||
(set/difference team-features)
|
|
||||||
(set/difference cfeat/no-team-inheritable-features)
|
(set/difference cfeat/no-team-inheritable-features)
|
||||||
(not-empty))]
|
(not-empty))]
|
||||||
(let [features (-> new-features
|
(let [features (-> features
|
||||||
(set/union team-features)
|
(set/union (:features team))
|
||||||
(set/difference cfeat/no-team-inheritable-features)
|
(set/difference cfeat/no-team-inheritable-features)
|
||||||
(into-array))]
|
(into-array))]
|
||||||
|
|
||||||
(db/update! conn :team
|
(db/update! conn :team
|
||||||
{:features features}
|
{:features features}
|
||||||
{:id team-id}
|
{:id (:id team)}
|
||||||
{::db/return-keys false}))))
|
{::db/return-keys false})))
|
||||||
|
|
||||||
(-> (create-file cfg params)
|
(-> (create-file cfg params)
|
||||||
(vary-meta assoc ::audit/props {:team-id team-id}))))
|
(vary-meta assoc ::audit/props {:team-id team-id}))))
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.binfile.common :as bfc]
|
[app.binfile.common :as bfc]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.features :as-alias cfeat]
|
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
@ -36,43 +35,6 @@
|
|||||||
(files/check-read-permissions! conn profile-id file-id)
|
(files/check-read-permissions! conn profile-id file-id)
|
||||||
(fsnap/get-visible-snapshots conn file-id))))
|
(fsnap/get-visible-snapshots conn file-id))))
|
||||||
|
|
||||||
;; --- COMMAND QUERY: get-file-snapshot
|
|
||||||
|
|
||||||
(def ^:private schema:get-file-snapshot
|
|
||||||
[:map {:title "get-file-snapshot"}
|
|
||||||
[:file-id ::sm/uuid]
|
|
||||||
[:id ::sm/uuid]
|
|
||||||
[:features {:optional true} ::cfeat/features]])
|
|
||||||
|
|
||||||
(sv/defmethod ::get-file-snapshot
|
|
||||||
"Retrieve a file bundle with data from a specific snapshot for
|
|
||||||
read-only preview. Does not modify any database state."
|
|
||||||
{::doc/added "2.16"
|
|
||||||
::sm/params schema:get-file-snapshot
|
|
||||||
::sm/result files/schema:file-with-permissions
|
|
||||||
::db/transaction true}
|
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
|
|
||||||
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
|
|
||||||
(files/check-read-permissions! perms)
|
|
||||||
(let [snapshot (fsnap/get-snapshot-data cfg file-id id)]
|
|
||||||
(when-not snapshot
|
|
||||||
(ex/raise :type :not-found
|
|
||||||
:code :snapshot-not-found
|
|
||||||
:hint "unable to find snapshot with the provided id"
|
|
||||||
:snapshot-id id
|
|
||||||
:file-id file-id))
|
|
||||||
;; Load current file metadata only (no data decoding) then overlay
|
|
||||||
;; the snapshot data so the client receives the same shape as a
|
|
||||||
;; normal get-file response but with historical page/object content.
|
|
||||||
(let [base-file (bfc/get-file cfg file-id :load-data? false)]
|
|
||||||
(-> base-file
|
|
||||||
(assoc :data (:data snapshot))
|
|
||||||
(assoc :version (:version snapshot))
|
|
||||||
(assoc :features (:features snapshot))
|
|
||||||
(assoc :revn (:revn snapshot))
|
|
||||||
(assoc :vern (rand-int 100000))
|
|
||||||
(assoc :permissions perms))))))
|
|
||||||
|
|
||||||
(def ^:private schema:create-file-snapshot
|
(def ^:private schema:create-file-snapshot
|
||||||
[:map
|
[:map
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
@ -109,7 +71,7 @@
|
|||||||
{::doc/added "1.20"
|
{::doc/added "1.20"
|
||||||
::sm/params schema:restore-file-snapshot
|
::sm/params schema:restore-file-snapshot
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}]
|
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
|
||||||
(files/check-edition-permissions! conn profile-id file-id)
|
(files/check-edition-permissions! conn profile-id file-id)
|
||||||
(let [file (bfc/get-file cfg file-id)
|
(let [file (bfc/get-file cfg file-id)
|
||||||
team (teams/get-team conn
|
team (teams/get-team conn
|
||||||
@ -126,8 +88,7 @@
|
|||||||
;; Send to the clients a notification to reload the file
|
;; Send to the clients a notification to reload the file
|
||||||
(mbus/pub! msgbus
|
(mbus/pub! msgbus
|
||||||
:topic (:id file)
|
:topic (:id file)
|
||||||
:message {:type :file-restored
|
:message {:type :file-restore
|
||||||
:session-id session-id
|
|
||||||
:file-id (:id file)
|
:file-id (:id file)
|
||||||
:vern vern})
|
:vern vern})
|
||||||
nil)))
|
nil)))
|
||||||
|
|||||||
@ -409,7 +409,10 @@
|
|||||||
|
|
||||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
(files/check-edition-permissions! conn profile-id file-id)
|
;; TODO For now we check read permissions instead of write,
|
||||||
|
;; to allow viewer users to update thumbnails. We might
|
||||||
|
;; review this approach on the future.
|
||||||
|
(files/check-read-permissions! conn profile-id file-id)
|
||||||
(when-not (db/read-only? conn)
|
(when-not (db/read-only? conn)
|
||||||
(let [media (create-file-thumbnail cfg params)]
|
(let [media (create-file-thumbnail cfg params)]
|
||||||
{:uri (files/resolve-public-uri (:id media))
|
{:uri (files/resolve-public-uri (:id media))
|
||||||
|
|||||||
@ -9,14 +9,12 @@
|
|||||||
[app.binfile.common :as bfc]
|
[app.binfile.common :as bfc]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.media :as cmedia]
|
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
[app.features.logical-deletion :as ldel]
|
[app.features.logical-deletion :as ldel]
|
||||||
[app.http :as-alias http]
|
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
@ -36,9 +34,7 @@
|
|||||||
java.io.InputStream
|
java.io.InputStream
|
||||||
java.io.OutputStream
|
java.io.OutputStream
|
||||||
java.io.SequenceInputStream
|
java.io.SequenceInputStream
|
||||||
java.util.Collections
|
java.util.Collections))
|
||||||
java.util.zip.ZipEntry
|
|
||||||
java.util.zip.ZipOutputStream))
|
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
(set! *warn-on-reflection* true)
|
||||||
|
|
||||||
@ -98,8 +94,7 @@
|
|||||||
[:font-id ::sm/uuid]
|
[:font-id ::sm/uuid]
|
||||||
[:font-family ::sm/text]
|
[:font-family ::sm/text]
|
||||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||||
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
[:font-style [::sm/one-of {:format "string"} valid-style]]])
|
||||||
[:variant-name {:optional true} [:maybe ::sm/text]]])
|
|
||||||
|
|
||||||
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
|
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
|
||||||
;; connection around the font creation
|
;; connection around the font creation
|
||||||
@ -185,7 +180,6 @@
|
|||||||
:font-family (:font-family params)
|
:font-family (:font-family params)
|
||||||
:font-weight (:font-weight params)
|
:font-weight (:font-weight params)
|
||||||
:font-style (:font-style params)
|
:font-style (:font-style params)
|
||||||
:variant-name (:variant-name params)
|
|
||||||
:woff1-file-id (:id woff1)
|
:woff1-file-id (:id woff1)
|
||||||
:woff2-file-id (:id woff2)
|
:woff2-file-id (:id woff2)
|
||||||
:otf-file-id (:id otf)
|
:otf-file-id (:id otf)
|
||||||
@ -302,98 +296,3 @@
|
|||||||
(rph/with-meta (rph/wrap)
|
(rph/with-meta (rph/wrap)
|
||||||
{::audit/props {:font-family (:font-family variant)
|
{::audit/props {:font-family (:font-family variant)
|
||||||
:font-id (:font-id variant)}})))
|
:font-id (:font-id variant)}})))
|
||||||
|
|
||||||
;; --- DOWNLOAD FONT
|
|
||||||
|
|
||||||
(defn- make-temporal-storage-object
|
|
||||||
[cfg profile-id content]
|
|
||||||
(let [storage (sto/resolve cfg)
|
|
||||||
content (media/check-input content)
|
|
||||||
hash (sto/calculate-hash (:path content))
|
|
||||||
data (-> (sto/content (:path content))
|
|
||||||
(sto/wrap-with-hash hash))
|
|
||||||
mtype (:mtype content "application/octet-stream")
|
|
||||||
content {::sto/content data
|
|
||||||
::sto/deduplicate? true
|
|
||||||
::sto/touched-at (ct/in-future {:minutes 30})
|
|
||||||
:profile-id profile-id
|
|
||||||
:content-type mtype
|
|
||||||
:bucket "tempfile"}]
|
|
||||||
|
|
||||||
(sto/put-object! storage content)))
|
|
||||||
|
|
||||||
(defn- make-variant-filename
|
|
||||||
[v mtype]
|
|
||||||
(str (:font-family v) "-" (:font-weight v)
|
|
||||||
(when-not (= "normal" (:font-style v)) (str "-" (:font-style v)))
|
|
||||||
(cmedia/mtype->extension mtype)))
|
|
||||||
|
|
||||||
(def ^:private schema:download-font
|
|
||||||
[:map {:title "download-font"}
|
|
||||||
[:id ::sm/uuid]])
|
|
||||||
|
|
||||||
(sv/defmethod ::download-font
|
|
||||||
"Download the font file. Returns a http redirect to the asset resource uri."
|
|
||||||
{::doc/added "2.15"
|
|
||||||
::sm/params schema:download-font}
|
|
||||||
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
|
||||||
(let [variant (db/get pool :team-font-variant {:id id})]
|
|
||||||
(teams/check-read-permissions! pool profile-id (:team-id variant))
|
|
||||||
|
|
||||||
;; Try to get the best available font format (prefer TTF for broader compatibility).
|
|
||||||
(let [media-id (or (:ttf-file-id variant)
|
|
||||||
(:otf-file-id variant)
|
|
||||||
(:woff2-file-id variant)
|
|
||||||
(:woff1-file-id variant))
|
|
||||||
sobj (sto/get-object storage media-id)
|
|
||||||
mtype (-> sobj meta :content-type)]
|
|
||||||
|
|
||||||
{:id (:id sobj)
|
|
||||||
:uri (files/resolve-public-uri (:id sobj))
|
|
||||||
:name (make-variant-filename variant mtype)})))
|
|
||||||
|
|
||||||
(def ^:private schema:download-font-family
|
|
||||||
[:map {:title "download-font-family"}
|
|
||||||
[:font-id ::sm/uuid]])
|
|
||||||
|
|
||||||
(sv/defmethod ::download-font-family
|
|
||||||
"Download the entire font family as a zip file. Returns the zip
|
|
||||||
bytes on the body, without encoding it on transit or json."
|
|
||||||
{::doc/added "2.15"
|
|
||||||
::sm/params schema:download-font-family}
|
|
||||||
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}]
|
|
||||||
(let [variants (db/query pool :team-font-variant
|
|
||||||
{:font-id font-id
|
|
||||||
:deleted-at nil})]
|
|
||||||
|
|
||||||
(when-not (seq variants)
|
|
||||||
(ex/raise :type :not-found
|
|
||||||
:code :object-not-found))
|
|
||||||
|
|
||||||
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
|
|
||||||
|
|
||||||
(let [tempfile (tmp/tempfile :suffix ".zip")
|
|
||||||
ffamily (-> variants first :font-family)]
|
|
||||||
|
|
||||||
(with-open [^OutputStream output (io/output-stream tempfile)
|
|
||||||
^OutputStream output (ZipOutputStream. output)]
|
|
||||||
(doseq [v variants]
|
|
||||||
(let [media-id (or (:ttf-file-id v)
|
|
||||||
(:otf-file-id v)
|
|
||||||
(:woff2-file-id v)
|
|
||||||
(:woff1-file-id v))
|
|
||||||
sobj (sto/get-object storage media-id)
|
|
||||||
mtype (-> sobj meta :content-type)
|
|
||||||
name (make-variant-filename v mtype)]
|
|
||||||
|
|
||||||
(with-open [input (sto/get-object-data storage sobj)]
|
|
||||||
(.putNextEntry ^ZipOutputStream output (ZipEntry. ^String name))
|
|
||||||
(io/copy input output :size (:size sobj))
|
|
||||||
(.closeEntry ^ZipOutputStream output)))))
|
|
||||||
|
|
||||||
(let [{:keys [id] :as sobj} (make-temporal-storage-object cfg profile-id
|
|
||||||
{:mtype "application/zip"
|
|
||||||
:path tempfile})]
|
|
||||||
{:id id
|
|
||||||
:uri (files/resolve-public-uri id)
|
|
||||||
:name (str ffamily ".zip")}))))
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
(when-not provider
|
(when-not provider
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :ldap-not-initialized
|
:code :ldap-not-initialized
|
||||||
:hint "ldap auth provider is not initialized"))
|
:hide "ldap auth provider is not initialized"))
|
||||||
|
|
||||||
(let [info (ldap/authenticate provider params)]
|
(let [info (ldap/authenticate provider params)]
|
||||||
(when-not info
|
(when-not info
|
||||||
@ -84,5 +84,5 @@
|
|||||||
(profile/get-profile-by-email conn))
|
(profile/get-profile-by-email conn))
|
||||||
(->> (assoc info :is-active true :is-demo false)
|
(->> (assoc info :is-active true :is-demo false)
|
||||||
(auth/create-profile cfg)
|
(auth/create-profile cfg)
|
||||||
(auth/create-profile-rels cfg)
|
(auth/create-profile-rels conn)
|
||||||
(profile/strip-private-attrs))))))
|
(profile/strip-private-attrs))))))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user