mirror of
https://github.com/penpot/penpot.git
synced 2026-09-10 20:59:12 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdcbde37c3 | ||
|
|
81c6cb52ca | ||
|
|
1dc274e6e2 |
No files matched your search
@@ -45,15 +45,6 @@
|
||||
:potok/reify-type
|
||||
{:level :error}
|
||||
|
||||
:redundant-primitive-coercion
|
||||
{:level :off}
|
||||
|
||||
:unused-excluded-var
|
||||
{:level :off}
|
||||
|
||||
:unresolved-excluded-var
|
||||
{:level :off}
|
||||
|
||||
:missing-protocol-method
|
||||
{:level :off}
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
:remove-multiple-non-indenting-spaces? false
|
||||
:remove-surrounding-whitespace? true
|
||||
:remove-consecutive-blank-lines? false
|
||||
:indent-line-comments? true
|
||||
:parallel? true
|
||||
:align-form-columns? false
|
||||
;; :align-map-columns? false
|
||||
;; :align-single-column-lines? false
|
||||
:extra-indents {rumext.v2/fnc [[:inner 0]]
|
||||
cljs.test/async [[:inner 0]]
|
||||
promesa.exec/thread [[:inner 0]]
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: New Render Bug Report
|
||||
about: Create a report about the bugs you have found in the new render
|
||||
title: ''
|
||||
labels: new render
|
||||
assignees: claragvinola
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps to Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots or screen recordings**
|
||||
If applicable, add screenshots or screen recording to help illustrate your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -40,7 +40,7 @@ on:
|
||||
jobs:
|
||||
build-bundle:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
@@ -7,28 +7,23 @@ jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
environment: release-admins
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
# Each job execution will use its own docker configuration.
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push DevEnv Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_IMAGE: 'penpotapp/devenv'
|
||||
with:
|
||||
|
||||
@@ -19,16 +19,11 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Penpot Docker Images
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
# Each job execution will use its own docker configuration.
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
@@ -59,43 +54,32 @@ jobs:
|
||||
mv penpot/frontend bundle-frontend
|
||||
mv penpot/exporter bundle-exporter
|
||||
mv penpot/storybook bundle-storybook
|
||||
mv penpot/mcp bundle-mcp
|
||||
popd
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# To avoid the “429 Too Many Requests” error when downloading
|
||||
# images from DockerHub for unregistered users.
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
- name: Login to DockerHub Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images:
|
||||
frontend
|
||||
backend
|
||||
exporter
|
||||
storybook
|
||||
mcp
|
||||
labels: |
|
||||
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_IMAGE: 'backend'
|
||||
BUNDLE_PATH: './bundle-backend'
|
||||
@@ -110,7 +94,7 @@ jobs:
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_IMAGE: 'frontend'
|
||||
BUNDLE_PATH: './bundle-frontend'
|
||||
@@ -125,7 +109,7 @@ jobs:
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push Exporter Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_IMAGE: 'exporter'
|
||||
BUNDLE_PATH: './bundle-exporter'
|
||||
@@ -140,7 +124,7 @@ jobs:
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push Storybook Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_IMAGE: 'storybook'
|
||||
BUNDLE_PATH: './bundle-storybook'
|
||||
@@ -154,21 +138,6 @@ jobs:
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push MCP Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_IMAGE: 'mcp'
|
||||
BUNDLE_PATH: './bundle-mcp'
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.mcp
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
|
||||
@@ -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"
|
||||
@@ -1,14 +0,0 @@
|
||||
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"
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
build_wasm: "yes"
|
||||
build_wasm: "no"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
@@ -23,7 +23,6 @@ jobs:
|
||||
|
||||
notify:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-docker
|
||||
|
||||
steps:
|
||||
@@ -33,7 +32,7 @@ jobs:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
🐳 *[PENPOT] Docker image available: ${{ github.ref_name }}*
|
||||
🐳 *[PENPOT] Docker image available.*
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ on:
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -22,14 +20,13 @@ on:
|
||||
|
||||
jobs:
|
||||
check-commit-message:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Check Commit Message
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check Commit Type
|
||||
uses: gsactions/commit-message-checker@v2
|
||||
with:
|
||||
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$'
|
||||
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
|
||||
flags: 'gm'
|
||||
error: 'Commit should match CONTRIBUTING.md guideline'
|
||||
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
name: Plugins/api-doc deployer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
paths:
|
||||
- 'plugins/libs/plugin-types/index.d.ts'
|
||||
- 'plugins/libs/plugin-types/REAME.md'
|
||||
- 'plugins/tools/typedoc.css'
|
||||
- 'plugins/CHANGELOG.md'
|
||||
- 'plugins/wrangler-penpot-plugins-api-doc.toml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'develop'
|
||||
options:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Extract some useful variables
|
||||
id: vars
|
||||
run: |
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
|
||||
# START: Setup Node and PNPM enabling cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable PNPM
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
# END: Setup Node and PNPM enabling cache
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile;
|
||||
pnpm add -D -w wrangler@latest;
|
||||
|
||||
- name: Build docs
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: pnpm run build:doc
|
||||
|
||||
- name: Select Worker name
|
||||
run: |
|
||||
REF="${{ steps.vars.outputs.gh_ref }}"
|
||||
case "$REF" in
|
||||
main)
|
||||
echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=doc.plugins.penpot.app" >> $GITHUB_ENV ;;
|
||||
staging)
|
||||
echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
|
||||
develop)
|
||||
echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=doc.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
|
||||
*) echo "Unsupported branch ${REF}" && exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Set the custom url
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml
|
||||
|
||||
- name: Add noindex header and robots.txt files for non-production environments
|
||||
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
ASSETS_DIR="dist/doc"
|
||||
|
||||
cat > "${ASSETS_DIR}/_headers" << 'EOF'
|
||||
/*
|
||||
X-Robots-Tag: noindex, nofollow
|
||||
EOF
|
||||
|
||||
cat > "${ASSETS_DIR}/robots.txt" << 'EOF'
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
workingDirectory: plugins
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --config wrangler-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🧩📚 *[PENPOT PLUGINS] Error deploying API documentation.*
|
||||
📄 Triggered from ref: `${{ inputs.gh_ref }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
@@ -1,127 +0,0 @@
|
||||
name: Plugins/package deployer
|
||||
|
||||
on:
|
||||
# Deploy package from manual action
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'develop'
|
||||
options:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
plugin_name:
|
||||
description: 'Pluging name (like plugins/apps/<plugin_name>-plugin)'
|
||||
type: string
|
||||
required: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
plugin_name:
|
||||
description: 'Publig name (from plugins/apps/<plugin_name>-plugin)'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: penpot-runner-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
# START: Setup Node and PNPM enabling cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable PNPM
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
# END: Setup Node and PNPM enabling cache
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile;
|
||||
pnpm add -D -w wrangler@latest;
|
||||
|
||||
- name: "Build package for ${{ inputs.plugin_name }}-plugin"
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: pnpm --filter ${{ inputs.plugin_name }}-plugin build
|
||||
|
||||
- name: Select Worker name
|
||||
run: |
|
||||
REF="${{ inputs.gh_ref }}"
|
||||
case "$REF" in
|
||||
main)
|
||||
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pro" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.app" >> $GITHUB_ENV ;;
|
||||
staging)
|
||||
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pre" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.dev" >> $GITHUB_ENV ;;
|
||||
develop)
|
||||
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-hourly" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
|
||||
*) echo "Unsupported branch ${REF}" && exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Set the custom url
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" apps/${{ inputs.plugin_name }}-plugin/wrangler.toml
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
workingDirectory: plugins
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --config apps/${{ inputs.plugin_name }}-plugin/wrangler.toml --name ${{ env.WORKER_NAME }}
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🧩📦 *[PENPOT PLUGINS] Error deploying ${{ env.WORKER_NAME }}.*
|
||||
📄 Triggered from ref: `${{ inputs.gh_ref }}`
|
||||
Plugin name: `${{ inputs.plugin_name }}-plugin`
|
||||
Cloudflare worker name: `${{ env.WORKER_NAME }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
@@ -1,143 +0,0 @@
|
||||
name: Plugins/packages deployer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
paths:
|
||||
- 'plugins/apps/*-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'develop'
|
||||
options:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
colors_to_tokens: ${{ steps.filter.outputs.colors_to_tokens }}
|
||||
create_palette: ${{ steps.filter.outputs.create_palette }}
|
||||
lorem_ipsum: ${{ steps.filter.outputs.lorem_ipsum }}
|
||||
rename_layers: ${{ steps.filter.outputs.rename_layers }}
|
||||
contrast: ${{ steps.filter.outputs.contrast }}
|
||||
icons: ${{ steps.filter.outputs.icons }}
|
||||
poc_state: ${{ steps.filter.outputs.poc_state }}
|
||||
table: ${{ steps.filter.outputs.table }}
|
||||
# [For new plugins]
|
||||
# Add more outputs here
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: filter
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
colors_to_tokens:
|
||||
- 'plugins/apps/colors-to-tokens-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
contrast:
|
||||
- 'plugins/apps/contrast-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
create_palette:
|
||||
- 'plugins/apps/create-palette-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
icons:
|
||||
- 'plugins/apps/icons-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
lorem_ipsum:
|
||||
- 'plugins/apps/lorem-ipsum-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
rename_layers:
|
||||
- 'plugins/apps/rename-layers-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
table:
|
||||
- 'plugins/apps/table-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
# [For new plugins]
|
||||
# Add more plugin filters here
|
||||
# another_plugin:
|
||||
# - 'plugins/apps/another-plugin/**'
|
||||
# - 'libs/plugins-styles/**'
|
||||
|
||||
colors-to-tokens-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.colors_to_tokens == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: colors-to-tokens
|
||||
|
||||
contrast-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.contrast == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: contrast
|
||||
|
||||
create-palette-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.create_palette == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: create-palette
|
||||
|
||||
icons-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.icons == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: icons
|
||||
|
||||
lorem-ipsum-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.lorem_ipsum == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: lorem-ipsum
|
||||
|
||||
rename-layers-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rename_layers == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: rename-layers
|
||||
|
||||
table-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.table == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: table
|
||||
|
||||
# [For new plugins]
|
||||
# Add more jobs for other plugins below, following the same pattern
|
||||
# another-plugin:
|
||||
# needs: detect-changes
|
||||
# if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.another_plugin == 'true'
|
||||
# uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
# plugin_name: another
|
||||
@@ -1,140 +0,0 @@
|
||||
name: Plugins/styles-doc deployer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
paths:
|
||||
- 'plugins/apps/example-styles/**'
|
||||
- 'plugins/libs/plugins-styles/**'
|
||||
- 'plugins/wrangler-penpot-plugins-styles-doc.toml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'develop'
|
||||
options:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Extract some useful variables
|
||||
id: vars
|
||||
run: |
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
|
||||
# START: Setup Node and PNPM enabling cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable PNPM
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
# END: Setup Node and PNPM enabling cache
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile;
|
||||
pnpm add -D -w wrangler@latest;
|
||||
|
||||
- name: Build styles
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: pnpm run build:styles-example
|
||||
|
||||
- name: Select Worker name
|
||||
run: |
|
||||
REF="${{ steps.vars.outputs.gh_ref }}"
|
||||
case "$REF" in
|
||||
main)
|
||||
echo "WORKER_NAME=penpot-plugins-styles-doc-pro" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=styles-doc.plugins.penpot.app" >> $GITHUB_ENV ;;
|
||||
staging)
|
||||
echo "WORKER_NAME=penpot-plugins-styles-doc-pre" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=styles-doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
|
||||
develop)
|
||||
echo "WORKER_NAME=penpot-plugins-styles-doc-hourly" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=styles-doc.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
|
||||
*) echo "Unsupported branch ${REF}" && exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Set the custom url
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml
|
||||
|
||||
- name: Add noindex header and robots.txt files for non-production environments
|
||||
if: ${{ steps.vars.outputs.gh_ref != 'main' }}
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
ASSETS_DIR="dist/apps/example-styles"
|
||||
|
||||
cat > "${ASSETS_DIR}/_headers" << 'EOF'
|
||||
/*
|
||||
X-Robots-Tag: noindex, nofollow
|
||||
EOF
|
||||
|
||||
cat > "${ASSETS_DIR}/robots.txt" << 'EOF'
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
workingDirectory: plugins
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --config wrangler-penpot-plugins-styles-doc.toml --name ${{ env.WORKER_NAME }}
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🧩💅 *[PENPOT PLUGINS] Error deploying Styles documentation.*
|
||||
📄 Triggered from ref: `${{ inputs.gh_ref }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
@@ -63,15 +63,14 @@ jobs:
|
||||
|
||||
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
|
||||
|
||||
IMAGES=("frontend" "backend" "exporter" "mcp" "storybook")
|
||||
SHORT_TAG=${TAG%.*}
|
||||
IMAGES=("frontend" "backend" "exporter" "storybook")
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
skopeo copy --all \
|
||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||
docker://docker.io/penpotapp/$image:$TAG
|
||||
|
||||
for alias in main latest "$SHORT_TAG"; do
|
||||
for alias in main latest; do
|
||||
skopeo copy --all \
|
||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||
docker://docker.io/penpotapp/$image:$alias
|
||||
@@ -94,7 +93,7 @@ jobs:
|
||||
|
||||
# --- Create GitHub release ---
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
name: "MCP CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
|
||||
jobs:
|
||||
test-mcp:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Test MCP"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup
|
||||
working-directory: ./mcp
|
||||
run: ./scripts/setup
|
||||
|
||||
- name: Check
|
||||
working-directory: ./mcp
|
||||
run: |
|
||||
pnpm run fmt:check;
|
||||
pnpm -r run build;
|
||||
pnpm -r run types:check;
|
||||
+73
-147
@@ -9,7 +9,6 @@ on:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
@@ -21,140 +20,45 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Linter"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint Common
|
||||
working-directory: ./common
|
||||
- name: Check clojure code format
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run check-fmt:js
|
||||
pnpm run lint:clj
|
||||
|
||||
- name: Lint Frontend
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:js
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run check-fmt:scss
|
||||
pnpm run lint:clj
|
||||
pnpm run lint:js
|
||||
pnpm run lint:scss
|
||||
|
||||
- name: Lint Backend
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
|
||||
- name: Lint Exporter
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
|
||||
- name: Lint Library
|
||||
working-directory: ./library
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
./scripts/lint
|
||||
|
||||
test-common:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Common Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run tests
|
||||
- name: Run tests on JVM
|
||||
working-directory: ./common
|
||||
run: |
|
||||
clojure -M:dev:test
|
||||
|
||||
- name: Run tests on NODE
|
||||
working-directory: ./common
|
||||
run: |
|
||||
./scripts/test
|
||||
|
||||
test-plugins:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Plugins Runtime Linter & Tests
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
|
||||
- name: Run Lint
|
||||
working-directory: ./plugins
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run Format Check
|
||||
working-directory: ./plugins
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Run Test
|
||||
working-directory: ./plugins
|
||||
run: pnpm run test
|
||||
|
||||
- name: Build runtime
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:runtime
|
||||
|
||||
- name: Build doc
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:doc
|
||||
|
||||
- name: Build plugins
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:plugins
|
||||
|
||||
- name: Build styles
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:styles-example
|
||||
|
||||
test-frontend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Frontend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Unit Tests
|
||||
working-directory: ./frontend
|
||||
@@ -163,20 +67,17 @@ jobs:
|
||||
|
||||
- name: Component Tests
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
VITEST_BROWSER_TIMEOUT: 120000
|
||||
run: |
|
||||
./scripts/test-components
|
||||
|
||||
test-render-wasm:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Render WASM Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Format
|
||||
working-directory: ./render-wasm
|
||||
@@ -194,9 +95,8 @@ jobs:
|
||||
./test
|
||||
|
||||
test-backend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Backend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
services:
|
||||
@@ -220,7 +120,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./backend
|
||||
@@ -234,14 +134,13 @@ jobs:
|
||||
clojure -M:dev:test --reporter kaocha.report/documentation
|
||||
|
||||
test-library:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Library Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./library
|
||||
@@ -249,39 +148,38 @@ jobs:
|
||||
./scripts/test
|
||||
|
||||
build-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Build Integration Bundle"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Bundle
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/build
|
||||
./scripts/build 0.0.0
|
||||
|
||||
- name: Store Bundle Cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
|
||||
|
||||
test-integration-1:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 1/3"
|
||||
runs-on: penpot-runner-02
|
||||
name: "Integration Tests 1/4"
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
@@ -289,10 +187,10 @@ jobs:
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e --shard="1/3";
|
||||
./scripts/test-e2e --shard="1/4";
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-1
|
||||
@@ -301,18 +199,17 @@ jobs:
|
||||
retention-days: 3
|
||||
|
||||
test-integration-2:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 2/3"
|
||||
runs-on: penpot-runner-02
|
||||
name: "Integration Tests 2/4"
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
@@ -320,10 +217,10 @@ jobs:
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e --shard="2/3";
|
||||
./scripts/test-e2e --shard="2/4";
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-2
|
||||
@@ -332,18 +229,17 @@ jobs:
|
||||
retention-days: 3
|
||||
|
||||
test-integration-3:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 3/3"
|
||||
runs-on: penpot-runner-02
|
||||
name: "Integration Tests 3/4"
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
@@ -351,13 +247,43 @@ jobs:
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e --shard="3/3";
|
||||
./scripts/test-e2e --shard="3/4";
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-3
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
|
||||
test-integration-4:
|
||||
name: "Integration Tests 4/4"
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e --shard="4/4";
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-4
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
+13
-16
@@ -1,4 +1,10 @@
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
*-init.clj
|
||||
*.css.json
|
||||
*.jar
|
||||
@@ -13,6 +19,7 @@
|
||||
.nyc_output
|
||||
.rebel_readline_history
|
||||
.repl
|
||||
.shadow-cljs
|
||||
/*.jpg
|
||||
/*.md
|
||||
/*.png
|
||||
@@ -24,13 +31,8 @@
|
||||
/.clj-kondo/.cache
|
||||
/_dump
|
||||
/notes
|
||||
/.opencode/package-lock.json
|
||||
/plans
|
||||
/prompts
|
||||
/playground/
|
||||
/backend/*.md
|
||||
!/backend/AGENTS.md
|
||||
/backend/.shadow-cljs
|
||||
/backend/*.sql
|
||||
/backend/*.txt
|
||||
/backend/assets/
|
||||
@@ -41,33 +43,27 @@
|
||||
/backend/resources/public/media
|
||||
/backend/target/
|
||||
/backend/experiments
|
||||
/backend/scripts/_env.local
|
||||
/bundle*
|
||||
/cd.md
|
||||
/clj-profiler/
|
||||
/common/coverage
|
||||
/common/target
|
||||
/common/.shadow-cljs
|
||||
/deploy
|
||||
/docker/images/bundle*
|
||||
/exporter/target
|
||||
/exporter/.shadow-cljs
|
||||
/frontend/.storybook/preview-body.html
|
||||
/frontend/.storybook/preview-head.html
|
||||
/frontend/playwright-report/
|
||||
/frontend/text-editor/src/wasm/
|
||||
/frontend/dist/
|
||||
/frontend/npm-debug.log
|
||||
/frontend/out/
|
||||
/frontend/package-lock.json
|
||||
/frontend/resources/fonts/experiments
|
||||
/frontend/resources/public/*
|
||||
/frontend/src/app/render_wasm/api/shared.js
|
||||
/frontend/storybook-static/
|
||||
/frontend/target/
|
||||
/frontend/test-results/
|
||||
/frontend/.shadow-cljs
|
||||
/other/
|
||||
/scripts/
|
||||
/nexus/
|
||||
/telemetry/
|
||||
/tmp/
|
||||
/vendor/**/target
|
||||
/vendor/svgclean/bundle*.js
|
||||
@@ -75,12 +71,13 @@
|
||||
/library/target/
|
||||
/library/*.zip
|
||||
/external
|
||||
/penpot-nitrate
|
||||
|
||||
clj-profiler/
|
||||
node_modules
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/render-wasm/target/
|
||||
/**/node_modules
|
||||
/**/.yarn/*
|
||||
/.pnpm-store
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
image:
|
||||
file: docker/gitpod/Dockerfile
|
||||
|
||||
ports:
|
||||
# nginx
|
||||
- port: 3449
|
||||
onOpen: open-preview
|
||||
|
||||
# frontend nREPL
|
||||
- port: 3447
|
||||
onOpen: ignore
|
||||
visibility: private
|
||||
|
||||
# frontend shadow server
|
||||
- port: 3448
|
||||
onOpen: ignore
|
||||
visibility: private
|
||||
|
||||
# backend
|
||||
- port: 6060
|
||||
onOpen: ignore
|
||||
|
||||
# exporter shadow server
|
||||
- port: 9630
|
||||
onOpen: ignore
|
||||
visibility: private
|
||||
|
||||
# exporter http server
|
||||
- port: 6061
|
||||
onOpen: ignore
|
||||
|
||||
# mailhog web interface
|
||||
- port: 8025
|
||||
onOpen: ignore
|
||||
|
||||
# mailhog postfix
|
||||
- port: 1025
|
||||
onOpen: ignore
|
||||
|
||||
# postgres
|
||||
- port: 5432
|
||||
onOpen: ignore
|
||||
|
||||
# redis
|
||||
- port: 6379
|
||||
onOpen: ignore
|
||||
|
||||
# openldap
|
||||
- port: 389
|
||||
onOpen: ignore
|
||||
|
||||
tasks:
|
||||
# https://github.com/gitpod-io/gitpod/issues/666#issuecomment-534347856
|
||||
- name: gulp
|
||||
command: >
|
||||
cd $GITPOD_REPO_ROOT/frontend/;
|
||||
yarn && gp sync-done 'frontend-yarn';
|
||||
npx gulp --theme=${PENPOT_THEME} watch
|
||||
|
||||
- name: frontend shadow watch
|
||||
command: >
|
||||
cd $GITPOD_REPO_ROOT/frontend/;
|
||||
gp sync-await 'frontend-yarn';
|
||||
npx shadow-cljs watch main
|
||||
|
||||
- init: gp await-port 5432 && psql -f $GITPOD_REPO_ROOT/docker/gitpod/files/postgresql_init.sql
|
||||
name: backend
|
||||
command: >
|
||||
cd $GITPOD_REPO_ROOT/backend/;
|
||||
./scripts/start-dev
|
||||
|
||||
- name: exporter shadow watch
|
||||
command:
|
||||
cd $GITPOD_REPO_ROOT/exporter/;
|
||||
gp sync-await 'frontend-yarn';
|
||||
yarn && npx shadow-cljs watch main
|
||||
|
||||
- name: exporter web server
|
||||
command: >
|
||||
cd $GITPOD_REPO_ROOT/exporter/;
|
||||
./scripts/wait-and-start.sh
|
||||
|
||||
- name: signed terminal
|
||||
before: >
|
||||
[[ ! -z ${GNUGPG} ]] &&
|
||||
cd ~ &&
|
||||
rm -rf .gnupg &&
|
||||
echo ${GNUGPG} | base64 -d | tar --no-same-owner -xzvf -
|
||||
init: >
|
||||
[[ ! -z ${GNUGPG_KEY} ]] &&
|
||||
git config --global commit.gpgsign true &&
|
||||
git config --global user.signingkey ${GNUGPG_KEY}
|
||||
command: cd $GITPOD_REPO_ROOT
|
||||
|
||||
- name: redis
|
||||
command: redis-server
|
||||
|
||||
- before: go get github.com/mailhog/MailHog
|
||||
name: mailhog
|
||||
command: MailHog
|
||||
|
||||
- name: Nginx
|
||||
command: >
|
||||
nginx &&
|
||||
multitail /var/log/nginx/access.log -I /var/log/nginx/error.log
|
||||
@@ -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
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
dist: xenial
|
||||
|
||||
language: generic
|
||||
sudo: required
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.m2
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- develop
|
||||
|
||||
install:
|
||||
- curl -O https://download.clojure.org/install/linux-install-1.10.1.447.sh
|
||||
- chmod +x linux-install-1.10.1.447.sh
|
||||
- sudo ./linux-install-1.10.1.447.sh
|
||||
|
||||
before_script:
|
||||
- env | sort
|
||||
|
||||
script:
|
||||
- ./manage.sh build-devenv
|
||||
- ./manage.sh run-frontend-tests
|
||||
- ./manage.sh run-backend-tests
|
||||
- ./manage.sh build-images
|
||||
- ./manage.sh run
|
||||
|
||||
after_script:
|
||||
- docker images
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
|
||||
env:
|
||||
- NODE_VERSION=10.16.0
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
enableGlobalCache: true
|
||||
|
||||
enableImmutableCache: false
|
||||
|
||||
enableImmutableInstalls: false
|
||||
|
||||
enableTelemetry: false
|
||||
|
||||
httpTimeout: 600000
|
||||
|
||||
nodeLinker: node-modules
|
||||
@@ -1,104 +0,0 @@
|
||||
# AI Agent Guide
|
||||
|
||||
This document provides the core context and operating guidelines for AI agents
|
||||
working in this repository.
|
||||
|
||||
## Before You Start
|
||||
|
||||
Before responding to any user request, you must:
|
||||
|
||||
1. Read this file completely.
|
||||
2. Identify which modules are affected by the task.
|
||||
3. Load the `AGENTS.md` file **only** for each affected module (see the
|
||||
architecture table below). Not all modules have an `AGENTS.md` — verify the
|
||||
file exists before attempting to read it.
|
||||
4. Do **not** load `AGENTS.md` files for unrelated modules.
|
||||
|
||||
## Role: Senior Software Engineer
|
||||
|
||||
You are a high-autonomy Senior Full-Stack Software Engineer. You have full
|
||||
permission to navigate the codebase, modify files, and execute commands to
|
||||
fulfill your tasks. Your goal is to solve complex technical tasks with high
|
||||
precision while maintaining a strong focus on maintainability and performance.
|
||||
|
||||
### Operational Guidelines
|
||||
|
||||
1. Before writing code, describe your plan. If the task is complex, break it
|
||||
down into atomic steps.
|
||||
2. Be concise and autonomous.
|
||||
3. Do **not** touch unrelated modules unless the task explicitly requires it.
|
||||
4. Commit only when explicitly asked. Follow the commit format rules in
|
||||
`CONTRIBUTING.md`.
|
||||
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
||||
`.gitignore` by default.
|
||||
|
||||
## Changelogs
|
||||
|
||||
The project has two changelogs:
|
||||
|
||||
- **Main project changelog**: `CHANGES.md` (root of the repository). Tracks changes for the core Penpot application (backend, frontend, common, render-wasm, exporter, mcp).
|
||||
- **Plugins changelog**: `plugins/CHANGELOG.md`. Tracks changes for the plugins subproject only.
|
||||
|
||||
When making changes, add a changelog entry to the appropriate file under the
|
||||
`## <version> (Unreleased)` section in the correct category
|
||||
(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`).
|
||||
|
||||
## GitHub Operations
|
||||
|
||||
To obtain the list of repository members/collaborators:
|
||||
|
||||
```bash
|
||||
gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login'
|
||||
```
|
||||
|
||||
To obtain the list of open PRs authored by members:
|
||||
|
||||
```bash
|
||||
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
||||
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
||||
($members | split("|")) as $m |
|
||||
.[] | select(.author.login as $a | $m | index($a)) |
|
||||
"\(.number)\t\(.author.login)\t\(.title)"
|
||||
'
|
||||
```
|
||||
|
||||
To obtain the list of open PRs from external contributors (non-members):
|
||||
|
||||
```bash
|
||||
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
||||
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
||||
($members | split("|")) as $m |
|
||||
.[] | select(.author.login as $a | $m | index($a) | not) |
|
||||
"\(.number)\t\(.author.login)\t\(.title)"
|
||||
'
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Penpot is an open-source design tool composed of several modules:
|
||||
|
||||
| Directory | Language | Purpose | Has `AGENTS.md` |
|
||||
|-----------|----------|---------|:----------------:|
|
||||
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes |
|
||||
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes |
|
||||
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes |
|
||||
| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes |
|
||||
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No |
|
||||
| `mcp/` | TypeScript | Model Context Protocol integration | No |
|
||||
| `plugins/` | TypeScript | Plugin runtime and example plugins | No |
|
||||
|
||||
Some submodules use `pnpm` workspaces. The root `package.json` and
|
||||
`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`.
|
||||
|
||||
### Module Dependency Graph
|
||||
|
||||
```
|
||||
frontend ──> common
|
||||
backend ──> common
|
||||
exporter ──> common
|
||||
frontend ──> render-wasm (loads compiled WASM)
|
||||
```
|
||||
|
||||
`common` is referenced as a local dependency (`{:local/root "../common"}`) by
|
||||
both `frontend` and `backend`. Changes to `common` can therefore affect multiple
|
||||
modules — test across consumers when modifying shared code.
|
||||
+9
-255
@@ -1,264 +1,22 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.15.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix mcp related internal config for docker images [Github #9565](https://github.com/penpot/penpot/pull/9565)
|
||||
|
||||
|
||||
## 2.15.1
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add support for chunked uploading of fonts [Github #9560](https://github.com/penpot/penpot/issues/9560)
|
||||
|
||||
|
||||
## 2.15.0
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
|
||||
(PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
|
||||
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #9516](https://github.com/penpot/penpot/issues/9516)
|
||||
(PR: [#8909](https://github.com/penpot/penpot/pull/8909))
|
||||
- Add anonymous telemetry event collection [Github #9467](https://github.com/penpot/penpot/issues/9467)
|
||||
(PR: [#9065](https://github.com/penpot/penpot/pull/9065), [#9483](https://github.com/penpot/penpot/pull/9483))
|
||||
- Improve team name validation [Github #9517](https://github.com/penpot/penpot/issues/9517)
|
||||
(PR: [#9176](https://github.com/penpot/penpot/pull/9176))
|
||||
- Enhance readability of applied tokens in plugins API [Github #9175](https://github.com/penpot/penpot/issues/9175)
|
||||
(PR: [#8607](https://github.com/penpot/penpot/pull/8607))
|
||||
- Encourage use of flex/grid layouts in designs generated via MCP [Github #9081](https://github.com/penpot/penpot/issues/9081)
|
||||
(PR: [#9084](https://github.com/penpot/penpot/pull/9084))
|
||||
- Improve MCP server logging, adding Loki support [Github #9415](https://github.com/penpot/penpot/issues/9415)
|
||||
(PR: [#9425](https://github.com/penpot/penpot/pull/9425))
|
||||
- Add security headers to Nginx on Docker images [Github #9519](https://github.com/penpot/penpot/issues/9519)
|
||||
(PR: [#9473](https://github.com/penpot/penpot/pull/9473))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346)
|
||||
(PR: [#9355](https://github.com/penpot/penpot/pull/9355))
|
||||
- Reduce memory usage of MCP server when handling images (by @opcode81) [Github #9420](https://github.com/penpot/penpot/issues/9420)
|
||||
(PR: [#9431](https://github.com/penpot/penpot/pull/9431))
|
||||
- Fix Plugin API token methods rejecting JS array of strings (by @boskodev790) [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
||||
(PR: [#9166](https://github.com/penpot/penpot/pull/9166))
|
||||
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
||||
(PR: [#9126](https://github.com/penpot/penpot/pull/9126), [#9233](https://github.com/penpot/penpot/pull/9233))
|
||||
- Fix empty warning on login [Github #9520](https://github.com/penpot/penpot/issues/9520)
|
||||
(PR: [#9056](https://github.com/penpot/penpot/pull/9056))
|
||||
- Fix maximum call stack size exceeded in SSE read-stream [Github #9470](https://github.com/penpot/penpot/issues/9470)
|
||||
(PR: [#9484](https://github.com/penpot/penpot/pull/9484))
|
||||
- Fix incorrect handling of version restore operation [Github #9515](https://github.com/penpot/penpot/issues/9515)
|
||||
(PR: [#9041](https://github.com/penpot/penpot/pull/9041))
|
||||
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9518](https://github.com/penpot/penpot/issues/9518)
|
||||
(PR: [#9400](https://github.com/penpot/penpot/pull/9400))
|
||||
- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238)
|
||||
(PR: [#9239](https://github.com/penpot/penpot/pull/9239))
|
||||
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9496](https://github.com/penpot/penpot/issues/9496)
|
||||
(PR: [#9322](https://github.com/penpot/penpot/pull/9322))
|
||||
- Fix multiple selection on shapes with token applied to stroke color [Github #9522](https://github.com/penpot/penpot/issues/9522)
|
||||
(PR: [#9110](https://github.com/penpot/penpot/pull/9110))
|
||||
- Fix onboarding modals appearing behind libraries and templates panel [Github #9521](https://github.com/penpot/penpot/issues/9521)
|
||||
(PR: [#9178](https://github.com/penpot/penpot/pull/9178))
|
||||
- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9430](https://github.com/penpot/penpot/issues/9430)
|
||||
(PR: [#9435](https://github.com/penpot/penpot/pull/9435))
|
||||
|
||||
## 2.14.5
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix incorrect invitation token handling on register process [Github #9380](https://github.com/penpot/penpot/pull/9380)
|
||||
|
||||
## 2.14.4
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix email validation [Taiga #14006](https://tree.taiga.io/project/penpot/issue/14006)
|
||||
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
|
||||
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
|
||||
|
||||
|
||||
## 2.14.3
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870)
|
||||
- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882)
|
||||
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
|
||||
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
|
||||
- Fix highlight on frames after rename [Github #8938](https://github.com/penpot/penpot/pull/8938)
|
||||
- Fix TypeError in sd-token-uuid when resolving tokens interactively [Github #8929](https://github.com/penpot/penpot/pull/8929)
|
||||
- Fix path drawing preview passing shape instead of content to next-node
|
||||
- Fix swapped arguments in CLJS PathData `-nth` with default
|
||||
- Normalize PathData coordinates to safe integer bounds on read
|
||||
- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962)
|
||||
- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963)
|
||||
- Fix "Move to" menu allowing same project as target when multiple files are selected
|
||||
- Fix crash when index query param is duplicated in URL
|
||||
- Fix wrong extremity point in path `calculate-extremities` for line-to segments
|
||||
- Fix reversed args in DTCG shadow composite token conversion
|
||||
- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?`
|
||||
- Fix wrong `mapcat` call in `collect-main-shapes`
|
||||
- Fix stale accumulator in `get-children-in-instance` recursion
|
||||
- Fix typo `:podition` in swap-shapes grid cell
|
||||
- Fix multiple selection on shapes with token applied to stroke color
|
||||
|
||||
|
||||
## 2.14.2
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- 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
|
||||
## 2.13.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
- Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`.
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990)
|
||||
- Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202)
|
||||
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966)
|
||||
- Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916)
|
||||
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
|
||||
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
|
||||
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
|
||||
- Fix prototype connections lost when switching between variants [Taiga #12812](https://tree.taiga.io/project/penpot/issue/12812)
|
||||
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040)
|
||||
- Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955)
|
||||
- Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012)
|
||||
- Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051)
|
||||
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
|
||||
- Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058)
|
||||
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
|
||||
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
|
||||
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
|
||||
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
|
||||
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
|
||||
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
|
||||
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
|
||||
- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463)
|
||||
- Fix warning when clicking on number token pills [Taiga #13661](https://tree.taiga.io/project/penpot/issue/13661)
|
||||
- Fix 'not ISeqable' error when entering float values in layout item and opacity inputs [Github #8569](https://github.com/penpot/penpot/pull/8569)
|
||||
- Fix crash in select component when options vector is empty [Github #8578](https://github.com/penpot/penpot/pull/8578)
|
||||
- Fix scroll on colorpicker [Taiga #13623](https://tree.taiga.io/project/penpot/issue/13623)
|
||||
- Fix crash when pasting non-map transit clipboard data [Github #8580](https://github.com/penpot/penpot/pull/8580)
|
||||
- Fix `penpot.openPage()` plugin API not navigating in the same tab; change default to same-tab navigation and allow passing a UUID string instead of a Page object [Github #8520](https://github.com/penpot/penpot/issues/8520)
|
||||
|
||||
## 2.13.3
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Revert yetti (http server) update, because that caused a regression on multipart uploads
|
||||
|
||||
## 2.13.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix modifying shapes by apply negative tokens to border radius [Taiga #13317](https://tree.taiga.io/project/penpot/issue/13317)
|
||||
- Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)
|
||||
|
||||
## 2.13.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix PDF Exporter outputs empty page when board has A4 format [Taiga #13181](https://tree.taiga.io/project/penpot/issue/13181)
|
||||
|
||||
## 2.13.0
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add new Box Shadow Tokens [Taiga #10201](https://tree.taiga.io/project/penpot/us/10201)
|
||||
- Make i18n translation files load on-demand [Taiga #11474](https://tree.taiga.io/project/penpot/us/11474)
|
||||
- Add deleted files to dashboard [Taiga #8149](https://tree.taiga.io/project/penpot/us/8149)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem when drag+duplicate a full grid [Taiga #12565](https://tree.taiga.io/project/penpot/issue/12565)
|
||||
- Fix problem when pasting elements in reverse flex layout [Taiga #12460](https://tree.taiga.io/project/penpot/issue/12460)
|
||||
- Fix wrong board size presets in Android [Taiga #12339](https://tree.taiga.io/project/penpot/issue/12339)
|
||||
- Fix problem with grid layout components and auto sizing [Github #7797](https://github.com/penpot/penpot/issues/7797)
|
||||
- Fix some alignments on inspect tab [Taiga #12915](https://tree.taiga.io/project/penpot/issue/12915)
|
||||
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
|
||||
- Fix color assets from shared libraries not appearing as assets in Selected colors panel [Taiga #12957](https://tree.taiga.io/project/penpot/issue/12957)
|
||||
- Fix CSS generated box-shadow property [Taiga #12997](https://tree.taiga.io/project/penpot/issue/12997)
|
||||
- Fix inner shadow selector on shadow token [Taiga #12951](https://tree.taiga.io/project/penpot/issue/12951)
|
||||
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
|
||||
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
|
||||
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
|
||||
- Fix 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 unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
|
||||
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
|
||||
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
|
||||
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
|
||||
- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229)
|
||||
- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184)
|
||||
- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219)
|
||||
|
||||
## 2.12.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix setting a portion of text as bold or underline messes things up [Github #7980](https://github.com/penpot/penpot/issues/7980)
|
||||
- Fix problem with style in fonts input [Taiga #12935](https://tree.taiga.io/project/penpot/issue/12935)
|
||||
- Fix problem with path editor and right click [Github #7917](https://github.com/penpot/penpot/issues/7917)
|
||||
|
||||
## 2.12.0
|
||||
## 2.12.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
@@ -269,6 +27,7 @@ The backend RPC API URLS are changed from `/api/rpc/command/<name>` to
|
||||
compatibility; however, if you are a user of this API, it is strongly
|
||||
recommended that you adapt your code to use the new PATH.
|
||||
|
||||
|
||||
#### Updated SSO Callback URL
|
||||
|
||||
The OAuth / Single Sign-On (SSO) callback endpoint has changed to
|
||||
@@ -301,6 +60,7 @@ This update standardizes all authentication flows under the single URL
|
||||
and makis it more modular, enabling the ability to configure SSO auth
|
||||
provider dinamically.
|
||||
|
||||
|
||||
#### Changes on default docker compose
|
||||
|
||||
We have updated the `docker/images/docker-compose.yaml` with a small
|
||||
@@ -317,8 +77,6 @@ example. It's still usable as before, we just removed the example.
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
- Ensure consistent snap behavior across all zoom levels [Github #7774](https://github.com/penpot/penpot/pull/7774) by [@Tokytome](https://github.com/Tokytome)
|
||||
- Fix crash in token grid view due to tooltip validation (by @dfelinto) [Github #7887](https://github.com/penpot/penpot/pull/7887)
|
||||
- Enable Hindi translations on the application
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@@ -334,8 +92,10 @@ example. It's still usable as before, we just removed the example.
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252)
|
||||
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
|
||||
- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985)
|
||||
- Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974)
|
||||
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
|
||||
- Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371)
|
||||
- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437)
|
||||
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
|
||||
@@ -344,13 +104,6 @@ example. It's still usable as before, we just removed the example.
|
||||
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
|
||||
- Fix copy/pasting application/transit+json [Taiga #12721](https://tree.taiga.io/project/penpot/issue/12721)
|
||||
- Fix problem with plugins content attribute [Plugins #209](https://github.com/penpot/penpot-plugins/issues/209)
|
||||
- Fix U and E icon displayed in project list [Taiga #12806](https://tree.taiga.io/project/penpot/issue/12806)
|
||||
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
|
||||
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
|
||||
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
|
||||
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
|
||||
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
|
||||
- Fix unicode handling on email template abbreviation filter [Github #7966](https://github.com/penpot/penpot/pull/7966)
|
||||
|
||||
## 2.11.1
|
||||
|
||||
@@ -362,6 +115,7 @@ example. It's still usable as before, we just removed the example.
|
||||
|
||||
- Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be
|
||||
removed in future versions:
|
||||
|
||||
- The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its
|
||||
values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`)
|
||||
- The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY`
|
||||
@@ -416,7 +170,7 @@ example. It's still usable as before, we just removed the example.
|
||||
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
|
||||
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
|
||||
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
|
||||
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #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 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)
|
||||
|
||||
+159
-235
@@ -1,289 +1,213 @@
|
||||
# Contributing Guide
|
||||
# Contributing Guide #
|
||||
|
||||
Thank you for your interest in contributing to Penpot. This guide covers
|
||||
how to propose changes, submit fixes, and follow project conventions.
|
||||
Thank you for your interest in contributing to Penpot. This is a
|
||||
generic guide that details how to contribute to the project in a way that
|
||||
is efficient for everyone. If you are looking for specific documentation on
|
||||
different parts of the platform, please refer to the `docs/` directory,
|
||||
or the rendered version at the [Help Center](https://help.penpot.app/).
|
||||
|
||||
For architecture details, module-specific guidelines, and AI-agent
|
||||
instructions, see [AGENTS.md](AGENTS.md). For final user technical
|
||||
documentation, see the `docs/` directory or the rendered [Help
|
||||
Center](https://help.penpot.app/).
|
||||
## Reporting Bugs ##
|
||||
|
||||
## Table of Contents
|
||||
We are using [GitHub Issues](https://github.com/penpot/penpot/issues)
|
||||
for our public bugs. We keep a close eye on them and try to make it
|
||||
clear when we have an internal fix in progress. Before filing a new
|
||||
task, try to make sure your problem doesn't already exist.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Workflow](#workflow)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
- [Branch naming](#branch-naming)
|
||||
- [Review process](#review-process)
|
||||
- [What we won't accept](#what-we-wont-accept)
|
||||
- [Good first issues](#good-first-issues)
|
||||
- [Commit Guidelines](#commit-guidelines)
|
||||
- [Commit types](#commit-types)
|
||||
- [Rules](#rules)
|
||||
- [Examples](#examples)
|
||||
- [Formatting and Linting](#formatting-and-linting)
|
||||
- [Changelog](#changelog)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Developer's Certificate of Origin (DCO)](#developers-certificate-of-origin-dco)
|
||||
If you found a bug, please report it, as far as possible, with:
|
||||
|
||||
## Prerequisites
|
||||
- a detailed explanation of steps to reproduce the error
|
||||
- the browser and browser version used
|
||||
- a dev tools console exception stack trace (if available)
|
||||
|
||||
- **Language**: Penpot is written primarily in Clojure (backend), ClojureScript
|
||||
(frontend/exporter), and Rust (render-wasm). Familiarity with the Clojure
|
||||
ecosystem is expected for most contributions.
|
||||
- **Issue tracker**: We use [GitHub Issues](https://github.com/penpot/penpot/issues)
|
||||
for public bugs and [Taiga](https://tree.taiga.io/project/penpot/) for
|
||||
internal project management. Changelog entries reference both.
|
||||
If you found a bug which you think is better to discuss in private (for
|
||||
example, security bugs), consider first sending an email to
|
||||
`support@penpot.app`.
|
||||
|
||||
## Reporting Bugs
|
||||
**We don't have a formal bug bounty program for security reports; this
|
||||
is an open source application, and your contribution will be recognized
|
||||
in the changelog.**
|
||||
|
||||
Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues).
|
||||
Before filing, search existing issues to avoid duplicates.
|
||||
|
||||
Include the following when possible:
|
||||
## Pull Requests ##
|
||||
|
||||
1. Steps to reproduce the error.
|
||||
2. Browser and browser version used.
|
||||
3. DevTools console exception stack trace (if available).
|
||||
If you want to propose a change or bug fix via a pull request (PR),
|
||||
you should first carefully read the section **Developer's Certificate of
|
||||
Origin**. You must also format your code and commits according to the
|
||||
instructions below.
|
||||
|
||||
For security bugs or issues better discussed in private, email
|
||||
`support@penpot.app` or report them on [Github Security
|
||||
Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||
If you intend to fix a bug, it's fine to submit a pull request right
|
||||
away, but we still recommend filing an issue detailing what you're
|
||||
fixing. This is helpful in case we don't accept that specific fix but
|
||||
want to keep track of the issue.
|
||||
|
||||
> **Note:** We do not have a formal bug bounty program. Security
|
||||
> contributions are recognized in the changelog.
|
||||
If you want to implement or start working on a new feature, please
|
||||
open a **question*- / **discussion*- issue for it. No PR
|
||||
will be accepted without a prior discussion about the changes,
|
||||
whether it is a new feature, an already planned one, or a quick win.
|
||||
|
||||
## Pull Requests
|
||||
If it is your first PR, you can learn how to proceed from
|
||||
[this free video
|
||||
series](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github)
|
||||
|
||||
### Workflow
|
||||
We use the `easy fix` tag to indicate issues that are appropriate for beginners.
|
||||
|
||||
1. **Read the DCO** — see [Developer's Certificate of Origin](#developers-certificate-of-origin-dco)
|
||||
below. All code patches must include a `Signed-off-by` line.
|
||||
2. **Discuss before building** — open a [GitHub
|
||||
Issue](https://github.com/penpot/penpot/issues) or start a [GitHub
|
||||
Discussion](https://github.com/penpot/penpot/discussions) before starting
|
||||
work on a new feature or significant change. For planned features on the
|
||||
roadmap, reference the corresponding Taiga story. No PR will be accepted
|
||||
without prior discussion, whether it is a new feature, a planned one, or a
|
||||
quick win.
|
||||
3. **Bug fixes** — you may submit a PR directly, but we still recommend
|
||||
filing an issue first so we can track it independently of your fix.
|
||||
4. **Format and lint** — run the checks described in
|
||||
[Formatting and Linting](#formatting-and-linting) before submitting.
|
||||
## Commit Guidelines ##
|
||||
|
||||
### Title format
|
||||
We have very precise rules on how our git commit messages must be formatted.
|
||||
|
||||
Pull request titles **must** follow the same convention as commit subjects:
|
||||
The commit message format is:
|
||||
|
||||
```
|
||||
:emoji: <subject>
|
||||
```
|
||||
|
||||
- Use the **imperative mood** (e.g. "Fix", not "Fixed").
|
||||
- Capitalize the first letter of the subject.
|
||||
- Do not end the subject with a period.
|
||||
- Keep the subject to **70 characters** or fewer.
|
||||
- Use one of the [commit type emojis](#commit-types) listed below.
|
||||
|
||||
When a PR contains multiple unrelated commits, choose the emoji that
|
||||
best represents the dominant change.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
:bug: Fix unexpected error on launching modal
|
||||
:sparkles: Enable new modal for profile
|
||||
:zap: Improve performance of dashboard navigation
|
||||
```
|
||||
|
||||
> **Note:** When a PR is squash-merged, the PR title becomes the
|
||||
> commit message on the main branch. Getting the title right matters.
|
||||
|
||||
### Description
|
||||
|
||||
Every pull request should include a description that helps reviewers
|
||||
understand the change quickly:
|
||||
|
||||
1. **What and why** — describe the change and its motivation.
|
||||
2. **Link related issues** — use `Closes #1234` or reference a Taiga
|
||||
story (e.g. `Taiga #5678`).
|
||||
3. **Screenshots or recordings** — required for any UI-visible change.
|
||||
4. **Testing notes** — how did you verify the change? Any edge cases?
|
||||
5. **Breaking changes** — call out anything that affects existing users
|
||||
or requires migration steps.
|
||||
|
||||
### Branch naming
|
||||
|
||||
Use a descriptive branch name that reflects the type and scope of the
|
||||
change:
|
||||
|
||||
```
|
||||
<type>/<short-description>
|
||||
```
|
||||
|
||||
Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`.
|
||||
|
||||
Optionally include the issue number:
|
||||
|
||||
```
|
||||
fix/9122-email-blacklisting
|
||||
feat/export-webp
|
||||
refactor/layout-sizing
|
||||
```
|
||||
|
||||
### Review process
|
||||
|
||||
- Maintainers review PRs when time permits. Please be patient.
|
||||
- Address review feedback by **pushing new commits** — do not
|
||||
force-push during review, as it breaks comment threads.
|
||||
- PRs require at least **one approval** before merge.
|
||||
- We use **squash-merge** by default. The PR title becomes the final
|
||||
commit message, so follow the [title format](#title-format) above.
|
||||
|
||||
### What we won't accept
|
||||
|
||||
To save time on both sides, please avoid submitting PRs that:
|
||||
|
||||
- Introduce new dependencies without prior discussion.
|
||||
- Change the build system or CI configuration without maintainer
|
||||
approval.
|
||||
- Mix unrelated changes in a single PR — keep PRs focused on one
|
||||
concern.
|
||||
- Skip the [discussion step](#workflow) for non-bug-fix changes.
|
||||
|
||||
### Good first issues
|
||||
|
||||
We use the `easy fix` label to mark issues appropriate for newcomers.
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
Commit messages must follow this format:
|
||||
|
||||
```
|
||||
:emoji: <subject>
|
||||
<type> <subject>
|
||||
|
||||
[body]
|
||||
|
||||
[footer]
|
||||
```
|
||||
|
||||
### Commit types
|
||||
Where type is:
|
||||
|
||||
| Emoji | Description |
|
||||
|-------|-------------|
|
||||
| :bug: | Bug fix |
|
||||
| :sparkles: | Improvement or enhancement |
|
||||
| :tada: | New feature |
|
||||
| :recycle: | Refactor |
|
||||
| :lipstick: | Cosmetic changes |
|
||||
| :ambulance: | Critical bug fix |
|
||||
| :books: | Documentation |
|
||||
| :construction: | Work in progress |
|
||||
| :boom: | Breaking change |
|
||||
| :wrench: | Configuration update |
|
||||
| :zap: | Performance improvement |
|
||||
| :whale: | Docker-related change |
|
||||
| :paperclip: | Other non-relevant changes |
|
||||
| :arrow_up: | Dependency update |
|
||||
| :arrow_down: | Dependency downgrade |
|
||||
| :fire: | Removal of code or files |
|
||||
| :globe_with_meridians: | Add or update translations |
|
||||
| :rocket: | Epic or highlight |
|
||||
- :bug: `:bug:` a commit that fixes a bug
|
||||
- :sparkles: `:sparkles:` a commit that adds an improvement
|
||||
- :tada: `:tada:` a commit with a new feature
|
||||
- :recycle: `:recycle:` a commit that introduces a refactor
|
||||
- :lipstick: `:lipstick:` a commit with cosmetic changes
|
||||
- :ambulance: `:ambulance:` a commit that fixes a critical bug
|
||||
- :books: `:books:` a commit that improves or adds documentation
|
||||
- :construction: `:construction:` a WIP commit
|
||||
- :boom: `:boom:` a commit with breaking changes
|
||||
- :wrench: `:wrench:` a commit for config updates
|
||||
- :zap: `:zap:` a commit with performance improvements
|
||||
- :whale: `:whale:` a commit for Docker-related stuff
|
||||
- :paperclip: `:paperclip:` a commit with other non-relevant changes
|
||||
- :arrow_up: `:arrow_up:` a commit with dependency updates
|
||||
- :arrow_down: `:arrow_down:` a commit with dependency downgrades
|
||||
- :fire: `:fire:` a commit that removes files or code
|
||||
- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates
|
||||
translations
|
||||
|
||||
### Rules
|
||||
More info:
|
||||
|
||||
- Use the **imperative mood** in the subject (e.g. "Fix", not "Fixed")
|
||||
- Capitalize the first letter of the subject
|
||||
- Add clear and concise description on the body
|
||||
- Do not end the subject with a period
|
||||
- Keep the subject to **70 characters** or fewer
|
||||
- Separate the subject from the body with a **blank line**
|
||||
- https://gist.github.com/parmentf/035de27d6ed1dce0b36a
|
||||
- https://gist.github.com/rxaviers/7360908
|
||||
|
||||
### Examples
|
||||
Each commit should have:
|
||||
|
||||
```
|
||||
:bug: Fix unexpected error on launching modal
|
||||
:sparkles: Enable new modal for profile
|
||||
:zap: Improve performance of dashboard navigation
|
||||
:ambulance: Fix critical bug on user registration process
|
||||
:tada: Add new approach for user registration
|
||||
```
|
||||
- A concise subject using the imperative mood.
|
||||
- The subject should capitalize the first letter, omit the period
|
||||
at the end, and be no longer than 65 characters.
|
||||
- A blank line between the subject line and the body.
|
||||
- An entry in the CHANGES.md file if applicable, referencing the
|
||||
GitHub or Taiga issue/user story using these same rules.
|
||||
|
||||
## Formatting and Linting
|
||||
Examples of good commit messages:
|
||||
|
||||
We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
|
||||
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
|
||||
- `:bug: Fix unexpected error on launching modal`
|
||||
- `:bug: Set proper error message on generic error`
|
||||
- `:sparkles: Enable new modal for profile`
|
||||
- `:zap: Improve performance of dashboard navigation`
|
||||
- `:wrench: Update default backend configuration`
|
||||
- `:books: Add more documentation for authentication process`
|
||||
- `:ambulance: Fix critical bug on user registration process`
|
||||
- `:tada: Add new approach for user registration`
|
||||
|
||||
## Formatting and Linting ##
|
||||
|
||||
You will want to make sure your code is formatted and linted before submitting
|
||||
a PR. We use [cljfmt](https://github.com/weavejester/cljfmt) and
|
||||
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for this. After installing
|
||||
them on your system, you can run them with:
|
||||
|
||||
```bash
|
||||
# Check formatting (does not modify files)
|
||||
./scripts/check-fmt
|
||||
# Check formatting
|
||||
yarn fmt:clj:check
|
||||
|
||||
# Fix formatting (modifies files in place)
|
||||
./scripts/fmt
|
||||
# Check and fix formatting
|
||||
yarn fmt:clj
|
||||
|
||||
# Lint
|
||||
./scripts/lint
|
||||
# Run the linter
|
||||
yarn lint:clj
|
||||
```
|
||||
|
||||
Ideally, run these as git pre-commit hooks.
|
||||
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
||||
setting this up.
|
||||
There are more choices in `package.json`.
|
||||
|
||||
## Changelog
|
||||
Ideally, you should run these commands as git pre-commit hooks. A convenient way
|
||||
of defining them is to use [Husky](https://typicode.github.io/husky/#/).
|
||||
|
||||
When your change is user-facing or otherwise notable, add an entry to
|
||||
[CHANGES.md](CHANGES.md) following the same commit-type conventions. Reference
|
||||
the relevant GitHub issue or Taiga user story.
|
||||
## Code of Conduct ##
|
||||
|
||||
## Code of Conduct
|
||||
As contributors and maintainers of this project, we pledge to respect
|
||||
all people who contribute through reporting issues, posting feature
|
||||
requests, updating documentation, submitting pull requests or patches,
|
||||
and other activities.
|
||||
|
||||
This project follows the [Contributor Covenant](https://www.contributor-covenant.org/).
|
||||
The full Code of Conduct is available at
|
||||
[help.penpot.app/contributing-guide/coc](https://help.penpot.app/contributing-guide/coc/)
|
||||
and in the repository's [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
||||
We are committed to making participation in this project a
|
||||
harassment-free experience for everyone, regardless of level of
|
||||
experience, gender, gender identity and expression, sexual
|
||||
orientation, disability, personal appearance, body size, race,
|
||||
ethnicity, age, or religion.
|
||||
|
||||
To report unacceptable behavior, open an issue or contact a project maintainer
|
||||
directly.
|
||||
Examples of unacceptable behavior by participants include the use of
|
||||
sexual language or imagery, derogatory comments or personal attacks,
|
||||
trolling, public or private harassment, insults, or other
|
||||
unprofessional conduct.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit,
|
||||
or reject comments, commits, code, wiki edits, issues, and other
|
||||
contributions that are not aligned with this Code of Conduct. Project
|
||||
maintainers who do not follow the Code of Conduct may be removed from
|
||||
the project team.
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public
|
||||
spaces when an individual is representing the project or its
|
||||
community.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior
|
||||
may be reported by opening an issue or contacting one or more of the
|
||||
project maintainers.
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version
|
||||
1.1.0, available from [http://contributor-covenant.org/version/1/1/0/](http://contributor-covenant.org/version/1/1/0/)
|
||||
|
||||
## Developer's Certificate of Origin (DCO)
|
||||
|
||||
By submitting code you agree to and can certify the following:
|
||||
|
||||
> **Developer's Certificate of Origin 1.1**
|
||||
>
|
||||
> By making a contribution to this project, I certify that:
|
||||
>
|
||||
> (a) The contribution was created in whole or in part by me and I have the
|
||||
> right to submit it under the open source license indicated in the file; or
|
||||
>
|
||||
> (b) The contribution is based upon previous work that, to the best of my
|
||||
> knowledge, is covered under an appropriate open source license and I have
|
||||
> the right under that license to submit that work with modifications,
|
||||
> whether created in whole or in part by me, under the same open source
|
||||
> license (unless I am permitted to submit under a different license), as
|
||||
> indicated in the file; or
|
||||
>
|
||||
> (c) The contribution was provided directly to me by some other person who
|
||||
> certified (a), (b) or (c) and I have not modified it.
|
||||
>
|
||||
> (d) I understand and agree that this project and the contribution are public
|
||||
> and that a record of the contribution (including all personal information
|
||||
> I submit with it, including my sign-off) is maintained indefinitely and
|
||||
> may be redistributed consistent with this project or the open source
|
||||
> license(s) involved.
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
### Signed-off-by
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
All code patches (**documentation is excluded**) must contain a sign-off line
|
||||
at the end of the commit body. Add it automatically with `git commit -s`.
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
|
||||
Then, all your code patches (**documentation is excluded**) should
|
||||
contain a sign-off at the end of the patch/commit description body. It
|
||||
can be automatically added by adding the `-s` parameter to `git commit`.
|
||||
|
||||
This is an example of what the line should look like:
|
||||
|
||||
```
|
||||
Signed-off-by: Your Real Name <your.email@example.com>
|
||||
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
|
||||
```
|
||||
|
||||
- Use your **real name** — pseudonyms and anonymous contributions are not
|
||||
allowed.
|
||||
- The `Signed-off-by` line is **mandatory** and must match the commit author.
|
||||
Please, use your real name (sorry, no pseudonyms or anonymous
|
||||
contributions are allowed).
|
||||
+1
-27
@@ -2,30 +2,4 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take the security of this project seriously. If you have discovered
|
||||
a security vulnerability, please do **not** open a public issue.
|
||||
|
||||
Please report vulnerabilities via email to: **[support@penpot.app]**
|
||||
|
||||
|
||||
### What to include:
|
||||
|
||||
* A brief description of the vulnerability.
|
||||
* Steps to reproduce the issue.
|
||||
* Potential impact if exploited.
|
||||
|
||||
We appreciate your patience and your commitment to **responsible disclosure**.
|
||||
|
||||
---
|
||||
|
||||
## Security Contributors
|
||||
|
||||
We are incredibly grateful to the following individuals and
|
||||
organizations for their help in keeping this project safe.
|
||||
|
||||
* **Ali Maharramli** – for identifying critical path traversal vulnerability
|
||||
|
||||
|
||||
> **Note:** This list is a work in progress. If you have contributed
|
||||
> to the security of this project and would like to be recognized (or
|
||||
> prefer to remain anonymous), please let us know.
|
||||
Please report security issues to `support@penpot.app`
|
||||
@@ -0,0 +1,7 @@
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
@@ -1,262 +0,0 @@
|
||||
# Penpot Backend – Agent Instructions
|
||||
|
||||
Clojure backend (RPC) service running on the JVM.
|
||||
|
||||
Uses Integrant for dependency injection, PostgreSQL for storage, and
|
||||
Redis for messaging/caching.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
To ensure consistency across the Penpot JVM stack, all contributions must adhere
|
||||
to these criteria.
|
||||
|
||||
IMPORTANT: all CLI commands should be executed under backend/
|
||||
subdirectory for make them work correctly.
|
||||
|
||||
### 1. Testing & Validation
|
||||
|
||||
* **Coverage:** If code is added or modified in `src/`, corresponding
|
||||
tests in `test/backend_tests/` must be added or updated.
|
||||
|
||||
* **Execution:**
|
||||
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace.
|
||||
* **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas.
|
||||
|
||||
### 2. Code Quality & Formatting
|
||||
|
||||
* **Linting:** All code must pass linter checks (run `pnpm run lint:clj` or `pnpm run lint` on the repository root)
|
||||
* **Formatting:** All the code must pass the formatting check (run `pnpm run
|
||||
check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty"
|
||||
diffs caused by unrelated whitespace changes.
|
||||
* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in
|
||||
performance-critical paths to avoid reflection overhead.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Namespace Overview
|
||||
|
||||
The source is located under `src` directory and this is a general overview of
|
||||
namespaces structure:
|
||||
|
||||
- `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.)
|
||||
- `app.http.*` – HTTP routes and middleware
|
||||
- `app.db.*` – Database layer
|
||||
- `app.tasks.*` – Background job tasks
|
||||
- `app.main` – Integrant system setup and entrypoint
|
||||
- `app.loggers` – Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`)
|
||||
|
||||
### RPC
|
||||
|
||||
The RPC methods are implemented using a multimethod-like structure via the
|
||||
`app.util.services` namespace. The main RPC methods are collected under
|
||||
`app.rpc.commands` namespace and exposed under `/api/rpc/command/<cmd-name>`.
|
||||
|
||||
The RPC method accepts POST and GET requests indistinctly and uses the `Accept`
|
||||
header to negotiate the response encoding (which can be Transit — the default —
|
||||
or plain JSON). It also accepts Transit (default) or JSON as input, which should
|
||||
be indicated using the `Content-Type` header.
|
||||
|
||||
The main convention is: use `get-` prefix on RPC name when we want READ
|
||||
operation.
|
||||
|
||||
Example of RPC method definition:
|
||||
|
||||
```clojure
|
||||
(sv/defmethod ::my-command
|
||||
{::rpc/auth true ;; requires auth
|
||||
::doc/added "1.18"
|
||||
::sm/params [:map ...] ;; malli input schema
|
||||
::sm/result [:map ...]} ;; malli output schema
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
;; return a plain map or throw
|
||||
{:id (uuid/next)})
|
||||
```
|
||||
|
||||
Look under `src/app/rpc/commands/*.clj` to see more examples.
|
||||
|
||||
### Tests
|
||||
|
||||
Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`.
|
||||
|
||||
|
||||
### Integrant System
|
||||
|
||||
The `src/app/main.clj` declares the system map. Each key is a component; values
|
||||
are config maps with `::ig/ref` for dependencies. Components implement
|
||||
`ig/init-key` / `ig/halt-key!`.
|
||||
|
||||
|
||||
### Connecting to the Database
|
||||
|
||||
Two PostgreSQL databases are used in this environment:
|
||||
|
||||
| Database | Purpose | Connection string |
|
||||
|---------------|--------------------|----------------------------------------------------|
|
||||
| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` |
|
||||
| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` |
|
||||
|
||||
**Interactive psql session:**
|
||||
|
||||
```bash
|
||||
# development DB
|
||||
psql "postgresql://penpot:penpot@postgres/penpot"
|
||||
|
||||
# test DB
|
||||
psql "postgresql://penpot:penpot@postgres/penpot_test"
|
||||
```
|
||||
|
||||
**One-shot query (non-interactive):**
|
||||
|
||||
```bash
|
||||
psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;"
|
||||
```
|
||||
|
||||
**Useful psql meta-commands:**
|
||||
|
||||
```
|
||||
\dt -- list all tables
|
||||
\d <table> -- describe a table (columns, types, constraints)
|
||||
\di -- list indexes
|
||||
\q -- quit
|
||||
```
|
||||
|
||||
> **Migrations table:** Applied migrations are tracked in the `migrations` table
|
||||
> with columns `module`, `step`, and `created_at`. When renaming a migration
|
||||
> logical name, update this table in both databases to match the new name;
|
||||
> otherwise the runner will attempt to re-apply the migration on next startup.
|
||||
|
||||
```bash
|
||||
# Example: fix a renamed migration entry in the test DB
|
||||
psql "postgresql://penpot:penpot@postgres/penpot_test" \
|
||||
-c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';"
|
||||
```
|
||||
|
||||
### Database Access (Clojure)
|
||||
|
||||
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
|
||||
|
||||
```clojure
|
||||
;; Query helpers
|
||||
(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing)
|
||||
(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil)
|
||||
(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows
|
||||
(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert
|
||||
(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update
|
||||
(db/delete! cfg-or-pool :table {:id id}) ; delete
|
||||
|
||||
;; Run multiple statements/queries on single connection
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(db/insert! conn :table row1)
|
||||
(db/insert! conn :table row2))
|
||||
|
||||
|
||||
;; Transactions
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||
(db/insert! conn :table row)))
|
||||
```
|
||||
|
||||
Almost all methods in the `app.db` namespace accept `pool`, `conn`, or
|
||||
`cfg` as params.
|
||||
|
||||
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
|
||||
|
||||
|
||||
### Error Handling
|
||||
|
||||
The exception helpers are defined on Common module, and are available under
|
||||
`app.common.exceptions` namespace.
|
||||
|
||||
Example of raising an exception:
|
||||
|
||||
```clojure
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "File does not exist"
|
||||
:file-id id)
|
||||
```
|
||||
|
||||
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
|
||||
|
||||
|
||||
### Performance Macros (`app.common.data.macros`)
|
||||
|
||||
Always prefer these macros over their `clojure.core` equivalents — they provide
|
||||
optimized implementations:
|
||||
|
||||
```clojure
|
||||
(dm/select-keys m [:a :b]) ;; faster than core/select-keys
|
||||
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
|
||||
(dm/str "a" "b" "c") ;; string concatenation
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with
|
||||
Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags
|
||||
:enable-smtp)`.
|
||||
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Background tasks live in `src/app/tasks/`. Each task is an Integrant component
|
||||
that exposes a `::handler` key and follows this three-method pattern:
|
||||
|
||||
```clojure
|
||||
(defmethod ig/assert-key ::handler ;; validate config at startup
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
||||
|
||||
(defmethod ig/expand-key ::handler ;; inject defaults before init
|
||||
[k v]
|
||||
{k (assoc v ::my-option default-value)})
|
||||
|
||||
(defmethod ig/init-key ::handler ;; return the task fn
|
||||
[_ cfg]
|
||||
(fn [_task] ;; receives the task row from the worker
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||
;; … do work …
|
||||
))))
|
||||
```
|
||||
|
||||
**Wiring a new task** requires two changes in `src/app/main.clj`:
|
||||
|
||||
1. **Handler config** – add an entry in `system-config` with the dependencies:
|
||||
|
||||
```clojure
|
||||
:app.tasks.my-task/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
```
|
||||
|
||||
2. **Registry + cron** – register the handler name and schedule it:
|
||||
|
||||
```clojure
|
||||
;; in ::wrk/registry ::wrk/tasks map:
|
||||
:my-task (ig/ref :app.tasks.my-task/handler)
|
||||
|
||||
;; in worker-config ::wrk/cron ::wrk/entries vector:
|
||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight
|
||||
:task :my-task}
|
||||
```
|
||||
|
||||
**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow):
|
||||
|
||||
| Expression | Meaning |
|
||||
|------------------------------|--------------------|
|
||||
| `"0 0 0 * * ?"` | Daily at midnight |
|
||||
| `"0 0 */6 * * ?"` | Every 6 hours |
|
||||
| `"0 */5 * * * ?"` | Every 5 minutes |
|
||||
|
||||
**Time helpers** (`app.common.time`):
|
||||
|
||||
```clojure
|
||||
(ct/now) ;; current instant
|
||||
(ct/duration {:hours 1}) ;; java.time.Duration
|
||||
(ct/minus (ct/now) some-duration) ;; subtract duration from instant
|
||||
```
|
||||
|
||||
`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL
|
||||
interval object suitable for use in SQL queries:
|
||||
|
||||
```clojure
|
||||
(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds"
|
||||
```
|
||||
+8
-8
@@ -3,7 +3,7 @@
|
||||
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
|
||||
@@ -28,8 +28,8 @@
|
||||
com.google.guava/guava {:mvn/version "33.4.8-jre"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v11.9"
|
||||
:git/sha "5fad7a9"
|
||||
{:git/tag "v11.8"
|
||||
:git/sha "1d1b33f"
|
||||
:git/url "https://github.com/funcool/yetti.git"
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
metosin/reitit-core {:mvn/version "0.9.1"}
|
||||
nrepl/nrepl {:mvn/version "1.4.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.9"}
|
||||
org.postgresql/postgresql {:mvn/version "42.7.7"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||
@@ -49,7 +49,7 @@
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.2"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.21.2"}
|
||||
org.im4java/im4java
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.33.10"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
@@ -97,8 +97,8 @@
|
||||
|
||||
:jmx-remote
|
||||
{:jvm-opts ["-Dcom.sun.management.jmxremote"
|
||||
"-Dcom.sun.management.jmxremote.port=9000"
|
||||
"-Dcom.sun.management.jmxremote.rmi.port=9000"
|
||||
"-Dcom.sun.management.jmxremote.port=9090"
|
||||
"-Dcom.sun.management.jmxremote.rmi.port=9090"
|
||||
"-Dcom.sun.management.jmxremote.local.only=false"
|
||||
"-Dcom.sun.management.jmxremote.authenticate=false"
|
||||
"-Dcom.sun.management.jmxremote.ssl=false"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
|
||||
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
@@ -19,9 +19,8 @@
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "clj-kondo --parallel --lint ../common/src src/",
|
||||
"check-fmt": "cljfmt check --parallel=true src/ test/",
|
||||
"fmt": "cljfmt fix --parallel=true src/ test/",
|
||||
"test": "clojure -M:dev:test"
|
||||
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||
"lint:clj": "clj-kondo --parallel --lint src/"
|
||||
}
|
||||
}
|
||||
Generated
-306
@@ -1,306 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
luxon:
|
||||
specifier: ^3.4.4
|
||||
version: 3.7.2
|
||||
sax:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.3
|
||||
devDependencies:
|
||||
nodemon:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.11
|
||||
source-map-support:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
ws:
|
||||
specifier: ^8.17.0
|
||||
version: 8.18.3
|
||||
|
||||
packages:
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
has-flag@3.0.0:
|
||||
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ignore-by-default@1.0.1:
|
||||
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-number@7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
luxon@3.7.2:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
nodemon@3.1.11:
|
||||
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
pstree.remy@1.1.8:
|
||||
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
sax@1.4.3:
|
||||
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
|
||||
source-map@0.6.1:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
touch@3.1.1:
|
||||
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
|
||||
hasBin: true
|
||||
|
||||
undefsafe@2.0.5:
|
||||
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
braces: 3.0.3
|
||||
glob-parent: 5.1.2
|
||||
is-binary-path: 2.1.0
|
||||
is-glob: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readdirp: 3.6.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
debug@4.4.3(supports-color@5.5.0):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
has-flag@3.0.0: {}
|
||||
|
||||
ignore-by-default@1.0.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nodemon@3.1.11:
|
||||
dependencies:
|
||||
chokidar: 3.6.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
ignore-by-default: 1.0.1
|
||||
minimatch: 3.1.2
|
||||
pstree.remy: 1.1.8
|
||||
semver: 7.7.3
|
||||
simple-update-notifier: 2.0.0
|
||||
supports-color: 5.5.0
|
||||
touch: 3.1.1
|
||||
undefsafe: 2.0.5
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
pstree.remy@1.1.8: {}
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
sax@1.4.3: {}
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
source-map-support@0.5.21:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
source-map: 0.6.1
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
supports-color@5.5.0:
|
||||
dependencies:
|
||||
has-flag: 3.0.0
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
touch@3.1.1: {}
|
||||
|
||||
undefsafe@2.0.5: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
Whitespace-only changes.
@@ -240,4 +240,4 @@
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
|
||||
{:id "penpot-design-system"
|
||||
:name "Penpot Design System | Pencil"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/penpot-app.penpot"}
|
||||
{:id "wireframing-kit"
|
||||
:name "Wireframe library"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
|
||||
<style>
|
||||
{% include "app/templates/styles.css" %}
|
||||
</style>
|
||||
|
||||
@@ -12,22 +12,43 @@ Debug Main Page
|
||||
</nav>
|
||||
<main class="dashboard">
|
||||
<section class="widget">
|
||||
<fieldset>
|
||||
<legend>Error reports</legend>
|
||||
<desc><a href="/dbg/error">CLICK HERE TO SEE THE ERROR REPORTS</a> </desc>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>CURRENT PROFILE</legend>
|
||||
<desc>
|
||||
<p>
|
||||
Name: <b>{{profile.fullname}}</b> <br />
|
||||
Email: <b>{{profile.email}}</b>
|
||||
</p>
|
||||
</desc>
|
||||
<legend>Profile Management</legend>
|
||||
<form method="post" action="/dbg/actions/resend-email-verification">
|
||||
<div class="row">
|
||||
<input type="email" name="email" placeholder="example@example.com" value="" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="force-verify">Are you sure?</label>
|
||||
<input id="force-verify" type="checkbox" name="force" />
|
||||
<br />
|
||||
<small>
|
||||
This is a just a security double check for prevent non intentional submits.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input type="submit" name="resend" value="Resend Verification" />
|
||||
<input type="submit" name="verify" value="Verify" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input type="submit" class="danger" name="block" value="Block" />
|
||||
<input type="submit" class="danger" name="unblock" value="Unblock" />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>VIRTUAL CLOCK</legend>
|
||||
|
||||
<desc>
|
||||
<p><b>IMPORTANT:</b> The virtual clock is profile based and only affects the currently logged-in profile.</p>
|
||||
<p>
|
||||
CURRENT CLOCK: <b>{{current-clock}}</b>
|
||||
<br />
|
||||
@@ -60,93 +81,8 @@ Debug Main Page
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>ERROR REPORTS</legend>
|
||||
<desc><a href="/dbg/error">CLICK HERE TO SEE THE ERROR REPORTS</a> </desc>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="widget">
|
||||
<fieldset>
|
||||
<legend>Profile Management</legend>
|
||||
<form method="post" action="/dbg/actions/resend-email-verification">
|
||||
<div class="row">
|
||||
<input type="email" name="email" placeholder="example@example.com" value="" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="force-verify">Are you sure?</label>
|
||||
<input id="force-verify" type="checkbox" name="force" />
|
||||
<br />
|
||||
<small>
|
||||
This is a just a security double check for prevent non intentional submits.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input type="submit" name="resend" value="Resend Verification" />
|
||||
<input type="submit" name="verify" value="Verify" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input type="submit" class="danger" name="block" value="Block" />
|
||||
<input type="submit" class="danger" name="unblock" value="Unblock" />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<fieldset>
|
||||
<legend>Feature Flags for Team</legend>
|
||||
<desc>Add a feature flag to a team</desc>
|
||||
<form method="post" action="/dbg/actions/handle-team-features">
|
||||
<div class="row">
|
||||
<input type="text" style="width:300px" name="team-id" placeholder="team-id" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<select type="text" style="width:100px" name="feature">
|
||||
{% for feature in supported-features %}
|
||||
<option value="{{feature}}">{{feature}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<select style="width:100px" name="action">
|
||||
<option value="">Action...</option>
|
||||
<option value="show">Show</option>
|
||||
<option value="enable">Enable</option>
|
||||
<option value="disable">Disable</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="check-feature">Skip feature check</label>
|
||||
<input id="check-feature" type="checkbox" name="skip-check" />
|
||||
<br />
|
||||
<small>
|
||||
Do not check if the feature is supported
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="force-version">Are you sure?</label>
|
||||
<input id="force-version" type="checkbox" name="force" />
|
||||
<br />
|
||||
<small>
|
||||
This is a just a security double check for prevent non intentional submits.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input type="submit" value="Submit" />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="widget">
|
||||
|
||||
<fieldset>
|
||||
@@ -237,5 +173,55 @@ Debug Main Page
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="widget">
|
||||
<fieldset>
|
||||
<legend>Feature Flags for Team</legend>
|
||||
<desc>Add a feature flag to a team</desc>
|
||||
<form method="post" action="/dbg/actions/handle-team-features">
|
||||
<div class="row">
|
||||
<input type="text" style="width:300px" name="team-id" placeholder="team-id" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<select type="text" style="width:100px" name="feature">
|
||||
{% for feature in supported-features %}
|
||||
<option value="{{feature}}">{{feature}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<select style="width:100px" name="action">
|
||||
<option value="">Action...</option>
|
||||
<option value="show">Show</option>
|
||||
<option value="enable">Enable</option>
|
||||
<option value="disable">Disable</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="check-feature">Skip feature check</label>
|
||||
<input id="check-feature" type="checkbox" name="skip-check" />
|
||||
<br />
|
||||
<small>
|
||||
Do not check if the feature is supported
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="force-version">Are you sure?</label>
|
||||
<input id="force-version" type="checkbox" name="force" />
|
||||
<br />
|
||||
<small>
|
||||
This is a just a security double check for prevent non intentional submits.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input type="submit" value="Submit" />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -5,26 +5,23 @@ penpot - error list
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div class="title">
|
||||
<a href="/dbg"> [BACK]</a>
|
||||
<h1>Error reports (last 300)</h1>
|
||||
|
||||
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
|
||||
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
|
||||
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="horizontal-list">
|
||||
<ul>
|
||||
{% for item in items %}
|
||||
<li>
|
||||
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
|
||||
<a class="hint" href="/dbg/error/{{item.id}}">
|
||||
<span class="title">{{item.hint|abbreviate:150}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</main>
|
||||
<nav>
|
||||
<div class="title">
|
||||
<h1>Error reports (last 200)
|
||||
<a href="/dbg">[GO BACK]</a>
|
||||
</h1>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="horizontal-list">
|
||||
<ul>
|
||||
{% for item in items %}
|
||||
<li>
|
||||
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
|
||||
<a class="hint" href="/dbg/error/{{item.id}}">
|
||||
<span class="title">{{item.hint|abbreviate:150}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
||||
<div>[<a href="/dbg/error">⮜</a>]</div>
|
||||
<div>[<a href="#head">head</a>]</div>
|
||||
<div>[<a href="#props">props</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "app/templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
||||
<div>[<a href="#head">head</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
{% if report %}
|
||||
<div>[<a href="#report">report</a>]</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<main>
|
||||
<div class="table">
|
||||
<div class="table-row multiline">
|
||||
<div id="head" class="table-key">HEAD</div>
|
||||
<div class="table-val">
|
||||
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
|
||||
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
|
||||
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
|
||||
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="context" class="table-key">CONTEXT: </div>
|
||||
|
||||
<div class="table-val">
|
||||
<pre>{{context}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if report %}
|
||||
<div class="table-row multiline">
|
||||
<div id="report" class="table-key">REPORT:</div>
|
||||
<div class="table-val">
|
||||
<pre>{{report}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -1,40 +0,0 @@
|
||||
{% extends "app/templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
||||
<div>[<a href="#head">head</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
<div>[<a href="#result">result</a>]</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="table">
|
||||
<div class="table-row multiline">
|
||||
<div id="head" class="table-key">HEAD:</div>
|
||||
<div class="table-val">
|
||||
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
|
||||
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
|
||||
<h2><span class="not-important">Report ID:</span> <br/> {{id}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="context" class="table-key">CONTEXT: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{context}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="result" class="table-key">RESULT: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{result}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
* {
|
||||
font-family: monospace;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -36,10 +36,6 @@ small {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.not-important {
|
||||
color: #888;
|
||||
font-weight: 200;
|
||||
@@ -61,26 +57,14 @@ nav {
|
||||
|
||||
nav > .title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
nav > .title > a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav > .title > a.strong {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
nav > .title > h1 {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav > .title > * {
|
||||
padding: 0px 6px;
|
||||
}
|
||||
|
||||
nav > div {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
{:default
|
||||
[[:default :window "200000/h"]]
|
||||
|
||||
;; #{:main/get-teams}
|
||||
;; #{:command/get-teams}
|
||||
;; [[:burst :bucket "5/5/5s"]]
|
||||
|
||||
;; #{:main/get-profile}
|
||||
;; #{:command/get-profile}
|
||||
;; [[:burst :bucket "60/60/1m"]]
|
||||
}
|
||||
+2
-16
@@ -1,25 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
|
||||
# DEPRECATED: only used for subscriptions
|
||||
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
||||
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_PUBLIC_URI=https://localhost:3449
|
||||
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-login-with-password \
|
||||
enable-login-with-password
|
||||
disable-login-with-ldap \
|
||||
disable-login-with-oidc \
|
||||
disable-login-with-google \
|
||||
disable-login-with-github \
|
||||
disable-login-with-gitlab \
|
||||
disable-telemetry \
|
||||
enable-backend-worker \
|
||||
enable-backend-asserts \
|
||||
disable-feature-fdata-pointer-map \
|
||||
@@ -45,10 +37,6 @@ export PENPOT_FLAGS="\
|
||||
enable-redis-cache \
|
||||
enable-subscriptions";
|
||||
|
||||
# Uncomment for nexus integration testing
|
||||
# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive";
|
||||
# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit";
|
||||
|
||||
# Default deletion delay for devenv
|
||||
export PENPOT_DELETION_DELAY="24h"
|
||||
|
||||
@@ -66,8 +54,6 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3
|
||||
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
|
||||
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
|
||||
|
||||
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/control-center
|
||||
|
||||
export JAVA_OPTS="\
|
||||
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||
-Djdk.attach.allowAttachSelf \
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
SCRIPT_DIR=$(dirname $0);
|
||||
source $SCRIPT_DIR/_env;
|
||||
|
||||
if [ -f $SCRIPT_DIR/_env.local ]; then
|
||||
source $SCRIPT_DIR/_env.local;
|
||||
fi
|
||||
|
||||
# Initialize MINIO config
|
||||
setup_minio;
|
||||
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
SCRIPT_DIR=$(dirname $0);
|
||||
|
||||
source $SCRIPT_DIR/_env;
|
||||
|
||||
if [ -f $SCRIPT_DIR/_env.local ]; then
|
||||
source $SCRIPT_DIR/_env.local;
|
||||
fi
|
||||
|
||||
export OPTIONS="-A:dev"
|
||||
|
||||
entrypoint=${1:-app.main};
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
SCRIPT_DIR=$(dirname $0);
|
||||
source $SCRIPT_DIR/_env;
|
||||
|
||||
if [ -f $SCRIPT_DIR/_env.local ]; then
|
||||
source $SCRIPT_DIR/_env.local;
|
||||
fi
|
||||
|
||||
# Initialize MINIO config
|
||||
setup_minio;
|
||||
|
||||
|
||||
@@ -36,6 +36,17 @@
|
||||
[integrant.core :as ig]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn obfuscate-string
|
||||
[s]
|
||||
(if (< (count s) 10)
|
||||
(apply str (take (count s) (repeat "*")))
|
||||
(str (subs s 0 5)
|
||||
(apply str (take (- (count s) 5) (repeat "*"))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; OIDC PROVIDER (GENERIC)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -43,7 +54,7 @@
|
||||
(defn- discover-oidc-config
|
||||
[cfg {:keys [base-uri] :as provider}]
|
||||
(let [uri (u/join base-uri ".well-known/openid-configuration")
|
||||
rsp (http/req cfg {:method :get :uri (dm/str uri)})]
|
||||
rsp (http/req! cfg {:method :get :uri (dm/str uri)})]
|
||||
|
||||
(if (= 200 (:status rsp))
|
||||
(let [data (-> rsp :body json/decode)
|
||||
@@ -105,7 +116,7 @@
|
||||
|
||||
(defn- fetch-oidc-jwks
|
||||
[cfg jwks-uri]
|
||||
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
|
||||
(let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri})]
|
||||
(if (= 200 status)
|
||||
(-> body json/decode :keys process-oidc-jwks)
|
||||
(ex/raise :type ::internal
|
||||
@@ -166,7 +177,7 @@
|
||||
(l/inf :hint "provider initialized"
|
||||
:provider (:id provider)
|
||||
:client-id (:client-id provider)
|
||||
:client-secret (d/obfuscate-string (:client-secret provider)))
|
||||
:client-secret (obfuscate-string (:client-secret provider)))
|
||||
provider)
|
||||
|
||||
(catch Throwable cause
|
||||
@@ -211,7 +222,7 @@
|
||||
(l/inf :hint "provider initialized"
|
||||
:provider (:id provider)
|
||||
:client-id (:client-id provider)
|
||||
:client-secret (d/obfuscate-string (:client-secret provider)))
|
||||
:client-secret (obfuscate-string (:client-secret provider)))
|
||||
provider)
|
||||
|
||||
(catch Throwable cause
|
||||
@@ -235,7 +246,7 @@
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
|
||||
{:keys [status body]} (http/req cfg params)]
|
||||
{:keys [status body]} (http/req! cfg params)]
|
||||
|
||||
(when-not (int-in-range? status 200 300)
|
||||
(ex/raise :type :internal
|
||||
@@ -288,7 +299,7 @@
|
||||
(l/inf :hint "provider initialized"
|
||||
:provider (:id provider)
|
||||
:client-id (:client-id provider)
|
||||
:client-secret (d/obfuscate-string (:client-secret provider)))
|
||||
:client-secret (obfuscate-string (:client-secret provider)))
|
||||
provider)
|
||||
|
||||
(catch Throwable cause
|
||||
@@ -330,7 +341,7 @@
|
||||
:provider "gitlab"
|
||||
:base-uri (:base-uri provider)
|
||||
:client-id (:client-id provider)
|
||||
:client-secret (d/obfuscate-string (:client-secret provider)))
|
||||
:client-secret (obfuscate-string (:client-secret provider)))
|
||||
provider)
|
||||
(catch Throwable cause
|
||||
(ex/raise :type ::internal
|
||||
@@ -350,7 +361,7 @@
|
||||
(l/inf :hint "provider initialized"
|
||||
:provider (:id provider)
|
||||
:client-id (:client-id provider)
|
||||
:client-secret (d/obfuscate-string (:client-secret provider)))
|
||||
:client-secret (obfuscate-string (:client-secret provider)))
|
||||
provider)
|
||||
|
||||
(catch Throwable cause
|
||||
@@ -423,7 +434,7 @@
|
||||
|
||||
(defn- qualify-prop-key
|
||||
[provider k]
|
||||
(keyword (:type provider) (-> k name str/kebab)))
|
||||
(keyword (:type provider) (name k)))
|
||||
|
||||
(defn- qualify-props
|
||||
[provider props]
|
||||
@@ -448,11 +459,11 @@
|
||||
(l/trc :hint "fetch access token"
|
||||
:provider (:id provider)
|
||||
:client-id (:client-id provider)
|
||||
:client-secret (d/obfuscate-string (:client-secret provider))
|
||||
:client-secret (obfuscate-string (:client-secret provider))
|
||||
:grant-type (:grant_type params)
|
||||
:redirect-uri (:redirect_uri params))
|
||||
|
||||
(let [{:keys [status body]} (http/req cfg req)]
|
||||
(let [{:keys [status body]} (http/req! cfg req)]
|
||||
(if (= status 200)
|
||||
(let [data (json/decode body)
|
||||
data {:token/access (get data :access_token)
|
||||
@@ -501,13 +512,13 @@
|
||||
[cfg provider tdata]
|
||||
(l/trc :hint "fetch user info"
|
||||
:uri (:user-uri provider)
|
||||
:token (d/obfuscate-string (:token/access tdata)))
|
||||
:token (obfuscate-string (:token/access tdata)))
|
||||
|
||||
(let [params {:uri (:user-uri provider)
|
||||
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
response (http/req cfg params)]
|
||||
response (http/req! cfg params)]
|
||||
|
||||
(l/trc :hint "user info response"
|
||||
:status (:status response)
|
||||
@@ -804,12 +815,12 @@
|
||||
props (audit/profile->props profile)
|
||||
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
||||
|
||||
(audit/submit cfg {:type "action"
|
||||
:name "login-with-oidc"
|
||||
:profile-id (:id profile)
|
||||
:ip-addr (inet/parse-request request)
|
||||
:props props
|
||||
:context context})
|
||||
(audit/submit! cfg {::audit/type "action"
|
||||
::audit/name "login-with-oidc"
|
||||
::audit/profile-id (:id profile)
|
||||
::audit/ip-addr (inet/parse-request request)
|
||||
::audit/props props
|
||||
::audit/context context})
|
||||
|
||||
(->> (redirect-to-verify-token token)
|
||||
(sxf request)))))
|
||||
|
||||
@@ -331,81 +331,6 @@
|
||||
(set/difference cfeat/backend-only-features))
|
||||
#{}))))
|
||||
|
||||
(defn check-file-exists
|
||||
[cfg id & {:keys [include-deleted?]
|
||||
:or {include-deleted? false}
|
||||
:as options}]
|
||||
(db/get-with-sql cfg [sql:get-minimal-file id]
|
||||
{:db/remove-deleted (not include-deleted?)}))
|
||||
|
||||
(def ^:private sql:file-permissions
|
||||
"select fpr.is_owner,
|
||||
fpr.is_admin,
|
||||
fpr.can_edit
|
||||
from file_profile_rel as fpr
|
||||
inner join file as f on (f.id = fpr.file_id)
|
||||
where fpr.file_id = ?
|
||||
and fpr.profile_id = ?
|
||||
union all
|
||||
select tpr.is_owner,
|
||||
tpr.is_admin,
|
||||
tpr.can_edit
|
||||
from team_profile_rel as tpr
|
||||
inner join project as p on (p.team_id = tpr.team_id)
|
||||
inner join file as f on (p.id = f.project_id)
|
||||
where f.id = ?
|
||||
and tpr.profile_id = ?
|
||||
union all
|
||||
select ppr.is_owner,
|
||||
ppr.is_admin,
|
||||
ppr.can_edit
|
||||
from project_profile_rel as ppr
|
||||
inner join file as f on (f.project_id = ppr.project_id)
|
||||
where f.id = ?
|
||||
and ppr.profile_id = ?")
|
||||
|
||||
(defn- get-file-permissions*
|
||||
[conn profile-id file-id]
|
||||
(when (and profile-id file-id)
|
||||
(db/exec! conn [sql:file-permissions
|
||||
file-id profile-id
|
||||
file-id profile-id
|
||||
file-id profile-id])))
|
||||
|
||||
(defn get-file-permissions
|
||||
([conn profile-id file-id]
|
||||
(let [rows (get-file-permissions* conn profile-id file-id)
|
||||
is-owner (boolean (some :is-owner rows))
|
||||
is-admin (boolean (some :is-admin rows))
|
||||
can-edit (boolean (some :can-edit rows))]
|
||||
(when (seq rows)
|
||||
{:type :membership
|
||||
:is-owner is-owner
|
||||
:is-admin (or is-owner is-admin)
|
||||
:can-edit (or is-owner is-admin can-edit)
|
||||
:can-read true
|
||||
:is-logged (some? profile-id)})))
|
||||
|
||||
([conn profile-id file-id share-id]
|
||||
(let [perms (get-file-permissions conn profile-id file-id)
|
||||
ldata (some-> (db/get* conn :share-link {:id share-id :file-id file-id})
|
||||
(dissoc :flags)
|
||||
(update :pages db/decode-pgarray #{}))]
|
||||
|
||||
;; NOTE: in a future when share-link becomes more powerful and
|
||||
;; will allow us specify which parts of the app is available, we
|
||||
;; will probably need to tweak this function in order to expose
|
||||
;; this flags to the frontend.
|
||||
(cond
|
||||
(some? perms) perms
|
||||
(some? ldata) {:type :share-link
|
||||
:can-read true
|
||||
:pages (:pages ldata)
|
||||
:is-logged (some? profile-id)
|
||||
:who-comment (:who-comment ldata)
|
||||
:who-inspect (:who-inspect ldata)}))))
|
||||
|
||||
|
||||
(defn get-project
|
||||
[cfg project-id]
|
||||
(db/get cfg :project {:id project-id}))
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
[promesa.util :as pu]
|
||||
[yetti.adapter :as yt])
|
||||
(:import
|
||||
com.github.luben.zstd.ZstdInputStream
|
||||
com.github.luben.zstd.ZstdIOException
|
||||
com.github.luben.zstd.ZstdInputStream
|
||||
com.github.luben.zstd.ZstdOutputStream
|
||||
java.io.DataInputStream
|
||||
java.io.DataOutputStream
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
|
||||
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
|
||||
{:id file-id
|
||||
@@ -301,7 +301,6 @@
|
||||
(write-entry! output path file))
|
||||
|
||||
(doseq [[index page-id] (d/enumerate pages)]
|
||||
|
||||
(let [path (str "files/" file-id "/pages/" page-id ".json")
|
||||
page (get pages-index page-id)
|
||||
objects (:objects page)
|
||||
@@ -312,8 +311,6 @@
|
||||
|
||||
(write-entry! output path page)
|
||||
|
||||
(events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id})
|
||||
|
||||
(doseq [[shape-id shape] objects]
|
||||
(let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json")
|
||||
shape (assoc shape :page-id page-id)
|
||||
@@ -326,8 +323,6 @@
|
||||
(doseq [{:keys [id] :as media} media]
|
||||
(let [path (str "files/" file-id "/media/" id ".json")
|
||||
media (encode-media media)]
|
||||
|
||||
(events/tap :progress {:section :media :id id :file-id file-id})
|
||||
(write-entry! output path media)))
|
||||
|
||||
(doseq [thumbnail thumbnails]
|
||||
@@ -337,13 +332,11 @@
|
||||
data (-> data
|
||||
(assoc :media-id (:media-id thumbnail))
|
||||
(encode-file-thumbnail))]
|
||||
(events/tap :progress {:section :thumbnails :id (:object-id thumbnail) :file-id file-id})
|
||||
(write-entry! output path data)))
|
||||
|
||||
(doseq [[id component] components]
|
||||
(let [path (str "files/" file-id "/components/" id ".json")
|
||||
component (encode-component component)]
|
||||
(events/tap :progress {:section :component :id id :file-id file-id})
|
||||
(write-entry! output path component)))
|
||||
|
||||
(doseq [[id color] colors]
|
||||
@@ -354,20 +347,17 @@
|
||||
(and (contains? color :path)
|
||||
(str/empty? (:path color)))
|
||||
(dissoc :path))]
|
||||
(events/tap :progress {:section :color :id id :file-id file-id})
|
||||
(write-entry! output path color)))
|
||||
|
||||
(doseq [[id object] typographies]
|
||||
(let [path (str "files/" file-id "/typographies/" id ".json")
|
||||
typography (encode-typography object)]
|
||||
(events/tap :progress {:section :typography :id id :file-id file-id})
|
||||
(write-entry! output path typography)))
|
||||
|
||||
(when (and tokens-lib
|
||||
(not (ctob/empty-lib? tokens-lib)))
|
||||
(let [path (str "files/" file-id "/tokens.json")
|
||||
encoded-tokens (encode-tokens-lib tokens-lib)]
|
||||
(events/tap :progress {:section :tokens-lib :file-id file-id})
|
||||
(write-entry! output path encoded-tokens)))))
|
||||
|
||||
(defn- export-files
|
||||
@@ -610,7 +600,6 @@
|
||||
(let [object (->> (read-entry input entry)
|
||||
(decode-color)
|
||||
(validate-color))]
|
||||
(events/tap :progress {:section :color :id id :file-id file-id})
|
||||
(if (= id (:id object))
|
||||
(assoc result id object)
|
||||
result)))
|
||||
@@ -642,7 +631,6 @@
|
||||
(clean-component-pre-decode)
|
||||
(decode-component)
|
||||
(clean-component-post-decode))]
|
||||
(events/tap :progress {:section :component :id id :file-id file-id})
|
||||
(if (= id (:id object))
|
||||
(assoc result id object)
|
||||
result)))
|
||||
@@ -656,7 +644,6 @@
|
||||
(let [object (->> (read-entry input entry)
|
||||
(decode-typography)
|
||||
(validate-typography))]
|
||||
(events/tap :progress {:section :typography :id id :file-id file-id})
|
||||
(if (= id (:id object))
|
||||
(assoc result id object)
|
||||
result)))
|
||||
@@ -666,7 +653,6 @@
|
||||
(defn- read-file-tokens-lib
|
||||
[{:keys [::bfc/input ::entries]} file-id]
|
||||
(when-let [entry (d/seek (match-tokens-lib-entry-fn file-id) entries)]
|
||||
(events/tap :progress {:section :tokens-lib :file-id file-id})
|
||||
(->> (read-plain-entry input entry)
|
||||
(decode-tokens-lib)
|
||||
(validate-tokens-lib))))
|
||||
@@ -692,7 +678,6 @@
|
||||
(let [page (->> (read-entry input entry)
|
||||
(decode-page))
|
||||
page (dissoc page :options)]
|
||||
(events/tap :progress {:section :page :id id :file-id file-id})
|
||||
(when (= id (:id page))
|
||||
(let [objects (read-file-shapes cfg file-id id)]
|
||||
(assoc page :objects objects))))))
|
||||
@@ -708,7 +693,6 @@
|
||||
(let [object (->> (read-entry input entry)
|
||||
(decode-file-thumbnail)
|
||||
(validate-file-thumbnail))]
|
||||
|
||||
(if (and (= frame-id (:frame-id object))
|
||||
(= page-id (:page-id object))
|
||||
(= tag (:tag object)))
|
||||
@@ -749,6 +733,8 @@
|
||||
|
||||
(vswap! bfc/*state* update :index bfc/update-index media :id)
|
||||
|
||||
(events/tap :progress {:section :media :file-id file-id})
|
||||
|
||||
(doseq [item media]
|
||||
(let [params (-> item
|
||||
(update :id bfc/lookup-index)
|
||||
@@ -756,8 +742,6 @@
|
||||
(d/update-when :media-id bfc/lookup-index)
|
||||
(d/update-when :thumbnail-id bfc/lookup-index))]
|
||||
|
||||
(events/tap :progress {:section :media :id (:id params) :file-id file-id})
|
||||
|
||||
(l/dbg :hint "inserting media object"
|
||||
:file-id (str file-id')
|
||||
:id (str (:id params))
|
||||
@@ -769,6 +753,8 @@
|
||||
(db/insert! conn :file-media-object params
|
||||
::db/on-conflict-do-nothing? (::bfc/overwrite cfg))))
|
||||
|
||||
(events/tap :progress {:section :thumbnails :file-id file-id})
|
||||
|
||||
(doseq [item thumbnails]
|
||||
(let [media-id (bfc/lookup-index (:media-id item))
|
||||
object-id (-> (assoc item :file-id file-id')
|
||||
@@ -783,8 +769,6 @@
|
||||
:media-id (str media-id)
|
||||
::l/sync? true)
|
||||
|
||||
(events/tap :progress {:section :thumbnail :file-id file-id :object-id object-id})
|
||||
|
||||
(db/insert! conn :file-tagged-object-thumbnail params
|
||||
::db/on-conflict-do-nothing? true)))
|
||||
|
||||
@@ -837,10 +821,9 @@
|
||||
entries (keep (match-storage-entry-fn) entries)]
|
||||
|
||||
(doseq [{:keys [id entry]} entries]
|
||||
(let [object (-> (read-entry input entry)
|
||||
(decode-storage-object)
|
||||
(update :bucket d/nilv sto/default-bucket)
|
||||
(validate-storage-object))
|
||||
(let [object (->> (read-entry input entry)
|
||||
(decode-storage-object)
|
||||
(validate-storage-object))
|
||||
|
||||
ext (cmedia/mtype->extension (:content-type object))
|
||||
path (str "objects/" id ext)
|
||||
@@ -889,8 +872,11 @@
|
||||
(import-storage-objects cfg)
|
||||
|
||||
(let [files (get manifest :files)
|
||||
result (reduce (fn [result file]
|
||||
result (reduce (fn [result {:keys [id] :as file}]
|
||||
(let [name' (get file :name)
|
||||
name' (if (map? name)
|
||||
(get name id)
|
||||
name')
|
||||
file (assoc file :name name')]
|
||||
(conj result (import-file cfg file))))
|
||||
[]
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
:telemetry-uri "https://telemetry.penpot.app/"
|
||||
|
||||
:media-max-file-size (* 1024 1024 30) ; 30MiB
|
||||
:font-max-file-size (* 1024 1024 30) ; 30MiB
|
||||
|
||||
:ldap-user-query "(|(uid=:username)(mail=:username))"
|
||||
:ldap-attrs-username "uid"
|
||||
@@ -83,14 +82,7 @@
|
||||
:initial-project-skey "initial-project"
|
||||
|
||||
;; time to avoid email sending after profile modification
|
||||
:email-verify-threshold "15m"
|
||||
|
||||
:quotes-upload-sessions-per-profile 5
|
||||
:quotes-upload-chunks-per-session 20
|
||||
|
||||
;; SSRF protection
|
||||
:ssrf-allowed-hosts #{}
|
||||
:ssrf-extra-blocked-cidrs #{}})
|
||||
:email-verify-threshold "15m"})
|
||||
|
||||
(def schema:config
|
||||
(do #_sm/optional-keys
|
||||
@@ -106,12 +98,10 @@
|
||||
[:http-server-port {:optional true} ::sm/int]
|
||||
[:http-server-host {:optional true} :string]
|
||||
[:http-server-max-body-size {:optional true} ::sm/int]
|
||||
[:http-server-max-multipart-body-size {:optional true} ::sm/int]
|
||||
[:http-server-io-threads {:optional true} ::sm/int]
|
||||
[:http-server-max-worker-threads {:optional true} ::sm/int]
|
||||
|
||||
[:exporter-shared-key {:optional true} :string]
|
||||
[:nitrate-shared-key {:optional true} :string]
|
||||
[:nexus-shared-key {:optional true} :string]
|
||||
[:management-api-key {:optional true} :string]
|
||||
|
||||
[:telemetry-uri {:optional true} :string]
|
||||
@@ -121,7 +111,6 @@
|
||||
[:auto-file-snapshot-timeout {:optional true} ::ct/duration]
|
||||
|
||||
[:media-max-file-size {:optional true} ::sm/int]
|
||||
[:font-max-file-size {:optional true} ::sm/int]
|
||||
[:deletion-delay {:optional true} ::ct/duration]
|
||||
[:file-clean-delay {:optional true} ::ct/duration]
|
||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||
@@ -163,8 +152,6 @@
|
||||
[:quotes-snapshots-per-team {:optional true} ::sm/int]
|
||||
[:quotes-team-access-requests-per-team {:optional true} ::sm/int]
|
||||
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
|
||||
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
|
||||
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
|
||||
|
||||
[:auth-token-cookie-name {:optional true} :string]
|
||||
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
||||
@@ -238,8 +225,6 @@
|
||||
[:netty-io-threads {:optional true} ::sm/int]
|
||||
[:executor-threads {:optional true} ::sm/int]
|
||||
|
||||
[:nitrate-backend-uri {:optional true} ::sm/uri]
|
||||
|
||||
;; DEPRECATED
|
||||
[:assets-storage-backend {:optional true} :keyword]
|
||||
[:storage-assets-fs-directory {:optional true} :string]
|
||||
@@ -251,26 +236,17 @@
|
||||
[:objects-storage-fs-directory {:optional true} :string]
|
||||
[:objects-storage-s3-bucket {:optional true} :string]
|
||||
[:objects-storage-s3-region {:optional true} :keyword]
|
||||
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]
|
||||
|
||||
;; SSRF protection
|
||||
[:ssrf-allowed-hosts {:optional true} [::sm/set :string]]
|
||||
[:ssrf-extra-blocked-cidrs {:optional true} [::sm/set :string]]]))
|
||||
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]]))
|
||||
|
||||
(defn- parse-flags
|
||||
[config]
|
||||
(let [public-uri (c/get config :public-uri)
|
||||
public-uri (some-> public-uri (u/uri))
|
||||
extra-flags (cond-> #{}
|
||||
;; When public-uri is http (non-localhost), disable secure cookies
|
||||
(and public-uri
|
||||
(= (:scheme public-uri) "http")
|
||||
(not= (:host public-uri) "localhost"))
|
||||
(conj :disable-secure-session-cookies)
|
||||
|
||||
;; When telemetry-enabled config is true, add :telemetry flag
|
||||
(true? (c/get config :telemetry-enabled))
|
||||
(conj :enable-telemetry))]
|
||||
extra-flags (if (and public-uri
|
||||
(= (:scheme public-uri) "http")
|
||||
(not= (:host public-uri) "localhost"))
|
||||
#{:disable-secure-session-cookies}
|
||||
#{})]
|
||||
(flags/parse flags/default extra-flags (:flags config))))
|
||||
|
||||
(defn read-env
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
java.sql.Connection
|
||||
java.sql.PreparedStatement
|
||||
java.sql.Savepoint
|
||||
org.postgresql.PGConnection
|
||||
org.postgresql.geometric.PGpoint
|
||||
org.postgresql.jdbc.PgArray
|
||||
org.postgresql.largeobject.LargeObject
|
||||
org.postgresql.largeobject.LargeObjectManager
|
||||
org.postgresql.PGConnection
|
||||
org.postgresql.util.PGInterval
|
||||
org.postgresql.util.PGobject))
|
||||
|
||||
|
||||
+10
-27
@@ -22,34 +22,15 @@
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
jakarta.mail.Message$RecipientType
|
||||
jakarta.mail.Session
|
||||
jakarta.mail.Transport
|
||||
jakarta.mail.internet.InternetAddress
|
||||
jakarta.mail.internet.MimeBodyPart
|
||||
jakarta.mail.internet.MimeMessage
|
||||
jakarta.mail.internet.MimeMultipart
|
||||
jakarta.mail.Message$RecipientType
|
||||
jakarta.mail.Session
|
||||
jakarta.mail.Transport
|
||||
java.util.Properties))
|
||||
|
||||
(defn clean
|
||||
"Clean and normalizes email address string"
|
||||
[email]
|
||||
(let [email (str/lower email)
|
||||
email (if (str/starts-with? email "mailto:")
|
||||
(subs email 7)
|
||||
email)
|
||||
email (if (or (str/starts-with? email "<")
|
||||
(str/ends-with? email ">"))
|
||||
(str/trim email "<>")
|
||||
email)]
|
||||
email))
|
||||
|
||||
(defn get-domain
|
||||
[email]
|
||||
(let [email (clean email)
|
||||
[_ domain] (str/split email "@" 2)]
|
||||
domain))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; EMAIL IMPL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -125,17 +106,17 @@
|
||||
(let [content-part (MimeBodyPart.)
|
||||
alternative-mpart (MimeMultipart. "alternative")]
|
||||
|
||||
(when-let [content (get body "text/plain")]
|
||||
(let [text-part (MimeBodyPart.)]
|
||||
(.setText text-part ^String content ^String charset)
|
||||
(.addBodyPart alternative-mpart text-part)))
|
||||
|
||||
(when-let [content (get body "text/html")]
|
||||
(let [html-part (MimeBodyPart.)]
|
||||
(.setContent html-part ^String content
|
||||
(str "text/html; charset=" charset))
|
||||
(.addBodyPart alternative-mpart html-part)))
|
||||
|
||||
(when-let [content (get body "text/plain")]
|
||||
(let [text-part (MimeBodyPart.)]
|
||||
(.setText text-part ^String content ^String charset)
|
||||
(.addBodyPart alternative-mpart text-part)))
|
||||
|
||||
(.setContent content-part alternative-mpart)
|
||||
(.addBodyPart mixed-mpart content-part))
|
||||
|
||||
@@ -143,6 +124,8 @@
|
||||
(throw (IllegalArgumentException. "invalid email body provided")))
|
||||
|
||||
(doseq [[name content] attachments]
|
||||
|
||||
(prn "attachment" name)
|
||||
(let [attachment-part (MimeBodyPart.)]
|
||||
(.setFileName attachment-part ^String name)
|
||||
(.setContent attachment-part ^String content (str "text/plain; charset=" charset))
|
||||
|
||||
@@ -36,18 +36,10 @@
|
||||
:cause cause)))))
|
||||
|
||||
(defn contains?
|
||||
"Check if email is in the blacklist. Also matches subdomains: if
|
||||
'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also
|
||||
be rejected."
|
||||
"Check if email is in the blacklist."
|
||||
[{:keys [::email/blacklist]} email]
|
||||
(let [[_ domain] (str/split email "@" 2)
|
||||
parts (str/split (str/lower domain) #"\.")]
|
||||
(loop [parts parts]
|
||||
(if (empty? parts)
|
||||
false
|
||||
(if (c/contains? blacklist (str/join "." parts))
|
||||
true
|
||||
(recur (rest parts)))))))
|
||||
(let [[_ domain] (str/split email "@" 2)]
|
||||
(c/contains? blacklist (str/lower domain))))
|
||||
|
||||
(defn enabled?
|
||||
"Check if the blacklist is enabled"
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
c.deleted_at
|
||||
FROM snapshots1 AS c
|
||||
WHERE c.file_id = ?
|
||||
ORDER BY c.created_at DESC
|
||||
), snapshots3 AS (
|
||||
(SELECT * FROM snapshots2
|
||||
WHERE created_by = 'system'
|
||||
@@ -151,7 +150,8 @@
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 500)
|
||||
)
|
||||
SELECT * FROM snapshots3;"))
|
||||
SELECT * FROM snapshots3
|
||||
ORDER BY created_at DESC"))
|
||||
|
||||
(defn get-visible-snapshots
|
||||
"Return a list of snapshots fecheable from the API, it has a limited
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
(def default-params
|
||||
{::port 6060
|
||||
::host "0.0.0.0"
|
||||
::max-body-size 367001600 ; default 350 MiB
|
||||
})
|
||||
::max-body-size 31457280 ; default 30 MiB
|
||||
::max-multipart-body-size 367001600}) ; default 350 MiB
|
||||
|
||||
(defmethod ig/expand-key ::server
|
||||
[k v]
|
||||
@@ -56,6 +56,7 @@
|
||||
[::io-threads {:optional true} ::sm/int]
|
||||
[::max-worker-threads {:optional true} ::sm/int]
|
||||
[::max-body-size {:optional true} ::sm/int]
|
||||
[::max-multipart-body-size {:optional true} ::sm/int]
|
||||
[::router {:optional true} [:fn r/router?]]
|
||||
[::handler {:optional true} ::sm/fn]])
|
||||
|
||||
@@ -78,7 +79,7 @@
|
||||
{:http/port port
|
||||
:http/host host
|
||||
:http/max-body-size (::max-body-size cfg)
|
||||
:http/max-multipart-body-size (::max-body-size cfg)
|
||||
:http/max-multipart-body-size (::max-multipart-body-size cfg)
|
||||
:xnio/direct-buffers false
|
||||
:xnio/io-threads (::io-threads cfg)
|
||||
:xnio/max-worker-threads (::max-worker-threads cfg)
|
||||
|
||||
@@ -12,56 +12,43 @@
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.db :as db]
|
||||
[app.http.session :as session]
|
||||
[app.storage :as sto]
|
||||
[integrant.core :as ig]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(def ^:private default-cache-max-age
|
||||
(def ^:private cache-max-age
|
||||
(ct/duration {:hours 24}))
|
||||
|
||||
(def ^:private default-signature-max-age
|
||||
(def ^:private signature-max-age
|
||||
(ct/duration {:hours 24 :minutes 15}))
|
||||
|
||||
;; Buckets that are legitimately public and do not require authentication.
|
||||
;; These are used by public shared board viewing, profile photos in UI,
|
||||
;; and embedded export/binfile flows.
|
||||
(def ^:private public-buckets
|
||||
#{"file-media-object"
|
||||
"file-object-thumbnail"
|
||||
"team-font-variant"
|
||||
"file-data-fragment"})
|
||||
|
||||
(defn get-id
|
||||
[{:keys [path-params]}]
|
||||
(or (some-> path-params :id d/parse-uuid)
|
||||
(ex/raise :type :not-found
|
||||
:hint "object not found")))
|
||||
:hunt "object not found")))
|
||||
|
||||
(defn- get-file-media-object
|
||||
[pool id]
|
||||
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||
(db/get pool :file-media-object {:id id}))
|
||||
|
||||
(defn- serve-object-from-s3
|
||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||
cch-max-age (or cache-max-age default-cache-max-age)
|
||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
|
||||
[{:keys [::sto/storage] :as cfg} obj]
|
||||
(let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
|
||||
{::yres/status 307
|
||||
::yres/headers {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}}))
|
||||
|
||||
(defn- serve-object-from-fs
|
||||
[{:keys [::path ::cache-max-age]} obj]
|
||||
(let [cch-max-age (or cache-max-age default-cache-max-age)
|
||||
purl (u/join (u/uri path)
|
||||
[{:keys [::path]} obj]
|
||||
(let [purl (u/join (u/uri path)
|
||||
(sto/object->relative-path obj))
|
||||
mdata (meta obj)
|
||||
headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
|
||||
{::yres/status 204
|
||||
::yres/headers headers}))
|
||||
|
||||
@@ -73,28 +60,14 @@
|
||||
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
||||
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
|
||||
|
||||
(defn- requires-auth?
|
||||
"Check if the storage object requires authentication based on its bucket."
|
||||
[obj]
|
||||
(let [bucket (-> obj meta :bucket)]
|
||||
(not (contains? public-buckets bucket))))
|
||||
|
||||
(defn objects-handler
|
||||
"Handler that serves storage objects by id.
|
||||
For non-public buckets (e.g. profile), requires an authenticated session."
|
||||
"Handler that servers storage objects by id."
|
||||
[{:keys [::sto/storage] :as cfg} request]
|
||||
(let [id (get-id request)
|
||||
obj (sto/get-object storage id)]
|
||||
(cond
|
||||
(nil? obj)
|
||||
{::yres/status 404}
|
||||
|
||||
(and (requires-auth? obj)
|
||||
(nil? (::session/profile-id request)))
|
||||
{::yres/status 401}
|
||||
|
||||
:else
|
||||
(serve-object cfg obj))))
|
||||
(if obj
|
||||
(serve-object cfg obj)
|
||||
{::yres/status 404})))
|
||||
|
||||
(defn- generic-handler
|
||||
"A generic handler helper/common code for file-media based handlers."
|
||||
@@ -123,12 +96,11 @@
|
||||
(defmethod ig/assert-key ::routes
|
||||
[_ params]
|
||||
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
|
||||
(assert (session/manager? (::session/manager params)) "expected valid session manager")
|
||||
(assert (string? (::path params))))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
["/assets" {:middleware [[session/authz cfg]]}
|
||||
["/assets"
|
||||
["/by-id/:id" {:handler (partial objects-handler cfg)}]
|
||||
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
|
||||
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
|
||||
@@ -53,7 +53,7 @@
|
||||
(let [surl (get body "SubscribeURL")
|
||||
stopic (get body "TopicArn")]
|
||||
(l/info :action "subscription received" :topic stopic :url surl)
|
||||
(http/req cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
|
||||
(http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
|
||||
|
||||
(= mtype "Notification")
|
||||
(when-let [message (parse-json (get body "Message"))]
|
||||
|
||||
@@ -5,24 +5,13 @@
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.http.client
|
||||
"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)."
|
||||
"Http client abstraction layer."
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[java-http-clj.core :as http])
|
||||
(:import
|
||||
java.net.http.HttpClient
|
||||
java.net.URI))
|
||||
|
||||
(def default-max-redirects 5)
|
||||
java.net.http.HttpClient))
|
||||
|
||||
(defn client?
|
||||
[o]
|
||||
@@ -34,8 +23,8 @@
|
||||
|
||||
(defmethod ig/init-key ::client
|
||||
[_ _]
|
||||
(http/build-client {:connect-timeout 30000
|
||||
:follow-redirects :never}))
|
||||
(http/build-client {:connect-timeout 30000 ;; 10s
|
||||
:follow-redirects :always}))
|
||||
|
||||
(defn send!
|
||||
([client req] (send! client req {}))
|
||||
@@ -55,82 +44,14 @@
|
||||
:else
|
||||
(throw (UnsupportedOperationException. "invalid arguments"))))
|
||||
|
||||
(defn req
|
||||
"Issue a single HTTP request. SSRF validation is applied to the
|
||||
target URI by default; pass `:skip-ssrf-check? true` in `options`
|
||||
to bypass it for known-safe, operator-configured endpoints."
|
||||
(defn req!
|
||||
"A convencience toplevel function for gradual migration to a new API
|
||||
convention."
|
||||
([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)
|
||||
resp (send! client current-req send-opts)
|
||||
status (:status resp)]
|
||||
(if (and (<= 300 status 399)
|
||||
(< hops max-redirects))
|
||||
(if-let [location (get-in resp [:headers "location"])]
|
||||
(let [next-uri (resolve-location (str (:uri current-req)) location)]
|
||||
(recur (update (redirect-request current-req next-uri status) :uri uri-coerce)
|
||||
(inc hops)))
|
||||
;; No Location header on a 3xx — return the response as-is
|
||||
resp)
|
||||
resp))))))
|
||||
(let [client (resolve-client cfg-or-client)
|
||||
request (update request :uri str)]
|
||||
(send! client request {})))
|
||||
([cfg-or-client request options]
|
||||
(let [client (resolve-client cfg-or-client)
|
||||
request (update request :uri str)]
|
||||
(send! client request options))))
|
||||
@@ -49,16 +49,13 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn index-handler
|
||||
[cfg request]
|
||||
(let [profile-id (::session/profile-id request)
|
||||
offset (clock/get-offset profile-id)
|
||||
profile (profile/get-profile cfg profile-id)]
|
||||
[_cfg _request]
|
||||
(let [{:keys [clock offset]} @clock/current]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/html"}
|
||||
::yres/body (-> (io/resource "app/templates/debug.tmpl")
|
||||
(tmpl/render {:version (:full cf/version)
|
||||
:profile profile
|
||||
:current-clock ct/*clock*
|
||||
:current-clock (str clock)
|
||||
:current-offset (if offset
|
||||
(ct/format-duration offset)
|
||||
"NO OFFSET")
|
||||
@@ -232,30 +229,13 @@
|
||||
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 3)
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||
|
||||
(render-template-v4 [{:keys [content id created-at]}]
|
||||
(-> (io/resource "app/templates/error-report.v4.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 4)
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||
|
||||
(render-template-v5 [{:keys [content id created-at]}]
|
||||
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 5)
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
|
||||
|
||||
(if-let [report (get-report request)]
|
||||
(let [result (case (:version report)
|
||||
1 (render-template-v1 report)
|
||||
2 (render-template-v2 report)
|
||||
3 (render-template-v3 report)
|
||||
4 (render-template-v4 report)
|
||||
5 (render-template-v5 report))]
|
||||
3 (render-template-v3 report))]
|
||||
{::yres/status 200
|
||||
::yres/body result
|
||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||
@@ -263,22 +243,20 @@
|
||||
{::yres/status 404
|
||||
::yres/body "not found"})))
|
||||
|
||||
(def ^:private sql:error-reports
|
||||
(def sql:error-reports
|
||||
"SELECT id, created_at,
|
||||
content->>'~:hint' AS hint
|
||||
FROM server_error_report
|
||||
WHERE version = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300")
|
||||
LIMIT 200")
|
||||
|
||||
(defn- error-list-handler
|
||||
[{:keys [::db/pool]} {:keys [params]}]
|
||||
(let [version (or (some-> (get params :version) parse-long) 3)
|
||||
items (->> (db/exec! pool [sql:error-reports version])
|
||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||
(defn error-list-handler
|
||||
[{:keys [::db/pool]} _request]
|
||||
(let [items (->> (db/exec! pool [sql:error-reports])
|
||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||
{::yres/status 200
|
||||
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
|
||||
(tmpl/render {:items items :version version}))
|
||||
(tmpl/render {:items items}))
|
||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"}}))
|
||||
|
||||
@@ -469,16 +447,15 @@
|
||||
|
||||
(defn- set-virtual-clock
|
||||
[_ {:keys [params] :as request}]
|
||||
(let [offset (some-> params :offset str/trim not-empty ct/duration)
|
||||
profile-id (::session/profile-id request)
|
||||
reset? (contains? params :reset)]
|
||||
(let [offset (some-> params :offset str/trim not-empty ct/duration)
|
||||
reset? (contains? params :reset)]
|
||||
(if (= "production" (cf/get :tenant))
|
||||
{::yres/status 501
|
||||
::yres/body "OPERATION NOT ALLOWED"}
|
||||
(do
|
||||
(if (or reset? (zero? (inst-ms offset)))
|
||||
(clock/assign-offset profile-id nil)
|
||||
(clock/assign-offset profile-id offset))
|
||||
(clock/set-offset! nil)
|
||||
(clock/set-offset! offset))
|
||||
{::yres/status 302
|
||||
::yres/headers {"location" "/dbg"}}))))
|
||||
|
||||
@@ -518,7 +495,7 @@
|
||||
|
||||
(defn authorized?
|
||||
[pool {:keys [::session/profile-id]}]
|
||||
(or (and (= "devenv" (cf/get :host)) profile-id)
|
||||
(or (= "devenv" (cf/get :host))
|
||||
(let [profile (ex/ignoring (profile/get-profile pool profile-id))
|
||||
admins (or (cf/get :admins) #{})]
|
||||
(contains? admins (:email profile)))))
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
(assoc :request/ip-addr (inet/parse-request request))
|
||||
(assoc :request/profile-id (get claims :uid))
|
||||
(assoc :request/auth-data auth)
|
||||
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||
|
||||
(defmulti handle-error
|
||||
(fn [cause _ _]
|
||||
@@ -220,14 +220,12 @@
|
||||
(assoc :hint (ex-message error)))}))))
|
||||
|
||||
(defmethod handle-exception java.io.IOException
|
||||
[cause request _]
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/wrn :hint "io exception" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :io-exception
|
||||
:hint (ex-message cause)
|
||||
:path (:path request)}}))
|
||||
[cause _ _]
|
||||
(l/wrn :hint "io exception" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :io-exception
|
||||
:hint (ex-message cause)}})
|
||||
|
||||
(defmethod handle-exception java.util.concurrent.CompletionException
|
||||
[cause request _]
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.middleware :as mw]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc.commands.profile :as cmd.profile]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[app.worker :as-alias wrk]
|
||||
[integrant.core :as ig]
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
;; ---- ROUTES
|
||||
@@ -49,40 +49,28 @@
|
||||
(fn [cfg request]
|
||||
(db/tx-run! cfg handler request)))))})
|
||||
|
||||
(def ^:private shared-key-auth
|
||||
{:name ::shared-key-auth
|
||||
:compile
|
||||
(fn [_ _]
|
||||
(fn [handler key]
|
||||
(if key
|
||||
(fn [request]
|
||||
(if-let [key' (yreq/get-header request "x-shared-key")]
|
||||
(if (= key key')
|
||||
(handler request)
|
||||
{::yres/status 403})
|
||||
{::yres/status 403}))
|
||||
(fn [_ _]
|
||||
{::yres/status 403}))))})
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
[_ {:keys [::setup/props] :as cfg}]
|
||||
|
||||
["" {:middleware [[shared-key-auth (cf/get :management-api-key)]
|
||||
[default-system cfg]
|
||||
[transaction]]}
|
||||
["/authenticate"
|
||||
{:handler authenticate
|
||||
:allowed-methods #{:post}}]
|
||||
(let [management-key (or (cf/get :management-api-key)
|
||||
(get props :management-key))]
|
||||
|
||||
["/get-customer"
|
||||
{:handler get-customer
|
||||
:transaction true
|
||||
:allowed-methods #{:post}}]
|
||||
["" {:middleware [[mw/shared-key-auth management-key]
|
||||
[default-system cfg]
|
||||
[transaction]]}
|
||||
["/authenticate"
|
||||
{:handler authenticate
|
||||
:allowed-methods #{:post}}]
|
||||
|
||||
["/update-customer"
|
||||
{:handler update-customer
|
||||
:allowed-methods #{:post}
|
||||
:transaction true}]])
|
||||
["/get-customer"
|
||||
{:handler get-customer
|
||||
:transaction true
|
||||
:allowed-methods #{:post}}]
|
||||
|
||||
["/update-customer"
|
||||
{:handler update-customer
|
||||
:allowed-methods #{:post}
|
||||
:transaction true}]]))
|
||||
|
||||
;; ---- HELPERS
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[app.http.errors :as errors]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[buddy.core.codecs :as bc]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.adapter :as yt]
|
||||
[yetti.middleware :as ymw]
|
||||
@@ -213,14 +214,14 @@
|
||||
(assoc "access-control-allow-origin" origin)
|
||||
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
|
||||
(assoc "access-control-allow-credentials" "true")
|
||||
(assoc "access-control-expose-headers" "content-type, set-cookie")
|
||||
(assoc "access-control-allow-headers" "x-frontend-version, x-client, x-requested-width, content-type, accept, cookie")))
|
||||
(assoc "access-control-expose-headers" "x-requested-with, content-type, cookie")
|
||||
(assoc "access-control-allow-headers" "x-frontend-version, content-type, accept, x-requested-width")))
|
||||
|
||||
(defn wrap-cors
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [response (if (= (yreq/method request) :options)
|
||||
{::yres/status 204}
|
||||
{::yres/status 200}
|
||||
(handler request))
|
||||
origin (yreq/get-header request "origin")]
|
||||
(update response ::yres/headers with-cors-headers origin))))
|
||||
@@ -300,20 +301,16 @@
|
||||
:compile (constantly wrap-auth)})
|
||||
|
||||
(defn- wrap-shared-key-auth
|
||||
[handler keys]
|
||||
(if (seq keys)
|
||||
(fn [request]
|
||||
(if-let [[key-id key] (some-> (yreq/get-header request "x-shared-key")
|
||||
(str/split #"\s+" 2))]
|
||||
(let [key-id (-> key-id str/lower keyword)]
|
||||
(if (and (string? key)
|
||||
(contains? keys key-id)
|
||||
(= key (get keys key-id)))
|
||||
(-> request
|
||||
(assoc ::http/auth-key-id key-id)
|
||||
(handler))
|
||||
{::yres/status 403}))
|
||||
{::yres/status 403}))
|
||||
[handler shared-key]
|
||||
(if shared-key
|
||||
(let [shared-key (if (string? shared-key)
|
||||
shared-key
|
||||
(bc/bytes->b64-str shared-key true))]
|
||||
(fn [request]
|
||||
(let [key (yreq/get-header request "x-shared-key")]
|
||||
(if (= key shared-key)
|
||||
(handler request)
|
||||
{::yres/status 403}))))
|
||||
(fn [_ _]
|
||||
{::yres/status 403})))
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
[app.http.session.tasks :as-alias tasks]
|
||||
[app.main :as-alias main]
|
||||
[app.setup :as-alias setup]
|
||||
[app.setup.clock :as clock]
|
||||
[app.tokens :as tokens]
|
||||
[integrant.core :as ig]
|
||||
[yetti.request :as yreq]
|
||||
@@ -230,22 +229,18 @@
|
||||
(let [{:keys [type token claims metadata]} (get request ::http/auth-data)]
|
||||
(cond
|
||||
(= type :cookie)
|
||||
(let [session
|
||||
(case (:ver metadata)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
0 (read-session manager token)
|
||||
1 (some->> (:sid claims) (read-session manager))
|
||||
nil)
|
||||
(let [session (case (:ver metadata)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
0 (read-session manager token)
|
||||
1 (some->> (:sid claims) (read-session manager))
|
||||
nil)
|
||||
|
||||
request
|
||||
(cond-> request
|
||||
(some? session)
|
||||
(-> (assoc ::profile-id (:profile-id session))
|
||||
(assoc ::session session)))
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
(-> (assoc ::profile-id (:profile-id session))
|
||||
(assoc ::session session)))
|
||||
|
||||
response
|
||||
(binding [ct/*clock* (clock/get-clock (:profile-id session))]
|
||||
(handler request))]
|
||||
response (handler request)]
|
||||
|
||||
(if (and session (renew-session? session))
|
||||
(let [session (->> session
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.http.sse
|
||||
"SSE (server sent events) helpers"
|
||||
(:refer-clojure :exclude [tap])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
|
||||
+160
-297
@@ -9,19 +9,18 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.json :as json]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as email]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token :as-alias actoken]
|
||||
[app.loggers.audit.tasks :as-alias tasks]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.retry :as rtry]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.inet :as inet]
|
||||
[app.util.services :as-alias sv]
|
||||
@@ -33,63 +32,6 @@
|
||||
;; HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private filter-auth-events
|
||||
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
|
||||
|
||||
(def ^:private safe-backend-context-keys
|
||||
#{:version
|
||||
:initiator
|
||||
:client-version
|
||||
:client-user-agent})
|
||||
|
||||
(def ^:private safe-frontend-context-keys
|
||||
#{:version
|
||||
:locale
|
||||
:browser
|
||||
:browser-version
|
||||
:engine
|
||||
:engine-version
|
||||
:os
|
||||
:os-version
|
||||
:device-type
|
||||
:device-arch
|
||||
:screen-width
|
||||
:screen-height
|
||||
:screen-color-depth
|
||||
:screen-orientation
|
||||
:event-origin
|
||||
:event-namespace
|
||||
:event-symbol})
|
||||
|
||||
(def profile-props
|
||||
[:id
|
||||
:is-active
|
||||
:is-muted
|
||||
:auth-backend
|
||||
:email
|
||||
:default-team-id
|
||||
:default-project-id
|
||||
:fullname
|
||||
:lang])
|
||||
|
||||
(def ^:private event-keys
|
||||
#{:id
|
||||
:name
|
||||
:type
|
||||
:profile-id
|
||||
:ip-addr
|
||||
:props
|
||||
:context
|
||||
:source
|
||||
:tracked-at
|
||||
:created-at})
|
||||
|
||||
(def reserved-props
|
||||
#{:session-id
|
||||
:password
|
||||
:old-password
|
||||
:token})
|
||||
|
||||
(defn extract-utm-params
|
||||
"Extracts additional data from params and namespace them under
|
||||
`penpot` ns."
|
||||
@@ -104,6 +46,17 @@
|
||||
(assoc (->> sk str/kebab (keyword "penpot")) v))))]
|
||||
(reduce-kv process-param {} params)))
|
||||
|
||||
(def profile-props
|
||||
[:id
|
||||
:is-active
|
||||
:is-muted
|
||||
:auth-backend
|
||||
:email
|
||||
:default-team-id
|
||||
:default-project-id
|
||||
:fullname
|
||||
:lang])
|
||||
|
||||
(defn profile->props
|
||||
[profile]
|
||||
(-> profile
|
||||
@@ -111,6 +64,12 @@
|
||||
(merge (:props profile))
|
||||
(d/without-nils)))
|
||||
|
||||
(def reserved-props
|
||||
#{:session-id
|
||||
:password
|
||||
:old-password
|
||||
:token})
|
||||
|
||||
(defn clean-props
|
||||
[props]
|
||||
(into {}
|
||||
@@ -120,6 +79,18 @@
|
||||
(remove #(contains? reserved-props (key %))))
|
||||
props))
|
||||
|
||||
(defn event-from-rpc-params
|
||||
"Create a base event skeleton with pre-filled some important
|
||||
data that can be extracted from RPC params object"
|
||||
[params]
|
||||
(let [context {:external-session-id (::rpc/external-session-id params)
|
||||
:external-event-origin (::rpc/external-event-origin params)
|
||||
:triggered-by (::rpc/handler-name params)}]
|
||||
{::type "action"
|
||||
::profile-id (::rpc/profile-id params)
|
||||
::ip-addr (::rpc/ip-addr params)
|
||||
::context (d/without-nils context)}))
|
||||
|
||||
(defn get-external-session-id
|
||||
[request]
|
||||
(when-let [session-id (yreq/get-header request "x-external-session-id")]
|
||||
@@ -128,24 +99,13 @@
|
||||
(str/blank? session-id))
|
||||
session-id)))
|
||||
|
||||
(defn- get-client-event-origin
|
||||
(defn- get-external-event-origin
|
||||
[request]
|
||||
(when-let [origin (yreq/get-header request "x-event-origin")]
|
||||
(when-not (or (= origin "null")
|
||||
(when-not (or (> (count origin) 256)
|
||||
(= origin "null")
|
||||
(str/blank? origin))
|
||||
(str/prune origin 200))))
|
||||
|
||||
(defn get-client-user-agent
|
||||
[request]
|
||||
(when-let [user-agent (yreq/get-header request "user-agent")]
|
||||
(str/prune user-agent 500)))
|
||||
|
||||
(defn- get-client-version
|
||||
[request]
|
||||
(when-let [origin (yreq/get-header request "x-frontend-version")]
|
||||
(when-not (or (= origin "null")
|
||||
(str/blank? origin))
|
||||
(str/prune origin 100))))
|
||||
origin)))
|
||||
|
||||
;; --- SPECS
|
||||
|
||||
@@ -153,24 +113,19 @@
|
||||
;; COLLECTOR API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare ^:private prepare-context-from-request)
|
||||
|
||||
;; Defines a service that collects the audit/activity log using
|
||||
;; internal database. Later this audit log can be transferred to
|
||||
;; an external storage and data cleared.
|
||||
|
||||
(def ^:private schema:event
|
||||
[:map {:title "AuditEvent"}
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:type ::sm/text]
|
||||
[:name ::sm/text]
|
||||
[:profile-id ::sm/uuid]
|
||||
[:props [:map-of :keyword :any]]
|
||||
[:context [:map-of :keyword :any]]
|
||||
[:tracked-at ::ct/inst]
|
||||
[:created-at ::ct/inst]
|
||||
[:source ::sm/text]
|
||||
[:ip-addr {:optional true} ::sm/text]
|
||||
[:map {:title "event"}
|
||||
[::type ::sm/text]
|
||||
[::name ::sm/text]
|
||||
[::profile-id ::sm/uuid]
|
||||
[::ip-addr {:optional true} ::sm/text]
|
||||
[::props {:optional true} [:map-of :keyword :any]]
|
||||
[::context {:optional true} [:map-of :keyword :any]]
|
||||
[::tracked-at {:optional true} ::ct/inst]
|
||||
[::webhooks/event? {:optional true} ::sm/boolean]
|
||||
[::webhooks/batch-timeout {:optional true} ::ct/duration]
|
||||
[::webhooks/batch-key {:optional true}
|
||||
@@ -179,159 +134,7 @@
|
||||
(def ^:private check-event
|
||||
(sm/check-fn schema:event))
|
||||
|
||||
(def valid-event?
|
||||
(sm/validator schema:event))
|
||||
|
||||
(defn- prepare-context-from-request
|
||||
"Prepare backend event context from request"
|
||||
[request]
|
||||
(let [client-event-origin (get-client-event-origin request)
|
||||
client-version (get-client-version request)
|
||||
client-user-agent (get-client-user-agent request)
|
||||
session-id (get-external-session-id request)
|
||||
key-id (::http/auth-key-id request)
|
||||
token-id (::actoken/id request)
|
||||
token-type (::actoken/type request)]
|
||||
{:external-session-id session-id
|
||||
:initiator (or key-id "app")
|
||||
:access-token-id (some-> token-id str)
|
||||
:access-token-type (some-> token-type str)
|
||||
:client-event-origin client-event-origin
|
||||
:client-user-agent client-user-agent
|
||||
:client-version client-version
|
||||
:version (:full cf/version)}))
|
||||
|
||||
(defn- append-audit-entry
|
||||
[cfg params]
|
||||
(let [params (-> params
|
||||
(assoc :id (uuid/next))
|
||||
(update :props db/tjson)
|
||||
(update :context db/tjson)
|
||||
(update :ip-addr db/inet))
|
||||
params (select-keys params event-keys)]
|
||||
(db/insert! cfg :audit-log params)))
|
||||
|
||||
(def ^:private xf:filter-telemetry-props
|
||||
"Transducer that keeps only map entries whose values are UUIDs,
|
||||
booleans or numbers."
|
||||
(filter (fn [[k v]]
|
||||
(and (simple-keyword? k)
|
||||
(or (uuid? v) (boolean? v) (number? v))))))
|
||||
|
||||
(declare filter-telemetry-props)
|
||||
(declare filter-telemetry-context)
|
||||
|
||||
(defn- process-event
|
||||
[cfg event]
|
||||
(when (contains? cf/flags :audit-log-logger)
|
||||
(l/log! ::l/logger "app.audit"
|
||||
::l/level :info
|
||||
:profile-id (str (:profile-id event))
|
||||
:ip-addr (str (:ip-addr event))
|
||||
:type (:type event)
|
||||
:name (:name event)
|
||||
:props (json/encode (:props event) :key-fn json/write-camel-key)
|
||||
:context (json/encode (:context event) :key-fn json/write-camel-key)))
|
||||
|
||||
(when (contains? cf/flags :audit-log)
|
||||
(append-audit-entry cfg event))
|
||||
|
||||
(when (contains? cf/flags :telemetry)
|
||||
;; NOTE: when both audit-log and telemetry are enabled, events are stored
|
||||
;; twice: once with full details (above) and once stripped of props and
|
||||
;; ip-addr, tagged with source="telemetry" so the telemetry task can
|
||||
;; collect and ship them. The profile-id is preserved (UUIDs are already
|
||||
;; anonymous random identifiers). Only a safe subset of context fields
|
||||
;; is kept: initiator, version, client-version and client-user-agent.
|
||||
;; Timestamps are truncated to day precision to avoid leaking exact event
|
||||
;; timing.
|
||||
(let [event (-> event
|
||||
(filter-telemetry-props)
|
||||
(filter-telemetry-context)
|
||||
(update :created-at ct/truncate :days)
|
||||
(update :tracked-at ct/truncate :days)
|
||||
(assoc :source "telemetry:backend")
|
||||
(assoc :ip-addr "0.0.0.0"))]
|
||||
(append-audit-entry cfg event)))
|
||||
|
||||
(when (and (contains? cf/flags :webhooks)
|
||||
(::webhooks/event? event))
|
||||
(let [batch-key (::webhooks/batch-key event)
|
||||
batch-timeout (::webhooks/batch-timeout event)
|
||||
label (dm/str "rpc:" (:name event))
|
||||
label (cond
|
||||
(ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event)))
|
||||
(string? batch-key) (dm/str label ":" batch-key)
|
||||
:else label)
|
||||
dedupe? (boolean (and batch-key batch-timeout))]
|
||||
|
||||
(wrk/submit! (-> cfg
|
||||
(assoc ::wrk/task :process-webhook-event)
|
||||
(assoc ::wrk/queue :webhooks)
|
||||
(assoc ::wrk/max-retries 0)
|
||||
(assoc ::wrk/delay (or batch-timeout 0))
|
||||
(assoc ::wrk/dedupe dedupe?)
|
||||
(assoc ::wrk/label label)
|
||||
(assoc ::wrk/params (-> event
|
||||
(d/without-qualified)
|
||||
(dissoc :source)
|
||||
(dissoc :context)
|
||||
(dissoc :ip-addr)
|
||||
(dissoc :type)))))))
|
||||
event)
|
||||
|
||||
(defn submit*
|
||||
"A public API, lower-level than submit, assumes all required fields are filled"
|
||||
[cfg event]
|
||||
(try
|
||||
(let [event (check-event event)]
|
||||
(db/tx-run! cfg process-event event))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error processing event" :cause cause))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PUBLIC API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn filter-telemetry-props
|
||||
[{:keys [source name props type] :as params}]
|
||||
(cond
|
||||
(or (and (= source "frontend")
|
||||
(= type "identify"))
|
||||
(and (= source "backend")
|
||||
(filter-auth-events name)))
|
||||
|
||||
(let [props' (into {} xf:filter-telemetry-props props)
|
||||
props' (-> props'
|
||||
(assoc :lang (:lang props))
|
||||
(assoc :auth-backend (:auth-backend props))
|
||||
(assoc :email-domain (email/get-domain (:email props)))
|
||||
(d/without-nils))]
|
||||
(assoc params :props props'))
|
||||
|
||||
(and (= source "backend")
|
||||
(= type "trigger")
|
||||
(= name "instance-start"))
|
||||
params
|
||||
|
||||
(and (= source "frontend")
|
||||
(= type "action")
|
||||
(= name "navigate"))
|
||||
(assoc params :props (select-keys props [:route :file-id :team-id :page-id]))
|
||||
|
||||
:else
|
||||
(let [props (into {} xf:filter-telemetry-props props)]
|
||||
(assoc params :props props))))
|
||||
|
||||
(defn filter-telemetry-context
|
||||
[{:keys [source context] :as params}]
|
||||
(let [context (case source
|
||||
"backend" (select-keys context safe-backend-context-keys)
|
||||
"frontend" (select-keys context safe-frontend-context-keys)
|
||||
{})]
|
||||
(assoc params :context context)))
|
||||
|
||||
(defn prepare-rpc-event
|
||||
(defn prepare-event
|
||||
[cfg mdata params result]
|
||||
(let [resultm (meta result)
|
||||
request (-> params meta ::http/request)
|
||||
@@ -341,32 +144,32 @@
|
||||
uuid/zero)
|
||||
|
||||
props (-> (or (::replace-props resultm)
|
||||
(merge params (::props resultm)))
|
||||
(-> params
|
||||
(merge (::props resultm))
|
||||
(dissoc :profile-id)
|
||||
(dissoc :type)))
|
||||
|
||||
(clean-props))
|
||||
|
||||
token-id (::actoken/id request)
|
||||
context (-> (::context resultm)
|
||||
(merge (prepare-context-from-request request))
|
||||
(assoc :request-id (::rpc/request-id params))
|
||||
(assoc :external-session-id
|
||||
(get-external-session-id request))
|
||||
(assoc :external-event-origin
|
||||
(get-external-event-origin request))
|
||||
(assoc :access-token-id (some-> token-id str))
|
||||
(d/without-nils))
|
||||
|
||||
ip-addr (inet/parse-request request)
|
||||
module (get cfg ::rpc/module)]
|
||||
ip-addr (inet/parse-request request)]
|
||||
|
||||
{:type (or (::type resultm)
|
||||
(::rpc/type cfg))
|
||||
:name (or (::name resultm)
|
||||
(let [sname (::sv/name mdata)]
|
||||
(if (not= module "main")
|
||||
(str module "-" sname)
|
||||
sname)))
|
||||
|
||||
:profile-id profile-id
|
||||
:ip-addr ip-addr
|
||||
:props props
|
||||
:context context
|
||||
|
||||
:created-at (::rpc/request-at params)
|
||||
:tracked-at (::rpc/request-at params)
|
||||
{::type (or (::type resultm)
|
||||
(::rpc/type cfg))
|
||||
::name (or (::name resultm)
|
||||
(::sv/name mdata))
|
||||
::profile-id profile-id
|
||||
::ip-addr ip-addr
|
||||
::props props
|
||||
::context context
|
||||
|
||||
;; NOTE: for batch-key lookup we need the params as-is
|
||||
;; because the rpc api does not need to know the
|
||||
@@ -386,49 +189,109 @@
|
||||
(::webhooks/event? resultm)
|
||||
false)}))
|
||||
|
||||
(defn event-from-rpc-params
|
||||
"Create a base event skeleton with pre-filled some important
|
||||
data that can be extracted from RPC params object"
|
||||
[params]
|
||||
(let [context (some-> params meta ::http/request prepare-context-from-request)
|
||||
context (assoc context :request-id (::rpc/request-id params))
|
||||
request-at (::rpc/request-at params)]
|
||||
{:type "action"
|
||||
:profile-id (::rpc/profile-id params)
|
||||
:created-at request-at
|
||||
:tracked-at request-at
|
||||
:ip-addr (::rpc/ip-addr params)
|
||||
:context (d/without-nils context)}))
|
||||
(defn- event->params
|
||||
[event]
|
||||
(let [params {:id (uuid/next)
|
||||
:name (::name event)
|
||||
:type (::type event)
|
||||
:profile-id (::profile-id event)
|
||||
:ip-addr (::ip-addr event)
|
||||
:context (::context event {})
|
||||
:props (::props event {})
|
||||
:source "backend"}
|
||||
tnow (::tracked-at event)]
|
||||
|
||||
(defn submit
|
||||
"Submit an event to be registered under audit-log subsystem"
|
||||
(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]
|
||||
(let [tnow (ct/now)
|
||||
event (-> event
|
||||
(assoc :created-at tnow)
|
||||
(update :profile-id d/nilv uuid/zero)
|
||||
(update :tracked-at d/nilv tnow)
|
||||
(update :ip-addr d/nilv "0.0.0.0")
|
||||
(update :props d/nilv {})
|
||||
(update :context d/nilv {})
|
||||
(assoc :source "backend")
|
||||
(d/without-nils))]
|
||||
(submit* cfg event)))
|
||||
(let [params (event->params event)
|
||||
tnow (ct/now)]
|
||||
|
||||
(defn insert
|
||||
(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.
|
||||
(let [params (-> params
|
||||
(assoc :created-at tnow)
|
||||
(update :tracked-at #(or % tnow)))]
|
||||
(append-audit-entry! cfg params)))
|
||||
|
||||
(when (and (or (contains? cf/flags :telemetry)
|
||||
(cf/get :telemetry-enabled))
|
||||
(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 :created-at tnow)
|
||||
(update :tracked-at #(or % tnow))
|
||||
(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
|
||||
command line helpers because this skips all webhooks and telemetry
|
||||
logic."
|
||||
[cfg event]
|
||||
(when (contains? cf/flags :audit-log)
|
||||
(let [tnow (ct/now)
|
||||
event (-> event
|
||||
(assoc :created-at tnow)
|
||||
(update :tracked-at d/nilv tnow)
|
||||
(update :profile-id d/nilv uuid/zero)
|
||||
(update :props d/nilv {})
|
||||
(update :context d/nilv {})
|
||||
(assoc :source "backend")
|
||||
(select-keys event-keys)
|
||||
(let [event (-> (d/without-nils 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.schema :as sm]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[integrant.core :as ig]
|
||||
[lambdaisland.uri :as u]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
;; This is a task responsible to send the accumulated events to
|
||||
@@ -49,18 +52,19 @@
|
||||
|
||||
(defn- send!
|
||||
[{:keys [::uri] :as cfg} events]
|
||||
(let [skey (-> cfg ::setup/shared-keys :nexus)
|
||||
(let [token (tokens/generate cfg
|
||||
{:iss "authentication"
|
||||
:uid uuid/zero})
|
||||
body (t/encode {:events events})
|
||||
headers {"content-type" "application/transit+json"
|
||||
"origin" (str (cf/get :public-uri))
|
||||
"x-shared-key" (str "nexus " skey)}
|
||||
"cookie" (u/map->query-string {:auth-token token})}
|
||||
params {:uri uri
|
||||
:timeout 12000
|
||||
:method :post
|
||||
:headers headers
|
||||
:body body}
|
||||
resp (http/req cfg params {:skip-ssrf-check? true})]
|
||||
|
||||
resp (http/req! cfg params)]
|
||||
(if (= (:status resp) 204)
|
||||
true
|
||||
(do
|
||||
@@ -105,7 +109,7 @@
|
||||
(def ^:private schema:handler-params
|
||||
[:map
|
||||
::db/pool
|
||||
::setup/shared-keys
|
||||
::setup/props
|
||||
::http/client])
|
||||
|
||||
(defmethod ig/assert-key ::handler
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.rlimit :as-alias rlimit]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]
|
||||
@@ -30,145 +28,69 @@
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- persist-on-database!
|
||||
[pool id version report]
|
||||
[pool id report]
|
||||
(when-not (db/read-only? pool)
|
||||
(db/insert! pool :server-error-report
|
||||
{:id id
|
||||
:version version
|
||||
:version 3
|
||||
:content (db/tjson report)})))
|
||||
|
||||
(defn- concurrent-exception?
|
||||
[cause]
|
||||
(or (instance? java.util.concurrent.CompletionException cause)
|
||||
(instance? java.util.concurrent.ExecutionException cause)))
|
||||
|
||||
(defn- log-record->report
|
||||
(defn record->report
|
||||
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
|
||||
(assert (l/valid-record? record) "expectd valid log record")
|
||||
(let [data (if (concurrent-exception? cause)
|
||||
(ex-data (ex-cause cause))
|
||||
(ex-data cause))
|
||||
(if (or (instance? java.util.concurrent.CompletionException cause)
|
||||
(instance? java.util.concurrent.ExecutionException cause))
|
||||
(-> record
|
||||
(assoc ::trace (ex/format-throwable cause :data? true :explain? false :header? false :summary? false))
|
||||
(assoc ::l/cause (ex-cause cause))
|
||||
(record->report))
|
||||
|
||||
ctx (-> context
|
||||
(assoc :service/tenant (cf/get :tenant))
|
||||
(assoc :service/host (cf/get :host))
|
||||
(assoc :service/public-uri (str (cf/get :public-uri)))
|
||||
(assoc :backend/version (:full cf/version))
|
||||
(assoc :logger/name logger)
|
||||
(assoc :logger/level level)
|
||||
(dissoc :request/params :value :params :data))]
|
||||
(let [data (ex-data cause)
|
||||
ctx (-> context
|
||||
(assoc :tenant (cf/get :tenant))
|
||||
(assoc :host (cf/get :host))
|
||||
(assoc :public-uri (str (cf/get :public-uri)))
|
||||
(assoc :logger/name logger)
|
||||
(assoc :logger/level level)
|
||||
(dissoc :request/params :value :params :data))]
|
||||
|
||||
(merge
|
||||
{:context (-> (into (sorted-map) ctx)
|
||||
(pp/pprint-str :length 50))
|
||||
:props (pp/pprint-str props :length 50)
|
||||
:hint (or (when-let [message (ex-message cause)]
|
||||
(if-let [props-hint (:hint props)]
|
||||
(str props-hint ": " message)
|
||||
message))
|
||||
@message)
|
||||
:trace (or (::trace record)
|
||||
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
|
||||
(merge
|
||||
{:context (-> (into (sorted-map) ctx)
|
||||
(pp/pprint-str :length 50))
|
||||
:props (pp/pprint-str props :length 50)
|
||||
:hint (or (when-let [message (ex-message cause)]
|
||||
(if-let [props-hint (:hint props)]
|
||||
(str props-hint ": " message)
|
||||
message))
|
||||
@message)
|
||||
:trace (or (::trace record)
|
||||
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
|
||||
|
||||
(when-let [params (or (:request/params context) (:params context))]
|
||||
{:params (pp/pprint-str params :length 20 :level 20)})
|
||||
(when-let [params (or (:request/params context) (:params context))]
|
||||
{:params (pp/pprint-str params :length 20 :level 20)})
|
||||
|
||||
(when-let [value (:value context)]
|
||||
{:value (pp/pprint-str value :length 30 :level 13)})
|
||||
(when-let [value (:value context)]
|
||||
{:value (pp/pprint-str value :length 30 :level 13)})
|
||||
|
||||
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
|
||||
{:data (pp/pprint-str data :length 30 :level 13)})
|
||||
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
|
||||
{:data (pp/pprint-str data :length 30 :level 13)})
|
||||
|
||||
(when-let [explain (ex/explain data :length 30 :level 13)]
|
||||
{:explain explain}))))
|
||||
(when-let [explain (ex/explain data :length 30 :level 13)]
|
||||
{:explain explain})))))
|
||||
|
||||
(defn- handle-log-record
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
(defn error-record?
|
||||
[{:keys [::l/level]}]
|
||||
(= :error level))
|
||||
|
||||
(defn- handle-event
|
||||
[{:keys [::db/pool]} {:keys [::l/id] :as record}]
|
||||
(try
|
||||
(let [uri (cf/get :public-uri)
|
||||
report (-> record log-record->report d/without-nils)]
|
||||
(l/dbg :hint "registering error on database"
|
||||
:id (str id)
|
||||
:src "logging"
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
(persist-on-database! pool id 3 report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
report (-> record record->report d/without-nils)]
|
||||
(l/debug :hint "registering error on database" :id id
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
|
||||
(defn- audit-event->report
|
||||
[{:keys [context props ip-addr] :as record}]
|
||||
(let [context
|
||||
(reduce-kv (fn [context k v]
|
||||
(let [k' (keyword "frontend" (name k))]
|
||||
(-> context
|
||||
(dissoc k)
|
||||
(assoc k' v))))
|
||||
context
|
||||
context)
|
||||
|
||||
context
|
||||
(-> context
|
||||
(assoc :backend/tenant (cf/get :tenant))
|
||||
(assoc :backend/host (cf/get :host))
|
||||
(assoc :backend/public-uri (str (cf/get :public-uri)))
|
||||
(assoc :backend/version (:full cf/version))
|
||||
(assoc :frontend/ip-addr ip-addr))]
|
||||
|
||||
{:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:origin (:name record)
|
||||
:href (get props :href)
|
||||
:hint (get props :hint)
|
||||
:report (get props :report)}))
|
||||
|
||||
(defn- handle-audit-event
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
[{:keys [::db/pool]} {:keys [id] :as event}]
|
||||
(try
|
||||
(let [uri (cf/get :public-uri)
|
||||
report (-> event audit-event->report d/without-nils)]
|
||||
(l/dbg :hint "registering error on database"
|
||||
:id (str id)
|
||||
:src "audit-log"
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
(persist-on-database! pool id 4 report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defn- rlimit-event->report
|
||||
[event]
|
||||
(let [context
|
||||
(-> {}
|
||||
(assoc :rlimit/uid (::rlimit/uid event))
|
||||
(assoc :rlimit/method (::rlimit/method event))
|
||||
(assoc :backend/tenant (cf/get :tenant))
|
||||
(assoc :backend/host (cf/get :host))
|
||||
(assoc :backend/public-uri (str (cf/get :public-uri)))
|
||||
(assoc :backend/version (:full cf/version)))
|
||||
|
||||
result
|
||||
(->> (::rlimit/results event)
|
||||
(mapv (fn [result]
|
||||
(-> (into (sorted-map) result)
|
||||
(dissoc ::rlimit/method)))))]
|
||||
|
||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||
:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:result (pp/pprint-str result :length 50)}))
|
||||
|
||||
(defn- handle-rlimit-event
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
[{:keys [::db/pool]} {:keys [::rlimit/id] :as event}]
|
||||
(try
|
||||
(let [uri (cf/get :public-uri)
|
||||
report (-> event rlimit-event->report d/without-nils)]
|
||||
(l/dbg :hint "registering rate limit rejection"
|
||||
:id (str id)
|
||||
:src "rlimit"
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
(persist-on-database! pool id 5 report))
|
||||
(persist-on-database! pool id report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
@@ -178,51 +100,26 @@
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ cfg]
|
||||
(let [input (sp/chan :buf (sp/sliding-buffer 256))
|
||||
thread (px/thread
|
||||
{:name "penpot/reporter/database"}
|
||||
(l/info :hint "initializing database error persistence")
|
||||
(try
|
||||
(loop []
|
||||
(when-let [item (sp/take! input)]
|
||||
(cond
|
||||
(::l/id item)
|
||||
(handle-log-record cfg item)
|
||||
(let [input (sp/chan :buf (sp/sliding-buffer 64)
|
||||
:xf (filter error-record?))]
|
||||
(add-watch l/log-record ::reporter #(sp/put! input %4))
|
||||
|
||||
(::rlimit/id item)
|
||||
(handle-rlimit-event cfg item)
|
||||
|
||||
(-> item meta ::audit/event)
|
||||
(handle-audit-event cfg item)
|
||||
|
||||
:else
|
||||
(l/warn :hint "received unexpected item" :item item))
|
||||
|
||||
(recur)))
|
||||
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "reporter interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error" :cause cause))
|
||||
(finally
|
||||
(l/info :hint "reporter terminated"))))]
|
||||
|
||||
(add-watch l/log-record ::reporter
|
||||
(fn [_ _ _ record]
|
||||
(when (= :error (::l/level record))
|
||||
(sp/put! input record))))
|
||||
|
||||
{::input input
|
||||
::thread thread}))
|
||||
(px/thread {:name "penpot/database-reporter"}
|
||||
(l/info :hint "initializing database error persistence")
|
||||
(try
|
||||
(loop []
|
||||
(when-let [record (sp/take! input)]
|
||||
(handle-event cfg record)
|
||||
(recur)))
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "reporter interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error" :cause cause))
|
||||
(finally
|
||||
(sp/close! input)
|
||||
(remove-watch l/log-record ::reporter)
|
||||
(l/info :hint "reporter terminated"))))))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
[_ {:keys [::input ::thread]}]
|
||||
(remove-watch l/log-record ::reporter)
|
||||
(sp/close! input)
|
||||
(px/interrupt! thread))
|
||||
|
||||
(defn emit
|
||||
"Emit an event/report into the database reporter"
|
||||
[cfg event]
|
||||
(when-let [{:keys [::input]} (get cfg ::reporter)]
|
||||
(sp/put! input event)))
|
||||
[_ thread]
|
||||
(some-> thread px/interrupt!))
|
||||
@@ -9,12 +9,9 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.rlimit :as-alias rlimit]
|
||||
[app.loggers.database :as ldb]
|
||||
[app.util.json :as json]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]
|
||||
@@ -23,110 +20,58 @@
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [id] :as report}]
|
||||
(let [type (get report :type)
|
||||
text (str "#" type " | " (get report :hint) "\n"
|
||||
(when id
|
||||
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
||||
[cfg {:keys [id public-uri] :as report}]
|
||||
|
||||
|
||||
(let [text (str "Exception: " public-uri "/dbg/error/" id " "
|
||||
(when-let [pid (:profile-id report)]
|
||||
(if (uuid? pid)
|
||||
(str "(pid: #uuid-" pid ")")
|
||||
(str "(pid: #ip-" pid ")")))
|
||||
(str "(pid: #uuid-" pid ")"))
|
||||
"\n"
|
||||
"- host: #" (:host report) "\n"
|
||||
"- tenant: #" (:tenant report) "\n"
|
||||
"- origin: #" (:origin report) "\n"
|
||||
(when-let [href (get report :href)]
|
||||
(str "- href: `" href "`\n"))
|
||||
(when-let [version (get report :frontend-version)]
|
||||
(str "- frontend-version: `" version "`\n"))
|
||||
(when-let [version (get report :backend-version)]
|
||||
(str "- backend-version: `" version "`\n"))
|
||||
"- logger: #" (:logger report) "\n"
|
||||
"- request-path: `" (:request-path report) "`\n"
|
||||
"- frontend-version: `" (:frontend-version report) "`\n"
|
||||
"- backend-version: `" (:backend-version report) "`\n"
|
||||
"\n"
|
||||
(when-let [info (:info report)]
|
||||
(str "```\n" info "```"))
|
||||
(when-let [trace (:trace report)]
|
||||
(str "```\n"
|
||||
"Trace:\n"
|
||||
trace
|
||||
"```")))
|
||||
"```\n"
|
||||
"Trace:\n"
|
||||
(:trace report)
|
||||
"```")
|
||||
|
||||
resp (http/req cfg
|
||||
{:uri (cf/get :error-report-webhook)
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode-str {:text text})}
|
||||
{:sync? true})]
|
||||
resp (http/req! cfg
|
||||
{:uri (cf/get :error-report-webhook)
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode-str {:text text})}
|
||||
{:sync? true})]
|
||||
|
||||
(when (not= 200 (:status resp))
|
||||
(l/warn :hint "error on sending data"
|
||||
:response (pr-str resp)))))
|
||||
|
||||
(defn- log-record->report
|
||||
[{:keys [::l/context ::l/id ::l/cause ::l/message] :as record}]
|
||||
(defn record->report
|
||||
[{:keys [::l/context ::l/id ::l/cause] :as record}]
|
||||
(assert (l/valid-record? record) "expectd valid log record")
|
||||
|
||||
(let [public-uri (cf/get :public-uri)]
|
||||
{:id id
|
||||
:type "exception"
|
||||
:origin "logging"
|
||||
:hint (or (some-> cause ex-message) @message)
|
||||
:tenant (cf/get :tenant)
|
||||
:host (cf/get :host)
|
||||
:backend-version (:full cf/version)
|
||||
:frontend-version (:frontend/version context)
|
||||
:profile-id (:request/profile-id context)
|
||||
:href (-> public-uri
|
||||
(assoc :path (:request/path context))
|
||||
(str))
|
||||
:trace (ex/format-throwable cause :detail? false :header? false)}))
|
||||
|
||||
(defn- audit-event->report
|
||||
[{:keys [context props id] :as event}]
|
||||
{:id id
|
||||
:type "exception"
|
||||
:origin "audit-log"
|
||||
:hint (get props :hint)
|
||||
:tenant (cf/get :tenant)
|
||||
:host (cf/get :host)
|
||||
:backend-version (:full cf/version)
|
||||
:frontend-version (:version context)
|
||||
:profile-id (:profile-id event)
|
||||
:href (get props :href)})
|
||||
:public-uri (cf/get :public-uri)
|
||||
:backend-version (or (:version/backend context) (:full cf/version))
|
||||
:frontend-version (:version/frontend context)
|
||||
:profile-id (:request/profile-id context)
|
||||
:request-path (:request/path context)
|
||||
:logger (::l/logger record)
|
||||
:trace (ex/format-throwable cause :detail? false :header? false)})
|
||||
|
||||
(defn- rlimit-event->report
|
||||
[event]
|
||||
{:id (::rlimit/id event)
|
||||
:type "notification"
|
||||
:origin "rlimit"
|
||||
:hint (str "rlimit reject of "
|
||||
(::rlimit/method event)
|
||||
" for "
|
||||
(::rlimit/uid event))
|
||||
:tenant (cf/get :tenant)
|
||||
:host (cf/get :host)
|
||||
:backend-version (:full cf/version)
|
||||
:profile-id (::rlimit/profile-id event)
|
||||
:info (with-out-str
|
||||
(println "Rejected by:")
|
||||
(println "------------")
|
||||
(println "Method: " (::rlimit/method event))
|
||||
(println "Limit Name: " (::rlimit/name event))
|
||||
(println "Limit Strategy:" (::rlimit/strategy event))
|
||||
(println)
|
||||
(println "Results & Config:")
|
||||
(println "-----------------")
|
||||
(doseq [result (::rlimit/results event)]
|
||||
(pp/pprint (into (sorted-map) result))))})
|
||||
|
||||
(defn- handle-event
|
||||
[cfg event event->report]
|
||||
(try
|
||||
(let [report (event->report event)]
|
||||
(send-mattermost-notification! cfg report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unhandled error" :cause cause))))
|
||||
(defn handle-event
|
||||
[cfg record]
|
||||
(when @enabled
|
||||
(try
|
||||
(let [report (record->report record)]
|
||||
(send-mattermost-notification! cfg report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unhandled error" :cause cause)))))
|
||||
|
||||
(defmethod ig/assert-key ::reporter
|
||||
[_ params]
|
||||
@@ -135,52 +80,27 @@
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ cfg]
|
||||
(when-let [uri (cf/get :error-report-webhook)]
|
||||
(let [input (sp/chan :buf (sp/sliding-buffer 256))
|
||||
thread (px/thread
|
||||
{:name "penpot/reporter/mattermost"}
|
||||
(l/info :hint "initializing error reporter" :uri uri)
|
||||
|
||||
(try
|
||||
(loop []
|
||||
(when-let [item (sp/take! input)]
|
||||
(when @enabled
|
||||
(cond
|
||||
(::l/id item)
|
||||
(handle-event cfg item log-record->report)
|
||||
|
||||
(::rlimit/id item)
|
||||
(handle-event cfg item rlimit-event->report)
|
||||
|
||||
(-> item meta ::audit/event)
|
||||
(handle-event cfg item audit-event->report)
|
||||
|
||||
:else
|
||||
(l/warn :hint "received unexpected item" :item item)))
|
||||
|
||||
(recur)))
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "reporter interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error" :cause cause))
|
||||
(finally
|
||||
(l/info :hint "reporter terminated"))))]
|
||||
|
||||
(add-watch l/log-record ::reporter
|
||||
(fn [_ _ _ record]
|
||||
(when (= :error (::l/level record))
|
||||
(sp/put! input record))))
|
||||
|
||||
{::input input
|
||||
::thread thread})))
|
||||
(px/thread
|
||||
{:name "penpot/mattermost-reporter"
|
||||
:virtual true}
|
||||
(l/info :hint "initializing error reporter" :uri uri)
|
||||
(let [input (sp/chan :buf (sp/sliding-buffer 128)
|
||||
:xf (filter ldb/error-record?))]
|
||||
(add-watch l/log-record ::reporter #(sp/put! input %4))
|
||||
(try
|
||||
(loop []
|
||||
(when-let [msg (sp/take! input)]
|
||||
(handle-event cfg msg)
|
||||
(recur)))
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "reporter interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error" :cause cause))
|
||||
(finally
|
||||
(sp/close! input)
|
||||
(remove-watch l/log-record ::reporter)
|
||||
(l/info :hint "reporter terminated")))))))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
[_ {:keys [::input ::thread]}]
|
||||
(remove-watch l/log-record ::reporter)
|
||||
(some-> input sp/close!)
|
||||
[_ thread]
|
||||
(some-> thread px/interrupt!))
|
||||
|
||||
(defn emit
|
||||
"Emit an event/report into the mattermost reporter"
|
||||
[cfg event]
|
||||
(when-let [{:keys [::input]} (get cfg ::reporter)]
|
||||
(sp/put! input event)))
|
||||
@@ -70,14 +70,14 @@
|
||||
(fn [{:keys [props] :as task}]
|
||||
|
||||
(let [items (lookup-webhooks cfg props)
|
||||
event {:profile-id (:profile-id props)
|
||||
:name "webhook"
|
||||
:type "trigger"
|
||||
:props {:name (get props :name)
|
||||
:event-id (get props :id)
|
||||
:total-affected (count items)}}]
|
||||
event {::audit/profile-id (:profile-id props)
|
||||
::audit/name "webhook"
|
||||
::audit/type "trigger"
|
||||
::audit/props {:name (get props :name)
|
||||
:event-id (get props :id)
|
||||
:total-affected (count items)}}]
|
||||
|
||||
(audit/insert cfg event)
|
||||
(audit/insert! cfg event)
|
||||
|
||||
(when items
|
||||
(l/trc :hint "webhooks found for event" :total (count items))
|
||||
@@ -159,7 +159,7 @@
|
||||
:method :post
|
||||
:body body}]
|
||||
(try
|
||||
(let [rsp (http/req cfg req {:response-type :input-stream :sync? true})
|
||||
(let [rsp (http/req! cfg req {:response-type :input-stream :sync? true})
|
||||
err (interpret-response rsp)]
|
||||
(report-delivery! whook req rsp err)
|
||||
(update-webhook! whook err))
|
||||
@@ -190,11 +190,4 @@
|
||||
"invalid-uri"
|
||||
|
||||
(instance? java.net.http.HttpConnectTimeoutException cause)
|
||||
"timeout"
|
||||
|
||||
:else
|
||||
(let [data (ex-data cause)]
|
||||
(if (and (= :validation (:type data))
|
||||
(= :ssrf-blocked-target (:code data)))
|
||||
(str "blocked-request:" (:hint data))
|
||||
nil))))
|
||||
"timeout"))
|
||||
+28
-53
@@ -61,15 +61,21 @@
|
||||
::mdef/help "A total number of bytes processed by update-file."
|
||||
::mdef/type :counter}
|
||||
|
||||
:rpc-main-timing
|
||||
{::mdef/name "penpot_rpc_main_timing"
|
||||
::mdef/help "RPC command method call timing for main"
|
||||
:rpc-mutation-timing
|
||||
{::mdef/name "penpot_rpc_mutation_timing"
|
||||
::mdef/help "RPC mutation method call timing."
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :histogram}
|
||||
|
||||
:rpc-management-timing
|
||||
{::mdef/name "penpot_rpc_management_timing"
|
||||
::mdef/help "RPC command method call timing for management."
|
||||
:rpc-command-timing
|
||||
{::mdef/name "penpot_rpc_command_timing"
|
||||
::mdef/help "RPC command method call timing."
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :histogram}
|
||||
|
||||
:rpc-query-timing
|
||||
{::mdef/name "penpot_rpc_query_timing"
|
||||
::mdef/help "RPC query method call timing."
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :histogram}
|
||||
|
||||
@@ -220,10 +226,11 @@
|
||||
::http/server
|
||||
{::http/port (cf/get :http-server-port)
|
||||
::http/host (cf/get :http-server-host)
|
||||
::http/router (ig/ref ::http/router)
|
||||
::http/io-threads (cf/get :http-server-io-threads)
|
||||
::http/max-worker-threads (cf/get :http-server-max-worker-threads)
|
||||
::http/max-body-size (cf/get :http-server-max-body-size)
|
||||
::http/router (ig/ref ::http/router)
|
||||
::http/max-multipart-body-size (cf/get :http-server-max-multipart-body-size)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
|
||||
::ldap/provider
|
||||
@@ -298,11 +305,10 @@
|
||||
::session/manager (ig/ref ::session/manager)}
|
||||
|
||||
:app.http.assets/routes
|
||||
{::http.assets/path (cf/get :assets-path)
|
||||
::http.assets/cache-max-age (ct/duration {:hours 24})
|
||||
::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15})
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::session/manager (ig/ref ::session/manager)}
|
||||
{::http.assets/path (cf/get :assets-path)
|
||||
::http.assets/cache-max-age (ct/duration {:hours 24})
|
||||
::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5})
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
::rpc/climit
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
@@ -311,19 +317,12 @@
|
||||
::climit/enabled (contains? cf/flags :rpc-climit)}
|
||||
|
||||
:app.rpc/rlimit
|
||||
{::wrk/executor (ig/ref ::wrk/netty-executor)
|
||||
|
||||
:app.loggers.mattermost/reporter
|
||||
(ig/ref :app.loggers.mattermost/reporter)
|
||||
|
||||
:app.loggers.database/reporter
|
||||
(ig/ref :app.loggers.database/reporter)}
|
||||
{::wrk/executor (ig/ref ::wrk/netty-executor)}
|
||||
|
||||
:app.rpc/methods
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::rds/pool (ig/ref ::rds/pool)
|
||||
:app.nitrate/client (ig/ref :app.nitrate/client)
|
||||
::wrk/executor (ig/ref ::wrk/netty-executor)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::ldap/provider (ig/ref ::ldap/provider)
|
||||
@@ -338,17 +337,7 @@
|
||||
::setup/props (ig/ref ::setup/props)
|
||||
|
||||
::email/blacklist (ig/ref ::email/blacklist)
|
||||
::email/whitelist (ig/ref ::email/whitelist)
|
||||
|
||||
:app.loggers.database/reporter
|
||||
(ig/ref :app.loggers.database/reporter)
|
||||
|
||||
:app.loggers.mattermost/reporter
|
||||
(ig/ref :app.loggers.mattermost/reporter)}
|
||||
|
||||
:app.nitrate/client
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::setup/shared-keys (ig/ref ::setup/shared-keys)}
|
||||
::email/whitelist (ig/ref ::email/whitelist)}
|
||||
|
||||
:app.rpc/management-methods
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
@@ -359,19 +348,17 @@
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::mbus/msgbus (ig/ref ::mbus/msgbus)
|
||||
:app.nitrate/client (ig/ref :app.nitrate/client)
|
||||
::rds/client (ig/ref ::rds/client)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
::rpc/routes
|
||||
{::rpc/methods (ig/ref :app.rpc/methods)
|
||||
{::rpc/methods (ig/ref :app.rpc/methods)
|
||||
::rpc/management-methods (ig/ref :app.rpc/management-methods)
|
||||
|
||||
;; FIXME: revisit if db/pool is necessary here
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::setup/props (ig/ref ::setup/props)
|
||||
::setup/shared-keys (ig/ref ::setup/shared-keys)}
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
::wrk/registry
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
@@ -383,7 +370,6 @@
|
||||
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
|
||||
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
||||
:telemetry (ig/ref :app.tasks.telemetry/handler)
|
||||
:upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler)
|
||||
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
|
||||
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
|
||||
:session-gc (ig/ref ::session.tasks/gc)
|
||||
@@ -419,9 +405,6 @@
|
||||
:app.tasks.tasks-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.upload-session-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.objects-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
@@ -463,19 +446,13 @@
|
||||
;; module requires the migrations to run before initialize.
|
||||
::migrations (ig/ref :app.migrations/migrations)}
|
||||
|
||||
::setup/shared-keys
|
||||
{::setup/props (ig/ref ::setup/props)
|
||||
:nexus (cf/get :nexus-shared-key)
|
||||
:nitrate (cf/get :nitrate-shared-key)
|
||||
:exporter (cf/get :exporter-shared-key)}
|
||||
|
||||
::setup/clock
|
||||
{}
|
||||
|
||||
:app.loggers.audit.archive-task/handler
|
||||
{::setup/shared-keys (ig/ref ::setup/shared-keys)
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)}
|
||||
{::setup/props (ig/ref ::setup/props)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
:app.loggers.audit.gc-task/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
@@ -543,9 +520,6 @@
|
||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
||||
:task :tasks-gc}
|
||||
|
||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
||||
:task :upload-session-gc}
|
||||
|
||||
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
|
||||
:task :file-gc-scheduler}
|
||||
|
||||
@@ -653,8 +627,9 @@
|
||||
[& _args]
|
||||
(try
|
||||
(let [p (promise)]
|
||||
(l/inf :hint "start nrepl server" :port 6064)
|
||||
(nrepl/start-server :bind "0.0.0.0" :port 6064)
|
||||
(when (contains? cf/flags :nrepl-server)
|
||||
(l/inf :hint "start nrepl server" :port 6064)
|
||||
(nrepl/start-server :bind "0.0.0.0" :port 6064))
|
||||
|
||||
(start)
|
||||
(deref p))
|
||||
|
||||
+50
-93
@@ -18,7 +18,6 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.http.client :as http]
|
||||
[app.media.sanitize :as sanitize]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[buddy.core.bytes :as bb]
|
||||
@@ -32,11 +31,15 @@
|
||||
(:import
|
||||
clojure.lang.XMLHandler
|
||||
java.io.InputStream
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
javax.xml.XMLConstants
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
org.apache.commons.io.IOUtils
|
||||
org.im4java.core.ConvertCmd
|
||||
org.im4java.core.IMOperation))
|
||||
org.im4java.core.IMOperation
|
||||
org.im4java.core.Info))
|
||||
|
||||
(def default-max-file-size
|
||||
(* 1024 1024 10)) ; 10 MiB
|
||||
|
||||
(def schema:upload
|
||||
[:map {:title "Upload"}
|
||||
@@ -76,20 +79,6 @@
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defn validate-font-size!
|
||||
"Validates that the font file `upload` does not exceed the configured
|
||||
`:font-max-file-size` limit. Accepts the same map shape as
|
||||
`validate-media-size!` — requires a `:size` key in bytes."
|
||||
[upload]
|
||||
(let [max-size (cf/get :font-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :font-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defmulti process :cmd)
|
||||
(defmulti process-error class)
|
||||
|
||||
@@ -235,18 +224,17 @@
|
||||
;; If we are processing an animated gif we use the first frame with -scene 0
|
||||
(let [dim-result (sh/sh "identify" "-format" "%w %h\n" path)
|
||||
orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)]
|
||||
(when (= 0 (:exit dim-result))
|
||||
(if (and (= 0 (:exit dim-result))
|
||||
(= 0 (:exit orient-result)))
|
||||
(let [[w h] (-> (:out dim-result)
|
||||
str/trim
|
||||
(clojure.string/split #"\s+")
|
||||
(->> (mapv #(Integer/parseInt %))))
|
||||
orientation-exit (:exit orient-result)
|
||||
orientation (-> orient-result :out str/trim)]
|
||||
(if (= 0 orientation-exit)
|
||||
(case orientation
|
||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||
{:width w :height h}) ; Normal or unknown orientation
|
||||
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
||||
orientation (-> orient-result :out str/trim)]
|
||||
(case orientation
|
||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||
{:width w :height h})) ; Normal or unknown orientation
|
||||
nil)))
|
||||
|
||||
(defmethod process :info
|
||||
[{:keys [input] :as params}]
|
||||
@@ -259,37 +247,26 @@
|
||||
:hint "uploaded svg does not provides dimensions"))
|
||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||
|
||||
(let [path-str (str path)
|
||||
identify-res (sh/sh "identify" "-format" "image/%[magick]\n" path-str)
|
||||
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
||||
mtype' (if (zero? (:exit identify-res))
|
||||
(-> identify-res
|
||||
:out
|
||||
str/trim
|
||||
(str/split #"\s+" 2)
|
||||
first
|
||||
str/lower)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image"))
|
||||
{:keys [width height]}
|
||||
(or (get-dimensions-with-orientation path-str)
|
||||
(do
|
||||
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image")))]
|
||||
(let [instance (Info. (str path))
|
||||
mtype' (.getProperty instance "Mime type")]
|
||||
(when (and (string? mtype)
|
||||
(not= (str/lower mtype) mtype'))
|
||||
(not= mtype mtype'))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-mismatch
|
||||
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
||||
"Expected: " mtype ". Got: " mtype')))
|
||||
(assoc input
|
||||
:width width
|
||||
:height height
|
||||
:size (fs/size path)
|
||||
:ts (ct/now))))))
|
||||
(let [{:keys [width height]}
|
||||
(or (get-dimensions-with-orientation (str path))
|
||||
(do
|
||||
(l/warn "Failed to read image dimensions with orientation; falling back to im4java"
|
||||
{:path path})
|
||||
{:width (.getPageWidth instance)
|
||||
:height (.getPageHeight instance)}))]
|
||||
(assoc input
|
||||
:width width
|
||||
:height height
|
||||
:size (fs/size path)
|
||||
:ts (ct/now)))))))
|
||||
|
||||
(defmethod process-error org.im4java.core.InfoException
|
||||
[error]
|
||||
@@ -305,52 +282,34 @@
|
||||
(defn download-image
|
||||
"Download an image from the provided URI and return the media input object"
|
||||
[{:keys [::http/client]} uri]
|
||||
(letfn [(parse-and-validate [{:keys [status headers] :as response}]
|
||||
(letfn [(parse-and-validate [{:keys [headers] :as response}]
|
||||
(let [size (some-> (get headers "content-length") d/parse-integer)
|
||||
mtype (get headers "content-type")]
|
||||
|
||||
(when-not (<= 200 status 299)
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-download-image
|
||||
:hint (str/ffmt "unable to download image from '%': unexpected status code %" uri status)))
|
||||
mtype (get headers "content-type")
|
||||
format (cm/mtype->format mtype)
|
||||
max-size (cf/get :media-max-file-size default-max-file-size)]
|
||||
|
||||
(when-not size
|
||||
(ex/raise :type :validation
|
||||
:code :unknown-size
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(-> {:size size :mtype mtype}
|
||||
(validate-media-type!)
|
||||
(validate-media-size!))))]
|
||||
(when (> size max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :file-too-large
|
||||
:hint (str/ffmt "the file size % is greater than the maximum %"
|
||||
size
|
||||
default-max-file-size)))
|
||||
|
||||
(let [{:keys [body] :as response}
|
||||
(try
|
||||
(http/req-with-redirects
|
||||
client
|
||||
{:method :get :uri uri}
|
||||
{:response-type :input-stream
|
||||
:max-redirects 3})
|
||||
(catch java.net.ConnectException cause
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-download-image
|
||||
:hint (str/ffmt "unable to download image from '%': connection refused or host unreachable" uri)
|
||||
:cause cause))
|
||||
(catch java.net.http.HttpConnectTimeoutException cause
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-download-image
|
||||
:hint (str/ffmt "unable to download image from '%': connection timeout" uri)
|
||||
:cause cause))
|
||||
(catch java.net.http.HttpTimeoutException cause
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-download-image
|
||||
:hint (str/ffmt "unable to download image from '%': request timeout" uri)
|
||||
:cause cause))
|
||||
(catch java.io.IOException cause
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-download-image
|
||||
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
|
||||
:cause cause)))
|
||||
(when (nil? format)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like the url points to an invalid media object"))
|
||||
|
||||
{:size size :mtype mtype :format format}))]
|
||||
|
||||
(let [{:keys [body] :as response} (http/req! client
|
||||
{:method :get :uri uri}
|
||||
{:response-type :input-stream})
|
||||
{:keys [size mtype]} (parse-and-validate response)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write* path body :size size)]
|
||||
@@ -360,11 +319,9 @@
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
;; Sanitize: strip trailing data after image EOF markers
|
||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||
{:path path
|
||||
:mtype mtype
|
||||
:size new-size}))))
|
||||
{;; :size size
|
||||
:path path
|
||||
:mtype mtype})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; FONTS
|
||||
|
||||
@@ -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.Counter
|
||||
io.prometheus.client.Counter$Child
|
||||
io.prometheus.client.exporter.common.TextFormat
|
||||
io.prometheus.client.Gauge
|
||||
io.prometheus.client.Gauge$Child
|
||||
io.prometheus.client.Histogram
|
||||
io.prometheus.client.Histogram$Child
|
||||
io.prometheus.client.hotspot.DefaultExports
|
||||
io.prometheus.client.SimpleCollector
|
||||
io.prometheus.client.Summary
|
||||
io.prometheus.client.Summary$Builder
|
||||
io.prometheus.client.Summary$Child
|
||||
io.prometheus.client.exporter.common.TextFormat
|
||||
io.prometheus.client.hotspot.DefaultExports
|
||||
java.io.StringWriter))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
[app.common.logging :as l]
|
||||
[app.db :as db]
|
||||
[app.migrations.clj.migration-0023 :as mg0023]
|
||||
[app.migrations.clj.migration-0145 :as mg0145]
|
||||
[app.util.migrations :as mg]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
@@ -457,28 +456,7 @@
|
||||
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
|
||||
|
||||
{:name "0143-http-session-v2-table"
|
||||
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}
|
||||
|
||||
{:name "0144-mod-server-error-report-table"
|
||||
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
|
||||
|
||||
{:name "0145-fix-plugins-uri-on-profile"
|
||||
:fn mg0145/migrate}
|
||||
|
||||
{:name "0145-mod-audit-log-table"
|
||||
:fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")}
|
||||
|
||||
{:name "0145-mod-audit-log-table"
|
||||
:fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")}
|
||||
|
||||
{:name "0146-mod-audit-log-table"
|
||||
:fn (mg/resource "app/migrations/sql/0146-mod-audit-log-table.sql")}
|
||||
|
||||
{:name "0146-mod-access-token-table"
|
||||
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}
|
||||
|
||||
{:name "0147-add-upload-session-table"
|
||||
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.migrations.clj.migration-0145
|
||||
"Migrate plugins references on profiles"
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
[app.db :as db]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:private replacements
|
||||
{"https://colors-to-tokens-plugin.pages.dev"
|
||||
"https://colors-to-tokens.plugins.penpot.app"
|
||||
|
||||
"https://contrast-penpot-plugin.pages.dev"
|
||||
"https://contrast.plugins.penpot.app"
|
||||
|
||||
"https://create-palette-penpot-plugin.pages.dev"
|
||||
"https://create-palette.plugins.penpot.app"
|
||||
|
||||
"https://icons-penpot-plugin.pages.dev"
|
||||
"https://icons.plugins.penpot.app"
|
||||
|
||||
"https://lorem-ipsum-penpot-plugin.pages.dev"
|
||||
"https://lorem-ipsum.plugins.penpot.app"
|
||||
|
||||
"https://rename-layers-penpot-plugin.pages.dev"
|
||||
"https://rename-layers.plugins.penpot.app"
|
||||
|
||||
"https://table-penpot-plugin.pages.dev"
|
||||
"https://table.plugins.penpot.app"})
|
||||
|
||||
(defn- fix-url
|
||||
[url]
|
||||
(reduce-kv (fn [url prefix replacement]
|
||||
(if (str/starts-with? url prefix)
|
||||
(reduced (str replacement (subs url (count prefix))))
|
||||
url))
|
||||
url
|
||||
replacements))
|
||||
|
||||
|
||||
(defn- fix-manifest
|
||||
[manifest]
|
||||
(-> manifest
|
||||
(d/update-when :url fix-url)
|
||||
(d/update-when :host fix-url)))
|
||||
|
||||
(defn- fix-plugins-data
|
||||
[props]
|
||||
(d/update-in-when props [:plugins :data]
|
||||
(fn [data]
|
||||
(reduce-kv (fn [data id manifest]
|
||||
(let [manifest' (fix-manifest manifest)]
|
||||
(if (= manifest manifest')
|
||||
data
|
||||
(assoc data id manifest'))))
|
||||
data
|
||||
data))))
|
||||
|
||||
(def ^:private sql:get-profiles
|
||||
"SELECT id, props FROM profile
|
||||
WHERE props ?? '~:plugins'
|
||||
ORDER BY created_at
|
||||
FOR UPDATE")
|
||||
|
||||
(defn migrate
|
||||
[conn]
|
||||
(->> (db/plan conn [sql:get-profiles])
|
||||
(run! (fn [{:keys [id props]}]
|
||||
(when-let [props (some-> props db/decode-transit-pgobject)]
|
||||
(let [props' (fix-plugins-data props)]
|
||||
(when (not= props props')
|
||||
(l/inf :hint "fixing plugins data on profile props" :profile-id (str id))
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson props')}
|
||||
{:id id}
|
||||
{::db/return-keys false}))))))))
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
ALTER TABLE server_error_report DROP CONSTRAINT server_error_report_pkey;
|
||||
|
||||
DELETE FROM server_error_report a
|
||||
USING server_error_report b
|
||||
WHERE a.id = b.id
|
||||
AND a.ctid < b.ctid;
|
||||
|
||||
ALTER TABLE server_error_report ADD PRIMARY KEY (id);
|
||||
|
||||
CREATE INDEX server_error_report__version__idx
|
||||
ON server_error_report ( version );
|
||||
@@ -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,130 +0,0 @@
|
||||
(ns app.nitrate
|
||||
"Module that make calls to the external nitrate aplication"
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.json :as json]
|
||||
[clojure.core :as c]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- request-builder
|
||||
[cfg method uri shared-key profile-id]
|
||||
(fn []
|
||||
(http/req cfg {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" shared-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1})))
|
||||
|
||||
|
||||
(defn- with-retries
|
||||
[handler max-retries]
|
||||
(fn []
|
||||
(loop [attempt 1]
|
||||
(let [result (try
|
||||
(handler)
|
||||
(catch Exception e
|
||||
(if (< attempt max-retries)
|
||||
::retry
|
||||
(do
|
||||
;; TODO Error handling
|
||||
(l/error :hint "request fail after multiple retries" :cause e)
|
||||
nil))))]
|
||||
(if (= result ::retry)
|
||||
(recur (inc attempt))
|
||||
result)))))
|
||||
|
||||
|
||||
(defn- with-validate [handler uri schema]
|
||||
(fn []
|
||||
(let [coercer-http (sm/coercer schema
|
||||
:type :validation
|
||||
:hint (str "invalid data received calling " uri))]
|
||||
(try
|
||||
(coercer-http (-> (handler) :body json/decode))
|
||||
(catch Exception e
|
||||
;; TODO Error handling
|
||||
(l/error :hint "error validating json response" :cause e)
|
||||
nil)))))
|
||||
|
||||
(defn- request-to-nitrate
|
||||
[cfg method uri schema {:keys [::rpc/profile-id] :as params}]
|
||||
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
|
||||
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
|
||||
(with-retries 3)
|
||||
(with-validate uri schema))]
|
||||
(full-http-call)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn call
|
||||
[cfg method params]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [client (get cfg ::client)
|
||||
method (get client method)]
|
||||
(method params))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:organization
|
||||
[:map
|
||||
[:id ::sm/text]
|
||||
[:name ::sm/text]])
|
||||
|
||||
(def ^:private schema:user
|
||||
[:map
|
||||
[:valid ::sm/boolean]])
|
||||
|
||||
(defn- get-team-org
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params)))
|
||||
|
||||
(defn- is-valid-user
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INITIALIZATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defmethod ig/init-key ::client
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
{:get-team-org (partial get-team-org cfg)
|
||||
:is-valid-user (partial is-valid-user cfg)}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; UTILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
|
||||
(defn add-nitrate-licence-to-profile
|
||||
[cfg profile]
|
||||
(try
|
||||
(let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})]
|
||||
(assoc profile :nitrate-licence (:valid nitrate-licence)))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "failed to get nitrate licence"
|
||||
:profile-id (:id profile)
|
||||
:cause cause)
|
||||
profile)))
|
||||
|
||||
(defn add-org-to-team
|
||||
[cfg team params]
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
org (call cfg :get-team-org params)]
|
||||
(assoc team :organization-id (:id org) :organization-name (:name org))))
|
||||
+11
-11
@@ -24,28 +24,28 @@
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
clojure.lang.MapEntry
|
||||
io.lettuce.core.api.StatefulRedisConnection
|
||||
io.lettuce.core.api.sync.RedisCommands
|
||||
io.lettuce.core.api.sync.RedisScriptingCommands
|
||||
io.lettuce.core.codec.RedisCodec
|
||||
io.lettuce.core.codec.StringCodec
|
||||
io.lettuce.core.KeyValue
|
||||
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
|
||||
io.lettuce.core.pubsub.RedisPubSubListener
|
||||
io.lettuce.core.pubsub.StatefulRedisPubSubConnection
|
||||
io.lettuce.core.RedisClient
|
||||
io.lettuce.core.RedisCommandInterruptedException
|
||||
io.lettuce.core.RedisCommandTimeoutException
|
||||
io.lettuce.core.RedisException
|
||||
io.lettuce.core.RedisURI
|
||||
io.lettuce.core.resource.ClientResources
|
||||
io.lettuce.core.resource.DefaultClientResources
|
||||
io.lettuce.core.ScriptOutputType
|
||||
io.lettuce.core.SetArgs
|
||||
io.lettuce.core.api.StatefulRedisConnection
|
||||
io.lettuce.core.api.sync.RedisCommands
|
||||
io.lettuce.core.api.sync.RedisScriptingCommands
|
||||
io.lettuce.core.codec.RedisCodec
|
||||
io.lettuce.core.codec.StringCodec
|
||||
io.lettuce.core.pubsub.RedisPubSubListener
|
||||
io.lettuce.core.pubsub.StatefulRedisPubSubConnection
|
||||
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
|
||||
io.lettuce.core.resource.ClientResources
|
||||
io.lettuce.core.resource.DefaultClientResources
|
||||
io.netty.channel.nio.NioEventLoopGroup
|
||||
io.netty.util.concurrent.EventExecutorGroup
|
||||
io.netty.util.HashedWheelTimer
|
||||
io.netty.util.Timer
|
||||
io.netty.util.concurrent.EventExecutorGroup
|
||||
java.lang.AutoCloseable
|
||||
java.time.Duration))
|
||||
|
||||
|
||||
+24
-32
@@ -14,7 +14,6 @@
|
||||
[app.common.spec :as us]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
@@ -90,23 +89,16 @@
|
||||
[methods]
|
||||
(let [methods (update-vals methods peek)]
|
||||
(fn [{:keys [params path-params method] :as request}]
|
||||
(let [handler-name (:method-name path-params)
|
||||
(let [handler-name (:type path-params)
|
||||
etag (yreq/get-header request "if-none-match")
|
||||
session-id (yreq/get-header request "x-session-id")
|
||||
|
||||
key-id (get request ::http/auth-key-id)
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request)
|
||||
(if key-id uuid/zero nil))
|
||||
|
||||
(::actoken/profile-id request))
|
||||
ip-addr (inet/parse-request request)
|
||||
|
||||
data (-> params
|
||||
(assoc ::handler-name handler-name)
|
||||
(assoc ::ip-addr ip-addr)
|
||||
(assoc ::request-at (ct/now))
|
||||
(assoc ::request-id (uuid/next))
|
||||
(assoc ::session-id (some-> session-id uuid/parse*))
|
||||
(assoc ::cond/key etag)
|
||||
(cond-> (uuid? profile-id)
|
||||
(assoc ::profile-id profile-id)))
|
||||
@@ -162,13 +154,12 @@
|
||||
(defn- wrap-audit
|
||||
[_ f mdata]
|
||||
(if (or (contains? cf/flags :webhooks)
|
||||
(contains? cf/flags :audit-log)
|
||||
(contains? cf/flags :telemetry))
|
||||
(contains? cf/flags :audit-log))
|
||||
(if-not (::audit/skip mdata)
|
||||
(fn [cfg params]
|
||||
(let [result (f cfg params)]
|
||||
(->> (audit/prepare-rpc-event cfg mdata params result)
|
||||
(audit/submit cfg))
|
||||
(->> (audit/prepare-event cfg mdata params result)
|
||||
(audit/submit! cfg))
|
||||
result))
|
||||
f)
|
||||
f))
|
||||
@@ -231,8 +222,8 @@
|
||||
(wrap-authentication cfg $ mdata)))
|
||||
|
||||
(defn- process-method
|
||||
[cfg wrap-fn [f mdata]]
|
||||
(l/trc :hint "add method" :module (::module cfg) :type (::type cfg) :name (::sv/name mdata))
|
||||
[cfg module wrap-fn [f mdata]]
|
||||
(l/trc :hint "add method" :module module :name (::sv/name mdata))
|
||||
(let [f (wrap-fn cfg f mdata)
|
||||
k (keyword (::sv/name mdata))]
|
||||
[k [mdata (partial f cfg)]]))
|
||||
@@ -243,7 +234,7 @@
|
||||
|
||||
(defn- resolve-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::module "main" ::type "command" ::metrics-id :rpc-main-timing)]
|
||||
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
|
||||
(->> (sv/scan-ns
|
||||
'app.rpc.commands.access-token
|
||||
'app.rpc.commands.audit
|
||||
@@ -270,7 +261,7 @@
|
||||
'app.rpc.commands.verify-token
|
||||
'app.rpc.commands.viewer
|
||||
'app.rpc.commands.webhooks)
|
||||
(map (partial process-method cfg wrap))
|
||||
(map (partial process-method cfg "rpc" wrap))
|
||||
(into {}))))
|
||||
|
||||
(def ^:private schema:methods-params
|
||||
@@ -302,13 +293,11 @@
|
||||
|
||||
(defn- resolve-management-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
|
||||
mods (cond->> (list 'app.rpc.management.exporter)
|
||||
(contains? cf/flags :nitrate)
|
||||
(cons 'app.rpc.management.nitrate))]
|
||||
|
||||
(->> (apply sv/scan-ns mods)
|
||||
(map (partial process-method cfg wrap-management))
|
||||
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
|
||||
(->> (sv/scan-ns
|
||||
'app.rpc.management.subscription
|
||||
'app.rpc.management.exporter)
|
||||
(map (partial process-method cfg "management" wrap-management))
|
||||
(into {}))))
|
||||
|
||||
(def ^:private schema:management-methods-params
|
||||
@@ -351,20 +340,23 @@
|
||||
|
||||
(defmethod ig/assert-key ::routes
|
||||
[_ params]
|
||||
(assert (map? (::setup/shared-keys params)))
|
||||
(assert (db/pool? (::db/pool params)) "expect valid database pool")
|
||||
(assert (some? (::setup/props params)))
|
||||
(assert (session/manager? (::session/manager params)) "expect valid session manager")
|
||||
(assert (valid-methods? (::methods params)) "expect valid methods map")
|
||||
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
|
||||
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}]
|
||||
|
||||
(let [public-uri (cf/get :public-uri)
|
||||
management-key (or (cf/get :management-api-key)
|
||||
(get props :management-key))]
|
||||
|
||||
(let [public-uri (cf/get :public-uri)]
|
||||
["/api"
|
||||
["/management"
|
||||
["/methods/:method-name"
|
||||
{:middleware [[mw/shared-key-auth shared-keys]
|
||||
["/methods/:type"
|
||||
{:middleware [[mw/shared-key-auth management-key]
|
||||
[session/authz cfg]]
|
||||
:handler (make-rpc-handler management-methods)}]
|
||||
|
||||
@@ -374,7 +366,7 @@
|
||||
:description "MANAGEMENT API")]
|
||||
|
||||
["/main"
|
||||
["/methods/:method-name"
|
||||
["/methods/:type"
|
||||
{:middleware [[mw/cors]
|
||||
[sec/client-header-check]
|
||||
[session/authz cfg]
|
||||
@@ -392,7 +384,7 @@
|
||||
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
|
||||
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
|
||||
|
||||
["/rpc/command/:method-name"
|
||||
["/rpc/command/:type"
|
||||
{:middleware [[mw/cors]
|
||||
[sec/client-header-check]
|
||||
[session/authz cfg]
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
(dissoc row :perms))
|
||||
|
||||
(defn create-access-token
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration]
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
created-at (ct/now)
|
||||
@@ -36,7 +36,6 @@
|
||||
{:id token-id
|
||||
:name name
|
||||
:token token
|
||||
:type type
|
||||
:profile-id profile-id
|
||||
:created-at created-at
|
||||
:updated-at created-at
|
||||
@@ -51,18 +50,17 @@
|
||||
(def ^:private schema:create-access-token
|
||||
[:map {:title "create-access-token"}
|
||||
[:name [:string {:max 250 :min 1}]]
|
||||
[:expiration {:optional true} ::ct/duration]
|
||||
[:type {:optional true} :string]])
|
||||
[:expiration {:optional true} ::ct/duration]])
|
||||
|
||||
(sv/defmethod ::create-access-token
|
||||
{::doc/added "1.18"
|
||||
::sm/params schema:create-access-token}
|
||||
[cfg {:keys [::rpc/profile-id name expiration type]}]
|
||||
[cfg {:keys [::rpc/profile-id name expiration]}]
|
||||
|
||||
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration type))
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||
|
||||
(def ^:private schema:delete-access-token
|
||||
[:map {:title "delete-access-token"}
|
||||
@@ -85,22 +83,5 @@
|
||||
(->> (db/query pool :access-token
|
||||
{:profile-id profile-id}
|
||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
|
||||
:columns [:id :name :perms :created-at :updated-at :expires-at]})
|
||||
(mapv decode-row)))
|
||||
|
||||
(def ^:private schema:get-current-mcp-token
|
||||
[:map {:title "get-current-mcp-token"}])
|
||||
|
||||
(sv/defmethod ::get-current-mcp-token
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:get-current-mcp-token}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
|
||||
(->> (db/query pool :access-token
|
||||
{:profile-id profile-id
|
||||
:type "mcp"}
|
||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||
:columns [:token :expires-at]})
|
||||
(remove #(and (some? (:expires-at %))
|
||||
(ct/is-after? request-at (:expires-at %))))
|
||||
(map decode-row)
|
||||
(first)))
|
||||
@@ -15,16 +15,13 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.loggers.database :as loggers.db]
|
||||
[app.loggers.mattermost :as loggers.mm]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.util.inet :as inet]
|
||||
[app.util.services :as sv]
|
||||
[clojure.set :as set]))
|
||||
[app.util.services :as sv]))
|
||||
|
||||
(def ^:private event-columns
|
||||
[:id
|
||||
@@ -39,11 +36,11 @@
|
||||
:context])
|
||||
|
||||
(defn- event->row [event]
|
||||
[(:id event)
|
||||
[(uuid/next)
|
||||
(:name event)
|
||||
(:source event)
|
||||
(:type event)
|
||||
(:tracked-at event)
|
||||
(:timestamp event)
|
||||
(:created-at event)
|
||||
(:profile-id event)
|
||||
(db/inet (:ip-addr event))
|
||||
@@ -51,92 +48,40 @@
|
||||
(db/tjson (d/without-nils (:context event)))])
|
||||
|
||||
(defn- adjust-timestamp
|
||||
[{:keys [tracked-at created-at] :as event}]
|
||||
(let [margin (inst-ms (ct/diff tracked-at created-at))]
|
||||
[{:keys [timestamp created-at] :as event}]
|
||||
(let [margin (inst-ms (ct/diff timestamp created-at))]
|
||||
(if (or (neg? margin)
|
||||
(> margin 3600000))
|
||||
;; If event is in future or lags more than 1 hour, we reasign
|
||||
;; tracked-at to the server creation date
|
||||
;; timestamp to the server creation date
|
||||
(-> event
|
||||
(assoc :tracked-at created-at)
|
||||
(update :context assoc :original-tracked-at tracked-at))
|
||||
(assoc :timestamp created-at)
|
||||
(update :context assoc :original-timestamp timestamp))
|
||||
event)))
|
||||
|
||||
(defn- exception-event?
|
||||
[{:keys [type name] :as ev}]
|
||||
(and (= "action" type)
|
||||
(or (= "unhandled-exception" name)
|
||||
(= "exception-page" name))))
|
||||
|
||||
(def ^:private xf:map-event-row
|
||||
(comp
|
||||
(map adjust-timestamp)
|
||||
(map event->row)))
|
||||
|
||||
(defn- prepare-events
|
||||
[{:keys [::rpc/request-at ::rpc/profile-id events] :as params}]
|
||||
(defn- handle-events
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}]
|
||||
(let [request (-> params meta ::http/request)
|
||||
ip-addr (inet/parse-request request)
|
||||
tnow (ct/now)
|
||||
xform (comp
|
||||
(map (fn [event]
|
||||
{:id (uuid/next)
|
||||
:type (:type event)
|
||||
:name (:name event)
|
||||
:props (:props event)
|
||||
:context (:context event)
|
||||
:profile-id profile-id
|
||||
:ip-addr ip-addr
|
||||
:source "frontend"
|
||||
:tracked-at (:timestamp event)
|
||||
:created-at request-at}))
|
||||
(map (fn [item]
|
||||
(with-meta item {::audit/event true}))))]
|
||||
(-> event
|
||||
(assoc :created-at tnow)
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :ip-addr ip-addr)
|
||||
(assoc :source "frontend"))))
|
||||
(filter :profile-id)
|
||||
(map adjust-timestamp)
|
||||
(map event->row))
|
||||
events (sequence xform events)]
|
||||
(when (seq events)
|
||||
(db/insert-many! pool :audit-log event-columns events))))
|
||||
|
||||
(sequence xform events)))
|
||||
(def valid-event-types
|
||||
#{"action" "identify"})
|
||||
|
||||
(def ^:private xf:map-telemetry-event-row
|
||||
(comp
|
||||
(map adjust-timestamp)
|
||||
(map (fn [event]
|
||||
(-> event
|
||||
(assoc :id (uuid/next))
|
||||
(update :created-at ct/truncate :days)
|
||||
(update :tracked-at ct/truncate :days)
|
||||
(audit/filter-telemetry-props)
|
||||
(audit/filter-telemetry-context)
|
||||
(assoc :ip-addr "0.0.0.0")
|
||||
(assoc :source "telemetry:frontend"))))
|
||||
(map event->row)))
|
||||
|
||||
(defn- handle-events
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(let [events (prepare-events params)]
|
||||
|
||||
;; Look for error reports and save them on internal reports table
|
||||
(when-let [events (->> events
|
||||
(sequence (filter exception-event?))
|
||||
(not-empty))]
|
||||
(run! (partial loggers.db/emit cfg) events)
|
||||
(run! (partial loggers.mm/emit cfg) events))
|
||||
|
||||
(when (contains? cf/flags :audit-log)
|
||||
;; Process and save full audit events when audit-log flag is active
|
||||
(when-let [rows (-> (sequence xf:map-event-row events)
|
||||
(not-empty))]
|
||||
(db/insert-many! pool :audit-log event-columns rows)))
|
||||
|
||||
(when (contains? cf/flags :telemetry)
|
||||
;; Store anonymized frontend events so the telemetry task can ship them
|
||||
;; in batches. Runs independently from the audit-log insert above so
|
||||
;; both modes can be active simultaneously.
|
||||
(when-let [rows (-> (sequence xf:map-telemetry-event-row events)
|
||||
(not-empty))]
|
||||
(db/insert-many! pool :audit-log event-columns rows)))))
|
||||
|
||||
(def ^:private valid-event-types
|
||||
#{"action" "identify" "trigger"})
|
||||
|
||||
(def ^:private schema:frontend-event
|
||||
(def schema:event
|
||||
[:map {:title "Event"}
|
||||
[:name
|
||||
[:and {:gen/elements ["update-file", "get-profile"]}
|
||||
@@ -148,13 +93,12 @@
|
||||
[::sm/one-of {:format "string"} valid-event-types]]]
|
||||
[:props
|
||||
[:map-of :keyword ::sm/any]]
|
||||
[:timestamp ::ct/inst]
|
||||
[:context {:optional true}
|
||||
[:map-of :keyword ::sm/any]]])
|
||||
|
||||
(def ^:private schema:push-audit-events
|
||||
(def schema:push-audit-events
|
||||
[:map {:title "push-audit-events"}
|
||||
[:events [:vector schema:frontend-event]]])
|
||||
[:events [:vector schema:event]]])
|
||||
|
||||
(sv/defmethod ::push-audit-events
|
||||
{::climit/id :submit-audit-events/by-profile
|
||||
@@ -164,26 +108,17 @@
|
||||
::doc/skip true
|
||||
::doc/added "1.17"}
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(let [telemetry? (contains? cf/flags :telemetry)
|
||||
audit-log? (contains? cf/flags :audit-log)
|
||||
enabled? (and (not (db/read-only? pool))
|
||||
(or audit-log? telemetry?))]
|
||||
(when enabled?
|
||||
(if (or (db/read-only? pool)
|
||||
(not (contains? cf/flags :audit-log)))
|
||||
(do
|
||||
(l/warn :hint "audit: http handler disabled or db is read-only")
|
||||
(rph/wrap nil))
|
||||
|
||||
(do
|
||||
(try
|
||||
(handle-events cfg params)
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error on persisting audit events from frontend"
|
||||
:cause cause))))
|
||||
:cause cause)))
|
||||
|
||||
(rph/wrap nil)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; 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}))
|
||||
(rph/wrap nil))))
|
||||
@@ -253,49 +253,27 @@
|
||||
:hint "email has complaint reports")))
|
||||
|
||||
(defn prepare-register
|
||||
[{:keys [::db/pool] :as cfg} {:keys [fullname email] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [fullname email accept-newsletter-updates] :as params}]
|
||||
|
||||
(validate-register-attempt! cfg params)
|
||||
|
||||
(let [email (profile/clean-email email)
|
||||
profile (profile/get-profile-by-email pool email)]
|
||||
profile (profile/get-profile-by-email pool email)
|
||||
params {:email email
|
||||
:fullname fullname
|
||||
:password (:password params)
|
||||
:invitation-token (:invitation-token params)
|
||||
:backend "penpot"
|
||||
:iss :prepared-register
|
||||
:profile-id (:id profile)
|
||||
:exp (ct/in-future {:days 7})
|
||||
:props {:newsletter-updates (or accept-newsletter-updates false)}}
|
||||
|
||||
;; 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"))
|
||||
params (d/without-nils params)
|
||||
token (tokens/generate cfg params)]
|
||||
|
||||
(let [props (-> (audit/extract-utm-params params)
|
||||
(cond-> (:accept-newsletter-updates params)
|
||||
(assoc :newsletter-updates true)))
|
||||
;; SECURITY: do NOT embed `:profile-id` of an existing
|
||||
;; profile into the prepared-register JWE. Doing so would
|
||||
;; let an anonymous caller, in possession of a valid
|
||||
;; team-invitation JWE, ask `register-profile` to load that
|
||||
;; profile by id and mint a session for it without password
|
||||
;; verification. `register-profile` independently re-detects
|
||||
;; duplicates by email and handles them in the
|
||||
;; "repeated-registry" branch.
|
||||
params {:email email
|
||||
:fullname fullname
|
||||
:password (:password params)
|
||||
:invitation-token (:invitation-token params)
|
||||
:backend "penpot"
|
||||
:iss :prepared-register
|
||||
:exp (ct/in-future {:days 7})
|
||||
:props props}
|
||||
params (d/without-nils params)
|
||||
token (tokens/generate cfg params)]
|
||||
|
||||
(-> {:token token}
|
||||
(with-meta {::audit/profile-id uuid/zero})))))
|
||||
(with-meta {:token token}
|
||||
{::audit/profile-id uuid/zero})))
|
||||
|
||||
(def schema:prepare-register-profile
|
||||
[:map {:title "prepare-register-profile"}
|
||||
@@ -303,7 +281,6 @@
|
||||
[:email ::sm/email]
|
||||
[:password schema:password]
|
||||
[:create-welcome-file {:optional true} :boolean]
|
||||
[:accept-newsletter-updates {:optional true} :boolean]
|
||||
[:invitation-token {:optional true} schema:token]])
|
||||
|
||||
(sv/defmethod ::prepare-register-profile
|
||||
@@ -330,8 +307,7 @@
|
||||
:content-type (:mtype input)})]
|
||||
(:id sobject))
|
||||
(catch Throwable cause
|
||||
(l/wrn :hint "unable to import profile picture"
|
||||
:uri uri
|
||||
(l/err :hint "unable to import profile picture"
|
||||
:cause cause)
|
||||
nil)))
|
||||
|
||||
@@ -340,7 +316,8 @@
|
||||
attrs (all the other attrs are filled with default values)."
|
||||
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
|
||||
(let [id (or (:id params) (uuid/next))
|
||||
props (-> (:props params)
|
||||
props (-> (audit/extract-utm-params params)
|
||||
(merge (:props params))
|
||||
(merge {:viewed-tutorial? false
|
||||
:viewed-walkthrough? false
|
||||
:nudge {:big 10 :small 1}
|
||||
@@ -391,6 +368,7 @@
|
||||
:cause cause)
|
||||
(throw cause))))))
|
||||
|
||||
|
||||
(defn create-profile-rels
|
||||
[conn {:keys [id] :as profile}]
|
||||
(let [features (cfeat/get-enabled-features cf/flags)
|
||||
@@ -407,50 +385,48 @@
|
||||
(profile/decode-row))))
|
||||
|
||||
(defn send-email-verification!
|
||||
([cfg profile] (send-email-verification! cfg profile nil))
|
||||
([{:keys [::db/conn] :as cfg} profile invitation-token]
|
||||
(let [vclaims (cond-> {:iss :verify-email
|
||||
:exp (ct/in-future "72h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)}
|
||||
;; If the user registered through a team-invitation flow but
|
||||
;; their profile is not yet active, we carry the invitation
|
||||
;; token inside the verify-email JWE so the team-invitation
|
||||
;; flow can resume after the user clicks the email link.
|
||||
(some? invitation-token)
|
||||
(assoc :invitation-token invitation-token))
|
||||
vtoken (tokens/generate cfg vclaims)
|
||||
;; NOTE: this token is mainly used for possible complains
|
||||
;; identification on the sns webhook
|
||||
ptoken (tokens/generate cfg
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (ct/in-future {:days 30})})]
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/register
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to (:email profile)
|
||||
:name (:fullname profile)
|
||||
:token vtoken
|
||||
:extra-data ptoken}))))
|
||||
[{:keys [::db/conn] :as cfg} profile]
|
||||
(let [vtoken (tokens/generate cfg
|
||||
{:iss :verify-email
|
||||
:exp (ct/in-future "72h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)})
|
||||
;; NOTE: this token is mainly used for possible complains
|
||||
;; identification on the sns webhook
|
||||
ptoken (tokens/generate cfg
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (ct/in-future {:days 30})})]
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/register
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to (:email profile)
|
||||
:name (:fullname profile)
|
||||
:token vtoken
|
||||
:extra-data ptoken})))
|
||||
|
||||
(defn register-profile
|
||||
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
|
||||
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
|
||||
params (cond-> claims
|
||||
(:accept-newsletter-updates params)
|
||||
(update :props assoc :newsletter-updates true))
|
||||
params (into claims params)
|
||||
|
||||
profile (or (profile/get-profile-by-email conn (:email claims))
|
||||
(let [is-active (or (boolean (:is-active claims))
|
||||
(boolean (:email-verified claims))
|
||||
(not (contains? cf/flags :email-verification)))
|
||||
params (-> params
|
||||
(assoc :is-active is-active)
|
||||
(update :password auth/derive-password))
|
||||
profile (->> (create-profile cfg params)
|
||||
(create-profile-rels cfg))]
|
||||
(vary-meta profile assoc :created true)))
|
||||
profile (if-let [profile-id (:profile-id claims)]
|
||||
(profile/get-profile conn profile-id)
|
||||
;; NOTE: we first try to match existing profile
|
||||
;; by email, that in normal circumstances will
|
||||
;; not return anything, but when a user tries to
|
||||
;; reuse the same token multiple times, we need
|
||||
;; to detect if the profile is already registered
|
||||
(or (profile/get-profile-by-email conn (:email claims))
|
||||
(let [is-active (or (boolean (:is-active claims))
|
||||
(boolean (:email-verified claims))
|
||||
(not (contains? cf/flags :email-verification)))
|
||||
params (-> params
|
||||
(assoc :is-active is-active)
|
||||
(update :password auth/derive-password))
|
||||
profile (->> (create-profile cfg params)
|
||||
(create-profile-rels conn))]
|
||||
(vary-meta profile assoc :created true))))
|
||||
|
||||
created? (-> profile meta :created true?)
|
||||
|
||||
@@ -466,7 +442,6 @@
|
||||
(when (:create-welcome-file params)
|
||||
(let [cfg (dissoc cfg ::db/conn)]
|
||||
(wrk/submit! executor (create-welcome-file cfg profile)))))]
|
||||
|
||||
(cond
|
||||
;; When profile is blocked, we just ignore it and return plain data
|
||||
(:is-blocked profile)
|
||||
@@ -474,74 +449,51 @@
|
||||
(l/wrn :hint "register attempt for already blocked profile"
|
||||
:profile-id (str (:id profile))
|
||||
:profile-email (:email profile))
|
||||
(rph/with-meta {:id (:id profile)
|
||||
:email (:email profile)}
|
||||
(rph/with-meta {:email (:email profile)}
|
||||
{::audit/replace-props props
|
||||
::audit/context {:action "ignore-because-blocked"}
|
||||
::audit/profile-id (:id profile)
|
||||
::audit/name "register-profile-retry"}))
|
||||
|
||||
;; A profile was just created in this call. Invitation handling is a
|
||||
;; sub-case of "newly created profile": we never honor invitations for
|
||||
;; pre-existing profiles via this anonymous RPC. The split below mirrors
|
||||
;; the non-invitation branches but threads the invitation through the
|
||||
;; appropriate path:
|
||||
;;
|
||||
;; - active + matching invitation → mint session and
|
||||
;; return :invitation-token. The frontend redirects to
|
||||
;; :auth-verify-token, which immediately accepts the
|
||||
;; invitation.
|
||||
;; - active + no/mismatched invitation → mint session
|
||||
;; ("login" action). New profile, no further action.
|
||||
;; - not-active + matching invitation → send the
|
||||
;; verify-email mail with the invitation token EMBEDDED
|
||||
;; into the verify-email JWE. No session yet. When the
|
||||
;; user clicks the link, verify-token activates the
|
||||
;; profile, mints a session, and propagates the
|
||||
;; invitation token to the frontend so it can complete
|
||||
;; the team-invitation flow.
|
||||
;; - not-active + no/mismatched invitation → standard
|
||||
;; "check your email" verification flow.
|
||||
created?
|
||||
(let [accept-invitation? (and (some? invitation)
|
||||
(= (:email profile)
|
||||
(:member-email invitation)))]
|
||||
(cond
|
||||
(and (:is-active profile) accept-invitation?)
|
||||
(let [invitation (assoc invitation :member-id (:id profile))
|
||||
token (tokens/generate cfg invitation)]
|
||||
(-> {:id (:id profile)
|
||||
:email (:email profile)
|
||||
:invitation-token token}
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta {::audit/replace-props props
|
||||
::audit/context {:action "accept-invitation"}
|
||||
::audit/profile-id (:id profile)})))
|
||||
;; If invitation token comes in params, this is because the user
|
||||
;; comes from team-invitation process; in this case, regenerate
|
||||
;; token and send back to the user a new invitation token (and
|
||||
;; mark current session as logged). This happens only if the
|
||||
;; invitation email matches with the register email.
|
||||
(and (some? invitation)
|
||||
(= (:email profile)
|
||||
(:member-email invitation)))
|
||||
(let [invitation (assoc invitation :member-id (:id profile))
|
||||
token (tokens/generate cfg invitation)]
|
||||
(-> {:invitation-token token}
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-meta {::audit/replace-props props
|
||||
::audit/context {:action "accept-invitation"}
|
||||
::audit/profile-id (:id profile)})))
|
||||
|
||||
(:is-active profile)
|
||||
(-> (profile/strip-private-attrs profile)
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
;; 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)
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta
|
||||
{::audit/replace-props props
|
||||
::audit/context {:action "login"}
|
||||
::audit/profile-id (:id profile)}))
|
||||
|
||||
(do
|
||||
(when-not (eml/has-reports? conn (:email profile))
|
||||
(send-email-verification! cfg profile))
|
||||
|
||||
(-> {:email (:email profile)}
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta
|
||||
{::audit/replace-props props
|
||||
::audit/context {:action "login"}
|
||||
::audit/profile-id (:id profile)}))
|
||||
|
||||
:else
|
||||
(do
|
||||
(when-not (eml/has-reports? conn (:email profile))
|
||||
(send-email-verification! cfg profile
|
||||
(when accept-invitation?
|
||||
(:invitation-token params))))
|
||||
|
||||
(-> {:id (:id profile)
|
||||
:email (:email profile)}
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta
|
||||
{::audit/replace-props props
|
||||
::audit/context {:action "email-verification"}
|
||||
::audit/profile-id (:id profile)})))))
|
||||
::audit/context {:action "email-verification"}
|
||||
::audit/profile-id (:id profile)}))))
|
||||
|
||||
:else
|
||||
(let [elapsed? (elapsed-verify-threshold? profile)
|
||||
@@ -563,8 +515,7 @@
|
||||
{:id (:id profile)})
|
||||
(send-email-verification! cfg profile))
|
||||
|
||||
(rph/with-meta {:email (:email profile)
|
||||
:id (:id profile)}
|
||||
(rph/with-meta {:email (:email profile)}
|
||||
{::audit/replace-props (audit/profile->props profile)
|
||||
::audit/context {:action action}
|
||||
::audit/profile-id (:id profile)
|
||||
@@ -572,8 +523,7 @@
|
||||
|
||||
(def schema:register-profile
|
||||
[:map {:title "register-profile"}
|
||||
[:token schema:token]
|
||||
[:accept-newsletter-updates {:optional true} :boolean]])
|
||||
[:token schema:token]])
|
||||
|
||||
(sv/defmethod ::register-profile
|
||||
{::rpc/auth false
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.media :as media-cmd]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
@@ -81,33 +80,20 @@
|
||||
;; --- Command: import-binfile
|
||||
|
||||
(defn- import-binfile
|
||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}]
|
||||
(let [team
|
||||
(teams/get-team pool
|
||||
:profile-id profile-id
|
||||
:project-id project-id)
|
||||
[{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file]}]
|
||||
(let [team (teams/get-team pool
|
||||
:profile-id profile-id
|
||||
:project-id project-id)
|
||||
cfg (-> cfg
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/name name)
|
||||
(assoc ::bfc/input (:path file)))
|
||||
|
||||
cfg
|
||||
(-> cfg
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/name name))
|
||||
|
||||
input-path (:path file)
|
||||
owned? (some? upload-id)
|
||||
|
||||
cfg
|
||||
(assoc cfg ::bfc/input input-path)
|
||||
|
||||
result
|
||||
(try
|
||||
(case (int version)
|
||||
1 (bf.v1/import-files! cfg)
|
||||
3 (bf.v3/import-files! cfg))
|
||||
(finally
|
||||
(when owned?
|
||||
(fs/delete input-path))))]
|
||||
result (case (int version)
|
||||
1 (bf.v1/import-files! cfg)
|
||||
3 (bf.v3/import-files! cfg))]
|
||||
|
||||
(db/update! pool :project
|
||||
{:modified-at (ct/now)}
|
||||
@@ -117,18 +103,13 @@
|
||||
result))
|
||||
|
||||
(def ^:private schema:import-binfile
|
||||
[:and
|
||||
[:map {:title "import-binfile"}
|
||||
[:name [:or [:string {:max 250}]
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:project-id ::sm/uuid]
|
||||
[:file-id {:optional true} ::sm/uuid]
|
||||
[:version {:optional true} ::sm/int]
|
||||
[:file {:optional true} media/schema:upload]
|
||||
[:upload-id {:optional true} ::sm/uuid]]
|
||||
[:fn {:error/message "one of :file or :upload-id is required"}
|
||||
(fn [{:keys [file upload-id]}]
|
||||
(or (some? file) (some? upload-id)))]])
|
||||
[:map {:title "import-binfile"}
|
||||
[:name [:or [:string {:max 250}]
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:project-id ::sm/uuid]
|
||||
[:file-id {:optional true} ::sm/uuid]
|
||||
[:version {:optional true} ::sm/int]
|
||||
[:file media/schema:upload]])
|
||||
|
||||
(sv/defmethod ::import-binfile
|
||||
"Import a penpot file in a binary format. If `file-id` is provided,
|
||||
@@ -136,40 +117,28 @@
|
||||
|
||||
The in-place imports are only supported for binfile-v3 and when a
|
||||
.penpot file only contains one penpot file.
|
||||
|
||||
The file content may be provided either as a multipart `file` upload
|
||||
or as an `upload-id` referencing a completed chunked-upload session,
|
||||
which allows importing files larger than the multipart size limit.
|
||||
"
|
||||
{::doc/added "1.15"
|
||||
::doc/changes ["1.20" "Add file-id param for in-place import"
|
||||
"1.20" "Set default version to 3"
|
||||
"2.15" "Add upload-id param for chunked upload support"]
|
||||
"1.20" "Set default version to 3"]
|
||||
|
||||
::webhooks/event? true
|
||||
::sse/stream? true
|
||||
::sm/params schema:import-binfile}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id file] :as params}]
|
||||
(projects/check-edition-permissions! pool profile-id project-id)
|
||||
(let [version (or version 3)
|
||||
params (-> params
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :version version))
|
||||
(let [version (or version 3)
|
||||
params (-> params
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :version version))
|
||||
|
||||
cfg (cond-> cfg
|
||||
(uuid? file-id)
|
||||
(assoc ::bfc/file-id file-id))
|
||||
cfg (cond-> cfg
|
||||
(uuid? file-id)
|
||||
(assoc ::bfc/file-id file-id))
|
||||
|
||||
params
|
||||
(if (some? upload-id)
|
||||
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
|
||||
(assoc params :file file))
|
||||
params)
|
||||
|
||||
manifest
|
||||
(case (int version)
|
||||
1 nil
|
||||
3 (bf.v3/get-manifest (-> params :file :path)))]
|
||||
manifest (case (int version)
|
||||
1 nil
|
||||
3 (bf.v3/get-manifest (:path file)))]
|
||||
|
||||
(with-meta
|
||||
(sse/response (partial import-binfile cfg params))
|
||||
|
||||
@@ -79,14 +79,85 @@
|
||||
|
||||
;; --- FILE PERMISSIONS
|
||||
|
||||
|
||||
(def ^:private sql:file-permissions
|
||||
"select fpr.is_owner,
|
||||
fpr.is_admin,
|
||||
fpr.can_edit
|
||||
from file_profile_rel as fpr
|
||||
inner join file as f on (f.id = fpr.file_id)
|
||||
where fpr.file_id = ?
|
||||
and fpr.profile_id = ?
|
||||
and f.deleted_at is null
|
||||
union all
|
||||
select tpr.is_owner,
|
||||
tpr.is_admin,
|
||||
tpr.can_edit
|
||||
from team_profile_rel as tpr
|
||||
inner join project as p on (p.team_id = tpr.team_id)
|
||||
inner join file as f on (p.id = f.project_id)
|
||||
where f.id = ?
|
||||
and tpr.profile_id = ?
|
||||
and f.deleted_at is null
|
||||
union all
|
||||
select ppr.is_owner,
|
||||
ppr.is_admin,
|
||||
ppr.can_edit
|
||||
from project_profile_rel as ppr
|
||||
inner join file as f on (f.project_id = ppr.project_id)
|
||||
where f.id = ?
|
||||
and ppr.profile_id = ?
|
||||
and f.deleted_at is null")
|
||||
|
||||
(defn get-file-permissions
|
||||
[conn profile-id file-id]
|
||||
(when (and profile-id file-id)
|
||||
(db/exec! conn [sql:file-permissions
|
||||
file-id profile-id
|
||||
file-id profile-id
|
||||
file-id profile-id])))
|
||||
|
||||
(defn get-permissions
|
||||
([conn profile-id file-id]
|
||||
(let [rows (get-file-permissions conn profile-id file-id)
|
||||
is-owner (boolean (some :is-owner rows))
|
||||
is-admin (boolean (some :is-admin rows))
|
||||
can-edit (boolean (some :can-edit rows))]
|
||||
(when (seq rows)
|
||||
{:type :membership
|
||||
:is-owner is-owner
|
||||
:is-admin (or is-owner is-admin)
|
||||
:can-edit (or is-owner is-admin can-edit)
|
||||
:can-read true
|
||||
:is-logged (some? profile-id)})))
|
||||
|
||||
([conn profile-id file-id share-id]
|
||||
(let [perms (get-permissions conn profile-id file-id)
|
||||
ldata (some-> (db/get* conn :share-link {:id share-id :file-id file-id})
|
||||
(dissoc :flags)
|
||||
(update :pages db/decode-pgarray #{}))]
|
||||
|
||||
;; NOTE: in a future when share-link becomes more powerful and
|
||||
;; will allow us specify which parts of the app is available, we
|
||||
;; will probably need to tweak this function in order to expose
|
||||
;; this flags to the frontend.
|
||||
(cond
|
||||
(some? perms) perms
|
||||
(some? ldata) {:type :share-link
|
||||
:can-read true
|
||||
:pages (:pages ldata)
|
||||
:is-logged (some? profile-id)
|
||||
:who-comment (:who-comment ldata)
|
||||
:who-inspect (:who-inspect ldata)}))))
|
||||
|
||||
(def has-edit-permissions?
|
||||
(perms/make-edition-predicate-fn bfc/get-file-permissions))
|
||||
(perms/make-edition-predicate-fn get-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn bfc/get-file-permissions))
|
||||
(perms/make-read-predicate-fn get-permissions))
|
||||
|
||||
(def has-comment-permissions?
|
||||
(perms/make-comment-predicate-fn bfc/get-file-permissions))
|
||||
(perms/make-comment-predicate-fn get-permissions))
|
||||
|
||||
(def check-edition-permissions!
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
@@ -99,7 +170,7 @@
|
||||
|
||||
(defn check-comment-permissions!
|
||||
[conn profile-id file-id share-id]
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
(let [perms (get-permissions conn profile-id file-id share-id)
|
||||
can-read (has-read-permissions? perms)
|
||||
can-comment (has-comment-permissions? perms)]
|
||||
(when-not (or can-read can-comment)
|
||||
@@ -151,7 +222,7 @@
|
||||
(defn- get-minimal-file-with-perms
|
||||
[cfg {:keys [:id ::rpc/profile-id]}]
|
||||
(let [mfile (get-minimal-file cfg id)
|
||||
perms (bfc/get-file-permissions cfg profile-id id)]
|
||||
perms (get-permissions cfg profile-id id)]
|
||||
(assoc mfile :permissions perms)))
|
||||
|
||||
(defn get-file-etag
|
||||
@@ -177,7 +248,7 @@
|
||||
;; will be already prefetched and we just reuse them instead
|
||||
;; of making an additional database queries.
|
||||
(let [perms (or (:permissions (::cond/object params))
|
||||
(bfc/get-file-permissions conn profile-id id))]
|
||||
(get-permissions conn profile-id id))]
|
||||
(check-read-permissions! perms)
|
||||
|
||||
(let [team (teams/get-team conn
|
||||
@@ -240,7 +311,7 @@
|
||||
::sm/result schema:file-fragment}
|
||||
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
|
||||
(db/run! cfg (fn [cfg]
|
||||
(let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)]
|
||||
(let [perms (get-permissions cfg profile-id file-id share-id)]
|
||||
(check-read-permissions! perms)
|
||||
(-> (get-file-fragment cfg file-id fragment-id)
|
||||
(rph/with-http-cache long-cache-duration))))))
|
||||
@@ -385,7 +456,8 @@
|
||||
:code :params-validation
|
||||
:hint "page-id is required when object-id is provided"))
|
||||
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
(let [perms (get-permissions conn profile-id file-id share-id)
|
||||
|
||||
file (bfc/get-file cfg file-id :read-only? true)
|
||||
|
||||
proj (db/get conn :project {:id (:project-id file)})
|
||||
@@ -616,10 +688,11 @@
|
||||
"Get libraries used by the specified file."
|
||||
{::doc/added "1.17"
|
||||
::sm/params schema:get-file-libraries}
|
||||
[cfg {:keys [::rpc/profile-id file-id]}]
|
||||
(bfc/check-file-exists cfg file-id)
|
||||
(check-read-permissions! cfg profile-id file-id)
|
||||
(bfc/get-file-libraries cfg file-id))
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(bfc/get-file-libraries conn file-id)))
|
||||
|
||||
|
||||
;; --- COMMAND QUERY: Files that use this File library
|
||||
|
||||
@@ -704,6 +777,7 @@
|
||||
f.created_at,
|
||||
f.modified_at,
|
||||
f.name,
|
||||
f.is_shared,
|
||||
f.deleted_at AS will_be_deleted_at,
|
||||
ft.media_id AS thumbnail_id,
|
||||
row_number() OVER w AS row_num,
|
||||
@@ -711,7 +785,8 @@
|
||||
FROM file AS f
|
||||
INNER JOIN project AS p ON (p.id = f.project_id)
|
||||
LEFT JOIN file_thumbnail AS ft on (ft.file_id = f.id
|
||||
AND ft.revn = f.revn)
|
||||
AND ft.revn = f.revn
|
||||
AND ft.deleted_at is null)
|
||||
WHERE p.team_id = ?
|
||||
AND (p.deleted_at > ?::timestamptz OR
|
||||
f.deleted_at > ?::timestamptz)
|
||||
@@ -813,7 +888,7 @@
|
||||
AND (f.deleted_at IS NULL OR f.deleted_at > now())
|
||||
ORDER BY f.created_at ASC;")
|
||||
|
||||
(defn- absorb-library-by-file
|
||||
(defn- absorb-library-by-file!
|
||||
[cfg ldata file-id]
|
||||
|
||||
(assert (db/connection-map? cfg)
|
||||
@@ -837,7 +912,7 @@
|
||||
:modified-at (ct/now)
|
||||
:has-media-trimmed false}))))
|
||||
|
||||
(defn- absorb-library*
|
||||
(defn- absorb-library
|
||||
"Find all files using a shared library, and absorb all library assets
|
||||
into the file local libraries"
|
||||
[cfg {:keys [id data] :as library}]
|
||||
@@ -852,10 +927,10 @@
|
||||
:library-id (str id)
|
||||
:files (str/join "," (map str ids)))
|
||||
|
||||
(run! (partial absorb-library-by-file cfg data) ids)
|
||||
(run! (partial absorb-library-by-file! cfg data) ids)
|
||||
library))
|
||||
|
||||
(defn absorb-library
|
||||
(defn absorb-library!
|
||||
[{:keys [::db/conn] :as cfg} id]
|
||||
(let [file (-> (bfc/get-file cfg id
|
||||
:realize? true
|
||||
@@ -872,7 +947,7 @@
|
||||
(-> (cfeat/get-team-enabled-features cf/flags team)
|
||||
(cfeat/check-file-features! (:features file)))
|
||||
|
||||
(absorb-library* cfg file)))
|
||||
(absorb-library cfg file)))
|
||||
|
||||
(defn- set-file-shared
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
|
||||
@@ -885,14 +960,14 @@
|
||||
;; file, we need to perform more complex operation,
|
||||
;; so in this case we retrieve the complete file and
|
||||
;; perform all required validations.
|
||||
(let [file (-> (absorb-library cfg id)
|
||||
(let [file (-> (absorb-library! cfg id)
|
||||
(assoc :is-shared false))]
|
||||
(db/delete! conn :file-library-rel {:library-file-id id})
|
||||
(db/update! conn :file
|
||||
{:is-shared false
|
||||
:modified-at (ct/now)}
|
||||
{:id id})
|
||||
file)
|
||||
(select-keys file [:id :name :is-shared]))
|
||||
|
||||
(and (false? (:is-shared file))
|
||||
(true? (:is-shared params)))
|
||||
@@ -939,11 +1014,6 @@
|
||||
{:id file-id}
|
||||
{::db/return-keys [:id :name :is-shared :deleted-at
|
||||
:project-id :created-at :modified-at]})]
|
||||
|
||||
;; Remove all possible relations for that file
|
||||
(db/delete! conn :file-library-rel
|
||||
{:library-file-id file-id})
|
||||
|
||||
(wrk/submit! {::db/conn conn
|
||||
::wrk/task :delete-object
|
||||
::wrk/params {:object :file
|
||||
@@ -1005,19 +1075,19 @@
|
||||
"Link a file to a library. Returns the recursive list of libraries used by that library"
|
||||
{::doc/added "1.17"
|
||||
::webhooks/event? true
|
||||
::sm/params schema:link-file-to-library
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||
|
||||
::sm/params schema:link-file-to-library}
|
||||
[cfg {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||
(when (= file-id library-id)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-library
|
||||
:hint "A file cannot be linked to itself"))
|
||||
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
(link-file-to-library conn params)
|
||||
(bfc/get-libraries cfg [library-id]))
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
(link-file-to-library conn params)
|
||||
(bfc/get-libraries cfg [library-id]))))
|
||||
|
||||
;; --- MUTATION COMMAND: unlink-file-from-library
|
||||
|
||||
@@ -1037,9 +1107,8 @@
|
||||
::webhooks/event? true
|
||||
::sm/params schema:unlink-file-to-library
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
(unlink-file-from-library conn params)
|
||||
nil)
|
||||
|
||||
@@ -1063,9 +1132,8 @@
|
||||
{::doc/added "1.17"
|
||||
::sm/params schema:update-file-library-sync-status
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
(update-sync conn params))
|
||||
|
||||
;; --- MUTATION COMMAND: ignore-sync
|
||||
@@ -1096,53 +1164,47 @@
|
||||
|
||||
;; --- MUTATION COMMAND: delete-files-immediatelly
|
||||
|
||||
(def ^:private sql:get-delete-team-files-candidates
|
||||
"SELECT f.id
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
JOIN team AS t ON (t.id = p.team_id)
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND t.id = ?
|
||||
AND f.id = ANY(?::uuid[])")
|
||||
(def ^:private sql:delete-team-files
|
||||
"UPDATE file AS uf SET deleted_at = ?::timestamptz
|
||||
FROM (
|
||||
SELECT f.id
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
JOIN team AS t ON (t.id = p.team_id)
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND t.id = ?
|
||||
AND f.id = ANY(?::uuid[])
|
||||
) AS subquery
|
||||
WHERE uf.id = subquery.id
|
||||
RETURNING uf.id, uf.deleted_at;")
|
||||
|
||||
(def ^:private schema:permanently-delete-team-files
|
||||
[:map {:title "permanently-delete-team-files"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:ids [::sm/set ::sm/uuid]]])
|
||||
|
||||
(defn- permanently-delete-team-files
|
||||
[{:keys [::db/conn]} {:keys [::rpc/request-at team-id ids]}]
|
||||
(let [ids (into #{}
|
||||
d/xf:map-id
|
||||
(db/exec! conn [sql:get-delete-team-files-candidates team-id
|
||||
(db/create-array conn "uuid" ids)]))]
|
||||
|
||||
(reduce (fn [acc id]
|
||||
(events/tap :progress {:file-id id :index (inc (count acc)) :total (count ids)})
|
||||
(db/update! conn :file
|
||||
{:deleted-at request-at}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(wrk/submit! {::db/conn conn
|
||||
::wrk/task :delete-object
|
||||
::wrk/params {:object :file
|
||||
:deleted-at request-at
|
||||
:id id}})
|
||||
(conj acc id))
|
||||
#{}
|
||||
ids)))
|
||||
|
||||
(sv/defmethod ::permanently-delete-team-files
|
||||
"Mark the specified files to be deleted immediatelly on the
|
||||
specified team. The team-id on params will be used to filter and
|
||||
check writable permissons on team."
|
||||
|
||||
{::doc/added "2.13"
|
||||
::sm/params schema:permanently-delete-team-files}
|
||||
{::doc/added "2.12"
|
||||
::sm/params schema:permanently-delete-team-files
|
||||
::db/transaction true}
|
||||
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(teams/check-edition-permissions! pool profile-id team-id)
|
||||
(sse/response #(db/tx-run! cfg permanently-delete-team-files params)))
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at team-id ids]}]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
|
||||
(reduce (fn [acc {:keys [id deleted-at]}]
|
||||
(wrk/submit! {::db/conn conn
|
||||
::wrk/task :delete-object
|
||||
::wrk/params {:object :file
|
||||
:deleted-at deleted-at
|
||||
:id id}})
|
||||
(conj acc id))
|
||||
#{}
|
||||
(db/plan conn [sql:delete-team-files request-at team-id
|
||||
(db/create-array conn "uuid" ids)])))
|
||||
|
||||
;; --- MUTATION COMMAND: restore-files-immediatelly
|
||||
|
||||
@@ -1206,7 +1268,7 @@
|
||||
{:keys [files projects]}
|
||||
(reduce (fn [result {:keys [id project-id]}]
|
||||
(let [index (-> result :files count)]
|
||||
(events/tap :progress {:file-id id :index (inc index) :total total-files})
|
||||
(events/tap :progress {:file-id id :index index :total total-files})
|
||||
(restore-file conn id)
|
||||
|
||||
(-> result
|
||||
@@ -1229,7 +1291,7 @@
|
||||
(sv/defmethod ::restore-deleted-team-files
|
||||
"Removes the deletion mark from the specified files (and respective
|
||||
projects) on the specified team."
|
||||
{::doc/added "2.13"
|
||||
{::doc/added "2.12"
|
||||
::sse/stream? true
|
||||
::sm/params schema:restore-deleted-team-files}
|
||||
[cfg params]
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
{::doc/added "1.20"
|
||||
::sm/params schema:restore-file-snapshot
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::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)
|
||||
(let [file (bfc/get-file cfg file-id)
|
||||
team (teams/get-team conn
|
||||
@@ -88,8 +88,7 @@
|
||||
;; Send to the clients a notification to reload the file
|
||||
(mbus/pub! msgbus
|
||||
:topic (:id file)
|
||||
:message {:type :file-restored
|
||||
:session-id session-id
|
||||
:message {:type :file-restore
|
||||
:file-id (:id file)
|
||||
:vern vern})
|
||||
nil)))
|
||||
|
||||
Loaded 100 of 1582 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user