mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 16:18:48 +00:00
Merge branch 'develop' into niwinz-performance-tests
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
commit
8b2991e13b
@ -88,6 +88,9 @@
|
|||||||
:dynamic-var-not-earmuffed
|
:dynamic-var-not-earmuffed
|
||||||
{:level :off}
|
{:level :off}
|
||||||
|
|
||||||
|
:type-mismatch
|
||||||
|
{:level :off}
|
||||||
|
|
||||||
:used-underscored-binding
|
:used-underscored-binding
|
||||||
{:level :warning}
|
{:level :warning}
|
||||||
|
|
||||||
|
|||||||
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Penpot API configuration for error-reports CLI tool
|
||||||
|
PENPOT_API_URI=http://localhost:3450
|
||||||
|
PENPOT_ACCESS_TOKEN=your-access-token-here
|
||||||
102
.github/workflows/build-bundle.yml
vendored
102
.github/workflows/build-bundle.yml
vendored
@ -9,16 +9,6 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 'develop'
|
default: 'develop'
|
||||||
build_wasm:
|
|
||||||
description: 'BUILD_WASM. Valid values: yes, no'
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: 'yes'
|
|
||||||
build_storybook:
|
|
||||||
description: 'BUILD_STORYBOOK. Valid values: yes, no'
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: 'yes'
|
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
gh_ref:
|
gh_ref:
|
||||||
@ -26,29 +16,21 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 'develop'
|
default: 'develop'
|
||||||
build_wasm:
|
|
||||||
description: 'BUILD_WASM. Valid values: yes, no'
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: 'yes'
|
|
||||||
build_storybook:
|
|
||||||
description: 'BUILD_STORYBOOK. Valid values: yes, no'
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: 'yes'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-bundle:
|
# ── 1. Decide whether there is anything to build ───────────────────────
|
||||||
name: Build and Upload Penpot Bundle
|
check:
|
||||||
|
name: Check current bundle
|
||||||
runs-on: penpot-runner-01
|
runs-on: penpot-runner-01
|
||||||
env:
|
timeout-minutes: 10
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
outputs:
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||||
|
exists: ${{ steps.check.outputs.exists }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@ -63,10 +45,52 @@ jobs:
|
|||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
|
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The uploaded zip carries its version as S3 metadata. If the
|
||||||
|
# existing object was already built from this same commit, the
|
||||||
|
# whole build job is skipped.
|
||||||
|
- name: Check if this bundle is already built
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
run: |
|
||||||
|
EXISTING_VERSION=$(aws s3api head-object \
|
||||||
|
--bucket ${{ secrets.S3_BUCKET }} \
|
||||||
|
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
|
||||||
|
--query 'Metadata."bundle-version"' \
|
||||||
|
--output text 2>/dev/null || echo "none")
|
||||||
|
|
||||||
|
if [ "$EXISTING_VERSION" = "${{ steps.vars.outputs.bundle_version }}" ]; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
{
|
||||||
|
echo "### ⏭️ Bundle build skipped"
|
||||||
|
echo ""
|
||||||
|
echo "The bundle in S3 was already built from \`${{ steps.vars.outputs.bundle_version }}\`."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Build and upload, only when needed ──────────────────────────────
|
||||||
|
build:
|
||||||
|
name: Build and Upload Penpot Bundle
|
||||||
|
runs-on: penpot-runner-01
|
||||||
|
timeout-minutes: 90
|
||||||
|
needs: check
|
||||||
|
if: needs.check.outputs.exists == 'false'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.gh_ref }}
|
||||||
|
|
||||||
- name: Build bundle
|
- name: Build bundle
|
||||||
env:
|
env:
|
||||||
BUILD_WASM: ${{ inputs.build_wasm }}
|
BUILD_WASM: 'yes'
|
||||||
BUILD_STORYBOOK: ${{ inputs.build_storybook }}
|
BUILD_STORYBOOK: 'yes'
|
||||||
run: ./manage.sh build-bundle
|
run: ./manage.sh build-bundle
|
||||||
|
|
||||||
- name: Prepare directories for zipping
|
- name: Prepare directories for zipping
|
||||||
@ -80,18 +104,32 @@ jobs:
|
|||||||
zip -r zips/penpot.zip penpot
|
zip -r zips/penpot.zip penpot
|
||||||
|
|
||||||
- name: Upload Penpot bundle to S3
|
- name: Upload Penpot bundle to S3
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||||
run: |
|
run: |
|
||||||
aws s3 cp zips/penpot.zip s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip --metadata bundle-version=${{ steps.vars.outputs.bundle_version }}
|
aws s3 cp zips/penpot.zip \
|
||||||
|
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
|
||||||
|
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
|
||||||
|
|
||||||
|
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||||
|
notify:
|
||||||
|
name: Notify failure
|
||||||
|
runs-on: penpot-runner-01
|
||||||
|
timeout-minutes: 5
|
||||||
|
needs: [check, build]
|
||||||
|
if: failure()
|
||||||
|
|
||||||
|
steps:
|
||||||
- name: Notify Mattermost
|
- name: Notify Mattermost
|
||||||
if: failure()
|
|
||||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||||
with:
|
with:
|
||||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||||
TEXT: |
|
TEXT: |
|
||||||
❌ 📦 *[PENPOT] Error building penpot bundles.*
|
❌ 📦 *[PENPOT] Error building penpot bundles.*
|
||||||
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
|
📄 Triggered from ref: `${{ needs.check.outputs.gh_ref || inputs.gh_ref }}`
|
||||||
Bundle version: `${{ steps.vars.outputs.bundle_version }}`
|
Bundle version: `${{ needs.check.outputs.bundle_version || 'n/a' }}`
|
||||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
@infra
|
@infra
|
||||||
|
|||||||
8
.github/workflows/build-develop.yml
vendored
8
.github/workflows/build-develop.yml
vendored
@ -11,8 +11,6 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
gh_ref: "develop"
|
gh_ref: "develop"
|
||||||
build_wasm: "yes"
|
|
||||||
build_storybook: "yes"
|
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
needs: build-bundle
|
needs: build-bundle
|
||||||
@ -20,3 +18,9 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
gh_ref: "develop"
|
gh_ref: "develop"
|
||||||
|
|
||||||
|
build-docker-admin-console:
|
||||||
|
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: "develop"
|
||||||
|
|||||||
91
.github/workflows/build-docker-admin-console.yml
vendored
Normal file
91
.github/workflows/build-docker-admin-console.yml
vendored
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
name: Admin Console Docker Builder
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
gh_ref:
|
||||||
|
description: 'Name of the branch or ref to build in penpot-nitrate'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: 'develop'
|
||||||
|
dispatch_ref:
|
||||||
|
description: 'Branch of penpot-nitrate from which the workflow definition is read'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: 'develop'
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
gh_ref:
|
||||||
|
description: 'Name of the branch or ref to build in penpot-nitrate'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
dispatch_ref:
|
||||||
|
description: 'Branch of penpot-nitrate from which the workflow definition is read'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: 'develop'
|
||||||
|
secrets:
|
||||||
|
ORG_WORKFLOW_TOKEN:
|
||||||
|
description: 'Token with Actions write access on penpot-nitrate'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-nitrate-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }}
|
||||||
|
REPO: penpot/penpot-nitrate
|
||||||
|
WORKFLOW: build-docker-admin-console.yml
|
||||||
|
GH_REF: ${{ inputs.gh_ref }}
|
||||||
|
DISPATCH_REF: ${{ inputs.dispatch_ref }}
|
||||||
|
steps:
|
||||||
|
- name: Trigger nitrate docker build
|
||||||
|
id: dispatch
|
||||||
|
run: |
|
||||||
|
DISTINCT_ID="${{ github.run_id }}-${{ github.run_attempt }}"
|
||||||
|
CALLER_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
|
||||||
|
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
|
||||||
|
-f gh_ref="$GH_REF" \
|
||||||
|
-f caller_run_id="$DISTINCT_ID" \
|
||||||
|
-f caller_run_url="$CALLER_URL"
|
||||||
|
|
||||||
|
# Locate the dispatched run using the correlation id embedded in its run-name
|
||||||
|
RUN_ID=""
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" \
|
||||||
|
--limit 10 --json databaseId,displayTitle \
|
||||||
|
--jq ".[] | select(.displayTitle | contains(\"$DISTINCT_ID\")) | .databaseId" \
|
||||||
|
| head -n1)
|
||||||
|
[ -n "$RUN_ID" ] && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RUN_ID" ]; then
|
||||||
|
echo "::error::Could not locate the dispatched run in $REPO"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID"
|
||||||
|
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "run_url=$RUN_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice title=Nitrate docker build::$RUN_URL"
|
||||||
|
|
||||||
|
- name: Wait for nitrate docker build
|
||||||
|
run: |
|
||||||
|
gh run watch "${{ steps.dispatch.outputs.run_id }}" \
|
||||||
|
--repo "$REPO" \
|
||||||
|
--interval 30 \
|
||||||
|
--exit-status
|
||||||
|
|
||||||
|
- name: Report result
|
||||||
|
if: always() && steps.dispatch.outputs.run_id != ''
|
||||||
|
run: |
|
||||||
|
CONCLUSION=$(gh run view "${{ steps.dispatch.outputs.run_id }}" \
|
||||||
|
--repo "$REPO" --json conclusion --jq '.conclusion')
|
||||||
|
{
|
||||||
|
echo "### 🐳 Nitrate docker build"
|
||||||
|
echo ""
|
||||||
|
echo "- Result: \`${CONCLUSION:-in_progress}\`"
|
||||||
|
echo "- Run: ${{ steps.dispatch.outputs.run_url }}"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
11
.github/workflows/build-docker-devenv.yml
vendored
11
.github/workflows/build-docker-devenv.yml
vendored
@ -20,12 +20,19 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry (push destination)
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hardened Images registry (base image pull)
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: dhi.io
|
||||||
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push DevEnv Docker image
|
- name: Build and push DevEnv Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
env:
|
env:
|
||||||
@ -35,6 +42,8 @@ jobs:
|
|||||||
file: ./docker/devenv/Dockerfile
|
file: ./docker/devenv/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
tags: ${{ env.DOCKER_IMAGE }}:latest
|
tags: ${{ env.DOCKER_IMAGE }}:latest
|
||||||
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
|
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||||
|
|||||||
319
.github/workflows/build-docker.yml
vendored
319
.github/workflows/build-docker.yml
vendored
@ -20,55 +20,117 @@ concurrency:
|
|||||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
ALL_IMAGES: backend frontend exporter storybook mcp
|
||||||
|
# All runner instances live on the same server, so the bundle is
|
||||||
|
# downloaded from S3 once and shared between build jobs through this
|
||||||
|
# host-local directory. Each build job falls back to S3 if the file is
|
||||||
|
# missing (e.g. if runners ever move to separate machines).
|
||||||
|
BUNDLE_CACHE: /var/tmp/penpot-bundle-cache
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||||
name: Build and Push Penpot Docker Images
|
prepare:
|
||||||
|
name: Prepare
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
|
timeout-minutes: 15
|
||||||
|
outputs:
|
||||||
|
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
|
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||||
|
build_key: ${{ steps.vars.outputs.build_key }}
|
||||||
|
exists: ${{ steps.check.outputs.exists }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.gh_ref }}
|
||||||
|
|
||||||
|
- name: Extract some useful variables
|
||||||
|
id: vars
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
run: |
|
||||||
|
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
|
||||||
|
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
BUNDLE_VERSION=$(aws s3api head-object \
|
||||||
|
--bucket ${{ secrets.S3_BUCKET }} \
|
||||||
|
--key "penpot-$GH_REF.zip" \
|
||||||
|
--query 'Metadata."bundle-version"' \
|
||||||
|
--output text)
|
||||||
|
echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Image content = bundle + docker build context, so the build key
|
||||||
|
# combines both.
|
||||||
|
CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12)
|
||||||
|
echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The image set is a single block, so a single set-level check is
|
||||||
|
# enough: `promote` drops a marker object in S3 only after every
|
||||||
|
# image was built AND every branch tag was moved. Marker present
|
||||||
|
# means there is nothing at all to do for this build key.
|
||||||
|
- name: Check if this image set is already built
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
run: |
|
||||||
|
if aws s3api head-object \
|
||||||
|
--bucket ${{ secrets.S3_BUCKET }} \
|
||||||
|
--key "markers/images-${{ steps.vars.outputs.build_key }}" \
|
||||||
|
> /dev/null 2>&1; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
{
|
||||||
|
echo "### ⏭️ Image set build skipped"
|
||||||
|
echo ""
|
||||||
|
echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Stage the bundle in the host-local cache, once, for all the
|
||||||
|
# build jobs. Download to a temp name and mv for atomicity;
|
||||||
|
# prune stale bundles while at it.
|
||||||
|
mkdir -p "$BUNDLE_CACHE"
|
||||||
|
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
|
||||||
|
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip"
|
||||||
|
if [ ! -f "$ZIP" ]; then
|
||||||
|
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||||
|
mv "$ZIP.$$.tmp" "$ZIP"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.image }}
|
||||||
|
runs-on: penpot-runner-02
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
# 4 runner slots are available for build jobs on this server; cap the
|
||||||
|
# matrix at 3 so short jobs (prepare and other workflows' checks)
|
||||||
|
# never queue behind long builds.
|
||||||
|
max-parallel: 3
|
||||||
|
matrix:
|
||||||
|
image: [backend, frontend, exporter, storybook, mcp]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set common environment variables
|
- name: Set common environment variables
|
||||||
run: |
|
run: |
|
||||||
# Each job execution will use its own docker configuration.
|
# Each job execution will use its own docker configuration.
|
||||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ inputs.gh_ref }}
|
ref: ${{ inputs.gh_ref }}
|
||||||
|
|
||||||
- name: Extract some useful variables
|
|
||||||
id: vars
|
|
||||||
run: |
|
|
||||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Download Penpot Bundles
|
|
||||||
id: bundles
|
|
||||||
env:
|
|
||||||
FILE_NAME: penpot-${{ steps.vars.outputs.gh_ref }}.zip
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
|
||||||
run: |
|
|
||||||
tmp=$(aws s3api head-object \
|
|
||||||
--bucket ${{ secrets.S3_BUCKET }} \
|
|
||||||
--key "$FILE_NAME" \
|
|
||||||
--query 'Metadata."bundle-version"' \
|
|
||||||
--output text)
|
|
||||||
echo "bundle_version=$tmp" >> $GITHUB_OUTPUT
|
|
||||||
pushd docker/images
|
|
||||||
aws s3 cp s3://${{ secrets.S3_BUCKET }}/$FILE_NAME .
|
|
||||||
unzip $FILE_NAME > /dev/null
|
|
||||||
mv penpot/backend bundle-backend
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
@ -85,103 +147,140 @@ jobs:
|
|||||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
# Images now build FROM Docker Hardened Images (dhi.io). DHI
|
||||||
|
# is free (Apache 2.0, no subscription), but pulling from it
|
||||||
|
# still requires an authenticated login -- a separate `docker
|
||||||
|
# login` against a different registry host, even though it
|
||||||
|
# reuses the same PUB_DOCKER_* credentials as the DockerHub
|
||||||
|
# login above.
|
||||||
|
- name: Login to Docker Hardened Images registry (base image pull)
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: dhi.io
|
||||||
|
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
# Bundle staged once by `prepare` on this host; the S3 fallback only
|
||||||
|
# triggers if the cache is unavailable (runners on another machine,
|
||||||
|
# cache pruned mid-run, ...).
|
||||||
|
- name: Prepare Penpot bundle
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
run: |
|
||||||
|
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip"
|
||||||
|
if [ ! -f "$ZIP" ]; then
|
||||||
|
echo "Bundle not found in host cache; falling back to S3."
|
||||||
|
mkdir -p "$BUNDLE_CACHE"
|
||||||
|
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.prepare.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||||
|
mv "$ZIP.$$.tmp" "$ZIP"
|
||||||
|
fi
|
||||||
|
# Extract only the bundle this job needs.
|
||||||
|
pushd docker/images
|
||||||
|
unzip -q "$ZIP" "penpot/${{ matrix.image }}/*"
|
||||||
|
mv "penpot/${{ matrix.image }}" "bundle-${{ matrix.image }}"
|
||||||
|
popd
|
||||||
|
|
||||||
|
- name: Set up QEMU (stable)
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels)
|
- name: Extract metadata (tags, labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images:
|
images: ${{ matrix.image }}
|
||||||
frontend
|
|
||||||
backend
|
|
||||||
exporter
|
|
||||||
storybook
|
|
||||||
mcp
|
|
||||||
labels: |
|
labels: |
|
||||||
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
bundle_version=${{ needs.prepare.outputs.bundle_version }}
|
||||||
|
|
||||||
- name: Build and push Backend Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
env:
|
|
||||||
DOCKER_IMAGE: 'backend'
|
|
||||||
BUNDLE_PATH: './bundle-backend'
|
|
||||||
with:
|
with:
|
||||||
context: ./docker/images/
|
context: ./docker/images/
|
||||||
file: ./docker/images/Dockerfile.backend
|
file: ./docker/images/Dockerfile.${{ matrix.image }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
|
# Immutable tag only; branch tags are moved atomically for the
|
||||||
|
# whole image set by the `promote` job.
|
||||||
|
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build and push Frontend Docker image
|
# ── 3. Move the branch tags of ALL images together ─────────────────────
|
||||||
uses: docker/build-push-action@v7
|
# Runs only when every build succeeded (default `needs` semantics); if
|
||||||
env:
|
# the set was already complete, `build` is skipped and so is this job —
|
||||||
DOCKER_IMAGE: 'frontend'
|
# the S3 marker guarantees the branch tags were already moved.
|
||||||
BUNDLE_PATH: './bundle-frontend'
|
promote:
|
||||||
|
name: Promote image set
|
||||||
|
runs-on: penpot-runner-02
|
||||||
|
timeout-minutes: 10
|
||||||
|
needs: [prepare, build]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set common environment variables
|
||||||
|
run: |
|
||||||
|
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
context: ./docker/images/
|
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||||
file: ./docker/images/Dockerfile.frontend
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
platforms: linux/amd64,linux/arm64
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
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: Build and push Exporter Docker image
|
- name: Point branch tags to the new build key
|
||||||
uses: docker/build-push-action@v7
|
run: |
|
||||||
|
set -e
|
||||||
|
for image in $ALL_IMAGES; do
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
|
||||||
|
"${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The marker is written LAST: its presence certifies that all five
|
||||||
|
# images exist and all branch tags point to this build key.
|
||||||
|
- name: Write set-completed marker
|
||||||
env:
|
env:
|
||||||
DOCKER_IMAGE: 'exporter'
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
BUNDLE_PATH: './bundle-exporter'
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
with:
|
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||||
context: ./docker/images/
|
run: |
|
||||||
file: ./docker/images/Dockerfile.exporter
|
echo "${{ github.run_id }}" | aws s3 cp - \
|
||||||
platforms: linux/amd64,linux/arm64
|
"s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
|
||||||
push: true
|
{
|
||||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
echo "### ✅ Image set promoted"
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
echo ""
|
||||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
|
||||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Build and push Storybook Docker image
|
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||||
uses: docker/build-push-action@v7
|
notify:
|
||||||
env:
|
name: Notify failure
|
||||||
DOCKER_IMAGE: 'storybook'
|
runs-on: penpot-runner-02
|
||||||
BUNDLE_PATH: './bundle-storybook'
|
timeout-minutes: 5
|
||||||
with:
|
needs: [prepare, build, promote]
|
||||||
context: ./docker/images/
|
if: failure()
|
||||||
file: ./docker/images/Dockerfile.storybook
|
|
||||||
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: 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
|
|
||||||
|
|
||||||
|
steps:
|
||||||
- name: Notify Mattermost
|
- name: Notify Mattermost
|
||||||
if: failure()
|
|
||||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||||
with:
|
with:
|
||||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||||
TEXT: |
|
TEXT: |
|
||||||
❌ 🐳 *[PENPOT] Error building penpot docker images.*
|
❌ 🐳 *[PENPOT] Error building/promoting the penpot docker image set.*
|
||||||
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
|
📄 Triggered from ref: `${{ needs.prepare.outputs.gh_ref || inputs.gh_ref }}`
|
||||||
📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}`
|
📦 Bundle: `${{ needs.prepare.outputs.bundle_version || 'n/a' }}`
|
||||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
@infra
|
@infra
|
||||||
|
|||||||
8
.github/workflows/build-staging.yml
vendored
8
.github/workflows/build-staging.yml
vendored
@ -11,8 +11,6 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
gh_ref: "staging"
|
gh_ref: "staging"
|
||||||
build_wasm: "yes"
|
|
||||||
build_storybook: "yes"
|
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
needs: build-bundle
|
needs: build-bundle
|
||||||
@ -20,3 +18,9 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
gh_ref: "staging"
|
gh_ref: "staging"
|
||||||
|
|
||||||
|
build-docker-admin-console:
|
||||||
|
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: "staging"
|
||||||
|
|||||||
17
.github/workflows/build-tag.yml
vendored
17
.github/workflows/build-tag.yml
vendored
@ -12,8 +12,6 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
gh_ref: ${{ github.ref_name }}
|
gh_ref: ${{ github.ref_name }}
|
||||||
build_wasm: "yes"
|
|
||||||
build_storybook: "yes"
|
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
needs: build-bundle
|
needs: build-bundle
|
||||||
@ -22,11 +20,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
gh_ref: ${{ github.ref_name }}
|
gh_ref: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
build-docker-admin-console:
|
||||||
|
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: ${{ github.ref_name }}
|
||||||
|
|
||||||
notify:
|
notify:
|
||||||
name: Notifications
|
name: Notifications
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
|
- build-docker-admin-console
|
||||||
steps:
|
steps:
|
||||||
- name: Notify Mattermost
|
- name: Notify Mattermost
|
||||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||||
@ -40,7 +45,9 @@ jobs:
|
|||||||
|
|
||||||
publish-final-tag:
|
publish-final-tag:
|
||||||
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
|
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
|
- build-docker-admin-console
|
||||||
uses: ./.github/workflows/release.yml
|
uses: ./.github/workflows/release.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
|
|||||||
69
.github/workflows/tests-composable-suite.yml
vendored
Normal file
69
.github/workflows/tests-composable-suite.yml
vendored
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
name: "CI: Composable Test Suite"
|
||||||
|
|
||||||
|
# Runs the composable component test suite (it exercises component semantics
|
||||||
|
# through the real Plugin API against the full frontend, so it needs the
|
||||||
|
# frontend bundle + the plugin runtime, but no backend): the driver serves the
|
||||||
|
# prebuilt frontend bundle and intercepts every backend RPC with Playwright
|
||||||
|
# fixtures. See plugins/apps/composable-test-suite/README.md ("Running in CI").
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'plugins/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'common/**'
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- staging
|
||||||
|
paths:
|
||||||
|
- 'plugins/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'common/**'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
composable-test-suite:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
|
name: "Run composable test suite (mocked backend)"
|
||||||
|
runs-on: penpot-runner-02
|
||||||
|
container:
|
||||||
|
image: penpotapp/devenv:latest
|
||||||
|
volumes:
|
||||||
|
- /var/cache/github-runner/m2:/root/.m2
|
||||||
|
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# The driver serves the prebuilt bundle from frontend/resources/public.
|
||||||
|
- name: Build frontend bundle
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: ./scripts/build
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ./plugins
|
||||||
|
run: |
|
||||||
|
corepack enable;
|
||||||
|
corepack install;
|
||||||
|
pnpm install;
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
working-directory: ./plugins
|
||||||
|
run: pnpm --filter composable-test-suite exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run composable test suite (mocked)
|
||||||
|
working-directory: ./plugins
|
||||||
|
run: pnpm --filter composable-test-suite run test:ci
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -59,6 +59,8 @@ opencode.json
|
|||||||
/docker/images/bundle*
|
/docker/images/bundle*
|
||||||
/exporter/target
|
/exporter/target
|
||||||
/exporter/.shadow-cljs
|
/exporter/.shadow-cljs
|
||||||
|
/exporter/resources/wasm/
|
||||||
|
/exporter/src/app/wasm/shared.js
|
||||||
/frontend/.storybook/preview-body.html
|
/frontend/.storybook/preview-body.html
|
||||||
/frontend/.storybook/preview-head.html
|
/frontend/.storybook/preview-head.html
|
||||||
/frontend/playwright-report/
|
/frontend/playwright-report/
|
||||||
@ -89,6 +91,7 @@ opencode.json
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/render-wasm/target/
|
/render-wasm/target/
|
||||||
|
/media-processor/dist/
|
||||||
/**/node_modules
|
/**/node_modules
|
||||||
/**/.yarn/*
|
/**/.yarn/*
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
@ -102,6 +105,7 @@ opencode.json
|
|||||||
/.opencode/plans
|
/.opencode/plans
|
||||||
/.opencode/reports
|
/.opencode/reports
|
||||||
/.opencode/prompts
|
/.opencode/prompts
|
||||||
|
/.ci-logs
|
||||||
/.codex/
|
/.codex/
|
||||||
/tools/__pycache__
|
/tools/__pycache__
|
||||||
/performance/results/
|
/performance/results/
|
||||||
@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
name: commiter
|
|
||||||
description: Git commit assistant
|
|
||||||
mode: subagent
|
|
||||||
permission:
|
|
||||||
read: allow
|
|
||||||
glob: allow
|
|
||||||
grep: allow
|
|
||||||
edit: deny
|
|
||||||
webfetch: deny
|
|
||||||
websearch: deny
|
|
||||||
task: deny
|
|
||||||
skill: deny
|
|
||||||
lsp: deny
|
|
||||||
todowrite: deny
|
|
||||||
question: deny
|
|
||||||
external_directory: deny
|
|
||||||
bash: allow
|
|
||||||
---
|
|
||||||
|
|
||||||
## Role
|
|
||||||
|
|
||||||
You are the Penpot commit assistant. You produce git commits that follow the
|
|
||||||
repository's commit conventions. You do not implement features, review code, or
|
|
||||||
push branches — you commit.
|
|
||||||
|
|
||||||
## Required Reading
|
|
||||||
|
|
||||||
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
|
|
||||||
end-to-end**. It is the authoritative source for the commit message format, the
|
|
||||||
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
|
|
||||||
exactly — do not improvise the format and do not restate its contents here.
|
|
||||||
|
|
||||||
## Pre-commit Workflow
|
|
||||||
|
|
||||||
1. **Stage the files** specified by the calling agent. Do not ask for
|
|
||||||
confirmation — the calling agent knows exactly which files to commit.
|
|
||||||
2. Run `git diff --staged` to review the content. If you see secrets (API
|
|
||||||
keys, tokens, passwords, private keys, `.env` values), debug prints, or
|
|
||||||
anything that does not match the stated intent, STOP and tell the user
|
|
||||||
before committing.
|
|
||||||
3. Following the format in the doc, draft the message and run
|
|
||||||
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
|
|
||||||
unusual characters). The `AI-assisted-by` trailer value is provided by the
|
|
||||||
calling agent — use it verbatim.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Do not push. Pushing is a separate workflow handled by the user.
|
|
||||||
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
|
|
||||||
- Do not pass `--author`. Author identity comes from the local git config.
|
|
||||||
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
|
|
||||||
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
|
|
||||||
- Do not add untracked files that were not created in this session.
|
|
||||||
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
|
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
|
||||||
agent: build
|
agent: build
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -32,12 +32,11 @@ Implement the prepared plan from the session context. Work methodically, keeping
|
|||||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||||
step 4.
|
step 4.
|
||||||
|
|
||||||
## 4. Commit with the commiter subagent
|
## 4. Commit with the create-commit skill
|
||||||
|
|
||||||
After the implementation is complete, delegate the commit to the **`commiter`**
|
After the implementation is complete, load the **`create-commit`** skill and
|
||||||
subagent. Give it a brief summary of what was implemented and why, the issue
|
follow its workflow to commit the changes. Provide a brief summary of what was
|
||||||
reference (`issue-NNNN`), and the model name you are running as so it sets the
|
implemented and why, the issue reference (`issue-NNNN`), and the model name you
|
||||||
`AI-assisted-by` trailer correctly. The subagent owns the commit format and
|
are running as so the `AI-assisted-by` trailer is set correctly.
|
||||||
conventions.
|
|
||||||
|
|
||||||
Do not push. Pushing is handled separately by the user.
|
Do not push. Pushing is handled separately by the user.
|
||||||
|
|||||||
@ -1,21 +1,79 @@
|
|||||||
---
|
Act as a senior software engineer and perform a thorough code review.
|
||||||
description: Review a commit (defaults to the last commit) with the code-review-and-quality skill across all five axes
|
|
||||||
agent: plan
|
|
||||||
subtask: true
|
|
||||||
---
|
|
||||||
|
|
||||||
You are performing a code review of a git commit. You MUST conduct it using the **`code-review-and-quality`** skill (the five-axis review: correctness, readability, architecture, security, performance).
|
## Instructions
|
||||||
|
|
||||||
The user may specify a commit or revision range as an argument ($ARGUMENTS). If no argument is given, default to reviewing the **last commit** (`HEAD`, i.e. the changes introduced by `HEAD` vs its parent).
|
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
|
||||||
|
2. Determine the diff or code to review from the provided context.
|
||||||
|
3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
|
||||||
|
4. Read the diff and the surrounding context for each changed file.
|
||||||
|
5. Review across all five axes: correctness, readability, architecture, security, performance.
|
||||||
|
6. Produce the review using this structure:
|
||||||
|
- **Summary**: One-paragraph overview of the change and its impact
|
||||||
|
- **Critical/High Findings**: Blockers that must be fixed (with file:line, severity, description, and proposed fix)
|
||||||
|
- **Other Findings**: Medium/Low issues and suggestions
|
||||||
|
- **Testing Recommendations**: Missing test coverage or test quality issues
|
||||||
|
- **Positive Observations**: What was done well (brief, specific)
|
||||||
|
- **Verdict**: Approve / Request Changes / Needs Discussion
|
||||||
|
7. For each finding:
|
||||||
|
- State the severity (Critical / High / Medium / Low / Suggestion)
|
||||||
|
- Identify the file and line
|
||||||
|
- Describe failure circumstances
|
||||||
|
- **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code
|
||||||
|
- **For Medium/Low**: Describe the fix clearly; code snippet optional
|
||||||
|
- If multiple approaches exist, briefly note trade-offs
|
||||||
|
8. **Perform a second review pass if the change is complex:**
|
||||||
|
- **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed
|
||||||
|
- **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings
|
||||||
|
- Second pass checks:
|
||||||
|
- Validate severity assignments: Are Critical/High findings truly blockers?
|
||||||
|
- Catch missed issues: Edge cases, error paths, test gaps overlooked in first pass
|
||||||
|
- Remove false positives: Discard findings that aren't real issues
|
||||||
|
- Verify fixes: Are the proposed solutions actually correct and complete?
|
||||||
|
|
||||||
Workflow:
|
## Strong Rules
|
||||||
|
|
||||||
1. Determine the target to review:
|
1. Do not invent problems. Every finding must be real and actionable.
|
||||||
- If the user provided a revision/range in $ARGUMENTS, use it.
|
2. Do not modify any code and do not create a commit — this command only reviews.
|
||||||
- Otherwise, default to the last commit: review `HEAD` (the diff of `HEAD` against `HEAD~1`).
|
3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
|
||||||
2. Inspect the change with `git show <target>` / `git diff <target>~1 <target>` and `git log -1 --stat <target>` to understand the intent and the files touched.
|
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||||
3. Invoke the **`code-review-and-quality`** skill and review the commit across all five axes. Categorize every finding as Critical / Required / Optional / Nit / FYI, and lead with correctness and security.
|
5. If tests are missing for new functionality, flag it as High severity.
|
||||||
4. For each finding, state the axis it belongs to, the severity, and a concrete suggested fix (propose the structural remedy, not just the problem).
|
|
||||||
5. Conclude with a clear verdict: **Approve** (ready to merge) or **Request changes** (issues that must be addressed), and summarize the highest-leverage items.
|
|
||||||
|
|
||||||
Do not modify any code and do not create a commit — this command only reviews.
|
## Context
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
|
|
||||||
|
## Expected Format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Review Summary
|
||||||
|
[1-2 sentences on what the change does and overall assessment]
|
||||||
|
|
||||||
|
## Critical/High Findings
|
||||||
|
### [Severity] file.ts:123
|
||||||
|
**Issue**: [Description of the problem]
|
||||||
|
**Impact**: [What could go wrong]
|
||||||
|
**Fix**:
|
||||||
|
```[language]
|
||||||
|
// Current code
|
||||||
|
[problematic code]
|
||||||
|
|
||||||
|
// Fixed code
|
||||||
|
[corrected code]
|
||||||
|
```
|
||||||
|
[Optional: note trade-offs if multiple approaches exist]
|
||||||
|
|
||||||
|
## Other Findings
|
||||||
|
### [Severity] file.ts:456
|
||||||
|
**Issue**: [Description]
|
||||||
|
**Fix**: [Clear description; code snippet optional]
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
[List specific test cases that should be added]
|
||||||
|
|
||||||
|
## Positive Observations
|
||||||
|
[2-3 specific things done well]
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
[Approve / Request Changes / Needs Discussion]
|
||||||
|
[If Request Changes: list the must-fix items]
|
||||||
|
```
|
||||||
|
|||||||
@ -19,9 +19,18 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
|
|||||||
- When refactoring existing code
|
- When refactoring existing code
|
||||||
- After any bug fix (review both the fix and the regression test)
|
- After any bug fix (review both the fix and the regression test)
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
These principles underpin every axis. When in doubt, default to them.
|
||||||
|
|
||||||
|
- **DRY (Don't Repeat Yourself):** Every piece of knowledge has one authoritative representation. If the same logic appears in two places, extract it into a shared helper, model, or type. Reviewers: flag duplicated logic as a required change — it's not "just similar," it's drift that will diverge.
|
||||||
|
- **KISS (Keep It Simple, Stupid):** The simplest solution that works is the best solution. Complexity must earn its place. Reviewers: if you need more than one sentence to explain what a piece of code does, it's too complex — push for simplification before merge.
|
||||||
|
- **YAGNI (You Aren't Gonna Need It):** Don't add abstractions, hooks, or generalizations for hypothetical future use cases. Generalize on the third occurrence, not the first. Reviewers: delete speculative generality.
|
||||||
|
- **Don't invent problems:** Do not manufacture issues to produce more feedback. Every finding must be a real risk, a real readability barrier, or a real architectural concern — not a hypothetical or a stylistic preference disguised as a problem.
|
||||||
|
|
||||||
## The Five-Axis Review
|
## The Five-Axis Review
|
||||||
|
|
||||||
Every review evaluates code across these dimensions:
|
Every review evaluates code across these dimensions.
|
||||||
|
|
||||||
### 1. Correctness
|
### 1. Correctness
|
||||||
|
|
||||||
@ -39,14 +48,13 @@ Can another engineer (or agent) understand this code without the author explaini
|
|||||||
|
|
||||||
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
|
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
|
||||||
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
|
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
|
||||||
- Is the code organized logically (related code grouped, clear module boundaries)?
|
|
||||||
- Are there any "clever" tricks that should be simplified?
|
- Are there any "clever" tricks that should be simplified?
|
||||||
- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)
|
- **KISS check:** Is this the simplest approach that solves the problem? A 20-line straightforward function beats a 5-line clever one that requires a comment to explain.
|
||||||
- **Are abstractions earning their complexity?** (Don't generalize until the third use case)
|
- Could this be done in fewer lines? (1000 lines where 100 suffice is a failure)
|
||||||
- Would comments help clarify non-obvious intent? (But don't comment obvious code.)
|
- Are abstractions earning their complexity? (Don't generalize until the third use case)
|
||||||
- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?
|
- Is a new conditional bolted onto an unrelated flow? Push the logic into its own helper, state, or policy.
|
||||||
- **Is a new conditional bolted onto an unrelated flow?** That's a design smell, not a nit — push the logic into its own helper, state, or policy instead of tangling an existing path.
|
- Do repeated conditionals on the same shape appear? They signal a missing model or dispatcher.
|
||||||
- **Do repeated conditionals on the same shape appear?** They signal a missing model or dispatcher. A "temporary" branch is usually permanent debt.
|
- Are there dead code artifacts: no-op variables, backwards-compat shims, or `// removed` comments?
|
||||||
|
|
||||||
### 3. Architecture
|
### 3. Architecture
|
||||||
|
|
||||||
@ -54,16 +62,17 @@ Does the change fit the system's design?
|
|||||||
|
|
||||||
- Does it follow existing patterns or introduce a new one? If new, is it justified?
|
- Does it follow existing patterns or introduce a new one? If new, is it justified?
|
||||||
- Does it maintain clean module boundaries?
|
- Does it maintain clean module boundaries?
|
||||||
- Is there code duplication that should be shared?
|
- **DRY check:** Is there existing code that does the same thing? Reuse the canonical helper instead of writing a near-duplicate. If two branches do nearly the same thing, collapse them.
|
||||||
- Are dependencies flowing in the right direction (no circular dependencies)?
|
- Are dependencies flowing in the right direction (no circular dependencies)?
|
||||||
- Is the abstraction level appropriate (not over-engineered, not too coupled)?
|
- Is the abstraction level appropriate (not over-engineered, not too coupled)?
|
||||||
- **Does this refactor reduce complexity or just relocate it?** Count the concepts a reader must hold to follow the change. If a "cleaner" version leaves that count unchanged, it isn't cleaner — prefer the restructuring that makes whole branches, modes, or layers disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
|
- Does this refactor reduce complexity or just relocate it? Count the concepts a reader must hold. Prefer the restructuring that makes whole branches disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
|
||||||
- **Is feature-specific logic leaking into a shared or general-purpose module?** Keep logic in its owning layer, reuse the existing canonical helper instead of a near-duplicate, and don't normalize architectural drift.
|
- Is feature-specific logic leaking into a shared or general-purpose module?
|
||||||
- **Are type boundaries explicit?** Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks that paper over an unclear invariant — making the boundary explicit often makes the surrounding control flow simpler.
|
- Are type boundaries explicit? Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks.
|
||||||
|
- **Structural remedies:** When you flag a problem, propose the move — not just the problem. Replace conditionals with dispatchers, collapse duplicate branches, separate orchestration from business logic, extract helpers, split large files. Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
|
||||||
|
|
||||||
### 4. Security
|
### 4. Security
|
||||||
|
|
||||||
For detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?
|
For detailed security guidance, see `security-and-hardening`.
|
||||||
|
|
||||||
- Is user input validated and sanitized?
|
- Is user input validated and sanitized?
|
||||||
- Are secrets kept out of code, logs, and version control?
|
- Are secrets kept out of code, logs, and version control?
|
||||||
@ -72,12 +81,9 @@ For detailed security guidance, see `security-and-hardening`. Does the change in
|
|||||||
- Are outputs encoded to prevent XSS?
|
- Are outputs encoded to prevent XSS?
|
||||||
- Are dependencies from trusted sources with no known vulnerabilities?
|
- Are dependencies from trusted sources with no known vulnerabilities?
|
||||||
- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?
|
- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?
|
||||||
- Are external data flows validated at system boundaries before use in logic or rendering?
|
|
||||||
|
|
||||||
### 5. Performance
|
### 5. Performance
|
||||||
|
|
||||||
Does the change introduce performance problems?
|
|
||||||
|
|
||||||
- Any N+1 query patterns?
|
- Any N+1 query patterns?
|
||||||
- Any unbounded loops or unconstrained data fetching?
|
- Any unbounded loops or unconstrained data fetching?
|
||||||
- Any synchronous operations that should be async?
|
- Any synchronous operations that should be async?
|
||||||
@ -85,24 +91,66 @@ Does the change introduce performance problems?
|
|||||||
- Any missing pagination on list endpoints?
|
- Any missing pagination on list endpoints?
|
||||||
- Any large objects created in hot paths?
|
- Any large objects created in hot paths?
|
||||||
|
|
||||||
## Structural Remedies
|
## Review Process
|
||||||
|
|
||||||
When you flag a structural problem, propose the move — not just the problem. A review that only says "this is complex" leaves the author guessing. Reach for a named restructuring:
|
1. **Understand the intent** — What is this change trying to accomplish? What spec or task does it implement?
|
||||||
|
2. **Review tests first** — Tests reveal intent and coverage. Do they test behavior, not implementation details? Are edge cases covered?
|
||||||
|
3. **Review the implementation** — Walk through each file with the five axes in mind.
|
||||||
|
4. **Categorize findings** — Label every comment with its severity:
|
||||||
|
|
||||||
- **Replace a chain of conditionals** with a typed model or an explicit dispatcher.
|
| Prefix | Meaning | Author Action |
|
||||||
- **Collapse duplicate branches** into a single clearer flow.
|
|--------|---------|---------------|
|
||||||
- **Separate orchestration from business logic** so each reads on its own.
|
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
|
||||||
- **Move feature-specific logic** out of a shared module into the package that owns the concept.
|
| **High:** | Required change | Must address before merge |
|
||||||
- **Reuse the canonical helper** instead of a bespoke near-duplicate.
|
| **Medium:** | Should fix | Strongly recommended, not a blocker |
|
||||||
- **Make a type boundary explicit** so downstream branching disappears.
|
| **Low:** | Minor, optional | Author may ignore — formatting, style preferences |
|
||||||
- **Delete a pass-through wrapper** that adds indirection without clarifying the API.
|
| **Suggestion:** | Worth considering | Not required, but improves the code |
|
||||||
- **Extract a helper, or split a large file** into focused modules.
|
|
||||||
|
|
||||||
Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
|
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
|
||||||
|
|
||||||
|
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
|
||||||
|
|
||||||
|
5. **Verify the verification** — What tests were run? Did the build pass? Was the change tested manually? Screenshots for UI changes?
|
||||||
|
|
||||||
|
## Review Output
|
||||||
|
|
||||||
|
Structure every review using this format:
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Briefly explain what the code does and give an overall assessment.
|
||||||
|
|
||||||
|
### Critical and High-Priority Issues
|
||||||
|
|
||||||
|
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
|
||||||
|
|
||||||
|
### Other Findings
|
||||||
|
|
||||||
|
List medium- and low-priority issues, including maintainability and design concerns.
|
||||||
|
|
||||||
|
### Suggested Refactoring
|
||||||
|
|
||||||
|
Provide focused code changes or revised snippets. Preserve existing behavior unless a behavior change is explicitly justified.
|
||||||
|
|
||||||
|
### Testing Recommendations
|
||||||
|
|
||||||
|
Identify missing tests and describe specific test cases, including edge cases and failure scenarios.
|
||||||
|
|
||||||
|
### Positive Observations
|
||||||
|
|
||||||
|
Mention implementation choices that are clear, safe, efficient, or well designed. This is not fluff — it reinforces good patterns and tells the author what to keep doing.
|
||||||
|
|
||||||
|
### Final Verdict
|
||||||
|
|
||||||
|
Choose one:
|
||||||
|
|
||||||
|
- **Approve** — Ready to merge
|
||||||
|
- **Approve with minor changes** — Good to merge after addressing low/medium issues
|
||||||
|
- **Request changes** — Critical or high issues must be resolved before merge
|
||||||
|
|
||||||
## Change Sizing
|
## Change Sizing
|
||||||
|
|
||||||
Small, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:
|
Small, focused changes are easier to review, faster to merge, and safer to deploy.
|
||||||
|
|
||||||
```
|
```
|
||||||
~100 lines changed → Good. Reviewable in one sitting.
|
~100 lines changed → Good. Reviewable in one sitting.
|
||||||
@ -110,11 +158,9 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
|
|||||||
~1000 lines changed → Too large. Split it.
|
~1000 lines changed → Too large. Split it.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add.
|
**Watch file size, not just diff size.** Around 1000 *total* lines in a single file is a common inspection signal. When a change materially grows an already-large file, decompose first.
|
||||||
|
|
||||||
**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.
|
**Splitting strategies:**
|
||||||
|
|
||||||
**Splitting strategies when a change is too large:**
|
|
||||||
|
|
||||||
| Strategy | How | When |
|
| Strategy | How | When |
|
||||||
|----------|-----|------|
|
|----------|-----|------|
|
||||||
@ -123,164 +169,17 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
|
|||||||
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
|
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
|
||||||
| **Vertical** | Break into smaller full-stack slices of the feature | Feature work |
|
| **Vertical** | Break into smaller full-stack slices of the feature | Feature work |
|
||||||
|
|
||||||
**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.
|
**Separate refactoring from feature work.** A change that refactors and adds new behavior is two changes — submit them separately.
|
||||||
|
|
||||||
**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.
|
|
||||||
|
|
||||||
## Change Descriptions
|
## Change Descriptions
|
||||||
|
|
||||||
Every change needs a description that stands alone in version control history.
|
- **First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC."
|
||||||
|
- **Body:** What is changing and why. Include context and reasoning not visible in the code itself.
|
||||||
|
- **Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Phase 1."
|
||||||
|
|
||||||
**First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC." Must be informative enough that someone searching history can understand the change without reading the diff.
|
## Dependencies
|
||||||
|
|
||||||
**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.
|
Before adding any dependency:
|
||||||
|
|
||||||
**Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Moving code from A to B," "Phase 1," "Add convenience functions."
|
|
||||||
|
|
||||||
## Review Process
|
|
||||||
|
|
||||||
### Step 1: Understand the Context
|
|
||||||
|
|
||||||
Before looking at code, understand the intent:
|
|
||||||
|
|
||||||
```
|
|
||||||
- What is this change trying to accomplish?
|
|
||||||
- What spec or task does it implement?
|
|
||||||
- What is the expected behavior change?
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Review the Tests First
|
|
||||||
|
|
||||||
Tests reveal intent and coverage:
|
|
||||||
|
|
||||||
```
|
|
||||||
- Do tests exist for the change?
|
|
||||||
- Do they test behavior (not implementation details)?
|
|
||||||
- Are edge cases covered?
|
|
||||||
- Do tests have descriptive names?
|
|
||||||
- Would the tests catch a regression if the code changed?
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Review the Implementation
|
|
||||||
|
|
||||||
Walk through the code with the five axes in mind:
|
|
||||||
|
|
||||||
```
|
|
||||||
For each file changed:
|
|
||||||
1. Correctness: Does this code do what the test says it should?
|
|
||||||
2. Readability: Can I understand this without help?
|
|
||||||
3. Architecture: Does this fit the system?
|
|
||||||
4. Security: Any vulnerabilities?
|
|
||||||
5. Performance: Any bottlenecks?
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Categorize Findings
|
|
||||||
|
|
||||||
Label every comment with its severity so the author knows what's required vs optional:
|
|
||||||
|
|
||||||
| Prefix | Meaning | Author Action |
|
|
||||||
|--------|---------|---------------|
|
|
||||||
| *(no prefix)* | Required change | Must address before merge |
|
|
||||||
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
|
|
||||||
| **Nit:** | Minor, optional | Author may ignore — formatting, style preferences |
|
|
||||||
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
|
|
||||||
| **FYI** | Informational only | No action needed — context for future reference |
|
|
||||||
|
|
||||||
This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.
|
|
||||||
|
|
||||||
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review.
|
|
||||||
|
|
||||||
### Step 5: Verify the Verification
|
|
||||||
|
|
||||||
Check the author's verification story:
|
|
||||||
|
|
||||||
```
|
|
||||||
- What tests were run?
|
|
||||||
- Did the build pass?
|
|
||||||
- Was the change tested manually?
|
|
||||||
- Are there screenshots for UI changes?
|
|
||||||
- Is there a before/after comparison?
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-Model Review Pattern
|
|
||||||
|
|
||||||
Use different models for different review perspectives:
|
|
||||||
|
|
||||||
```
|
|
||||||
Model A writes the code
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Model B reviews for correctness and architecture
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Model A addresses the feedback
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Human makes the final call
|
|
||||||
```
|
|
||||||
|
|
||||||
This catches issues that a single model might miss — different models have different blind spots.
|
|
||||||
|
|
||||||
**Example prompt for a review agent:**
|
|
||||||
```
|
|
||||||
Review this code change for correctness, security, and adherence to
|
|
||||||
our project conventions. The spec says [X]. The change should [Y].
|
|
||||||
Flag any issues as Critical, Required, Optional, or Nit.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dead Code Hygiene
|
|
||||||
|
|
||||||
After any refactoring or implementation change, check for orphaned code:
|
|
||||||
|
|
||||||
1. Identify code that is now unreachable or unused
|
|
||||||
2. List it explicitly
|
|
||||||
3. **Ask before deleting:** "Should I remove these now-unused elements: [list]?"
|
|
||||||
|
|
||||||
Don't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.
|
|
||||||
|
|
||||||
```
|
|
||||||
DEAD CODE IDENTIFIED:
|
|
||||||
- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()
|
|
||||||
- OldTaskCard component in src/components/ — replaced by TaskCard
|
|
||||||
- LEGACY_API_URL constant in src/config.ts — no remaining references
|
|
||||||
→ Safe to remove these?
|
|
||||||
```
|
|
||||||
|
|
||||||
## Review Speed
|
|
||||||
|
|
||||||
Slow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.
|
|
||||||
|
|
||||||
- **Respond within one business day** — this is the maximum, not the target
|
|
||||||
- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day
|
|
||||||
- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed
|
|
||||||
- **Large changes:** Ask the author to split them rather than reviewing one massive changeset
|
|
||||||
|
|
||||||
## Handling Disagreements
|
|
||||||
|
|
||||||
When resolving review disputes, apply this hierarchy:
|
|
||||||
|
|
||||||
1. **Technical facts and data** override opinions and preferences
|
|
||||||
2. **Style guides** are the absolute authority on style matters
|
|
||||||
3. **Software design** must be evaluated on engineering principles, not personal preference
|
|
||||||
4. **Codebase consistency** is acceptable if it doesn't degrade overall health
|
|
||||||
|
|
||||||
**Don't accept "I'll clean it up later."** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.
|
|
||||||
|
|
||||||
## Honesty in Review
|
|
||||||
|
|
||||||
When reviewing code — whether written by you, another agent, or a human:
|
|
||||||
|
|
||||||
- **Don't rubber-stamp.** "LGTM" without evidence of review helps no one.
|
|
||||||
- **Don't soften real issues.** "This might be a minor concern" when it's a bug that will hit production is dishonest.
|
|
||||||
- **Quantify problems when possible.** "This N+1 query will add ~50ms per item in the list" is better than "this could be slow."
|
|
||||||
- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.
|
|
||||||
- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.
|
|
||||||
|
|
||||||
## Dependency Discipline
|
|
||||||
|
|
||||||
Part of code review is dependency review:
|
|
||||||
|
|
||||||
**Before adding any dependency:**
|
|
||||||
|
|
||||||
1. Does the existing stack solve this? (Often it does.)
|
1. Does the existing stack solve this? (Often it does.)
|
||||||
2. How large is the dependency? (Check bundle impact.)
|
2. How large is the dependency? (Check bundle impact.)
|
||||||
@ -290,67 +189,14 @@ Part of code review is dependency review:
|
|||||||
|
|
||||||
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
|
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
|
||||||
|
|
||||||
**Upgrading an existing dependency** is a code change like any other, and the riskiest upgrades are the ones merged in bulk with a message like "bump deps." Review them with the same discipline:
|
**Upgrading dependencies:**
|
||||||
|
|
||||||
1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks.
|
- Read the changelog, not just the version number. Semver is a promise the maintainer may not have kept.
|
||||||
2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean.
|
- One dependency per change. When a bulk bump breaks the build, you've lost which package did it.
|
||||||
3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
|
- Let the tests decide — a green suite before *and* after, not just "it installed."
|
||||||
4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes.
|
- Review the lockfile diff, not just `package.json`. Commit it and never hand-edit it.
|
||||||
5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships.
|
|
||||||
|
|
||||||
For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict.
|
For supply-chain risk triage, follow the `security-and-hardening` skill.
|
||||||
|
|
||||||
## The Review Checklist
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Review: [PR/Change title]
|
|
||||||
|
|
||||||
### Context
|
|
||||||
- [ ] I understand what this change does and why
|
|
||||||
|
|
||||||
### Correctness
|
|
||||||
- [ ] Change matches spec/task requirements
|
|
||||||
- [ ] Edge cases handled
|
|
||||||
- [ ] Error paths handled
|
|
||||||
- [ ] Tests cover the change adequately
|
|
||||||
|
|
||||||
### Readability
|
|
||||||
- [ ] Names are clear and consistent
|
|
||||||
- [ ] Logic is straightforward
|
|
||||||
- [ ] No unnecessary complexity
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- [ ] Follows existing patterns
|
|
||||||
- [ ] No unnecessary coupling or dependencies
|
|
||||||
- [ ] Appropriate abstraction level
|
|
||||||
- [ ] Refactors reduce complexity rather than relocate it
|
|
||||||
- [ ] No feature logic in shared modules; file stays within a healthy size
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- [ ] No secrets in code
|
|
||||||
- [ ] Input validated at boundaries
|
|
||||||
- [ ] No injection vulnerabilities
|
|
||||||
- [ ] Auth checks in place
|
|
||||||
- [ ] External data sources treated as untrusted
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- [ ] No N+1 patterns
|
|
||||||
- [ ] No unbounded operations
|
|
||||||
- [ ] Pagination on list endpoints
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
- [ ] Tests pass
|
|
||||||
- [ ] Build succeeds
|
|
||||||
- [ ] Manual verification done (if applicable)
|
|
||||||
|
|
||||||
### Verdict
|
|
||||||
- [ ] **Approve** — Ready to merge
|
|
||||||
- [ ] **Request changes** — Issues must be addressed
|
|
||||||
```
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- For detailed security review guidance, see `security-and-hardening`
|
|
||||||
|
|
||||||
## Common Rationalizations
|
## Common Rationalizations
|
||||||
|
|
||||||
@ -358,13 +204,16 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
|
|||||||
|---|---|
|
|---|---|
|
||||||
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
|
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
|
||||||
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
|
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
|
||||||
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. |
|
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. |
|
||||||
| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. |
|
| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. |
|
||||||
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |
|
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture, security, or readability problems. |
|
||||||
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve — look for the version where branches disappear. |
|
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve. |
|
||||||
| "It's only a small addition to this file" | Small diffs still push files past a healthy size and bolt branches onto unrelated flows. Judge the resulting structure, not the diff size. |
|
| "It's only a small addition to this file" | Small diffs still push files past healthy size and bolt branches onto unrelated flows. |
|
||||||
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog; semver doesn't guarantee no breakage. |
|
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog. |
|
||||||
| "I'll upgrade everything in one PR to save time" | A bulk bump that breaks the build hides which package did it. One dependency per change keeps the cause and the revert clean. |
|
| "I'll upgrade everything in one PR" | A bulk bump hides which package broke the build. One per change. |
|
||||||
|
| "It's duplicated but it's only two places" | Two becomes three becomes five. Extract now, before the copies diverge. |
|
||||||
|
| "The abstraction is future-proof" | YAGNI. Delete speculative generality — generalize on the third occurrence, not the first. |
|
||||||
|
| "It's clever but efficient" | Cleverness is a readability tax. If it needs a comment to understand, simplify it. |
|
||||||
|
|
||||||
## Red Flags
|
## Red Flags
|
||||||
|
|
||||||
@ -374,14 +223,11 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
|
|||||||
- Security-sensitive changes without security-focused review
|
- Security-sensitive changes without security-focused review
|
||||||
- Large PRs that are "too big to review properly" (split them)
|
- Large PRs that are "too big to review properly" (split them)
|
||||||
- No regression tests with bug fix PRs
|
- No regression tests with bug fix PRs
|
||||||
- Review comments without severity labels — makes it unclear what's required vs optional
|
|
||||||
- Accepting "I'll fix it later" — it never happens
|
- Accepting "I'll fix it later" — it never happens
|
||||||
- A refactor that moves code around without reducing the number of concepts a reader must hold
|
- A refactor that moves code around without reducing the number of concepts a reader must hold
|
||||||
- A change that grows an already-large file instead of decomposing it
|
|
||||||
- New conditionals scattered into unrelated code paths (a missing abstraction)
|
- New conditionals scattered into unrelated code paths (a missing abstraction)
|
||||||
- A bespoke helper that duplicates an existing canonical one, or feature logic placed in a shared module
|
- A bespoke helper that duplicates an existing canonical one
|
||||||
- A bulk "bump dependencies" PR with no changelog review and no per-package isolation
|
- A bulk "bump dependencies" PR with no changelog review
|
||||||
- A lockfile change that's hand-edited, uncommitted, or merged without reviewing its diff
|
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@ -392,6 +238,18 @@ After review is complete:
|
|||||||
- [ ] Tests pass
|
- [ ] Tests pass
|
||||||
- [ ] Build succeeds
|
- [ ] Build succeeds
|
||||||
- [ ] The verification story is documented (what changed, how it was verified)
|
- [ ] The verification story is documented (what changed, how it was verified)
|
||||||
- [ ] Dependency upgrades were reviewed against their changelog, isolated per package, and verified by a green suite with the lockfile diff reviewed
|
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
|
||||||
|
|
||||||
**Presumptive blockers:** surface and propose the simpler design for each of these; escalate to Required only when the change actively makes structure worse: a refactor that relocates complexity instead of reducing it; a change that pushes a file past the size boundary with no decomposition; feature logic added to a shared module; a near-duplicate of an existing canonical helper; a silent fallback that hides an unclear invariant.
|
## Multi-Model Review Pattern
|
||||||
|
|
||||||
|
Use different models for different review perspectives:
|
||||||
|
|
||||||
|
```
|
||||||
|
Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
|
||||||
|
```
|
||||||
|
|
||||||
|
Different models have different blind spots.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- For detailed security review guidance, see `security-and-hardening`
|
||||||
|
|||||||
47
.opencode/skills/create-commit/SKILL.md
Normal file
47
.opencode/skills/create-commit/SKILL.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: create-commit
|
||||||
|
description: Stage, review, and commit files following Penpot commit conventions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: create-commit
|
||||||
|
|
||||||
|
Produce a git commit that follows Penpot's commit message conventions. This
|
||||||
|
skill owns the commit format, staging review, and safety checks — it does not
|
||||||
|
implement features or push.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- After code changes are complete and files need to be committed
|
||||||
|
- When delegated by a workflow step (e.g. implement-plan) to handle the commit
|
||||||
|
|
||||||
|
## Required Reading
|
||||||
|
|
||||||
|
Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
|
||||||
|
is the authoritative source for the commit message format, the emoji menu,
|
||||||
|
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Stage the files** specified by the calling context. Do not ask for
|
||||||
|
confirmation.
|
||||||
|
2. Run `git diff --staged` to review the content. If you see secrets (API keys,
|
||||||
|
tokens, passwords, private keys, `.env` values), debug prints, or anything
|
||||||
|
that does not match the stated intent, **STOP** and tell the user before
|
||||||
|
committing.
|
||||||
|
3. Draft the message following the format in the memory doc, wrapping the body
|
||||||
|
at 72 characters per line, and run:
|
||||||
|
```bash
|
||||||
|
git commit -m "<subject>" -m "<body>"
|
||||||
|
```
|
||||||
|
(or `git commit -F -` if the body has unusual characters).
|
||||||
|
4. The `AI-assisted-by` trailer value is provided by the calling context — use
|
||||||
|
it verbatim.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not push. Pushing is a separate workflow handled by the user.
|
||||||
|
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm`.
|
||||||
|
- Do not pass `--author`. Author identity comes from the local git config.
|
||||||
|
- Do not amend a commit you did not create in this session, unless explicitly asked.
|
||||||
|
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
|
||||||
|
- Do not add untracked files that were not created in this session.
|
||||||
57
.opencode/skills/testing/SKILL.md
Normal file
57
.opencode/skills/testing/SKILL.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: testing
|
||||||
|
description: Enforce TDD workflow and testing best practices for Penpot. Use when implementing features, fixing bugs, or modifying behavior. Reads testing memory for full guidance.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Skill
|
||||||
|
|
||||||
|
Enforces test-driven development and Penpot testing conventions.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Implementing new logic or behavior
|
||||||
|
- Fixing any bug (reproduction test required)
|
||||||
|
- Modifying existing functionality
|
||||||
|
- Adding edge case handling
|
||||||
|
|
||||||
|
**Skip:** Pure configuration changes, documentation updates, or static content with no behavioral impact.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
Follow TDD (Red → Green → Refactor) whenever practical:
|
||||||
|
|
||||||
|
1. **RED** — Write a failing test first
|
||||||
|
2. **GREEN** — Write minimal code to pass
|
||||||
|
3. **REFACTOR** — Clean up while tests stay green
|
||||||
|
|
||||||
|
For bug fixes, use the Prove-It Pattern: write a test that reproduces the bug, confirm it fails, implement the fix, confirm it passes.
|
||||||
|
|
||||||
|
## Required Reading
|
||||||
|
|
||||||
|
Before writing any test, read:
|
||||||
|
|
||||||
|
1. `.serena/memories/testing.md` — cross-cutting testing principles, TDD workflow, anti-patterns, execution discipline
|
||||||
|
2. Module-specific testing memory for the affected module:
|
||||||
|
- `mem:common/testing` — CLJC unit tests
|
||||||
|
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E
|
||||||
|
- `mem:backend/core` — JVM clojure.test conventions
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- Every behavior change needs a test
|
||||||
|
- Test state, not interactions
|
||||||
|
- DAMP over DRY — tests are specifications; duplication is OK if each test is self-contained and readable
|
||||||
|
- Prefer Real > Fake > Stub > Mock
|
||||||
|
- Arrange-Act-Assert structure
|
||||||
|
- One assertion per concept
|
||||||
|
- Never pipe test output to filters — redirect to file first
|
||||||
|
- Register new test files in the module's runner/entrypoint
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After completing implementation:
|
||||||
|
|
||||||
|
- [ ] Every new behavior has a test
|
||||||
|
- [ ] All tests pass for touched modules
|
||||||
|
- [ ] Bug fixes include a reproduction test
|
||||||
|
- [ ] Lint/formatter passes
|
||||||
@ -92,8 +92,8 @@ Fixtures can populate local data for manual testing/perf work. From the backend
|
|||||||
|
|
||||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
||||||
|
|
||||||
* **Linting:** `clj-kondo --lint ../common/src/ src/`.
|
* **Linting:** `pnpm run lint:clj`.
|
||||||
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
|
* **Formatting:** `pnpm run check-fmt:clj` to check, `pnpm run fmt:clj` to fix. After running `fmt:clj`, `check-fmt:clj` is redundant. Avoid unrelated whitespace diffs.
|
||||||
|
|
||||||
**Before linting:** if delimiter errors are suspected (after LLM edits), run
|
**Before linting:** if delimiter errors are suspected (after LLM edits), run
|
||||||
`scripts/paren-repair` on the affected files first. Delimiter errors produce
|
`scripts/paren-repair` on the affected files first. Delimiter errors produce
|
||||||
|
|||||||
@ -24,6 +24,12 @@ Variant masters are main instances and component roots. Their descendants may th
|
|||||||
|
|
||||||
Masters are not normally touched through `set-shape-attr`, but touched flags can appear on master shapes through cloning/duplication paths. `add-touched-from-ref-chain` in `app.common.logic.variants` unions touched flags from ancestors into the copy being processed, so upstream/master touched state can affect downstream switch behavior.
|
Masters are not normally touched through `set-shape-attr`, but touched flags can appear on master shapes through cloning/duplication paths. `add-touched-from-ref-chain` in `app.common.logic.variants` unions touched flags from ancestors into the copy being processed, so upstream/master touched state can affect downstream switch behavior.
|
||||||
|
|
||||||
|
## Swap slots and positional matching
|
||||||
|
|
||||||
|
- A swap slot (stored via `ctk/set-swap-slot`, a `:touched` group `swap-slot-<uuid>`) marks a copy sub-head that was SWAPPED to another component; `compare-children` then pairs it to the main child by slot instead of by `shape-ref`.
|
||||||
|
- Copy sub-heads without a slot are paired to main children by `shape-ref` (seek, not index). `find-near-match` (positional) is only a validator/repair heuristic; validity requires membership of the ref among the near-main parent's children, not index equality (`mem:common/file-change-validation-migration-subtleties`).
|
||||||
|
- Copy child ORDER converges to the main's via the async sync (`moved` branch of `compare-children`); local code must never reorder copy children directly (guards in `:mov-objects`/`:reorder-children`).
|
||||||
|
|
||||||
## Cloning paths
|
## Cloning paths
|
||||||
|
|
||||||
`make-component-instance` in `app.common.types.container` produces a clean component copy through `update-new-shape`, dissociating attrs such as `:touched`, `:variant-id`, and `:variant-name` on cloned shapes.
|
`make-component-instance` in `app.common.types.container` produces a clean component copy through `update-new-shape`, dissociating attrs such as `:touched`, `:variant-id`, and `:variant-name` on cloned shapes.
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
## Stable namespace map
|
## Stable namespace map
|
||||||
|
|
||||||
- `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities.
|
- `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities.
|
||||||
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.nitrate-permissions` contains shared fail-closed Nitrate organization/team permission rules.
|
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.organization` contains organization schemas, `apply-organization`, and fail-closed organization/team permission rules (`allowed?`, `can-send-invitations?`).
|
||||||
- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic.
|
- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic.
|
||||||
- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc.
|
- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc.
|
||||||
- `app.common.geom.*`: geometry helpers and transformations.
|
- `app.common.geom.*`: geometry helpers and transformations.
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
- `set-shape-attr` treats `:position-data` as derived and never touched. Geometry/content-path changes use approximate equality; geometry differences under about 1px can be ignored for touched purposes.
|
- `set-shape-attr` treats `:position-data` as derived and never touched. Geometry/content-path changes use approximate equality; geometry differences under about 1px can be ignored for touched purposes.
|
||||||
- Width/height are excluded from the `is-geometry?` branch in `set-shape-attr`; do not assume all geometry-group attrs follow identical ignore-geometry behavior.
|
- Width/height are excluded from the `is-geometry?` branch in `set-shape-attr`; do not assume all geometry-group attrs follow identical ignore-geometry behavior.
|
||||||
- `process-touched-change` marks the owning component modified when a touched shape belongs to a main instance; component-data changes can come from shape ops through this second pass.
|
- `process-touched-change` marks the owning component modified when a touched shape belongs to a main instance; component-data changes can come from shape ops through this second pass.
|
||||||
|
- Copy structure is guarded at change application: `:mov-objects` (`is-valid-move?`) and `:reorder-children` both refuse to alter children of shapes inside component copies unless the change carries `allow-altering-copies` (sync/swap flows set it). New structural change types must follow the same rule.
|
||||||
|
- `cls/generate-delete-shapes` propagates deletions from INSIDE a component main to the copy shapes referencing them (transitively, all pages of the file) so no dangling `shape-ref`s remain; skipped when the main root itself is deleted (copies then resolve into the deleted component) and for `allow-altering-copies` flows (swap replaces the shape; sync reconciles).
|
||||||
|
|
||||||
## Shape tree edits
|
## Shape tree edits
|
||||||
|
|
||||||
@ -19,6 +21,7 @@
|
|||||||
- Full referential/semantic validation currently runs only when file features contain `"components/v2"`.
|
- Full referential/semantic validation currently runs only when file features contain `"components/v2"`.
|
||||||
- Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details.
|
- Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details.
|
||||||
- `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes.
|
- `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes.
|
||||||
|
- `:missing-slot` fires only for a REAL swap: a copy sub-head whose `shape-ref` is no longer a child of the near main parent. A pure positional mismatch (ref still a sibling elsewhere) is a reorder — valid, realigned by the async component sync; do not "repair" it by assigning swap slots (a slot freezes the child out of normal sync). `fix-missing-swap-slots` (migration 0019) follows the same membership rule.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,9 @@
|
|||||||
## Grid assignment
|
## Grid assignment
|
||||||
|
|
||||||
- Grid `assign-cells` ensures at least one column and row, skips absolute-position children, creates non-tracked rows/cols when children exceed tracked cells, and asserts that assigned cells do not overlap.
|
- Grid `assign-cells` ensures at least one column and row, skips absolute-position children, creates non-tracked rows/cols when children exceed tracked cells, and asserts that assigned cells do not overlap.
|
||||||
|
- `position-absolute?` counts HIDDEN shapes as absolute: hiding a grid child frees its cell on the next `assign-cells`.
|
||||||
|
- `reorder-grid-children` rewrites the parent's `:shapes` to the REVERSE of the sorted cell order, but children with no cell (hidden/absolute) keep their original index — do not "fix" this into moving them to an end; that broke copy/main positional slot alignment (referential-integrity crash).
|
||||||
|
- The `:reorder-children` change it emits is refused on parents inside component copies unless `allow-altering-copies` (same rule as `:mov-objects`); `pcb/reorder-grid-children` also skips copy grids producer-side. Copy child order is owned by the component sync engine.
|
||||||
- Grid deassignment removes cells for shapes that are no longer direct children or have become absolute-positioned.
|
- Grid deassignment removes cells for shapes that are no longer direct children or have become absolute-positioned.
|
||||||
- Auto-positioning is not just sorting: some auto cells are converted to manual when empty/manual/span state would break the auto sequence, then auto single-span items can be compacted.
|
- Auto-positioning is not just sorting: some auto cells are converted to manual when empty/manual/span state would break the auto sequence, then auto single-span items can be compacted.
|
||||||
- `fix-overlaps` is marked dev-only and removes one overlapping cell, preferring empty cells first. Avoid depending on it as normal production repair.
|
- `fix-overlaps` is marked dev-only and removes one overlapping cell, preferring empty cells first. Avoid depending on it as normal production repair.
|
||||||
@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply
|
|||||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
||||||
- `library/`: design library workflows; core conventions: `mem:library/core`.
|
- `library/`: design library workflows; core conventions: `mem:library/core`.
|
||||||
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
|
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
|
||||||
|
- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`.
|
||||||
|
|
||||||
The memory is structured in a way that you can get the critical information about the
|
The memory is structured in a way that you can get the critical information about the
|
||||||
module. You can read it from `mem:<MODULE>/core`
|
module. You can read it from `mem:<MODULE>/core`
|
||||||
@ -52,7 +53,7 @@ module. You can read it from `mem:<MODULE>/core`
|
|||||||
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
|
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
|
||||||
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
|
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
|
||||||
|
|
||||||
# Dev tools
|
# Dev Scripts (scripts/)
|
||||||
|
|
||||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
|
- `scripts/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
|
||||||
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
|
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
|
||||||
|
|||||||
@ -25,7 +25,9 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
|
|||||||
|
|
||||||
## Worker policy
|
## Worker policy
|
||||||
|
|
||||||
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
||||||
|
|
||||||
|
Each workspace is independent and can be started/stopped in any order. Shared infra (postgres, minio, etc.) is shut down only when no instances remain running.
|
||||||
|
|
||||||
## Port layout
|
## Port layout
|
||||||
|
|
||||||
@ -63,8 +65,8 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
|
|||||||
|
|
||||||
## CLI surface
|
## CLI surface
|
||||||
|
|
||||||
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
|
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet).
|
||||||
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra.
|
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra.
|
||||||
- `run-devenv`: legacy alias, ws0 non-agentic attached.
|
- `run-devenv`: legacy alias, ws0 non-agentic attached.
|
||||||
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
|
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
|
||||||
- `run-devenv-shell [--instance 0|wsN|N] [cmd...]`: bash in target instance. (`--instance` flag not yet renamed to `--ws`.)
|
- `run-devenv-shell [--instance 0|wsN|N] [cmd...]`: bash in target instance. (`--instance` flag not yet renamed to `--ws`.)
|
||||||
|
|||||||
@ -1,316 +1,151 @@
|
|||||||
# Composable component tests
|
# Composable component tests
|
||||||
|
|
||||||
A framework for systematically testing Penpot's component subsystem (synchronisation/propagation,
|
A framework concept for systematically testing Penpot's component subsystem
|
||||||
swaps, variant switches, nesting), plus the suite of cases built on it. Lives entirely in the
|
(synchronisation/propagation, swaps, variant switches, nesting, overrides), implemented in TWO test
|
||||||
**frontend** test tree as `.cljs`; it is test-only code with a single consumer (the frontend test
|
suites that share the principles below:
|
||||||
suite, which runs the real app). There is nothing "common" about it — it is not under `app/common`.
|
|
||||||
|
|
||||||
## Core idea
|
1. **ClojureScript suite** — in the frontend test tree (`frontend/test/frontend_tests/
|
||||||
A test is a **composition of operations** over a **situation**, plus assertions. You describe a
|
composable_tests/`), driving a minimally-assembled real app headlessly. The original.
|
||||||
test as data (a setup + a sequence of operations) rather than writing bespoke imperative code, and
|
2. **TypeScript suite** — a Penpot plugin (`plugins/apps/composable-test-suite/`), driving the FULL
|
||||||
coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing pieces,
|
production app end-to-end through the Plugin API, with a slightly more elaborate set of
|
||||||
not a copied test. One written case stands for a whole matrix of concrete cases.
|
abstractions. Runs interactively (panel), remotely (Playwright), and headlessly in CI. Its
|
||||||
|
README is the authoritative operational reference.
|
||||||
|
|
||||||
- **Situation** — the in-memory Penpot file value, plus named **roles** (meaningful shapes, e.g.
|
## Shared core idea
|
||||||
`:main-instance`/`:copy-instance`), a `:vars` map (named non-shape values), and an ordered
|
A test is a **composition of operations** over a starting configuration, plus assertions. You
|
||||||
**applied-log** of what ran.
|
describe a test as data (a setup + a sequence of operations) rather than writing bespoke imperative
|
||||||
- **Operation** — a step, reified as a DATA record implementing `IOperation` (single method
|
code, and coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing
|
||||||
`apply-to`; `apply` collides with core). Operations are printable, navigable, and enumerable.
|
pieces, not a copied test. Choice points (one-of alternatives, optional steps) EXPAND the
|
||||||
Most transform the situation; some do not — hence the genus is "operation", not "transformation"
|
composition into a full sweep of variants — one written case stands for a whole matrix of concrete
|
||||||
(`Test` asserts and returns the situation unchanged; `Skip` is a no-op).
|
tests.
|
||||||
- **Assertions** — inline `Test` operations placed in the sequence (assert at intermediate points)
|
|
||||||
and/or a trailing asserter (a `situation -> any` lambda calling `t/is`). The runner makes NO
|
|
||||||
judgment; it applies operations and returns the situation. Only *retrieval* helpers live outside
|
|
||||||
the test (role accessors, `has-property-of`, `applied?`).
|
|
||||||
|
|
||||||
## Principles
|
## Shared principles
|
||||||
- **Operations are data with identity.** Each node gets a unique id at construction (`assign-id`),
|
- **Every producing object is the accessor interface to what it produces downstream.** An
|
||||||
records what it did under that id (`record-application`), and is interrogated by identity
|
operation — and related objects such as content-creation strategies — is not merely an action:
|
||||||
(`applied?`, `get-choice`). No flat keyword-tagged log.
|
the SAME object instance the case holds is the typed interface through which everything it
|
||||||
- **Drive the real production pipeline.** Every operation routes through the actual production
|
created or changed is later retrieved, checked, and asserted, parameterized by the situation. A
|
||||||
change functions (`generate-update-shapes`, `generate-component-swap`, `generate-reset-component`,
|
foundation operation exposes accessors for the participants it built; an edit operation exposes
|
||||||
`generate-sync-file-changes`, …) — never raw field writes, or propagation would have nothing to
|
its dual check (`assertHasChangedProperty` / `has-property-of`); a choice is recovered by asking
|
||||||
react to. The test exercises genuine Penpot logic, not a reimplementation.
|
the one-of object (`getChoice`/`get-choice`); "did this step run" is asked of the step
|
||||||
- **The frontend runs the real app.** Synchronous file-ops apply directly to the store; event-ops
|
(`wasApplied`/`applied?`). NEVER reach into a situation (or the document) for something an
|
||||||
dispatch the REAL workspace events and await settlement, so the production watcher's AUTOMATIC
|
upstream object produced — ask the producer. This is what keeps sweeps sound (object identity
|
||||||
propagation is what's under test. Observed semantics are genuine.
|
ties the question to the exact node that ran) and what keeps retrieval logic in exactly one
|
||||||
- **Roles resolve to ids at setup time.** The global label→id map (`thi`) is shared and
|
place. Particularly explicit in the TS OOP implementation, where these accessors are methods on
|
||||||
time-varying, so a role is captured as an id when the situation is built; resolving late (across
|
the operation/strategy classes; repeatedly violating it (reading the document directly,
|
||||||
enumerated variants) would be unsound. Absence throws a diagnostic (never silent nil).
|
duplicating retrieval) was the most common review correction while building the suites.
|
||||||
- **Targets resolve at apply-time and may be rebound.** A target is a `(situation -> id)` FUNCTION,
|
- **Operations are data with identity.** Each operation node has a unique id at construction and
|
||||||
else a currently-bound ROLE, else a LABEL (`target-shape-id`). So one operation targeting a role
|
records what it did under that id; interrogation is by identity. Bind an operation to a value
|
||||||
follows that role as state-building ops re-point it — which is what lets a single operation be
|
ONCE and reuse it in the composition and in every query about it.
|
||||||
swept across depth.
|
- **Drive the real production pipeline.** Operations route through genuine Penpot logic — real
|
||||||
- **Enumeration is authored, not exhaustive.** You compose only VALID cases (every `one-of` branch
|
change functions / real workspace events / the real Plugin API, never raw field writes — so the
|
||||||
must be valid against the setup), so outcomes are just pass / fail / error — no not-applicable
|
production watcher's AUTOMATIC propagation is what's under test.
|
||||||
cells. Adding a variation across a matrix is a one-expression edit.
|
- **Roles, not internals.** A starting configuration names its participants (roles). Role→id
|
||||||
|
capture happens when the configuration is built; operation TARGETS resolve at apply-time and may
|
||||||
|
be re-bound, so an operation targeting a role follows it as state-building ops re-point it —
|
||||||
|
which lets a single operation be swept across depth.
|
||||||
|
- **Enumeration is authored, not exhaustive.** Compose only VALID cases, so outcomes are just
|
||||||
|
pass / fail / error — no not-applicable cells.
|
||||||
- **Naming discipline.** Penpot domain nouns ("component", "variant") must not name framework
|
- **Naming discipline.** Penpot domain nouns ("component", "variant") must not name framework
|
||||||
abstractions (they collide with real domain concepts). Framework vocabulary is testing concepts
|
abstractions; an operation may name the domain ACTION it performs.
|
||||||
only; an operation may name the domain *action* it performs (`swap`, `propagate`).
|
- **Operator algebra** (same in both suites): sequence (cartesian product of the steps' variants),
|
||||||
|
one-of (union, choice recorded), optional(X) = one-of([X, skip]), inline assertion ops, trailing
|
||||||
|
asserters.
|
||||||
|
- **Case authoring:** a case carries a CamelCase identifier and a plain-terms description in three
|
||||||
|
parts — situation setup, actions/variations, asserted requirement.
|
||||||
|
|
||||||
## Composition operators
|
---
|
||||||
- `in-sequence` — ordered application, threads the situation; enumerates to the CARTESIAN PRODUCT
|
|
||||||
of its steps' variants. Operations do not commute, so order is explicit. The workhorse.
|
|
||||||
- `one-of` — exactly one branch; applying it throws (must be enumerated); enumerates to the UNION
|
|
||||||
of branches, each wrapped in a `RecordedChoice` so `get-choice` recovers which ran.
|
|
||||||
- `optional(X)` = `one-of([X, skip])` — sweeps "with and without X" (two variants). `skip` is the
|
|
||||||
identity operation. The workhorse for adding a state-building step as an axis over a case.
|
|
||||||
- `test-that [assert-fn]` — an inline `Test`: asserts at this point in the sequence, situation
|
|
||||||
unchanged. Lets checkpoints sit at intermediate steps. Engine stays clojure.test-free (it just
|
|
||||||
calls the supplied fn).
|
|
||||||
- `applied? [situation operation]` — whether that exact node ran (identity-based). Composes with
|
|
||||||
`optional`/`one-of` for free. REQUIREMENT: bind an operation to a value ONCE and reuse it (in the
|
|
||||||
composition AND any `Test` querying it), so the id you ask about is the id that ran.
|
|
||||||
|
|
||||||
## Structure (frontend/test/frontend_tests/composable_tests/)
|
# ClojureScript suite (frontend test tree)
|
||||||
Two boundaries: the domain-agnostic **engine** and the **comp** subject library (about components;
|
|
||||||
naming the domain is correct there).
|
|
||||||
|
|
||||||
- `core.cljs` (ns `frontend-tests.composable-tests.core`) — the engine, one file:
|
Test-only `.cljs` code in the frontend test tree (nothing "common" about it). A **situation** =
|
||||||
- situation: `make-situation`, `file`/`with-file`, `with-aux-files`/`aux-files` (carry extra
|
the in-memory file value + named roles + `:vars` + an ordered applied-log. Operations are records
|
||||||
files, e.g. a library for case H), the applied-log.
|
implementing `IOperation`/`apply-to` (`apply` collides with core). Assertions = inline `Test` ops
|
||||||
- identity & transcript: `assign-id`, `node-id`, `record-application` (also stores a `::kind`
|
and/or a trailing asserter; the runner makes no judgment. Failures carry `describe-applied` (the
|
||||||
from the record type), `node-data`, `describe-applied` (readable ordered transcript, attached
|
transcript), which is what makes a failing variant in a sweep identifiable.
|
||||||
to every failure so a failing variant in a sweep is identifiable).
|
|
||||||
- roles & lookup: `role-shape` (strict, by stored id), `has-role?`, `rebind-role`/`rebind-role-id`
|
|
||||||
(re-point a role — used by state-building ops), `target-shape-id` (fn | role | label),
|
|
||||||
`shape-by-id` (read a shape whose id is held in a `:vars` object), `resolve-shape`/`-id`.
|
|
||||||
- protocols: `IOperation`(`apply-to`); `IEnumerable`(`-enumerate`) + `enumerate`.
|
|
||||||
- operators: `Sequence`/`in-sequence`; `OneOf`/`one-of`/`RecordedChoice`/`get-choice`;
|
|
||||||
`Skip`/`skip`; `optional`; `Test`/`test-that`; `applied?`; vars `set-var`/`get-var`.
|
|
||||||
- runners (clojure.test-free): `run-variant`, `run-all` (enumerate → vector, `thi/reset-idmap!`
|
|
||||||
per variant).
|
|
||||||
- per-op-interpreter helpers: `sequence-ops` (flatten a concrete variant — flattens `Sequence`,
|
|
||||||
keeps `RecordedChoice` as a unit), `recorded-choice?`, `choice-of`, `choice-one-of-id`.
|
|
||||||
- `comp/setups.cljs` — setup fns returning a situation, + role accessors (`main-instance`/
|
|
||||||
`copy-instance`/`main-root`/`copy-root`, `copy-child` 1-based): `simple-component-with-copy`,
|
|
||||||
`simple-component-with-labeled-copy`, `component-with-many-children` (E, F),
|
|
||||||
`nested-component-with-copy`, `cross-file-component-with-copy` (H: main in a linked library, copy
|
|
||||||
in the consuming file; primary `:file` = consuming, aux = library), and `empty-situation` (empty
|
|
||||||
file, no roles — the `:setup` for the sweep cases, whose first operation is `create-component`).
|
|
||||||
- `comp/nodes.cljs` — the component OPERATIONS. General edit/structure ops:
|
|
||||||
`change-property [target property value]` (`:fills`/`:opacity`) with its dual `has-property-of`
|
|
||||||
(`IPropertyCheck`); `change-attr`/`has-attr?` are aliases. `add-child`/`remove-child`/`move-child`
|
|
||||||
(with `IStructuralCheck`). `sync-from-library` (H: `generate-sync-file-changes`, libraries =
|
|
||||||
primary + aux). `undo` (I; frontend `dwu/undo`). Plus the scenario building blocks below.
|
|
||||||
- `interpreter.cljs` (ns `frontend-tests.composable-tests.interpreter`) — runs a case against the
|
|
||||||
real frontend (see "Interpreter").
|
|
||||||
- `comp/sync_test.cljs` (ns `frontend-tests.composable-tests.comp.sync-test`) — the cases;
|
|
||||||
registered in `frontend_tests/runner.cljs`.
|
|
||||||
|
|
||||||
## Scenario object model (behind the sweep cases)
|
Layout: `core.cljs` (the domain-agnostic engine: situation, identity/transcript, roles/targets,
|
||||||
Scenario ops track one or more named component LINEAGES as OBJECTS under `:vars :components` (keyed
|
operators, runners), `comp/setups.cljs` (setups + role accessors), `comp/nodes.cljs` (the component
|
||||||
by name, e.g. "main"). Grouping a lineage's fields into one object lets several lineages coexist
|
operations and their check duals), `interpreter.cljs` (runs cases against the real frontend),
|
||||||
(a swap targets a DIFFERENT component) and makes each op "read object `name`, update, write back".
|
`comp/sync_test.cljs` (the cases; registered in `frontend_tests/runner.cljs`). Case letters B..N;
|
||||||
|
the sweeps (K: depth × edit-precedence; L: swaps; M: variant switches; N: rotated-instance
|
||||||
|
geometry, on the #10109 fix branch until merged) are the flagship pattern — read them before
|
||||||
|
writing a new sweep.
|
||||||
|
|
||||||
A lineage object has:
|
**Scenario lineage model** (behind the sweeps): scenario ops track named component lineages as
|
||||||
- `:main-component-id` — the component the next instantiate/nest uses (advances to the new OUTER
|
objects under `:vars`, each holding the FIXED deepest origin (`:remote-*`), the ADVANCING outer
|
||||||
component per `make-nested-component`).
|
main (`:main-*`), and per-nesting-level data whose `:nested-head` (the deepest instance at that
|
||||||
- `:remote-head`/`:remote-rect` — the FIXED deepest origin (the original component everything is
|
level, found by descending the `:shape-ref` chain — matching chain MEMBERSHIP, not terminus) is
|
||||||
derived from). For a plain lineage this is never re-pointed; a variant nesting DOES re-point it
|
the swap/switch target, anchored by its swap-stable parent. Nesting seeks the FIXED origin, not
|
||||||
(see below).
|
the advancing main — that is what makes each level's `:nested-head` land on the deepest instance.
|
||||||
- `:main-head`/`:main-rect` — the current outer main (advances per nesting).
|
A variant nesting re-points the lineage's remote to the chosen member. Construction lesson:
|
||||||
- `:nesting-count`; `:copies`, `:copy-head`/`:copy-rect` (from `instantiate-copy`).
|
cross-level propagation requires progressively NESTED levels (one variant + plain wraps); sibling
|
||||||
- `:nesting-data` — vector, one entry per level i: `{:main-head, :nested-head, :nested-rect,
|
nestings do not propagate between each other.
|
||||||
:nested-head-parent}`.
|
|
||||||
- `:nested-head` is the DEEPEST instance at level i — the descendant of the level's copy head
|
|
||||||
that corresponds to `:remote-head`, found by descending the `:shape-ref` chain (at level 0 the
|
|
||||||
inner copy itself; deeper, the corresponding shape nested within the outer wrapper). This is THE
|
|
||||||
SWAP / SWITCH TARGET; it carries a `:component-id`.
|
|
||||||
- `:nested-head-parent` is the SWAP-STABLE anchor: a swap/switch replaces the head in place but
|
|
||||||
keeps its parent, so assertions re-resolve parent → current head → rect.
|
|
||||||
|
|
||||||
Accessors / targets (in nodes): `lineage-component-id`, `lineage-rect`, `lineage-copy-rect`,
|
**Interpreter:** installs the situation's files into the global `st/state` (aux files tagged
|
||||||
`lineage-nesting`, `level-rect`/`level-rect-of` (level i's CURRENT rect via the parent anchor);
|
`:library-of`), starts the real `watch-component-changes` (+ harness `watch-undo-stack`), maps
|
||||||
target-fns `remote-rect-of`/`main-rect-of`/`copy-rect-of` and `nested-head-of` (return a
|
event-ops to REAL workspace events (`dwsh/update-shapes`, `dwl/component-swap`,
|
||||||
`(situation -> id)` for use as an operation target). "Corresponds to" across layers follows the
|
`dwv/variants-switch`, `dwt/increase-rotation` — which runs the `check-delta` placement
|
||||||
`:shape-ref` CHAIN, matching on chain MEMBERSHIP not terminus (a copy rect refs its near-main,
|
classification — `dwt/update-dimensions`, `dwu/undo`, `dwl/sync-file`, …) and runs sync-ops'
|
||||||
which carries a further `:shape-ref`, so the terminus over-walks). Single-file setups, so the chain
|
`apply-to` against the live store file; awaits settlement (idle-gap heuristic + per-op grace) and
|
||||||
resolves in the local page objects.
|
re-reads `:file` each step so the shared accessors keep working.
|
||||||
|
STORE-SWAP IMMUNITY: other test namespaces `set!` `st/state`/`st/stream` and never restore, while
|
||||||
|
the `app.main.refs` lenses stay bound to the ORIGINAL atoms — propagation then dies silently. The
|
||||||
|
interpreter captures the atoms at namespace-load time and re-`set!`s them per variant.
|
||||||
|
|
||||||
Scenario operations (each takes a lineage `name`):
|
Running: `cd frontend && pnpm run build:test`, then
|
||||||
- `create-component [name color]` — a component (frame + rect child of `color`); remote == main,
|
`node target/tests/test.js --focus frontend-tests.composable-tests.comp.sync-test`
|
||||||
count 0.
|
(var-level focus for one case).
|
||||||
- `make-nested-component [name]` — wrap `name`'s component in a NEW OUTER component whose main
|
|
||||||
contains a COPY of it inside a board (add-frame → instantiate inside → make-component); the OUTER
|
|
||||||
becomes `:main-component-id`; advance `:main-*`; append a `:nesting-data` entry; bump count.
|
|
||||||
ITERABLE: `×N` = board-within-board nesting with one rect at the bottom. Each level's
|
|
||||||
`:nested-head` is the deepest instance there, so swapping/switching it propagates (via the
|
|
||||||
watcher) outward to copies of it in OUTER levels.
|
|
||||||
- `instantiate-copy [name]` — instantiate `name`'s current component; track `:copy-head` and the
|
|
||||||
rect corresponding to its main rect as `:copy-rect`.
|
|
||||||
- `reset-copy-instance [name]` — reset overrides on `:copy-head` (production
|
|
||||||
`generate-reset-component`, `:validate? false` — a file-op on the frontend too: the real reset
|
|
||||||
event reads browser globals and cannot run headless).
|
|
||||||
- `swap-component [name level target & {:keys [keep-touched?]}]` — swap level `level`'s
|
|
||||||
`:nested-head` for lineage `target`'s component, via production `generate-component-swap`.
|
|
||||||
Frontend = the REAL `dwl/component-swap` event, so the watcher AUTOMATICALLY propagates the swap
|
|
||||||
to copies (incl. deeper levels). A swap replaces the head in place: Penpot keeps the head id,
|
|
||||||
rewrites it to the new component, stamps a `:swap-slot-<uuid>` touched group. `keep-touched?`
|
|
||||||
default false (discards overrides); true is the variant-switch flavour.
|
|
||||||
- Shared nesting helper `nest-in-new-outer-component [situation name op seek-rect-id seek-head-id
|
|
||||||
instantiate-inner-fn]` — the contain-outward mechanism behind BOTH nesting ops. Adds the outer
|
|
||||||
frame, calls `instantiate-inner-fn` to place the inner instance, makes the outer a component,
|
|
||||||
then computes this level's `:nested-rect`/`:nested-head` as the IMAGES (inside the new inner copy)
|
|
||||||
of `seek-rect-id`/`seek-head-id` — the FIXED deepest origin — and does the bookkeeping. A flavour
|
|
||||||
supplies only the two origin ids + the instantiate fn. Seeking the FIXED origin (not the advancing
|
|
||||||
`:main-*`) is what makes `:nested-head` land on the deepest instance at every level.
|
|
||||||
`self-or-descendant-corresponding-to` is the chain-descent that also matches the head itself
|
|
||||||
(needed at level 0, where the inner copy head IS the origin's image).
|
|
||||||
|
|
||||||
## Variant operations
|
**Fidelity warning:** the harness drives a MINIMALLY-ASSEMBLED app — only some
|
||||||
A variant switch IS a keep-touched swap whose target is resolved by a property VALUE (the
|
`initialize-workspace` subscriptions are wired. Risk = SILENT UNDER-WIRING (e.g. undo needs the
|
||||||
production `variants-switch`/`variant-switch` reduces to `component-swap … keep-touched? true`), so
|
harness `watch-undo-stack`). When a case needs app behaviour beyond a raw edit, check for an
|
||||||
it routes through the SAME `generate-component-swap` and the watcher auto-propagates it across
|
unwired subscription and verify by PROBING store state, not by trusting a green assertion.
|
||||||
nesting levels exactly like a swap.
|
|
||||||
- `make-variant-container [name members]` (sync-op) — build a variant SET synchronously, mirroring
|
|
||||||
the test-helpers' `add-variant` idiom: a container frame (`:is-variant-container`), each `members`
|
|
||||||
entry `[value color]` becoming a member component whose ROOT is a child of the container carrying
|
|
||||||
the shared `:variant-id`/`:variant-name`, then `update-component` stamps `:variant-id` +
|
|
||||||
`:variant-properties [{:name "Property 1" :value value}]` on the component. Read the container id
|
|
||||||
via `(thi/id container-label)` only AFTER adding the container frame (`thi/id` returns nil before
|
|
||||||
the shape exists). Records the set in `:vars` (`variant-set`/`variant-member`/
|
|
||||||
`variant-member-component-id` read it back).
|
|
||||||
- `make-nested-component-with-variant [name set-name value]` (sync-op) — nest a chosen member via
|
|
||||||
the shared nesting helper, AND re-point the lineage's `:remote-head`/`:remote-rect` to that
|
|
||||||
member's root/rect: nesting a variant makes the member the new deepest origin, so subsequent plain
|
|
||||||
`make-nested-component` descends to the variant's image (its `:nested-head`) at every level.
|
|
||||||
- `switch-variant [target value]` (frontend event) — switch the variant copy head bound to `target`
|
|
||||||
to the sibling member with property value `value`, via the REAL `dwv/variants-switch` event (which
|
|
||||||
DISCOVERS the sibling in the container via `find-variant-components`). `target` uses the standard
|
|
||||||
resolution (role | label | fn), so the op knows nothing about nesting; cases supply
|
|
||||||
`nested-head-of name i`.
|
|
||||||
|
|
||||||
## Interpreter (interpreter.cljs)
|
**Caveats:** inline `Test` exceptions are UNCAUGHT on the frontend (crash the runner — assert in
|
||||||
Drives the real app:
|
the trailing asserter). `(optional (in-sequence …))` is not flattened for the interpreter — use
|
||||||
- `op->events [op situation]` maps each event-dispatching operation to its real workspace event(s):
|
independent optionals. The Serena/clj-kondo cache for `nodes.cljs` goes stale (phantom symbols) —
|
||||||
`ChangeProperty`→`dwsh/update-shapes` (the SHARED `set-property`, target via `target-shape-id`);
|
trust the build. Cross-namespace global-state leaks land in this suite first; suspect them before
|
||||||
`MoveChild`→`dwsh/relocate-shapes`; `RemoveChild`→`dwsh/delete-shapes`; `AddChild`→`dwsh/add-shape`;
|
the framework on inexplicable full-run-only failures. Case H's `sync-file` schedules a delayed RPC
|
||||||
`SyncFromLibrary`→`dwl/sync-file`; `Undo`→`dwu/undo`; `SwapComponent`→`dwl/component-swap`;
|
that fails headless (benign; absorbed by per-op grace).
|
||||||
`SwitchVariant`→`dwv/variants-switch {:shapes [head] :pos 0 :val value}`. All real events, so the
|
|
||||||
watcher auto-propagates.
|
|
||||||
- `sync-op?` ops (`MakeNestedComponent`, `CreateComponent`, `InstantiateCopy`, `ResetCopyInstance`,
|
|
||||||
`MakeVariantContainer`, `MakeNestedComponentWithVariant`, `Skip`, `Test`) are NOT dispatched as
|
|
||||||
events: `run-sync-op` runs the shared `apply-to` against a situation whose `:file` is the live
|
|
||||||
store file, then writes back synchronously. The property under test is still exercised by the
|
|
||||||
subsequent real-event ops + the watcher.
|
|
||||||
- It installs the situation's files into the global `st/state` store (primary as current; aux files
|
|
||||||
tagged `:library-of` the current file so the library-sync machinery treats them as linked), starts
|
|
||||||
the real `watch-component-changes` and the harness `watch-undo-stack`, then folds the operations:
|
|
||||||
dispatch events → await settlement → re-read the current file into `:file` → record. Re-reading
|
|
||||||
`:file` each step is why the shared role accessors keep working.
|
|
||||||
- `watch-undo-stack` mirrors the production undo-append subscription from `initialize-workspace`
|
|
||||||
(which the harness does not run); without it `dwu/undo` has an empty stack.
|
|
||||||
- Settlement (`await-settle`): subscribe to the commit stream, resolve on the first 60ms idle gap
|
|
||||||
after a commit (captures the edit commit and the watcher's follow-up sync commit), 2000ms timeout.
|
|
||||||
Debounce-based, not a deterministic per-op stopper. After settling, `op-grace-ms` adds a per-op
|
|
||||||
grace wait (currently only `SyncFromLibrary`, ~3.2s — see the Running note).
|
|
||||||
- Thumbnail rendering is stubbed for this suite (`install-thumbnail-noop!` no-ops
|
|
||||||
`dwth/update-thumbnail`): the propagation watcher schedules thumbnail renders that reach `window`,
|
|
||||||
absent headless.
|
|
||||||
- `check [done case-map asserter]` enumerates, runs each variant via the async fold, wraps the
|
|
||||||
asserter in `describe-applied`, and calls `done`. Per-variant isolation = id-map reset + global
|
|
||||||
state re-install (the global `st/state` is a shared `defonce`).
|
|
||||||
- STORE-SWAP IMMUNITY (`original-store`/`restore-global-store!`): many plugins-suite namespaces
|
|
||||||
`set!` `st/state`/`st/stream` to isolated stores and never restore them. The `app.main.refs`
|
|
||||||
lenses (through which the watcher observes commits) are okulary lenses bound to the ORIGINAL
|
|
||||||
atom instance at load time — after such a swap, events commit to a store the watcher cannot see
|
|
||||||
and ALL propagation dies silently (assertions see base/unsynced state; no error). The interpreter
|
|
||||||
therefore captures `st/state`/`st/stream` at namespace-LOAD time (before any test runs) and
|
|
||||||
re-`set!`s them at the start of every variant, making the harness immune to run order. Diagnosed
|
|
||||||
by bisecting the runner's deterministic execution order (it is NOT the `test-namespaces` vector
|
|
||||||
order: `t/test-vars-block` groups vars by namespace, and the group-by hash order decides — same
|
|
||||||
order locally and in CI).
|
|
||||||
|
|
||||||
## Cases
|
---
|
||||||
Asserters are inline; no `doseq`, no count assertions.
|
|
||||||
- **B** — an override on the copy survives a later main change (override present + `:touched`
|
|
||||||
contains the fill group + `:shape-ref` present).
|
|
||||||
- **C** — attribute sweep via `one-of {fills, opacity}`: assert `(has-attr? (get-choice …) copy)`
|
|
||||||
per enumerated variant.
|
|
||||||
- **D** — `add-child` to the main gives the copy a ref-integral child (`is-main-of?` +
|
|
||||||
`parent-of?` + untouched).
|
|
||||||
- **E** — `remove-child` of the MIDDLE of three: survivors keep order `[child1, child3]`,
|
|
||||||
`:shape-ref` intact, untouched (middle removal is where index maintenance is tested).
|
|
||||||
- **F** — `move-child` of child1 to index 2: copy mirrors `[child2, child1, child3]`, identity
|
|
||||||
preserved.
|
|
||||||
- **H** — locality (cross-file): main in a linked library, copy in the consuming (current) file,
|
|
||||||
library main diverged. The in-file watcher does not cross a library boundary; the cross-file
|
|
||||||
mechanism is the library-update action (`sync-from-library` → `sync-file file-id library-id`).
|
|
||||||
Setup order matters: instantiate the copy first (captures the old value), diverge the library main
|
|
||||||
after.
|
|
||||||
- **I** — undo: an edit then `undo`; copy and main return to baseline, copy untouched. A single
|
|
||||||
`dwu/undo` reverses the whole logical action — the edit AND its auto-propagation — because the two
|
|
||||||
commits share an undo-group.
|
|
||||||
- **K** — SYNC-SCENARIO SWEEP (the flagship). On `empty-situation`: `create-component` → two
|
|
||||||
`(optional make-nested-component)` (depths 0/1/2) → `instantiate-copy` → three `(optional change-*)`
|
|
||||||
over remote/main/copy → INLINE checkpoints: (1) override-precedence at the copy (copy wins, else
|
|
||||||
main, else remote — branch via `applied?`), (2) force a copy override and confirm it wins, (3)
|
|
||||||
after `reset-copy-instance`, copy reverts to main's value if main changed, else remote's. No
|
|
||||||
explicit propagate (the watcher auto-propagates at all depths, incl. chained remote→deep-copy).
|
|
||||||
- **L** — SWAP SWEEP. On `empty-situation`: `create-component` (base) + one swap-target lineage per
|
|
||||||
level → three `make-nested-component` → three `(optional (swap-component "main" i target_i))` →
|
|
||||||
one `Test` asserting each level's colour. A swap at level i auto-propagates to level i and every
|
|
||||||
OUTER level until a higher swap overrides; colour at level i = applied swap at highest j<=i, else
|
|
||||||
base.
|
|
||||||
- **M** — VARIANT-SWITCH SWEEP (case L with a variant switch instead of the plain swap, driving the
|
|
||||||
REAL variant-switch machinery). On `empty-situation`: `create-component` (base lineage "main") +
|
|
||||||
`make-variant-container` (4 peer members `v0..v3`) → ONE `make-nested-component-with-variant
|
|
||||||
"main" "vset" "v0"` (introduce the variant innermost) + two plain `make-nested-component`
|
|
||||||
(progressive wraps, so each outer level CONTAINS the one below) → three
|
|
||||||
`(optional (switch-variant (nested-head-of "main" i) v_{i+1}))` → one `Test` with case L's exact
|
|
||||||
precedence asserter. Because the single variant instance has a switchable `:nested-head` at EVERY
|
|
||||||
level and the levels are progressively nested, a switch at level i propagates outward like a swap.
|
|
||||||
NOTE on structure: ONE variant + plain wraps is required for cross-level propagation (the levels
|
|
||||||
nest WITHIN each other). Three independent `make-nested-component-with-variant` would nest SIBLING
|
|
||||||
variants (none a descendant of another), so switches would not propagate between them — the right
|
|
||||||
construction for a different test (one asserting switches DON'T cross unrelated instances).
|
|
||||||
|
|
||||||
Running: `cd frontend && pnpm run build:test` then `node target/tests/test.js --focus
|
# TypeScript suite (the plugin) — full e2e
|
||||||
frontend-tests.composable-tests.comp.sync-test`. To run one case, use var-level focus, e.g.
|
|
||||||
`…/case-m-variant-switch-scenarios`. NOTE: the production `sync-file` event (case H) additionally
|
|
||||||
schedules a 3s-delayed `update-file-library-sync-status` RPC, which fails headless (no backend; a
|
|
||||||
swallowed URL-parse trace is benign). The interpreter absorbs it: `op-grace-ms` makes the run wait
|
|
||||||
~3.2s after a `SyncFromLibrary` settles, so the failure lands inside case H instead of leaking into
|
|
||||||
(and potentially destabilising) whichever test runs next.
|
|
||||||
|
|
||||||
## Frontend fidelity — read before extending
|
`plugins/apps/composable-test-suite/` — same principles against the FULL production app through the
|
||||||
The frontend runs the REAL production logic from the dispatched event onward, so observed semantics
|
Plugin API (real frontend, real propagation). Continuation of the CLJS suite per issue #10584.
|
||||||
are genuine. But it drives a MINIMALLY-ASSEMBLED app: it installs a file into the global store and
|
Operational details (build/run, connect URL, remote control, reading logs, auto-reload, CI): the
|
||||||
starts only the watchers known to be needed. The real app assembles its workspace via
|
plugin README.
|
||||||
`initialize-workspace`, which wires many subscriptions; the harness reproduces only some.
|
|
||||||
|
|
||||||
The risk is SILENT UNDER-WIRING — a behaviour that works in the real app can be silently absent in
|
Distinguishing abstractions (the OOP articulation of the shared principles):
|
||||||
the harness with no error (e.g. the undo stack is empty unless `watch-undo-stack` is started).
|
- `TestCase {identifier, description, operation}` with the three-part description mandated in the
|
||||||
Therefore: when adding a case needing app behaviour beyond a raw edit (undo, persistence, selection,
|
constructor docstring.
|
||||||
layout, thumbnails, library auto-detection), first check whether that behaviour lives in an
|
- The accessor-interface principle is class-level: foundation operations (e.g.
|
||||||
`initialize-workspace` subscription the harness has not wired — and verify by PROBING store state,
|
`OpCreateSimpleComponentWithCopy`) expose the roles they build; **content-creation strategies**
|
||||||
not by trusting a green assertion. The harness hand-wires two stand-ins (`install-file-event`,
|
(pluggable: what content a foundation builds around) expose accessors for the content they
|
||||||
`watch-undo-stack`); track them for drift. A durable fix would be to drive the real
|
created; edit operations expose their checks (`OpChangeProperty.assertHasChangedProperty`);
|
||||||
`initialize-workspace` headlessly (not done — full init may pull in machinery that doesn't run
|
`OpOneOf`/`OpOptional` are queried for what ran. Tests never grope the document for something a
|
||||||
cleanly headless).
|
producer can be asked for.
|
||||||
|
- `ShapeProp` model: property duals with numeric tolerance; rotation is a writable attr, height
|
||||||
|
goes via resize (readonly in the Plugin API).
|
||||||
|
- `TestSuite` enumerates cases into a `TestTree` with stable per-test ids;
|
||||||
|
`run(ids, TestRunObserver)` is the ONLY output channel — the framework is UI-free by
|
||||||
|
construction. `plugin.ts` (panel adapter), `main.ts` (panel UI) and `src/ci/headless.ts`
|
||||||
|
(CI adapter) are three thin consumers.
|
||||||
|
- Cases live in `src/composable-tests/cases/` as `case<Identifier>.ts` (e.g. `MainEditSyncs` — the
|
||||||
|
sweep that found #10109).
|
||||||
|
- Panel checkboxes carry stable DOM ids (case identifier / `Identifier-N` composites) for remote
|
||||||
|
control via Playwright; recipe in the README.
|
||||||
|
|
||||||
## Other caveats
|
## CI
|
||||||
- Cross-namespace leaks land in this suite first because it (correctly) uses the real global
|
Headless per-PR gate: `.github/workflows/tests-composable-suite.yml` runs
|
||||||
store: besides the store swap above, `frontend-tests.helpers.wasm/teardown-wasm-mocks!` used to
|
`pnpm --filter composable-test-suite run test:ci` — mocked backend (frontend e2e static server +
|
||||||
`set!` every WASM fn to nil when run against an empty snapshot (double teardown / async misuse of
|
Playwright RPC fixtures, no backend/login), the in-sandbox bundle injected via `ɵloadPlugin`,
|
||||||
`with-wasm-mocks*`), which a leaked debounced `resize-wasm-text` event then tripped over during
|
results streamed via console markers, `TEST_FILTER` by identifier substring. The mocked backend is
|
||||||
our cases (a "Store error: initialized? is not a function"). The teardown is now guarded
|
NOT a limitation for this suite (everything asserted is frontend store logic; empirically
|
||||||
(no-op on empty snapshot). If a new inexplicable full-run-only failure appears here, suspect
|
confirmed against the interactive runs). Architecture mirrors `plugin-api-test-suite`'s CI driver;
|
||||||
leaked global state from a preceding namespace before suspecting the framework.
|
the mock harness exists in THREE places that must stay in sync (provenance note in `ci/run-ci.ts`).
|
||||||
- INLINE `Test` exceptions on the frontend are UNCAUGHT (they run during the async fold, not under
|
Details: README, "Running in CI".
|
||||||
`check`'s try): a throwing checkpoint crashes the whole runner rather than failing one test.
|
|
||||||
- A `RecordedChoice` wrapping a `Sequence` (i.e. `(optional (in-sequence […]))`) is NOT flattened by
|
|
||||||
`sequence-ops`, so `op->events` chokes on the `Sequence`. Use independent optionals instead (as
|
|
||||||
case K does with two `(optional make-nested-component)`), or have the interpreter recurse into a
|
|
||||||
choice's composite alternative.
|
|
||||||
- The Serena symbol index / clj-kondo cache for `nodes.cljs` can go STALE and report PHANTOM symbols
|
|
||||||
or spurious "unresolved symbol" errors against code that compiles — TRUST THE BUILD (a real
|
|
||||||
`pnpm run build:test`), not the lint or the symbol overview, for this file.
|
|
||||||
- Label-after-the-fact resolution: `add-child`'s `added-shape` resolves `:new-label` via `thi/id`,
|
|
||||||
relying on the global label map still reflecting that run's setup — unsound if a structural node
|
|
||||||
is swept via `one-of`. Fix when needed by capturing the created shape's id at apply-time (as roles
|
|
||||||
already do).
|
|
||||||
|
|
||||||
## Substrate
|
## Substrate
|
||||||
`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`,
|
`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ From `frontend/`:
|
|||||||
- JS lint currently no-ops via `pnpm run lint:js`.
|
- JS lint currently no-ops via `pnpm run lint:js`.
|
||||||
- SCSS lint: `pnpm run lint:scss`.
|
- SCSS lint: `pnpm run lint:scss`.
|
||||||
- Format checks: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss`.
|
- Format checks: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss`.
|
||||||
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`.
|
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. After running `fmt:*`, `check-fmt:*` is redundant.
|
||||||
- Translation formatting after i18n edits: `pnpm run translations`.
|
- Translation formatting after i18n edits: `pnpm run translations`.
|
||||||
|
|
||||||
**Before linting:** if delimiter errors are suspected (after LLM edits, or
|
**Before linting:** if delimiter errors are suspected (after LLM edits, or
|
||||||
|
|||||||
100
.serena/memories/media-processor/core.md
Normal file
100
.serena/memories/media-processor/core.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Media Processor
|
||||||
|
|
||||||
|
Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Language: TypeScript
|
||||||
|
- Runtime: Node.js
|
||||||
|
- Framework: Express
|
||||||
|
- Image processing: sharp (libvips)
|
||||||
|
- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress
|
||||||
|
- Upload handling: multer (hybrid storage: memory for small, disk for large)
|
||||||
|
- Logging: pino (with optional Loki transport)
|
||||||
|
- Config validation: Zod
|
||||||
|
- Testing: Vitest
|
||||||
|
- Package Manager: pnpm
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
media-processor/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Express app setup, routes, middleware
|
||||||
|
│ ├── config.ts # Zod-validated env config, HKDF key derivation
|
||||||
|
│ ├── types.ts # TypeScript type definitions
|
||||||
|
│ ├── upload.ts # Multer configuration, getFileBuffer helper
|
||||||
|
│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold)
|
||||||
|
│ ├── logger.ts # Pino logger setup
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.ts # Timing-safe shared key authentication
|
||||||
|
│ │ ├── error-handler.ts # ProcessingError class, centralized error handling
|
||||||
|
│ │ └── timeout.ts # Request timeout middleware
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── health.ts # GET /api/health
|
||||||
|
│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail
|
||||||
|
│ │ └── font.ts # POST /api/font/convert
|
||||||
|
│ └── services/
|
||||||
|
│ ├── image.ts # sharp-based image info/thumbnail generation
|
||||||
|
│ ├── font.ts # FontForge/woff-tools font conversion
|
||||||
|
│ └── errors.ts # throwValidation, throwRestriction, throwProcessing
|
||||||
|
├── test/ # Vitest test files
|
||||||
|
├── vitest.config.ts # Test configuration
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
├── esbuild.config.mjs # Build configuration
|
||||||
|
└── package.json # Dependencies and scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- Requests authenticated via `x-shared-key` header using timing-safe comparison
|
||||||
|
- When no key configured, all requests rejected with 403
|
||||||
|
- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY`
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
- Image: max pixels, max width/height enforced before processing
|
||||||
|
- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits
|
||||||
|
- Concurrency: p-queue limits concurrent requests (default 10)
|
||||||
|
- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD`
|
||||||
|
- Max file size: configurable (default 350MB)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- `throwValidation(code, hint)` — 400 errors for invalid input
|
||||||
|
- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded
|
||||||
|
- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills)
|
||||||
|
|
||||||
|
### Image Processing
|
||||||
|
- EXIF orientation applied before dimension validation and thumbnail generation
|
||||||
|
- sharp caching disabled to prevent unbounded memory growth
|
||||||
|
- `withoutEnlargement: true` prevents upscaling small images
|
||||||
|
|
||||||
|
### Font Conversion
|
||||||
|
- Supported formats: TTF, OTF, WOFF, WOFF2
|
||||||
|
- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF)
|
||||||
|
- Temp files cleaned up in finally blocks (best-effort)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
All commands run from `media-processor/` directory:
|
||||||
|
|
||||||
|
- `pnpm run test` — Run Vitest test suite
|
||||||
|
- `pnpm run types:check` — TypeScript type checking (tsc --noEmit)
|
||||||
|
- `pnpm run fmt` — Format code with Prettier
|
||||||
|
- `pnpm run fmt:check` — Check formatting without modifying
|
||||||
|
- `pnpm run build` — Build for production (esbuild)
|
||||||
|
- `pnpm run start:dev` — Start development server (tsx)
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`)
|
||||||
|
- Must be deployed on internal Docker network only (not public-facing)
|
||||||
|
- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI`
|
||||||
|
|
||||||
|
## Testing Principles
|
||||||
|
|
||||||
|
Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||||
|
|
||||||
|
- Run `pnpm run test` after changes
|
||||||
|
- Run `pnpm run types:check` after TypeScript changes
|
||||||
|
- Run `pnpm run fmt:check` before commits
|
||||||
289
.serena/memories/scripts/error-reports.md
Normal file
289
.serena/memories/scripts/error-reports.md
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
# Error Reports CLI Tool
|
||||||
|
|
||||||
|
`scripts/error-reports.mjs` is a Node.js CLI tool for querying Penpot error reports via the RPC API. Provides access to error logs with filtering, pagination, and multiple output formats.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Querying error reports from the database for debugging or analysis
|
||||||
|
- Filtering errors by source, kind, tenant, or backend version
|
||||||
|
- Exporting error data in JSON, NDJSON, or table format
|
||||||
|
- Computing error statistics (top signatures, version, source, audit-log kind, hourly distribution, bursts, heatmap)
|
||||||
|
- Investigating specific error reports by ID
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js with `commander` and `dotenv` packages installed (in root `package.json`)
|
||||||
|
- Running Penpot backend with error-reports RPC endpoints
|
||||||
|
- Access token with `error-reports:read` permission
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PENPOT_API_URI=http://localhost:3450
|
||||||
|
PENPOT_ACCESS_TOKEN=<your-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Grant the required permission to your access token:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE access_token
|
||||||
|
SET perms = ARRAY['error-reports:read']::text[],
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = '<token-uuid>';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
#### `list` - List error reports with pagination and filters
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Flag | Description | Default |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `-l, --limit <n>` | Max items per page (max: 200) | `50` |
|
||||||
|
| `--from <date>` | ISO timestamp — oldest boundary (items after this) | — |
|
||||||
|
| `--to <date>` | ISO timestamp — newest boundary (items before this) | — |
|
||||||
|
| `--since <date>` | ISO timestamp — explicit cursor for manual pagination | — |
|
||||||
|
| `--since-id <uuid>` | Fetch errors after this ID (cursor pagination) | — |
|
||||||
|
| `-s, --source <name>` | Filter by source (see source names below) | — |
|
||||||
|
| `-p, --profile-id <uuid>` | Filter by profile ID | — |
|
||||||
|
| `-k, --kind <kind>` | Filter by kind (string) | — |
|
||||||
|
| `-t, --tenant <tenant>` | Filter by tenant (string) | — |
|
||||||
|
| `--version <version>` | Filter by version | — |
|
||||||
|
| `--hint <text>` | Filter by hint (ILIKE match) | — |
|
||||||
|
| `-a, --all` | Fetch all pages automatically (streams output) | `false` |
|
||||||
|
| `-f, --format <type>` | Output format: `json`, `table`, or `ndjson` | `table` |
|
||||||
|
| `--normalize-hints` | Normalize hints by stripping dynamic values | `false` |
|
||||||
|
| `-o, --output <file>` | Write output to file instead of stdout | — |
|
||||||
|
| `--env <path>` | Custom .env file path | `.env` |
|
||||||
|
| `-h, --help` | Show help message | — |
|
||||||
|
|
||||||
|
**Streaming behavior:** With `--all`, output must be `ndjson` or `table`; `--all --format json` is rejected because `--all` streams output. `--all --format table` prints rows immediately. `--format ndjson` always streams one JSON object per line.
|
||||||
|
|
||||||
|
#### `get` - Get a single error report by ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs get [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Flag | Description | Required |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| `--id <uuid>` | Error report ID | Yes (or --error-id) |
|
||||||
|
| `--error-id <id>` | Error report error-id | Yes (or --id) |
|
||||||
|
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `table`) |
|
||||||
|
| `--env <path>` | Custom .env file path | No (default: `.env`) |
|
||||||
|
| `-h, --help` | Show help message | No |
|
||||||
|
|
||||||
|
#### `stats` - Compute error report statistics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs stats [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads from `--input <file>`, stdin (piped), or fetches from API. Computes aggregations by signature, version, source, audit-log kind, hour, optional 5-minute bursts, and optional day-of-week × hour heatmap.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Flag | Description | Default |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `--from <date>` | Start of interval (ISO timestamp) | — |
|
||||||
|
| `--to <date>` | End of interval (ISO timestamp) | — |
|
||||||
|
| `--limit <n>` | Items per page when fetching from API | `200` |
|
||||||
|
| `--input <file>` | Read from local JSON/NDJSON file instead of API | — |
|
||||||
|
| `--burst` | Detect 5-minute windows above 3× the average rate | `false` |
|
||||||
|
| `--heatmap` | Show day-of-week × hour-of-day heatmap | `false` |
|
||||||
|
| `-f, --format <type>` | Output format: `json` or `table` | `table` |
|
||||||
|
| `--env <path>` | Custom .env file path | `.env` |
|
||||||
|
|
||||||
|
## Source Names
|
||||||
|
|
||||||
|
The `--source` filter accepts these values:
|
||||||
|
|
||||||
|
- `logging`
|
||||||
|
- `audit-log`
|
||||||
|
- `rlimit`
|
||||||
|
|
||||||
|
## Hint Normalization
|
||||||
|
|
||||||
|
With `--normalize-hints` (or always in `stats`), hints are normalized by stripping dynamic values:
|
||||||
|
|
||||||
|
1. File IDs in file-id context → `<file-id>`
|
||||||
|
2. UUIDs (8-4-4-4-12 hex) → `<uuid>`
|
||||||
|
3. Numeric IDs in parentheses `(12345)` → `(<id>)`
|
||||||
|
4. Elapsed times (`7.5s`, `2m3.027s`) → `<elapsed>`
|
||||||
|
5. URIs (`https://...`) → `<uri>`
|
||||||
|
6. Unicode quotes and whitespace normalized
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### List recent errors
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Time-range query (today)
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream all errors as NDJSON
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format ndjson > errors.ndjson
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save to file with --output
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
|
||||||
|
./scripts/error-reports.mjs list --format json -o errors.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by source
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --source audit-log --limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by kind
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --kind exception-page
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by tenant
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --tenant production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by version
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --version 2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search by hint (partial match)
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --hint "NullPointerException"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch all errors with pagination
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get specific error by ID
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs get --id 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output as JSON
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --limit 5 --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combine filters
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --source audit-log --kind exception-page --tenant production --limit 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats with burst and heatmap analysis
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs stats --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --burst --heatmap
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats from file
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs stats --input errors.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats from pipe
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format json | ./scripts/error-reports.mjs stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
### Table (default)
|
||||||
|
Human-readable table format for terminal display. With `--all`, rows stream as they arrive.
|
||||||
|
|
||||||
|
### JSON
|
||||||
|
Single page: `{items: [...], nextSince, nextId}`. `--all` cannot be combined with `--format json`; use `--format ndjson` for streaming.
|
||||||
|
|
||||||
|
### NDJSON
|
||||||
|
One JSON object per line, always streaming. Pipe-friendly: `| jq -c '.hint'`, `| wc -l`.
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
The server returns items in **ascending** order (oldest first). Cursor pagination uses `--since` / `--since-id` to fetch the next page of newer items.
|
||||||
|
|
||||||
|
### Manual pagination
|
||||||
|
Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the response:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --limit 50
|
||||||
|
# Use nextSince and nextId from response
|
||||||
|
./scripts/error-reports.mjs list --limit 50 --since "2026-01-20T10:29:00Z" --since-id "next-uuid"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic pagination
|
||||||
|
Use `--all` to fetch all pages automatically (streams output):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Time-range queries
|
||||||
|
Use `--from` and `--to` to bound the query. These map to the server's `--since` and `--until` parameters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --from 2026-07-20T00:00:00Z --to 2026-07-23T23:59:59Z --all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key principles
|
||||||
|
|
||||||
|
- **Authentication required** - Uses access token with `error-reports:read` permission
|
||||||
|
- **API endpoint configurable** - Set via `PENPOT_API_URI` in `.env` file
|
||||||
|
- **Table is default format** - Use `--format json` for structured JSON, `--format ndjson` for streaming
|
||||||
|
- **Streaming with --all** - Items print as they arrive, no buffering. Use `--format ndjson` or `--format table`; `--all --format json` is rejected.
|
||||||
|
- **Filters are combinable** - All filter options can be used together
|
||||||
|
- **Both flag formats supported** - `--option=value` and `--option value` both work
|
||||||
|
- **Ascending order** - Server returns oldest items first (changed from DESC)
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
The tool provides helpful error messages for common issues:
|
||||||
|
|
||||||
|
- **Missing configuration**: Shows setup instructions for `.env` file
|
||||||
|
- **Authentication errors (401)**: Indicates invalid or expired token
|
||||||
|
- **Authorization errors (403)**: Indicates missing `error-reports:read` permission
|
||||||
|
- **RPC errors**: Displays error code and message from the API
|
||||||
|
|
||||||
|
## Integration with other scripts
|
||||||
|
|
||||||
|
- **jq**: Pipe NDJSON output to `jq` for further processing
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format ndjson | jq -c '{id, hint}'
|
||||||
|
```
|
||||||
|
- **stats from pipe**: Fetch data once, compute stats
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
|
||||||
|
```
|
||||||
|
- **stats from NDJSON pipe**: Works with NDJSON format too
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
|
||||||
|
```
|
||||||
|
- **grep/search**: Filter output by specific patterns
|
||||||
|
- **--output**: Save to file without shell redirection
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
|
||||||
|
```
|
||||||
@ -29,7 +29,7 @@ bb scripts/paren-repair --help
|
|||||||
|
|
||||||
## Native Tool Available (opencode)
|
## Native Tool Available (opencode)
|
||||||
|
|
||||||
A native opencode tool `paren-repair` is available at `.opencode/tools/paren-repair.ts`.
|
A native opencode tool `paren-repair` is available at `.opencode/scripts/paren-repair.ts`.
|
||||||
The LLM can call it directly with:
|
The LLM can call it directly with:
|
||||||
- `files`: Array of file paths to fix
|
- `files`: Array of file paths to fix
|
||||||
- `code`: Code string to fix via stdin
|
- `code`: Code string to fix via stdin
|
||||||
|
|||||||
@ -137,17 +137,32 @@ E2E tests should not be added unless explicitly requested.
|
|||||||
|
|
||||||
## Execution discipline
|
## Execution discipline
|
||||||
|
|
||||||
When running CLJS/JS tests (frontend, common):
|
**CRITICAL: Test output handling rules**
|
||||||
|
|
||||||
|
When running ANY test command (CLJS/JS or JVM):
|
||||||
|
|
||||||
|
1. **NEVER pipe test output directly to `| head`, `| tail`, `| grep`, or similar filters** — this can hide failures and cause you to miss critical errors.
|
||||||
|
2. **ALWAYS pipe to a file first, then read the file:**
|
||||||
|
```bash
|
||||||
|
# CORRECT:
|
||||||
|
pnpm run test 2>&1 > /tmp/test-output.txt
|
||||||
|
grep -A 5 "failures" /tmp/test-output.txt
|
||||||
|
|
||||||
|
# WRONG:
|
||||||
|
pnpm run test 2>&1 | tail -20
|
||||||
|
pnpm run test 2>&1 | grep "failures"
|
||||||
|
```
|
||||||
|
3. **Use `--focus` to narrow test scope** instead of filtering output.
|
||||||
|
4. **Read the full output file** to understand test results completely.
|
||||||
|
|
||||||
|
When running CLJS/JS tests (frontend, common):
|
||||||
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
|
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
|
||||||
- **Never pipe test output through `tail`, `head`, or similar filters** — doing so can silently hide test failures. Use `--focus` to narrow scope instead.
|
|
||||||
- **If you need to filter output, tee to a temp file first:** `pnpm run test:quiet 2>&1 | tee /tmp/penpot-test-output.txt`. The full output is preserved on disk so you can `grep`/`tail`/`head` the file without re-running.
|
|
||||||
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
|
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
|
||||||
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
|
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
|
||||||
|
|
||||||
When running JVM tests (backend, common):
|
When running JVM tests (backend, common):
|
||||||
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
|
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
|
||||||
- The same no-piping rule applies: use `--focus` to narrow scope.
|
- Same file-piping rule applies.
|
||||||
|
|
||||||
## Verification Checklist
|
## Verification Checklist
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ automatically pull the identity from the local git config `user.name` and `user.
|
|||||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||||
|
|
||||||
Body explaining what changed and why.
|
Body explaining what changed and why.
|
||||||
|
Wrap lines at 72 characters — git log and tooling
|
||||||
|
render long lines poorly. Keep each line concise.
|
||||||
|
|
||||||
AI-assisted-by: model-name
|
AI-assisted-by: model-name
|
||||||
```
|
```
|
||||||
@ -25,3 +27,7 @@ AI-assisted-by: model-name
|
|||||||
## Commit Type Emojis
|
## Commit Type Emojis
|
||||||
|
|
||||||
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
|
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
|
||||||
|
|
||||||
|
## Referencing Issues
|
||||||
|
|
||||||
|
Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
|
||||||
|
|||||||
@ -30,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
|
|||||||
|
|
||||||
Include concise sections covering:
|
Include concise sections covering:
|
||||||
- what changed and why;
|
- what changed and why;
|
||||||
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
|
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
|
||||||
- screenshots or recordings for UI-visible changes;
|
- screenshots or recordings for UI-visible changes;
|
||||||
- testing performed and residual risk;
|
- testing performed and residual risk;
|
||||||
- breaking changes or migration notes, if any.
|
- breaking changes or migration notes, if any.
|
||||||
@ -42,15 +42,15 @@ PR descriptions follow this structure:
|
|||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
<one paragraph: the problem or feature, user-facing impact>
|
<the problem or feature and its user-facing impact — short bullet items where there is more than one point>
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
<root cause or motivation, why this change was necessary>
|
<root cause or motivation — a short paragraph or bullets>
|
||||||
|
|
||||||
## How
|
## How
|
||||||
|
|
||||||
<high-level approach, key technical decisions>
|
<high-level approach and key decisions — bullet items, grouped by area (bold lead-ins) for larger PRs>
|
||||||
```
|
```
|
||||||
|
|
||||||
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
|
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
|
||||||
@ -59,6 +59,8 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
|
|||||||
|
|
||||||
- **Write for humans.** The diff shows what changed. The description explains why.
|
- **Write for humans.** The diff shows what changed. The description explains why.
|
||||||
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
|
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
|
||||||
|
- **Prefer bullets over paragraphs.** Short bullet items, grouped by area with bold lead-ins where helpful, are far easier to digest than prose; keep any remaining paragraph to a few sentences.
|
||||||
|
- **No manual line wraps.** Markdown renders adapting to the viewport; hard-wrapped lines degrade rendering. One line per paragraph or bullet, however long.
|
||||||
- **Skip the obvious.** Don't explain what `git diff` already shows.
|
- **Skip the obvious.** Don't explain what `git diff` already shows.
|
||||||
|
|
||||||
### What NOT to Include
|
### What NOT to Include
|
||||||
|
|||||||
@ -8,6 +8,9 @@
|
|||||||
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
|
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
|
||||||
- **Never amend a commit that has been pushed** unless the user explicitly asks.
|
- **Never amend a commit that has been pushed** unless the user explicitly asks.
|
||||||
If the user pushes, treat that commit as final from the agent's side.
|
If the user pushes, treat that commit as final from the agent's side.
|
||||||
|
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
|
||||||
|
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
|
||||||
|
This prevents hiding test failures. See `mem:testing` for details.
|
||||||
- **Read the workflow memory BEFORE the corresponding action**:
|
- **Read the workflow memory BEFORE the corresponding action**:
|
||||||
- Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
|
- Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
|
||||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type)
|
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type)
|
||||||
@ -109,4 +112,5 @@ precision while maintaining a strong focus on maintainability and performance.
|
|||||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
|
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
|
||||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||||
|
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
|
||||||
|
|
||||||
|
|||||||
47
CHANGES.md
47
CHANGES.md
@ -23,6 +23,18 @@
|
|||||||
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
|
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
|
||||||
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
|
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
|
||||||
|
|
||||||
|
|
||||||
|
## 2.17.1 (Unreleased)
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
|
||||||
|
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
|
||||||
|
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
|
||||||
|
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
|
||||||
|
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
|
||||||
|
- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805))
|
||||||
|
|
||||||
## 2.17.0
|
## 2.17.0
|
||||||
|
|
||||||
### :rocket: Epics and highlights
|
### :rocket: Epics and highlights
|
||||||
@ -59,49 +71,28 @@
|
|||||||
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
|
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
|
||||||
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
|
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
|
||||||
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
|
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
|
||||||
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
|
- Add color variants and positioning to selection size badge [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
|
||||||
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
|
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
|
||||||
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
|
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
|
||||||
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
|
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
- Fix LDAP provider params schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
|
- Fix Plugin API variant creation failing due to undocumented multi-step workflow [#10075](https://github.com/penpot/penpot/issues/10075) (PR: [#10149](https://github.com/penpot/penpot/pull/10149))
|
||||||
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide` → `:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
|
- Fix workspace crash when editing text shapes with degenerate selrect [#10617](https://github.com/penpot/penpot/issues/10617) (PR: [#10618](https://github.com/penpot/penpot/pull/10618))
|
||||||
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
|
|
||||||
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
|
|
||||||
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
|
|
||||||
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
|
|
||||||
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
|
|
||||||
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
|
|
||||||
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
|
|
||||||
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
|
|
||||||
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
|
|
||||||
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
|
|
||||||
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
|
|
||||||
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
|
|
||||||
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
|
|
||||||
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
|
|
||||||
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
|
|
||||||
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
|
|
||||||
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
|
|
||||||
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
|
|
||||||
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
|
|
||||||
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
|
|
||||||
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
|
|
||||||
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
|
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
|
||||||
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
|
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @davidv399) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
|
||||||
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
|
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
|
||||||
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
|
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
|
||||||
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
|
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
|
||||||
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
|
- Fix plugin API `typography.remove()` passing wrong parameter format (by @peter-rango) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
|
||||||
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
|
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
|
||||||
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
|
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
|
||||||
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
|
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
|
||||||
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
|
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
|
||||||
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
|
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
|
||||||
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
|
- Fix lost-update race on team features during concurrent file creation (by @Lobster-0429) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
|
||||||
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
|
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @davidv399) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
|
||||||
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
|
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
|
||||||
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
|
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
|
||||||
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
|
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
org.clojure/clojure {:mvn/version "1.12.5"}
|
org.clojure/clojure {:mvn/version "1.12.5"}
|
||||||
org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
||||||
|
|
||||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-11"}
|
com.github.luben/zstd-jni {:mvn/version "1.5.7-12"}
|
||||||
|
|
||||||
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
||||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
||||||
@ -34,27 +34,28 @@
|
|||||||
:exclusions [org.slf4j/slf4j-api]}
|
:exclusions [org.slf4j/slf4j-api]}
|
||||||
|
|
||||||
com.github.seancorfield/next.jdbc
|
com.github.seancorfield/next.jdbc
|
||||||
{:mvn/version "1.3.1108"}
|
{:mvn/version "1.3.1118"}
|
||||||
|
|
||||||
metosin/reitit-core {:mvn/version "0.10.1"}
|
metosin/reitit-core {:mvn/version "0.10.1"}
|
||||||
nrepl/nrepl {:mvn/version "1.7.0"}
|
nrepl/nrepl {:mvn/version "1.7.0"}
|
||||||
|
|
||||||
org.postgresql/postgresql {:mvn/version "42.7.12"}
|
org.postgresql/postgresql {:mvn/version "42.7.13"}
|
||||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
|
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"}
|
||||||
|
|
||||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
com.zaxxer/HikariCP {:mvn/version "7.1.0"}
|
||||||
|
|
||||||
io.whitfin/siphash {:mvn/version "2.0.0"}
|
io.whitfin/siphash {:mvn/version "2.0.0"}
|
||||||
|
|
||||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||||
|
org.passay/passay {:mvn/version "1.6.6"}
|
||||||
|
|
||||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||||
|
|
||||||
org.jsoup/jsoup {:mvn/version "1.22.2"}
|
org.jsoup/jsoup {:mvn/version "1.23.1"}
|
||||||
|
|
||||||
at.yawk.lz4/lz4-java
|
at.yawk.lz4/lz4-java
|
||||||
{:mvn/version "1.11.0"}
|
{:mvn/version "1.11.1"}
|
||||||
|
|
||||||
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
|
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
|
||||||
|
|
||||||
@ -63,8 +64,8 @@
|
|||||||
|
|
||||||
;; Pretty Print specs
|
;; Pretty Print specs
|
||||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||||
software.amazon.awssdk/s3 {:mvn/version "2.46.18"}
|
software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
|
||||||
software.amazon.awssdk/sts {:mvn/version "2.46.18"}}
|
software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
|
||||||
|
|
||||||
:paths ["src" "resources" "target/classes"]
|
:paths ["src" "resources" "target/classes"]
|
||||||
:aliases
|
:aliases
|
||||||
|
|||||||
@ -104,24 +104,20 @@
|
|||||||
[]
|
[]
|
||||||
(try
|
(try
|
||||||
(main/start)
|
(main/start)
|
||||||
:started
|
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(ex/print-throwable cause))))
|
(ex/print-throwable cause))))
|
||||||
|
|
||||||
(defn- stop
|
(defn- stop
|
||||||
[]
|
[]
|
||||||
(main/stop)
|
(main/stop))
|
||||||
:stopped)
|
|
||||||
|
|
||||||
(defn restart
|
(defn restart
|
||||||
[]
|
[]
|
||||||
(stop)
|
(main/restart))
|
||||||
(repl/refresh :after 'user/start))
|
|
||||||
|
|
||||||
(defn restart-all
|
(defn restart-all
|
||||||
[]
|
[]
|
||||||
(stop)
|
(main/restart-all))
|
||||||
(repl/refresh-all :after 'user/start))
|
|
||||||
|
|
||||||
;; (defn compression-bench
|
;; (defn compression-bench
|
||||||
;; [data]
|
;; [data]
|
||||||
|
|||||||
@ -4,23 +4,25 @@
|
|||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"author": "Kaleidos INC Sucursal en España SL",
|
"author": "Kaleidos INC Sucursal en España SL",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
|
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/penpot/penpot"
|
"url": "https://github.com/penpot/penpot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"luxon": "^3.4.4",
|
"eventsource-parser": "^3.0.6",
|
||||||
"sax": "^1.6.0"
|
"luxon": "^3.7.2",
|
||||||
|
"sax": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "clj-kondo --parallel --lint ../common/src src/",
|
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
|
||||||
"check-fmt": "cljfmt check --parallel=true src/ test/",
|
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
||||||
"fmt": "cljfmt fix --parallel=true src/ test/"
|
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||||
|
"test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/pnpm-lock.yaml
generated
41
backend/pnpm-lock.yaml
generated
@ -8,12 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
eventsource-parser:
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.1.0
|
||||||
luxon:
|
luxon:
|
||||||
specifier: ^3.4.4
|
specifier: ^3.7.2
|
||||||
version: 3.7.2
|
version: 3.7.2
|
||||||
sax:
|
sax:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.1
|
||||||
version: 1.6.0
|
version: 1.6.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
nodemon:
|
nodemon:
|
||||||
specifier: ^3.1.14
|
specifier: ^3.1.14
|
||||||
@ -22,8 +25,8 @@ importers:
|
|||||||
specifier: ^0.5.21
|
specifier: ^0.5.21
|
||||||
version: 0.5.21
|
version: 0.5.21
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.21.0
|
specifier: ^8.21.1
|
||||||
version: 8.21.0
|
version: 8.21.1
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -39,9 +42,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
brace-expansion@5.0.7:
|
brace-expansion@5.0.9:
|
||||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
@ -63,6 +66,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
eventsource-parser@3.1.0:
|
||||||
|
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -130,8 +137,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
sax@1.6.0:
|
sax@1.6.1:
|
||||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||||
engines: {node: '>=11.0.0'}
|
engines: {node: '>=11.0.0'}
|
||||||
|
|
||||||
semver@7.8.5:
|
semver@7.8.5:
|
||||||
@ -165,8 +172,8 @@ packages:
|
|||||||
undefsafe@2.0.5:
|
undefsafe@2.0.5:
|
||||||
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
||||||
|
|
||||||
ws@8.21.0:
|
ws@8.21.1:
|
||||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
bufferutil: ^4.0.1
|
bufferutil: ^4.0.1
|
||||||
@ -188,7 +195,7 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
brace-expansion@5.0.7:
|
brace-expansion@5.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
@ -216,6 +223,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 5.5.0
|
supports-color: 5.5.0
|
||||||
|
|
||||||
|
eventsource-parser@3.1.0: {}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@ -247,7 +256,7 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.7
|
brace-expansion: 5.0.9
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
@ -274,7 +283,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.2
|
picomatch: 2.3.2
|
||||||
|
|
||||||
sax@1.6.0: {}
|
sax@1.6.1: {}
|
||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
@ -301,4 +310,4 @@ snapshots:
|
|||||||
|
|
||||||
undefsafe@2.0.5: {}
|
undefsafe@2.0.5: {}
|
||||||
|
|
||||||
ws@8.21.0: {}
|
ws@8.21.1: {}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- brace-expansion@5.0.8 || 5.0.9
|
||||||
@ -219,9 +219,15 @@
|
|||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its
|
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its
|
||||||
teams and files goes
|
teams and files now goes through your organization's identity provider.
|
||||||
through your organization's identity provider. If you can't get in, your account probably isn't
|
</div>
|
||||||
in the directory yet.
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
If you can't get in, your account probably isn't in the directory yet.
|
||||||
To get access, contact the organization owner.
|
To get access, contact the organization owner.
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -3,8 +3,10 @@ Hello!
|
|||||||
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:50 }}”.
|
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:50 }}”.
|
||||||
|
|
||||||
{% if organization.sso-active %}
|
{% if organization.sso-active %}
|
||||||
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes
|
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes
|
||||||
through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
|
through your organization's identity provider.
|
||||||
|
|
||||||
|
If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
Accept invitation using this link:
|
Accept invitation using this link:
|
||||||
@ -197,10 +197,15 @@
|
|||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to
|
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to
|
||||||
its
|
its teams and files now goes through your organization's identity provider.
|
||||||
teams and files goes
|
</div>
|
||||||
through your organization's identity provider. If you can't get in, your account probably isn't
|
</td>
|
||||||
in the directory yet.
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
If you can't get in, your account probably isn't in the directory yet.
|
||||||
To get access, contact the organization owner.
|
To get access, contact the organization owner.
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -257,4 +262,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -3,8 +3,10 @@ Hello!
|
|||||||
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:50 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}.
|
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:50 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}.
|
||||||
|
|
||||||
{% if organization.sso-active %}
|
{% if organization.sso-active %}
|
||||||
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes
|
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes
|
||||||
through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
|
through your organization's identity provider.
|
||||||
|
|
||||||
|
If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
Accept invitation using this link:
|
Accept invitation using this link:
|
||||||
|
|||||||
@ -179,7 +179,7 @@
|
|||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
|
Hi,
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -188,8 +188,16 @@
|
|||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its
|
"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its
|
||||||
teams and files goes through your organization's identity provider. If you can't get in, your
|
teams and files now goes through your organization's identity provider.
|
||||||
account probably isn't in the directory yet. To get access, contact the organization owner.
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
If you can't get in, your account probably isn't in the directory yet. To get access, contact the
|
||||||
|
organization owner.
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
“{{ organization-name|abbreviate:25 }}” has set up single sign-on (SSO) in Penpot
|
“{{ organization-name|abbreviate:25 }}” uses single sign-on
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
Hello!
|
Hi,
|
||||||
|
|
||||||
"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes
|
"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes
|
||||||
through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
|
through your organization's identity provider.
|
||||||
|
|
||||||
|
If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
|
||||||
|
|
||||||
The Penpot team.
|
The Penpot team.
|
||||||
|
|||||||
@ -10,9 +10,10 @@ penpot - error list
|
|||||||
<a href="/dbg"> [BACK]</a>
|
<a href="/dbg"> [BACK]</a>
|
||||||
<h1>Error reports (last 300)</h1>
|
<h1>Error reports (last 300)</h1>
|
||||||
|
|
||||||
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
|
<a class="{% if source = 0 %}strong{% endif %}" href="?source=0">[ALL ERRORS]</a>
|
||||||
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
|
<a class="{% if source = 3 %}strong{% endif %}" href="?source=3">[BACKEND ERRORS]</a>
|
||||||
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
|
<a class="{% if source = 4 %}strong{% endif %}" href="?source=4">[FRONTEND ERRORS]</a>
|
||||||
|
<a class="{% if source = 5 %}strong{% endif %}" href="?source=5">[RLIMIT REPORTS]</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="horizontal-list">
|
<main class="horizontal-list">
|
||||||
|
|||||||
@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav>
|
<nav>
|
||||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||||
<div>[<a href="#head">head</a>]</div>
|
<div>[<a href="#head">head</a>]</div>
|
||||||
<div>[<a href="#props">props</a>]</div>
|
<div>[<a href="#props">props</a>]</div>
|
||||||
<div>[<a href="#context">context</a>]</div>
|
<div>[<a href="#context">context</a>]</div>
|
||||||
|
|||||||
@ -6,11 +6,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav>
|
<nav>
|
||||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||||
<div>[<a href="#head">head</a>]</div>
|
<div>[<a href="#head">head</a>]</div>
|
||||||
<div>[<a href="#context">context</a>]</div>
|
<div>[<a href="#context">context</a>]</div>
|
||||||
{% if report %}
|
{% if trace %}
|
||||||
<div>[<a href="#report">report</a>]</div>
|
<div>[<a href="#trace">trace</a>]</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
@ -20,7 +20,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
|||||||
<div class="table-val">
|
<div class="table-val">
|
||||||
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
|
<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">Reported at:</span> <br/> {{created-at}}</h2>
|
||||||
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
|
<h2><span class="not-important">Kind:</span> <br/> {{kind}}</h2>
|
||||||
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
|
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -33,11 +33,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if report %}
|
{% if trace %}
|
||||||
<div class="table-row multiline">
|
<div class="table-row multiline">
|
||||||
<div id="report" class="table-key">REPORT:</div>
|
<div id="trace" class="table-key">TRACE:</div>
|
||||||
<div class="table-val">
|
<div class="table-val">
|
||||||
<pre>{{report}}</pre>
|
<pre>{{trace}}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -6,10 +6,10 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav>
|
<nav>
|
||||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||||
<div>[<a href="#head">head</a>]</div>
|
<div>[<a href="#head">head</a>]</div>
|
||||||
<div>[<a href="#context">context</a>]</div>
|
<div>[<a href="#context">context</a>]</div>
|
||||||
<div>[<a href="#result">result</a>]</div>
|
<div>[<a href="#value">value</a>]</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<div class="table">
|
<div class="table">
|
||||||
@ -30,9 +30,9 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-row multiline">
|
<div class="table-row multiline">
|
||||||
<div id="result" class="table-key">RESULT: </div>
|
<div id="value" class="table-key">VALUE: </div>
|
||||||
<div class="table-val">
|
<div class="table-val">
|
||||||
<pre>{{result}}</pre>
|
<pre>{{value}}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,4 +39,10 @@
|
|||||||
{:permits 3}
|
{:permits 3}
|
||||||
|
|
||||||
:create-file-snapshot/by-profile
|
:create-file-snapshot/by-profile
|
||||||
{:permits 1 :queue 2 :timeout 60000}}
|
{:permits 1 :queue 2 :timeout 60000}
|
||||||
|
|
||||||
|
:send-user-feedback/global
|
||||||
|
{:permits 4}
|
||||||
|
|
||||||
|
:send-user-feedback/by-profile
|
||||||
|
{:permits 1 :queue 3}}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key
|
||||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||||
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
||||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||||
|
export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key
|
||||||
|
|
||||||
# DEPRECATED: only used for subscriptions
|
# DEPRECATED: only used for subscriptions
|
||||||
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
||||||
@ -12,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
|||||||
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
|
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
|
||||||
# docker/devenv/defaults.env and injected via the main service's env block.
|
# docker/devenv/defaults.env and injected via the main service's env block.
|
||||||
|
|
||||||
|
if [ -f /home/selfsigned.crt ]; then
|
||||||
|
export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
|
||||||
|
fi
|
||||||
|
|
||||||
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
|
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
|
||||||
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
|
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
|
||||||
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
|
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
|
||||||
@ -21,6 +26,8 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then
|
|||||||
__worker_flag="enable-backend-worker"
|
__worker_flag="enable-backend-worker"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
|
||||||
|
|
||||||
export PENPOT_FLAGS="\
|
export PENPOT_FLAGS="\
|
||||||
$PENPOT_FLAGS \
|
$PENPOT_FLAGS \
|
||||||
enable-login-with-password \
|
enable-login-with-password \
|
||||||
@ -36,12 +43,14 @@ export PENPOT_FLAGS="\
|
|||||||
enable-feature-fdata-objects-map \
|
enable-feature-fdata-objects-map \
|
||||||
enable-audit-log \
|
enable-audit-log \
|
||||||
enable-transit-readable-response \
|
enable-transit-readable-response \
|
||||||
|
disable-remote-media-processing \
|
||||||
enable-demo-users \
|
enable-demo-users \
|
||||||
enable-user-feedback \
|
enable-user-feedback \
|
||||||
disable-secure-session-cookies \
|
disable-secure-session-cookies \
|
||||||
enable-smtp \
|
enable-smtp \
|
||||||
enable-prepl-server \
|
enable-prepl-server \
|
||||||
enable-urepl-server \
|
enable-urepl-server \
|
||||||
|
enable-nrepl-server \
|
||||||
enable-rpc-climit \
|
enable-rpc-climit \
|
||||||
enable-rpc-rlimit \
|
enable-rpc-rlimit \
|
||||||
enable-quotes \
|
enable-quotes \
|
||||||
@ -70,7 +79,7 @@ export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
|
|||||||
|
|
||||||
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
|
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
|
||||||
|
|
||||||
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
|
export PENPOT_ADMIN_CONSOLE_URI=http://localhost:3000/admin-console
|
||||||
|
|
||||||
export JAVA_OPTS="\
|
export JAVA_OPTS="\
|
||||||
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||||
@ -96,5 +105,3 @@ function setup_minio() {
|
|||||||
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
|
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
|
||||||
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
|
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -459,9 +459,10 @@
|
|||||||
(let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
|
(let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
|
||||||
(if (= status 200)
|
(if (= status 200)
|
||||||
(let [data (json/decode body)
|
(let [data (json/decode body)
|
||||||
data {:token/access (get data :access_token)
|
data {:token/access (get data :access_token)
|
||||||
:token/id (get data :id_token)
|
:token/id (get data :id_token)
|
||||||
:token/type (get data :token_type)}]
|
:token/type (get data :token_type)
|
||||||
|
:token/expires-in (get data :expires_in)}]
|
||||||
(l/trc :hint "access token fetched"
|
(l/trc :hint "access token fetched"
|
||||||
:token-id (:token/id data)
|
:token-id (:token/id data)
|
||||||
:token-type (:token/type data)
|
:token-type (:token/type data)
|
||||||
@ -619,6 +620,9 @@
|
|||||||
(some? (:external-session-id state))
|
(some? (:external-session-id state))
|
||||||
(assoc :external-session-id (:external-session-id state))
|
(assoc :external-session-id (:external-session-id state))
|
||||||
|
|
||||||
|
(some? (:token/expires-in tdata))
|
||||||
|
(assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)}))
|
||||||
|
|
||||||
;; If state token comes with props, merge them. The state token
|
;; If state token comes with props, merge them. The state token
|
||||||
;; props can contain pm_ and utm_ prefixed query params.
|
;; props can contain pm_ and utm_ prefixed query params.
|
||||||
(map? (:props state))
|
(map? (:props state))
|
||||||
@ -765,40 +769,36 @@
|
|||||||
[value]
|
[value]
|
||||||
(when-not (str/blank? value) value))
|
(when-not (str/blank? value) value))
|
||||||
|
|
||||||
(defn org-sso-discovery-uri
|
(defn organization-sso-discovery-uri
|
||||||
"Return the OIDC discovery URI from an org SSO config, preferring :issuer."
|
"Return the OIDC discovery URI from an organization SSO config."
|
||||||
[sso]
|
[sso]
|
||||||
(or (non-blank-uri (:issuer sso))
|
(non-blank-uri (:issuer sso)))
|
||||||
(non-blank-uri (:base-url sso))))
|
|
||||||
|
|
||||||
(defn prepare-org-sso-provider
|
(defn prepare-organization-sso-provider
|
||||||
"Build an OIDC provider map dynamically from the Nitrate org SSO config.
|
"Build an OIDC provider map dynamically from the Nitrate organization SSO config.
|
||||||
Uses OIDC discovery via :base-url (or :issuer as fallback) when
|
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
|
||||||
token/auth/user URIs are absent."
|
[cfg {:keys [client-id client-secret issuer]}]
|
||||||
[cfg {:keys [client-id client-secret base-url issuer scopes]}]
|
|
||||||
(prepare-oidc-provider cfg
|
(prepare-oidc-provider cfg
|
||||||
{:type "oidc"
|
{:type "oidc"
|
||||||
:client-id client-id
|
:client-id client-id
|
||||||
:client-secret client-secret
|
:client-secret client-secret
|
||||||
:base-uri (some-> (or (non-blank-uri base-url)
|
:base-uri (some-> (non-blank-uri issuer)
|
||||||
(non-blank-uri issuer))
|
|
||||||
(str/rtrim "/")
|
(str/rtrim "/")
|
||||||
(str "/"))
|
(str "/"))
|
||||||
:scopes (into default-oidc-scopes (or scopes #{}))
|
:scopes default-oidc-scopes}))
|
||||||
:skip-ssrf-check? true}))
|
|
||||||
|
|
||||||
(defn build-org-sso-auth-redirect-uri
|
(defn build-organization-sso-auth-redirect-uri
|
||||||
"Build the OIDC authorization redirect URI for an organization SSO config.
|
"Build the OIDC authorization redirect URI for an organization SSO config.
|
||||||
Raises if the config is incomplete or OIDC discovery fails."
|
Raises if the config is incomplete or OIDC discovery fails."
|
||||||
[cfg sso & {:keys [dest-url organization-id provider]}]
|
[cfg sso & {:keys [dest-url organization-id provider]}]
|
||||||
(let [organization-id (or organization-id (:organization-id sso))
|
(let [organization-id (or organization-id (:organization-id sso))
|
||||||
issuer (org-sso-discovery-uri sso)
|
issuer (organization-sso-discovery-uri sso)
|
||||||
dest-url (or dest-url (str (cf/get :public-uri)))]
|
dest-url (or dest-url (str (cf/get :public-uri)))]
|
||||||
(when-not issuer
|
(when-not issuer
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :invalid-sso-config
|
:code :invalid-sso-config
|
||||||
:hint "missing issuer or base-url"))
|
:hint "missing issuer"))
|
||||||
(let [oidc-provider (or provider (prepare-org-sso-provider cfg sso))
|
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
|
||||||
state-token (tokens/generate cfg {:iss "oidc"
|
state-token (tokens/generate cfg {:iss "oidc"
|
||||||
:dest-url dest-url
|
:dest-url dest-url
|
||||||
:organization-id organization-id
|
:organization-id organization-id
|
||||||
@ -838,7 +838,7 @@
|
|||||||
(and (= error "access_denied")
|
(and (= error "access_denied")
|
||||||
(str/includes? description "unauthorized")))))
|
(str/includes? description "unauthorized")))))
|
||||||
|
|
||||||
(defn- probe-org-sso-client-credentials
|
(defn- probe-organization-sso-client-credentials
|
||||||
"Probe the token endpoint with a dummy authorization code.
|
"Probe the token endpoint with a dummy authorization code.
|
||||||
Valid client credentials are expected to answer with `invalid_grant`."
|
Valid client credentials are expected to answer with `invalid_grant`."
|
||||||
[cfg provider]
|
[cfg provider]
|
||||||
@ -863,10 +863,10 @@
|
|||||||
and the client credentials are accepted by the token endpoint."
|
and the client credentials are accepted by the token endpoint."
|
||||||
[cfg sso]
|
[cfg sso]
|
||||||
(try
|
(try
|
||||||
(if (org-sso-discovery-uri sso)
|
(if (organization-sso-discovery-uri sso)
|
||||||
(let [provider (prepare-org-sso-provider cfg sso)]
|
(let [provider (prepare-organization-sso-provider cfg sso)]
|
||||||
(and (build-org-sso-auth-redirect-uri cfg sso :provider provider)
|
(and (build-organization-sso-auth-redirect-uri cfg sso :provider provider)
|
||||||
(probe-org-sso-client-credentials cfg provider)))
|
(probe-organization-sso-client-credentials cfg provider)))
|
||||||
false)
|
false)
|
||||||
(catch Throwable _ false)))
|
(catch Throwable _ false)))
|
||||||
|
|
||||||
@ -896,16 +896,15 @@
|
|||||||
state (get params :state)
|
state (get params :state)
|
||||||
state (tokens/verify cfg {:token state :iss "oidc"})]
|
state (tokens/verify cfg {:token state :iss "oidc"})]
|
||||||
|
|
||||||
;; Org SSO flow: state carries :dest-url — exchange the authorization
|
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||||
;; code with the OIDC provider to verify authentication actually occurred.
|
;; code with the OIDC provider to verify authentication actually occurred.
|
||||||
(if-let [dest-url (:dest-url state)]
|
(if-let [dest-url (:dest-url state)]
|
||||||
(let [organization-id (:organization-id state)
|
(let [organization-id (:organization-id state)
|
||||||
sso (nitrate/call cfg :get-org-sso {:organization-id organization-id})
|
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
|
||||||
provider (prepare-org-sso-provider cfg sso)
|
provider (prepare-organization-sso-provider cfg sso)
|
||||||
;; verify token or throw error
|
info (get-info cfg provider state code)
|
||||||
_info (get-info cfg provider state code)
|
|
||||||
session (session/get-session request)
|
session (session/get-session request)
|
||||||
exp (ct/in-future {:hours 48})]
|
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
|
||||||
(when (and session organization-id)
|
(when (and session organization-id)
|
||||||
(let [props (-> (or (:props session) {})
|
(let [props (-> (or (:props session) {})
|
||||||
(update :sso assoc organization-id exp))]
|
(update :sso assoc organization-id exp))]
|
||||||
|
|||||||
53
backend/src/app/auth/passwords.clj
Normal file
53
backend/src/app/auth/passwords.clj
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.auth.passwords
|
||||||
|
"Password strength validation using Passay library."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex])
|
||||||
|
(:import
|
||||||
|
[org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData]))
|
||||||
|
|
||||||
|
(defonce ^:private passay-code->translation-key
|
||||||
|
{"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
|
||||||
|
"INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase"
|
||||||
|
"INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits"
|
||||||
|
"INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
|
||||||
|
|
||||||
|
(defonce ^:private character-characteristics-rule
|
||||||
|
(doto (CharacterCharacteristicsRule.)
|
||||||
|
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
|
||||||
|
(CharacterRule. EnglishCharacterData/UpperCase 1)
|
||||||
|
(CharacterRule. EnglishCharacterData/Digit 1)
|
||||||
|
(CharacterRule. EnglishCharacterData/Special 1)])
|
||||||
|
(.setNumberOfCharacteristics 4)))
|
||||||
|
|
||||||
|
(defn validate-password
|
||||||
|
"Validates password strength.
|
||||||
|
Returns nil if valid, or raises exception if invalid.
|
||||||
|
Checks:
|
||||||
|
- Minimum length of 8 characters
|
||||||
|
- At least 1 lowercase letter
|
||||||
|
- At least 1 uppercase letter
|
||||||
|
- At least 1 digit
|
||||||
|
- At least 1 special character"
|
||||||
|
[password]
|
||||||
|
(when (< (count password) 8)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :weak-password
|
||||||
|
:hint "password must be at least 8 characters"
|
||||||
|
:details ["errors.weak-password.too-short"]))
|
||||||
|
|
||||||
|
(let [password-data (PasswordData. password)
|
||||||
|
char-result (.validate character-characteristics-rule password-data)]
|
||||||
|
(when-not (.isValid char-result)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :weak-password
|
||||||
|
:hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
|
||||||
|
:details (->> (.getDetails char-result)
|
||||||
|
(mapv #(.getErrorCode %))
|
||||||
|
(mapv passay-code->translation-key)
|
||||||
|
(filterv some?))))))
|
||||||
@ -748,9 +748,17 @@
|
|||||||
(fmigr/upsert-migrations! conn file))
|
(fmigr/upsert-migrations! conn file))
|
||||||
|
|
||||||
(let [file (encode-file cfg file)]
|
(let [file (encode-file cfg file)]
|
||||||
(db/insert! conn :file
|
(try
|
||||||
(file->params file)
|
(db/insert! conn :file
|
||||||
(assoc opts ::db/return-keys false))
|
(file->params file)
|
||||||
|
(assoc opts ::db/return-keys false))
|
||||||
|
(catch org.postgresql.util.PSQLException cause
|
||||||
|
(if (db/duplicate-key-error? cause)
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "file already exists"
|
||||||
|
:cause cause)
|
||||||
|
(throw cause))))
|
||||||
|
|
||||||
(->> (file->file-data-params file)
|
(->> (file->file-data-params file)
|
||||||
(fdata/upsert! cfg))
|
(fdata/upsert! cfg))
|
||||||
|
|||||||
@ -174,6 +174,10 @@
|
|||||||
(assert-mark m :obj)
|
(assert-mark m :obj)
|
||||||
(let [size (read-long! input)]
|
(let [size (read-long! input)]
|
||||||
(assert (pos? size) "incorrect header size found on reading header")
|
(assert (pos? size) "incorrect header size found on reading header")
|
||||||
|
(when (> size bfc/max-object-size)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :max-file-size-reached
|
||||||
|
:hint (dm/str "unable to import object with size " size " bytes")))
|
||||||
(let [buff (byte-array size)]
|
(let [buff (byte-array size)]
|
||||||
(read-bytes! input buff)
|
(read-bytes! input buff)
|
||||||
(fres/decode buff)))))
|
(fres/decode buff)))))
|
||||||
|
|||||||
@ -119,8 +119,9 @@
|
|||||||
[:allowed-origins {:optional true} [::sm/set :string]]
|
[:allowed-origins {:optional true} [::sm/set :string]]
|
||||||
|
|
||||||
[:exporter-shared-key {:optional true} :string]
|
[:exporter-shared-key {:optional true} :string]
|
||||||
[:nitrate-shared-key {:optional true} :string]
|
[:admin-console-shared-key {:optional true} :string]
|
||||||
[:nexus-shared-key {:optional true} :string]
|
[:nexus-shared-key {:optional true} :string]
|
||||||
|
[:media-processor-shared-key {:optional true} :string]
|
||||||
[:management-api-key {:optional true} :string]
|
[:management-api-key {:optional true} :string]
|
||||||
|
|
||||||
[:telemetry-uri {:optional true} :string]
|
[:telemetry-uri {:optional true} :string]
|
||||||
@ -147,6 +148,9 @@
|
|||||||
[:imagemagick-width-limit {:optional true} :string]
|
[:imagemagick-width-limit {:optional true} :string]
|
||||||
[:imagemagick-height-limit {:optional true} :string]
|
[:imagemagick-height-limit {:optional true} :string]
|
||||||
|
|
||||||
|
[:media-processing-service-uri {:optional true} ::sm/uri]
|
||||||
|
[:media-processing-service-timeout {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:deletion-delay {:optional true} ::ct/duration]
|
[:deletion-delay {:optional true} ::ct/duration]
|
||||||
[:file-clean-delay {:optional true} ::ct/duration]
|
[:file-clean-delay {:optional true} ::ct/duration]
|
||||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||||
@ -253,6 +257,8 @@
|
|||||||
[:urepl-port {:optional true} ::sm/int]
|
[:urepl-port {:optional true} ::sm/int]
|
||||||
[:prepl-host {:optional true} :string]
|
[:prepl-host {:optional true} :string]
|
||||||
[:prepl-port {:optional true} ::sm/int]
|
[:prepl-port {:optional true} ::sm/int]
|
||||||
|
[:nrepl-host {:optional true} :string]
|
||||||
|
[:nrepl-port {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:file-data-backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
[:file-data-backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
||||||
|
|
||||||
@ -262,7 +268,7 @@
|
|||||||
|
|
||||||
[:netty-io-threads {:optional true} ::sm/int]
|
[:netty-io-threads {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:nitrate-backend-uri {:optional true} ::sm/uri]
|
[:admin-console-uri {:optional true} ::sm/uri]
|
||||||
|
|
||||||
;; DEPRECATED
|
;; DEPRECATED
|
||||||
[:assets-storage-backend {:optional true} :keyword]
|
[:assets-storage-backend {:optional true} :keyword]
|
||||||
|
|||||||
@ -440,22 +440,21 @@
|
|||||||
:id ::invite-to-team
|
:id ::invite-to-team
|
||||||
:schema schema:invite-to-team))
|
:schema schema:invite-to-team))
|
||||||
|
|
||||||
(def ^:private schema:invite-to-org
|
(def ^:private schema:invite-to-organization
|
||||||
[:map
|
[:map
|
||||||
[:invited-by ::sm/text]
|
[:invited-by ::sm/text]
|
||||||
[:user-name [:maybe ::sm/text]]
|
[:user-name [:maybe ::sm/text]]
|
||||||
[:token ::sm/text]
|
[:token ::sm/text]
|
||||||
[:organization schema:organization-data]])
|
[:organization schema:organization-data]])
|
||||||
|
|
||||||
(def invite-to-org
|
(def invite-to-organization
|
||||||
"Org member invitation email."
|
"Organization member invitation email."
|
||||||
(template-factory
|
(template-factory
|
||||||
:id ::invite-to-org
|
:id ::invite-to-organization
|
||||||
:schema schema:invite-to-org))
|
:schema schema:invite-to-organization))
|
||||||
|
|
||||||
(def ^:private schema:organization-setup-sso
|
(def ^:private schema:organization-setup-sso
|
||||||
[:map
|
[:map
|
||||||
[:user-name {:optional true} [:maybe ::sm/text]]
|
|
||||||
[:organization-name ::sm/text]])
|
[:organization-name ::sm/text]])
|
||||||
|
|
||||||
(def organization-setup-sso
|
(def organization-setup-sso
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
:cause cause))))
|
:cause cause))))
|
||||||
|
|
||||||
(def sql:get-token-data
|
(def sql:get-token-data
|
||||||
"SELECT perms, profile_id, expires_at
|
"SELECT perms, profile_id, expires_at, type
|
||||||
FROM access_token
|
FROM access_token
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
AND (expires_at IS NULL
|
AND (expires_at IS NULL
|
||||||
@ -42,15 +42,19 @@
|
|||||||
(fn [request]
|
(fn [request]
|
||||||
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
||||||
(if (= :token type)
|
(if (= :token type)
|
||||||
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
|
(let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool))
|
||||||
;; FIXME: revisit this, this data looks unused
|
token-id (get claims :tid)]
|
||||||
(handler (cond-> request
|
(handler (cond-> request
|
||||||
(some? perms)
|
(some? perms)
|
||||||
(assoc ::perms perms)
|
(assoc ::perms perms)
|
||||||
(some? profile-id)
|
(some? profile-id)
|
||||||
(assoc ::profile-id profile-id)
|
(assoc ::profile-id profile-id)
|
||||||
(some? expires-at)
|
(some? expires-at)
|
||||||
(assoc ::expires-at expires-at))))
|
(assoc ::expires-at expires-at)
|
||||||
|
(some? token-id)
|
||||||
|
(assoc ::id token-id)
|
||||||
|
(some? type)
|
||||||
|
(assoc ::type type))))
|
||||||
|
|
||||||
(handler request)))))
|
(handler request)))))
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.http.assets
|
(ns app.http.assets
|
||||||
"Assets related handlers."
|
"Assets related handlers."
|
||||||
(:require
|
(:require
|
||||||
|
[app.binfile.common :as bfc]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
@ -31,7 +32,8 @@
|
|||||||
#{"file-media-object"
|
#{"file-media-object"
|
||||||
"file-object-thumbnail"
|
"file-object-thumbnail"
|
||||||
"team-font-variant"
|
"team-font-variant"
|
||||||
"file-data-fragment"})
|
"file-data-fragment"
|
||||||
|
"organization"})
|
||||||
|
|
||||||
(defn get-id
|
(defn get-id
|
||||||
[{:keys [path-params]}]
|
[{:keys [path-params]}]
|
||||||
@ -41,7 +43,7 @@
|
|||||||
|
|
||||||
(defn- get-file-media-object
|
(defn- get-file-media-object
|
||||||
[pool id]
|
[pool id]
|
||||||
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||||
|
|
||||||
(defn- serve-object-from-s3
|
(defn- serve-object-from-s3
|
||||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||||
@ -108,13 +110,21 @@
|
|||||||
(defn- generic-handler
|
(defn- generic-handler
|
||||||
"A generic handler helper/common code for file-media based handlers."
|
"A generic handler helper/common code for file-media based handlers."
|
||||||
[{:keys [::sto/storage] :as cfg} request kf]
|
[{:keys [::sto/storage] :as cfg} request kf]
|
||||||
(let [pool (::db/pool storage)
|
(let [pool (::db/pool storage)
|
||||||
id (get-id request)
|
id (get-id request)
|
||||||
mobj (get-file-media-object pool id)
|
mobj (get-file-media-object pool id)]
|
||||||
sobj (sto/get-object storage (kf mobj))]
|
(if (nil? mobj)
|
||||||
(if sobj
|
{::yres/status 404}
|
||||||
(serve-object cfg sobj)
|
(let [file-id (:file-id mobj)
|
||||||
{::yres/status 404})))
|
profile-id (or (::session/profile-id request)
|
||||||
|
(::actoken/profile-id request))
|
||||||
|
perms (bfc/get-file-permissions pool profile-id file-id)]
|
||||||
|
(if-not (:can-read perms)
|
||||||
|
{::yres/status 404}
|
||||||
|
(let [sobj (sto/get-object storage (kf mobj))]
|
||||||
|
(if sobj
|
||||||
|
(serve-object cfg sobj)
|
||||||
|
{::yres/status 404})))))))
|
||||||
|
|
||||||
(defn file-objects-handler
|
(defn file-objects-handler
|
||||||
"Handler that serves storage objects by file media id."
|
"Handler that serves storage objects by file media id."
|
||||||
|
|||||||
@ -230,25 +230,28 @@
|
|||||||
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
||||||
(tmpl/render (-> content
|
(tmpl/render (-> content
|
||||||
(assoc :id id)
|
(assoc :id id)
|
||||||
(assoc :version 3)
|
(assoc :source 3)
|
||||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||||
|
|
||||||
(render-template-v4 [{:keys [content id created-at]}]
|
(render-template-v4 [{:keys [content id created-at]}]
|
||||||
(-> (io/resource "app/templates/error-report.v4.tmpl")
|
(-> (io/resource "app/templates/error-report.v4.tmpl")
|
||||||
(tmpl/render (-> content
|
(tmpl/render (-> content
|
||||||
(assoc :id id)
|
(assoc :id id)
|
||||||
(assoc :version 4)
|
(assoc :source 4)
|
||||||
|
(assoc :kind (or (:kind content) (:origin content)))
|
||||||
|
(assoc :trace (or (:trace content) (:report content)))
|
||||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||||
|
|
||||||
(render-template-v5 [{:keys [content id created-at]}]
|
(render-template-v5 [{:keys [content id created-at]}]
|
||||||
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
||||||
(tmpl/render (-> content
|
(tmpl/render (-> content
|
||||||
(assoc :id id)
|
(assoc :id id)
|
||||||
(assoc :version 5)
|
(assoc :source 5)
|
||||||
|
(assoc :value (or (:value content) (:result content)))
|
||||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
|
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
|
||||||
|
|
||||||
(if-let [report (get-report request)]
|
(if-let [report (get-report request)]
|
||||||
(let [result (case (:version report)
|
(let [result (case (:source report)
|
||||||
1 (render-template-v1 report)
|
1 (render-template-v1 report)
|
||||||
2 (render-template-v2 report)
|
2 (render-template-v2 report)
|
||||||
3 (render-template-v3 report)
|
3 (render-template-v3 report)
|
||||||
@ -265,18 +268,19 @@
|
|||||||
"SELECT id, created_at,
|
"SELECT id, created_at,
|
||||||
content->>'~:hint' AS hint
|
content->>'~:hint' AS hint
|
||||||
FROM server_error_report
|
FROM server_error_report
|
||||||
WHERE version = ?
|
WHERE (version = ? OR source = ? OR ? = 0)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 300")
|
LIMIT 300")
|
||||||
|
|
||||||
(defn- error-list-handler
|
(defn- error-list-handler
|
||||||
[{:keys [::db/pool]} {:keys [params]}]
|
[{:keys [::db/pool]} {:keys [params]}]
|
||||||
(let [version (or (some-> (get params :version) parse-long) 3)
|
(let [source (or (some-> (get params :source) parse-long) 3)
|
||||||
items (->> (db/exec! pool [sql:error-reports version])
|
items (->> (db/exec! pool [sql:error-reports source source source])
|
||||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||||
|
|
||||||
{::yres/status 200
|
{::yres/status 200
|
||||||
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
|
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
|
||||||
(tmpl/render {:items items :version version}))
|
(tmpl/render {:items items :source source}))
|
||||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||||
"x-robots-tag" "noindex"}}))
|
"x-robots-tag" "noindex"}}))
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
(assoc :request/user-agent (yreq/get-header request "user-agent"))
|
(assoc :request/user-agent (yreq/get-header request "user-agent"))
|
||||||
(assoc :request/ip-addr (inet/parse-request request))
|
(assoc :request/ip-addr (inet/parse-request request))
|
||||||
(assoc :request/profile-id (get claims :uid))
|
(assoc :request/profile-id (get claims :uid))
|
||||||
(assoc :request/auth-data auth)
|
(assoc :request/auth-data (dissoc auth :token))
|
||||||
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||||
|
|
||||||
(defmulti handle-error
|
(defmulti handle-error
|
||||||
|
|||||||
@ -65,12 +65,25 @@
|
|||||||
:else
|
:else
|
||||||
request)))
|
request)))
|
||||||
|
|
||||||
|
;; The specific-exception branches below (IAE,
|
||||||
|
;; RequestTooBigException, EOFException) raise with
|
||||||
|
;; `ex/raise` rather than calling `errors/handle` directly.
|
||||||
|
;; This is intentional: the throw is caught by the
|
||||||
|
;; top-level error handler in `app.http/router-handler`
|
||||||
|
;; (`backend/src/app/http.clj`), which routes every
|
||||||
|
;; uncaught exception through `errors/handle`. The
|
||||||
|
;; per-route `wrap-errors` middleware in the route list
|
||||||
|
;; is a defensive layer; correctness does not depend on
|
||||||
|
;; it. Raising here keeps the cond uniform with the
|
||||||
|
;; existing RequestTooBigException / EOFException
|
||||||
|
;; branches.
|
||||||
(handle-error [cause request]
|
(handle-error [cause request]
|
||||||
(cond
|
(cond
|
||||||
(instance? RuntimeException cause)
|
(instance? IllegalArgumentException cause)
|
||||||
(if-let [cause (ex-cause cause)]
|
(ex/raise :type :validation
|
||||||
(handle-error cause request)
|
:code :malformed-json
|
||||||
(errors/handle cause request))
|
:hint (ex-message cause)
|
||||||
|
:cause cause)
|
||||||
|
|
||||||
(instance? RequestTooBigException cause)
|
(instance? RequestTooBigException cause)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -83,6 +96,11 @@
|
|||||||
:hint (ex-message cause)
|
:hint (ex-message cause)
|
||||||
:cause cause)
|
:cause cause)
|
||||||
|
|
||||||
|
(instance? RuntimeException cause)
|
||||||
|
(if-let [cause (ex-cause cause)]
|
||||||
|
(handle-error cause request)
|
||||||
|
(errors/handle cause request))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(errors/handle cause request)))]
|
(errors/handle cause request)))]
|
||||||
|
|
||||||
|
|||||||
@ -226,19 +226,19 @@
|
|||||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||||
(db/get-update-count))))
|
(db/get-update-count))))
|
||||||
|
|
||||||
(def ^:private sql:clear-org-sso-sessions
|
(def ^:private sql:clear-organization-sso-sessions
|
||||||
(str "UPDATE http_session_v2 "
|
(str "UPDATE http_session_v2 "
|
||||||
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
||||||
"WHERE props IS NOT NULL "
|
"WHERE props IS NOT NULL "
|
||||||
"AND jsonb_exists(props -> '~:sso', ?)"))
|
"AND jsonb_exists(props -> '~:sso', ?)"))
|
||||||
|
|
||||||
(defn clear-org-sso-sessions!
|
(defn clear-organization-sso-sessions!
|
||||||
"Remove the SSO entry for organization-id from the props of every
|
"Remove the SSO entry for organization-id from the props of every
|
||||||
session that currently holds it. The key is transit-encoded as the
|
session that currently holds it. The key is transit-encoded as the
|
||||||
string '~u<uuid>' under the '~:sso' path."
|
string '~u<uuid>' under the '~:sso' path."
|
||||||
[pool organization-id]
|
[pool organization-id]
|
||||||
(let [org-key (str "~u" organization-id)]
|
(let [organization-key (str "~u" organization-id)]
|
||||||
(db/exec! pool [sql:clear-org-sso-sessions org-key org-key])))
|
(db/exec! pool [sql:clear-organization-sso-sessions organization-key organization-key])))
|
||||||
|
|
||||||
(defn- renew-session?
|
(defn- renew-session?
|
||||||
[{:keys [id modified-at] :as session}]
|
[{:keys [id modified-at] :as session}]
|
||||||
|
|||||||
@ -154,7 +154,7 @@
|
|||||||
;; COLLECTOR API
|
;; COLLECTOR API
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(declare ^:private prepare-context-from-request)
|
(declare prepare-context-from-request)
|
||||||
|
|
||||||
;; Defines a service that collects the audit/activity log using
|
;; Defines a service that collects the audit/activity log using
|
||||||
;; internal database. Later this audit log can be transferred to
|
;; internal database. Later this audit log can be transferred to
|
||||||
@ -183,7 +183,7 @@
|
|||||||
(def valid-event?
|
(def valid-event?
|
||||||
(sm/validator schema:event))
|
(sm/validator schema:event))
|
||||||
|
|
||||||
(defn- prepare-context-from-request
|
(defn prepare-context-from-request
|
||||||
"Prepare backend event context from request"
|
"Prepare backend event context from request"
|
||||||
[request]
|
[request]
|
||||||
(let [client-event-origin (get-client-event-origin request)
|
(let [client-event-origin (get-client-event-origin request)
|
||||||
@ -337,7 +337,9 @@
|
|||||||
(let [resultm (meta result)
|
(let [resultm (meta result)
|
||||||
request (-> params meta ::http/request)
|
request (-> params meta ::http/request)
|
||||||
profile-id (or (::profile-id resultm)
|
profile-id (or (::profile-id resultm)
|
||||||
(:profile-id result)
|
(some-> (:profile-id result)
|
||||||
|
(cond-> (string? (:profile-id result))
|
||||||
|
uuid/parse*))
|
||||||
(::rpc/profile-id params)
|
(::rpc/profile-id params)
|
||||||
uuid/zero)
|
uuid/zero)
|
||||||
|
|
||||||
@ -412,7 +414,7 @@
|
|||||||
(update :ip-addr d/nilv "0.0.0.0")
|
(update :ip-addr d/nilv "0.0.0.0")
|
||||||
(update :props d/nilv {})
|
(update :props d/nilv {})
|
||||||
(update :context d/nilv {})
|
(update :context d/nilv {})
|
||||||
(assoc :source "backend")
|
(update :source d/nilv "backend")
|
||||||
(d/without-nils))]
|
(d/without-nils))]
|
||||||
(submit* cfg event)))
|
(submit* cfg event)))
|
||||||
|
|
||||||
@ -429,7 +431,7 @@
|
|||||||
(update :profile-id d/nilv uuid/zero)
|
(update :profile-id d/nilv uuid/zero)
|
||||||
(update :props d/nilv {})
|
(update :props d/nilv {})
|
||||||
(update :context d/nilv {})
|
(update :context d/nilv {})
|
||||||
(assoc :source "backend")
|
(update :source d/nilv "backend")
|
||||||
(select-keys event-keys)
|
(select-keys event-keys)
|
||||||
(check-event))]
|
(check-event))]
|
||||||
(db/run! cfg append-audit-entry event))))
|
(db/run! cfg append-audit-entry event))))
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.pprint :as pp]
|
[app.common.pprint :as pp]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
|
[app.common.uri :as u]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
@ -30,11 +31,12 @@
|
|||||||
(defonce enabled (atom true))
|
(defonce enabled (atom true))
|
||||||
|
|
||||||
(defn- persist-on-database!
|
(defn- persist-on-database!
|
||||||
[pool id version report]
|
[pool id source report]
|
||||||
(when-not (db/read-only? pool)
|
(when-not (db/read-only? pool)
|
||||||
(db/insert! pool :server-error-report
|
(db/insert! pool :server-error-report
|
||||||
{:id id
|
{:id id
|
||||||
:version version
|
:source source
|
||||||
|
:version source ;; backward compatibility with old code that reads version column
|
||||||
:content (db/tjson report)})))
|
:content (db/tjson report)})))
|
||||||
|
|
||||||
(defn- concurrent-exception?
|
(defn- concurrent-exception?
|
||||||
@ -56,19 +58,27 @@
|
|||||||
(assoc :backend/version (:full cf/version))
|
(assoc :backend/version (:full cf/version))
|
||||||
(assoc :logger/name logger)
|
(assoc :logger/name logger)
|
||||||
(assoc :logger/level level)
|
(assoc :logger/level level)
|
||||||
(dissoc :request/params :value :params :data))]
|
(dissoc :request/params :value :params :data))
|
||||||
|
|
||||||
|
href (if-let [path (:request/path context)]
|
||||||
|
(str (u/join (cf/get :public-uri) path))
|
||||||
|
(str (cf/get :public-uri)))]
|
||||||
|
|
||||||
(merge
|
(merge
|
||||||
{:context (-> (into (sorted-map) ctx)
|
{:context (-> (into (sorted-map) ctx)
|
||||||
(pp/pprint-str :length 50))
|
(pp/pprint-str :length 50))
|
||||||
:props (pp/pprint-str props :length 50)
|
:props (pp/pprint-str props :length 50)
|
||||||
:hint (or (when-let [message (ex-message cause)]
|
:hint (or (when-let [message (ex-message cause)]
|
||||||
(if-let [props-hint (:hint props)]
|
(if-let [props-hint (:hint props)]
|
||||||
(str props-hint ": " message)
|
(str props-hint ": " message)
|
||||||
message))
|
message))
|
||||||
@message)
|
@message)
|
||||||
:trace (or (::trace record)
|
:trace (or (::trace record)
|
||||||
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
|
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))
|
||||||
|
:tenant (cf/get :tenant)
|
||||||
|
:version (:full cf/version)
|
||||||
|
:profile-id (some-> (:request/profile-id context) str)
|
||||||
|
:href href}
|
||||||
|
|
||||||
(when-let [params (or (:request/params context) (:params context))]
|
(when-let [params (or (:request/params context) (:params context))]
|
||||||
{:params (pp/pprint-str params :length 20 :level 20)})
|
{:params (pp/pprint-str params :length 20 :level 20)})
|
||||||
@ -97,7 +107,7 @@
|
|||||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||||
|
|
||||||
(defn- audit-event->report
|
(defn- audit-event->report
|
||||||
[{:keys [context props ip-addr] :as record}]
|
[{:keys [context props ip-addr profile-id] :as record}]
|
||||||
(let [context
|
(let [context
|
||||||
(reduce-kv (fn [context k v]
|
(reduce-kv (fn [context k v]
|
||||||
(let [k' (keyword "frontend" (name k))]
|
(let [k' (keyword "frontend" (name k))]
|
||||||
@ -115,12 +125,15 @@
|
|||||||
(assoc :backend/version (:full cf/version))
|
(assoc :backend/version (:full cf/version))
|
||||||
(assoc :frontend/ip-addr ip-addr))]
|
(assoc :frontend/ip-addr ip-addr))]
|
||||||
|
|
||||||
{:context (-> (into (sorted-map) context)
|
{:context (-> (into (sorted-map) context)
|
||||||
(pp/pprint-str :length 50))
|
(pp/pprint-str :length 50))
|
||||||
:origin (:name record)
|
:kind (:name record)
|
||||||
:href (get props :href)
|
:profile-id (some-> profile-id str)
|
||||||
:hint (get props :hint)
|
:href (get props :href)
|
||||||
:report (get props :report)}))
|
:hint (get props :hint)
|
||||||
|
:trace (get props :report)
|
||||||
|
:tenant (cf/get :tenant)
|
||||||
|
:version (:full cf/version)}))
|
||||||
|
|
||||||
(defn- handle-audit-event
|
(defn- handle-audit-event
|
||||||
"Convert the log record into a report object and persist it on the database"
|
"Convert the log record into a report object and persist it on the database"
|
||||||
@ -153,10 +166,13 @@
|
|||||||
(-> (into (sorted-map) result)
|
(-> (into (sorted-map) result)
|
||||||
(dissoc ::rlimit/method)))))]
|
(dissoc ::rlimit/method)))))]
|
||||||
|
|
||||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||||
:context (-> (into (sorted-map) context)
|
:context (-> (into (sorted-map) context)
|
||||||
(pp/pprint-str :length 50))
|
(pp/pprint-str :length 50))
|
||||||
:result (pp/pprint-str result :length 50)}))
|
:value (pp/pprint-str result :length 50)
|
||||||
|
:tenant (cf/get :tenant)
|
||||||
|
:version (:full cf/version)
|
||||||
|
:href (str (cf/get :public-uri))}))
|
||||||
|
|
||||||
(defn- handle-rlimit-event
|
(defn- handle-rlimit-event
|
||||||
"Convert the log record into a report object and persist it on the database"
|
"Convert the log record into a report object and persist it on the database"
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
[app.storage.gc-deleted :as-alias sto.gc-deleted]
|
[app.storage.gc-deleted :as-alias sto.gc-deleted]
|
||||||
[app.storage.gc-touched :as-alias sto.gc-touched]
|
[app.storage.gc-touched :as-alias sto.gc-touched]
|
||||||
[app.storage.s3 :as-alias sto.s3]
|
[app.storage.s3 :as-alias sto.s3]
|
||||||
|
[app.system :as sys]
|
||||||
[app.util.cron]
|
[app.util.cron]
|
||||||
[app.worker :as-alias wrk]
|
[app.worker :as-alias wrk]
|
||||||
[app.worker.executor]
|
[app.worker.executor]
|
||||||
@ -45,7 +46,6 @@
|
|||||||
[clojure.tools.namespace.repl :as repl]
|
[clojure.tools.namespace.repl :as repl]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[nrepl.server :as nrepl]
|
|
||||||
[promesa.exec :as px])
|
[promesa.exec :as px])
|
||||||
(:gen-class))
|
(:gen-class))
|
||||||
|
|
||||||
@ -335,6 +335,7 @@
|
|||||||
::rpc/rlimit (ig/ref ::rpc/rlimit)
|
::rpc/rlimit (ig/ref ::rpc/rlimit)
|
||||||
::setup/templates (ig/ref ::setup/templates)
|
::setup/templates (ig/ref ::setup/templates)
|
||||||
::setup/props (ig/ref ::setup/props)
|
::setup/props (ig/ref ::setup/props)
|
||||||
|
::setup/shared-keys (ig/ref ::setup/shared-keys)
|
||||||
|
|
||||||
::email/blacklist (ig/ref ::email/blacklist)
|
::email/blacklist (ig/ref ::email/blacklist)
|
||||||
::email/whitelist (ig/ref ::email/whitelist)
|
::email/whitelist (ig/ref ::email/whitelist)
|
||||||
@ -449,13 +450,17 @@
|
|||||||
::http.client/client (ig/ref ::http.client/client)
|
::http.client/client (ig/ref ::http.client/client)
|
||||||
::setup/props (ig/ref ::setup/props)}
|
::setup/props (ig/ref ::setup/props)}
|
||||||
|
|
||||||
[::srepl/urepl ::srepl/server]
|
::srepl/urepl
|
||||||
{::srepl/port (cf/get :urepl-port 6062)
|
{:port (cf/get :urepl-port 6062)
|
||||||
::srepl/host (cf/get :urepl-host "localhost")}
|
:host (cf/get :urepl-host "localhost")}
|
||||||
|
|
||||||
[::srepl/prepl ::srepl/server]
|
::srepl/prepl
|
||||||
{::srepl/port (cf/get :prepl-port 6063)
|
{:port (cf/get :prepl-port 6063)
|
||||||
::srepl/host (cf/get :prepl-host "localhost")}
|
:host (cf/get :prepl-host "localhost")}
|
||||||
|
|
||||||
|
::srepl/nrepl
|
||||||
|
{:port (cf/get :nrepl-port 6064)
|
||||||
|
:host (cf/get :nrepl-host "localhost")}
|
||||||
|
|
||||||
::setup/templates {}
|
::setup/templates {}
|
||||||
|
|
||||||
@ -468,10 +473,11 @@
|
|||||||
::migrations (ig/ref :app.migrations/migrations)}
|
::migrations (ig/ref :app.migrations/migrations)}
|
||||||
|
|
||||||
::setup/shared-keys
|
::setup/shared-keys
|
||||||
{::setup/props (ig/ref ::setup/props)
|
{::setup/props (ig/ref ::setup/props)
|
||||||
:nexus (cf/get :nexus-shared-key)
|
:nexus (cf/get :nexus-shared-key)
|
||||||
:nitrate (cf/get :nitrate-shared-key)
|
:admin-console (cf/get :admin-console-shared-key)
|
||||||
:exporter (cf/get :exporter-shared-key)}
|
:exporter (cf/get :exporter-shared-key)
|
||||||
|
:media-processor (cf/get :media-processor-shared-key)}
|
||||||
|
|
||||||
::setup/clock
|
::setup/clock
|
||||||
{}
|
{}
|
||||||
@ -589,42 +595,70 @@
|
|||||||
::db/pool (ig/ref ::db/pool)}})
|
::db/pool (ig/ref ::db/pool)}})
|
||||||
|
|
||||||
|
|
||||||
(def system nil)
|
|
||||||
|
|
||||||
(defn start
|
(defn start
|
||||||
[]
|
[]
|
||||||
(cf/validate!)
|
(cf/validate!)
|
||||||
(ig/load-namespaces (merge system-config worker-config))
|
(ig/load-namespaces (merge system-config worker-config))
|
||||||
(alter-var-root #'system (fn [sys]
|
(alter-var-root #'app.system/system
|
||||||
(when sys (ig/halt! sys))
|
(fn [sys]
|
||||||
(-> system-config
|
(some-> sys not-empty ig/halt!)
|
||||||
(cond-> (contains? cf/flags :backend-worker)
|
(-> system-config
|
||||||
(merge worker-config))
|
(cond-> (contains? cf/flags :backend-worker)
|
||||||
(ig/expand)
|
(merge worker-config))
|
||||||
(ig/init))))
|
(ig/expand)
|
||||||
|
(ig/init))))
|
||||||
|
|
||||||
(l/inf :hint "welcome to penpot"
|
(l/inf :hint "welcome to penpot"
|
||||||
:flags (str/join "," (map name cf/flags))
|
:flags (str/join "," (map name cf/flags))
|
||||||
:worker? (contains? cf/flags :backend-worker)
|
:worker? (contains? cf/flags :backend-worker)
|
||||||
:version (:full cf/version)))
|
:version (:full cf/version))
|
||||||
|
:start)
|
||||||
|
|
||||||
|
(defn resume
|
||||||
|
[]
|
||||||
|
(cf/validate!)
|
||||||
|
(ig/load-namespaces (merge system-config worker-config))
|
||||||
|
(alter-var-root #'app.system/system
|
||||||
|
(fn [sys]
|
||||||
|
(let [config (-> system-config
|
||||||
|
(cond-> (contains? cf/flags :backend-worker)
|
||||||
|
(merge worker-config))
|
||||||
|
(ig/expand))]
|
||||||
|
(if-let [sys (not-empty sys)]
|
||||||
|
(ig/resume config sys)
|
||||||
|
(ig/init config)))))
|
||||||
|
:resume)
|
||||||
|
|
||||||
(defn start-custom
|
(defn start-custom
|
||||||
[config]
|
[config]
|
||||||
(ig/load-namespaces config)
|
(ig/load-namespaces config)
|
||||||
(alter-var-root #'system (fn [sys]
|
(alter-var-root #'app.system/system
|
||||||
(when sys (ig/halt! sys))
|
(fn [sys]
|
||||||
(-> config
|
(some-> sys not-empty ig/halt!)
|
||||||
(ig/expand)
|
(-> config
|
||||||
(ig/init)))))
|
(ig/expand)
|
||||||
|
(ig/init)))))
|
||||||
|
|
||||||
(defn stop
|
(defn stop
|
||||||
[]
|
[]
|
||||||
(alter-var-root #'system (fn [sys]
|
(alter-var-root #'app.system/system
|
||||||
(when sys (ig/halt! sys))
|
(fn [sys]
|
||||||
nil)))
|
(some-> sys not-empty ig/halt!)
|
||||||
|
{}))
|
||||||
|
:stop)
|
||||||
|
|
||||||
|
(defn suspend
|
||||||
|
[]
|
||||||
|
(alter-var-root #'app.system/system
|
||||||
|
(fn [sys]
|
||||||
|
(some-> sys not-empty ig/suspend!)
|
||||||
|
sys))
|
||||||
|
:suspend)
|
||||||
|
|
||||||
(defn restart
|
(defn restart
|
||||||
[]
|
[]
|
||||||
(stop)
|
(suspend)
|
||||||
(repl/refresh :after 'app.main/start))
|
(repl/refresh :after 'app.main/resume))
|
||||||
|
|
||||||
(defn restart-all
|
(defn restart-all
|
||||||
[]
|
[]
|
||||||
@ -651,15 +685,15 @@
|
|||||||
(test/test-vars [(resolve o)]))
|
(test/test-vars [(resolve o)]))
|
||||||
(test/test-ns o)))))
|
(test/test-ns o)))))
|
||||||
|
|
||||||
(repl/disable-reload! (find-ns 'integrant.core))
|
|
||||||
|
|
||||||
(defn -main
|
(defn -main
|
||||||
[& _args]
|
[& _args]
|
||||||
(try
|
(try
|
||||||
(let [p (promise)]
|
(ex/ignoring
|
||||||
(l/inf :hint "start nrepl server" :port 6064)
|
(repl/disable-reload! (find-ns 'integrant.core))
|
||||||
(nrepl/start-server :bind "0.0.0.0" :port 6064)
|
(repl/disable-reload! (find-ns 'app.system))
|
||||||
|
(repl/disable-reload! (find-ns 'app.common.debug)))
|
||||||
|
|
||||||
|
(let [p (promise)]
|
||||||
(start)
|
(start)
|
||||||
(deref p))
|
(deref p))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
|
|||||||
@ -5,316 +5,37 @@
|
|||||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
(ns app.media
|
(ns app.media
|
||||||
"Media & Font postprocessing."
|
"Media & Font postprocessing.
|
||||||
|
|
||||||
|
This namespace is the dispatch layer only. Processing implementations
|
||||||
|
live in two separate namespaces, each owning their own defmulti:
|
||||||
|
|
||||||
|
app.media.local — shell/ImageMagick/FontForge implementations
|
||||||
|
app.media.remote — HTTP delegation to media-processor service
|
||||||
|
|
||||||
|
Validation and schemas live in app.media.validation (leaf namespace,
|
||||||
|
no circular dep). When adding a new :cmd type, add defmethods in
|
||||||
|
BOTH local and remote."
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as l]
|
|
||||||
[app.common.media :as cm]
|
|
||||||
[app.common.schema :as sm]
|
|
||||||
[app.common.schema.openapi :as-alias oapi]
|
|
||||||
[app.common.time :as ct]
|
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as-alias db]
|
[app.db :as-alias db]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
|
[app.media.local :as media.local]
|
||||||
|
[app.media.remote :as media.remote]
|
||||||
[app.media.sanitize :as sanitize]
|
[app.media.sanitize :as sanitize]
|
||||||
|
[app.media.validation :as validation]
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
[app.util.shell :as shell]
|
|
||||||
[buddy.core.bytes :as bb]
|
|
||||||
[buddy.core.codecs :as bc]
|
|
||||||
[clojure.string]
|
|
||||||
[clojure.xml :as xml]
|
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[datoteka.fs :as fs]
|
[datoteka.io :as io]))
|
||||||
[datoteka.io :as io])
|
|
||||||
(:import
|
|
||||||
clojure.lang.XMLHandler
|
|
||||||
java.io.InputStream
|
|
||||||
javax.xml.parsers.SAXParserFactory
|
|
||||||
javax.xml.XMLConstants
|
|
||||||
org.apache.commons.io.IOUtils))
|
|
||||||
|
|
||||||
(def schema:upload
|
|
||||||
[:map {:title "Upload"}
|
|
||||||
[:filename :string]
|
|
||||||
[:size ::sm/int]
|
|
||||||
[:path ::fs/path]
|
|
||||||
[:mtype {:optional true} :string]
|
|
||||||
[:headers {:optional true}
|
|
||||||
[:map-of :string :string]]])
|
|
||||||
|
|
||||||
(def ^:private schema:input
|
|
||||||
[:map {:title "Input"}
|
|
||||||
[:path ::fs/path]
|
|
||||||
[:mtype {:optional true} ::sm/text]])
|
|
||||||
|
|
||||||
(def check-input
|
|
||||||
(sm/check-fn schema:input))
|
|
||||||
|
|
||||||
(defn validate-media-type!
|
|
||||||
([upload] (validate-media-type! upload cm/image-types))
|
|
||||||
([upload allowed]
|
|
||||||
(when-not (contains? allowed (:mtype upload))
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :media-type-not-allowed
|
|
||||||
:hint "Seems like you are uploading an invalid media object"))
|
|
||||||
|
|
||||||
upload))
|
|
||||||
|
|
||||||
(defn validate-media-size!
|
|
||||||
[upload]
|
|
||||||
(let [max-size (cf/get :media-max-file-size)]
|
|
||||||
(when (> (:size upload) max-size)
|
|
||||||
(ex/raise :type :restriction
|
|
||||||
:code :media-max-file-size-reached
|
|
||||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
|
||||||
(:size upload)
|
|
||||||
max-size)))
|
|
||||||
upload))
|
|
||||||
|
|
||||||
(defn validate-font-size!
|
|
||||||
"Validates that the font file `upload` does not exceed the configured
|
|
||||||
`:font-max-file-size` limit. Accepts the same map shape as
|
|
||||||
`validate-media-size!` — requires a `:size` key in bytes."
|
|
||||||
[upload]
|
|
||||||
(let [max-size (cf/get :font-max-file-size)]
|
|
||||||
(when (> (:size upload) max-size)
|
|
||||||
(ex/raise :type :restriction
|
|
||||||
:code :font-max-file-size-reached
|
|
||||||
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
|
||||||
(:size upload)
|
|
||||||
max-size)))
|
|
||||||
upload))
|
|
||||||
|
|
||||||
(defmulti process (fn [_system params] (:cmd params)))
|
|
||||||
|
|
||||||
(defmethod process :default
|
|
||||||
[_system {:keys [cmd] :as params}]
|
|
||||||
(ex/raise :type :internal
|
|
||||||
:code :not-implemented
|
|
||||||
:hint (str/fmt "No impl found for process cmd: %s" cmd)))
|
|
||||||
|
|
||||||
(defn run
|
(defn run
|
||||||
[system params]
|
[system params]
|
||||||
(process system params))
|
(if (contains? cf/flags :remote-media-processing)
|
||||||
|
(media.remote/process system params)
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
(media.local/process system params)))
|
||||||
;; SVG PARSING
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(defn- secure-parser-factory
|
|
||||||
[^InputStream input ^XMLHandler handler]
|
|
||||||
(.. (doto (SAXParserFactory/newInstance)
|
|
||||||
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
|
||||||
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
|
||||||
(newSAXParser)
|
|
||||||
(parse input handler)))
|
|
||||||
|
|
||||||
(defn- strip-doctype
|
|
||||||
[data]
|
|
||||||
(cond-> data
|
|
||||||
(str/includes? data "<!DOCTYPE")
|
|
||||||
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
|
||||||
|
|
||||||
(defn- parse-svg
|
|
||||||
[text]
|
|
||||||
(let [text (strip-doctype text)]
|
|
||||||
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
|
||||||
(xml/parse istream secure-parser-factory))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
;; IMAGE THUMBNAILS
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(def ^:private schema:thumbnail-params
|
|
||||||
[:map {:title "ThumbnailParams"}
|
|
||||||
[:input schema:input]
|
|
||||||
[:format [:enum :jpeg :webp :png]]
|
|
||||||
[:quality [:int {:min 1 :max 100}]]
|
|
||||||
[:width :int]
|
|
||||||
[:height :int]])
|
|
||||||
|
|
||||||
(def ^:private check-thumbnail-params
|
|
||||||
(sm/check-fn schema:thumbnail-params))
|
|
||||||
|
|
||||||
;; Related info on how thumbnails generation
|
|
||||||
;; http://www.imagemagick.org/Usage/thumbnails/
|
|
||||||
|
|
||||||
(def ^:private imagemagick-default-env
|
|
||||||
"Default environment variables for ImageMagick resource limits.
|
|
||||||
These are the soft ceiling — policy.xml is the hard ceiling."
|
|
||||||
{"MAGICK_THREAD_LIMIT" "2"
|
|
||||||
"MAGICK_MEMORY_LIMIT" "256MiB"
|
|
||||||
"MAGICK_MAP_LIMIT" "512MiB"
|
|
||||||
"MAGICK_AREA_LIMIT" "128MP"
|
|
||||||
"MAGICK_DISK_LIMIT" "1GiB"
|
|
||||||
"MAGICK_TIME_LIMIT" "30"})
|
|
||||||
|
|
||||||
(defn- get-imagemagick-env
|
|
||||||
"Returns environment variables for ImageMagick commands.
|
|
||||||
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
|
|
||||||
[]
|
|
||||||
(let [thread (cf/get :imagemagick-thread-limit)
|
|
||||||
memory (cf/get :imagemagick-memory-limit)
|
|
||||||
map-l (cf/get :imagemagick-map-limit)
|
|
||||||
area (cf/get :imagemagick-area-limit)
|
|
||||||
disk (cf/get :imagemagick-disk-limit)
|
|
||||||
time (cf/get :imagemagick-time-limit)
|
|
||||||
width (cf/get :imagemagick-width-limit)
|
|
||||||
height (cf/get :imagemagick-height-limit)]
|
|
||||||
(cond-> imagemagick-default-env
|
|
||||||
thread (assoc "MAGICK_THREAD_LIMIT" thread)
|
|
||||||
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
|
|
||||||
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
|
|
||||||
area (assoc "MAGICK_AREA_LIMIT" area)
|
|
||||||
disk (assoc "MAGICK_DISK_LIMIT" disk)
|
|
||||||
time (assoc "MAGICK_TIME_LIMIT" time)
|
|
||||||
width (assoc "MAGICK_WIDTH_LIMIT" width)
|
|
||||||
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
|
|
||||||
|
|
||||||
(defn- exec-magick!
|
|
||||||
"Execute an ImageMagick command with resource limits.
|
|
||||||
`args` is a vector of string arguments to pass to `magick`."
|
|
||||||
[system args]
|
|
||||||
(let [cmd (into ["magick"] args)
|
|
||||||
result (shell/exec! system
|
|
||||||
:cmd cmd
|
|
||||||
:env (get-imagemagick-env)
|
|
||||||
:timeout 60)]
|
|
||||||
(when (not= 0 (:exit result))
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint (str "ImageMagick command failed: " (:err result))
|
|
||||||
:cmd cmd
|
|
||||||
:exit (:exit result)))
|
|
||||||
result))
|
|
||||||
|
|
||||||
(defn- generic-process
|
|
||||||
[system {:keys [input format convert-args] :as params}]
|
|
||||||
(let [{:keys [path mtype]} input
|
|
||||||
format (or format (cm/mtype->format mtype))
|
|
||||||
ext (cm/format->extension format)
|
|
||||||
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
|
||||||
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
|
|
||||||
(exec-magick! system args)
|
|
||||||
(assoc params
|
|
||||||
:format format
|
|
||||||
:mtype (cm/format->mtype format)
|
|
||||||
:size (fs/size tmp)
|
|
||||||
:data tmp)))
|
|
||||||
|
|
||||||
(defmethod process :generic-thumbnail
|
|
||||||
[system params]
|
|
||||||
(let [{:keys [quality width height] :as params}
|
|
||||||
(check-thumbnail-params params)]
|
|
||||||
(generic-process system
|
|
||||||
(assoc params
|
|
||||||
:convert-args ["-auto-orient" "-strip"
|
|
||||||
"-thumbnail" (str width "x" height ">")
|
|
||||||
"-quality" (str quality)]))))
|
|
||||||
|
|
||||||
(defmethod process :profile-thumbnail
|
|
||||||
[system params]
|
|
||||||
(let [{:keys [quality width height] :as params}
|
|
||||||
(check-thumbnail-params params)]
|
|
||||||
(generic-process system
|
|
||||||
(assoc params
|
|
||||||
:convert-args ["-auto-orient" "-strip"
|
|
||||||
"-thumbnail" (str width "x" height "^")
|
|
||||||
"-gravity" "center"
|
|
||||||
"-extent" (str width "x" height)
|
|
||||||
"-quality" (str quality)]))))
|
|
||||||
|
|
||||||
(defn get-basic-info-from-svg
|
|
||||||
[{:keys [tag attrs] :as data}]
|
|
||||||
(when (not= tag :svg)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :unable-to-parse-svg
|
|
||||||
:hint "uploaded svg has invalid content"))
|
|
||||||
(reduce (fn [default f]
|
|
||||||
(if-let [res (f attrs)]
|
|
||||||
(reduced res)
|
|
||||||
default))
|
|
||||||
{:width 100 :height 100}
|
|
||||||
[(fn parse-width-and-height
|
|
||||||
[{:keys [width height]}]
|
|
||||||
(when (and (string? width)
|
|
||||||
(string? height))
|
|
||||||
(let [width (d/parse-double width)
|
|
||||||
height (d/parse-double height)]
|
|
||||||
(when (and width height)
|
|
||||||
{:width (int width)
|
|
||||||
:height (int height)}))))
|
|
||||||
(fn parse-viewbox
|
|
||||||
[{:keys [viewBox]}]
|
|
||||||
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
|
||||||
(map d/parse-double))]
|
|
||||||
(when (and x y width height)
|
|
||||||
{:width (int width)
|
|
||||||
:height (int height)})))]))
|
|
||||||
|
|
||||||
(defn- get-dimensions-with-orientation [system ^String path]
|
|
||||||
;; Image magick doesn't give info about exif rotation so we use the identify command
|
|
||||||
;; If we are processing an animated gif we use the first frame with -scene 0
|
|
||||||
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
|
|
||||||
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
|
|
||||||
(when (= 0 (:exit dim-result))
|
|
||||||
(let [[w h] (-> (:out dim-result)
|
|
||||||
str/trim
|
|
||||||
(clojure.string/split #"\s+")
|
|
||||||
(->> (mapv #(Integer/parseInt %))))
|
|
||||||
orientation-exit (:exit orient-result)
|
|
||||||
orientation (-> orient-result :out str/trim)]
|
|
||||||
(if (= 0 orientation-exit)
|
|
||||||
(case orientation
|
|
||||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
|
||||||
{:width w :height h}) ; Normal or unknown orientation
|
|
||||||
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
|
||||||
|
|
||||||
(defmethod process :info
|
|
||||||
[system {:keys [input] :as params}]
|
|
||||||
(let [{:keys [path mtype] :as input} (check-input input)]
|
|
||||||
(if (= mtype "image/svg+xml")
|
|
||||||
(let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
|
|
||||||
(when-not info
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-svg-file
|
|
||||||
:hint "uploaded svg does not provides dimensions"))
|
|
||||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
|
||||||
|
|
||||||
(let [path-str (str path)
|
|
||||||
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
|
|
||||||
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
|
||||||
mtype' (if (zero? (:exit identify-res))
|
|
||||||
(-> identify-res
|
|
||||||
:out
|
|
||||||
str/trim
|
|
||||||
(str/split #"\s+" 2)
|
|
||||||
first
|
|
||||||
str/lower)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint "invalid image"))
|
|
||||||
{:keys [width height]}
|
|
||||||
(or (get-dimensions-with-orientation system path-str)
|
|
||||||
(do
|
|
||||||
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint "invalid image")))]
|
|
||||||
(when (and (string? mtype)
|
|
||||||
(not= (str/lower mtype) mtype'))
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :media-type-mismatch
|
|
||||||
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
|
||||||
"Expected: " mtype ". Got: " mtype')))
|
|
||||||
(assoc input
|
|
||||||
:width width
|
|
||||||
:height height
|
|
||||||
:size (fs/size path)
|
|
||||||
:ts (ct/now))))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; IMAGE HELPERS
|
;; IMAGE HELPERS
|
||||||
@ -338,8 +59,8 @@
|
|||||||
:hint "seems like the url points to resource with unknown size"))
|
:hint "seems like the url points to resource with unknown size"))
|
||||||
|
|
||||||
(-> {:size size :mtype mtype}
|
(-> {:size size :mtype mtype}
|
||||||
(validate-media-type!)
|
(validation/validate-media-type!)
|
||||||
(validate-media-size!))))]
|
(validation/validate-media-size!))))]
|
||||||
|
|
||||||
(let [{:keys [body] :as response}
|
(let [{:keys [body] :as response}
|
||||||
(try
|
(try
|
||||||
@ -367,188 +88,24 @@
|
|||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :unable-to-download-image
|
:code :unable-to-download-image
|
||||||
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
|
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
|
||||||
:cause cause)))
|
:cause cause)))]
|
||||||
|
|
||||||
{:keys [size mtype]} (parse-and-validate response)
|
(if body
|
||||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
(with-open [body body]
|
||||||
written (io/write* path body :size size)]
|
(let [{:keys [size mtype]} (parse-and-validate response)
|
||||||
|
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||||
|
written (io/write* path body :size size)]
|
||||||
|
|
||||||
(when (not= written size)
|
(when (not= written size)
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :mismatch-write-size
|
:code :mismatch-write-size
|
||||||
:hint "unexpected state: unable to write to file"))
|
:hint "unexpected state: unable to write to file"))
|
||||||
|
|
||||||
;; Sanitize: strip trailing data after image EOF markers
|
;; Sanitize: strip trailing data after image EOF markers
|
||||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||||
{:path path
|
{:path path
|
||||||
:mtype mtype
|
:mtype mtype
|
||||||
:size new-size}))))
|
:size new-size})))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;; No body - validation will raise appropriate error
|
||||||
;; FONTS
|
(parse-and-validate response)))))
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(defn- get-font-prlimit
|
|
||||||
"Returns resource limits for font processing tools, read from config."
|
|
||||||
[]
|
|
||||||
{:mem (cf/get :font-process-mem)
|
|
||||||
:cpu (cf/get :font-process-cpu)})
|
|
||||||
|
|
||||||
(defn- get-font-timeout
|
|
||||||
"Returns the wall-clock timeout for font processing, read from config."
|
|
||||||
[]
|
|
||||||
(cf/get :font-process-timeout))
|
|
||||||
|
|
||||||
(defn- exec-font!
|
|
||||||
"Execute a font processing command with resource limits.
|
|
||||||
`args` is a vector of string arguments."
|
|
||||||
[system args]
|
|
||||||
(shell/exec! system
|
|
||||||
:cmd args
|
|
||||||
:prlimit (get-font-prlimit)
|
|
||||||
:timeout (get-font-timeout)))
|
|
||||||
|
|
||||||
(defmethod process :generate-fonts
|
|
||||||
[system {:keys [input] :as params}]
|
|
||||||
(letfn [(ttf->otf [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
|
||||||
foutput (fs/path (str finput ".otf"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
|
||||||
(str/fmt "Open('%s'); Generate('%s')"
|
|
||||||
(str finput)
|
|
||||||
(str foutput))])]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
foutput))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(otf->ttf [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
|
||||||
foutput (fs/path (str finput ".ttf"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
|
||||||
(str/fmt "Open('%s'); Generate('%s')"
|
|
||||||
(str finput)
|
|
||||||
(str foutput))])]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
foutput))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(ttf-or-otf->woff [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
|
||||||
foutput (fs/path (str finput ".woff"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
foutput))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(woff->sfnt [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (shell/exec! system
|
|
||||||
:cmd ["woff2sfnt" (str finput)]
|
|
||||||
:out-enc :bytes
|
|
||||||
:prlimit (get-font-prlimit)
|
|
||||||
:timeout (get-font-timeout))]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
(:out res)))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(woff2->sfnt [data]
|
|
||||||
;; woff2_decompress outputs to same directory with .ttf extension
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
|
||||||
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
|
|
||||||
(if (zero? (:exit res))
|
|
||||||
foutput
|
|
||||||
(do
|
|
||||||
(when (fs/exists? foutput)
|
|
||||||
(fs/delete foutput))
|
|
||||||
nil)))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
;; Documented here:
|
|
||||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
|
||||||
(get-sfnt-type [data]
|
|
||||||
(let [buff (bb/slice data 0 4)
|
|
||||||
type (bc/bytes->hex buff)]
|
|
||||||
(case type
|
|
||||||
"4f54544f" :otf
|
|
||||||
"00010000" :ttf
|
|
||||||
(ex/raise :type :internal
|
|
||||||
:code :unexpected-data
|
|
||||||
:hint "unexpected font data"))))
|
|
||||||
|
|
||||||
(gen-if-nil [val factory]
|
|
||||||
(if (nil? val)
|
|
||||||
(factory)
|
|
||||||
val))]
|
|
||||||
|
|
||||||
(let [current (into #{} (keys input))]
|
|
||||||
(cond
|
|
||||||
(contains? current "font/ttf")
|
|
||||||
(let [data (get input "font/ttf")]
|
|
||||||
(-> input
|
|
||||||
(update "font/otf" gen-if-nil #(ttf->otf data))
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
|
|
||||||
|
|
||||||
(contains? current "font/otf")
|
|
||||||
(let [data (get input "font/otf")]
|
|
||||||
(-> input
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
|
|
||||||
(assoc "font/ttf" (otf->ttf data))))
|
|
||||||
|
|
||||||
(contains? current "font/woff")
|
|
||||||
(let [data (get input "font/woff")
|
|
||||||
sfnt (woff->sfnt data)]
|
|
||||||
(when-not sfnt
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-woff-file
|
|
||||||
:hint "invalid woff file"))
|
|
||||||
(let [stype (get-sfnt-type sfnt)]
|
|
||||||
(cond-> input
|
|
||||||
true
|
|
||||||
(-> (assoc "font/woff" data))
|
|
||||||
|
|
||||||
(= stype :otf)
|
|
||||||
(-> (assoc "font/otf" sfnt)
|
|
||||||
(assoc "font/ttf" (otf->ttf sfnt)))
|
|
||||||
|
|
||||||
(= stype :ttf)
|
|
||||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
|
||||||
(assoc "font/ttf" sfnt)))))
|
|
||||||
|
|
||||||
(contains? current "font/woff2")
|
|
||||||
(let [data (get input "font/woff2")
|
|
||||||
foutput (woff2->sfnt data)]
|
|
||||||
(when-not foutput
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-woff2-file
|
|
||||||
:hint "invalid woff2 file"))
|
|
||||||
(try
|
|
||||||
(let [sfnt (io/read* foutput)
|
|
||||||
type (get-sfnt-type sfnt)]
|
|
||||||
(cond-> input
|
|
||||||
(= type :otf)
|
|
||||||
(-> (assoc "font/otf" sfnt)
|
|
||||||
(assoc "font/ttf" (otf->ttf sfnt))
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
|
||||||
|
|
||||||
(= type :ttf)
|
|
||||||
(-> (assoc "font/ttf" sfnt)
|
|
||||||
(assoc "font/otf" (ttf->otf sfnt))
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
|
||||||
(finally
|
|
||||||
(fs/delete foutput))))))))
|
|
||||||
|
|||||||
366
backend/src/app/media/local.clj
Normal file
366
backend/src/app/media/local.clj
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.local
|
||||||
|
"Local media processing via ImageMagick and FontForge shell commands."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[app.common.media :as cm]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.config :as cf]
|
||||||
|
[app.media.svg :as svg]
|
||||||
|
[app.media.validation :as validation]
|
||||||
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.shell :as shell]
|
||||||
|
[buddy.core.bytes :as bb]
|
||||||
|
[buddy.core.codecs :as bc]
|
||||||
|
[clojure.string]
|
||||||
|
[cuerdas.core :as str]
|
||||||
|
[datoteka.fs :as fs]
|
||||||
|
[datoteka.io :as io]))
|
||||||
|
|
||||||
|
(defmulti process (fn [_system params] (:cmd params)))
|
||||||
|
|
||||||
|
(defmethod process :default
|
||||||
|
[_system {:keys [cmd] :as params}]
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :not-implemented
|
||||||
|
:hint (str/fmt "No impl found for local process cmd: %s" cmd)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; IMAGE THUMBNAILS
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(def ^:private schema:thumbnail-params
|
||||||
|
[:map {:title "ThumbnailParams"}
|
||||||
|
[:input validation/schema:input]
|
||||||
|
[:format [:enum :jpeg :webp :png]]
|
||||||
|
[:quality [:int {:min 1 :max 100}]]
|
||||||
|
[:width :int]
|
||||||
|
[:height :int]])
|
||||||
|
|
||||||
|
(def ^:private check-thumbnail-params
|
||||||
|
(sm/check-fn schema:thumbnail-params))
|
||||||
|
|
||||||
|
;; Related info on how thumbnails generation
|
||||||
|
;; http://www.imagemagick.org/Usage/thumbnails/
|
||||||
|
|
||||||
|
(def ^:private imagemagick-default-env
|
||||||
|
"Default environment variables for ImageMagick resource limits.
|
||||||
|
These are the soft ceiling — policy.xml is the hard ceiling."
|
||||||
|
{"MAGICK_THREAD_LIMIT" "2"
|
||||||
|
"MAGICK_MEMORY_LIMIT" "256MiB"
|
||||||
|
"MAGICK_MAP_LIMIT" "512MiB"
|
||||||
|
"MAGICK_AREA_LIMIT" "128MP"
|
||||||
|
"MAGICK_DISK_LIMIT" "1GiB"
|
||||||
|
"MAGICK_TIME_LIMIT" "30"})
|
||||||
|
|
||||||
|
(defn- get-imagemagick-env
|
||||||
|
"Returns environment variables for ImageMagick commands.
|
||||||
|
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
|
||||||
|
[]
|
||||||
|
(let [thread (cf/get :imagemagick-thread-limit)
|
||||||
|
memory (cf/get :imagemagick-memory-limit)
|
||||||
|
map-l (cf/get :imagemagick-map-limit)
|
||||||
|
area (cf/get :imagemagick-area-limit)
|
||||||
|
disk (cf/get :imagemagick-disk-limit)
|
||||||
|
time (cf/get :imagemagick-time-limit)
|
||||||
|
width (cf/get :imagemagick-width-limit)
|
||||||
|
height (cf/get :imagemagick-height-limit)]
|
||||||
|
(cond-> imagemagick-default-env
|
||||||
|
thread (assoc "MAGICK_THREAD_LIMIT" thread)
|
||||||
|
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
|
||||||
|
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
|
||||||
|
area (assoc "MAGICK_AREA_LIMIT" area)
|
||||||
|
disk (assoc "MAGICK_DISK_LIMIT" disk)
|
||||||
|
time (assoc "MAGICK_TIME_LIMIT" time)
|
||||||
|
width (assoc "MAGICK_WIDTH_LIMIT" width)
|
||||||
|
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
|
||||||
|
|
||||||
|
(defn- exec-magick!
|
||||||
|
"Execute an ImageMagick command with resource limits.
|
||||||
|
`args` is a vector of string arguments to pass to `magick`."
|
||||||
|
[system args]
|
||||||
|
(let [cmd (into ["magick"] args)
|
||||||
|
result (shell/exec! system
|
||||||
|
:cmd cmd
|
||||||
|
:env (get-imagemagick-env)
|
||||||
|
:timeout 60)]
|
||||||
|
(when (not= 0 (:exit result))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint (str "ImageMagick command failed: " (:err result))
|
||||||
|
:cmd cmd
|
||||||
|
:exit (:exit result)))
|
||||||
|
result))
|
||||||
|
|
||||||
|
(defn- generic-process
|
||||||
|
[system {:keys [input format convert-args] :as params}]
|
||||||
|
(let [{:keys [path mtype]} input
|
||||||
|
format (or format (cm/mtype->format mtype))
|
||||||
|
ext (cm/format->extension format)
|
||||||
|
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||||
|
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
|
||||||
|
(exec-magick! system args)
|
||||||
|
(assoc params
|
||||||
|
:format format
|
||||||
|
:mtype (cm/format->mtype format)
|
||||||
|
:size (fs/size tmp)
|
||||||
|
:data tmp)))
|
||||||
|
|
||||||
|
(defmethod process :generic-thumbnail
|
||||||
|
[system params]
|
||||||
|
(let [{:keys [quality width height] :as params}
|
||||||
|
(check-thumbnail-params params)]
|
||||||
|
(generic-process system
|
||||||
|
(assoc params
|
||||||
|
:convert-args ["-auto-orient" "-strip"
|
||||||
|
"-thumbnail" (str width "x" height ">")
|
||||||
|
"-quality" (str quality)]))))
|
||||||
|
|
||||||
|
(defmethod process :profile-thumbnail
|
||||||
|
[system params]
|
||||||
|
(let [{:keys [quality width height] :as params}
|
||||||
|
(check-thumbnail-params params)]
|
||||||
|
(generic-process system
|
||||||
|
(assoc params
|
||||||
|
:convert-args ["-auto-orient" "-strip"
|
||||||
|
"-thumbnail" (str width "x" height "^")
|
||||||
|
"-gravity" "center"
|
||||||
|
"-extent" (str width "x" height)
|
||||||
|
"-quality" (str quality)]))))
|
||||||
|
|
||||||
|
(defn- get-dimensions-with-orientation [system ^String path]
|
||||||
|
;; Image magick doesn't give info about exif rotation so we use the identify command
|
||||||
|
;; If we are processing an animated gif we use the first frame with -scene 0
|
||||||
|
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
|
||||||
|
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
|
||||||
|
(when (= 0 (:exit dim-result))
|
||||||
|
(let [[w h] (-> (:out dim-result)
|
||||||
|
str/trim
|
||||||
|
(clojure.string/split #"\s+")
|
||||||
|
(->> (mapv #(Integer/parseInt %))))
|
||||||
|
orientation-exit (:exit orient-result)
|
||||||
|
orientation (-> orient-result :out str/trim)]
|
||||||
|
(if (= 0 orientation-exit)
|
||||||
|
(case orientation
|
||||||
|
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||||
|
{:width w :height h}) ; Normal or unknown orientation
|
||||||
|
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
||||||
|
|
||||||
|
(defmethod process :info
|
||||||
|
[system {:keys [input] :as params}]
|
||||||
|
(let [{:keys [path mtype] :as input} (validation/check-input input)]
|
||||||
|
(if (= mtype "image/svg+xml")
|
||||||
|
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||||
|
(when-not info
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "uploaded svg does not provides dimensions"))
|
||||||
|
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||||
|
|
||||||
|
(let [path-str (str path)
|
||||||
|
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
|
||||||
|
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
||||||
|
mtype' (if (zero? (:exit identify-res))
|
||||||
|
(-> identify-res
|
||||||
|
:out
|
||||||
|
str/trim
|
||||||
|
(str/split #"\s+" 2)
|
||||||
|
first
|
||||||
|
str/lower)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint "invalid image"))
|
||||||
|
{:keys [width height]}
|
||||||
|
(or (get-dimensions-with-orientation system path-str)
|
||||||
|
(do
|
||||||
|
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint "invalid image")))]
|
||||||
|
(when (and (string? mtype)
|
||||||
|
(not= (str/lower mtype) mtype'))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :media-type-mismatch
|
||||||
|
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
||||||
|
"Expected: " mtype ". Got: " mtype')))
|
||||||
|
(assoc input
|
||||||
|
:width width
|
||||||
|
:height height
|
||||||
|
:size (fs/size path)
|
||||||
|
:ts (ct/now))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; FONTS
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn- get-font-prlimit
|
||||||
|
"Returns resource limits for font processing tools, read from config."
|
||||||
|
[]
|
||||||
|
{:mem (cf/get :font-process-mem)
|
||||||
|
:cpu (cf/get :font-process-cpu)})
|
||||||
|
|
||||||
|
(defn- get-font-timeout
|
||||||
|
"Returns the wall-clock timeout for font processing, read from config."
|
||||||
|
[]
|
||||||
|
(cf/get :font-process-timeout))
|
||||||
|
|
||||||
|
(defn- exec-font!
|
||||||
|
"Execute a font processing command with resource limits.
|
||||||
|
`args` is a vector of string arguments."
|
||||||
|
[system args]
|
||||||
|
(shell/exec! system
|
||||||
|
:cmd args
|
||||||
|
:prlimit (get-font-prlimit)
|
||||||
|
:timeout (get-font-timeout)))
|
||||||
|
|
||||||
|
(defmethod process :generate-fonts
|
||||||
|
[system {:keys [input] :as params}]
|
||||||
|
(letfn [(ttf->otf [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
|
foutput (fs/path (str finput ".otf"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||||
|
(str/fmt "Open('%s'); Generate('%s')"
|
||||||
|
(str finput)
|
||||||
|
(str foutput))])]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(otf->ttf [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
|
foutput (fs/path (str finput ".ttf"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||||
|
(str/fmt "Open('%s'); Generate('%s')"
|
||||||
|
(str finput)
|
||||||
|
(str foutput))])]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(ttf-or-otf->woff [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
|
foutput (fs/path (str finput ".woff"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(woff->sfnt [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (shell/exec! system
|
||||||
|
:cmd ["woff2sfnt" (str finput)]
|
||||||
|
:out-enc :bytes
|
||||||
|
:prlimit (get-font-prlimit)
|
||||||
|
:timeout (get-font-timeout))]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
(:out res)))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(woff2->sfnt [data]
|
||||||
|
;; woff2_decompress outputs to same directory with .ttf extension
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
||||||
|
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
|
||||||
|
(if (zero? (:exit res))
|
||||||
|
foutput
|
||||||
|
(do
|
||||||
|
(when (fs/exists? foutput)
|
||||||
|
(fs/delete foutput))
|
||||||
|
nil)))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
;; Documented here:
|
||||||
|
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||||
|
(get-sfnt-type [data]
|
||||||
|
(let [buff (bb/slice data 0 4)
|
||||||
|
type (bc/bytes->hex buff)]
|
||||||
|
(case type
|
||||||
|
"4f54544f" :otf
|
||||||
|
"00010000" :ttf
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :unexpected-data
|
||||||
|
:hint "unexpected font data"))))
|
||||||
|
|
||||||
|
(gen-if-nil [val factory]
|
||||||
|
(if (nil? val)
|
||||||
|
(factory)
|
||||||
|
val))]
|
||||||
|
|
||||||
|
(let [current (into #{} (keys input))]
|
||||||
|
(cond
|
||||||
|
(contains? current "font/ttf")
|
||||||
|
(let [data (get input "font/ttf")]
|
||||||
|
(-> input
|
||||||
|
(update "font/otf" gen-if-nil #(ttf->otf data))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
|
||||||
|
|
||||||
|
(contains? current "font/otf")
|
||||||
|
(let [data (get input "font/otf")]
|
||||||
|
(-> input
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
|
||||||
|
(assoc "font/ttf" (otf->ttf data))))
|
||||||
|
|
||||||
|
(contains? current "font/woff")
|
||||||
|
(let [data (get input "font/woff")
|
||||||
|
sfnt (woff->sfnt data)]
|
||||||
|
(when-not sfnt
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-woff-file
|
||||||
|
:hint "invalid woff file"))
|
||||||
|
(let [stype (get-sfnt-type sfnt)]
|
||||||
|
(cond-> input
|
||||||
|
true
|
||||||
|
(-> (assoc "font/woff" data))
|
||||||
|
|
||||||
|
(= stype :otf)
|
||||||
|
(-> (assoc "font/otf" sfnt)
|
||||||
|
(assoc "font/ttf" (otf->ttf sfnt)))
|
||||||
|
|
||||||
|
(= stype :ttf)
|
||||||
|
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||||
|
(assoc "font/ttf" sfnt)))))
|
||||||
|
|
||||||
|
(contains? current "font/woff2")
|
||||||
|
(let [data (get input "font/woff2")
|
||||||
|
foutput (woff2->sfnt data)]
|
||||||
|
(when-not foutput
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-woff2-file
|
||||||
|
:hint "invalid woff2 file"))
|
||||||
|
(try
|
||||||
|
(let [sfnt (io/read* foutput)
|
||||||
|
type (get-sfnt-type sfnt)]
|
||||||
|
(cond-> input
|
||||||
|
(= type :otf)
|
||||||
|
(-> (assoc "font/otf" sfnt)
|
||||||
|
(assoc "font/ttf" (otf->ttf sfnt))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
||||||
|
|
||||||
|
(= type :ttf)
|
||||||
|
(-> (assoc "font/ttf" sfnt)
|
||||||
|
(assoc "font/otf" (ttf->otf sfnt))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
||||||
|
(finally
|
||||||
|
(fs/delete foutput))))))))
|
||||||
264
backend/src/app/media/remote.clj
Normal file
264
backend/src/app/media/remote.clj
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.remote
|
||||||
|
"Remote media processing via the media-processor HTTP service."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.media :as cm]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.common.uri :as uri]
|
||||||
|
[app.config :as cf]
|
||||||
|
[app.http.client :as http]
|
||||||
|
[app.media.svg :as svg]
|
||||||
|
[app.media.validation :as validation]
|
||||||
|
[app.setup :as-alias setup]
|
||||||
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.json :as json]
|
||||||
|
[cuerdas.core :as str]
|
||||||
|
[datoteka.fs :as fs]
|
||||||
|
[datoteka.io :as io])
|
||||||
|
(:import
|
||||||
|
java.io.ByteArrayInputStream
|
||||||
|
java.io.InputStream
|
||||||
|
java.io.SequenceInputStream
|
||||||
|
java.net.ConnectException
|
||||||
|
java.net.http.HttpTimeoutException
|
||||||
|
java.util.Collections))
|
||||||
|
|
||||||
|
(defn- service-base-url
|
||||||
|
"Returns the base URL of the media-processor service."
|
||||||
|
[]
|
||||||
|
(or (cf/get :media-processing-service-uri)
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :media-processor-not-configured
|
||||||
|
:hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured")))
|
||||||
|
|
||||||
|
(defn- service-timeout
|
||||||
|
"Returns the HTTP timeout (ms) for media-processor requests."
|
||||||
|
[]
|
||||||
|
(or (cf/get :media-processing-service-timeout)
|
||||||
|
120000))
|
||||||
|
|
||||||
|
(defn- get-shared-key
|
||||||
|
"Returns the shared key for authenticating with the media-processor."
|
||||||
|
[system]
|
||||||
|
(-> system ::setup/shared-keys :media-processor))
|
||||||
|
|
||||||
|
(defn- parse-json-response
|
||||||
|
"Parse a JSON response body."
|
||||||
|
[body]
|
||||||
|
(json/read! body))
|
||||||
|
|
||||||
|
(defn- translate-error
|
||||||
|
"Translate a media-processor error response into a Penpot exception."
|
||||||
|
[status body]
|
||||||
|
(let [code (or (:code body) "media-processor-error")
|
||||||
|
hint (or (:hint body) "media-processor request failed")]
|
||||||
|
(case status
|
||||||
|
400 {:type :validation :code (keyword code) :hint hint}
|
||||||
|
403 {:type :authorization :code :forbidden :hint hint}
|
||||||
|
413 {:type :restriction :code (keyword code) :hint hint}
|
||||||
|
504 {:type :internal :code :media-processor-timeout :hint hint}
|
||||||
|
{:type :internal :code (keyword code) :hint hint})))
|
||||||
|
|
||||||
|
(defn service-request
|
||||||
|
"Make an HTTP request to the media-processor service."
|
||||||
|
[system {:keys [method uri body headers timeout]}]
|
||||||
|
(let [client (::http/client system)
|
||||||
|
timeout (or timeout (service-timeout))]
|
||||||
|
(try
|
||||||
|
(let [resp (http/req client
|
||||||
|
{:method method
|
||||||
|
:uri uri
|
||||||
|
:body body
|
||||||
|
:headers headers}
|
||||||
|
{:response-type :input-stream
|
||||||
|
:skip-ssrf-check? true
|
||||||
|
:timeout timeout})
|
||||||
|
status (:status resp)]
|
||||||
|
(when (not (<= 200 status 299))
|
||||||
|
(let [body (:body resp)]
|
||||||
|
(try
|
||||||
|
(let [parsed (try (parse-json-response body) (catch Exception _ nil))
|
||||||
|
err (translate-error status parsed)]
|
||||||
|
(ex/raise :type (:type err) :code (:code err) :hint (:hint err)))
|
||||||
|
(finally
|
||||||
|
(.close body)))))
|
||||||
|
resp)
|
||||||
|
(catch ConnectException _cause
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :media-processor-unavailable
|
||||||
|
:hint "Cannot connect to media-processor service"))
|
||||||
|
(catch HttpTimeoutException _cause
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :media-processor-timeout
|
||||||
|
:hint "media-processor service request timed out")))))
|
||||||
|
|
||||||
|
(defn- multipart-boundary
|
||||||
|
[]
|
||||||
|
(str "----PenpotBoundary" (System/currentTimeMillis)))
|
||||||
|
|
||||||
|
(defn- build-multipart-stream
|
||||||
|
"Build a streaming multipart/form-data body with a single file field.
|
||||||
|
Returns an InputStream that lazily reads from the file on demand."
|
||||||
|
[^String boundary mtype ^InputStream file-stream]
|
||||||
|
(let [header (.getBytes (str "--" boundary "\r\n"
|
||||||
|
"Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
|
||||||
|
"Content-Type: " mtype "\r\n"
|
||||||
|
"\r\n")
|
||||||
|
"UTF-8")
|
||||||
|
footer (.getBytes (str "\r\n--" boundary "--\r\n")
|
||||||
|
"UTF-8")
|
||||||
|
parts (Collections/enumeration
|
||||||
|
[(ByteArrayInputStream. header)
|
||||||
|
file-stream
|
||||||
|
(ByteArrayInputStream. footer)])]
|
||||||
|
(SequenceInputStream. parts)))
|
||||||
|
|
||||||
|
(defn- service-multipart-request
|
||||||
|
"Send a multipart request to the media-processor service.
|
||||||
|
Accepts a file from disk via :path. The file stream is closed
|
||||||
|
after the HTTP request completes (success or failure)."
|
||||||
|
[system {:keys [endpoint path mtype query timeout]}]
|
||||||
|
(let [shared-key (get-shared-key system)
|
||||||
|
boundary (multipart-boundary)
|
||||||
|
ctype (or mtype "application/octet-stream")
|
||||||
|
base-url (service-base-url)
|
||||||
|
request-uri (cond-> (uri/join base-url endpoint)
|
||||||
|
(seq query)
|
||||||
|
(str "?" (uri/map->query-string query)))]
|
||||||
|
(with-open [file-stream (io/input-stream path)]
|
||||||
|
(let [body (build-multipart-stream boundary ctype file-stream)]
|
||||||
|
(service-request system
|
||||||
|
{:method :post
|
||||||
|
:uri request-uri
|
||||||
|
:body body
|
||||||
|
:headers {"Content-Type" (str "multipart/form-data; boundary=" boundary)
|
||||||
|
"x-shared-key" shared-key}
|
||||||
|
:timeout timeout})))))
|
||||||
|
|
||||||
|
(def ^:private known-font-types
|
||||||
|
"Priority-ordered list of font mime-types the system knows how to convert.
|
||||||
|
Order matters: when a font upload contains multiple variants, the first
|
||||||
|
match becomes the conversion source (ttf preferred for best coverage)."
|
||||||
|
["font/ttf" "font/otf" "font/woff" "font/woff2"])
|
||||||
|
|
||||||
|
(defn- font-convert
|
||||||
|
"Convert a font to the given target mime-type via the media-processor service.
|
||||||
|
Accepts source font data as a filesystem Path. Returns a tempfile Path."
|
||||||
|
[system source-mtype target-mtype data]
|
||||||
|
(let [resp (service-multipart-request system {:endpoint "api/font/convert"
|
||||||
|
:path data
|
||||||
|
:mtype source-mtype
|
||||||
|
:query {:target-type target-mtype}
|
||||||
|
:timeout 180000})
|
||||||
|
ext (cm/mtype->extension target-mtype)
|
||||||
|
tmp (tmp/tempfile :prefix "penpot.font." :suffix ext)
|
||||||
|
body (:body resp)]
|
||||||
|
(try
|
||||||
|
(io/write* tmp body)
|
||||||
|
(finally
|
||||||
|
(.close body)))
|
||||||
|
tmp))
|
||||||
|
|
||||||
|
(defn- font-missing-variants
|
||||||
|
"Return the set of target mime-types that should be generated for the given
|
||||||
|
source mime-type (excluding font/woff2, which is never generated)."
|
||||||
|
[source-mtype]
|
||||||
|
(case source-mtype
|
||||||
|
"font/ttf" #{"font/otf" "font/woff"}
|
||||||
|
"font/otf" #{"font/ttf" "font/woff"}
|
||||||
|
"font/woff" #{"font/ttf" "font/otf"}
|
||||||
|
"font/woff2" #{"font/ttf" "font/otf" "font/woff"}))
|
||||||
|
|
||||||
|
(defmulti process (fn [_system params] (:cmd params)))
|
||||||
|
|
||||||
|
(defmethod process :info
|
||||||
|
[system {:keys [input]}]
|
||||||
|
(let [{:keys [path mtype]} (validation/check-input input)]
|
||||||
|
(if (= mtype "image/svg+xml")
|
||||||
|
;; SVG: parse locally (Sharp doesn't support SVG)
|
||||||
|
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||||
|
(when-not info
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "uploaded svg does not provide dimensions"))
|
||||||
|
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||||
|
;; Raster: delegate to media-processor
|
||||||
|
(let [resp (service-multipart-request system {:endpoint "api/image/info"
|
||||||
|
:path path
|
||||||
|
:mtype mtype})
|
||||||
|
body (:body resp)]
|
||||||
|
(try
|
||||||
|
(let [info (parse-json-response body)
|
||||||
|
detected-mtype (:mtype info)]
|
||||||
|
(when (and (string? mtype)
|
||||||
|
(string? detected-mtype)
|
||||||
|
(not= (str/lower mtype) (str/lower detected-mtype)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :media-type-mismatch
|
||||||
|
:hint (str "File content does not match the declared type. "
|
||||||
|
"Expected: " mtype ". Got: " detected-mtype)))
|
||||||
|
(assoc input
|
||||||
|
:width (:width info)
|
||||||
|
:height (:height info)
|
||||||
|
:size (fs/size path)
|
||||||
|
:ts (ct/now)))
|
||||||
|
(finally
|
||||||
|
(.close body)))))))
|
||||||
|
|
||||||
|
(defn- thumbnail-request
|
||||||
|
"Shared implementation for generic-thumbnail and profile-thumbnail."
|
||||||
|
[system params mode]
|
||||||
|
(let [{:keys [input format quality width height]} params
|
||||||
|
{:keys [path mtype]} (validation/check-input input)
|
||||||
|
fmt (name (or format (cm/mtype->format mtype) :jpeg))
|
||||||
|
resp (service-multipart-request system {:endpoint "api/image/thumbnail"
|
||||||
|
:path path
|
||||||
|
:mtype mtype
|
||||||
|
:query {:width width
|
||||||
|
:height height
|
||||||
|
:quality quality
|
||||||
|
:format fmt
|
||||||
|
:mode mode}})
|
||||||
|
out-format (or format (cm/mtype->format mtype) :jpeg)
|
||||||
|
ext (cm/format->extension out-format)
|
||||||
|
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||||
|
body (:body resp)]
|
||||||
|
(try
|
||||||
|
(io/write* tmp body)
|
||||||
|
(finally
|
||||||
|
(.close body)))
|
||||||
|
(assoc params
|
||||||
|
:format out-format
|
||||||
|
:mtype (cm/format->mtype out-format)
|
||||||
|
:size (fs/size tmp)
|
||||||
|
:data tmp)))
|
||||||
|
|
||||||
|
(defmethod process :generic-thumbnail
|
||||||
|
[system params]
|
||||||
|
(thumbnail-request system params "fit"))
|
||||||
|
|
||||||
|
(defmethod process :profile-thumbnail
|
||||||
|
[system params]
|
||||||
|
(thumbnail-request system params "crop"))
|
||||||
|
|
||||||
|
(defmethod process :generate-fonts
|
||||||
|
[system {:keys [input]}]
|
||||||
|
(let [source-mtype (or (some #(when (contains? input %) %) known-font-types)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-font
|
||||||
|
:hint "No recognized font variant in input"))
|
||||||
|
data (get input source-mtype)
|
||||||
|
present (set (keys input))
|
||||||
|
targets (remove present (font-missing-variants source-mtype))]
|
||||||
|
(reduce (fn [acc target-mtype]
|
||||||
|
(assoc acc target-mtype
|
||||||
|
(font-convert system source-mtype target-mtype data)))
|
||||||
|
input
|
||||||
|
targets)))
|
||||||
|
|
||||||
130
backend/src/app/media/svg.clj
Normal file
130
backend/src/app/media/svg.clj
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.svg
|
||||||
|
"SVG parsing, sanitization, and info extraction.
|
||||||
|
Centralizes all SVG-related security concerns."
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[clojure.xml :as xml]
|
||||||
|
[cuerdas.core :as str])
|
||||||
|
(:import
|
||||||
|
clojure.lang.XMLHandler
|
||||||
|
java.io.InputStream
|
||||||
|
javax.xml.parsers.SAXParserFactory
|
||||||
|
javax.xml.XMLConstants
|
||||||
|
org.apache.commons.io.IOUtils))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; SVG PARSING
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn- secure-parser-factory
|
||||||
|
[^InputStream input ^XMLHandler handler]
|
||||||
|
(.. (doto (SAXParserFactory/newInstance)
|
||||||
|
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
||||||
|
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
||||||
|
(newSAXParser)
|
||||||
|
(parse input handler)))
|
||||||
|
|
||||||
|
(defn- strip-doctype
|
||||||
|
[data]
|
||||||
|
(cond-> data
|
||||||
|
(str/includes? data "<!DOCTYPE")
|
||||||
|
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
||||||
|
|
||||||
|
(defn parse-svg
|
||||||
|
[text]
|
||||||
|
(let [text (strip-doctype text)]
|
||||||
|
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
||||||
|
(xml/parse istream secure-parser-factory))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; SVG SANITIZATION
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$")
|
||||||
|
(def ^:private javascript-href-pattern #"(?i)^javascript:")
|
||||||
|
|
||||||
|
(defn- sanitize-svg-element
|
||||||
|
"Recursively sanitize an SVG element by removing dangerous tags and attributes."
|
||||||
|
[{:keys [tag attrs content] :as element}]
|
||||||
|
(when (and (map? element) tag)
|
||||||
|
(let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}]
|
||||||
|
(when-not (contains? dangerous-tags tag)
|
||||||
|
(let [clean-attrs (->> attrs
|
||||||
|
(remove (fn [[k v]]
|
||||||
|
(or (re-matches dangerous-attrs-pattern (name k))
|
||||||
|
(and (#{:href :xlink:href} k)
|
||||||
|
(string? v)
|
||||||
|
(re-find javascript-href-pattern (str/trim v))))))
|
||||||
|
(into {}))
|
||||||
|
clean-content (when content
|
||||||
|
(->> content
|
||||||
|
(filter #(or (string? %) (map? %)))
|
||||||
|
(map (fn [child]
|
||||||
|
(if (map? child)
|
||||||
|
(sanitize-svg-element child)
|
||||||
|
child)))
|
||||||
|
(filter some?)
|
||||||
|
vec))]
|
||||||
|
(cond-> {:tag tag :attrs clean-attrs}
|
||||||
|
(seq clean-content) (assoc :content clean-content)))))))
|
||||||
|
|
||||||
|
(defn sanitize-svg
|
||||||
|
"Sanitize SVG content by removing dangerous elements and attributes.
|
||||||
|
Removes <script> tags, <foreignObject> elements, event handlers (on*),
|
||||||
|
and javascript: URLs from href attributes."
|
||||||
|
[svg-text]
|
||||||
|
(try
|
||||||
|
(let [parsed (parse-svg svg-text)
|
||||||
|
sanitized (sanitize-svg-element parsed)]
|
||||||
|
(if sanitized
|
||||||
|
(with-out-str (xml/emit sanitized))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "SVG sanitization produced no output")))
|
||||||
|
(catch Exception e
|
||||||
|
(l/warn :hint "SVG sanitization failed, rejecting upload" :cause e)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "SVG parsing failed during sanitization"
|
||||||
|
:cause e))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; SVG INFO EXTRACTION
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn get-basic-info-from-svg
|
||||||
|
[{:keys [tag attrs] :as data}]
|
||||||
|
(when (not= tag :svg)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :unable-to-parse-svg
|
||||||
|
:hint "uploaded svg has invalid content"))
|
||||||
|
(reduce (fn [default f]
|
||||||
|
(if-let [res (f attrs)]
|
||||||
|
(reduced res)
|
||||||
|
default))
|
||||||
|
{:width 100 :height 100}
|
||||||
|
[(fn parse-width-and-height
|
||||||
|
[{:keys [width height]}]
|
||||||
|
(when (and (string? width)
|
||||||
|
(string? height))
|
||||||
|
(let [width (d/parse-double width)
|
||||||
|
height (d/parse-double height)]
|
||||||
|
(when (and width height)
|
||||||
|
{:width (int width)
|
||||||
|
:height (int height)}))))
|
||||||
|
(fn parse-viewbox
|
||||||
|
[{:keys [viewBox]}]
|
||||||
|
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
||||||
|
(map d/parse-double))]
|
||||||
|
(when (and x y width height)
|
||||||
|
{:width (int width)
|
||||||
|
:height (int height)})))]))
|
||||||
68
backend/src/app/media/validation.clj
Normal file
68
backend/src/app/media/validation.clj
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.validation
|
||||||
|
"Schemas and validation functions for media uploads.
|
||||||
|
Leaf namespace — depends on app.common.* and app.config only."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.media :as cm]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.config :as cf]
|
||||||
|
[cuerdas.core :as str]
|
||||||
|
[datoteka.fs :as fs]))
|
||||||
|
|
||||||
|
(def schema:upload
|
||||||
|
[:map {:title "Upload"}
|
||||||
|
[:filename :string]
|
||||||
|
[:size ::sm/int]
|
||||||
|
[:path ::fs/path]
|
||||||
|
[:mtype {:optional true} :string]
|
||||||
|
[:headers {:optional true}
|
||||||
|
[:map-of :string :string]]])
|
||||||
|
|
||||||
|
(def schema:input
|
||||||
|
[:map {:title "Input"}
|
||||||
|
[:path ::fs/path]
|
||||||
|
[:mtype {:optional true} ::sm/text]])
|
||||||
|
|
||||||
|
(def check-input
|
||||||
|
(sm/check-fn schema:input))
|
||||||
|
|
||||||
|
(defn validate-media-type!
|
||||||
|
([upload] (validate-media-type! upload cm/image-types))
|
||||||
|
([upload allowed]
|
||||||
|
(when-not (contains? allowed (:mtype upload))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :media-type-not-allowed
|
||||||
|
:hint "Seems like you are uploading an invalid media object"))
|
||||||
|
|
||||||
|
upload))
|
||||||
|
|
||||||
|
(defn validate-media-size!
|
||||||
|
[upload]
|
||||||
|
(let [max-size (cf/get :media-max-file-size)]
|
||||||
|
(when (> (:size upload) max-size)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :media-max-file-size-reached
|
||||||
|
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||||
|
(:size upload)
|
||||||
|
max-size)))
|
||||||
|
upload))
|
||||||
|
|
||||||
|
(defn validate-font-size!
|
||||||
|
"Validates that the font file `upload` does not exceed the configured
|
||||||
|
`:font-max-file-size` limit. Accepts the same map shape as
|
||||||
|
`validate-media-size!` — requires a `:size` key in bytes."
|
||||||
|
[upload]
|
||||||
|
(let [max-size (cf/get :font-max-file-size)]
|
||||||
|
(when (> (:size upload) max-size)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :font-max-file-size-reached
|
||||||
|
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
||||||
|
(:size upload)
|
||||||
|
max-size)))
|
||||||
|
upload))
|
||||||
@ -496,7 +496,10 @@
|
|||||||
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
|
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
|
||||||
|
|
||||||
{:name "0152-improve-uuid-defaults-and-drop-extension"
|
{:name "0152-improve-uuid-defaults-and-drop-extension"
|
||||||
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")}])
|
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")}
|
||||||
|
|
||||||
|
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
|
||||||
|
|
||||||
(defn apply-migrations!
|
(defn apply-migrations!
|
||||||
[pool name migrations]
|
[pool name migrations]
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
-- Add source column (keep version column as-is for backward compatibility)
|
||||||
|
ALTER TABLE server_error_report
|
||||||
|
ADD COLUMN source integer;
|
||||||
|
|
||||||
|
-- Trigger function to sync version -> source (backward compatibility with old code)
|
||||||
|
CREATE OR REPLACE FUNCTION server_error_report__sync_version_to_source()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.version IS NOT NULL AND NEW.source IS NULL THEN
|
||||||
|
NEW.source := NEW.version;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger fires on INSERT or UPDATE OF version column
|
||||||
|
CREATE TRIGGER server_error_report__sync_version_to_source__tgr
|
||||||
|
BEFORE INSERT OR UPDATE OF version ON server_error_report
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION server_error_report__sync_version_to_source();
|
||||||
|
|
||||||
|
-- Backfill existing rows
|
||||||
|
UPDATE server_error_report SET source = version WHERE source IS NULL;
|
||||||
|
|
||||||
|
-- Drop old version index
|
||||||
|
DROP INDEX IF EXISTS server_error_report__version__idx;
|
||||||
|
|
||||||
|
-- Create new source index
|
||||||
|
CREATE INDEX server_error_report__source__idx
|
||||||
|
ON server_error_report (source);
|
||||||
|
|
||||||
|
-- Content-based indexes
|
||||||
|
CREATE INDEX server_error_report__content_kind__idx
|
||||||
|
ON server_error_report (COALESCE(content->>'~:kind', content->>'~:origin'));
|
||||||
|
|
||||||
|
CREATE INDEX server_error_report__content_tenant__idx
|
||||||
|
ON server_error_report ((content->>'~:tenant'));
|
||||||
|
|
||||||
|
CREATE INDEX server_error_report__content_version__idx
|
||||||
|
ON server_error_report ((content->>'~:version'));
|
||||||
|
|
||||||
|
-- Index for pagination
|
||||||
|
CREATE INDEX server_error_report__created_at_id__idx
|
||||||
|
ON server_error_report (created_at DESC, id DESC);
|
||||||
@ -49,7 +49,7 @@
|
|||||||
"Joins relative path segments to the Nitrate backend URI.
|
"Joins relative path segments to the Nitrate backend URI.
|
||||||
Segments must not start with `/`"
|
Segments must not start with `/`"
|
||||||
[& segments]
|
[& segments]
|
||||||
(apply join-base-uri (cf/get :nitrate-backend-uri) segments))
|
(apply join-base-uri (cf/get :admin-console-uri) segments))
|
||||||
|
|
||||||
(defn- generate-public-uri
|
(defn- generate-public-uri
|
||||||
"Joins relative path segments to the public backend URI.
|
"Joins relative path segments to the public backend URI.
|
||||||
@ -143,7 +143,7 @@
|
|||||||
|
|
||||||
(defn- request-to-nitrate
|
(defn- request-to-nitrate
|
||||||
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
|
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
|
||||||
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
|
(let [shared-key (-> cfg ::setup/shared-keys :admin-console)
|
||||||
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
|
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
|
||||||
(with-retries 3)
|
(with-retries 3)
|
||||||
(with-validate uri schema :throw-on-error? throw-on-error?))]
|
(with-validate uri schema :throw-on-error? throw-on-error?))]
|
||||||
@ -155,14 +155,14 @@
|
|||||||
|
|
||||||
(defn call
|
(defn call
|
||||||
[cfg method params]
|
[cfg method params]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [client (get cfg ::client)
|
(let [client (get cfg ::client)
|
||||||
method (get client method)]
|
method (get client method)]
|
||||||
(method params))))
|
(method params))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(def ^:private schema:org-summary
|
(def ^:private schema:organization-summary
|
||||||
[:map
|
[:map
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:name ::sm/text]
|
[:name ::sm/text]
|
||||||
@ -173,13 +173,6 @@
|
|||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:is-your-penpot :boolean]]]]])
|
[:is-your-penpot :boolean]]]]])
|
||||||
|
|
||||||
(def ^:private schema:profile-org
|
|
||||||
[:map
|
|
||||||
[:is-member :boolean]
|
|
||||||
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
|
||||||
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
|
|
||||||
|
|
||||||
|
|
||||||
;; TODO Unify with schemas on backend/src/app/http/management.clj
|
;; TODO Unify with schemas on backend/src/app/http/management.clj
|
||||||
(def ^:private schema:timestamp
|
(def ^:private schema:timestamp
|
||||||
(sm/type-schema
|
(sm/type-schema
|
||||||
@ -196,6 +189,13 @@
|
|||||||
:decode/json ct/inst
|
:decode/json ct/inst
|
||||||
:encode/json inst-ms}}))
|
:encode/json inst-ms}}))
|
||||||
|
|
||||||
|
(def ^:private schema:profile-organization
|
||||||
|
[:map
|
||||||
|
[:is-member :boolean]
|
||||||
|
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:default-team-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:created-at {:optional true} [:maybe schema:timestamp]]])
|
||||||
|
|
||||||
(def ^:private schema:subscription
|
(def ^:private schema:subscription
|
||||||
[:map {:title "Subscription"}
|
[:map {:title "Subscription"}
|
||||||
[:id ::sm/text]
|
[:id ::sm/text]
|
||||||
@ -253,13 +253,13 @@
|
|||||||
[:map
|
[:map
|
||||||
[:licenses ::sm/boolean]])
|
[:licenses ::sm/boolean]])
|
||||||
|
|
||||||
(defn- get-team-org-api
|
(defn- get-team-organization-api
|
||||||
[cfg {:keys [team-id] :as params}]
|
[cfg {:keys [team-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri "api/teams/" team-id)
|
(generate-nitrate-uri "api/teams/" team-id)
|
||||||
cto/schema:team-with-organization params))
|
cto/schema:team-with-organization params))
|
||||||
|
|
||||||
(defn- get-org-membership-api
|
(defn- get-organization-membership-api
|
||||||
[cfg {:keys [profile-id organization-id] :as params}]
|
[cfg {:keys [profile-id organization-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
@ -267,9 +267,9 @@
|
|||||||
organization-id
|
organization-id
|
||||||
"members/"
|
"members/"
|
||||||
profile-id)
|
profile-id)
|
||||||
schema:profile-org params))
|
schema:profile-organization params))
|
||||||
|
|
||||||
(defn- get-org-membership-by-team-api
|
(defn- get-organization-membership-by-team-api
|
||||||
[cfg {:keys [profile-id team-id] :as params}]
|
[cfg {:keys [profile-id team-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
@ -277,28 +277,28 @@
|
|||||||
team-id
|
team-id
|
||||||
"users/"
|
"users/"
|
||||||
profile-id)
|
profile-id)
|
||||||
schema:profile-org params))
|
schema:profile-organization params))
|
||||||
|
|
||||||
(defn- get-org-summary-api
|
(defn- get-organization-summary-api
|
||||||
[cfg {:keys [organization-id] :as params}]
|
[cfg {:keys [organization-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
"api/organizations/"
|
"api/organizations/"
|
||||||
organization-id
|
organization-id
|
||||||
"summary")
|
"summary")
|
||||||
schema:org-summary params))
|
schema:organization-summary params))
|
||||||
|
|
||||||
(defn- get-owned-orgs-api
|
(defn- get-owned-organizations-api
|
||||||
[cfg {:keys [profile-id] :as params}]
|
[cfg {:keys [profile-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
"api/users/"
|
"api/users/"
|
||||||
profile-id
|
profile-id
|
||||||
"owned-organizations")
|
"owned-organizations")
|
||||||
[:vector schema:org-summary]
|
[:vector schema:organization-summary]
|
||||||
params))
|
params))
|
||||||
|
|
||||||
(def ^:private schema:org-summary-counts
|
(def ^:private schema:organization-summary-counts
|
||||||
[:map
|
[:map
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:name ::sm/text]
|
[:name ::sm/text]
|
||||||
@ -308,20 +308,20 @@
|
|||||||
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
|
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
|
||||||
|
|
||||||
(defn- get-owned-orgs-summary-api
|
(defn- get-owned-organizations-summary-api
|
||||||
[cfg {:keys [profile-id] :as params}]
|
[cfg {:keys [profile-id] :as params}]
|
||||||
(let [orgs (request-to-nitrate cfg :get
|
(let [organizations (request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
"api/users/"
|
"api/users/"
|
||||||
profile-id
|
profile-id
|
||||||
"owned-organizations-summary")
|
"owned-organizations-summary")
|
||||||
[:vector schema:org-summary-counts]
|
[:vector schema:organization-summary-counts]
|
||||||
params)]
|
params)]
|
||||||
(mapv (fn [org]
|
(mapv (fn [organization]
|
||||||
(if-let [logo-id (:logo-id org)]
|
(if-let [logo-id (:logo-id organization)]
|
||||||
(assoc org :custom-photo (generate-public-uri "assets/by-id/" logo-id))
|
(assoc organization :custom-photo (generate-public-uri "assets/by-id/" logo-id))
|
||||||
org))
|
organization))
|
||||||
orgs)))
|
organizations)))
|
||||||
|
|
||||||
(defn- cleanup-deleted-penpot-user-api
|
(defn- cleanup-deleted-penpot-user-api
|
||||||
[cfg {:keys [profile-id] :as params}]
|
[cfg {:keys [profile-id] :as params}]
|
||||||
@ -332,7 +332,7 @@
|
|||||||
"cleanup-after-deletion")
|
"cleanup-after-deletion")
|
||||||
nil params))
|
nil params))
|
||||||
|
|
||||||
(defn- set-team-org-api
|
(defn- set-team-organization-api
|
||||||
[cfg {:keys [organization-id team-id is-default] :as params}]
|
[cfg {:keys [organization-id team-id is-default] :as params}]
|
||||||
(let [params (assoc params :request-params {:team-id team-id
|
(let [params (assoc params :request-params {:team-id team-id
|
||||||
:is-your-penpot (true? is-default)})
|
:is-your-penpot (true? is-default)})
|
||||||
@ -348,7 +348,7 @@
|
|||||||
custom-photo
|
custom-photo
|
||||||
(assoc-in [:organization :custom-photo] custom-photo))))
|
(assoc-in [:organization :custom-photo] custom-photo))))
|
||||||
|
|
||||||
(defn- add-profile-to-org-api
|
(defn- add-profile-to-organization-api
|
||||||
[cfg {:keys [profile-id organization-id team-id email] :as params}]
|
[cfg {:keys [profile-id organization-id team-id email] :as params}]
|
||||||
(let [request-params (cond-> {:user-id profile-id :team-id team-id}
|
(let [request-params (cond-> {:user-id profile-id :team-id team-id}
|
||||||
(some? email) (assoc :email email))
|
(some? email) (assoc :email email))
|
||||||
@ -358,11 +358,16 @@
|
|||||||
"api/organizations/"
|
"api/organizations/"
|
||||||
organization-id
|
organization-id
|
||||||
"add-user")
|
"add-user")
|
||||||
schema:profile-org params)))
|
schema:profile-organization params)))
|
||||||
|
|
||||||
(defn- remove-profile-from-org-api
|
(defn- remove-profile-from-organization-api
|
||||||
[cfg {:keys [profile-id organization-id] :as params}]
|
[cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}]
|
||||||
(let [params (assoc params :request-params {:user-id profile-id})]
|
(let [request-params (cond-> {:user-id profile-id}
|
||||||
|
(some? user-who-delete-member)
|
||||||
|
(assoc :user-who-delete-member user-who-delete-member)
|
||||||
|
(some? deleted-by-role)
|
||||||
|
(assoc :deleted-by-role deleted-by-role))
|
||||||
|
params (assoc params :request-params request-params)]
|
||||||
(request-to-nitrate cfg :post
|
(request-to-nitrate cfg :post
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
"api/organizations/"
|
"api/organizations/"
|
||||||
@ -370,7 +375,7 @@
|
|||||||
"remove-user")
|
"remove-user")
|
||||||
nil params)))
|
nil params)))
|
||||||
|
|
||||||
(defn- remove-team-from-org-api
|
(defn- remove-team-from-organization-api
|
||||||
[cfg {:keys [team-id organization-id] :as params}]
|
[cfg {:keys [team-id organization-id] :as params}]
|
||||||
(let [params (assoc params :request-params {:team-id team-id})]
|
(let [params (assoc params :request-params {:team-id team-id})]
|
||||||
(request-to-nitrate cfg :post
|
(request-to-nitrate cfg :post
|
||||||
@ -413,11 +418,22 @@
|
|||||||
(generate-nitrate-uri "api/connectivity")
|
(generate-nitrate-uri "api/connectivity")
|
||||||
schema:connectivity params))
|
schema:connectivity params))
|
||||||
|
|
||||||
|
(def ^:private schema:identity
|
||||||
|
[:map
|
||||||
|
[:nitrate-id ::sm/text]
|
||||||
|
[:public-key ::sm/text]])
|
||||||
|
|
||||||
|
(defn- get-identity-api
|
||||||
|
[cfg params]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(generate-nitrate-uri "api/identity")
|
||||||
|
schema:identity params))
|
||||||
|
|
||||||
(def ^:private schema:redeem-result
|
(def ^:private schema:redeem-result
|
||||||
[:map
|
[:map
|
||||||
[:cancel-at [:maybe schema:timestamp]]])
|
[:cancel-at [:maybe schema:timestamp]]])
|
||||||
|
|
||||||
(defn- get-org-permissions-api
|
(defn- get-organization-permissions-api
|
||||||
[cfg {:keys [organization-id] :as params}]
|
[cfg {:keys [organization-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
@ -430,7 +446,7 @@
|
|||||||
[:permissions [:map-of :keyword :string]]]
|
[:permissions [:map-of :keyword :string]]]
|
||||||
params))
|
params))
|
||||||
|
|
||||||
(defn- get-org-sso-api
|
(defn- get-organization-sso-api
|
||||||
"Fetches the SSO configuration for an organization from Nitrate."
|
"Fetches the SSO configuration for an organization from Nitrate."
|
||||||
[cfg {:keys [organization-id] :as params}]
|
[cfg {:keys [organization-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
@ -441,14 +457,14 @@
|
|||||||
schema:nitrate-sso
|
schema:nitrate-sso
|
||||||
params))
|
params))
|
||||||
|
|
||||||
(defn- get-org-sso-by-team-api
|
(defn- get-organization-sso-by-team-api
|
||||||
[cfg {:keys [team-id] :as params}]
|
[cfg {:keys [team-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri "api/teams/" team-id "sso")
|
(generate-nitrate-uri "api/teams/" team-id "sso")
|
||||||
schema:nitrate-sso
|
schema:nitrate-sso
|
||||||
params))
|
params))
|
||||||
|
|
||||||
(defn- get-org-members-api
|
(defn- get-organization-members-api
|
||||||
[cfg {:keys [organization-id] :as params}]
|
[cfg {:keys [organization-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
@ -471,35 +487,36 @@
|
|||||||
|
|
||||||
(defmethod ig/init-key ::client
|
(defmethod ig/init-key ::client
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
{:get-team-org (partial get-team-org-api cfg)
|
{:get-team-organization (partial get-team-organization-api cfg)
|
||||||
:set-team-org (partial set-team-org-api cfg)
|
:set-team-organization (partial set-team-organization-api cfg)
|
||||||
:get-org-membership (partial get-org-membership-api cfg)
|
:get-organization-membership (partial get-organization-membership-api cfg)
|
||||||
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
|
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
|
||||||
:get-org-summary (partial get-org-summary-api cfg)
|
:get-organization-summary (partial get-organization-summary-api cfg)
|
||||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
:get-owned-organizations (partial get-owned-organizations-api cfg)
|
||||||
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
|
:get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg)
|
||||||
:get-org-members (partial get-org-members-api cfg)
|
:get-organization-members (partial get-organization-members-api cfg)
|
||||||
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg)
|
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg)
|
||||||
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
:add-profile-to-organization (partial add-profile-to-organization-api cfg)
|
||||||
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
:remove-profile-from-organization (partial remove-profile-from-organization-api cfg)
|
||||||
:get-org-permissions (partial get-org-permissions-api cfg)
|
:get-organization-permissions (partial get-organization-permissions-api cfg)
|
||||||
:get-org-sso-by-team (partial get-org-sso-by-team-api cfg)
|
:get-organization-sso-by-team (partial get-organization-sso-by-team-api cfg)
|
||||||
:get-org-sso (partial get-org-sso-api cfg)
|
:get-organization-sso (partial get-organization-sso-api cfg)
|
||||||
:delete-team (partial delete-team-api cfg)
|
:delete-team (partial delete-team-api cfg)
|
||||||
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
:remove-team-from-organization (partial remove-team-from-organization-api cfg)
|
||||||
:get-subscription (partial get-subscription-api cfg)
|
:get-subscription (partial get-subscription-api cfg)
|
||||||
:get-subscription-warning (partial get-subscription-warning-api cfg)
|
:get-subscription-warning (partial get-subscription-warning-api cfg)
|
||||||
:connectivity (partial get-connectivity-api cfg)
|
:connectivity (partial get-connectivity-api cfg)
|
||||||
|
:get-identity (partial get-identity-api cfg)
|
||||||
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
|
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; UTILS
|
;; UTILS
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(defonce ^:private team-org-owner-cache
|
(defonce ^:private team-organization-owner-cache
|
||||||
;; Short TTL: permission checks run on the read path, so we avoid an
|
;; Short TTL: permission checks run on the read path, so we avoid an
|
||||||
;; HTTP call to nitrate per check. The org owner of a team rarely
|
;; HTTP call to nitrate per check. The organization owner of a team rarely
|
||||||
;; changes, and stale entries only grant read access for a few seconds.
|
;; changes, and stale entries only grant read access for a few seconds.
|
||||||
(cache/create :expire "30s" :max-size 2048))
|
(cache/create :expire "30s" :max-size 2048))
|
||||||
|
|
||||||
@ -509,47 +526,47 @@
|
|||||||
[cfg]
|
[cfg]
|
||||||
(and (map? cfg) (some? (get cfg ::client))))
|
(and (map? cfg) (some? (get cfg ::client))))
|
||||||
|
|
||||||
(def ^:private cache-miss ::no-org-owner)
|
(def ^:private cache-miss ::no-organization-owner)
|
||||||
|
|
||||||
(defn- get-team-org-owner-id
|
(defn- get-team-organization-owner-id
|
||||||
"Returns the organization owner-id for `team-id`, or nil. Cached
|
"Returns the organization owner-id for `team-id`, or nil. Cached
|
||||||
briefly, including negative results (teams with no organization) so
|
briefly, including negative results (teams with no organization) so
|
||||||
repeated unauthorized probes don't each hit nitrate."
|
repeated unauthorized probes don't each hit nitrate."
|
||||||
[cfg team-id]
|
[cfg team-id]
|
||||||
(let [owner-id (cache/get team-org-owner-cache team-id
|
(let [owner-id (cache/get team-organization-owner-cache team-id
|
||||||
(fn [team-id]
|
(fn [team-id]
|
||||||
(let [team-with-org (call cfg :get-team-org {:team-id team-id})]
|
(let [team-with-organization (call cfg :get-team-organization {:team-id team-id})]
|
||||||
(or (get-in team-with-org [:organization :owner-id])
|
(or (get-in team-with-organization [:organization :owner-id])
|
||||||
cache-miss))))]
|
cache-miss))))]
|
||||||
(when-not (= owner-id cache-miss)
|
(when-not (= owner-id cache-miss)
|
||||||
owner-id)))
|
owner-id)))
|
||||||
|
|
||||||
(defn organization-owner-of-team?
|
(defn organization-owner-of-team?
|
||||||
"True if `profile-id` is the owner of the organization that owns
|
"True if `profile-id` is the owner of the organization that owns
|
||||||
`team-id`. Used to grant non-member org owners read-only access to the
|
`team-id`. Used to grant non-member organization owners read-only access to the
|
||||||
teams of their organizations. `cfg` must be a config map with the
|
teams of their organizations. `cfg` must be a config map with the
|
||||||
nitrate client; raw db connections/pools yield false so internal
|
nitrate client; raw db connections/pools yield false so internal
|
||||||
callers are unaffected. Returns false when the :nitrate flag is off."
|
callers are unaffected. Returns false when the :nitrate flag is off."
|
||||||
[cfg profile-id team-id]
|
[cfg profile-id team-id]
|
||||||
(boolean
|
(boolean
|
||||||
(when (and (contains? cf/flags :nitrate)
|
(when (and (contains? cf/flags :admin-console)
|
||||||
(nitrate-client? cfg)
|
(nitrate-client? cfg)
|
||||||
(some? team-id)
|
(some? team-id)
|
||||||
(some? profile-id))
|
(some? profile-id))
|
||||||
(= profile-id (get-team-org-owner-id cfg team-id)))))
|
(= profile-id (get-team-organization-owner-id cfg team-id)))))
|
||||||
|
|
||||||
(defn sso-session-authorized?
|
(defn sso-session-authorized?
|
||||||
"Fetches the org-SSO config for the given organization or team and checks
|
"Fetches the organization-SSO config for the given organization or team and checks
|
||||||
whether the HTTP request has a valid session entry for it. Returns a map
|
whether the HTTP request has a valid session entry for it. Returns a map
|
||||||
with :authorized and :sso keys."
|
with :authorized and :sso keys."
|
||||||
[cfg organization-id team-id request]
|
[cfg organization-id team-id request]
|
||||||
(let [session (session/get-session request)
|
(let [session (session/get-session request)
|
||||||
sso (if organization-id
|
sso (if organization-id
|
||||||
(call cfg :get-org-sso {:organization-id organization-id})
|
(call cfg :get-organization-sso {:organization-id organization-id})
|
||||||
(call cfg :get-org-sso-by-team {:team-id team-id}))]
|
(call cfg :get-organization-sso-by-team {:team-id team-id}))]
|
||||||
(if-not (:active sso)
|
(if-not (:active sso)
|
||||||
{:authorized true :sso sso}
|
{:authorized true :sso sso}
|
||||||
(if (or (:issuer sso) (:base-url sso))
|
(if-not (str/blank? (:issuer sso))
|
||||||
(let [props (:props session)
|
(let [props (:props session)
|
||||||
sso-map (get props :sso {})
|
sso-map (get props :sso {})
|
||||||
organization-id (:organization-id sso)
|
organization-id (:organization-id sso)
|
||||||
@ -579,21 +596,21 @@
|
|||||||
:cause cause)
|
:cause cause)
|
||||||
profile)))))
|
profile)))))
|
||||||
|
|
||||||
(defn add-org-info-to-team
|
(defn add-organization-info-to-team
|
||||||
"Enriches a team map with organization information from Nitrate.
|
"Enriches a team map with organization information from Nitrate.
|
||||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||||
Returns the original team unchanged if the request fails or org data is nil.
|
Returns the original team unchanged if the request fails or organization data is nil.
|
||||||
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
||||||
[cfg team params]
|
[cfg team params]
|
||||||
(try
|
(try
|
||||||
(let [params (assoc (or params {}) :team-id (:id team))
|
(let [params (assoc (or params {}) :team-id (:id team))
|
||||||
team-with-org (call cfg :get-team-org params)
|
team-with-organization (call cfg :get-team-organization params)
|
||||||
org (:organization team-with-org)]
|
organization (:organization team-with-organization)]
|
||||||
(if (some? org)
|
(if (some? organization)
|
||||||
(-> (cto/apply-organization team (assoc org :custom-photo
|
(-> (cto/apply-organization team (assoc organization :custom-photo
|
||||||
(when-let [logo-id (:logo-id org)]
|
(when-let [logo-id (:logo-id organization)]
|
||||||
(generate-public-uri "assets/by-id/" logo-id))))
|
(generate-public-uri "assets/by-id/" logo-id))))
|
||||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
|
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
||||||
team))
|
team))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||||
@ -613,10 +630,10 @@
|
|||||||
:team-id (:id team)
|
:team-id (:id team)
|
||||||
:organization-id (:organization-id params)
|
:organization-id (:organization-id params)
|
||||||
:is-default (:is-default params))
|
:is-default (:is-default params))
|
||||||
result (call cfg :set-team-org params)]
|
result (call cfg :set-team-organization params)]
|
||||||
(when (nil? result)
|
(when (nil? result)
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :failed-to-set-team-org
|
:code :failed-to-set-team-organization
|
||||||
:context {:team-id (:id team)
|
:context {:team-id (:id team)
|
||||||
:organization-id (:organization-id params)}))
|
:organization-id (:organization-id params)}))
|
||||||
team))
|
team))
|
||||||
|
|||||||
@ -41,6 +41,7 @@
|
|||||||
[app.util.cache :as cache]
|
[app.util.cache :as cache]
|
||||||
[app.util.inet :as inet]
|
[app.util.inet :as inet]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
|
[clojure.set :as set]
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
@ -102,8 +103,10 @@
|
|||||||
session-id (yreq/get-header request "x-session-id")
|
session-id (yreq/get-header request "x-session-id")
|
||||||
|
|
||||||
key-id (get request ::http/auth-key-id)
|
key-id (get request ::http/auth-key-id)
|
||||||
profile-id (or (::session/profile-id request)
|
session-pid (::session/profile-id request)
|
||||||
(::actoken/profile-id request)
|
token-pid (::actoken/profile-id request)
|
||||||
|
profile-id (or session-pid
|
||||||
|
token-pid
|
||||||
(if key-id uuid/zero nil))
|
(if key-id uuid/zero nil))
|
||||||
|
|
||||||
ip-addr (inet/parse-request request)
|
ip-addr (inet/parse-request request)
|
||||||
@ -116,7 +119,15 @@
|
|||||||
(assoc ::session-id (some-> session-id uuid/parse*))
|
(assoc ::session-id (some-> session-id uuid/parse*))
|
||||||
(assoc ::cond/key etag)
|
(assoc ::cond/key etag)
|
||||||
(cond-> (uuid? profile-id)
|
(cond-> (uuid? profile-id)
|
||||||
(assoc ::profile-id profile-id)))
|
(assoc ::profile-id profile-id))
|
||||||
|
(cond-> (uuid? session-pid)
|
||||||
|
(assoc ::auth-type :session))
|
||||||
|
(cond-> (and (not (uuid? session-pid))
|
||||||
|
(uuid? token-pid))
|
||||||
|
(-> (assoc ::auth-type :token)
|
||||||
|
(assoc ::token-perms (set (::actoken/perms request #{})))))
|
||||||
|
(cond-> key-id
|
||||||
|
(assoc ::auth-key-id key-id)))
|
||||||
|
|
||||||
data (with-meta data
|
data (with-meta data
|
||||||
{::http/request request})
|
{::http/request request})
|
||||||
@ -151,13 +162,40 @@
|
|||||||
|
|
||||||
(defn- wrap-authentication
|
(defn- wrap-authentication
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
(fn [cfg params]
|
(let [required-auth? (::auth mdata true)
|
||||||
(let [profile-id (::profile-id params)]
|
required-auth-type (::auth-type mdata)
|
||||||
(if (and (::auth mdata true) (not (uuid? profile-id)))
|
required-perms (into #{} (::perms mdata))]
|
||||||
(ex/raise :type :authentication
|
(fn [cfg params]
|
||||||
:code :authentication-required
|
(let [profile-id (::profile-id params)
|
||||||
:hint "authentication required for this endpoint")
|
auth-type (::auth-type params)
|
||||||
(f cfg params)))))
|
token-perms (set (::token-perms params #{}))]
|
||||||
|
(cond
|
||||||
|
(and required-auth? (not (uuid? profile-id)))
|
||||||
|
(ex/raise :type :authentication
|
||||||
|
:code :authentication-required
|
||||||
|
:hint "authentication required for this endpoint")
|
||||||
|
|
||||||
|
(and (= required-auth-type :token)
|
||||||
|
(not= auth-type :token))
|
||||||
|
(ex/raise :type :authorization
|
||||||
|
:code :token-auth-required
|
||||||
|
:hint "access token authentication required for this endpoint")
|
||||||
|
|
||||||
|
(and (seq required-perms)
|
||||||
|
(not= auth-type :token))
|
||||||
|
(ex/raise :type :authorization
|
||||||
|
:code :token-auth-required
|
||||||
|
:hint "access token authentication required for this endpoint")
|
||||||
|
|
||||||
|
(and (seq required-perms)
|
||||||
|
(not (set/subset? required-perms token-perms)))
|
||||||
|
(ex/raise :type :authorization
|
||||||
|
:code :missing-perms
|
||||||
|
:hint "missing required permissions"
|
||||||
|
:required required-perms)
|
||||||
|
|
||||||
|
:else
|
||||||
|
(f cfg params))))))
|
||||||
|
|
||||||
(defn- wrap-db-transaction
|
(defn- wrap-db-transaction
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
@ -212,13 +250,13 @@
|
|||||||
f))
|
f))
|
||||||
|
|
||||||
|
|
||||||
(defonce ^:private org-sso-auth-cache
|
(defonce ^:private organization-sso-auth-cache
|
||||||
(cache/create :expire "15m" :max-size 1024))
|
(cache/create :expire "15m" :max-size 1024))
|
||||||
|
|
||||||
(defn invalidate-org-sso-cache-by-org!
|
(defn invalidate-organization-sso-cache-by-organization!
|
||||||
"Invalidates all org-SSO authorization cache entries for the given organization-id."
|
"Invalidates all organization-SSO authorization cache entries for the given organization-id."
|
||||||
[organization-id]
|
[organization-id]
|
||||||
(cache/invalidate-if org-sso-auth-cache #(= (:organization-id %) organization-id)))
|
(cache/invalidate-if organization-sso-auth-cache #(= (:organization-id %) organization-id)))
|
||||||
|
|
||||||
(defn- wrap-nitrate-sso
|
(defn- wrap-nitrate-sso
|
||||||
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
||||||
@ -230,18 +268,18 @@
|
|||||||
4. Explicit :file-id param -> lookup file's team via join
|
4. Explicit :file-id param -> lookup file's team via join
|
||||||
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
||||||
|
|
||||||
Once the context is resolved, checks if the user is authorized within that org's
|
Once the context is resolved, checks if the user is authorized within that organization's
|
||||||
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
|
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
|
||||||
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
|
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
|
||||||
|
|
||||||
Only activates when:
|
Only activates when:
|
||||||
- Nitrate flag is enabled
|
- Nitrate flag is enabled
|
||||||
- Endpoint requires authentication (::auth true by default)
|
- Endpoint requires authentication (::auth true by default)
|
||||||
- Endpoint is not marked with ::nitrate/org-sso false
|
- Endpoint is not marked with ::nitrate/organization-sso false
|
||||||
|
|
||||||
Raises :nitrate-sso-required error if user is not authorized in the org."
|
Raises :nitrate-sso-required error if user is not authorized in the organization."
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
(if (and (contains? cf/flags :nitrate)
|
(if (and (contains? cf/flags :admin-console)
|
||||||
(::auth mdata true) ;; only for endpoints that needs auth
|
(::auth mdata true) ;; only for endpoints that needs auth
|
||||||
(::nitrate/sso mdata true))
|
(::nitrate/sso mdata true))
|
||||||
(fn [cfg params]
|
(fn [cfg params]
|
||||||
@ -261,7 +299,7 @@
|
|||||||
(let [cache-ref (or organization-id team-id project-id file-id)
|
(let [cache-ref (or organization-id team-id project-id file-id)
|
||||||
|
|
||||||
cache-key [profile-id cache-ref]
|
cache-key [profile-id cache-ref]
|
||||||
cached (cache/get org-sso-auth-cache cache-key)
|
cached (cache/get organization-sso-auth-cache cache-key)
|
||||||
result (if (some? cached)
|
result (if (some? cached)
|
||||||
cached
|
cached
|
||||||
(let [team-id (when-not organization-id
|
(let [team-id (when-not organization-id
|
||||||
@ -276,7 +314,7 @@
|
|||||||
entry {:authorized authorized
|
entry {:authorized authorized
|
||||||
:organization-id (:organization-id sso)}]
|
:organization-id (:organization-id sso)}]
|
||||||
(when authorized
|
(when authorized
|
||||||
(cache/get org-sso-auth-cache cache-key (constantly entry)))
|
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
||||||
entry))]
|
entry))]
|
||||||
(if (:authorized result)
|
(if (:authorized result)
|
||||||
(f cfg params)
|
(f cfg params)
|
||||||
@ -339,6 +377,7 @@
|
|||||||
'app.rpc.commands.binfile
|
'app.rpc.commands.binfile
|
||||||
'app.rpc.commands.comments
|
'app.rpc.commands.comments
|
||||||
'app.rpc.commands.demo
|
'app.rpc.commands.demo
|
||||||
|
'app.rpc.commands.error-reports
|
||||||
'app.rpc.commands.files
|
'app.rpc.commands.files
|
||||||
'app.rpc.commands.files-create
|
'app.rpc.commands.files-create
|
||||||
'app.rpc.commands.files-share
|
'app.rpc.commands.files-share
|
||||||
@ -391,7 +430,7 @@
|
|||||||
[cfg]
|
[cfg]
|
||||||
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
|
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
|
||||||
mods (cond->> (list 'app.rpc.management.exporter)
|
mods (cond->> (list 'app.rpc.management.exporter)
|
||||||
(contains? cf/flags :nitrate)
|
(contains? cf/flags :admin-console)
|
||||||
(cons 'app.rpc.management.nitrate))]
|
(cons 'app.rpc.management.nitrate))]
|
||||||
|
|
||||||
(->> (apply sv/scan-ns mods)
|
(->> (apply sv/scan-ns mods)
|
||||||
|
|||||||
@ -29,11 +29,16 @@
|
|||||||
AND type = 'mcp'")
|
AND type = 'mcp'")
|
||||||
|
|
||||||
(defn create-access-token
|
(defn create-access-token
|
||||||
|
"Create an access token with empty perms.
|
||||||
|
|
||||||
|
Elevated permissions (e.g. error-reports:read) are not assignable via
|
||||||
|
the public API; grant them with SQL or `repl:grant-access-token-perm`."
|
||||||
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
||||||
(let [token-id (uuid/next)
|
(let [token-id (uuid/next)
|
||||||
expires-at (some-> expiration (ct/in-future))
|
expires-at (some-> expiration (ct/in-future))
|
||||||
created-at (ct/now)
|
created-at (ct/now)
|
||||||
token (tokens/generate cfg {:iss "access-token"
|
token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token")
|
||||||
|
token (tokens/generate cfg {:iss token-iss
|
||||||
:uid profile-id
|
:uid profile-id
|
||||||
:iat created-at
|
:iat created-at
|
||||||
:tid token-id})
|
:tid token-id})
|
||||||
@ -61,6 +66,27 @@
|
|||||||
[cfg profile-id name expiration]
|
[cfg profile-id name expiration]
|
||||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||||
|
|
||||||
|
(def ^:private sql:grant-access-token-perm
|
||||||
|
"UPDATE access_token
|
||||||
|
SET perms = (
|
||||||
|
SELECT ARRAY(
|
||||||
|
SELECT DISTINCT unnest(perms || ARRAY[?]::text[])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, perms")
|
||||||
|
|
||||||
|
(defn repl:grant-access-token-perm
|
||||||
|
"Append a permission string to an access token (operator/SQL path).
|
||||||
|
|
||||||
|
Example: (repl:grant-access-token-perm cfg token-id \"error-reports:read\")"
|
||||||
|
[cfg token-id perm]
|
||||||
|
(db/tx-run! cfg
|
||||||
|
(fn [{:keys [::db/conn]}]
|
||||||
|
(let [row (db/exec-one! conn [sql:grant-access-token-perm perm token-id])]
|
||||||
|
(some-> row (update :perms db/decode-pgarray #{}))))))
|
||||||
|
|
||||||
(def ^:private schema:create-access-token
|
(def ^:private schema:create-access-token
|
||||||
[:map {:title "create-access-token"}
|
[:map {:title "create-access-token"}
|
||||||
[:name [:string {:max 250 :min 1}]]
|
[:name [:string {:max 250 :min 1}]]
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.auth :as auth]
|
[app.auth :as auth]
|
||||||
[app.auth.oidc :as oidc]
|
[app.auth.oidc :as oidc]
|
||||||
|
[app.auth.passwords :as passwords]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
@ -182,6 +183,7 @@
|
|||||||
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
|
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
|
||||||
nil))]
|
nil))]
|
||||||
|
|
||||||
|
(passwords/validate-password password)
|
||||||
(->> (validate-token token)
|
(->> (validate-token token)
|
||||||
(update-password conn))
|
(update-password conn))
|
||||||
|
|
||||||
@ -240,6 +242,9 @@
|
|||||||
:code :email-as-password
|
:code :email-as-password
|
||||||
:hint "you can't use your email as password"))
|
:hint "you can't use your email as password"))
|
||||||
|
|
||||||
|
;; Validate password strength against common password dictionary
|
||||||
|
(passwords/validate-password (:password params))
|
||||||
|
|
||||||
(when (eml/has-bounce-reports? cfg (:email params))
|
(when (eml/has-bounce-reports? cfg (:email params))
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :email-has-permanent-bounces
|
:code :email-has-permanent-bounces
|
||||||
@ -258,7 +263,8 @@
|
|||||||
(validate-register-attempt! cfg params)
|
(validate-register-attempt! cfg params)
|
||||||
|
|
||||||
(let [email (profile/clean-email email)
|
(let [email (profile/clean-email email)
|
||||||
profile (profile/get-profile-by-email pool email)]
|
profile (profile/get-profile-by-email pool email)
|
||||||
|
fullname (d/normalize-string fullname)]
|
||||||
|
|
||||||
;; SECURITY: refuse to issue a prepared-register token when an active
|
;; SECURITY: refuse to issue a prepared-register token when an active
|
||||||
;; profile already exists for this email.
|
;; profile already exists for this email.
|
||||||
@ -359,6 +365,9 @@
|
|||||||
is-active (:is-active params false)
|
is-active (:is-active params false)
|
||||||
theme (:theme params nil)
|
theme (:theme params nil)
|
||||||
email (str/lower email)
|
email (str/lower email)
|
||||||
|
fullname (d/normalize-string (:fullname params))
|
||||||
|
locale (d/normalize-string locale)
|
||||||
|
theme (d/normalize-string theme)
|
||||||
|
|
||||||
photo-id (some->> (or (:oidc/picture props)
|
photo-id (some->> (or (:oidc/picture props)
|
||||||
(:google/picture props)
|
(:google/picture props)
|
||||||
@ -367,7 +376,7 @@
|
|||||||
(import-profile-picture cfg))
|
(import-profile-picture cfg))
|
||||||
|
|
||||||
params {:id id
|
params {:id id
|
||||||
:fullname (:fullname params)
|
:fullname fullname
|
||||||
:email email
|
:email email
|
||||||
:auth-backend backend
|
:auth-backend backend
|
||||||
:lang locale
|
:lang locale
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
[app.http.sse :as sse]
|
[app.http.sse :as sse]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
[app.rpc.commands.media :as media-cmd]
|
[app.rpc.commands.media :as media-cmd]
|
||||||
@ -122,44 +122,34 @@
|
|||||||
[:name [:or [:string {:max 250}]
|
[:name [:or [:string {:max 250}]
|
||||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:file-id {:optional true} ::sm/uuid]
|
|
||||||
[:version {:optional true} ::sm/int]
|
[:version {:optional true} ::sm/int]
|
||||||
[:file {:optional true} media/schema:upload]
|
[:file {:optional true} media.v/schema:upload]
|
||||||
[:upload-id {:optional true} ::sm/uuid]]
|
[:upload-id {:optional true} ::sm/uuid]]
|
||||||
[:fn {:error/message "one of :file or :upload-id is required"}
|
[:fn {:error/message "one of :file or :upload-id is required"}
|
||||||
(fn [{:keys [file upload-id]}]
|
(fn [{:keys [file upload-id]}]
|
||||||
(or (some? file) (some? upload-id)))]])
|
(or (some? file) (some? upload-id)))]])
|
||||||
|
|
||||||
(sv/defmethod ::import-binfile
|
(sv/defmethod ::import-binfile
|
||||||
"Import a penpot file in a binary format. If `file-id` is provided,
|
"Import a penpot file in a binary format.
|
||||||
an in-place import will be performed instead of creating a new file.
|
|
||||||
|
|
||||||
The in-place imports are only supported for binfile-v3 and when a
|
|
||||||
.penpot file only contains one penpot file.
|
|
||||||
|
|
||||||
The file content may be provided either as a multipart `file` upload
|
The file content may be provided either as a multipart `file` upload
|
||||||
or as an `upload-id` referencing a completed chunked-upload session,
|
or as an `upload-id` referencing a completed chunked-upload session,
|
||||||
which allows importing files larger than the multipart size limit.
|
which allows importing files larger than the multipart size limit.
|
||||||
"
|
"
|
||||||
{::doc/added "1.15"
|
{::doc/added "1.15"
|
||||||
::doc/changes ["1.20" "Add file-id param for in-place import"
|
::doc/changes [["1.20" "Set default version to 3"]
|
||||||
"1.20" "Set default version to 3"
|
["2.15" "Add upload-id param for chunked upload support"]]
|
||||||
"2.15" "Add upload-id param for chunked upload support"]
|
|
||||||
|
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sse/stream? true
|
::sse/stream? true
|
||||||
::sm/params schema:import-binfile}
|
::sm/params schema:import-binfile}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
|
||||||
(projects/check-edition-permissions! pool profile-id project-id)
|
(projects/check-edition-permissions! pool profile-id project-id)
|
||||||
(let [version (or version 3)
|
(let [version (or version 3)
|
||||||
params (-> params
|
params (-> params
|
||||||
(assoc :profile-id profile-id)
|
(assoc :profile-id profile-id)
|
||||||
(assoc :version version))
|
(assoc :version version))
|
||||||
|
|
||||||
cfg (cond-> cfg
|
|
||||||
(uuid? file-id)
|
|
||||||
(assoc ::bfc/file-id file-id))
|
|
||||||
|
|
||||||
params
|
params
|
||||||
(if (some? upload-id)
|
(if (some? upload-id)
|
||||||
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
|
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
|
||||||
@ -174,6 +164,5 @@
|
|||||||
(with-meta
|
(with-meta
|
||||||
(sse/response (partial import-binfile cfg params))
|
(sse/response (partial import-binfile cfg params))
|
||||||
{::audit/props {:file nil
|
{::audit/props {:file nil
|
||||||
:file-id file-id
|
|
||||||
:generated-by (:generated-by manifest)
|
:generated-by (:generated-by manifest)
|
||||||
:referer (:referer manifest)}})))
|
:referer (:referer manifest)}})))
|
||||||
|
|||||||
185
backend/src/app/rpc/commands/error_reports.clj
Normal file
185
backend/src/app/rpc/commands/error_reports.clj
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.rpc.commands.error-reports
|
||||||
|
"RPC methods for listing and fetching server error reports.
|
||||||
|
|
||||||
|
Access is restricted to access-token authentication with the
|
||||||
|
`error-reports:read` permission. Grant via SQL (or REPL helper):
|
||||||
|
|
||||||
|
UPDATE access_token
|
||||||
|
SET perms = ARRAY['error-reports:read']::text[],
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = '<token-uuid>';
|
||||||
|
|
||||||
|
Call with: Authorization: Token <jwt>"
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
|
[app.db :as db]
|
||||||
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.doc :as doc]
|
||||||
|
[app.util.services :as sv]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
|
(def ^:private max-limit 200)
|
||||||
|
(def ^:private default-limit 50)
|
||||||
|
|
||||||
|
(def ^:private source-names
|
||||||
|
{1 "legacy-v1"
|
||||||
|
2 "legacy-v2"
|
||||||
|
3 "logging"
|
||||||
|
4 "audit-log"
|
||||||
|
5 "rlimit"})
|
||||||
|
|
||||||
|
(defn- source->name
|
||||||
|
[source]
|
||||||
|
(get source-names source (str "unknown-" source)))
|
||||||
|
|
||||||
|
(defn- name->source
|
||||||
|
[name]
|
||||||
|
(some (fn [[k v]] (when (= v name) k)) source-names))
|
||||||
|
|
||||||
|
(def ^:private schema:error-report-summary
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:created-at ct/schema:inst]
|
||||||
|
[:source ::sm/text]
|
||||||
|
[:profile-id {:optional true} ::sm/text]
|
||||||
|
[:kind {:optional true} ::sm/text]
|
||||||
|
[:tenant {:optional true} ::sm/text]
|
||||||
|
[:version {:optional true} ::sm/text]
|
||||||
|
[:hint {:optional true} ::sm/text]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-error-reports-params
|
||||||
|
[:map {:title "get-error-reports-params"}
|
||||||
|
[:since {:optional true} ct/schema:inst]
|
||||||
|
[:since-id {:optional true} ::sm/uuid]
|
||||||
|
[:limit {:optional true}
|
||||||
|
[:and ::sm/int [:fn #(<= 1 % max-limit)]]]
|
||||||
|
[:source {:optional true} ::sm/text]
|
||||||
|
[:profile-id {:optional true} ::sm/text]
|
||||||
|
[:kind {:optional true} ::sm/text]
|
||||||
|
[:tenant {:optional true} ::sm/text]
|
||||||
|
[:version {:optional true} ::sm/text]
|
||||||
|
[:hint {:optional true} ::sm/text]
|
||||||
|
[:until {:optional true} ct/schema:inst]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-error-reports-result
|
||||||
|
[:map
|
||||||
|
[:items [:vector schema:error-report-summary]]
|
||||||
|
[:next-since {:optional true} ct/schema:inst]
|
||||||
|
[:next-id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private schema:error-report
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:created-at ct/schema:inst]
|
||||||
|
[:source ::sm/text]
|
||||||
|
[:profile-id {:optional true} ::sm/text]
|
||||||
|
[:kind {:optional true} ::sm/text]
|
||||||
|
[:tenant {:optional true} ::sm/text]
|
||||||
|
[:version {:optional true} ::sm/text]
|
||||||
|
[:hint {:optional true} ::sm/text]
|
||||||
|
[:report {:optional true} ::sm/text]
|
||||||
|
[:href {:optional true} ::sm/text]
|
||||||
|
[:context {:optional true} ::sm/text]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-error-report-params
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private base-list-sql
|
||||||
|
(str "SELECT id, created_at, source, "
|
||||||
|
"COALESCE(content->>'~:kind', content->>'~:origin') AS kind, "
|
||||||
|
"content->>'~:tenant' AS tenant, "
|
||||||
|
"content->>'~:version' AS version, "
|
||||||
|
"content->>'~:hint' AS hint, "
|
||||||
|
"content->>'~:profile-id' AS profile_id "
|
||||||
|
"FROM server_error_report"))
|
||||||
|
|
||||||
|
(defn- build-list-query
|
||||||
|
[{:keys [since since-id source profile-id kind tenant version hint until limit]
|
||||||
|
:or {limit default-limit}}]
|
||||||
|
(let [source-id (when source (name->source source))
|
||||||
|
clauses (keep identity
|
||||||
|
[(when source-id
|
||||||
|
{:where "source = ?" :params [source-id]})
|
||||||
|
(when profile-id
|
||||||
|
{:where "content->>'~:profile-id' = ?"
|
||||||
|
:params [profile-id]})
|
||||||
|
(when kind
|
||||||
|
{:where "COALESCE(content->>'~:kind', content->>'~:origin') = ?"
|
||||||
|
:params [kind]})
|
||||||
|
(when tenant
|
||||||
|
{:where "content->>'~:tenant' = ?"
|
||||||
|
:params [tenant]})
|
||||||
|
(when version
|
||||||
|
{:where "content->>'~:version' = ?"
|
||||||
|
:params [version]})
|
||||||
|
(when hint
|
||||||
|
{:where "content->>'~:hint' ILIKE ?"
|
||||||
|
:params [(str "%" hint "%")]})
|
||||||
|
(when since
|
||||||
|
{:where "(created_at, id) > (?::timestamptz, ?::uuid)"
|
||||||
|
:params [since (or since-id uuid/zero)]})
|
||||||
|
(when until
|
||||||
|
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
|
||||||
|
:params [until uuid/zero]})])
|
||||||
|
sql-parts (map :where clauses)
|
||||||
|
sql-params (mapcat :params clauses)
|
||||||
|
sql (str base-list-sql
|
||||||
|
(when (seq sql-parts)
|
||||||
|
(str " WHERE " (str/join " AND " sql-parts)))
|
||||||
|
" ORDER BY created_at ASC, id ASC"
|
||||||
|
" LIMIT ?")]
|
||||||
|
(into [sql] (concat sql-params [limit]))))
|
||||||
|
|
||||||
|
(sv/defmethod ::get-error-reports
|
||||||
|
{::doc/added "2.20"
|
||||||
|
::rpc/auth-type :token
|
||||||
|
::rpc/perms #{"error-reports:read"}
|
||||||
|
::sm/params schema:get-error-reports-params
|
||||||
|
::sm/result schema:get-error-reports-result}
|
||||||
|
[cfg params]
|
||||||
|
(let [limit (min (or (:limit params) default-limit) max-limit)
|
||||||
|
params (assoc params :limit (inc limit))
|
||||||
|
[sql & sql-args] (build-list-query params)
|
||||||
|
rows (db/exec! cfg (into [sql] sql-args))]
|
||||||
|
(if (seq rows)
|
||||||
|
(let [items (->> (take limit rows)
|
||||||
|
(mapv #(-> %
|
||||||
|
(update :source source->name)
|
||||||
|
d/without-nils)))
|
||||||
|
last-item (peek items)
|
||||||
|
has-more? (> (count rows) limit)]
|
||||||
|
{:items items
|
||||||
|
:next-since (when has-more? (:created-at last-item))
|
||||||
|
:next-id (when has-more? (:id last-item))})
|
||||||
|
{:items []})))
|
||||||
|
|
||||||
|
(sv/defmethod ::get-error-report
|
||||||
|
{::doc/added "2.20"
|
||||||
|
::rpc/auth-type :token
|
||||||
|
::rpc/perms #{"error-reports:read"}
|
||||||
|
::sm/params schema:get-error-report-params
|
||||||
|
::sm/result schema:error-report}
|
||||||
|
[cfg {:keys [id]}]
|
||||||
|
(if-let [report (db/get-by-id cfg :server-error-report id {::db/check-deleted false})]
|
||||||
|
(let [content (db/decode-transit-pgobject (:content report))]
|
||||||
|
(-> report
|
||||||
|
(dissoc :content)
|
||||||
|
(merge content)
|
||||||
|
(update :source source->name)
|
||||||
|
(assoc :kind (or (:kind content) (:origin content)))
|
||||||
|
(assoc :version (:version content))
|
||||||
|
(d/without-nils)))
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :report-not-found
|
||||||
|
:hint (str "error report " id " not found"))))
|
||||||
@ -14,22 +14,25 @@
|
|||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email :as eml]
|
[app.email :as eml]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.util.services :as sv]))
|
[app.util.services :as sv]))
|
||||||
|
|
||||||
(declare ^:private send-user-feedback!)
|
(declare ^:private send-user-feedback!)
|
||||||
|
|
||||||
(def ^:private schema:send-user-feedback
|
(def schema:send-user-feedback
|
||||||
[:map {:title "send-user-feedback"}
|
[:map {:title "send-user-feedback"}
|
||||||
[:subject [:string {:max 500}]]
|
[:subject [:string {:max 500}]]
|
||||||
[:content [:string {:max 2500}]]
|
[:content [:string {:max 2500}]]
|
||||||
[:type {:optional true} :string]
|
[:type {:optional true} :string]
|
||||||
[:error-href {:optional true} [:string {:max 2500}]]
|
[:error-href {:optional true} [:string {:max 2500}]]
|
||||||
[:error-report {:optional true} :string]])
|
[:error-report {:optional true} [:string {:max 1048576}]]])
|
||||||
|
|
||||||
(sv/defmethod ::send-user-feedback
|
(sv/defmethod ::send-user-feedback
|
||||||
{::doc/added "1.18"
|
{::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id]
|
||||||
|
[:send-user-feedback/global]]
|
||||||
|
::doc/added "1.18"
|
||||||
::sm/params schema:send-user-feedback}
|
::sm/params schema:send-user-feedback}
|
||||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
|
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
|
||||||
(when-not (contains? cf/flags :user-feedback)
|
(when-not (contains? cf/flags :user-feedback)
|
||||||
|
|||||||
@ -156,11 +156,13 @@
|
|||||||
(assoc mfile :permissions perms)))
|
(assoc mfile :permissions perms)))
|
||||||
|
|
||||||
(defn get-file-etag
|
(defn get-file-etag
|
||||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
|
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
|
||||||
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
|
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
|
||||||
(ct/format-inst modified-at :iso)
|
(ct/format-inst modified-at :iso)
|
||||||
"/"
|
"/"
|
||||||
(uri/map->query-string permissions)))
|
(uri/map->query-string permissions)
|
||||||
|
"/"
|
||||||
|
(some-> deleted-at (ct/format-inst :iso))))
|
||||||
|
|
||||||
(sv/defmethod ::get-file
|
(sv/defmethod ::get-file
|
||||||
"Retrieve a file by its ID. Only authenticated users."
|
"Retrieve a file by its ID. Only authenticated users."
|
||||||
@ -1067,6 +1069,25 @@
|
|||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))
|
(db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
|
;; --- Library relation helpers
|
||||||
|
|
||||||
|
(defn- check-library-team-ownership!
|
||||||
|
"Verify that file and library belong to the same team.
|
||||||
|
Prevents cross-team library relation injection."
|
||||||
|
[conn file-id library-id]
|
||||||
|
(let [sql "SELECT EXISTS (
|
||||||
|
SELECT 1 FROM file AS f
|
||||||
|
JOIN project AS fp ON (fp.id = f.project_id)
|
||||||
|
JOIN file AS l ON (l.id = ?)
|
||||||
|
JOIN project AS lp ON (lp.id = l.project_id)
|
||||||
|
WHERE f.id = ? AND fp.team_id = lp.team_id
|
||||||
|
) AS ok"
|
||||||
|
row (db/exec-one! conn [sql library-id file-id])]
|
||||||
|
(when-not (:ok row)
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "file and library must belong to the same team"))))
|
||||||
|
|
||||||
;; --- MUTATION COMMAND: link-file-to-library
|
;; --- MUTATION COMMAND: link-file-to-library
|
||||||
|
|
||||||
(def sql:link-file-to-library
|
(def sql:link-file-to-library
|
||||||
@ -1102,6 +1123,14 @@
|
|||||||
|
|
||||||
(check-edition-permissions! conn profile-id file-id)
|
(check-edition-permissions! conn profile-id file-id)
|
||||||
(check-edition-permissions! conn profile-id library-id)
|
(check-edition-permissions! conn profile-id library-id)
|
||||||
|
(check-library-team-ownership! conn file-id library-id)
|
||||||
|
|
||||||
|
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
|
||||||
|
(when (contains? transitive-deps file-id)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :circular-library-reference
|
||||||
|
:hint "linking this library would create a circular dependency")))
|
||||||
|
|
||||||
(link-file-to-library conn params)
|
(link-file-to-library conn params)
|
||||||
(bfc/get-libraries cfg [library-id]))
|
(bfc/get-libraries cfg [library-id]))
|
||||||
|
|
||||||
@ -1126,6 +1155,7 @@
|
|||||||
[{: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 library-id] :as params}]
|
||||||
(check-edition-permissions! conn profile-id file-id)
|
(check-edition-permissions! conn profile-id file-id)
|
||||||
(check-edition-permissions! conn profile-id library-id)
|
(check-edition-permissions! conn profile-id library-id)
|
||||||
|
(check-library-team-ownership! conn file-id library-id)
|
||||||
(unlink-file-from-library conn params)
|
(unlink-file-from-library conn params)
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
@ -1150,6 +1180,7 @@
|
|||||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||||
(check-edition-permissions! conn profile-id file-id)
|
(check-edition-permissions! conn profile-id file-id)
|
||||||
(check-edition-permissions! conn profile-id library-id)
|
(check-edition-permissions! conn profile-id library-id)
|
||||||
|
(check-library-team-ownership! conn file-id library-id)
|
||||||
(update-sync conn params))
|
(update-sync conn params))
|
||||||
|
|
||||||
;; --- MUTATION COMMAND: ignore-sync
|
;; --- MUTATION COMMAND: ignore-sync
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as-alias climit]
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
@ -275,7 +275,7 @@
|
|||||||
[:map {:title "create-file-object-thumbnail"}
|
[:map {:title "create-file-object-thumbnail"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:object-id [:string {:max 250}]]
|
[:object-id [:string {:max 250}]]
|
||||||
[:media media/schema:upload]
|
[:media media.v/schema:upload]
|
||||||
[:tag {:optional true} [:string {:max 50}]]])
|
[:tag {:optional true} [:string {:max 50}]]])
|
||||||
|
|
||||||
(sv/defmethod ::create-file-object-thumbnail
|
(sv/defmethod ::create-file-object-thumbnail
|
||||||
@ -289,8 +289,8 @@
|
|||||||
::sm/params schema:create-file-object-thumbnail}
|
::sm/params schema:create-file-object-thumbnail}
|
||||||
|
|
||||||
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
|
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
|
||||||
(media/validate-media-type! media)
|
(media.v/validate-media-type! media)
|
||||||
(media/validate-media-size! media)
|
(media.v/validate-media-size! media)
|
||||||
|
|
||||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||||
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
|
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
|
||||||
@ -374,67 +374,12 @@
|
|||||||
|
|
||||||
;; --- MUTATION COMMAND: create-file-thumbnail
|
;; --- MUTATION COMMAND: create-file-thumbnail
|
||||||
|
|
||||||
(defn- create-file-thumbnail
|
|
||||||
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}]
|
|
||||||
(media/validate-media-type! media)
|
|
||||||
(media/validate-media-size! media)
|
|
||||||
|
|
||||||
(let [file (bfc/get-file cfg file-id
|
|
||||||
:include-deleted? true
|
|
||||||
:load-data? false)
|
|
||||||
|
|
||||||
props (db/tjson (or props {}))
|
|
||||||
path (:path media)
|
|
||||||
mtype (:mtype media)
|
|
||||||
hash (sto/calculate-hash path)
|
|
||||||
data (-> (sto/content path)
|
|
||||||
(sto/wrap-with-hash hash))
|
|
||||||
tnow (ct/now)
|
|
||||||
|
|
||||||
media (sto/put-object! storage
|
|
||||||
{::sto/content data
|
|
||||||
::sto/deduplicate? true
|
|
||||||
::sto/touched-at tnow
|
|
||||||
:content-type mtype
|
|
||||||
:bucket "file-thumbnail"})
|
|
||||||
|
|
||||||
thumb (db/get* conn :file-thumbnail
|
|
||||||
{:file-id file-id
|
|
||||||
:revn revn}
|
|
||||||
{::db/remove-deleted false
|
|
||||||
::sql/for-update true})]
|
|
||||||
|
|
||||||
(if (some? thumb)
|
|
||||||
(do
|
|
||||||
;; We mark the old media id as touched if it does not match
|
|
||||||
(when (not= (:id media) (:media-id thumb))
|
|
||||||
(sto/touch-object! storage (:media-id thumb)))
|
|
||||||
|
|
||||||
(db/update! conn :file-thumbnail
|
|
||||||
{:media-id (:id media)
|
|
||||||
:deleted-at (:deleted-at file)
|
|
||||||
:updated-at tnow
|
|
||||||
:props props}
|
|
||||||
{:file-id file-id
|
|
||||||
:revn revn}))
|
|
||||||
|
|
||||||
(db/insert! conn :file-thumbnail
|
|
||||||
{:file-id file-id
|
|
||||||
:revn revn
|
|
||||||
:created-at tnow
|
|
||||||
:updated-at tnow
|
|
||||||
:deleted-at (:deleted-at file)
|
|
||||||
:props props
|
|
||||||
:media-id (:id media)}))
|
|
||||||
|
|
||||||
media))
|
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:create-file-thumbnail
|
schema:create-file-thumbnail
|
||||||
[:map {:title "create-file-thumbnail"}
|
[:map {:title "create-file-thumbnail"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:revn ::sm/int]
|
[:revn ::sm/int]
|
||||||
[:media media/schema:upload]])
|
[:media media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::create-file-thumbnail
|
(sv/defmethod ::create-file-thumbnail
|
||||||
"Creates or updates the file thumbnail. Mainly used for paint the
|
"Creates or updates the file thumbnail. Mainly used for paint the
|
||||||
@ -448,12 +393,57 @@
|
|||||||
::rtry/when rtry/conflict-exception?
|
::rtry/when rtry/conflict-exception?
|
||||||
::sm/params schema:create-file-thumbnail}
|
::sm/params schema:create-file-thumbnail}
|
||||||
|
|
||||||
;; FIXME: do not run the thumbnail upload inside a transaction
|
|
||||||
|
|
||||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(media.v/validate-media-type! (:media params))
|
||||||
(files/check-edition-permissions! conn profile-id file-id)
|
(media.v/validate-media-size! (:media params))
|
||||||
(when-not (db/read-only? conn)
|
|
||||||
(let [media (create-file-thumbnail cfg params)]
|
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||||
{:uri (files/resolve-public-uri (:id media))
|
|
||||||
:id (:id media)})))))
|
(when-not (db/read-only? (::db/pool cfg))
|
||||||
|
(let [storage (::sto/storage cfg)
|
||||||
|
file (bfc/get-file cfg file-id :include-deleted? true :load-data? false)
|
||||||
|
props (db/tjson (or (:props params) {}))
|
||||||
|
{:keys [path mtype]} (:media params)
|
||||||
|
hash (sto/calculate-hash path)
|
||||||
|
data (-> (sto/content path)
|
||||||
|
(sto/wrap-with-hash hash))
|
||||||
|
tnow (ct/now)
|
||||||
|
|
||||||
|
media (sto/put-object! storage
|
||||||
|
{::sto/content data
|
||||||
|
::sto/deduplicate? true
|
||||||
|
::sto/touched-at tnow
|
||||||
|
:content-type mtype
|
||||||
|
:bucket "file-thumbnail"})
|
||||||
|
|
||||||
|
revn (:revn params)
|
||||||
|
|
||||||
|
result (db/tx-run! cfg
|
||||||
|
(fn [{:keys [::db/conn]}]
|
||||||
|
(let [thumb (db/get* conn :file-thumbnail
|
||||||
|
{:file-id file-id :revn revn}
|
||||||
|
{::db/remove-deleted false
|
||||||
|
::sql/for-update true})]
|
||||||
|
(if (some? thumb)
|
||||||
|
(do
|
||||||
|
(when (not= (:id media) (:media-id thumb))
|
||||||
|
(sto/touch-object! storage (:media-id thumb)))
|
||||||
|
(db/update! conn :file-thumbnail
|
||||||
|
{:media-id (:id media)
|
||||||
|
:deleted-at (:deleted-at file)
|
||||||
|
:updated-at tnow
|
||||||
|
:props props}
|
||||||
|
{:file-id file-id :revn revn}))
|
||||||
|
(db/insert! conn :file-thumbnail
|
||||||
|
{:file-id file-id
|
||||||
|
:revn revn
|
||||||
|
:created-at tnow
|
||||||
|
:updated-at tnow
|
||||||
|
:deleted-at (:deleted-at file)
|
||||||
|
:props props
|
||||||
|
:media-id (:id media)}))
|
||||||
|
media)))]
|
||||||
|
|
||||||
|
(when result
|
||||||
|
{:uri (files/resolve-public-uri (:id result))
|
||||||
|
:id (:id result)}))))
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as-alias climit]
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
@ -38,10 +39,7 @@
|
|||||||
[datoteka.fs :as fs]
|
[datoteka.fs :as fs]
|
||||||
[datoteka.io :as io])
|
[datoteka.io :as io])
|
||||||
(:import
|
(:import
|
||||||
java.io.InputStream
|
|
||||||
java.io.OutputStream
|
java.io.OutputStream
|
||||||
java.io.SequenceInputStream
|
|
||||||
java.util.Collections
|
|
||||||
java.util.zip.ZipEntry
|
java.util.zip.ZipEntry
|
||||||
java.util.zip.ZipOutputStream))
|
java.util.zip.ZipOutputStream))
|
||||||
|
|
||||||
@ -95,19 +93,26 @@
|
|||||||
|
|
||||||
(declare create-font-variant)
|
(declare create-font-variant)
|
||||||
|
|
||||||
|
(defn- check-font-team-ownership!
|
||||||
|
"When font-id already has variants belonging to a different team,
|
||||||
|
raises :not-found to prevent cross-team font injection."
|
||||||
|
[conn team-id font-id]
|
||||||
|
(let [row (db/get* conn :team-font-variant
|
||||||
|
{:font-id font-id}
|
||||||
|
{::db/columns [:team-id]})]
|
||||||
|
(when (and row (not= (:team-id row) team-id))
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "font does not belong to this team"))))
|
||||||
|
|
||||||
(def ^:private schema:create-font-variant
|
(def ^:private schema:create-font-variant
|
||||||
[:and
|
[:map {:title "create-font-variant"}
|
||||||
[:map {:title "create-font-variant"}
|
[:team-id ::sm/uuid]
|
||||||
[:team-id ::sm/uuid]
|
[:font-id ::sm/uuid]
|
||||||
[:font-id ::sm/uuid]
|
[:font-family types.font/schema:font-family]
|
||||||
[:font-family types.font/schema:font-family]
|
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
||||||
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
[:uploads [:map-of ::sm/text ::sm/uuid]]])
|
||||||
[:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]]
|
|
||||||
[:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]]
|
|
||||||
[:fn {:error/message "one of :data or :uploads is required"}
|
|
||||||
(fn [{:keys [data uploads]}]
|
|
||||||
(or (seq data) (seq uploads)))]])
|
|
||||||
|
|
||||||
(defn- prepare-font-data-from-uploads
|
(defn- prepare-font-data-from-uploads
|
||||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||||
@ -118,8 +123,8 @@
|
|||||||
(fn [acc mtype session-id]
|
(fn [acc mtype session-id]
|
||||||
(let [assembled (assemble-chunks cfg session-id)]
|
(let [assembled (assemble-chunks cfg session-id)]
|
||||||
(-> {:mtype mtype :size (:size assembled)}
|
(-> {:mtype mtype :size (:size assembled)}
|
||||||
(media/validate-media-type! cm/font-types)
|
(media.v/validate-media-type! cm/font-types)
|
||||||
(media/validate-font-size!))
|
(media.v/validate-font-size!))
|
||||||
(assoc acc mtype (:path assembled))))
|
(assoc acc mtype (:path assembled))))
|
||||||
{}
|
{}
|
||||||
uploads)]
|
uploads)]
|
||||||
@ -128,54 +133,24 @@
|
|||||||
(assoc :data data)
|
(assoc :data data)
|
||||||
(dissoc :uploads))))
|
(dissoc :uploads))))
|
||||||
|
|
||||||
(defn- prepare-font-data-from-legacy
|
|
||||||
"Validates the media type and size of every entry in the legacy
|
|
||||||
`:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every
|
|
||||||
entry to a tempfile. Returns params with a normalised
|
|
||||||
`{mtype → path}` data map."
|
|
||||||
[{:keys [data] :as params}]
|
|
||||||
(let [data (reduce-kv
|
|
||||||
(fn [acc mtype content]
|
|
||||||
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
|
|
||||||
chunks (if (vector? content) content [content])
|
|
||||||
streams (map io/input-stream chunks)
|
|
||||||
streams (Collections/enumeration streams)]
|
|
||||||
|
|
||||||
;; Generate the tempfile from all chunks
|
|
||||||
(with-open [^OutputStream output (io/output-stream tmp)
|
|
||||||
^InputStream input (SequenceInputStream. streams)]
|
|
||||||
(io/copy input output))
|
|
||||||
|
|
||||||
;; Validate
|
|
||||||
(-> {:mtype mtype :size (fs/size tmp)}
|
|
||||||
(media/validate-media-type! cm/font-types)
|
|
||||||
(media/validate-font-size!))
|
|
||||||
|
|
||||||
(assoc acc mtype tmp)))
|
|
||||||
{}
|
|
||||||
data)]
|
|
||||||
(assoc params :data data)))
|
|
||||||
|
|
||||||
(sv/defmethod ::create-font-variant
|
(sv/defmethod ::create-font-variant
|
||||||
"Upload a font variant. Font data may be provided either as a
|
"Upload a font variant. Font data must be provided as an `:uploads`
|
||||||
Transit-encoded `:data` map (keyed by mime-type) for small fonts, or
|
map (keyed by mime-type, values are upload-session UUIDs from the
|
||||||
as an `:uploads` map (keyed by mime-type, values are upload-session
|
chunked-upload API)."
|
||||||
UUIDs from the chunked-upload API) for large fonts. Exactly one of
|
|
||||||
the two must be present."
|
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
::doc/changes ["2.16" "Add :uploads param for chunked upload support"]
|
::doc/changes [["2.16" "Add :uploads param for chunked upload support"]
|
||||||
|
["2.18" "Remove :data param, use :uploads exclusively"]]
|
||||||
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
||||||
[:process-font/global]]
|
[:process-font/global]]
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:create-font-variant}
|
::sm/params schema:create-font-variant}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id] :as params}]
|
||||||
(teams/check-edition-permissions! pool profile-id team-id)
|
(teams/check-edition-permissions! pool profile-id team-id)
|
||||||
|
(check-font-team-ownership! pool team-id font-id)
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/team-id team-id})
|
::quotes/team-id team-id})
|
||||||
(let [params (if (some? uploads)
|
(let [params (db/tx-run! cfg prepare-font-data-from-uploads params)]
|
||||||
(db/tx-run! cfg prepare-font-data-from-uploads params)
|
|
||||||
(prepare-font-data-from-legacy params))]
|
|
||||||
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
||||||
|
|
||||||
(defn create-font-variant
|
(defn create-font-variant
|
||||||
@ -229,9 +204,7 @@
|
|||||||
(let [tpoint (ct/tpoint)
|
(let [tpoint (ct/tpoint)
|
||||||
mtypes (vec (keys data))
|
mtypes (vec (keys data))
|
||||||
total-size (reduce-kv (fn [acc _ content]
|
total-size (reduce-kv (fn [acc _ content]
|
||||||
(+ acc (if (bytes? content)
|
(+ acc (fs/size content)))
|
||||||
(alength ^bytes content)
|
|
||||||
(fs/size content))))
|
|
||||||
0
|
0
|
||||||
data)]
|
data)]
|
||||||
|
|
||||||
@ -370,7 +343,7 @@
|
|||||||
(defn- make-temporal-storage-object
|
(defn- make-temporal-storage-object
|
||||||
[cfg profile-id content]
|
[cfg profile-id content]
|
||||||
(let [storage (sto/resolve cfg)
|
(let [storage (sto/resolve cfg)
|
||||||
content (media/check-input content)
|
content (media.v/check-input content)
|
||||||
hash (sto/calculate-hash (:path content))
|
hash (sto/calculate-hash (:path content))
|
||||||
data (-> (sto/content (:path content))
|
data (-> (sto/content (:path content))
|
||||||
(sto/wrap-with-hash hash))
|
(sto/wrap-with-hash hash))
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
|
[app.media.svg :as svg]
|
||||||
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as climit]
|
[app.rpc.climit :as climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
@ -44,7 +46,7 @@
|
|||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:is-local ::sm/boolean]
|
[:is-local ::sm/boolean]
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:content media/schema:upload]])
|
[:content media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::upload-file-media-object
|
(sv/defmethod ::upload-file-media-object
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
@ -53,8 +55,8 @@
|
|||||||
[:process-image/global]]}
|
[:process-image/global]]}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
||||||
(files/check-edition-permissions! pool profile-id file-id)
|
(files/check-edition-permissions! pool profile-id file-id)
|
||||||
(media/validate-media-type! content)
|
(media.v/validate-media-type! content)
|
||||||
(media/validate-media-size! content)
|
(media.v/validate-media-size! content)
|
||||||
|
|
||||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
;; We get the minimal file for proper checking if
|
;; We get the minimal file for proper checking if
|
||||||
@ -113,13 +115,22 @@
|
|||||||
|
|
||||||
(defn- process-main-image
|
(defn- process-main-image
|
||||||
[info]
|
[info]
|
||||||
(let [hash (sto/calculate-hash (:path info))
|
(let [path (:path info)
|
||||||
data (-> (sto/content (:path info))
|
mtype (:mtype info)
|
||||||
(sto/wrap-with-hash hash))]
|
path (if (= mtype "image/svg+xml")
|
||||||
|
(let [content (slurp path)
|
||||||
|
sanitized (svg/sanitize-svg content)
|
||||||
|
temp-path (tmp/tempfile :prefix "penpot-svg-" :suffix ".svg" :min-age "5m")]
|
||||||
|
(spit (str temp-path) sanitized)
|
||||||
|
temp-path)
|
||||||
|
path)
|
||||||
|
hash (sto/calculate-hash path)
|
||||||
|
data (-> (sto/content path)
|
||||||
|
(sto/wrap-with-hash hash))]
|
||||||
{::sto/content data
|
{::sto/content data
|
||||||
::sto/deduplicate? true
|
::sto/deduplicate? true
|
||||||
::sto/touched-at (:ts info)
|
::sto/touched-at (:ts info)
|
||||||
:content-type (:mtype info)
|
:content-type mtype
|
||||||
:bucket "file-media-object"}))
|
:bucket "file-media-object"}))
|
||||||
|
|
||||||
(defn- process-thumb-image
|
(defn- process-thumb-image
|
||||||
@ -315,7 +326,7 @@
|
|||||||
[:map {:title "upload-chunk"}
|
[:map {:title "upload-chunk"}
|
||||||
[:session-id ::sm/uuid]
|
[:session-id ::sm/uuid]
|
||||||
[:index ::sm/int]
|
[:index ::sm/int]
|
||||||
[:content media/schema:upload]])
|
[:content media.v/schema:upload]])
|
||||||
|
|
||||||
(def ^:private schema:upload-chunk-result
|
(def ^:private schema:upload-chunk-result
|
||||||
[:map {:title "upload-chunk-result"}
|
[:map {:title "upload-chunk-result"}
|
||||||
@ -386,7 +397,7 @@
|
|||||||
(defn assemble-chunks
|
(defn assemble-chunks
|
||||||
"Validates that all expected chunks are present for `session-id` and
|
"Validates that all expected chunks are present for `session-id` and
|
||||||
concatenates them into a single temporary file. Returns a map
|
concatenates them into a single temporary file. Returns a map
|
||||||
conforming to `media/schema:upload` with `:filename`, `:path` and
|
conforming to `media.v/schema:upload` with `:filename`, `:path` and
|
||||||
`:size`.
|
`:size`.
|
||||||
|
|
||||||
Raises a :validation/:missing-chunks error when the number of stored
|
Raises a :validation/:missing-chunks error when the number of stored
|
||||||
@ -440,8 +451,8 @@
|
|||||||
content (-> content
|
content (-> content
|
||||||
(assoc :filename (str "upload:" name))
|
(assoc :filename (str "upload:" name))
|
||||||
(assoc :mtype mtype)
|
(assoc :mtype mtype)
|
||||||
(media/validate-media-type!)
|
(media.v/validate-media-type!)
|
||||||
(media/validate-media-size!))
|
(media.v/validate-media-size!))
|
||||||
mobj (create-file-media-object cfg (assoc params
|
mobj (create-file-media-object cfg (assoc params
|
||||||
:id id
|
:id id
|
||||||
:from-chunks? true
|
:from-chunks? true
|
||||||
|
|||||||
@ -11,9 +11,10 @@
|
|||||||
[app.auth.oidc :as oidc]
|
[app.auth.oidc :as oidc]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.json :as json]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
[app.common.types.organization :as cto]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
@ -24,7 +25,8 @@
|
|||||||
[app.rpc.nitrate.emails-helper :as neh]
|
[app.rpc.nitrate.emails-helper :as neh]
|
||||||
[app.rpc.nitrate.organization-helper :as noh]
|
[app.rpc.nitrate.organization-helper :as noh]
|
||||||
[app.rpc.notifications :as notifications]
|
[app.rpc.notifications :as notifications]
|
||||||
[app.util.services :as sv]))
|
[app.util.services :as sv]
|
||||||
|
[buddy.core.codecs :as bc]))
|
||||||
|
|
||||||
|
|
||||||
(defn assert-is-owner [cfg profile-id team-id]
|
(defn assert-is-owner [cfg profile-id team-id]
|
||||||
@ -40,11 +42,11 @@
|
|||||||
:code :cant-move-default-team))))
|
:code :cant-move-default-team))))
|
||||||
|
|
||||||
(defn assert-membership [cfg profile-id organization-id]
|
(defn assert-membership [cfg profile-id organization-id]
|
||||||
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||||
:organization-id organization-id})]
|
:organization-id organization-id})]
|
||||||
(when-not (:organization-id membership)
|
(when-not (:organization-id membership)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :organization-doesnt-exists))
|
:code :organization-does-not-exist))
|
||||||
|
|
||||||
(when-not (:is-member membership)
|
(when-not (:is-member membership)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -114,6 +116,35 @@
|
|||||||
:cause cause)
|
:cause cause)
|
||||||
(throw cause)))))))
|
(throw cause)))))))
|
||||||
|
|
||||||
|
(def ^:private activation-code-request-filename
|
||||||
|
"penpot-activation-code-request.txt")
|
||||||
|
|
||||||
|
(sv/defmethod ::get-nitrate-activation-code-request
|
||||||
|
"Returns a Base64-encoded JSON file requesting a Nitrate activation code.
|
||||||
|
Payload includes nitrateId, publicKey, email and iat."
|
||||||
|
{::rpc/auth true
|
||||||
|
::doc/added "2.20"
|
||||||
|
::sm/params [:map]
|
||||||
|
::sm/result ::sm/text}
|
||||||
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
|
(let [profile (db/get cfg :profile {:id profile-id})
|
||||||
|
nitrate-identity (nitrate/call cfg :get-identity {})]
|
||||||
|
(when-not nitrate-identity
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :nitrate-identity-unavailable
|
||||||
|
:hint "Unable to retrieve nitrate identity"))
|
||||||
|
(-> (json/encode {:nitrate-id (:nitrate-id nitrate-identity)
|
||||||
|
:public-key (:public-key nitrate-identity)
|
||||||
|
:email (:email profile)
|
||||||
|
:iat (ct/seconds (ct/now))}
|
||||||
|
:key-fn json/write-camel-key)
|
||||||
|
(bc/str->bytes)
|
||||||
|
(bc/bytes->b64-str)
|
||||||
|
(rph/wrap)
|
||||||
|
(rph/with-header "content-type" "text/plain")
|
||||||
|
(rph/with-header "content-disposition"
|
||||||
|
(str "attachment; filename=\"" activation-code-request-filename "\"")))))
|
||||||
|
|
||||||
(def ^:private sql:prefix-team-name-and-unset-default
|
(def ^:private sql:prefix-team-name-and-unset-default
|
||||||
"UPDATE team
|
"UPDATE team
|
||||||
SET name = ? || name,
|
SET name = ? || name,
|
||||||
@ -150,7 +181,7 @@
|
|||||||
{})))
|
{})))
|
||||||
{}))
|
{}))
|
||||||
|
|
||||||
(defn- build-leave-org-plan
|
(defn- build-leave-organization-plan
|
||||||
[{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?]
|
[{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?]
|
||||||
(let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
|
(let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
|
||||||
files-counts (get-team-files-counts conn all-teams)
|
files-counts (get-team-files-counts conn all-teams)
|
||||||
@ -163,18 +194,18 @@
|
|||||||
{:deletable-team-ids deletable
|
{:deletable-team-ids deletable
|
||||||
:keep-default-team? keep-default?
|
:keep-default-team? keep-default?
|
||||||
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
|
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
|
||||||
:detach-from-org-team-ids to-detach}))
|
:detach-from-organization-team-ids to-detach}))
|
||||||
|
|
||||||
(defn get-leave-org-summary
|
(defn get-leave-organization-summary
|
||||||
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
|
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
|
||||||
(let [{:keys [deletable-team-ids detach-from-org-team-ids]}
|
(let [{:keys [deletable-team-ids detach-from-organization-team-ids]}
|
||||||
(build-leave-org-plan cfg default-team-id teams-to-delete nil)]
|
(build-leave-organization-plan cfg default-team-id teams-to-delete nil)]
|
||||||
{:teams-to-delete (count deletable-team-ids)
|
{:teams-to-delete (count deletable-team-ids)
|
||||||
:teams-to-transfer teams-to-transfer-count
|
:teams-to-transfer teams-to-transfer-count
|
||||||
:teams-to-exit teams-to-exit-count
|
:teams-to-exit teams-to-exit-count
|
||||||
:teams-to-detach (count detach-from-org-team-ids)}))
|
:teams-to-detach (count detach-from-organization-team-ids)}))
|
||||||
|
|
||||||
(def ^:private schema:leave-org
|
(def ^:private schema:leave-organization
|
||||||
[:map
|
[:map
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:name ::sm/text]
|
[:name ::sm/text]
|
||||||
@ -187,47 +218,49 @@
|
|||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
||||||
|
|
||||||
(def ^:private schema:get-leave-org-summary-result
|
(def ^:private schema:get-leave-organization-summary-result
|
||||||
[:map
|
[:map
|
||||||
[:teams-to-delete ::sm/int]
|
[:teams-to-delete ::sm/int]
|
||||||
[:teams-to-transfer ::sm/int]
|
[:teams-to-transfer ::sm/int]
|
||||||
[:teams-to-exit ::sm/int]
|
[:teams-to-exit ::sm/int]
|
||||||
[:teams-to-detach ::sm/int]])
|
[:teams-to-detach ::sm/int]
|
||||||
|
[:member-added-at [:maybe ct/schema:inst]]
|
||||||
|
[:organization-member-count-before ::sm/int]])
|
||||||
|
|
||||||
(def ^:private schema:get-leave-org-summary
|
(def ^:private schema:get-leave-organization-summary
|
||||||
[:map
|
[:map
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:default-team-id ::sm/uuid]])
|
[:default-team-id ::sm/uuid]])
|
||||||
|
|
||||||
|
|
||||||
(defn- get-organization-teams-for-user
|
(defn- get-organization-teams-for-user
|
||||||
[{:keys [::db/conn] :as cfg} org-summary profile-id]
|
[{:keys [::db/conn] :as cfg} organization-summary profile-id]
|
||||||
(let [org-team-ids (->> (:teams org-summary)
|
(let [organization-team-ids (->> (:teams organization-summary)
|
||||||
(map :id))
|
(map :id))
|
||||||
ids-array (db/create-array conn "uuid" org-team-ids)]
|
ids-array (db/create-array conn "uuid" organization-team-ids)]
|
||||||
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
|
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
|
||||||
|
|
||||||
(defn- calculate-valid-teams
|
(defn- calculate-valid-teams
|
||||||
([org-teams default-team-id]
|
([organization-teams default-team-id]
|
||||||
(let [;; valid default team is the one which id is default-team-id
|
(let [;; valid default team is the one which id is default-team-id
|
||||||
valid-default-team (d/seek #(= default-team-id (:id %)) org-teams)
|
valid-default-team (d/seek #(= default-team-id (:id %)) organization-teams)
|
||||||
|
|
||||||
;; Remove your-penpot for the rest of validations
|
;; Remove your-penpot for the rest of validations
|
||||||
org-teams (remove #(= default-team-id (:id %)) org-teams)
|
organization-teams (remove #(= default-team-id (:id %)) organization-teams)
|
||||||
|
|
||||||
;; valid teams to delete are those that the user is owner, and only have one member
|
;; valid teams to delete are those that the user is owner, and only have one member
|
||||||
valid-teams-to-delete-ids (->> org-teams
|
valid-teams-to-delete-ids (->> organization-teams
|
||||||
(filter #(and (:is-owner %)
|
(filter #(and (:is-owner %)
|
||||||
(= (:num-members %) 1)))
|
(= (:num-members %) 1)))
|
||||||
(map :id)
|
(map :id)
|
||||||
(into #{}))
|
(into #{}))
|
||||||
;; valid teams to transfer are those that the user is owner, and have more than one member
|
;; valid teams to transfer are those that the user is owner, and have more than one member
|
||||||
valid-teams-to-transfer (->> org-teams
|
valid-teams-to-transfer (->> organization-teams
|
||||||
(filter #(and (:is-owner %)
|
(filter #(and (:is-owner %)
|
||||||
(> (:num-members %) 1))))
|
(> (:num-members %) 1))))
|
||||||
|
|
||||||
;; valid teams to exit are those that the user isn't owner, and have more than one member
|
;; valid teams to exit are those that the user isn't owner, and have more than one member
|
||||||
valid-teams-to-exit (->> org-teams
|
valid-teams-to-exit (->> organization-teams
|
||||||
(filter #(and (not (:is-owner %))
|
(filter #(and (not (:is-owner %))
|
||||||
(> (:num-members %) 1))))]
|
(> (:num-members %) 1))))]
|
||||||
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
|
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
|
||||||
@ -236,17 +269,17 @@
|
|||||||
:valid-default-team valid-default-team})))
|
:valid-default-team valid-default-team})))
|
||||||
|
|
||||||
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
|
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
|
||||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||||
org-teams (get-organization-teams-for-user cfg org-summary profile-id)]
|
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)]
|
||||||
(calculate-valid-teams org-teams default-team-id)))
|
(calculate-valid-teams organization-teams default-team-id)))
|
||||||
|
|
||||||
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
|
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
|
||||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||||
org-teams (get-organization-teams-for-user cfg org-summary profile-id)
|
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)
|
||||||
{:keys [valid-teams-to-delete-ids
|
{:keys [valid-teams-to-delete-ids
|
||||||
valid-teams-to-transfer
|
valid-teams-to-transfer
|
||||||
valid-teams-to-exit
|
valid-teams-to-exit
|
||||||
valid-default-team]} (calculate-valid-teams org-teams default-team-id)
|
valid-default-team]} (calculate-valid-teams organization-teams default-team-id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -264,7 +297,7 @@
|
|||||||
;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and
|
;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and
|
||||||
;; the reassign-to is a member of the team and not the current user;
|
;; the reassign-to is a member of the team and not the current user;
|
||||||
;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit
|
;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit
|
||||||
teams-by-id (d/index-by :id org-teams)
|
teams-by-id (d/index-by :id organization-teams)
|
||||||
valid-teams-to-leave? (and
|
valid-teams-to-leave? (and
|
||||||
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
|
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
|
||||||
(every? (fn [{:keys [id reassign-to]}]
|
(every? (fn [{:keys [id reassign-to]}]
|
||||||
@ -275,10 +308,10 @@
|
|||||||
(contains? members reassign-to)))
|
(contains? members reassign-to)))
|
||||||
(contains? valid-teams-to-exit-ids id)))
|
(contains? valid-teams-to-exit-ids id)))
|
||||||
teams-to-leave))]
|
teams-to-leave))]
|
||||||
;; the org owner cannot leave
|
;; the organization owner cannot leave
|
||||||
(when (= (:owner-id org-summary) profile-id)
|
(when (= (:owner-id organization-summary) profile-id)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :org-owner-cannot-leave))
|
:code :organization-owner-cannot-leave))
|
||||||
|
|
||||||
(when (or
|
(when (or
|
||||||
(not valid-teams-to-delete?)
|
(not valid-teams-to-delete?)
|
||||||
@ -289,13 +322,14 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn leave-org
|
(defn leave-organization
|
||||||
[{:keys [::db/conn] :as cfg}
|
[{:keys [::db/conn] :as cfg}
|
||||||
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?]}]
|
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?
|
||||||
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
user-who-delete-member deleted-by-role]}]
|
||||||
|
(let [organization-prefix (str "[" (d/sanitize-string name) "] ")
|
||||||
{:keys [deletable-team-ids
|
{:keys [deletable-team-ids
|
||||||
keep-default-team?
|
keep-default-team?
|
||||||
detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
detach-from-organization-team-ids]} (build-leave-organization-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
||||||
|
|
||||||
;; assert that the received teams are valid, checking the different constraints
|
;; assert that the received teams are valid, checking the different constraints
|
||||||
(when-not skip-validation
|
(when-not skip-validation
|
||||||
@ -312,87 +346,102 @@
|
|||||||
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
||||||
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
||||||
|
|
||||||
;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||||
(when default-team-id
|
(when default-team-id
|
||||||
(if keep-default-team?
|
(if keep-default-team?
|
||||||
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])
|
(db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id])
|
||||||
(teams/delete-team cfg {:profile-id profile-id
|
(teams/delete-team cfg {:profile-id profile-id
|
||||||
:team-id default-team-id})))
|
:team-id default-team-id})))
|
||||||
|
|
||||||
;; Detach retained owned teams from the organization in Nitrate.
|
;; Detach retained owned teams from the organization in Nitrate.
|
||||||
;; Nitrate will rehome them to its fallback/default org.
|
;; Nitrate will rehome them to its fallback/default organization.
|
||||||
(doseq [team-id detach-from-org-team-ids]
|
(doseq [team-id detach-from-organization-team-ids]
|
||||||
(nitrate/call cfg :remove-team-from-org {:team-id team-id
|
(nitrate/call cfg :remove-team-from-organization {:team-id team-id
|
||||||
:organization-id id}))
|
:organization-id id}))
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
|
(nitrate/call cfg :remove-profile-from-organization
|
||||||
|
{:profile-id profile-id
|
||||||
|
:organization-id id
|
||||||
|
:user-who-delete-member user-who-delete-member
|
||||||
|
:deleted-by-role deleted-by-role})
|
||||||
|
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
|
||||||
(sv/defmethod ::leave-org
|
(sv/defmethod ::leave-organization
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.15"
|
::doc/added "2.15"
|
||||||
::sm/params schema:leave-org
|
::sm/params schema:leave-organization
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(leave-org cfg (assoc params :profile-id profile-id)))
|
(leave-organization cfg (assoc params
|
||||||
|
:profile-id profile-id
|
||||||
|
:user-who-delete-member profile-id
|
||||||
|
:deleted-by-role "organization-member")))
|
||||||
|
|
||||||
|
|
||||||
(sv/defmethod ::get-leave-org-summary
|
(sv/defmethod ::get-leave-organization-summary
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.18"
|
::doc/added "2.18"
|
||||||
::sm/params schema:get-leave-org-summary
|
::sm/params schema:get-leave-organization-summary
|
||||||
::sm/result schema:get-leave-org-summary-result
|
::sm/result schema:get-leave-organization-summary-result
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id id default-team-id]}]
|
[cfg {:keys [::rpc/profile-id id default-team-id]}]
|
||||||
(let [{:keys [valid-teams-to-delete-ids
|
(let [{:keys [valid-teams-to-delete-ids
|
||||||
valid-teams-to-transfer
|
valid-teams-to-transfer
|
||||||
valid-teams-to-exit
|
valid-teams-to-exit
|
||||||
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
|
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
|
||||||
|
membership (nitrate/call cfg :get-organization-membership
|
||||||
|
{:profile-id profile-id
|
||||||
|
:organization-id id})
|
||||||
|
organization-members (nitrate/call cfg :get-organization-members
|
||||||
|
{:organization-id id})
|
||||||
teams-to-transfer-count (count valid-teams-to-transfer)
|
teams-to-transfer-count (count valid-teams-to-transfer)
|
||||||
teams-to-exit-count (count valid-teams-to-exit)]
|
teams-to-exit-count (count valid-teams-to-exit)]
|
||||||
(when-not valid-default-team
|
(when-not valid-default-team
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-valid-teams))
|
:code :not-valid-teams))
|
||||||
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)))
|
(assoc
|
||||||
|
(get-leave-organization-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
|
||||||
|
:member-added-at (:created-at membership)
|
||||||
|
:organization-member-count-before (count organization-members))))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private schema:remove-team-from-org
|
(def ^:private schema:remove-team-from-organization
|
||||||
[:map
|
[:map
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
[:organization-name ::sm/text]])
|
[:organization-name ::sm/text]])
|
||||||
|
|
||||||
(sv/defmethod ::remove-team-from-org
|
(sv/defmethod ::remove-team-from-organization
|
||||||
{::doc/added "2.17"
|
{::doc/added "2.17"
|
||||||
::sm/params schema:remove-team-from-org}
|
::sm/params schema:remove-team-from-organization}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
||||||
|
|
||||||
(assert-is-owner cfg profile-id team-id)
|
(assert-is-owner cfg profile-id team-id)
|
||||||
(assert-not-default-team cfg team-id)
|
(assert-not-default-team cfg team-id)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(assert-membership cfg profile-id organization-id)
|
||||||
;; Check moveTeams permission on the source organization
|
;; Check moveTeams permission on the source organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [org-perms (nitrate/call cfg :get-org-permissions
|
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||||
{:organization-id organization-id})]
|
{:organization-id organization-id})]
|
||||||
(if (nil? org-perms)
|
(if (nil? organization-perms)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "Unable to verify organization permissions")
|
:hint "Unable to verify organization permissions")
|
||||||
(when-not (nitrate-perms/allowed? :move-team
|
(when-not (cto/allowed? :move-team
|
||||||
{:org-perms org-perms
|
{:organization-perms organization-perms
|
||||||
:profile-id profile-id})
|
:profile-id profile-id})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))))
|
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))))
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
|
(nitrate/call cfg :remove-team-from-organization {:team-id team-id :organization-id organization-id})
|
||||||
|
|
||||||
;; Notify connected users
|
;; Notify connected users
|
||||||
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
|
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-organization")
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
(def ^:private sql:delete-team-external-invitations
|
(def ^:private sql:delete-team-external-invitations
|
||||||
@ -408,12 +457,12 @@
|
|||||||
AND deleted_at IS NULL")
|
AND deleted_at IS NULL")
|
||||||
|
|
||||||
(defn- get-external-invitation-info
|
(defn- get-external-invitation-info
|
||||||
"Returns info about external (non-org-member) invitations pending for a team.
|
"Returns info about external (non-organization-member) invitations pending for a team.
|
||||||
External invitations are those sent to users who are not members of the given org.
|
External invitations are those sent to users who are not members of the given organization.
|
||||||
Returns {:allows-anybody bool :external-emails [...]}"
|
Returns {:allows-anybody bool :external-emails [...]}"
|
||||||
[{:keys [::db/conn] :as cfg} team-id organization-id]
|
[{:keys [::db/conn] :as cfg} team-id organization-id]
|
||||||
(let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id})
|
(let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})
|
||||||
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})]
|
allows-anybody (cto/allowed? :add-anybody-to-team {:organization-perms organization-perms})]
|
||||||
(if allows-anybody
|
(if allows-anybody
|
||||||
{:allows-anybody true :external-emails []}
|
{:allows-anybody true :external-emails []}
|
||||||
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
|
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
|
||||||
@ -421,9 +470,9 @@
|
|||||||
{:allows-anybody false :external-emails []}
|
{:allows-anybody false :external-emails []}
|
||||||
(let [emails-array (db/create-array conn "text" (vec emails))
|
(let [emails-array (db/create-array conn "text" (vec emails))
|
||||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||||
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
|
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||||
external-emails (->> profiles
|
external-emails (->> profiles
|
||||||
(remove #(contains? org-member-ids (:id %)))
|
(remove #(contains? organization-member-ids (:id %)))
|
||||||
(map :email)
|
(map :email)
|
||||||
(vec))]
|
(vec))]
|
||||||
{:allows-anybody false :external-emails external-emails}))))))
|
{:allows-anybody false :external-emails external-emails}))))))
|
||||||
@ -444,59 +493,61 @@
|
|||||||
(assert-not-default-team cfg team-id)
|
(assert-not-default-team cfg team-id)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(assert-membership cfg profile-id organization-id)
|
||||||
|
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [org-member-ids-before (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
|
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||||
team-with-org (nitrate/call cfg :get-team-org {:team-id team-id})
|
team-with-organization (nitrate/call cfg :get-team-organization {:team-id team-id})
|
||||||
source-org-id (get-in team-with-org [:organization :id])
|
source-organization-id (get-in team-with-organization [:organization :id])
|
||||||
source-org-perms (when source-org-id
|
source-organization-perms (when source-organization-id
|
||||||
(nitrate/call cfg :get-org-permissions
|
(nitrate/call cfg :get-organization-permissions
|
||||||
{:organization-id source-org-id}))
|
{:organization-id source-organization-id}))
|
||||||
target-org-perms (nitrate/call cfg :get-org-permissions
|
target-organization-perms (nitrate/call cfg :get-organization-permissions
|
||||||
{:organization-id organization-id})
|
{:organization-id organization-id})
|
||||||
target-org-same-owner? (and (some? source-org-perms)
|
target-organization-same-owner? (and (some? source-organization-perms)
|
||||||
(some? target-org-perms)
|
(some? target-organization-perms)
|
||||||
(= (:owner-id source-org-perms)
|
(= (:owner-id source-organization-perms)
|
||||||
(:owner-id target-org-perms)))]
|
(:owner-id target-organization-perms)))]
|
||||||
(when (nil? target-org-perms)
|
(when (nil? target-organization-perms)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "Unable to verify organization permissions"))
|
:hint "Unable to verify organization permissions"))
|
||||||
|
|
||||||
;; Team already belongs to an organization: check move-teams on source org.
|
;; Team already belongs to an organization: check move-teams on the source organization.
|
||||||
(when (some? source-org-id)
|
(when (some? source-organization-id)
|
||||||
(when (nil? source-org-perms)
|
(when (nil? source-organization-perms)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "Unable to verify organization permissions"))
|
:hint "Unable to verify organization permissions"))
|
||||||
(when-not (nitrate-perms/allowed? :move-team
|
(when-not (cto/allowed? :move-team
|
||||||
{:org-perms source-org-perms
|
{:organization-perms source-organization-perms
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:target-org-same-owner? target-org-same-owner?})
|
:target-organization-same-owner? target-organization-same-owner?})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))
|
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))
|
||||||
|
|
||||||
;; Always check target create-teams permission (new/add and move flows).
|
;; Always check target create-teams permission (new/add and move flows).
|
||||||
(when-not (nitrate-perms/allowed? :create-team
|
(when-not (cto/allowed? :create-team
|
||||||
{:org-perms target-org-perms
|
{:organization-perms target-organization-perms
|
||||||
:profile-id profile-id})
|
:profile-id profile-id})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "You are not allowed to add teams in this organization"))
|
:hint "You are not allowed to add teams in this organization"))
|
||||||
|
|
||||||
;; Add teammates to the org if needed
|
;; Add teammates to the organization if needed
|
||||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||||
new-member-ids (->> team-members
|
new-member-ids (->> team-members
|
||||||
(map :profile-id)
|
(map :profile-id)
|
||||||
(remove #{profile-id})
|
(remove #{profile-id})
|
||||||
(remove org-member-ids-before))]
|
(remove organization-member-ids-before))]
|
||||||
(doseq [member-id new-member-ids]
|
(doseq [member-id new-member-ids]
|
||||||
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
|
(teams/initialize-user-in-organization cfg member-id organization-id)))
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
(let [team (nitrate/call cfg :set-team-organization {:team-id team-id
|
||||||
|
:organization-id organization-id
|
||||||
|
:is-default false})]
|
||||||
;; Notify connected users
|
;; Notify connected users
|
||||||
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
(notifications/notify-team-change cfg team "dashboard.team-belong-organization"))
|
||||||
|
|
||||||
;; Delete pending invitations for users who are not members of the target organization
|
;; Delete pending invitations for users who are not members of the target organization
|
||||||
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
||||||
@ -505,73 +556,73 @@
|
|||||||
emails-array (db/create-array conn "text" external-emails)]
|
emails-array (db/create-array conn "text" external-emails)]
|
||||||
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))
|
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))
|
||||||
|
|
||||||
;; Send warnings via email if the org has sso
|
;; Send warnings via email if the organization has sso
|
||||||
(neh/send-organization-setup-sso-emails-for-team!
|
(neh/send-organization-setup-sso-emails-for-team!
|
||||||
cfg organization-id team-id org-member-ids-before)))
|
cfg organization-id team-id organization-member-ids-before)))
|
||||||
|
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
(def ^:private schema:check-org-members-params
|
(def ^:private schema:check-organization-members-params
|
||||||
[:map {:title "CheckOrgMembersParams"}
|
[:map {:title "CheckOrganizationMembersParams"}
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
[:emails [:vector ::sm/email]]])
|
[:emails [:vector ::sm/email]]])
|
||||||
|
|
||||||
(sv/defmethod ::check-org-members
|
(sv/defmethod ::check-organization-members
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.17"
|
||||||
::sm/params schema:check-org-members-params
|
::sm/params schema:check-organization-members-params
|
||||||
::sm/result [:map-of :string :boolean]
|
::sm/result [:map-of :string :boolean]
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
||||||
(or (when (contains? cf/flags :nitrate)
|
(or (when (contains? cf/flags :admin-console)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(assert-membership cfg profile-id organization-id)
|
||||||
(let [emails-array (db/create-array conn "text" emails)
|
(let [emails-array (db/create-array conn "text" emails)
|
||||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||||
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
||||||
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))]
|
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))]
|
||||||
(into {}
|
(into {}
|
||||||
(map (fn [email]
|
(map (fn [email]
|
||||||
(let [pid (get email->id email)]
|
(let [pid (get email->id email)]
|
||||||
[email (boolean (and pid (contains? org-member-ids pid)))])))
|
[email (boolean (and pid (contains? organization-member-ids pid)))])))
|
||||||
emails)))
|
emails)))
|
||||||
{}))
|
{}))
|
||||||
|
|
||||||
(def ^:private schema:all-org-members-in-team-params
|
(def ^:private schema:all-organization-members-in-team-params
|
||||||
[:map {:title "CheckOrgMembersInTeamParams"}
|
[:map {:title "CheckOrganizationMembersInTeamParams"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:organization-id ::sm/uuid]])
|
[:organization-id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::all-org-members-in-team
|
(sv/defmethod ::all-organization-members-in-team
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.17"
|
||||||
::sm/params schema:all-org-members-in-team-params
|
::sm/params schema:all-organization-members-in-team-params
|
||||||
::sm/result ::sm/boolean}
|
::sm/result ::sm/boolean}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :insufficient-permissions))
|
:code :insufficient-permissions))
|
||||||
(assert-membership cfg profile-id organization-id)
|
(assert-membership cfg profile-id organization-id)
|
||||||
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||||
org-member-ids (into #{} org-members)
|
organization-member-ids (into #{} organization-members)
|
||||||
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||||
team-member-ids (into #{} (map :profile-id team-members))]
|
team-member-ids (into #{} (map :profile-id team-members))]
|
||||||
(every? #(contains? team-member-ids %) org-member-ids)))
|
(every? #(contains? team-member-ids %) organization-member-ids)))
|
||||||
false))
|
false))
|
||||||
|
|
||||||
(def ^:private schema:all-team-members-in-orgs-params
|
(def ^:private schema:all-team-members-in-organizations-params
|
||||||
[:map {:title "CheckTeamMembersInOrgsParams"}
|
[:map {:title "CheckTeamMembersInOrganizationsParams"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:organization-ids [:vector ::sm/uuid]]])
|
[:organization-ids [:vector ::sm/uuid]]])
|
||||||
|
|
||||||
(sv/defmethod ::all-team-members-in-orgs
|
(sv/defmethod ::all-team-members-in-organizations
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.17"
|
||||||
::sm/params schema:all-team-members-in-orgs-params
|
::sm/params schema:all-team-members-in-organizations-params
|
||||||
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
|
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -579,15 +630,15 @@
|
|||||||
|
|
||||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||||
team-member-ids (into #{} (map :profile-id team-members))]
|
team-member-ids (into #{} (map :profile-id team-members))]
|
||||||
;; Validate requester membership in all orgs before fetching members.
|
;; Validate requester membership in all organizations before fetching members.
|
||||||
(run! #(assert-membership cfg profile-id %) organization-ids)
|
(run! #(assert-membership cfg profile-id %) organization-ids)
|
||||||
|
|
||||||
(into {}
|
(into {}
|
||||||
(map (fn [organization-id]
|
(map (fn [organization-id]
|
||||||
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||||
org-member-ids (into #{} org-members)]
|
organization-member-ids (into #{} organization-members)]
|
||||||
[organization-id
|
[organization-id
|
||||||
(every? #(contains? org-member-ids %) team-member-ids)])))
|
(every? #(contains? organization-member-ids %) team-member-ids)])))
|
||||||
organization-ids)))
|
organization-ids)))
|
||||||
{}))
|
{}))
|
||||||
|
|
||||||
@ -608,7 +659,7 @@
|
|||||||
::sm/result schema:check-team-external-invitations-result
|
::sm/result schema:check-team-external-invitations-result
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -631,8 +682,8 @@
|
|||||||
|
|
||||||
(sv/defmethod ::check-nitrate-sso
|
(sv/defmethod ::check-nitrate-sso
|
||||||
"Check if a user needs to login into the organization SSO.
|
"Check if a user needs to login into the organization SSO.
|
||||||
Accepts either team-id (to look up the org via the team) or organization-id directly.
|
Accepts either team-id (to look up the organization via the team) or organization-id directly.
|
||||||
Returns {:authorized true} when SSO is not active.
|
Returns {:authorized true} when SSO is not active or the user cannot access the team.
|
||||||
Returns {:authorized false :redirect-uri <url>} when SSO is active;
|
Returns {:authorized false :redirect-uri <url>} when SSO is active;
|
||||||
the client must redirect there. The OIDC provider itself handles
|
the client must redirect there. The OIDC provider itself handles
|
||||||
re-authentication transparently if the user already has an active SSO session."
|
re-authentication transparently if the user already has an active SSO session."
|
||||||
@ -640,17 +691,22 @@
|
|||||||
::doc/added "2.19"
|
::doc/added "2.19"
|
||||||
::sm/params schema:check-nitrate-sso
|
::sm/params schema:check-nitrate-sso
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [team-id organization-id url] :as params}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(let [request (rph/get-request params)
|
(if (and team-id
|
||||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
(not (teams/has-read-permissions? cfg profile-id team-id)))
|
||||||
(if authorized
|
;; Let the destination RPC enforce its own permissions. Starting SSO before
|
||||||
{:authorized true}
|
;; access is established sends unrelated users through the organization's IdP.
|
||||||
(if (oidc/org-sso-discovery-uri sso)
|
{:authorized true}
|
||||||
{:authorized false
|
(let [request (rph/get-request params)
|
||||||
:redirect-uri (oidc/build-org-sso-auth-redirect-uri cfg sso
|
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
||||||
:dest-url url
|
(if authorized
|
||||||
:organization-id organization-id)}
|
{:authorized true}
|
||||||
{:authorized false
|
(if (oidc/organization-sso-discovery-uri sso)
|
||||||
:redirect-uri nil})))
|
{:authorized false
|
||||||
|
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
|
||||||
|
:dest-url url
|
||||||
|
:organization-id organization-id)}
|
||||||
|
{:authorized false
|
||||||
|
:redirect-uri nil}))))
|
||||||
{:authorized true}))
|
{:authorized true}))
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.rpc.commands.profile
|
(ns app.rpc.commands.profile
|
||||||
(:require
|
(:require
|
||||||
[app.auth :as auth]
|
[app.auth :as auth]
|
||||||
|
[app.auth.passwords :as passwords]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
@ -21,6 +22,7 @@
|
|||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
|
[app.media.validation :as media.v]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as climit]
|
[app.rpc.climit :as climit]
|
||||||
@ -45,8 +47,17 @@
|
|||||||
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
[:email-invites [::sm/one-of #{:all :none}]]])
|
[:email-invites [::sm/one-of #{:all :none}]]])
|
||||||
|
|
||||||
|
(def schema:nudge
|
||||||
|
[:map {:title "Nudge"}
|
||||||
|
[:big {:optional true} ::sm/number]
|
||||||
|
[:small {:optional true} ::sm/number]])
|
||||||
|
|
||||||
|
(def system-managed-props
|
||||||
|
"Props keys managed by the system (not user-writable via RPC)."
|
||||||
|
#{:subscription})
|
||||||
|
|
||||||
(def schema:props
|
(def schema:props
|
||||||
[:map {:title "ProfileProps"}
|
[:map {:title "ProfileProps" :closed true}
|
||||||
[:plugins {:optional true} schema:plugin-registry]
|
[:plugins {:optional true} schema:plugin-registry]
|
||||||
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
||||||
[:mcp-enabled {:optional true} ::sm/boolean]
|
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||||
@ -54,12 +65,18 @@
|
|||||||
[:newsletter-news {:optional true} ::sm/boolean]
|
[:newsletter-news {:optional true} ::sm/boolean]
|
||||||
[:onboarding-team-id {:optional true} ::sm/uuid]
|
[:onboarding-team-id {:optional true} ::sm/uuid]
|
||||||
[:onboarding-viewed {:optional true} ::sm/boolean]
|
[:onboarding-viewed {:optional true} ::sm/boolean]
|
||||||
|
[:onboarding-questions {:optional true} [:map-of :keyword :string]]
|
||||||
|
[:onboarding-questions-answered {:optional true} ::sm/boolean]
|
||||||
|
[:nitrate-onboarding-viewed {:optional true} ::sm/boolean]
|
||||||
[:v2-info-shown {:optional true} ::sm/boolean]
|
[:v2-info-shown {:optional true} ::sm/boolean]
|
||||||
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
||||||
[:release-notes-viewed {:optional true}
|
[:release-notes-viewed {:optional true}
|
||||||
[::sm/text {:max 100}]]
|
[::sm/text {:max 100}]]
|
||||||
[:notifications {:optional true} schema:props-notifications]
|
[:notifications {:optional true} schema:props-notifications]
|
||||||
[:workspace-visited {:optional true} ::sm/boolean]])
|
[:workspace-visited {:optional true} ::sm/boolean]
|
||||||
|
[:custom-shortcuts {:optional true}
|
||||||
|
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]
|
||||||
|
[:nudge {:optional true} schema:nudge]])
|
||||||
|
|
||||||
(def schema:profile
|
(def schema:profile
|
||||||
[:map {:title "Profile"}
|
[:map {:title "Profile"}
|
||||||
@ -93,7 +110,7 @@
|
|||||||
|
|
||||||
(defn- with-nitrate-licence
|
(defn- with-nitrate-licence
|
||||||
[profile cfg]
|
[profile cfg]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||||
profile))
|
profile))
|
||||||
|
|
||||||
@ -146,6 +163,9 @@
|
|||||||
;; it or not for explicit locking and avoid concurrent updates of
|
;; it or not for explicit locking and avoid concurrent updates of
|
||||||
;; the same row/object.
|
;; the same row/object.
|
||||||
(let [profile (get-profile conn profile-id ::db/for-update true)
|
(let [profile (get-profile conn profile-id ::db/for-update true)
|
||||||
|
fullname (d/normalize-string fullname)
|
||||||
|
lang (d/normalize-string lang)
|
||||||
|
theme (d/normalize-string theme)
|
||||||
;; Update the profile map with direct params
|
;; Update the profile map with direct params
|
||||||
profile (-> profile
|
profile (-> profile
|
||||||
(assoc :fullname fullname)
|
(assoc :fullname fullname)
|
||||||
@ -191,6 +211,9 @@
|
|||||||
:code :email-as-password
|
:code :email-as-password
|
||||||
:hint "you can't use your email as password"))
|
:hint "you can't use your email as password"))
|
||||||
|
|
||||||
|
;; Validate password strength against common password dictionary
|
||||||
|
(passwords/validate-password (:password params))
|
||||||
|
|
||||||
(update-profile-password! cfg (assoc profile :password password))
|
(update-profile-password! cfg (assoc profile :password password))
|
||||||
|
|
||||||
(->> (rph/get-request params)
|
(->> (rph/get-request params)
|
||||||
@ -263,7 +286,7 @@
|
|||||||
(def ^:private
|
(def ^:private
|
||||||
schema:update-profile-photo
|
schema:update-profile-photo
|
||||||
[:map {:title "update-profile-photo"}
|
[:map {:title "update-profile-photo"}
|
||||||
[:file media/schema:upload]])
|
[:file media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::update-profile-photo
|
(sv/defmethod ::update-profile-photo
|
||||||
{:doc/added "1.1"
|
{:doc/added "1.1"
|
||||||
@ -271,8 +294,8 @@
|
|||||||
::sm/result :nil}
|
::sm/result :nil}
|
||||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||||
;; Validate incoming mime type
|
;; Validate incoming mime type
|
||||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||||
(media/validate-media-size! file)
|
(media.v/validate-media-size! file)
|
||||||
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
(defn update-profile-photo
|
(defn update-profile-photo
|
||||||
@ -449,7 +472,7 @@
|
|||||||
(assoc props k v))
|
(assoc props k v))
|
||||||
props))
|
props))
|
||||||
(:props profile)
|
(:props profile)
|
||||||
props)]
|
(apply dissoc props system-managed-props))]
|
||||||
|
|
||||||
(db/update! conn :profile
|
(db/update! conn :profile
|
||||||
{:props (db/tjson props)}
|
{:props (db/tjson props)}
|
||||||
@ -491,14 +514,14 @@
|
|||||||
{:id profile-id})
|
{:id profile-id})
|
||||||
|
|
||||||
;; Delete owned organizations on the fly (no grace period).
|
;; Delete owned organizations on the fly (no grace period).
|
||||||
;; Nitrate iterates the user's owned orgs and, per org, calls
|
;; Nitrate iterates the user's owned organizations and, per organization, calls
|
||||||
;; Penpot back through two paths: ::notify-user-organizations-deletion
|
;; Penpot back through two paths: ::notify-user-organizations-deletion
|
||||||
;; (during delete-owned-orgs) and ::notify-organization-deletion.
|
;; (during delete-owned-organizations) and ::notify-organization-deletion.
|
||||||
;; Both preserve org teams unchanged and only prefix or delete
|
;; Both preserve organization teams unchanged and only prefix or delete
|
||||||
;; imported "Your Penpot" teams according to whether they still have files.
|
;; imported "Your Penpot" teams according to whether they still have files.
|
||||||
;; Let Nitrate clean up the data associated with the deleted Penpot user:
|
;; Let Nitrate clean up the data associated with the deleted Penpot user:
|
||||||
;; owned organizations, remaining memberships, and subscription cancellation.
|
;; owned organizations, remaining memberships, and subscription cancellation.
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(nitrate/call cfg :cleanup-deleted-penpot-user
|
(nitrate/call cfg :cleanup-deleted-penpot-user
|
||||||
{:profile-id profile-id}))
|
{:profile-id profile-id}))
|
||||||
|
|
||||||
@ -557,8 +580,8 @@
|
|||||||
{::doc/added "2.18"
|
{::doc/added "2.18"
|
||||||
::sm/result schema:get-owned-organizations-summary-result}
|
::sm/result schema:get-owned-organizations-summary-result}
|
||||||
[cfg {:keys [::rpc/profile-id]}]
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) [])
|
(or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) [])
|
||||||
[]))
|
[]))
|
||||||
|
|
||||||
;; --- HELPERS
|
;; --- HELPERS
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
(ns app.rpc.commands.projects
|
(ns app.rpc.commands.projects
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
@ -259,7 +260,8 @@
|
|||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
|
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
|
||||||
(check-edition-permissions! conn profile-id id)
|
(check-edition-permissions! conn profile-id id)
|
||||||
(let [project (db/get-by-id conn :project id ::sql/for-update true)]
|
(let [project (db/get-by-id conn :project id ::sql/for-update true)
|
||||||
|
name (d/normalize-string name)]
|
||||||
(db/update! conn :project
|
(db/update! conn :project
|
||||||
{:name name}
|
{:name name}
|
||||||
{:id id})
|
{:id id})
|
||||||
|
|||||||
@ -6,9 +6,11 @@
|
|||||||
|
|
||||||
(ns app.rpc.commands.search
|
(ns app.rpc.commands.search
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.util.services :as sv]))
|
[app.util.services :as sv]))
|
||||||
|
|
||||||
@ -66,11 +68,13 @@
|
|||||||
(def ^:private schema:search-files
|
(def ^:private schema:search-files
|
||||||
[:map {:title "search-files"}
|
[:map {:title "search-files"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:search-term {:optional true} :string]])
|
[:search-term {:optional true} [:string {:max 250}]]])
|
||||||
|
|
||||||
(sv/defmethod ::search-files
|
(sv/defmethod ::search-files
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::doc/module :files
|
::doc/module :files
|
||||||
::sm/params schema:search-files}
|
::sm/params schema:search-files}
|
||||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}]
|
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}]
|
||||||
(some->> search-term (search-files pool profile-id team-id)))
|
(dm/with-open [conn (db/open pool)]
|
||||||
|
(teams/check-read-permissions! conn profile-id team-id)
|
||||||
|
(some->> search-term (search-files conn profile-id team-id))))
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
[app.common.types.organization :as cto]
|
||||||
[app.common.types.team :as types.team]
|
[app.common.types.team :as types.team]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
@ -22,7 +22,7 @@
|
|||||||
[app.features.logical-deletion :as ldel]
|
[app.features.logical-deletion :as ldel]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.msgbus :as mbus]
|
[app.msgbus :as mbus]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
@ -197,9 +197,9 @@
|
|||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||||
(dm/with-open [conn (db/open pool)]
|
(dm/with-open [conn (db/open pool)]
|
||||||
(cond->> (get-teams conn profile-id)
|
(cond->> (get-teams conn profile-id)
|
||||||
(contains? cf/flags :nitrate)
|
(contains? cf/flags :admin-console)
|
||||||
(map #(nitrate/add-org-info-to-team cfg % params))
|
(map #(nitrate/add-organization-info-to-team cfg % params))
|
||||||
(contains? cf/flags :nitrate)
|
(contains? cf/flags :admin-console)
|
||||||
(remove #(get-in % [:organization :expired-license])))))
|
(remove #(get-in % [:organization :expired-license])))))
|
||||||
|
|
||||||
(def ^:private sql:get-owned-teams
|
(def ^:private sql:get-owned-teams
|
||||||
@ -244,11 +244,11 @@
|
|||||||
::sm/params schema:get-team}
|
::sm/params schema:get-team}
|
||||||
[cfg {:keys [::rpc/profile-id id file-id] :as params}]
|
[cfg {:keys [::rpc/profile-id id file-id] :as params}]
|
||||||
(let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)]
|
(let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-org-info-to-team cfg team params)
|
(nitrate/add-organization-info-to-team cfg team params)
|
||||||
team)))
|
team)))
|
||||||
|
|
||||||
(defn- get-org-owner-viewer-team
|
(defn- get-organization-owner-viewer-team
|
||||||
"When `profile-id` is a non-member owner of the organization that owns
|
"When `profile-id` is a non-member owner of the organization that owns
|
||||||
the requested team, returns the team shaped with viewer permissions;
|
the requested team, returns the team shaped with viewer permissions;
|
||||||
otherwise nil. `cfg` must carry the nitrate client."
|
otherwise nil. `cfg` must carry the nitrate client."
|
||||||
@ -305,7 +305,7 @@
|
|||||||
(-> result
|
(-> result
|
||||||
(decode-row)
|
(decode-row)
|
||||||
(process-permissions))
|
(process-permissions))
|
||||||
(or (get-org-owner-viewer-team cfg profile-id default-team-id params)
|
(or (get-organization-owner-viewer-team cfg profile-id default-team-id params)
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
:code :team-does-not-exist)))))
|
:code :team-does-not-exist)))))
|
||||||
|
|
||||||
@ -535,18 +535,18 @@
|
|||||||
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||||
::quotes/profile-id profile-id})
|
::quotes/profile-id profile-id})
|
||||||
|
|
||||||
;; When creating inside an org, verify the user has permission to do so.
|
;; When creating inside an organization, verify the user has permission to do so.
|
||||||
;; Fail closed: if org permissions cannot be fetched, deny the operation.
|
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
|
||||||
(when (and organization-id (contains? cf/flags :nitrate))
|
(when (and organization-id (contains? cf/flags :admin-console))
|
||||||
(let [org-perms (nitrate/call cfg :get-org-permissions
|
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||||
{:organization-id organization-id})]
|
{:organization-id organization-id})]
|
||||||
(if (nil? org-perms)
|
(if (nil? organization-perms)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "Unable to verify organization permissions")
|
:hint "Unable to verify organization permissions")
|
||||||
(when-not (nitrate-perms/allowed? :create-team
|
(when-not (cto/allowed? :create-team
|
||||||
{:org-perms org-perms
|
{:organization-perms organization-perms
|
||||||
:profile-id profile-id})
|
:profile-id profile-id})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "You are not allowed to create teams in this organization")))))
|
:hint "You are not allowed to create teams in this organization")))))
|
||||||
@ -563,7 +563,7 @@
|
|||||||
{::audit/props {:id (:id team)}})))
|
{::audit/props {:id (:id team)}})))
|
||||||
|
|
||||||
|
|
||||||
(defn create-default-org-team
|
(defn create-default-organization-team
|
||||||
[cfg profile-id organization-id]
|
[cfg profile-id organization-id]
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||||
::quotes/profile-id profile-id})
|
::quotes/profile-id profile-id})
|
||||||
@ -579,37 +579,37 @@
|
|||||||
team (create-team cfg params)]
|
team (create-team cfg params)]
|
||||||
(select-keys team [:id])))
|
(select-keys team [:id])))
|
||||||
|
|
||||||
(defn initialize-user-in-nitrate-org
|
(defn initialize-user-in-organization
|
||||||
"If needed, create a default team for the user on the organization,
|
"If needed, create a default team for the user on the organization,
|
||||||
and notify Nitrate that an user has been added to an org."
|
and initialize the user in the organization."
|
||||||
([cfg profile-id organization-id]
|
([cfg profile-id organization-id]
|
||||||
(initialize-user-in-nitrate-org cfg profile-id organization-id nil))
|
(initialize-user-in-organization cfg profile-id organization-id nil))
|
||||||
([cfg profile-id organization-id email]
|
([cfg profile-id organization-id email]
|
||||||
(assert (db/connection-map? cfg)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(db/tx-run!
|
(db/tx-run!
|
||||||
cfg
|
cfg
|
||||||
(fn [{:keys [::db/conn] :as tx-cfg}]
|
(fn [{:keys [::db/conn] :as tx-cfg}]
|
||||||
|
|
||||||
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||||
:organization-id organization-id})]
|
:organization-id organization-id})]
|
||||||
;; Only when the user doesn't belong to the organization yet
|
;; Only when the user doesn't belong to the organization yet
|
||||||
(when (and
|
(when (and
|
||||||
(some? (:organization-id membership)) ;; the organization exists
|
(some? (:organization-id membership)) ;; the organization exists
|
||||||
(not (:is-member membership))) ;; the user is not a member of the org yet
|
(not (:is-member membership))) ;; the user is not a member of the organization yet
|
||||||
|
|
||||||
|
|
||||||
(let [organization-id organization-id
|
(let [organization-id organization-id
|
||||||
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
|
default-team (create-default-organization-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
|
||||||
default-team-id (:id default-team)
|
default-team-id (:id default-team)
|
||||||
result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id
|
result (nitrate/call tx-cfg :add-profile-to-organization (cond-> {:profile-id profile-id
|
||||||
:team-id default-team-id
|
:team-id default-team-id
|
||||||
:organization-id organization-id}
|
:organization-id organization-id}
|
||||||
(some? email) (assoc :email email)))]
|
(some? email) (assoc :email email)))]
|
||||||
(when (not (:is-member result))
|
(when (not (:is-member result))
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :failed-add-profile-org-nitrate
|
:code :failed-add-profile-organization-nitrate
|
||||||
:context {:profile-id profile-id
|
:context {:profile-id profile-id
|
||||||
:organization-id organization-id
|
:organization-id organization-id
|
||||||
:default-team-id default-team-id}))
|
:default-team-id default-team-id}))
|
||||||
@ -621,13 +621,13 @@
|
|||||||
([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options]
|
([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options]
|
||||||
(assert (db/connection-map? cfg)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})]
|
(let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})]
|
||||||
;; Only when the team belong to an organization and the user is not a member
|
;; Only when the team belong to an organization and the user is not a member
|
||||||
(when (and
|
(when (and
|
||||||
(some? (:organization-id membership)) ;; the team do belong to an organization
|
(some? (:organization-id membership)) ;; the team do belong to an organization
|
||||||
(not (:is-member membership))) ;; the user is not a member of the org yet
|
(not (:is-member membership))) ;; the user is not a member of the organization yet
|
||||||
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
|
(initialize-user-in-organization cfg profile-id (:organization-id membership)))))
|
||||||
(db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
|
(db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
|
||||||
|
|
||||||
(defn create-team
|
(defn create-team
|
||||||
@ -643,7 +643,7 @@
|
|||||||
project (create-team-default-project conn params)]
|
project (create-team-default-project conn params)]
|
||||||
(create-team-role cfg params)
|
(create-team-role cfg params)
|
||||||
;; Set team organization in Nitrate if organization-id is provided
|
;; Set team organization in Nitrate if organization-id is provided
|
||||||
(when (and (contains? cf/flags :nitrate) (:organization-id params))
|
(when (and (contains? cf/flags :admin-console) (:organization-id params))
|
||||||
(nitrate/set-team-organization cfg team params))
|
(nitrate/set-team-organization cfg team params))
|
||||||
(assoc team :default-project-id (:id project))))
|
(assoc team :default-project-id (:id project))))
|
||||||
|
|
||||||
@ -652,6 +652,7 @@
|
|||||||
(let [id (or id (uuid/next))
|
(let [id (or id (uuid/next))
|
||||||
is-default (if (boolean? is-default) is-default false)
|
is-default (if (boolean? is-default) is-default false)
|
||||||
features (db/create-array conn "text" features)
|
features (db/create-array conn "text" features)
|
||||||
|
name (d/normalize-string name)
|
||||||
team (db/insert! conn :team
|
team (db/insert! conn :team
|
||||||
{:id id
|
{:id id
|
||||||
:name name
|
:name name
|
||||||
@ -688,6 +689,7 @@
|
|||||||
[conn {:keys [id team-id name is-default created-at modified-at]}]
|
[conn {:keys [id team-id name is-default created-at modified-at]}]
|
||||||
(let [id (or id (uuid/next))
|
(let [id (or id (uuid/next))
|
||||||
is-default (if (boolean? is-default) is-default false)
|
is-default (if (boolean? is-default) is-default false)
|
||||||
|
name (d/normalize-string name)
|
||||||
params {:id id
|
params {:id id
|
||||||
:name name
|
:name name
|
||||||
:team-id team-id
|
:team-id team-id
|
||||||
@ -718,9 +720,10 @@
|
|||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
||||||
(check-edition-permissions! conn profile-id id)
|
(check-edition-permissions! conn profile-id id)
|
||||||
(db/update! conn :team
|
(let [name (d/normalize-string name)]
|
||||||
{:name name}
|
(db/update! conn :team
|
||||||
{:id id})
|
{:name name}
|
||||||
|
{:id id}))
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
|
|
||||||
@ -803,19 +806,19 @@
|
|||||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||||
|
|
||||||
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
||||||
team (if (contains? cf/flags :nitrate)
|
team (if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-org-info-to-team cfg team params)
|
(nitrate/add-organization-info-to-team cfg team params)
|
||||||
team)
|
team)
|
||||||
perms (get team :permissions)
|
perms (get team :permissions)
|
||||||
org (:organization team)
|
organization (:organization team)
|
||||||
in-org? (and (contains? cf/flags :nitrate) org)
|
in-organization? (and (contains? cf/flags :admin-console) organization)
|
||||||
can-delete?
|
can-delete?
|
||||||
(if in-org?
|
(if in-organization?
|
||||||
(nitrate-perms/allowed? :delete-team
|
(cto/allowed? :delete-team
|
||||||
{:org-perms {:owner-id (dm/get-in team [:organization :owner-id])
|
{:organization-perms {:owner-id (dm/get-in team [:organization :owner-id])
|
||||||
:permissions (dm/get-in team [:organization :permissions])}
|
:permissions (dm/get-in team [:organization :permissions])}
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:team-perms perms})
|
:team-perms perms})
|
||||||
(boolean (:is-owner perms)))]
|
(boolean (:is-owner perms)))]
|
||||||
|
|
||||||
(when-not can-delete?
|
(when-not can-delete?
|
||||||
@ -823,8 +826,8 @@
|
|||||||
:code :only-owner-can-delete-team))
|
:code :only-owner-can-delete-team))
|
||||||
|
|
||||||
;; Protect the user's personal default team from deletion.
|
;; Protect the user's personal default team from deletion.
|
||||||
;; Org-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
||||||
(when (and (:is-default team) (not in-org?))
|
(when (and (:is-default team) (not in-organization?))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :non-deletable-team
|
:code :non-deletable-team
|
||||||
:hint "impossible to delete default team"))
|
:hint "impossible to delete default team"))
|
||||||
@ -836,7 +839,7 @@
|
|||||||
{::db/return-keys true})]
|
{::db/return-keys true})]
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
|
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
|
||||||
|
|
||||||
(wrk/submit! {::db/conn conn
|
(wrk/submit! {::db/conn conn
|
||||||
@ -951,12 +954,23 @@
|
|||||||
|
|
||||||
(db/delete! conn :team-profile-rel {:profile-id member-id
|
(db/delete! conn :team-profile-rel {:profile-id member-id
|
||||||
:team-id team-id})
|
:team-id team-id})
|
||||||
(mbus/pub! msgbus
|
|
||||||
:topic member-id
|
;; A removed member that owns the organization of this team keeps
|
||||||
:message {:type :team-membership-change
|
;; read-only access to it, so instead of kicking them out we degrade
|
||||||
:change :removed
|
;; their session to viewer, same as any other role change.
|
||||||
:team-id team-id
|
(if (nitrate/organization-owner-of-team? cfg member-id team-id)
|
||||||
:team-name (:name team)})
|
(mbus/pub! msgbus
|
||||||
|
:topic member-id
|
||||||
|
:message {:type :team-role-change
|
||||||
|
:topic member-id
|
||||||
|
:team-id team-id
|
||||||
|
:role :viewer})
|
||||||
|
(mbus/pub! msgbus
|
||||||
|
:topic member-id
|
||||||
|
:message {:type :team-membership-change
|
||||||
|
:change :removed
|
||||||
|
:team-id team-id
|
||||||
|
:team-name (:name team)}))
|
||||||
|
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
@ -968,7 +982,7 @@
|
|||||||
(def ^:private schema:update-team-photo
|
(def ^:private schema:update-team-photo
|
||||||
[:map {:title "update-team-photo"}
|
[:map {:title "update-team-photo"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:file media/schema:upload]])
|
[:file media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::update-team-photo
|
(sv/defmethod ::update-team-photo
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
@ -976,8 +990,8 @@
|
|||||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||||
;; Validate incoming mime type
|
;; Validate incoming mime type
|
||||||
|
|
||||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||||
(media/validate-media-size! file)
|
(media.v/validate-media-size! file)
|
||||||
(update-team-photo cfg (assoc params :profile-id profile-id)))
|
(update-team-photo cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
(defn update-team-photo
|
(defn update-team-photo
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
[app.common.types.organization :as cto]
|
||||||
[app.common.types.team :as types.team]
|
[app.common.types.team :as types.team]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
@ -44,12 +44,31 @@
|
|||||||
update set role = ?, valid_until = ?, updated_at = now()
|
update set role = ?, valid_until = ?, updated_at = now()
|
||||||
returning *")
|
returning *")
|
||||||
|
|
||||||
(def sql:upsert-org-invitation
|
(def sql:upsert-organization-invitation
|
||||||
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
||||||
values (?, null, ?, ?, ?, ?, ?)
|
values (?, null, ?, ?, ?, ?, ?)
|
||||||
on conflict(org_id, email_to) where team_id is null do
|
on conflict(org_id, email_to) where team_id is null do
|
||||||
update set role = ?, valid_until = ?, updated_at = now()
|
update set role = ?, valid_until = ?, updated_at = now()
|
||||||
returning *")
|
returning *")
|
||||||
|
|
||||||
|
(def ^:private sql:check-recent-invitation
|
||||||
|
"SELECT 1 FROM team_invitation
|
||||||
|
WHERE team_id = ? AND email_to = ?
|
||||||
|
AND updated_at > now() - interval '5 minutes'
|
||||||
|
LIMIT 1")
|
||||||
|
|
||||||
|
(def ^:private sql:check-recent-org-invitation
|
||||||
|
"SELECT 1 FROM team_invitation
|
||||||
|
WHERE org_id = ? AND email_to = ?
|
||||||
|
AND updated_at > now() - interval '5 minutes'
|
||||||
|
LIMIT 1")
|
||||||
|
|
||||||
|
(defn- recently-invited?
|
||||||
|
[{:keys [::db/conn]} team-id org-id email]
|
||||||
|
(let [query (if org-id
|
||||||
|
[sql:check-recent-org-invitation org-id email]
|
||||||
|
[sql:check-recent-invitation team-id email])]
|
||||||
|
(some? (db/exec-one! conn query))))
|
||||||
|
|
||||||
(defn- create-invitation-token
|
(defn- create-invitation-token
|
||||||
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
||||||
@ -86,17 +105,10 @@
|
|||||||
[:role types.team/schema:role]
|
[:role types.team/schema:role]
|
||||||
[:email ::sm/email]])
|
[:email ::sm/email]])
|
||||||
|
|
||||||
(def ^:private schema:create-org-invitation
|
(def ^:private schema:create-organization-invitation
|
||||||
[:map {:title "params:create-org-invitation"}
|
[:map {:title "params:create-organization-invitation"}
|
||||||
[::rpc/profile-id ::sm/uuid]
|
[::rpc/profile-id ::sm/uuid]
|
||||||
[:organization
|
[:organization cto/schema:organization-with-avatar]
|
||||||
[:map
|
|
||||||
[:id ::sm/uuid]
|
|
||||||
[:name :string]
|
|
||||||
[:initials [:maybe :string]]
|
|
||||||
[:logo ::sm/uri]
|
|
||||||
[:avatar-bg-url [:maybe ::sm/uri]]
|
|
||||||
[:sso-active [:maybe ::sm/boolean]]]]
|
|
||||||
[:profile
|
[:profile
|
||||||
[:map
|
[:map
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
@ -107,8 +119,8 @@
|
|||||||
(def ^:private check-create-invitation-params
|
(def ^:private check-create-invitation-params
|
||||||
(sm/check-fn schema:create-invitation))
|
(sm/check-fn schema:create-invitation))
|
||||||
|
|
||||||
(def ^:private check-create-org-invitation-params
|
(def ^:private check-create-organization-invitation-params
|
||||||
(sm/check-fn schema:create-org-invitation))
|
(sm/check-fn schema:create-organization-invitation))
|
||||||
|
|
||||||
(defn- allow-invitation-emails?
|
(defn- allow-invitation-emails?
|
||||||
[member]
|
[member]
|
||||||
@ -116,23 +128,24 @@
|
|||||||
(not= :none (:email-invites notifications))))
|
(not= :none (:email-invites notifications))))
|
||||||
|
|
||||||
(defn- assert-email-can-be-invited
|
(defn- assert-email-can-be-invited
|
||||||
"Asserts that member is an org member when the org
|
"Asserts that member is an organization member when the organization
|
||||||
restricts who can be added to teams."
|
restricts who can be added to teams."
|
||||||
[member org-member-ids]
|
[member organization-member-ids]
|
||||||
(when (some? org-member-ids)
|
(when (some? organization-member-ids)
|
||||||
(let [is-member? (and (some? member) (contains? org-member-ids (:id member)))]
|
(let [is-member? (and (some? member) (contains? organization-member-ids (:id member)))]
|
||||||
(when-not is-member?
|
(when-not is-member?
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :email-not-org-member
|
:code :email-not-organization-member
|
||||||
:hint "The invited email is not a member of the organization")))))
|
:hint "The invited email is not a member of the organization")))))
|
||||||
|
|
||||||
(defn- create-invitation
|
(defn- create-invitation
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}]
|
[{:keys [::db/conn] :as cfg}
|
||||||
|
{:keys [team organization profile role email organization-member-ids all-organization-member-ids] :as params}]
|
||||||
|
|
||||||
(assert (db/connection-map? cfg)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
(if organization
|
(if organization
|
||||||
(assert (check-create-org-invitation-params params))
|
(assert (check-create-organization-invitation-params params))
|
||||||
(assert (check-create-invitation-params params)))
|
(assert (check-create-invitation-params params)))
|
||||||
|
|
||||||
(let [email (profile/clean-email email)
|
(let [email (profile/clean-email email)
|
||||||
@ -144,11 +157,11 @@
|
|||||||
:code :email-domain-is-not-allowed
|
:code :email-domain-is-not-allowed
|
||||||
:hint "email domain is in the blacklist"))
|
:hint "email domain is in the blacklist"))
|
||||||
|
|
||||||
;; When nitrate is active and the team belongs to an org, check that
|
;; When nitrate is active and the team belongs to an organization, check that
|
||||||
;; the email is already an org member unless the org explicitly allows adding anybody.
|
;; the email is already an organization member unless the organization explicitly allows adding anybody.
|
||||||
(when (and (contains? cf/flags :nitrate)
|
(when (and (contains? cf/flags :admin-console)
|
||||||
(:organization team))
|
(:organization team))
|
||||||
(assert-email-can-be-invited member org-member-ids))
|
(assert-email-can-be-invited member organization-member-ids))
|
||||||
|
|
||||||
|
|
||||||
;; When we have email verification disabled and invitation user is
|
;; When we have email verification disabled and invitation user is
|
||||||
@ -164,9 +177,9 @@
|
|||||||
(get types.team/permissions-for-role role))]
|
(get types.team/permissions-for-role role))]
|
||||||
|
|
||||||
(if organization
|
(if organization
|
||||||
;; Insert the invited member to the org
|
;; Insert the invited member to the organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization) email))
|
(teams/initialize-user-in-organization cfg (:id member) (:id organization) email))
|
||||||
;; Insert the invited member to the team
|
;; Insert the invited member to the team
|
||||||
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
|
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
|
||||||
|
|
||||||
@ -184,57 +197,79 @@
|
|||||||
(teams/check-email-bounce conn email true)
|
(teams/check-email-bounce conn email true)
|
||||||
(teams/check-email-spam conn email true)
|
(teams/check-email-spam conn email true)
|
||||||
|
|
||||||
(let [id (uuid/next)
|
(let [id (uuid/next)
|
||||||
expire (if organization
|
expire (if organization
|
||||||
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||||
(ct/in-future "168h")) ;; 7 days
|
(ct/in-future "168h")) ;; 7 days
|
||||||
invitation (db/exec-one! conn (if organization
|
recent? (recently-invited? cfg (:id team) (:id organization) email)
|
||||||
[sql:upsert-org-invitation id
|
invitation (db/exec-one! conn (if organization
|
||||||
(:id organization)
|
[sql:upsert-organization-invitation id
|
||||||
(str/lower email)
|
(:id organization)
|
||||||
(:id profile)
|
(str/lower email)
|
||||||
(name role) expire
|
(:id profile)
|
||||||
(name role) expire]
|
(name role) expire
|
||||||
[sql:upsert-team-invitation id
|
(name role) expire]
|
||||||
(:id team)
|
[sql:upsert-team-invitation id
|
||||||
(str/lower email)
|
(:id team)
|
||||||
(:id profile)
|
(str/lower email)
|
||||||
(name role) expire
|
(:id profile)
|
||||||
(name role) expire]))
|
(name role) expire
|
||||||
updated? (not= id (:id invitation))
|
(name role) expire]))
|
||||||
profile-id (:id profile)
|
updated? (not= id (:id invitation))
|
||||||
tprops {:profile-id profile-id
|
profile-id (:id profile)
|
||||||
:invitation-id (:id invitation)
|
team-organization-id (get-in team [:organization :id])
|
||||||
:valid-until expire
|
tprops {:profile-id profile-id
|
||||||
:team-id (:id team)
|
:invitation-id (:id invitation)
|
||||||
:organization-id (:id organization)
|
:valid-until expire
|
||||||
:organization-name (:name organization)
|
:team-id (:id team)
|
||||||
:member-email (:email-to invitation)
|
:organization-id (:id organization)
|
||||||
:member-id (:id member)
|
:organization-name (:name organization)
|
||||||
:role role}
|
:member-email (:email-to invitation)
|
||||||
itoken (create-invitation-token cfg tprops)
|
:member-id (:id member)
|
||||||
ptoken (create-profile-identity-token cfg profile-id)]
|
:role role}
|
||||||
|
audit-props
|
||||||
|
(cond-> {:invitation-id (:id invitation)
|
||||||
|
:valid-until expire
|
||||||
|
:team-id (:id team)
|
||||||
|
:organization-id (:id organization)
|
||||||
|
:organization-name (:name organization)
|
||||||
|
:member-email (:email-to invitation)
|
||||||
|
:member-id (:id member)
|
||||||
|
:role role}
|
||||||
|
organization
|
||||||
|
(assoc :user-who-send-invitation (str profile-id))
|
||||||
|
|
||||||
|
(not organization)
|
||||||
|
(assoc :team-belongs-to-organization (boolean team-organization-id)
|
||||||
|
:adds-invitee-to-organization (boolean team-organization-id)
|
||||||
|
:invitee-already-organization-member
|
||||||
|
(boolean
|
||||||
|
(and team-organization-id
|
||||||
|
member
|
||||||
|
(contains? all-organization-member-ids (:id member))))))
|
||||||
|
itoken (create-invitation-token cfg tprops)
|
||||||
|
ptoken (create-profile-identity-token cfg profile-id)]
|
||||||
|
|
||||||
(when (contains? cf/flags :log-invitation-tokens)
|
(when (contains? cf/flags :log-invitation-tokens)
|
||||||
(l/info :hint "invitation token" :token itoken))
|
(l/info :hint "invitation token" :token itoken))
|
||||||
|
|
||||||
(let [props (-> (dissoc tprops :profile-id)
|
(let [props (audit/clean-props audit-props)
|
||||||
(audit/clean-props))
|
|
||||||
evname (cond
|
evname (cond
|
||||||
(and updated? organization) "update-org-invitation"
|
(and updated? organization) "update-organization-invitation"
|
||||||
updated? "update-team-invitation"
|
updated? "update-team-invitation"
|
||||||
organization "create-org-invitation"
|
organization "create-organization-invitation"
|
||||||
:else "create-team-invitation")
|
:else "create-team-invitation")
|
||||||
event (-> (audit/event-from-rpc-params params)
|
event (-> (audit/event-from-rpc-params params)
|
||||||
(assoc :name evname)
|
(assoc :name evname)
|
||||||
(assoc :props props))]
|
(assoc :props props))]
|
||||||
(audit/submit cfg event))
|
(audit/submit cfg event))
|
||||||
|
|
||||||
(when (allow-invitation-emails? member)
|
(when (and (allow-invitation-emails? member)
|
||||||
|
(not recent?))
|
||||||
(if organization
|
(if organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(eml/send! {::eml/conn conn
|
(eml/send! {::eml/conn conn
|
||||||
::eml/factory eml/invite-to-org
|
::eml/factory eml/invite-to-organization
|
||||||
:public-uri (cf/get :public-uri)
|
:public-uri (cf/get :public-uri)
|
||||||
:to email
|
:to email
|
||||||
:invited-by (:fullname profile)
|
:invited-by (:fullname profile)
|
||||||
@ -254,7 +289,7 @@
|
|||||||
|
|
||||||
itoken)))))
|
itoken)))))
|
||||||
|
|
||||||
(defn create-org-invitation
|
(defn create-organization-invitation
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(let [profile (db/get-by-id cfg :profile profile-id)]
|
(let [profile (db/get-by-id cfg :profile profile-id)]
|
||||||
(create-invitation cfg
|
(create-invitation cfg
|
||||||
@ -324,16 +359,21 @@
|
|||||||
- emails (set) + role (single role for all emails)
|
- emails (set) + role (single role for all emails)
|
||||||
- invitations (vector of {:email :role} maps)"
|
- invitations (vector of {:email :role} maps)"
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
||||||
(let [;; Enrich team with org info once for all invitations when nitrate is active
|
(let [;; Enrich team with organization info once for all invitations when nitrate is active
|
||||||
team (if (contains? cf/flags :nitrate)
|
team (if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-org-info-to-team cfg team {})
|
(nitrate/add-organization-info-to-team cfg team {})
|
||||||
team)
|
team)
|
||||||
org (:organization team)
|
organization (:organization team)
|
||||||
org-id (:id org)
|
organization-id (:id organization)
|
||||||
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
|
restricted? (and organization-id (not (cto/allowed? :add-anybody-to-team {:organization-perms organization})))
|
||||||
org-member-ids (when restricted?
|
all-organization-member-ids
|
||||||
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
|
(when organization-id
|
||||||
params (assoc params :team team :org-member-ids org-member-ids)
|
(into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})))
|
||||||
|
organization-member-ids (when restricted? all-organization-member-ids)
|
||||||
|
params (assoc params
|
||||||
|
:team team
|
||||||
|
:organization-member-ids organization-member-ids
|
||||||
|
:all-organization-member-ids all-organization-member-ids)
|
||||||
|
|
||||||
;; Normalize input to a consistent format: [{:email :role}]
|
;; Normalize input to a consistent format: [{:email :role}]
|
||||||
invitation-data (cond
|
invitation-data (cond
|
||||||
|
|||||||
@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
(defn- with-nitrate-licence
|
(defn- with-nitrate-licence
|
||||||
[profile cfg]
|
[profile cfg]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||||
profile))
|
profile))
|
||||||
|
|
||||||
@ -101,6 +101,17 @@
|
|||||||
|
|
||||||
;; --- Team Invitation
|
;; --- Team Invitation
|
||||||
|
|
||||||
|
(def ^:private sql:get-organization-invitation
|
||||||
|
"SELECT *
|
||||||
|
FROM team_invitation
|
||||||
|
WHERE email_to = ?
|
||||||
|
AND org_id = ?")
|
||||||
|
|
||||||
|
(def ^:private sql:delete-organization-invitation
|
||||||
|
"DELETE FROM team_invitation
|
||||||
|
WHERE email_to = ?
|
||||||
|
AND org_id = ?")
|
||||||
|
|
||||||
(defn- accept-invitation
|
(defn- accept-invitation
|
||||||
[{:keys [::db/conn] :as cfg}
|
[{:keys [::db/conn] :as cfg}
|
||||||
{:keys [team-id organization-id role member-email] :as claims} invitation member]
|
{:keys [team-id organization-id role member-email] :as claims} invitation member]
|
||||||
@ -124,9 +135,9 @@
|
|||||||
(get types.team/permissions-for-role role))
|
(get types.team/permissions-for-role role))
|
||||||
|
|
||||||
accepted-team-id (if organization-id
|
accepted-team-id (if organization-id
|
||||||
;; Insert the invited member to the org
|
;; Insert the invited member to the organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email))
|
(teams/initialize-user-in-organization cfg id-member organization-id member-email))
|
||||||
;; Insert the invited member to the team
|
;; Insert the invited member to the team
|
||||||
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
||||||
team-id))]
|
team-id))]
|
||||||
@ -145,10 +156,11 @@
|
|||||||
{:id id-member}))
|
{:id id-member}))
|
||||||
|
|
||||||
;; Delete the invitation
|
;; Delete the invitation
|
||||||
(db/delete! conn :team-invitation
|
(if organization-id
|
||||||
(cond-> {:email-to member-email}
|
(db/exec-one! conn [sql:delete-organization-invitation member-email organization-id])
|
||||||
team-id (assoc :team-id team-id)
|
(db/delete! conn :team-invitation
|
||||||
organization-id (assoc :org-id organization-id)))
|
{:email-to member-email
|
||||||
|
:team-id team-id}))
|
||||||
|
|
||||||
;; Delete any request (only applicable for team invitations)
|
;; Delete any request (only applicable for team invitations)
|
||||||
(when team-id
|
(when team-id
|
||||||
@ -184,16 +196,17 @@
|
|||||||
:code :invalid-invitation-token
|
:code :invalid-invitation-token
|
||||||
:hint "invitation token contains unexpected data"))
|
:hint "invitation token contains unexpected data"))
|
||||||
|
|
||||||
(let [invitation (db/get* conn :team-invitation
|
(let [invitation (if organization-id
|
||||||
(cond-> {:email-to member-email}
|
(db/exec-one! conn [sql:get-organization-invitation member-email organization-id])
|
||||||
team-id (assoc :team-id team-id)
|
(db/get* conn :team-invitation
|
||||||
organization-id (assoc :org-id organization-id)))
|
{:email-to member-email
|
||||||
|
:team-id team-id}))
|
||||||
profile (db/get* conn :profile
|
profile (db/get* conn :profile
|
||||||
{:id profile-id}
|
{:id profile-id}
|
||||||
{:columns [:id :email :default-team-id]})
|
{:columns [:id :email :default-team-id]})
|
||||||
registration-disabled? (not (contains? cf/flags :registration))
|
registration-disabled? (not (contains? cf/flags :registration))
|
||||||
|
|
||||||
org-invitation? (and (contains? cf/flags :nitrate) organization-id)]
|
organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
|
||||||
|
|
||||||
(if profile
|
(if profile
|
||||||
(do
|
(do
|
||||||
@ -215,58 +228,112 @@
|
|||||||
;; existing invitation; querying it when the invitation is absent
|
;; existing invitation; querying it when the invitation is absent
|
||||||
;; would call nitrate needlessly and could mask the clean
|
;; would call nitrate needlessly and could mask the clean
|
||||||
;; :canceled-invitation/:invalid-token response with a generic error.
|
;; :canceled-invitation/:invalid-token response with a generic error.
|
||||||
(let [membership (when org-invitation?
|
(let [membership
|
||||||
(nitrate/call cfg :get-org-membership {:profile-id profile-id
|
(when (contains? cf/flags :admin-console)
|
||||||
:organization-id organization-id}))]
|
(cond
|
||||||
|
organization-id
|
||||||
|
(nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||||
|
:organization-id organization-id})
|
||||||
|
|
||||||
|
team-id
|
||||||
|
(nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id
|
||||||
|
:team-id team-id})))
|
||||||
|
|
||||||
|
organization-id-on-add
|
||||||
|
(when (and (:organization-id membership)
|
||||||
|
(not (:is-member membership)))
|
||||||
|
(:organization-id membership))
|
||||||
|
|
||||||
|
organization-add-source
|
||||||
|
(when organization-id-on-add
|
||||||
|
(if organization-id
|
||||||
|
"direct-organization-invitation"
|
||||||
|
"team-invitation"))
|
||||||
|
|
||||||
|
organization-event-origin
|
||||||
|
(when organization-id-on-add
|
||||||
|
(if organization-id
|
||||||
|
"organization-invitation-acceptance"
|
||||||
|
"team-invitation-acceptance"))
|
||||||
|
|
||||||
|
organization-member-count-before
|
||||||
|
(when organization-id-on-add
|
||||||
|
(count
|
||||||
|
(nitrate/call cfg :get-organization-members
|
||||||
|
{:organization-id organization-id-on-add})))]
|
||||||
|
|
||||||
(when (:is-member membership)
|
(when (:is-member membership)
|
||||||
(ex/raise :type :validation
|
(when organization-invitation?
|
||||||
:code :already-an-org-member
|
(ex/raise :type :validation
|
||||||
:team-id (:default-team-id membership)
|
:code :already-an-organization-member
|
||||||
:hint "the user is already a member of the organization"))
|
:team-id (:default-team-id membership)
|
||||||
|
:hint "the user is already a member of the organization")))
|
||||||
|
|
||||||
(when (and org-invitation? (not (:organization-id membership)))
|
(when (and organization-invitation? (not (:organization-id membership)))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :org-not-found
|
:code :organization-not-found
|
||||||
:team-id (:default-team-id profile)
|
:team-id (:default-team-id profile)
|
||||||
:hint "the organization doesn't exist")))
|
:hint "the organization doesn't exist"))
|
||||||
|
|
||||||
;; if we have logged-in user and it matches the invitation we proceed
|
;; if we have logged-in user and it matches the invitation we proceed
|
||||||
;; with accepting the invitation and joining the current profile to the
|
;; with accepting the invitation and joining the current profile to the
|
||||||
;; invited team.
|
;; invited team.
|
||||||
(let [props {:team-id (:team-id claims)
|
(let [props {:team-id (:team-id claims)
|
||||||
:role (:role claims)
|
:role (:role claims)
|
||||||
:invitation-id (:id invitation)}]
|
:invitation-id (:id invitation)}]
|
||||||
|
|
||||||
(audit/submit cfg
|
(when team-id
|
||||||
(-> (audit/event-from-rpc-params params)
|
(audit/submit cfg
|
||||||
(assoc :name "accept-team-invitation")
|
(-> (audit/event-from-rpc-params params)
|
||||||
(assoc :props props)))
|
(assoc :name "accept-team-invitation")
|
||||||
|
(assoc :props props)))
|
||||||
|
|
||||||
;; NOTE: Backward compatibility; old invitations can
|
;; NOTE: Backward compatibility; old invitations can
|
||||||
;; have the `created-by` to be nil; so in this case we
|
;; have the `created-by` to be nil; so in this case we
|
||||||
;; don't submit this event to the audit-log
|
;; don't submit this event to the audit-log
|
||||||
(when-let [created-by (:created-by invitation)]
|
(when-let [created-by (:created-by invitation)]
|
||||||
(audit/submit cfg
|
(audit/submit cfg
|
||||||
(-> (audit/event-from-rpc-params params)
|
(-> (audit/event-from-rpc-params params)
|
||||||
(assoc :profile-id created-by)
|
(assoc :profile-id created-by)
|
||||||
(assoc :name "accept-team-invitation-from")
|
(assoc :name "accept-team-invitation-from")
|
||||||
(assoc :props (assoc props
|
(assoc :props (assoc props
|
||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
:email (:email profile))))))
|
:email (:email profile)))))))
|
||||||
|
|
||||||
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||||
(cond-> (assoc claims :state :created)
|
(when organization-id-on-add
|
||||||
;; when the invitation is to an org, instead of a team, add the
|
(audit/submit
|
||||||
;; accepted-team-id as :org-team-id
|
cfg
|
||||||
(:organization-id claims)
|
(-> (audit/event-from-rpc-params params)
|
||||||
(assoc :org-team-id accepted-team-id)))))
|
(assoc :name "accept-organization-invitation")
|
||||||
|
(assoc :props
|
||||||
|
(-> props
|
||||||
|
(assoc :organization-id organization-id-on-add)
|
||||||
|
(audit/clean-props))))))
|
||||||
|
|
||||||
|
(cond-> (assoc claims :state :created)
|
||||||
|
;; when the invitation is to an organization, instead of a team, add the
|
||||||
|
;; accepted-team-id as :organization-team-id
|
||||||
|
(:organization-id claims)
|
||||||
|
(assoc :organization-team-id accepted-team-id)
|
||||||
|
|
||||||
|
organization-id-on-add
|
||||||
|
(assoc :organization-invitation-audit
|
||||||
|
{:origin organization-event-origin
|
||||||
|
:props
|
||||||
|
(-> props
|
||||||
|
(assoc :organization-id organization-id-on-add
|
||||||
|
:organization-member-add-source organization-add-source
|
||||||
|
:belongs-to-team-on-add (boolean team-id)
|
||||||
|
:organization-member-count-before
|
||||||
|
organization-member-count-before)
|
||||||
|
(audit/clean-props))}))))))
|
||||||
|
|
||||||
(do
|
(do
|
||||||
;; If the user is not logged-in and the invitation has been canceled
|
;; If the user is not logged-in and the invitation has been canceled
|
||||||
;; we return a specific error code so the frontend can redirect to
|
;; we return a specific error code so the frontend can redirect to
|
||||||
;; login with an appropriate message instead of showing the error page.
|
;; login with an appropriate message instead of showing the error page.
|
||||||
;; This only applies to org invitations; team invitations keep the
|
;; This only applies to organization invitations; team invitations keep the
|
||||||
;; existing :invalid-token behavior.
|
;; existing :invalid-token behavior.
|
||||||
(when (nil? invitation)
|
(when (nil? invitation)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -289,4 +356,3 @@
|
|||||||
[_ _ _]
|
[_ _ _]
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :invalid-token))
|
:code :invalid-token))
|
||||||
|
|
||||||
|
|||||||
@ -23,11 +23,9 @@
|
|||||||
[cuerdas.core :as str]))
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
(defn get-webhooks-permissions
|
(defn get-webhooks-permissions
|
||||||
[conn profile-id team-id creator-id]
|
[conn profile-id team-id]
|
||||||
(let [permissions (t/get-permissions conn profile-id team-id)
|
(let [permissions (t/get-permissions conn profile-id team-id)
|
||||||
|
can-edit (boolean (:can-edit permissions))]
|
||||||
can-edit (boolean (or (:can-edit permissions)
|
|
||||||
(= profile-id creator-id)))]
|
|
||||||
(assoc permissions :can-edit can-edit)))
|
(assoc permissions :can-edit can-edit)))
|
||||||
|
|
||||||
(def has-webhook-edit-permissions?
|
(def has-webhook-edit-permissions?
|
||||||
@ -120,7 +118,7 @@
|
|||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:create-webhook}
|
::sm/params schema:create-webhook}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||||
(check-webhook-edition-permissions! pool profile-id team-id profile-id)
|
(t/check-edition-permissions! pool profile-id team-id)
|
||||||
(validate-quotes! cfg params)
|
(validate-quotes! cfg params)
|
||||||
(validate-webhook! cfg nil params)
|
(validate-webhook! cfg nil params)
|
||||||
(insert-webhook! cfg params))
|
(insert-webhook! cfg params))
|
||||||
@ -137,7 +135,7 @@
|
|||||||
::sm/params schema:update-webhook}
|
::sm/params schema:update-webhook}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||||
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
|
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
|
||||||
(check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook))
|
(check-webhook-edition-permissions! pool profile-id (:team-id whook))
|
||||||
(validate-webhook! cfg whook params)
|
(validate-webhook! cfg whook params)
|
||||||
(update-webhook! cfg whook params)))
|
(update-webhook! cfg whook params)))
|
||||||
|
|
||||||
@ -151,7 +149,7 @@
|
|||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
|
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
|
||||||
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
|
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
|
||||||
(check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook))
|
(check-webhook-edition-permissions! conn profile-id (:team-id whook))
|
||||||
(db/delete! conn :webhook {:id id})
|
(db/delete! conn :webhook {:id id})
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uri :as u]
|
[app.common.uri :as u]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.media :refer [schema:upload]]
|
[app.media.validation :refer [schema:upload]]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.doc :as doc]
|
[app.rpc.doc :as doc]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
|
|||||||
@ -14,15 +14,17 @@
|
|||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]]
|
[app.common.types.organization :as cto]
|
||||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||||
[app.common.types.team :refer [schema:team]]
|
[app.common.types.team :refer [schema:team]]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email :as eml]
|
[app.email :as eml]
|
||||||
|
[app.http :as-alias http]
|
||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as rpc]
|
[app.rpc :as rpc]
|
||||||
[app.rpc.commands.auth :as auth]
|
[app.rpc.commands.auth :as auth]
|
||||||
@ -45,6 +47,7 @@
|
|||||||
{:id (:id profile)
|
{:id (:id profile)
|
||||||
:name (:fullname profile)
|
:name (:fullname profile)
|
||||||
:email (:email profile)
|
:email (:email profile)
|
||||||
|
:created-at (:created-at profile)
|
||||||
:photo-url (files/resolve-public-uri (get profile :photo-id))})
|
:photo-url (files/resolve-public-uri (get profile :photo-id))})
|
||||||
|
|
||||||
;; ---- API: authenticate
|
;; ---- API: authenticate
|
||||||
@ -116,7 +119,7 @@
|
|||||||
|
|
||||||
(def ^:private schema:upload-organization-logo
|
(def ^:private schema:upload-organization-logo
|
||||||
[:map
|
[:map
|
||||||
[:content media/schema:upload]
|
[:content media.v/schema:upload]
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
[:previous-id {:optional true} ::sm/uuid]])
|
[:previous-id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
@ -149,7 +152,7 @@
|
|||||||
(sv/defmethod ::notify-team-change
|
(sv/defmethod ::notify-team-change
|
||||||
"Notify to Penpot a team change from nitrate"
|
"Notify to Penpot a team change from nitrate"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.14"
|
||||||
::sm/params schema:team-with-organization
|
::sm/params cto/schema:team-with-organization
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg team]
|
[cfg team]
|
||||||
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
||||||
@ -164,12 +167,12 @@
|
|||||||
[:role ::sm/text]])
|
[:role ::sm/text]])
|
||||||
|
|
||||||
(sv/defmethod ::notify-user-added-to-organization
|
(sv/defmethod ::notify-user-added-to-organization
|
||||||
"Notify to Penpot that an user has joined an org from nitrate"
|
"Notify to Penpot that an user has joined an organization from nitrate"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.14"
|
||||||
::sm/params schema:notify-user-added-to-organization
|
::sm/params schema:notify-user-added-to-organization
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [profile-id organization-id]}]
|
[cfg {:keys [profile-id organization-id]}]
|
||||||
(db/tx-run! cfg teams/create-default-org-team profile-id organization-id))
|
(db/tx-run! cfg teams/create-default-organization-team profile-id organization-id))
|
||||||
|
|
||||||
|
|
||||||
;; ---- API: get-managed-profiles
|
;; ---- API: get-managed-profiles
|
||||||
@ -311,7 +314,7 @@ RETURNING id, deleted_at;")
|
|||||||
nil)
|
nil)
|
||||||
|
|
||||||
(defn manage-deleted-organization-teams
|
(defn manage-deleted-organization-teams
|
||||||
"For a deleted organization, preserve org teams unchanged and only prefix or
|
"For a deleted organization, preserve organization teams unchanged and only prefix or
|
||||||
delete member Your Penpot teams depending on whether they still contain files."
|
delete member Your Penpot teams depending on whether they still contain files."
|
||||||
[cfg {:keys [organization-id organization-name teams]}]
|
[cfg {:keys [organization-id organization-name teams]}]
|
||||||
(let [all-team-ids (->> teams
|
(let [all-team-ids (->> teams
|
||||||
@ -326,7 +329,7 @@ RETURNING id, deleted_at;")
|
|||||||
distinct
|
distinct
|
||||||
(into []))]
|
(into []))]
|
||||||
(when (seq all-team-ids)
|
(when (seq all-team-ids)
|
||||||
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
(let [organization-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||||
(db/tx-run!
|
(db/tx-run!
|
||||||
cfg
|
cfg
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
@ -340,11 +343,11 @@ RETURNING id, deleted_at;")
|
|||||||
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
|
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
|
||||||
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
|
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
|
||||||
|
|
||||||
;; Org teams move to the fallback org unchanged. Only imported
|
;; Organization teams move to the fallback organization unchanged. Only imported
|
||||||
;; Your Penpot teams keep the org prefix when they still have files.
|
;; Your Penpot teams keep the organization prefix when they still have files.
|
||||||
(when (seq teams-to-prefix)
|
(when (seq teams-to-prefix)
|
||||||
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
||||||
org-prefix
|
organization-prefix
|
||||||
(db/create-array conn "uuid" teams-to-prefix)]))
|
(db/create-array conn "uuid" teams-to-prefix)]))
|
||||||
|
|
||||||
;; Empty imported Your Penpot teams disappear entirely.
|
;; Empty imported Your Penpot teams disappear entirely.
|
||||||
@ -355,16 +358,16 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
|
|
||||||
(sv/defmethod ::notify-organization-deletion
|
(sv/defmethod ::notify-organization-deletion
|
||||||
"For a deleted organization, preserve org teams and only prefix or delete
|
"For a deleted organization, preserve organization teams and only prefix or delete
|
||||||
imported Your Penpot teams before notifying connected users."
|
imported Your Penpot teams before notifying connected users."
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.15"
|
||||||
::sm/params schema:notify-organization-deletion
|
::sm/params schema:notify-organization-deletion
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [organization-id]}]
|
[cfg {:keys [organization-id]}]
|
||||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||||
teams (:teams org-summary)]
|
teams (:teams organization-summary)]
|
||||||
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary)
|
(manage-deleted-organization-teams cfg {:organization-name (:name organization-summary)
|
||||||
:organization-id (:id org-summary)
|
:organization-id (:id organization-summary)
|
||||||
:teams teams})
|
:teams teams})
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
@ -375,18 +378,18 @@ RETURNING id, deleted_at;")
|
|||||||
[:profile-id ::sm/uuid]])
|
[:profile-id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::notify-user-organizations-deletion
|
(sv/defmethod ::notify-user-organizations-deletion
|
||||||
"For a given user, find all owned organizations and apply the deleted-org
|
"For a given user, find all owned organizations and apply the deleted-organization
|
||||||
transfer rules to their imported Your Penpot teams."
|
transfer rules to their imported Your Penpot teams."
|
||||||
{::doc/added "2.18"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:notify-user-organizations-deletion
|
::sm/params schema:notify-user-organizations-deletion
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [profile-id]}]
|
[cfg {:keys [profile-id]}]
|
||||||
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
|
(let [owned-organizations (nitrate/call cfg :get-owned-organizations {:profile-id profile-id})]
|
||||||
(doseq [org owned-orgs]
|
(doseq [organization owned-organizations]
|
||||||
(let [organization-name (:name org)
|
(let [organization-name (:name organization)
|
||||||
teams (:teams org)]
|
teams (:teams organization)]
|
||||||
(manage-deleted-organization-teams cfg {:organization-name organization-name
|
(manage-deleted-organization-teams cfg {:organization-name organization-name
|
||||||
:organization-id (:id org)
|
:organization-id (:id organization)
|
||||||
:teams teams}))))
|
:teams teams}))))
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
@ -490,10 +493,10 @@ RETURNING id, deleted_at;")
|
|||||||
{::doc/added "2.15"
|
{::doc/added "2.15"
|
||||||
::sm/params [:map
|
::sm/params [:map
|
||||||
[:email ::sm/email]
|
[:email ::sm/email]
|
||||||
[:organization schema:organization-with-avatar]]
|
[:organization cto/schema:organization-with-avatar]]
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg params]
|
[cfg params]
|
||||||
(db/tx-run! cfg ti/create-org-invitation params)
|
(db/tx-run! cfg ti/create-organization-invitation params)
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
|
|
||||||
@ -521,9 +524,9 @@ RETURNING id, deleted_at;")
|
|||||||
::sm/result schema:get-organization-invitations-result
|
::sm/result schema:get-organization-invitations-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [organization-id]}]
|
[cfg {:keys [organization-id]}]
|
||||||
(let [team-ids (noh/get-org-team-ids cfg organization-id)]
|
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
(->> (noh/get-org-invitations conn organization-id team-ids)
|
(->> (noh/get-organization-invitations conn organization-id team-ids)
|
||||||
(mapv (fn [{:keys [photo-id] :as invitation}]
|
(mapv (fn [{:keys [photo-id] :as invitation}]
|
||||||
(cond-> (dissoc invitation :photo-id)
|
(cond-> (dissoc invitation :photo-id)
|
||||||
photo-id
|
photo-id
|
||||||
@ -543,13 +546,13 @@ RETURNING id, deleted_at;")
|
|||||||
[:email ::sm/email]])
|
[:email ::sm/email]])
|
||||||
|
|
||||||
(sv/defmethod ::delete-organization-invitations
|
(sv/defmethod ::delete-organization-invitations
|
||||||
"Delete all invitations for one email in an organization scope (org + org teams)."
|
"Delete all invitations for one email in an organization scope (organization + organization teams)."
|
||||||
{::doc/added "2.16"
|
{::doc/added "2.16"
|
||||||
::sm/params schema:delete-organization-invitations-params
|
::sm/params schema:delete-organization-invitations-params
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [organization-id email]}]
|
[cfg {:keys [organization-id email]}]
|
||||||
(let [clean-email (profile/clean-email email)
|
(let [clean-email (profile/clean-email email)
|
||||||
team-ids (noh/get-org-team-ids cfg organization-id)]
|
team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
(db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array]))))
|
(db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array]))))
|
||||||
@ -568,14 +571,14 @@ RETURNING id, deleted_at;")
|
|||||||
[:organization-id ::sm/uuid]])
|
[:organization-id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::delete-all-organization-invitations
|
(sv/defmethod ::delete-all-organization-invitations
|
||||||
"Delete every pending invitation associated with an organization (org-level + team-level).
|
"Delete every pending invitation associated with an organization (organization-level + team-level).
|
||||||
Called from Nitrate when an organization is about to be deleted, so users that click
|
Called from Nitrate when an organization is about to be deleted, so users that click
|
||||||
their invitation token hit the existing invalid-token landing page."
|
their invitation token hit the existing invalid-token landing page."
|
||||||
{::doc/added "2.18"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:delete-all-organization-invitations-params
|
::sm/params schema:delete-all-organization-invitations-params
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [organization-id]}]
|
[cfg {:keys [organization-id]}]
|
||||||
(let [team-ids (noh/get-org-team-ids cfg organization-id)]
|
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
(db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array]))))
|
(db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array]))))
|
||||||
@ -617,8 +620,12 @@ RETURNING id, deleted_at;")
|
|||||||
[:default-team-id ::sm/uuid]]
|
[:default-team-id ::sm/uuid]]
|
||||||
::db/transaction true
|
::db/transaction true
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
|
[cfg {actor-profile-id ::rpc/profile-id
|
||||||
(let [{:keys [valid-teams-to-delete-ids
|
:keys [profile-id organization-id organization-name default-team-id]
|
||||||
|
:as params}]
|
||||||
|
(let [actor-profile-id (when-not (= actor-profile-id uuid/zero)
|
||||||
|
actor-profile-id)
|
||||||
|
{:keys [valid-teams-to-delete-ids
|
||||||
valid-teams-to-transfer
|
valid-teams-to-transfer
|
||||||
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
||||||
add-reassign-to (partial add-reassign-to cfg profile-id)
|
add-reassign-to (partial add-reassign-to cfg profile-id)
|
||||||
@ -626,13 +633,16 @@ RETURNING id, deleted_at;")
|
|||||||
valid-teams-to-leave (into valid-teams-to-exit
|
valid-teams-to-leave (into valid-teams-to-exit
|
||||||
(map add-reassign-to valid-teams-to-transfer))]
|
(map add-reassign-to valid-teams-to-transfer))]
|
||||||
|
|
||||||
(cnit/leave-org cfg (assoc params
|
(cnit/leave-organization cfg (assoc params
|
||||||
:id organization-id
|
:id organization-id
|
||||||
:name organization-name
|
:name organization-name
|
||||||
:teams-to-delete valid-teams-to-delete-ids
|
:teams-to-delete valid-teams-to-delete-ids
|
||||||
:teams-to-leave valid-teams-to-leave
|
:teams-to-leave valid-teams-to-leave
|
||||||
:skip-validation true))
|
:skip-validation true
|
||||||
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
|
:user-who-delete-member actor-profile-id
|
||||||
|
:deleted-by-role (when actor-profile-id
|
||||||
|
"organization-owner")))
|
||||||
|
(notifications/notify-user-organization-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-organization")
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
;; API: get-remove-from-organization-summary
|
;; API: get-remove-from-organization-summary
|
||||||
@ -663,11 +673,11 @@ RETURNING id, deleted_at;")
|
|||||||
(when-not valid-default-team
|
(when-not valid-default-team
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-valid-teams))
|
:code :not-valid-teams))
|
||||||
(cnit/get-leave-org-summary cfg
|
(cnit/get-leave-organization-summary cfg
|
||||||
default-team-id
|
default-team-id
|
||||||
valid-teams-to-delete-ids
|
valid-teams-to-delete-ids
|
||||||
(count valid-teams-to-transfer)
|
(count valid-teams-to-transfer)
|
||||||
(count valid-teams-to-exit))))
|
(count valid-teams-to-exit))))
|
||||||
|
|
||||||
;; API: send-renewal-email
|
;; API: send-renewal-email
|
||||||
|
|
||||||
@ -678,7 +688,7 @@ RETURNING id, deleted_at;")
|
|||||||
[:user-name [:maybe ::sm/text]]
|
[:user-name [:maybe ::sm/text]]
|
||||||
[:renewal-date :string]
|
[:renewal-date :string]
|
||||||
[:estimated-amount :double]
|
[:estimated-amount :double]
|
||||||
[:organizations [:vector schema:organization-with-avatar]]])
|
[:organizations [:vector cto/schema:organization-with-avatar]]])
|
||||||
|
|
||||||
(sv/defmethod ::send-renewal-email
|
(sv/defmethod ::send-renewal-email
|
||||||
"Send an Enterprise subscription renewal notice email to a user."
|
"Send an Enterprise subscription renewal notice email to a user."
|
||||||
@ -710,7 +720,7 @@ RETURNING id, deleted_at;")
|
|||||||
WHERE id = ANY(?)
|
WHERE id = ANY(?)
|
||||||
AND deleted_at IS NULL")
|
AND deleted_at IS NULL")
|
||||||
|
|
||||||
(def ^:private sql:exists-non-member-org-team-invitations
|
(def ^:private sql:exists-non-member-organization-team-invitations
|
||||||
"SELECT EXISTS (
|
"SELECT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM team_invitation
|
FROM team_invitation
|
||||||
@ -718,13 +728,13 @@ RETURNING id, deleted_at;")
|
|||||||
AND email_to <> ALL(?)
|
AND email_to <> ALL(?)
|
||||||
) AS non_member")
|
) AS non_member")
|
||||||
|
|
||||||
(def ^:private sql:delete-non-member-org-team-invitations
|
(def ^:private sql:delete-non-member-organization-team-invitations
|
||||||
"DELETE FROM team_invitation
|
"DELETE FROM team_invitation
|
||||||
WHERE team_id = ANY(?)
|
WHERE team_id = ANY(?)
|
||||||
AND email_to <> ALL(?)
|
AND email_to <> ALL(?)
|
||||||
RETURNING email_to")
|
RETURNING email_to")
|
||||||
|
|
||||||
(def ^:private schema:org-team-invitations-for-non-members-params
|
(def ^:private schema:organization-team-invitations-for-non-members-params
|
||||||
[:map
|
[:map
|
||||||
[:team-ids [:vector ::sm/uuid]]
|
[:team-ids [:vector ::sm/uuid]]
|
||||||
[:member-ids [:vector ::sm/uuid]]])
|
[:member-ids [:vector ::sm/uuid]]])
|
||||||
@ -732,8 +742,8 @@ RETURNING id, deleted_at;")
|
|||||||
(def ^:private schema:exists-organization-team-invitations-for-non-members-result
|
(def ^:private schema:exists-organization-team-invitations-for-non-members-result
|
||||||
[:map [:exists ::sm/boolean]])
|
[:map [:exists ::sm/boolean]])
|
||||||
|
|
||||||
(defn- org-team-invitations-for-non-members-arrays
|
(defn- organization-team-invitations-for-non-members-arrays
|
||||||
"Member emails and PG arrays used by exists/delete org team invitation endpoints."
|
"Member emails and PG arrays used by exists/delete organization team invitation endpoints."
|
||||||
[conn {:keys [team-ids member-ids]}]
|
[conn {:keys [team-ids member-ids]}]
|
||||||
(let [member-ids-array (db/create-array conn "uuid" member-ids)
|
(let [member-ids-array (db/create-array conn "uuid" member-ids)
|
||||||
member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
|
member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
|
||||||
@ -742,11 +752,11 @@ RETURNING id, deleted_at;")
|
|||||||
{:emails-array (db/create-array conn "text" (vec member-emails))
|
{:emails-array (db/create-array conn "text" (vec member-emails))
|
||||||
:teams-array (db/create-array conn "uuid" team-ids)}))
|
:teams-array (db/create-array conn "uuid" team-ids)}))
|
||||||
|
|
||||||
(defn- non-member-org-team-invitations-exist?
|
(defn- non-member-organization-team-invitations-exist?
|
||||||
[conn params]
|
[conn params]
|
||||||
(let [{:keys [emails-array teams-array]}
|
(let [{:keys [emails-array teams-array]}
|
||||||
(org-team-invitations-for-non-members-arrays conn params)]
|
(organization-team-invitations-for-non-members-arrays conn params)]
|
||||||
(-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations
|
(-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations
|
||||||
teams-array
|
teams-array
|
||||||
emails-array])
|
emails-array])
|
||||||
:non-member)))
|
:non-member)))
|
||||||
@ -754,24 +764,24 @@ RETURNING id, deleted_at;")
|
|||||||
(sv/defmethod ::exists-organization-team-invitations-for-non-members
|
(sv/defmethod ::exists-organization-team-invitations-for-non-members
|
||||||
"Return if there are any team invitations for emails that are not organization members."
|
"Return if there are any team invitations for emails that are not organization members."
|
||||||
{::doc/added "2.18"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:org-team-invitations-for-non-members-params
|
::sm/params schema:organization-team-invitations-for-non-members-params
|
||||||
::sm/result schema:exists-organization-team-invitations-for-non-members-result
|
::sm/result schema:exists-organization-team-invitations-for-non-members-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg params]
|
[cfg params]
|
||||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
{:exists (boolean (non-member-org-team-invitations-exist? conn params))})))
|
{:exists (boolean (non-member-organization-team-invitations-exist? conn params))})))
|
||||||
|
|
||||||
(sv/defmethod ::delete-organization-team-invitations-for-non-members
|
(sv/defmethod ::delete-organization-team-invitations-for-non-members
|
||||||
"Delete team invitations for emails that are not organization members."
|
"Delete team invitations for emails that are not organization members."
|
||||||
{::doc/added "2.18"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:org-team-invitations-for-non-members-params
|
::sm/params schema:organization-team-invitations-for-non-members-params
|
||||||
::db/transaction true
|
::db/transaction true
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg params]
|
[cfg params]
|
||||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
(let [{:keys [emails-array teams-array]}
|
(let [{:keys [emails-array teams-array]}
|
||||||
(org-team-invitations-for-non-members-arrays conn params)]
|
(organization-team-invitations-for-non-members-arrays conn params)]
|
||||||
(db/exec! conn [sql:delete-non-member-org-team-invitations
|
(db/exec! conn [sql:delete-non-member-organization-team-invitations
|
||||||
teams-array
|
teams-array
|
||||||
emails-array])
|
emails-array])
|
||||||
nil))))
|
nil))))
|
||||||
@ -782,38 +792,42 @@ RETURNING id, deleted_at;")
|
|||||||
[:map {:title "NitrateAuditEvent"}
|
[:map {:title "NitrateAuditEvent"}
|
||||||
[:name [:and [:string {:max 250}]
|
[:name [:and [:string {:max 250}]
|
||||||
[:re #"[\d\w-]{1,50}"]]]
|
[:re #"[\d\w-]{1,50}"]]]
|
||||||
|
[:type {:optional true} ::sm/text]
|
||||||
[:profile-id ::sm/uuid]
|
[:profile-id ::sm/uuid]
|
||||||
[:props {:optional true} [:map-of :keyword :any]]])
|
[:props {:optional true} [:map-of :keyword :any]]
|
||||||
|
[:context {:optional true} [:map-of :keyword :any]]])
|
||||||
|
|
||||||
(def ^:private schema:push-audit-events-params
|
(def ^:private schema:push-audit-events-params
|
||||||
[:map {:title "PushAuditEventsParams"}
|
[:map {:title "PushAuditEventsParams"}
|
||||||
[:events [:vector schema:nitrate-audit-event]]])
|
[:events [:vector schema:nitrate-audit-event]]])
|
||||||
|
|
||||||
(defn- submit-nitrate-audit-event
|
|
||||||
[cfg {:keys [name profile-id props]}]
|
|
||||||
(let [now (ct/now)]
|
|
||||||
(audit/submit* cfg {:type "action"
|
|
||||||
:name name
|
|
||||||
:profile-id profile-id
|
|
||||||
:props (or props {})
|
|
||||||
:context {}
|
|
||||||
:tracked-at now
|
|
||||||
:created-at now
|
|
||||||
:source "nitrate"
|
|
||||||
:ip-addr "0.0.0.0"})))
|
|
||||||
|
|
||||||
(sv/defmethod ::push-audit-events
|
(sv/defmethod ::push-audit-events
|
||||||
"Push audit events from Nitrate to Penpot audit log"
|
"Push audit events from nitrate (strictly for nitrate backend
|
||||||
|
events)"
|
||||||
|
|
||||||
{::doc/added "2.19"
|
{::doc/added "2.19"
|
||||||
|
::audit/skip true
|
||||||
::sm/params schema:push-audit-events-params
|
::sm/params schema:push-audit-events-params
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [events]}]
|
[cfg {:keys [::rpc/request-at events] :as params}]
|
||||||
(let [telemetry? (contains? cf/flags :telemetry)
|
(let [request (-> params meta ::http/request)
|
||||||
audit-log? (contains? cf/flags :audit-log)
|
context' (-> (audit/prepare-context-from-request request)
|
||||||
enabled? (and (not (db/read-only? pool))
|
(assoc :request-id (::rpc/request-id params)))
|
||||||
(or audit-log? telemetry?))]
|
|
||||||
(when (and enabled? (seq events))
|
ip-addr (::rpc/ip-addr params)]
|
||||||
(run! (partial submit-nitrate-audit-event cfg) events))
|
|
||||||
|
(run! (fn [{:keys [type name profile-id props context] :as event}]
|
||||||
|
(let [context (-> (merge context (d/without-nils context'))
|
||||||
|
(d/without-nils))]
|
||||||
|
(audit/submit cfg {:type (d/nilv type "action")
|
||||||
|
:name name
|
||||||
|
:profile-id profile-id
|
||||||
|
:props (or props {})
|
||||||
|
:context context
|
||||||
|
:tracked-at request-at
|
||||||
|
:ip-addr ip-addr})))
|
||||||
|
events)
|
||||||
|
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
|
||||||
@ -839,6 +853,15 @@ RETURNING id, deleted_at;")
|
|||||||
WHERE p.team_id = t.id
|
WHERE p.team_id = t.id
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
AND f.deleted_at IS NULL
|
AND f.deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT tpr2.created_at
|
||||||
|
FROM team_profile_rel AS tpr2
|
||||||
|
WHERE tpr2.team_id = t.id
|
||||||
|
AND tpr2.is_owner IS NOT TRUE
|
||||||
|
UNION ALL
|
||||||
|
SELECT ti.updated_at
|
||||||
|
FROM team_invitation AS ti
|
||||||
|
WHERE ti.team_id = t.id
|
||||||
) AS activity) AS last_activity_at,
|
) AS activity) AS last_activity_at,
|
||||||
owner_tpr.profile_id AS owner_profile_id,
|
owner_tpr.profile_id AS owner_profile_id,
|
||||||
owner_p.fullname AS owner_name,
|
owner_p.fullname AS owner_name,
|
||||||
@ -894,8 +917,8 @@ RETURNING id, deleted_at;")
|
|||||||
::sm/result schema:get-teams-detail-result
|
::sm/result schema:get-teams-detail-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [organization-id]}]
|
[cfg {:keys [organization-id]}]
|
||||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||||
team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams org-summary))]
|
team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams organization-summary))]
|
||||||
(if (empty? team-ids)
|
(if (empty? team-ids)
|
||||||
[]
|
[]
|
||||||
(db/run! cfg
|
(db/run! cfg
|
||||||
@ -918,7 +941,7 @@ RETURNING id, deleted_at;")
|
|||||||
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
||||||
discovery before saving the settings."
|
discovery before saving the settings."
|
||||||
{::doc/added "2.20"
|
{::doc/added "2.20"
|
||||||
::sm/params schema:nitrate-sso
|
::sm/params cto/schema:nitrate-sso
|
||||||
::sm/result schema:check-organization-sso-result
|
::sm/result schema:check-organization-sso-result
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg params]
|
[cfg params]
|
||||||
@ -931,14 +954,14 @@ RETURNING id, deleted_at;")
|
|||||||
::sm/params [:map
|
::sm/params [:map
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
[:updated-props ::sm/boolean]
|
[:updated-props ::sm/boolean]
|
||||||
[:became-active ::sm/boolean]]
|
[:announce-activation ::sm/boolean]]
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props became-active]}]
|
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}]
|
||||||
(when updated-props
|
(when updated-props
|
||||||
(rpc/invalidate-org-sso-cache-by-org! organization-id)
|
(rpc/invalidate-organization-sso-cache-by-organization! organization-id)
|
||||||
(session/clear-org-sso-sessions! pool organization-id))
|
(session/clear-organization-sso-sessions! pool organization-id))
|
||||||
(notifications/notify-organization-change-sso cfg organization-id)
|
(notifications/notify-organization-change-sso cfg organization-id)
|
||||||
(when became-active
|
(when announce-activation
|
||||||
(neh/send-organization-setup-sso-emails! cfg organization-id))
|
(neh/send-organization-setup-sso-emails! cfg organization-id))
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
@ -974,12 +997,19 @@ RETURNING id, deleted_at;")
|
|||||||
created users skip email verification and onboarding. Emails that already
|
created users skip email verification and onboarding. Emails that already
|
||||||
belong to an existing profile are skipped. Intended for the Nitrate admin
|
belong to an existing profile are skipped. Intended for the Nitrate admin
|
||||||
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
|
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
|
||||||
email allow-list."
|
email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled
|
||||||
|
by default so it is only available on test environments."
|
||||||
{::doc/added "2.19"
|
{::doc/added "2.19"
|
||||||
::sm/params schema:bulk-create-profiles-params
|
::sm/params schema:bulk-create-profiles-params
|
||||||
::sm/result schema:bulk-create-profiles-result
|
::sm/result schema:bulk-create-profiles-result
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [password emails]}]
|
[cfg {:keys [password emails]}]
|
||||||
|
|
||||||
|
(when-not (contains? cf/flags :admin-console-bulk-create-profiles)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :bulk-create-profiles-not-allowed
|
||||||
|
:hint "Bulk profile creation is disabled by config."))
|
||||||
|
|
||||||
(let [derived (aauth/derive-password password)]
|
(let [derived (aauth/derive-password password)]
|
||||||
(db/tx-run!
|
(db/tx-run!
|
||||||
cfg
|
cfg
|
||||||
|
|||||||
@ -23,21 +23,21 @@
|
|||||||
AND deleted_at IS NULL")
|
AND deleted_at IS NULL")
|
||||||
|
|
||||||
(def ^:private sql:get-profiles-by-emails
|
(def ^:private sql:get-profiles-by-emails
|
||||||
"SELECT id, email, fullname, is_muted
|
"SELECT id, email, is_muted
|
||||||
FROM profile
|
FROM profile
|
||||||
WHERE email = ANY(?)
|
WHERE email = ANY(?)
|
||||||
AND deleted_at IS NULL")
|
AND deleted_at IS NULL")
|
||||||
|
|
||||||
(defn- org-sso-active?
|
(defn- organization-sso-active?
|
||||||
"Return whether SSO is enabled for the organization."
|
"Return whether SSO is enabled for the organization."
|
||||||
[cfg organization-id]
|
[cfg organization-id]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(true? (:active (nitrate/call cfg :get-org-sso {:organization-id organization-id})))))
|
(true? (:active (nitrate/call cfg :get-organization-sso {:organization-id organization-id})))))
|
||||||
|
|
||||||
(def ^:private xf:map-email (map :email))
|
(def ^:private xf:map-email (map :email))
|
||||||
|
|
||||||
(defn- recipients-by-emails
|
(defn- recipients-by-emails
|
||||||
"Build `{:email :user-name :profile}` maps for a deduplicated email list."
|
"Build `{:email :profile}` maps for a deduplicated email list."
|
||||||
[conn emails]
|
[conn emails]
|
||||||
(let [profiles (if (seq emails)
|
(let [profiles (if (seq emails)
|
||||||
(let [emails-array (db/create-array conn "text" emails)]
|
(let [emails-array (db/create-array conn "text" emails)]
|
||||||
@ -47,60 +47,58 @@
|
|||||||
(map (fn [email]
|
(map (fn [email]
|
||||||
(let [profile (get profile-by-email (str/lower email))]
|
(let [profile (get profile-by-email (str/lower email))]
|
||||||
{:email email
|
{:email email
|
||||||
:user-name (:fullname profile)
|
|
||||||
:profile profile}))
|
:profile profile}))
|
||||||
emails)))
|
emails)))
|
||||||
|
|
||||||
(defn- send-organization-setup-sso-email!
|
(defn- send-organization-setup-sso-email!
|
||||||
"Send the organization SSO setup email to a single recipient, when allowed."
|
"Send the organization SSO setup email to a single recipient, when allowed."
|
||||||
[conn organization-name {:keys [email user-name profile]}]
|
[conn organization-name {:keys [email profile]}]
|
||||||
(when (or (nil? profile)
|
(when (or (nil? profile)
|
||||||
(eml/allow-send-emails? conn profile))
|
(eml/allow-send-emails? conn profile))
|
||||||
(eml/send! {::eml/conn conn
|
(eml/send! {::eml/conn conn
|
||||||
::eml/factory eml/organization-setup-sso
|
::eml/factory eml/organization-setup-sso
|
||||||
:public-uri (cf/get :public-uri)
|
:public-uri (cf/get :public-uri)
|
||||||
:to email
|
:to email
|
||||||
:user-name user-name
|
|
||||||
:organization-name organization-name})))
|
:organization-name organization-name})))
|
||||||
|
|
||||||
(defn- get-org-sso-notify-recipients
|
(defn- get-organization-sso-notify-recipients
|
||||||
"Unique org members and pending org/team invitees for SSO activation emails."
|
"Unique organization members and pending organization/team invitees for SSO activation emails."
|
||||||
[conn cfg organization-id org-summary]
|
[conn cfg organization-id organization-summary]
|
||||||
(let [member-ids (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
(let [member-ids (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||||
team-ids (neh/get-org-team-ids org-summary)
|
team-ids (neh/get-organization-team-ids organization-summary)
|
||||||
member-emails (if (seq member-ids)
|
member-emails (if (seq member-ids)
|
||||||
(let [ids-array (db/create-array conn "uuid" member-ids)]
|
(let [ids-array (db/create-array conn "uuid" member-ids)]
|
||||||
(into #{} (map :email (db/exec! conn [sql:get-profile-emails-by-ids ids-array]))))
|
(into #{} (map :email (db/exec! conn [sql:get-profile-emails-by-ids ids-array]))))
|
||||||
#{})
|
#{})
|
||||||
invite-emails (into #{} (map :email
|
invite-emails (into #{} (map :email
|
||||||
(neh/get-org-invitations conn organization-id team-ids)))
|
(neh/get-organization-invitations conn organization-id team-ids)))
|
||||||
emails (into #{} (concat member-emails invite-emails))]
|
emails (into #{} (concat member-emails invite-emails))]
|
||||||
(recipients-by-emails conn emails)))
|
(recipients-by-emails conn emails)))
|
||||||
|
|
||||||
(defn- get-team-sso-notify-recipients
|
(defn- get-team-sso-notify-recipients
|
||||||
"Team members who are not in `org-member-ids`, plus pending team invitations."
|
"Team members who are not in `organization-member-ids`, plus pending team invitations."
|
||||||
[conn team-id org-member-ids]
|
[conn team-id organization-member-ids]
|
||||||
(let [team-members (->> (teams/get-team-members conn team-id)
|
(let [team-members (->> (teams/get-team-members conn team-id)
|
||||||
(remove #(contains? org-member-ids (:id %))))
|
(remove #(contains? organization-member-ids (:id %))))
|
||||||
invitations (neh/get-team-invitation-emails conn team-id)]
|
invitations (neh/get-team-invitation-emails conn team-id)]
|
||||||
(->> (sequence xf:map-email (concat team-members invitations))
|
(->> (sequence xf:map-email (concat team-members invitations))
|
||||||
(recipients-by-emails conn))))
|
(recipients-by-emails conn))))
|
||||||
|
|
||||||
(defn send-organization-setup-sso-emails!
|
(defn send-organization-setup-sso-emails!
|
||||||
"Notify all org members and pending org/team invitees that SSO is active."
|
"Notify all organization members and pending organization/team invitees that SSO is active."
|
||||||
[cfg organization-id]
|
[cfg organization-id]
|
||||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})]
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
|
||||||
(db/tx-run! cfg
|
(db/tx-run! cfg
|
||||||
(fn [{:keys [::db/conn]}]
|
(fn [{:keys [::db/conn]}]
|
||||||
(doseq [recipient (get-org-sso-notify-recipients conn cfg organization-id org-summary)]
|
(doseq [recipient (get-organization-sso-notify-recipients conn cfg organization-id organization-summary)]
|
||||||
(send-organization-setup-sso-email! conn (:name org-summary) recipient))))))
|
(send-organization-setup-sso-email! conn (:name organization-summary) recipient))))))
|
||||||
|
|
||||||
(defn send-organization-setup-sso-emails-for-team!
|
(defn send-organization-setup-sso-emails-for-team!
|
||||||
"Notify team members who are not in `org-member-ids-before` and pending team invitees."
|
"Notify team members who are not in `organization-member-ids-before` and pending team invitees."
|
||||||
[cfg organization-id team-id org-member-ids-before]
|
[cfg organization-id team-id organization-member-ids-before]
|
||||||
(when (org-sso-active? cfg organization-id)
|
(when (organization-sso-active? cfg organization-id)
|
||||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})]
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
|
||||||
(db/tx-run! cfg
|
(db/tx-run! cfg
|
||||||
(fn [{:keys [::db/conn]}]
|
(fn [{:keys [::db/conn]}]
|
||||||
(doseq [recipient (get-team-sso-notify-recipients conn team-id org-member-ids-before)]
|
(doseq [recipient (get-team-sso-notify-recipients conn team-id organization-member-ids-before)]
|
||||||
(send-organization-setup-sso-email! conn (:name org-summary) recipient)))))))
|
(send-organization-setup-sso-email! conn (:name organization-summary) recipient)))))))
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.nitrate :as nitrate]))
|
[app.nitrate :as nitrate]))
|
||||||
|
|
||||||
(def ^:private sql:get-org-invitations
|
(def ^:private sql:get-organization-invitations
|
||||||
"SELECT DISTINCT ON (email_to)
|
"SELECT DISTINCT ON (email_to)
|
||||||
ti.id,
|
ti.id,
|
||||||
ti.org_id AS organization_id,
|
ti.org_id AS organization_id,
|
||||||
@ -35,24 +35,24 @@ LEFT JOIN profile AS p
|
|||||||
AND ti.valid_until >= now()
|
AND ti.valid_until >= now()
|
||||||
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
|
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
|
||||||
|
|
||||||
(defn get-org-team-ids
|
(defn get-organization-team-ids
|
||||||
"Return team ids for an organization.
|
"Return team ids for an organization.
|
||||||
|
|
||||||
Accepts either `cfg` and `organization-id` (fetches the org summary from
|
Accepts either `cfg` and `organization-id` (fetches the organization summary from
|
||||||
Nitrate) or an already-resolved org summary map."
|
Nitrate) or an already-resolved organization summary map."
|
||||||
([cfg organization-id]
|
([cfg organization-id]
|
||||||
(get-org-team-ids (nitrate/call cfg :get-org-summary {:organization-id organization-id})))
|
(get-organization-team-ids (nitrate/call cfg :get-organization-summary {:organization-id organization-id})))
|
||||||
([org-summary]
|
([organization-summary]
|
||||||
(->> (:teams org-summary)
|
(->> (:teams organization-summary)
|
||||||
(map :id)
|
(map :id)
|
||||||
(filter uuid?)
|
(filter uuid?)
|
||||||
(vec))))
|
(vec))))
|
||||||
|
|
||||||
(defn get-org-invitations
|
(defn get-organization-invitations
|
||||||
"Fetch valid org-level and team-level invitations for an organization."
|
"Fetch valid organization-level and team-level invitations for an organization."
|
||||||
[conn organization-id team-ids]
|
[conn organization-id team-ids]
|
||||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
(db/exec! conn [sql:get-org-invitations organization-id ids-array])))
|
(db/exec! conn [sql:get-organization-invitations organization-id ids-array])))
|
||||||
|
|
||||||
(defn get-team-invitation-emails
|
(defn get-team-invitation-emails
|
||||||
"Return distinct valid team invitation recipient emails."
|
"Return distinct valid team invitation recipient emails."
|
||||||
|
|||||||
@ -16,17 +16,17 @@
|
|||||||
;;TODO There is a bug on dashboard with teams notifications.
|
;;TODO There is a bug on dashboard with teams notifications.
|
||||||
;;For now we send it to uuid/zero instead of team-id
|
;;For now we send it to uuid/zero instead of team-id
|
||||||
:topic uuid/zero
|
:topic uuid/zero
|
||||||
:message {:type :team-org-change
|
:message {:type :team-organization-change
|
||||||
:team team
|
:team team
|
||||||
:notification notification})))
|
:notification notification})))
|
||||||
|
|
||||||
|
|
||||||
(defn notify-user-org-change
|
(defn notify-user-organization-change
|
||||||
[cfg profile-id organization-id organization-name notification]
|
[cfg profile-id organization-id organization-name notification]
|
||||||
(let [msgbus (::mbus/msgbus cfg)]
|
(let [msgbus (::mbus/msgbus cfg)]
|
||||||
(mbus/pub! msgbus
|
(mbus/pub! msgbus
|
||||||
:topic profile-id
|
:topic profile-id
|
||||||
:message {:type :user-org-change
|
:message {:type :user-organization-change
|
||||||
:topic profile-id
|
:topic profile-id
|
||||||
:organization-id organization-id
|
:organization-id organization-id
|
||||||
:organization-name organization-name
|
:organization-name organization-name
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user