Compare commits

..
Author SHA1 Message Date
Andrey Antukh bf58ac0b9e 🐛 Eliminate N+1 query storm in unread comment threads
The dashboard's unread-comment-threads flow fired one
get-profiles-for-file-comments RPC call per file-id, producing
20+ concurrent HTTP requests and 20+ SQL queries for users with
unread threads across multiple files.

Widen the existing get-profiles-for-file-comments RPC to accept a
set of file ids (max 100) instead of a single id. The SQL uses
ANY(?::uuid[]) so a 1-element set has the same query plan as the
previous file_id = ?. Update the frontend unread-threads flow to
send a single batch request, and the workspace fetch-profiles
event to wrap the current file id in a 1-element set. The 100 cap
is enforced in both the frontend (with a log/warn on overflow) and
the schema (:max 100).

Fixes #10587

AI-assisted-by: opencode-go/minimax-m3
2026-07-23 10:01:18 +02:00
834 changed files with 20040 additions and 65160 deletions

No files matched your search

-3
View File
@@ -88,9 +88,6 @@
:dynamic-var-not-earmuffed
{:level :off}
:type-mismatch
{:level :off}
:used-underscored-binding
{:level :warning}
+1 -1
View File
@@ -3,7 +3,7 @@ name: Auto Label and Add to Project
on:
issues:
types: [opened]
pull_request_target:
pull_request:
types: [opened]
jobs:
+33 -71
View File
@@ -9,6 +9,16 @@ on:
type: string
required: true
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:
inputs:
gh_ref:
@@ -16,21 +26,29 @@ on:
type: string
required: true
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:
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
cancel-in-progress: true
jobs:
# ── 1. Decide whether there is anything to build ───────────────────────
check:
name: Check current bundle
build-bundle:
name: Build and Upload Penpot Bundle
runs-on: penpot-runner-01
timeout-minutes: 10
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
exists: ${{ steps.check.outputs.exists }}
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 }}
steps:
- name: Checkout repository
@@ -45,52 +63,10 @@ jobs:
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $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
env:
BUILD_WASM: 'yes'
BUILD_STORYBOOK: 'yes'
BUILD_WASM: ${{ inputs.build_wasm }}
BUILD_STORYBOOK: ${{ inputs.build_storybook }}
run: ./manage.sh build-bundle
- name: Prepare directories for zipping
@@ -104,32 +80,18 @@ jobs:
zip -r zips/penpot.zip penpot
- 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: |
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 }}
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 }}
# ── 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
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 📦 *[PENPOT] Error building penpot bundles.*
📄 Triggered from ref: `${{ needs.check.outputs.gh_ref || inputs.gh_ref }}`
Bundle version: `${{ needs.check.outputs.bundle_version || 'n/a' }}`
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
Bundle version: `${{ steps.vars.outputs.bundle_version }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra
+2 -6
View File
@@ -11,6 +11,8 @@ jobs:
secrets: inherit
with:
gh_ref: "develop"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
needs: build-bundle
@@ -18,9 +20,3 @@ jobs:
secrets: inherit
with:
gh_ref: "develop"
build-admin-console-docker:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "develop"
@@ -1,91 +0,0 @@
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"
+2 -11
View File
@@ -20,19 +20,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Registry (push destination)
- name: Login to Docker Registry
uses: docker/login-action@v4
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
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
uses: docker/build-push-action@v7
env:
@@ -42,14 +35,12 @@ jobs:
file: ./docker/devenv/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
provenance: mode=max
sbom: true
tags: ${{ env.DOCKER_IMAGE }}:latest
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
+113 -212
View File
@@ -20,117 +20,55 @@ concurrency:
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
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:
# ── 1. Resolve the build key and check the whole set at once ───────────
prepare:
name: Prepare
build-and-push:
name: Build and Push Penpot Docker Images
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:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
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
uses: docker/login-action@v4
with:
@@ -147,140 +85,103 @@ jobs:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
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)
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ matrix.image }}
images:
frontend
backend
exporter
storybook
mcp
labels: |
bundle_version=${{ needs.prepare.outputs.bundle_version }}
bundle_version=${{ steps.bundles.outputs.bundle_version }}
- name: Build and push Docker image
- name: Build and push Backend Docker image
uses: docker/build-push-action@v7
env:
DOCKER_IMAGE: 'backend'
BUNDLE_PATH: './bundle-backend'
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.${{ matrix.image }}
file: ./docker/images/Dockerfile.backend
platforms: linux/amd64,linux/arm64
push: true
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 }}
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 }}/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
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
# ── 3. Move the branch tags of ALL images together ─────────────────────
# Runs only when every build succeeded (default `needs` semantics); if
# the set was already complete, `build` is skipped and so is this job —
# the S3 marker guarantees the branch tags were already moved.
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:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Point branch tags to the new build key
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
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v7
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: |
echo "${{ github.run_id }}" | aws s3 cp - \
"s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
{
echo "### ✅ Image set promoted"
echo ""
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
} >> "$GITHUB_STEP_SUMMARY"
DOCKER_IMAGE: 'frontend'
BUNDLE_PATH: './bundle-frontend'
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.frontend
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
# ── 4. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
runs-on: penpot-runner-02
timeout-minutes: 5
needs: [prepare, build, promote]
if: failure()
- name: Build and push Exporter Docker image
uses: docker/build-push-action@v7
env:
DOCKER_IMAGE: 'exporter'
BUNDLE_PATH: './bundle-exporter'
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.exporter
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 Storybook Docker image
uses: docker/build-push-action@v7
env:
DOCKER_IMAGE: 'storybook'
BUNDLE_PATH: './bundle-storybook'
with:
context: ./docker/images/
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
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🐳 *[PENPOT] Error building/promoting the penpot docker image set.*
📄 Triggered from ref: `${{ needs.prepare.outputs.gh_ref || inputs.gh_ref }}`
📦 Bundle: `${{ needs.prepare.outputs.bundle_version || 'n/a' }}`
❌ 🐳 *[PENPOT] Error building penpot docker images.*
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra
+2 -6
View File
@@ -11,6 +11,8 @@ jobs:
secrets: inherit
with:
gh_ref: "staging"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
needs: build-bundle
@@ -18,9 +20,3 @@ jobs:
secrets: inherit
with:
gh_ref: "staging"
build-admin-console-docker:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "staging"
+4 -1
View File
@@ -12,6 +12,8 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
build_wasm: "yes"
build_storybook: "yes"
build-docker:
needs: build-bundle
@@ -24,9 +26,10 @@ jobs:
name: Notifications
runs-on: ubuntu-24.04
needs: build-docker
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
+1 -1
View File
@@ -131,7 +131,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
@@ -129,7 +129,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
@@ -1,69 +0,0 @@
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
-2
View File
@@ -88,7 +88,6 @@ opencode.json
/blob-report/
/playwright/.cache/
/render-wasm/target/
/media-processor/dist/
/**/node_modules
/**/.yarn/*
/.pnpm-store
@@ -102,6 +101,5 @@ opencode.json
/.opencode/plans
/.opencode/reports
/.opencode/prompts
/.ci-logs
/.codex/
/tools/__pycache__
+1 -1
View File
@@ -1 +1 @@
v24.18.1
v24.18.0
+55
View File
@@ -0,0 +1,55 @@
---
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.
+7 -6
View File
@@ -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 create-commit skill
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
agent: build
---
@@ -32,11 +32,12 @@ 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
step 4.
## 4. Commit with the create-commit skill
## 4. Commit with the commiter subagent
After the implementation is complete, load the **`create-commit`** skill and
follow its workflow to commit the changes. Provide a brief summary of what was
implemented and why, the issue reference (`issue-NNNN`), and the model name you
are running as so the `AI-assisted-by` trailer is set correctly.
After the implementation is complete, delegate the commit to the **`commiter`**
subagent. Give it a brief summary of what was implemented and why, the issue
reference (`issue-NNNN`), and the model name you are running as so it sets the
`AI-assisted-by` trailer correctly. The subagent owns the commit format and
conventions.
Do not push. Pushing is handled separately by the user.
+6 -68
View File
@@ -4,76 +4,14 @@ Act as a senior software engineer and perform a thorough code review.
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?
3. Read the diff and the surrounding context for each changed file.
4. Review across all five axes: correctness, readability, architecture, security, performance.
5. Produce the review using the **Review Output** format from the skill (Summary → Critical/High → Other Findings → Refactoring → Testing Recommendations → Positive Observations → Final Verdict).
6. For each finding: state the severity (Critical / High / Medium / Low / Suggestion), identify the file and line, describe failure circumstances, and propose a concrete fix.
7. Do not invent problems. Every finding must be real and actionable.
## Strong Rules
1. Do not invent problems. Every finding must be real and actionable.
2. Do not modify any code and do not create a commit — this command only reviews.
3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
4. Prioritize by impact. One structural issue outweighs ten nits.
5. If tests are missing for new functionality, flag it as High severity.
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]
```
-47
View File
@@ -1,47 +0,0 @@
---
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
View File
@@ -1,57 +0,0 @@
---
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
+2 -2
View File
@@ -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.
* **Linting:** `pnpm run lint:clj`.
* **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.
* **Linting:** `clj-kondo --lint ../common/src/ src/`.
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
**Before linting:** if delimiter errors are suspected (after LLM edits), run
`scripts/paren-repair` on the affected files first. Delimiter errors produce
@@ -24,12 +24,6 @@ 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.
## 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
`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.
+1 -1
View File
@@ -5,7 +5,7 @@
## 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.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.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.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.geom.*`: geometry helpers and transformations.
@@ -7,8 +7,6 @@
- `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.
- `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
@@ -21,7 +19,6 @@
- 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.
- `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
@@ -8,9 +8,6 @@
## 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.
- `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.
- 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.
-1
View File
@@ -39,7 +39,6 @@ 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`.
- `library/`: design library workflows; core conventions: `mem:library/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
module. You can read it from `mem:<MODULE>/core`
+3 -5
View File
@@ -25,9 +25,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## Worker policy
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.
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`.
## Port layout
@@ -65,8 +63,8 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
## 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).
- `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 --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.
- `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.
- `run-devenv`: legacy alias, ws0 non-agentic attached.
- `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`.)
@@ -1,152 +0,0 @@
# Composable component tests
A framework concept for systematically testing Penpot's component subsystem
(synchronisation/propagation, swaps, variant switches, nesting, overrides), implemented in TWO test
suites that share the principles below:
1. **ClojureScript suite** — in the frontend test tree (`frontend/test/frontend_tests/
composable_tests/`), driving a minimally-assembled real app headlessly. The original.
2. **TypeScript suite** — a Penpot plugin (`plugins/apps/composable-test-suite/`), driving the FULL
production app end-to-end through the Plugin API, with a slightly more elaborate set of
abstractions. Runs interactively (panel), remotely (Playwright), and headlessly in CI. Its
README is the authoritative operational reference.
## Shared core idea
A test is a **composition of operations** over a starting configuration, plus assertions. You
describe a test as data (a setup + a sequence of operations) rather than writing bespoke imperative
code, and coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing
pieces, not a copied test. Choice points (one-of alternatives, optional steps) EXPAND the
composition into a full sweep of variants — one written case stands for a whole matrix of concrete
tests.
## Shared principles
- **Every producing object is the accessor interface to what it produces downstream.** An
operation — and related objects such as content-creation strategies — is not merely an action:
the SAME object instance the case holds is the typed interface through which everything it
created or changed is later retrieved, checked, and asserted, parameterized by the situation. A
foundation operation exposes accessors for the participants it built; an edit operation exposes
its dual check (`assertHasChangedProperty` / `has-property-of`); a choice is recovered by asking
the one-of object (`getChoice`/`get-choice`); "did this step run" is asked of the step
(`wasApplied`/`applied?`). NEVER reach into a situation (or the document) for something an
upstream object produced — ask the producer. This is what keeps sweeps sound (object identity
ties the question to the exact node that ran) and what keeps retrieval logic in exactly one
place. Particularly explicit in the TS OOP implementation, where these accessors are methods on
the operation/strategy classes; repeatedly violating it (reading the document directly,
duplicating retrieval) was the most common review correction while building the suites.
- **Operations are data with identity.** Each operation node has a unique id at construction and
records what it did under that id; interrogation is by identity. Bind an operation to a value
ONCE and reuse it in the composition and in every query about it.
- **Drive the real production pipeline.** Operations route through genuine Penpot logic — real
change functions / real workspace events / the real Plugin API, never raw field writes — so the
production watcher's AUTOMATIC propagation is what's under test.
- **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
abstractions; an operation may name the domain ACTION it performs.
- **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.
---
# ClojureScript suite (frontend test tree)
Test-only `.cljs` code in the frontend test tree (nothing "common" about it). A **situation** =
the in-memory file value + named roles + `:vars` + an ordered applied-log. Operations are records
implementing `IOperation`/`apply-to` (`apply` collides with core). Assertions = inline `Test` ops
and/or a trailing asserter; the runner makes no judgment. Failures carry `describe-applied` (the
transcript), which is what makes a failing variant in a sweep identifiable.
Layout: `core.cljs` (the domain-agnostic engine: situation, identity/transcript, roles/targets,
operators, runners), `comp/setups.cljs` (setups + role accessors), `comp/nodes.cljs` (the component
operations and their check duals), `interpreter.cljs` (runs cases against the real frontend),
`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.
**Scenario lineage model** (behind the sweeps): scenario ops track named component lineages as
objects under `:vars`, each holding the FIXED deepest origin (`:remote-*`), the ADVANCING outer
main (`:main-*`), and per-nesting-level data whose `:nested-head` (the deepest instance at that
level, found by descending the `:shape-ref` chain — matching chain MEMBERSHIP, not terminus) is
the swap/switch target, anchored by its swap-stable parent. Nesting seeks the FIXED origin, not
the advancing main — that is what makes each level's `:nested-head` land on the deepest instance.
A variant nesting re-points the lineage's remote to the chosen member. Construction lesson:
cross-level propagation requires progressively NESTED levels (one variant + plain wraps); sibling
nestings do not propagate between each other.
**Interpreter:** installs the situation's files into the global `st/state` (aux files tagged
`:library-of`), starts the real `watch-component-changes` (+ harness `watch-undo-stack`), maps
event-ops to REAL workspace events (`dwsh/update-shapes`, `dwl/component-swap`,
`dwv/variants-switch`, `dwt/increase-rotation` — which runs the `check-delta` placement
classification — `dwt/update-dimensions`, `dwu/undo`, `dwl/sync-file`, …) and runs sync-ops'
`apply-to` against the live store file; awaits settlement (idle-gap heuristic + per-op grace) and
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.
Running: `cd frontend && pnpm run build:test`, then
`node target/tests/test.js --focus frontend-tests.composable-tests.comp.sync-test`
(var-level focus for one case).
**Fidelity warning:** the harness drives a MINIMALLY-ASSEMBLED app — only some
`initialize-workspace` subscriptions are wired. Risk = SILENT UNDER-WIRING (e.g. undo needs the
harness `watch-undo-stack`). When a case needs app behaviour beyond a raw edit, check for an
unwired subscription and verify by PROBING store state, not by trusting a green assertion.
**Caveats:** inline `Test` exceptions are UNCAUGHT on the frontend (crash the runner — assert in
the trailing asserter). `(optional (in-sequence …))` is not flattened for the interpreter — use
independent optionals. The Serena/clj-kondo cache for `nodes.cljs` goes stale (phantom symbols) —
trust the build. Cross-namespace global-state leaks land in this suite first; suspect them before
the framework on inexplicable full-run-only failures. Case H's `sync-file` schedules a delayed RPC
that fails headless (benign; absorbed by per-op grace).
---
# TypeScript suite (the plugin) — full e2e
`plugins/apps/composable-test-suite/` — same principles against the FULL production app through the
Plugin API (real frontend, real propagation). Continuation of the CLJS suite per issue #10584.
Operational details (build/run, connect URL, remote control, reading logs, auto-reload, CI): the
plugin README.
Distinguishing abstractions (the OOP articulation of the shared principles):
- `TestCase {identifier, description, operation}` with the three-part description mandated in the
constructor docstring.
- The accessor-interface principle is class-level: foundation operations (e.g.
`OpCreateSimpleComponentWithCopy`) expose the roles they build; **content-creation strategies**
(pluggable: what content a foundation builds around) expose accessors for the content they
created; edit operations expose their checks (`OpChangeProperty.assertHasChangedProperty`);
`OpOneOf`/`OpOptional` are queried for what ran. Tests never grope the document for something a
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.
## CI
Headless per-PR gate: `.github/workflows/tests-composable-suite.yml` runs
`pnpm --filter composable-test-suite run test:ci` — mocked backend (frontend e2e static server +
Playwright RPC fixtures, no backend/login), the in-sandbox bundle injected via `ɵloadPlugin`,
results streamed via console markers, `TEST_FILTER` by identifier substring. The mocked backend is
NOT a limitation for this suite (everything asserted is frontend store logic; empirically
confirmed against the interactive runs). Architecture mirrors `plugin-api-test-suite`'s CI driver;
the mock harness exists in THREE places that must stay in sync (provenance note in `ci/run-ci.ts`).
Details: README, "Running in CI".
## Substrate
`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`,
`mem:frontend/testing`.
+1 -1
View File
@@ -23,7 +23,7 @@ From `frontend/`:
- JS lint currently no-ops via `pnpm run lint:js`.
- SCSS lint: `pnpm run lint: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`. After running `fmt:*`, `check-fmt:*` is redundant.
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`.
- Translation formatting after i18n edits: `pnpm run translations`.
**Before linting:** if delimiter errors are suspected (after LLM edits, or
-100
View File
@@ -1,100 +0,0 @@
# 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
+45 -106
View File
@@ -6,8 +6,7 @@
- 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)
- Exporting error data in JSON or table format
- Investigating specific error reports by ID
## Prerequisites
@@ -53,25 +52,19 @@ WHERE id = '<token-uuid>';
| 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) | — |
| `--since <date>` | ISO timestamp (fetch errors before this date) | — |
| `--since-id <uuid>` | Fetch errors before 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 | — |
| `-a, --all` | Fetch all pages automatically | `false` |
| `-f, --format <type>` | Output format: `json` or `table` | `json` |
| `--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
@@ -84,31 +77,10 @@ WHERE id = '<token-uuid>';
|------|-------------|----------|
| `--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`) |
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `json`) |
| `--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:
@@ -117,17 +89,6 @@ The `--source` filter accepts these values:
- `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
@@ -135,22 +96,6 @@ With `--normalize-hints` (or always in `stats`), hints are normalized by strippi
./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
@@ -178,7 +123,7 @@ With `--normalize-hints` (or always in `stats`), hints are normalized by strippi
### Fetch all errors with pagination
```bash
./scripts/error-reports.mjs list --all
./scripts/error-reports.mjs list --all --format json
```
### Get specific error by ID
@@ -196,36 +141,46 @@ With `--normalize-hints` (or always in `stats`), hints are normalized by strippi
./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.
Human-readable table format for terminal display:
```
Found 15 error reports
ID | Created At | Source | Profile ID | Kind | Hint
-------------------------------------+---------------------+-----------+--------------------------------------+----------------+------------------
550e8400-e29b-41d4-a716-446655440000 | 2026-01-20 10:30:00 | audit-log | e98bb95f-573d-8137-8008-252580aa456d | exception-page | Error description
abc12345-e29b-41d4-a716-446655440001 | 2026-01-20 10:29:00 | logging | - | error | Another error that is very long and ne...
More results: use --since 2026-01-20T10:28:00Z --since-id def45678-e29b-41d4-a716-446655440002
```
### JSON
Single page: `{items: [...], nextSince, nextId}`. `--all` cannot be combined with `--format json`; use `--format ndjson` for streaming.
Returns structured JSON with error details and pagination metadata:
### NDJSON
One JSON object per line, always streaming. Pipe-friendly: `| jq -c '.hint'`, `| wc -l`.
```json
{
"items": [
{
"id": "uuid",
"createdAt": "2026-01-20T10:30:00Z",
"source": "audit-log",
"profileId": "e98bb95f-573d-8137-8008-252580aa456d",
"kind": "exception-page",
"tenant": "production",
"version": "2.1.0",
"hint": "Error description"
}
],
"nextSince": "2026-01-20T10:29:00Z",
"nextId": "next-uuid"
}
```
## 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:
@@ -236,28 +191,20 @@ Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the
```
### Automatic pagination
Use `--all` to fetch all pages automatically (streams output):
Use `--all` to fetch all pages automatically:
```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.
- **Table is default format** - Use `--format json` for structured JSON output
- **Pagination is automatic with --all** - Fetches all pages without manual cursor management
- **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
@@ -270,20 +217,12 @@ The tool provides helpful error messages for common issues:
## Integration with other scripts
- **jq**: Pipe NDJSON output to `jq` for further processing
- **jq**: Pipe JSON 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
./scripts/error-reports.mjs list --all --format json | jq '.items[] | {id, kind, hint}'
```
- **grep/search**: Filter output by specific patterns
- **--output**: Save to file without shell redirection
- **Redirect**: Save output to files for analysis
```bash
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
./scripts/error-reports.mjs list --all --format json > errors.json
```
+4 -19
View File
@@ -137,32 +137,17 @@ E2E tests should not be added unless explicitly requested.
## Execution discipline
**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.
- **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).
- 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):
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
- Same file-piping rule applies.
- The same no-piping rule applies: use `--focus` to narrow scope.
## Verification Checklist
@@ -14,8 +14,6 @@ automatically pull the identity from the local git config `user.name` and `user.
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
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
```
@@ -27,7 +25,3 @@ AI-assisted-by: model-name
## 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
## Referencing Issues
Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
+4 -6
View File
@@ -30,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering:
- what changed and why;
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- screenshots or recordings for UI-visible changes;
- testing performed and residual risk;
- breaking changes or migration notes, if any.
@@ -42,15 +42,15 @@ PR descriptions follow this structure:
## What
<the problem or feature and its user-facing impact — short bullet items where there is more than one point>
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation — a short paragraph or bullets>
<root cause or motivation, why this change was necessary>
## How
<high-level approach and key decisions — bullet items, grouped by area (bold lead-ins) for larger PRs>
<high-level approach, key technical decisions>
```
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
@@ -59,8 +59,6 @@ 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.
- **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.
### What NOT to Include
+23 -60
View File
@@ -1,31 +1,26 @@
# the name by which the project can be referenced within Serena/when chatting with the LLM.
# the name by which the project can be referenced within Serena
project_name: "penpot"
# list of languages for which language servers are started (LSP backend only); choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart elixir
# elm erlang fortran fsharp gdscript
# list of languages for which language servers are started; choose from:
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl
# html java json julia kotlin
# latex lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor php_phpantom powershell
# python python_jedi python_pyrefly python_ty r
# rego ruby ruby_solargraph rust scala
# scss solidity svelte swift systemverilog
# terraform toml typescript typescript_vts vue
# yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@@ -59,19 +54,12 @@ ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
@@ -142,38 +130,13 @@ ignored_memory_patterns: []
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support.
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- .
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0
additional_workspace_folders: []
-4
View File
@@ -8,9 +8,6 @@
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.
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**:
- 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)
@@ -112,5 +109,4 @@ precision while maintaining a strong focus on maintainability and performance.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `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`.
-37
View File
@@ -1,40 +1,5 @@
# CHANGELOG
## 2.18.0 (Unreleased)
### :bug: Bugs fixed
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
- Fix synced component copy not reflowing children after spacing token update [#9892](https://github.com/penpot/penpot/issues/9892)
- Fix spacebar activating pan mode while typing a comment (by @Krishcode264) [#10285](https://github.com/penpot/penpot/issues/10285) (PR: [#10287](https://github.com/penpot/penpot/pull/10287))
- Fix plugin API rejecting negative letterSpacing values (by @filipsajdak) [#9780](https://github.com/penpot/penpot/issues/9780) (PR: [#10257](https://github.com/penpot/penpot/pull/10257))
- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359))
- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541))
- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535))
- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523))
- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381))
### :sparkles: New features & Enhancements
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677))
- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- 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))
## 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
### :rocket: Epics and highlights
@@ -78,8 +43,6 @@
### :bug: Bugs fixed
- 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 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 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 @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))
+9 -10
View File
@@ -6,7 +6,7 @@
org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/tools.namespace {:mvn/version "1.5.1"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-12"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-11"}
io.prometheus/simpleclient {:mvn/version "0.16.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
@@ -34,28 +34,27 @@
:exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc
{:mvn/version "1.3.1118"}
{:mvn/version "1.3.1108"}
metosin/reitit-core {:mvn/version "0.10.1"}
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.13"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"}
org.postgresql/postgresql {:mvn/version "42.7.12"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
com.zaxxer/HikariCP {:mvn/version "7.1.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
io.whitfin/siphash {:mvn/version "2.0.0"}
buddy/buddy-hashers {:mvn/version "2.0.167"}
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"}
org.jsoup/jsoup {:mvn/version "1.23.1"}
org.jsoup/jsoup {:mvn/version "1.22.2"}
at.yawk.lz4/lz4-java
{:mvn/version "1.11.1"}
{:mvn/version "1.11.0"}
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
@@ -64,8 +63,8 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
software.amazon.awssdk/s3 {:mvn/version "2.46.18"}
software.amazon.awssdk/sts {:mvn/version "2.46.18"}}
:paths ["src" "resources" "target/classes"]
:aliases
+7 -9
View File
@@ -4,25 +4,23 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"dependencies": {
"eventsource-parser": "^3.0.6",
"luxon": "^3.7.2",
"sax": "^1.6.1"
"luxon": "^3.4.4",
"sax": "^1.6.0"
},
"devDependencies": {
"nodemon": "^3.1.14",
"source-map-support": "^0.5.21",
"ws": "^8.21.1"
"ws": "^8.21.0"
},
"scripts": {
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs"
"lint": "clj-kondo --parallel --lint ../common/src src/",
"check-fmt": "cljfmt check --parallel=true src/ test/",
"fmt": "cljfmt fix --parallel=true src/ test/"
}
}
+16 -25
View File
@@ -8,15 +8,12 @@ importers:
.:
dependencies:
eventsource-parser:
specifier: ^3.0.6
version: 3.1.0
luxon:
specifier: ^3.7.2
specifier: ^3.4.4
version: 3.7.2
sax:
specifier: ^1.6.1
version: 1.6.1
specifier: ^1.6.0
version: 1.6.0
devDependencies:
nodemon:
specifier: ^3.1.14
@@ -25,8 +22,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.21.1
version: 8.21.1
specifier: ^8.21.0
version: 8.21.0
packages:
@@ -42,9 +39,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -66,10 +63,6 @@ packages:
supports-color:
optional: true
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -137,8 +130,8 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
sax@1.6.1:
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
semver@7.8.5:
@@ -172,8 +165,8 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
ws@8.21.1:
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -195,7 +188,7 @@ snapshots:
binary-extensions@2.3.0: {}
brace-expansion@5.0.9:
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
@@ -223,8 +216,6 @@ snapshots:
optionalDependencies:
supports-color: 5.5.0
eventsource-parser@3.1.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -256,7 +247,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.7
ms@2.1.3: {}
@@ -283,7 +274,7 @@ snapshots:
dependencies:
picomatch: 2.3.2
sax@1.6.1: {}
sax@1.6.0: {}
semver@7.8.5: {}
@@ -310,4 +301,4 @@ snapshots:
undefsafe@2.0.5: {}
ws@8.21.1: {}
ws@8.21.0: {}
-2
View File
@@ -1,2 +0,0 @@
minimumReleaseAgeExclude:
- brace-expansion@5.0.8 || 5.0.9
@@ -195,45 +195,21 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20"
style="display:inline-block;vertical-align:middle;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr>
<td width="20" height="20" align="center" valign="middle"
background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}"
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}"
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
{% if organization.initials %}{{organization.initials}}{% endif %}
</td>
</tr>
</table>
<span
style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
</span>
</div>
</td>
</tr>
{% if organization.sso-active %}
<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;">
"{{ 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.
</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>
</td>
</tr>
{% endif %}
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
@@ -0,0 +1,10 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”.
Accept invitation using this link:
{{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy!
The Penpot team.
@@ -1,17 +0,0 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:50 }}”.
{% if organization.sso-active %}
"{{ 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.
{% endif %}
Accept invitation using this link:
{{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy!
The Penpot team.
@@ -186,31 +186,10 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:50 }}”{% if
organization %}
part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}.</div>
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
</td>
</tr>
{% if organization.sso-active %}
<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;">
"{{ 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.
</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>
</td>
</tr>
{% endif %}
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
@@ -1,13 +1,6 @@
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 %}.
{% if organization.sso-active %}
"{{ 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.
{% endif %}
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
Accept invitation using this link:
@@ -1,231 +0,0 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Hi,
</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;">
"{{ 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.
</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>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>
@@ -1 +0,0 @@
“{{ organization-name|abbreviate:25 }}” uses single sign-on
@@ -1,8 +0,0 @@
Hi,
"{{ 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.
The Penpot team.
+1 -7
View File
@@ -39,10 +39,4 @@
{:permits 3}
:create-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}
:send-user-feedback/global
{:permits 4}
:send-user-feedback/by-profile
{:permits 1 :queue 3}}
{:permits 1 :queue 2 :timeout 60000}}
+2 -6
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env bash
export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-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
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
@@ -22,8 +21,6 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then
__worker_flag="enable-backend-worker"
fi
export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-password \
@@ -39,7 +36,6 @@ export PENPOT_FLAGS="\
enable-feature-fdata-objects-map \
enable-audit-log \
enable-transit-readable-response \
disable-remote-media-processing \
enable-demo-users \
enable-user-feedback \
disable-secure-session-cookies \
@@ -75,7 +71,7 @@ export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
export PENPOT_ADMIN_CONSOLE_URI=http://localhost:3000/admin-console
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
+19 -111
View File
@@ -459,10 +459,9 @@
(let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
(if (= status 200)
(let [data (json/decode body)
data {:token/access (get data :access_token)
:token/id (get data :id_token)
:token/type (get data :token_type)
:token/expires-in (get data :expires_in)}]
data {:token/access (get data :access_token)
:token/id (get data :id_token)
:token/type (get data :token_type)}]
(l/trc :hint "access token fetched"
:token-id (:token/id data)
:token-type (:token/type data)
@@ -620,9 +619,6 @@
(some? (: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
;; props can contain pm_ and utm_ prefixed query params.
(map? (:props state))
@@ -765,110 +761,20 @@
;; ORG SSO HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- non-blank-uri
[value]
(when-not (str/blank? value) value))
(defn organization-sso-discovery-uri
"Return the OIDC discovery URI from an organization SSO config."
[sso]
(non-blank-uri (:issuer sso)))
(defn prepare-organization-sso-provider
"Build an OIDC provider map dynamically from the Nitrate organization SSO config.
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
[cfg {:keys [client-id client-secret issuer]}]
(defn prepare-org-sso-provider
"Build an OIDC provider map dynamically from the Nitrate org SSO config.
Uses OIDC discovery via :base-url (or :issuer as fallback) when
token/auth/user URIs are absent."
[cfg {:keys [client-id client-secret base-url issuer scopes]}]
(prepare-oidc-provider cfg
{:type "oidc"
:client-id client-id
:client-secret client-secret
:base-uri (some-> (non-blank-uri issuer)
:base-uri (some-> (or base-url issuer)
(str/rtrim "/")
(str "/"))
:scopes default-oidc-scopes}))
(defn build-organization-sso-auth-redirect-uri
"Build the OIDC authorization redirect URI for an organization SSO config.
Raises if the config is incomplete or OIDC discovery fails."
[cfg sso & {:keys [dest-url organization-id provider]}]
(let [organization-id (or organization-id (:organization-id sso))
issuer (organization-sso-discovery-uri sso)
dest-url (or dest-url (str (cf/get :public-uri)))]
(when-not issuer
(ex/raise :type :validation
:code :invalid-sso-config
:hint "missing issuer"))
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
state-token (tokens/generate cfg {:iss "oidc"
:dest-url dest-url
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})]
(build-auth-redirect-uri oidc-provider state-token))))
(def ^:private probe-auth-code "penpot-sso-config-probe")
(defn- decode-token-error-response
[body]
(when (and (string? body) (pos? (count body)))
(try
(json/decode body)
(catch Throwable _ nil))))
(defn- token-endpoint-error
[response]
(some-> response :body decode-token-error-response :error d/name))
(defn- token-endpoint-error-description
[response]
(some-> response :body decode-token-error-response :error-description))
(defn- token-endpoint-valid-client-error?
"Token endpoint rejected the dummy auth code but accepted the client credentials."
[response]
(= "invalid_grant" (token-endpoint-error response)))
(defn- token-endpoint-invalid-client-error?
"Token endpoint rejected the client credentials."
[{:keys [status] :as response}]
(let [error (token-endpoint-error response)
description (str/lower (or (token-endpoint-error-description response) ""))]
(or (= status 401)
(#{"invalid_client" "unauthorized_client"} error)
(and (= error "access_denied")
(str/includes? description "unauthorized")))))
(defn- probe-organization-sso-client-credentials
"Probe the token endpoint with a dummy authorization code.
Valid client credentials are expected to answer with `invalid_grant`."
[cfg provider]
(let [params {:client_id (:client-id provider)
:client_secret (:client-secret provider)
:code probe-auth-code
:grant_type "authorization_code"
:redirect_uri (build-redirect-uri)}
req {:method :post
:headers {"content-type" "application/x-www-form-urlencoded"
"accept" "application/json"}
:uri (:token-uri provider)
:body (u/map->query-string params)}
response (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
(cond
(token-endpoint-valid-client-error? response) true
(token-endpoint-invalid-client-error? response) false
:else false)))
(defn is-organization-sso-config-valid?
"Return true when the SSO config can be discovered, can build a login URL,
and the client credentials are accepted by the token endpoint."
[cfg sso]
(try
(if (organization-sso-discovery-uri sso)
(let [provider (prepare-organization-sso-provider cfg sso)]
(and (build-organization-sso-auth-redirect-uri cfg sso :provider provider)
(probe-organization-sso-client-credentials cfg provider)))
false)
(catch Throwable _ false)))
:scopes (into default-oidc-scopes (or scopes #{}))
:skip-ssrf-check? true}))
(defn- auth-handler
[cfg {:keys [params] :as request}]
@@ -896,15 +802,17 @@
state (get params :state)
state (tokens/verify cfg {:token state :iss "oidc"})]
;; Organization SSO flow: state carries :dest-url — exchange the authorization
;; Org SSO flow: state carries :dest-url — exchange the authorization
;; code with the OIDC provider to verify authentication actually occurred.
(if-let [dest-url (:dest-url state)]
(let [organization-id (:organization-id state)
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
provider (prepare-organization-sso-provider cfg sso)
info (get-info cfg provider state code)
(let [team-id (:team-id state)
organization-id (:organization-id state)
sso (nitrate/call cfg :get-org-sso-by-team {:team-id team-id})
provider (prepare-org-sso-provider cfg sso)
;; verify token or throw error
_info (get-info cfg provider state code)
session (session/get-session request)
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
exp (ct/in-future {:hours 48})]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
-53
View File
@@ -1,53 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC 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?))))))
+3 -11
View File
@@ -748,17 +748,9 @@
(fmigr/upsert-migrations! conn file))
(let [file (encode-file cfg file)]
(try
(db/insert! conn :file
(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))))
(db/insert! conn :file
(file->params file)
(assoc opts ::db/return-keys false))
(->> (file->file-data-params file)
(fdata/upsert! cfg))
-4
View File
@@ -174,10 +174,6 @@
(assert-mark m :obj)
(let [size (read-long! input)]
(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)]
(read-bytes! input buff)
(fres/decode buff)))))
+2 -6
View File
@@ -119,9 +119,8 @@
[:allowed-origins {:optional true} [::sm/set :string]]
[:exporter-shared-key {:optional true} :string]
[:admin-console-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string]
[:media-processor-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string]
@@ -148,9 +147,6 @@
[:imagemagick-width-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]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
@@ -268,7 +264,7 @@
[:netty-io-threads {:optional true} ::sm/int]
[:admin-console-uri {:optional true} ::sm/uri]
[:nitrate-backend-uri {:optional true} ::sm/uri]
;; DEPRECATED
[:assets-storage-backend {:optional true} :keyword]
+12 -22
View File
@@ -419,19 +419,10 @@
:id ::change-email
:schema schema:change-email))
(def ^:private schema:organization-data
[:map
[:name ::sm/text]
[:initials {:optional true} [:maybe :string]]
[:logo {:optional true} [:maybe ::sm/uri]]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:sso-active {:optional true} [:maybe ::sm/boolean]]])
(def ^:private schema:invite-to-team
[:map
[:invited-by ::sm/text]
[:team ::sm/text]
[:organization {:optional true} [:maybe schema:organization-data]]
[:token ::sm/text]])
(def invite-to-team
@@ -440,28 +431,27 @@
:id ::invite-to-team
:schema schema:invite-to-team))
(def ^:private schema:invite-to-organization
(def ^:private schema:organization-data
[:map
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]]
[:avatar-bg-url [:maybe ::sm/uri]]])
(def ^:private schema:invite-to-org
[:map
[:invited-by ::sm/text]
[:user-name [:maybe ::sm/text]]
[:token ::sm/text]
[:organization schema:organization-data]])
(def invite-to-organization
"Organization member invitation email."
(def invite-to-org
"Org member invitation email."
(template-factory
:id ::invite-to-organization
:schema schema:invite-to-organization))
:id ::invite-to-org
:schema schema:invite-to-org))
(def ^:private schema:organization-setup-sso
[:map
[:organization-name ::sm/text]])
(def organization-setup-sso
"Email when an organization set up SSO"
(template-factory
:id ::organization-setup-sso
:schema schema:organization-setup-sso))
(def ^:private schema:renewal-notice
[:map
+3 -8
View File
@@ -24,7 +24,7 @@
:cause cause))))
(def sql:get-token-data
"SELECT perms, profile_id, expires_at, type
"SELECT perms, profile_id, expires_at
FROM access_token
WHERE id = ?
AND (expires_at IS NULL
@@ -42,19 +42,14 @@
(fn [request]
(let [{:keys [type claims]} (get request ::http/auth-data)]
(if (= :token type)
(let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool))
token-id (get claims :tid)]
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at)
(some? token-id)
(assoc ::id token-id)
(some? type)
(assoc ::type type))))
(assoc ::expires-at expires-at))))
(handler request)))))
+9 -19
View File
@@ -7,7 +7,6 @@
(ns app.http.assets
"Assets related handlers."
(:require
[app.binfile.common :as bfc]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.time :as ct]
@@ -32,8 +31,7 @@
#{"file-media-object"
"file-object-thumbnail"
"team-font-variant"
"file-data-fragment"
"organization"})
"file-data-fragment"})
(defn get-id
[{:keys [path-params]}]
@@ -43,7 +41,7 @@
(defn- get-file-media-object
[pool id]
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
@@ -110,21 +108,13 @@
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
[{:keys [::sto/storage] :as cfg} request kf]
(let [pool (::db/pool storage)
id (get-id request)
mobj (get-file-media-object pool id)]
(if (nil? mobj)
{::yres/status 404}
(let [file-id (:file-id mobj)
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})))))))
(let [pool (::db/pool storage)
id (get-id request)
mobj (get-file-media-object pool id)
sobj (sto/get-object storage (kf mobj))]
(if sobj
(serve-object cfg sobj)
{::yres/status 404})))
(defn file-objects-handler
"Handler that serves storage objects by file media id."
+1 -1
View File
@@ -31,7 +31,7 @@
(assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data (dissoc auth :token))
(assoc :request/auth-data auth)
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error
+4 -22
View File
@@ -65,25 +65,12 @@
:else
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]
(cond
(instance? IllegalArgumentException cause)
(ex/raise :type :validation
:code :malformed-json
:hint (ex-message cause)
:cause cause)
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
(instance? RequestTooBigException cause)
(ex/raise :type :validation
@@ -96,11 +83,6 @@
:hint (ex-message cause)
:cause cause)
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
:else
(errors/handle cause request)))]
+4 -4
View File
@@ -226,19 +226,19 @@
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count))))
(def ^:private sql:clear-organization-sso-sessions
(def ^:private sql:clear-org-sso-sessions
(str "UPDATE http_session_v2 "
"SET props = props #- ARRAY['~:sso', ?]::text[] "
"WHERE props IS NOT NULL "
"AND jsonb_exists(props -> '~:sso', ?)"))
(defn clear-organization-sso-sessions!
(defn clear-org-sso-sessions!
"Remove the SSO entry for organization-id from the props of every
session that currently holds it. The key is transit-encoded as the
string '~u<uuid>' under the '~:sso' path."
[pool organization-id]
(let [organization-key (str "~u" organization-id)]
(db/exec! pool [sql:clear-organization-sso-sessions organization-key organization-key])))
(let [org-key (str "~u" organization-id)]
(db/exec! pool [sql:clear-org-sso-sessions org-key org-key])))
(defn- renew-session?
[{:keys [id modified-at] :as session}]
+6 -9
View File
@@ -88,8 +88,7 @@
#{:session-id
:password
:old-password
:token
:client-secret})
:token})
(defn extract-utm-params
"Extracts additional data from params and namespace them under
@@ -154,7 +153,7 @@
;; COLLECTOR API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare prepare-context-from-request)
(declare ^:private prepare-context-from-request)
;; Defines a service that collects the audit/activity log using
;; internal database. Later this audit log can be transferred to
@@ -183,7 +182,7 @@
(def valid-event?
(sm/validator schema:event))
(defn prepare-context-from-request
(defn- prepare-context-from-request
"Prepare backend event context from request"
[request]
(let [client-event-origin (get-client-event-origin request)
@@ -337,9 +336,7 @@
(let [resultm (meta result)
request (-> params meta ::http/request)
profile-id (or (::profile-id resultm)
(some-> (:profile-id result)
(cond-> (string? (:profile-id result))
uuid/parse*))
(:profile-id result)
(::rpc/profile-id params)
uuid/zero)
@@ -414,7 +411,7 @@
(update :ip-addr d/nilv "0.0.0.0")
(update :props d/nilv {})
(update :context d/nilv {})
(update :source d/nilv "backend")
(assoc :source "backend")
(d/without-nils))]
(submit* cfg event)))
@@ -431,7 +428,7 @@
(update :profile-id d/nilv uuid/zero)
(update :props d/nilv {})
(update :context d/nilv {})
(update :source d/nilv "backend")
(assoc :source "backend")
(select-keys event-keys)
(check-event))]
(db/run! cfg append-audit-entry event))))
+4 -6
View File
@@ -335,7 +335,6 @@
::rpc/rlimit (ig/ref ::rpc/rlimit)
::setup/templates (ig/ref ::setup/templates)
::setup/props (ig/ref ::setup/props)
::setup/shared-keys (ig/ref ::setup/shared-keys)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)
@@ -468,11 +467,10 @@
::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
:nexus (cf/get :nexus-shared-key)
:admin-console (cf/get :admin-console-shared-key)
:exporter (cf/get :exporter-shared-key)
:media-processor (cf/get :media-processor-shared-key)}
{::setup/props (ig/ref ::setup/props)
:nexus (cf/get :nexus-shared-key)
:nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock
{}
+480 -37
View File
@@ -5,37 +5,316 @@
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.media
"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."
"Media & Font postprocessing."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.schema.openapi :as-alias oapi]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as-alias db]
[app.http.client :as http]
[app.media.local :as media.local]
[app.media.remote :as media.remote]
[app.media.sanitize :as sanitize]
[app.media.validation :as validation]
[app.storage :as-alias sto]
[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]
[datoteka.io :as io]))
[datoteka.fs :as fs]
[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
[system params]
(if (contains? cf/flags :remote-media-processing)
(media.remote/process system params)
(media.local/process system params)))
(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
@@ -59,8 +338,8 @@
:hint "seems like the url points to resource with unknown size"))
(-> {:size size :mtype mtype}
(validation/validate-media-type!)
(validation/validate-media-size!))))]
(validate-media-type!)
(validate-media-size!))))]
(let [{:keys [body] :as response}
(try
@@ -88,24 +367,188 @@
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
:cause cause)))]
:cause cause)))
(if body
(with-open [body body]
(let [{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
{: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)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
(when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
;; Sanitize: strip trailing data after image EOF markers
(let [new-size (sanitize/truncate-after-eof path mtype)]
{:path path
:mtype mtype
:size new-size})))
;; Sanitize: strip trailing data after image EOF markers
(let [new-size (sanitize/truncate-after-eof path mtype)]
{:path path
:mtype mtype
:size new-size}))))
;; No body - validation will raise appropriate error
(parse-and-validate response)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; 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))))))))
-366
View File
@@ -1,366 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC 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
View File
@@ -1,264 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC 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
View File
@@ -1,130 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC 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
View File
@@ -1,68 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC 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))
-3
View File
@@ -495,9 +495,6 @@
{:name "0151-mod-file-tagged-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
{:name "0152-improve-uuid-defaults-and-drop-extension"
: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")}])
@@ -1,29 +0,0 @@
-- Migration: Replace uuid_generate_v4() defaults with gen_random_uuid()
-- and remove uuid-ossp extension.
--
-- gen_random_uuid() is built into PostgreSQL >= 13 and requires no extension.
-- The application already generates IDs explicitly via uuid/next in all
-- code paths; this migration adds gen_random_uuid() as a safety-net default
-- instead of the extension-dependent uuid_generate_v4().
ALTER TABLE access_token ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE audit_log ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE comment ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE comment_thread ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE file ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE file_change ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE file_media_object ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE profile ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE project ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE project_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE scheduled_task_history ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE share_link ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE storage_object ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE task ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_access_request ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_font_variant ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_invitation ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE team_project_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid();
ALTER TABLE usage_quote ALTER COLUMN id SET DEFAULT gen_random_uuid();
+204 -264
View File
@@ -14,49 +14,19 @@
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.common.types.organization :as cto
:refer [schema:nitrate-sso]]
[app.common.uri :as u]
[app.common.types.organization :as cto]
[app.config :as cf]
[app.http.client :as http]
[app.http.session :as session]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[app.util.cache :as cache]
[clojure.core :as c]
[clojure.string :as str]
[integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- join-path-segments
"Build a single relative path from Nitrate URI segments, normalizing slashes."
[segments]
(let [path (->> segments (map str) (str/join "/"))]
(->> (str/split path #"/")
(remove str/blank?)
(str/join "/"))))
(defn- join-base-uri
"Join path segments to a base URI."
[base-uri & segments]
(u/join (u/ensure-path-slash base-uri)
(join-path-segments segments)))
(defn- generate-nitrate-uri
"Joins relative path segments to the Nitrate backend URI.
Segments must not start with `/`"
[& segments]
(apply join-base-uri (cf/get :admin-console-uri) segments))
(defn- generate-public-uri
"Joins relative path segments to the public backend URI.
Segments must not start with `/`"
[& segments]
(apply join-base-uri (cf/get :public-uri) segments))
(defn- request-builder
[cfg method uri shared-key profile-id request-params]
(fn []
@@ -143,7 +113,7 @@
(defn- request-to-nitrate
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
(let [shared-key (-> cfg ::setup/shared-keys :admin-console)
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
(with-retries 3)
(with-validate uri schema :throw-on-error? throw-on-error?))]
@@ -155,14 +125,14 @@
(defn call
[cfg method params]
(when (contains? cf/flags :admin-console)
(when (contains? cf/flags :nitrate)
(let [client (get cfg ::client)
method (get client method)]
(method params))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:organization-summary
(def ^:private schema:org-summary
[:map
[:id ::sm/uuid]
[:name ::sm/text]
@@ -173,6 +143,13 @@
[:id ::sm/uuid]
[:is-your-penpot :boolean]]]]])
(def ^:private schema:profile-org
[:map
[:is-member :boolean]
[:organization-id {:optional true} [:maybe ::sm/uuid]]
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
;; TODO Unify with schemas on backend/src/app/http/management.clj
(def ^:private schema:timestamp
(sm/type-schema
@@ -189,13 +166,6 @@
:decode/json ct/inst
: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
[:map {:title "Subscription"}
[:id ::sm/text]
@@ -253,52 +223,60 @@
[:map
[:licenses ::sm/boolean]])
(defn- get-team-organization-api
(defn- get-team-org-api
[cfg {:keys [team-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri "api/teams/" team-id)
cto/schema:team-with-organization params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id)
cto/schema:team-with-organization params)))
(defn- get-organization-membership-api
(defn- get-org-membership-api
[cfg {:keys [profile-id organization-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri
"api/organizations/"
organization-id
"members/"
profile-id)
schema:profile-organization params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/members/"
profile-id)
schema:profile-org params)))
(defn- get-organization-membership-by-team-api
(defn- get-org-membership-by-team-api
[cfg {:keys [profile-id team-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri
"api/teams/"
team-id
"users/"
profile-id)
schema:profile-organization params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id
"/users/"
profile-id)
schema:profile-org params)))
(defn- get-organization-summary-api
(defn- get-org-summary-api
[cfg {:keys [organization-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri
"api/organizations/"
organization-id
"summary")
schema:organization-summary params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/summary")
schema:org-summary params)))
(defn- get-owned-organizations-api
(defn- get-owned-orgs-api
[cfg {:keys [profile-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri
"api/users/"
profile-id
"owned-organizations")
[:vector schema:organization-summary]
params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/users/"
profile-id
"/owned-organizations")
[:vector schema:org-summary]
params)))
(def ^:private schema:organization-summary-counts
(def ^:private schema:org-summary-counts
[:map
[:id ::sm/uuid]
[:name ::sm/text]
@@ -308,94 +286,101 @@
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
(defn- get-owned-organizations-summary-api
(defn- get-owned-orgs-summary-api
[cfg {:keys [profile-id] :as params}]
(let [organizations (request-to-nitrate cfg :get
(generate-nitrate-uri
"api/users/"
profile-id
"owned-organizations-summary")
[:vector schema:organization-summary-counts]
params)]
(mapv (fn [organization]
(if-let [logo-id (:logo-id organization)]
(assoc organization :custom-photo (generate-public-uri "assets/by-id/" logo-id))
organization))
organizations)))
(let [baseuri (cf/get :nitrate-backend-uri)
orgs (request-to-nitrate cfg :get
(str baseuri
"/api/users/"
profile-id
"/owned-organizations-summary")
[:vector schema:org-summary-counts]
params)]
(mapv (fn [org]
(if-let [logo-id (:logo-id org)]
(assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id))
org))
orgs)))
(defn- cleanup-deleted-penpot-user-api
[cfg {:keys [profile-id] :as params}]
(request-to-nitrate cfg :post
(generate-nitrate-uri
"api/users/"
profile-id
"cleanup-after-deletion")
nil params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :post
(str baseuri
"/api/users/"
profile-id
"/cleanup-after-deletion")
nil params)))
(defn- set-team-organization-api
(defn- set-team-org-api
[cfg {:keys [organization-id team-id is-default] :as params}]
(let [params (assoc params :request-params {:team-id team-id
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:team-id team-id
:is-your-penpot (true? is-default)})
team (request-to-nitrate cfg :post
(generate-nitrate-uri
"api/organizations/"
organization-id
"add-team")
(str baseuri
"/api/organizations/"
organization-id
"/add-team")
cto/schema:team-with-organization params)
custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])]
(generate-public-uri "assets/by-id/" logo-id))]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
(cond-> team
custom-photo
(assoc-in [:organization :custom-photo] custom-photo))))
(defn- add-profile-to-organization-api
(defn- add-profile-to-org-api
[cfg {:keys [profile-id organization-id team-id email] :as params}]
(let [request-params (cond-> {:user-id profile-id :team-id team-id}
(let [baseuri (cf/get :nitrate-backend-uri)
request-params (cond-> {:user-id profile-id :team-id team-id}
(some? email) (assoc :email email))
params (assoc params :request-params request-params)]
(request-to-nitrate cfg :post
(generate-nitrate-uri
"api/organizations/"
organization-id
"add-user")
schema:profile-organization params)))
(str baseuri
"/api/organizations/"
organization-id
"/add-user")
schema:profile-org params)))
(defn- remove-profile-from-organization-api
[cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}]
(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)]
(defn- remove-profile-from-org-api
[cfg {:keys [profile-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:user-id profile-id})]
(request-to-nitrate cfg :post
(generate-nitrate-uri
"api/organizations/"
organization-id
"remove-user")
(str baseuri
"/api/organizations/"
organization-id
"/remove-user")
nil params)))
(defn- remove-team-from-organization-api
(defn- remove-team-from-org-api
[cfg {:keys [team-id organization-id] :as params}]
(let [params (assoc params :request-params {:team-id team-id})]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:team-id team-id})]
(request-to-nitrate cfg :post
(generate-nitrate-uri
"api/organizations/"
organization-id
"remove-team")
(str baseuri
"/api/organizations/"
organization-id
"/remove-team")
nil params)))
(defn- delete-team-api
[cfg {:keys [team-id] :as params}]
(request-to-nitrate cfg :delete
(generate-nitrate-uri "api/teams/" team-id)
nil params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :delete
(str baseuri
"/api/teams/"
team-id)
nil params)))
(defn- get-subscription-api
[cfg {:keys [profile-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri "api/subscriptions/" profile-id)
schema:subscription params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/subscriptions/"
profile-id)
schema:subscription params)))
(def ^:private schema:subscription-warning
[:maybe
@@ -407,79 +392,80 @@
(defn- get-subscription-warning-api
[cfg {:keys [penpot-id profile-id] :as params}]
(let [penpot-id (or penpot-id profile-id)]
(let [baseuri (cf/get :nitrate-backend-uri)
penpot-id (or penpot-id profile-id)]
(request-to-nitrate cfg :get
(generate-nitrate-uri "api/subscription-warning/" penpot-id)
(str baseuri
"/api/subscription-warning/"
penpot-id)
schema:subscription-warning params)))
(defn- get-connectivity-api
[cfg params]
(request-to-nitrate cfg :get
(generate-nitrate-uri "api/connectivity")
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))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/connectivity")
schema:connectivity params)))
(def ^:private schema:redeem-result
[:map
[:cancel-at [:maybe schema:timestamp]]])
(defn- get-organization-permissions-api
(defn- get-org-permissions-api
[cfg {:keys [organization-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri
"api/organizations/"
organization-id
"permissions")
[:map
[:organization-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:permissions [:map-of :keyword :string]]]
params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/permissions")
[:map
[:organization-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:permissions [:map-of :keyword :string]]]
params)))
(defn- get-organization-sso-api
"Fetches the SSO configuration for an organization from Nitrate."
[cfg {:keys [organization-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri
"api/organizations/"
organization-id
"sso")
schema:nitrate-sso
params))
(def ^:private schema:nitrate-sso
[:map
[:organization-id ::sm/uuid]
[:active [:maybe :boolean]]
[:provider [:maybe :string]]
[:client-id [:maybe :string]]
[:base-url [:maybe :string]]
[:client-secret [:maybe :string]]
[:issuer [:maybe :string]]
[:scopes [:maybe [::sm/set ::sm/text]]]])
(defn- get-organization-sso-by-team-api
(defn- get-org-sso-by-team-api
[cfg {:keys [team-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri "api/teams/" team-id "sso")
schema:nitrate-sso
params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id
"/sso")
schema:nitrate-sso
params)))
(defn- get-organization-members-api
(defn- get-org-members-api
[cfg {:keys [organization-id] :as params}]
(request-to-nitrate cfg :get
(generate-nitrate-uri
"api/organizations/"
organization-id
"members-list")
[:vector ::sm/uuid]
params))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/members-list")
[:vector ::sm/uuid]
params)))
(defn- redeem-activation-code-api
[cfg params]
(request-to-nitrate cfg :post
(generate-nitrate-uri "api/activation-codes/redeem")
schema:redeem-result
(assoc params :throw-on-error? true)))
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :post
(str baseuri "/api/activation-codes/redeem")
schema:redeem-result
(assoc params :throw-on-error? true))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
@@ -487,86 +473,40 @@
(defmethod ig/init-key ::client
[_ cfg]
(when (contains? cf/flags :admin-console)
{:get-team-organization (partial get-team-organization-api cfg)
:set-team-organization (partial set-team-organization-api cfg)
:get-organization-membership (partial get-organization-membership-api cfg)
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
:get-organization-summary (partial get-organization-summary-api cfg)
:get-owned-organizations (partial get-owned-organizations-api cfg)
:get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg)
:get-organization-members (partial get-organization-members-api cfg)
(when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org-api cfg)
:set-team-org (partial set-team-org-api cfg)
:get-org-membership (partial get-org-membership-api cfg)
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
:get-org-summary (partial get-org-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-api cfg)
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
:get-org-members (partial get-org-members-api cfg)
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg)
:add-profile-to-organization (partial add-profile-to-organization-api cfg)
:remove-profile-from-organization (partial remove-profile-from-organization-api cfg)
:get-organization-permissions (partial get-organization-permissions-api cfg)
:get-organization-sso-by-team (partial get-organization-sso-by-team-api cfg)
:get-organization-sso (partial get-organization-sso-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
:get-org-permissions (partial get-org-permissions-api cfg)
:get-org-sso-by-team (partial get-org-sso-by-team-api cfg)
:delete-team (partial delete-team-api cfg)
:remove-team-from-organization (partial remove-team-from-organization-api cfg)
:remove-team-from-org (partial remove-team-from-org-api cfg)
:get-subscription (partial get-subscription-api cfg)
:get-subscription-warning (partial get-subscription-warning-api cfg)
:connectivity (partial get-connectivity-api cfg)
:get-identity (partial get-identity-api cfg)
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defonce ^:private team-organization-owner-cache
;; Short TTL: permission checks run on the read path, so we avoid an
;; 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.
(cache/create :expire "30s" :max-size 2048))
(defn- nitrate-client?
"True when `cfg` is a config map carrying the nitrate client (i.e. not
a raw db connection/pool passed by an internal caller)."
[cfg]
(and (map? cfg) (some? (get cfg ::client))))
(def ^:private cache-miss ::no-organization-owner)
(defn- get-team-organization-owner-id
"Returns the organization owner-id for `team-id`, or nil. Cached
briefly, including negative results (teams with no organization) so
repeated unauthorized probes don't each hit nitrate."
[cfg team-id]
(let [owner-id (cache/get team-organization-owner-cache team-id
(fn [team-id]
(let [team-with-organization (call cfg :get-team-organization {:team-id team-id})]
(or (get-in team-with-organization [:organization :owner-id])
cache-miss))))]
(when-not (= owner-id cache-miss)
owner-id)))
(defn organization-owner-of-team?
"True if `profile-id` is the owner of the organization that owns
`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
nitrate client; raw db connections/pools yield false so internal
callers are unaffected. Returns false when the :nitrate flag is off."
[cfg profile-id team-id]
(boolean
(when (and (contains? cf/flags :admin-console)
(nitrate-client? cfg)
(some? team-id)
(some? profile-id))
(= profile-id (get-team-organization-owner-id cfg team-id)))))
(defn sso-session-authorized?
"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
"Fetches the org-SSO config for the given team and checks whether
the HTTP request has a valid session entry for it. Returns a map
with :authorized and :sso keys."
[cfg organization-id team-id request]
(let [session (session/get-session request)
sso (if organization-id
(call cfg :get-organization-sso {:organization-id organization-id})
(call cfg :get-organization-sso-by-team {:team-id team-id}))]
[cfg team-id request]
(let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})]
(if-not (:active sso)
{:authorized true :sso sso}
(if-not (str/blank? (:issuer sso))
(if (or (:issuer sso) (:base-url sso))
(let [props (:props session)
sso-map (get props :sso {})
organization-id (:organization-id sso)
@@ -596,21 +536,21 @@
:cause cause)
profile)))))
(defn add-organization-info-to-team
(defn add-org-info-to-team
"Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or organization data is nil.
Returns the original team unchanged if the request fails or org data is nil.
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
[cfg team params]
(try
(let [params (assoc (or params {}) :team-id (:id team))
team-with-organization (call cfg :get-team-organization params)
organization (:organization team-with-organization)]
(if (some? organization)
(-> (cto/apply-organization team (assoc organization :custom-photo
(when-let [logo-id (:logo-id organization)]
(generate-public-uri "assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
team-with-org (call cfg :get-team-org params)
org (:organization team-with-org)]
(if (some? org)
(-> (cto/apply-organization team (assoc org :custom-photo
(when-let [logo-id (:logo-id org)]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
team))
(catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type))
@@ -630,10 +570,10 @@
:team-id (:id team)
:organization-id (:organization-id params)
:is-default (:is-default params))
result (call cfg :set-team-organization params)]
result (call cfg :set-team-org params)]
(when (nil? result)
(ex/raise :type :internal
:code :failed-to-set-team-organization
:code :failed-to-set-team-org
:context {:team-id (:id team)
:organization-id (:organization-id params)}))
team))
+34 -41
View File
@@ -250,71 +250,64 @@
f))
(defonce ^:private organization-sso-auth-cache
(defonce ^:private org-sso-auth-cache
(cache/create :expire "15m" :max-size 1024))
(defn invalidate-organization-sso-cache-by-organization!
"Invalidates all organization-SSO authorization cache entries for the given organization-id."
(defn invalidate-org-sso-cache-by-org!
"Invalidates all org-SSO authorization cache entries for the given organization-id."
[organization-id]
(cache/invalidate-if organization-sso-auth-cache #(= (:organization-id %) organization-id)))
(cache/invalidate-if org-sso-auth-cache #(= (:organization-id %) organization-id)))
(defn- wrap-nitrate-sso
"Enforce Nitrate organization SSO authentication for RPC handlers.
Resolves the organization/team context from request params using priority order:
1. Explicit :organization-id param
2. Explicit :team-id param
3. Explicit :project-id param -> lookup project.team_id
4. Explicit :file-id param -> lookup file's team via join
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
Resolves the team context from request params using priority order:
1. Explicit :team-id param
2. Explicit :project-id param → lookup project.team_id
3. Explicit :file-id param lookup file's team via join
4. :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 organization's
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
Once team-id is resolved, checks if the user is authorized within that org's SSO
session using nitrate/sso-session-authorized?. Results are cached by [profile-id cache-ref]
for 15 minutes to avoid repeated lookups.
Only activates when:
- Nitrate flag is enabled
- Endpoint requires authentication (::auth true by default)
- Endpoint is not marked with ::nitrate/organization-sso false
- Endpoint is not marked with ::nitrate/org-sso false
Raises :nitrate-sso-required error if user is not authorized in the organization."
Raises :nitrate-sso-required error if user is not authorized in the org."
[_ f mdata]
(if (and (contains? cf/flags :admin-console)
(if (and (contains? cf/flags :nitrate)
(::auth mdata true) ;; only for endpoints that needs auth
(::nitrate/sso mdata true))
(fn [cfg params]
;; Resolve team/project/file from explicit keys or from :id via metadata
(let [profile-id (::profile-id params)
organization-id (uuid/coerce (:organization-id params))
id-type (::id-type mdata)
id (uuid/coerce (:id params))
team-id (or (uuid/coerce (:team-id params))
(when (= id-type :team) id))
project-id (or (uuid/coerce (:project-id params))
(when (= id-type :project) id))
file-id (or (uuid/coerce (:file-id params))
(when (= id-type :file) id))]
(if (and profile-id
(or organization-id team-id project-id file-id))
(let [cache-ref (or organization-id team-id project-id file-id)
(let [id-type (::id-type mdata)
id (uuid/coerce (:id params))
team-id (or (uuid/coerce (:team-id params))
(when (= id-type :team) id))
project-id (or (uuid/coerce (:project-id params))
(when (= id-type :project) id))
file-id (or (uuid/coerce (:file-id params))
(when (= id-type :file) id))]
(if (or team-id project-id file-id)
(let [cache-ref (or team-id project-id file-id)
profile-id (::profile-id params)
cache-key [profile-id cache-ref]
cached (cache/get organization-sso-auth-cache cache-key)
cached (cache/get org-sso-auth-cache cache-key)
result (if (some? cached)
cached
(let [team-id (when-not organization-id
(or team-id
(when project-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
(:id (teams/get-team-for-file cfg file-id))))
(let [team-id (or team-id
(when project-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
(:id (teams/get-team-for-file cfg file-id)))
request (-> (meta params) (get ::http/request))
{:keys [authorized sso]} (if organization-id
(nitrate/sso-session-authorized? cfg organization-id nil request)
(nitrate/sso-session-authorized? cfg nil team-id request))
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)
entry {:authorized authorized
:organization-id (:organization-id sso)}]
(when authorized
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
(cache/get org-sso-auth-cache cache-key (constantly entry)))
entry))]
(if (:authorized result)
(f cfg params)
@@ -430,7 +423,7 @@
[cfg]
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
mods (cond->> (list 'app.rpc.management.exporter)
(contains? cf/flags :admin-console)
(contains? cf/flags :nitrate)
(cons 'app.rpc.management.nitrate))]
(->> (apply sv/scan-ns mods)
@@ -37,8 +37,7 @@
(let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token")
token (tokens/generate cfg {:iss token-iss
token (tokens/generate cfg {:iss "access-token"
:uid profile-id
:iat created-at
:tid token-id})
+2 -11
View File
@@ -8,7 +8,6 @@
(:require
[app.auth :as auth]
[app.auth.oidc :as oidc]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
@@ -183,7 +182,6 @@
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
nil))]
(passwords/validate-password password)
(->> (validate-token token)
(update-password conn))
@@ -242,9 +240,6 @@
:code :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))
(ex/raise :type :restriction
:code :email-has-permanent-bounces
@@ -263,8 +258,7 @@
(validate-register-attempt! cfg params)
(let [email (profile/clean-email email)
profile (profile/get-profile-by-email pool email)
fullname (d/normalize-string fullname)]
profile (profile/get-profile-by-email pool email)]
;; SECURITY: refuse to issue a prepared-register token when an active
;; profile already exists for this email.
@@ -365,9 +359,6 @@
is-active (:is-active params false)
theme (:theme params nil)
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)
(:google/picture props)
@@ -376,7 +367,7 @@
(import-profile-picture cfg))
params {:id id
:fullname fullname
:fullname (:fullname params)
:email email
:auth-backend backend
:lang locale
+20 -9
View File
@@ -19,7 +19,7 @@
[app.http.sse :as sse]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media.validation :as media.v]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.media :as media-cmd]
@@ -74,8 +74,8 @@
::doc/changes [["2.12" "Remove version parameter, only one version is supported"]]
::webhooks/event? true
::sm/params schema:export-binfile}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(files/check-read-permissions! cfg profile-id file-id)
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(files/check-read-permissions! pool profile-id file-id)
(sse/response (partial export-binfile cfg params)))
;; --- Command: import-binfile
@@ -118,38 +118,48 @@
(def ^:private schema:import-binfile
[:and
[:map {:title "import-binfile" :closed true}
[:map {:title "import-binfile"}
[:name [:or [:string {:max 250}]
[:map-of ::sm/uuid [:string {:max 250}]]]]
[:project-id ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:version {:optional true} ::sm/int]
[:file {:optional true} media.v/schema:upload]
[:file {:optional true} media/schema:upload]
[:upload-id {:optional true} ::sm/uuid]]
[:fn {:error/message "one of :file or :upload-id is required"}
(fn [{:keys [file upload-id]}]
(or (some? file) (some? upload-id)))]])
(sv/defmethod ::import-binfile
"Import a penpot file in a binary format.
"Import a penpot file in a binary format. If `file-id` is provided,
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
or as an `upload-id` referencing a completed chunked-upload session,
which allows importing files larger than the multipart size limit.
"
{::doc/added "1.15"
::doc/changes [["1.20" "Set default version to 3"]
["2.15" "Add upload-id param for chunked upload support"]]
::doc/changes ["1.20" "Add file-id param for in-place import"
"1.20" "Set default version to 3"
"2.15" "Add upload-id param for chunked upload support"]
::webhooks/event? true
::sse/stream? true
::sm/params schema:import-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
(projects/check-edition-permissions! pool profile-id project-id)
(let [version (or version 3)
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
cfg (cond-> cfg
(uuid? file-id)
(assoc ::bfc/file-id file-id))
params
(if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
@@ -164,5 +174,6 @@
(with-meta
(sse/response (partial import-binfile cfg params))
{::audit/props {:file nil
:file-id file-id
:generated-by (:generated-by manifest)
:referer (:referer manifest)}})))
+50 -43
View File
@@ -230,8 +230,8 @@
{::doc/added "1.15"
::sm/params schema:get-comment-threads}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comment-threads conn profile-id file-id))))
(defn- get-comment-threads-sql
@@ -328,8 +328,8 @@
{::doc/added "1.15"
::sm/params schema:get-comment-thread}
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
(decode-row)))))
@@ -347,9 +347,9 @@
{::doc/added "1.15"
::sm/params schema:get-comments}
[cfg {:keys [::rpc/profile-id thread-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [file-id]} (get-comment-thread conn thread-id)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comments conn thread-id)))))
(def sql:get-comments
@@ -371,44 +371,51 @@
;; --- COMMAND: Get file comments users
;; All the profiles that had comment the file, plus the current
;; profile.
;; All the profiles that had comment any of the given files, plus the
;; current profile. The :file-id param is a set (max 100) of file ids
;; so the same method serves both single-file and dashboard batch
;; callers.
(def ^:private sql:file-comment-users
"WITH available_profiles AS (
SELECT DISTINCT owner_id AS id
FROM comment
WHERE thread_id IN (SELECT id FROM comment_thread WHERE file_id=?)
)
SELECT p.id,
p.email,
p.fullname AS name,
p.fullname AS fullname,
p.photo_id,
p.is_active
FROM profile AS p
WHERE p.id IN (SELECT id FROM available_profiles) OR p.id=?")
SELECT DISTINCT c.owner_id AS id
FROM comment AS c
INNER JOIN comment_thread AS ct ON (ct.id = c.thread_id)
WHERE ct.file_id = ANY(?::uuid[])
)
SELECT p.id,
p.email,
p.fullname AS name,
p.fullname AS fullname,
p.photo_id,
p.is_active
FROM profile AS p
WHERE p.id IN (SELECT id FROM available_profiles) OR p.id=?")
(defn get-file-comments-users
[conn file-id profile-id]
(db/exec! conn [sql:file-comment-users file-id profile-id]))
(defn- get-file-comments-users
[conn file-ids profile-id]
(let [file-ids (db/create-array conn "uuid" file-ids)]
(db/exec! conn [sql:file-comment-users file-ids profile-id])))
(def ^:private
schema:get-profiles-for-file-comments
[:map {:title "get-profiles-for-file-comments"}
[:file-id ::sm/uuid]
[:file-id [::sm/set {:max 100} ::sm/uuid]]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::get-profiles-for-file-comments
"Retrieves a list of profiles with limited set of properties of all
participants on comment threads of the file."
participants on comment threads of the given file(s)."
{::doc/added "1.15"
::doc/changes ["1.15" "Imported from queries and renamed."]
::doc/changes ["1.15.0" "Imported from queries and renamed."
"2.17.1" "Schema widened: :file-id now accepts a set (max 100) of file ids."]
::sm/params schema:get-profiles-for-file-comments}
[cfg {:keys [::rpc/profile-id file-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(get-file-comments-users conn file-id profile-id))))
(db/run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(doseq [fid file-id]
(files/check-comment-permissions! cfg profile-id fid share-id))
(get-file-comments-users conn file-id profile-id))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MUTATION COMMANDS
@@ -534,9 +541,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread-status
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(upsert-comment-thread-status! conn profile-id id)))
;; --- COMMAND: Update Comment Thread
@@ -552,9 +559,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id]}]
[{:keys [::db/conn]} {:keys [::rpc/profile-id id is-resolved share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:is-resolved is-resolved}
{:id id})
@@ -582,7 +589,7 @@
{:keys [team-id project-id] :as file}
(get-file cfg file-id page-id)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
@@ -653,7 +660,7 @@
{:keys [file-id page-id] :as thread}
(get-comment-thread conn thread-id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
;; Don't allow edit comments to not owners
(when-not (= owner-id profile-id)
@@ -690,9 +697,9 @@
{::doc/added "1.15"
::sm/params schema:delete-comment-thread
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
@@ -713,14 +720,14 @@
{::doc/added "1.15"
::sm/params schema:delete-comment
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [owner-id thread-id] :as comment}
(get-comment conn id ::sql/for-update true)
{:keys [file-id]}
(get-comment-thread conn thread-id)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
@@ -743,9 +750,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread-position
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:position (db/pgpoint position)
@@ -767,9 +774,9 @@
{::doc/added "1.15"
::sm/params schema:update-comment-thread-frame
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:frame-id frame-id}
@@ -68,8 +68,7 @@
[: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]])
[:hint {:optional true} ::sm/text]])
(def ^:private schema:get-error-reports-result
[:map
@@ -105,7 +104,7 @@
"FROM server_error_report"))
(defn- build-list-query
[{:keys [since since-id source profile-id kind tenant version hint until limit]
[{:keys [since since-id source profile-id kind tenant version hint limit]
:or {limit default-limit}}]
(let [source-id (when source (name->source source))
clauses (keep identity
@@ -127,17 +126,14 @@
{: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]})])
:params [since (or since-id 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"
" ORDER BY created_at DESC, id DESC"
" LIMIT ?")]
(into [sql] (concat sql-params [limit]))))
@@ -178,7 +174,6 @@
(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
+3 -6
View File
@@ -14,25 +14,22 @@
[app.db :as db]
[app.email :as eml]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
(declare ^:private send-user-feedback!)
(def schema:send-user-feedback
(def ^:private schema:send-user-feedback
[:map {:title "send-user-feedback"}
[:subject [:string {:max 500}]]
[:content [:string {:max 2500}]]
[:type {:optional true} :string]
[:error-href {:optional true} [:string {:max 2500}]]
[:error-report {:optional true} [:string {:max 1048576}]]])
[:error-report {:optional true} :string]])
(sv/defmethod ::send-user-feedback
{::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id]
[:send-user-feedback/global]]
::doc/added "1.18"
{::doc/added "1.18"
::sm/params schema:send-user-feedback}
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
(when-not (contains? cf/flags :user-feedback)
+24 -55
View File
@@ -84,10 +84,10 @@
(perms/make-edition-predicate-fn bfc/get-file-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn perms/get-file-read-permissions))
(perms/make-read-predicate-fn bfc/get-file-permissions))
(def has-comment-permissions?
(perms/make-comment-predicate-fn perms/get-file-read-permissions))
(perms/make-comment-predicate-fn bfc/get-file-permissions))
(def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?))
@@ -99,8 +99,8 @@
;; explicit comment permissions through the share-id
(defn check-comment-permissions!
[cfg profile-id file-id share-id]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
[conn profile-id file-id share-id]
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
can-read (has-read-permissions? perms)
can-comment (has-comment-permissions? perms)]
(when-not (or can-read can-comment)
@@ -152,17 +152,15 @@
(defn- get-minimal-file-with-perms
[cfg {:keys [:id ::rpc/profile-id]}]
(let [mfile (get-minimal-file cfg id)
perms (perms/get-file-read-permissions cfg profile-id id)]
perms (bfc/get-file-permissions cfg profile-id id)]
(assoc mfile :permissions perms)))
(defn get-file-etag
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
(ct/format-inst modified-at :iso)
"/"
(uri/map->query-string permissions)
"/"
(some-> deleted-at (ct/format-inst :iso))))
(uri/map->query-string permissions)))
(sv/defmethod ::get-file
"Retrieve a file by its ID. Only authenticated users."
@@ -173,7 +171,7 @@
::sm/params schema:get-file
::sm/result schema:file-with-permissions
::db/transaction true}
[cfg {:keys [::rpc/profile-id id project-id] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}]
;; The COND middleware makes initial request for a file and
;; permissions when the incoming request comes with an
;; ETAG. When ETAG does not matches, the request is resolved
@@ -181,10 +179,10 @@
;; will be already prefetched and we just reuse them instead
;; of making an additional database queries.
(let [perms (or (:permissions (::cond/object params))
(perms/get-file-read-permissions cfg profile-id id))]
(bfc/get-file-permissions conn profile-id id))]
(check-read-permissions! perms)
(let [team (teams/get-team cfg
(let [team (teams/get-team conn
:profile-id profile-id
:project-id project-id
:file-id id)
@@ -244,7 +242,7 @@
::sm/result schema:file-fragment}
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
(db/run! cfg (fn [cfg]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
(let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)]
(check-read-permissions! perms)
(-> (get-file-fragment cfg file-id fragment-id)
(rph/with-http-cache long-cache-duration))))))
@@ -288,7 +286,7 @@
::sm/params schema:get-project-files
::sm/result schema:files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}]
(projects/check-read-permissions! cfg profile-id project-id)
(projects/check-read-permissions! pool profile-id project-id)
(get-project-files pool project-id))
;; --- COMMAND QUERY: has-file-libraries
@@ -306,7 +304,7 @@
::sm/result ::sm/boolean}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! cfg profile-id file-id)
(check-read-permissions! pool profile-id file-id)
(get-has-file-libraries conn file-id)))
(def ^:private sql:has-file-libraries
@@ -339,7 +337,7 @@
::sm/result ::sm/int}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! cfg profile-id file-id)
(check-read-permissions! pool profile-id file-id)
(get-library-usage conn file-id)))
(def ^:private sql:get-library-usage
@@ -389,7 +387,7 @@
:code :params-validation
:hint "page-id is required when object-id is provided"))
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
file (bfc/get-file cfg file-id :read-only? true)
proj (db/get conn :project {:id (:project-id file)})
@@ -440,8 +438,8 @@
::sm/params schema:get-page}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/tx-run! cfg
(fn [cfg]
(check-read-permissions! cfg profile-id file-id share-id)
(fn [{:keys [::db/conn] :as cfg}]
(check-read-permissions! conn profile-id file-id share-id)
(get-page cfg (assoc params :profile-id profile-id)))))
;; --- COMMAND QUERY: get-team-shared-files
@@ -564,7 +562,7 @@
(defn- get-team-shared-files
[{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}]
(teams/check-read-permissions! cfg profile-id team-id)
(teams/check-read-permissions! conn profile-id team-id)
(let [process-row
(fn [{:keys [id library-file-ids]}]
@@ -677,8 +675,8 @@
::sm/params schema:get-file-stats
::sm/result schema:get-file-stats-result
::db/transaction true}
[cfg {:keys [::rpc/profile-id id]}]
(check-read-permissions! cfg profile-id id)
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
(check-read-permissions! conn profile-id id)
(get-file-stats cfg id))
@@ -721,7 +719,7 @@
::sm/params schema:get-library-file-references}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! cfg profile-id file-id)
(check-read-permissions! conn profile-id file-id)
(get-library-file-references conn file-id)))
;; --- COMMAND QUERY: get-team-recent-files
@@ -765,7 +763,7 @@
::sm/params schema:get-team-recent-files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(teams/check-read-permissions! cfg profile-id team-id)
(teams/check-read-permissions! conn profile-id team-id)
(get-team-recent-files conn team-id)))
@@ -810,8 +808,8 @@
{::doc/added "2.12"
::sm/params schema:get-team-deleted-files}
[cfg {:keys [::rpc/profile-id team-id]}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(teams/check-read-permissions! cfg profile-id team-id)
(db/run! cfg (fn [{:keys [::db/conn]}]
(teams/check-read-permissions! conn profile-id team-id)
(get-team-deleted-files conn team-id))))
;; --- COMMAND QUERY: get-file-info
@@ -1069,25 +1067,6 @@
[cfg {:keys [::rpc/profile-id] :as params}]
(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
(def sql:link-file-to-library
@@ -1123,14 +1102,6 @@
(check-edition-permissions! conn profile-id file-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)
(bfc/get-libraries cfg [library-id]))
@@ -1155,7 +1126,6 @@
[{: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 library-id)
(check-library-team-ownership! conn file-id library-id)
(unlink-file-from-library conn params)
nil)
@@ -1180,7 +1150,6 @@
[{: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 library-id)
(check-library-team-ownership! conn file-id library-id)
(update-sync conn params))
;; --- MUTATION COMMAND: ignore-sync
@@ -22,7 +22,6 @@
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.util.services :as sv]))
@@ -34,8 +33,8 @@
{::doc/added "1.20"
::sm/params schema:get-file-snapshots}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! cfg profile-id file-id)
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-read-permissions! conn profile-id file-id)
(fsnap/get-visible-snapshots conn file-id))))
;; --- COMMAND QUERY: get-file-snapshot
@@ -53,8 +52,8 @@
::sm/params schema:get-file-snapshot
::sm/result files/schema:file-with-permissions
::db/transaction true}
[cfg {:keys [::rpc/profile-id file-id id] :as params}]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id)]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
(files/check-read-permissions! perms)
(let [snapshot (fsnap/get-snapshot cfg file-id id)]
(when-not snapshot
@@ -21,7 +21,7 @@
[app.db.sql :as-alias sql]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media.validation :as media.v]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
@@ -85,7 +85,7 @@
::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}]
(dm/with-open [conn (db/open pool)]
(files/check-read-permissions! cfg profile-id file-id)
(files/check-read-permissions! conn profile-id file-id)
(if tag
(get-object-thumbnails-by-tag conn file-id tag)
(get-object-thumbnails conn file-id))))
@@ -197,9 +197,9 @@
::sm/params schema:get-file-data-for-thumbnail
::sm/result schema:partial-file}
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
(db/run! cfg (fn [cfg]
(files/check-read-permissions! cfg profile-id file-id)
(let [team (teams/get-team cfg
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! conn profile-id file-id)
(let [team (teams/get-team conn
:profile-id profile-id
:file-id file-id)
file (bfc/get-file cfg file-id
@@ -275,7 +275,7 @@
[:map {:title "create-file-object-thumbnail"}
[:file-id ::sm/uuid]
[:object-id [:string {:max 250}]]
[:media media.v/schema:upload]
[:media media/schema:upload]
[:tag {:optional true} [:string {:max 50}]]])
(sv/defmethod ::create-file-object-thumbnail
@@ -289,8 +289,8 @@
::sm/params schema:create-file-object-thumbnail}
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
(media.v/validate-media-type! media)
(media.v/validate-media-size! media)
(media/validate-media-type! media)
(media/validate-media-size! media)
(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})]
@@ -374,12 +374,67 @@
;; --- 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
schema:create-file-thumbnail
[:map {:title "create-file-thumbnail"}
[:file-id ::sm/uuid]
[:revn ::sm/int]
[:media media.v/schema:upload]])
[:media media/schema:upload]])
(sv/defmethod ::create-file-thumbnail
"Creates or updates the file thumbnail. Mainly used for paint the
@@ -393,57 +448,12 @@
::rtry/when rtry/conflict-exception?
::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}]
(media.v/validate-media-type! (:media params))
(media.v/validate-media-size! (:media params))
(db/run! cfg files/check-edition-permissions! profile-id file-id)
(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)}))))
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media))
:id (:id media)})))))
+65 -25
View File
@@ -6,6 +6,7 @@
(ns app.rpc.commands.fonts
(:require
[app.binfile.common :as bfc]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
@@ -21,7 +22,6 @@
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media :as media]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
@@ -30,7 +30,6 @@
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.storage.tmp :as tmp]
@@ -39,7 +38,10 @@
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
java.io.InputStream
java.io.OutputStream
java.io.SequenceInputStream
java.util.Collections
java.util.zip.ZipEntry
java.util.zip.ZipOutputStream))
@@ -69,14 +71,14 @@
(cond
(uuid? team-id)
(do
(teams/check-read-permissions! cfg profile-id team-id)
(teams/check-read-permissions! conn profile-id team-id)
(db/query conn :team-font-variant
{:team-id team-id
:deleted-at nil}))
(uuid? project-id)
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})]
(projects/check-read-permissions! cfg profile-id project-id)
(projects/check-read-permissions! conn profile-id project-id)
(db/query conn :team-font-variant
{:team-id (:team-id project)
:deleted-at nil}))
@@ -84,7 +86,7 @@
(uuid? file-id)
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]})
project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]})
perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
perms (bfc/get-file-permissions conn profile-id file-id share-id)]
(files/check-read-permissions! perms)
(db/query conn :team-font-variant
{:team-id (:team-id project)
@@ -94,13 +96,18 @@
(declare create-font-variant)
(def ^:private schema:create-font-variant
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:font-id ::sm/uuid]
[:font-family types.font/schema:font-family]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:uploads [:map-of ::sm/text ::sm/uuid]]])
[:and
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:font-id ::sm/uuid]
[:font-family types.font/schema:font-family]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]]
[:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]]
[:fn {:error/message "one of :data or :uploads is required"}
(fn [{:keys [data uploads]}]
(or (seq data) (seq uploads)))]])
(defn- prepare-font-data-from-uploads
"Assembles each chunked-upload session in `uploads` (a `{mtype →
@@ -111,8 +118,8 @@
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media.v/validate-media-type! cm/font-types)
(media.v/validate-font-size!))
(media/validate-media-type! cm/font-types)
(media/validate-font-size!))
(assoc acc mtype (:path assembled))))
{}
uploads)]
@@ -121,23 +128,54 @@
(assoc :data data)
(dissoc :uploads))))
(defn- prepare-font-data-from-legacy
"Validates the media type and size of every entry in the legacy
`:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every
entry to a tempfile. Returns params with a normalised
`{mtype → path}` data map."
[{:keys [data] :as params}]
(let [data (reduce-kv
(fn [acc mtype content]
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
chunks (if (vector? content) content [content])
streams (map io/input-stream chunks)
streams (Collections/enumeration streams)]
;; Generate the tempfile from all chunks
(with-open [^OutputStream output (io/output-stream tmp)
^InputStream input (SequenceInputStream. streams)]
(io/copy input output))
;; Validate
(-> {:mtype mtype :size (fs/size tmp)}
(media/validate-media-type! cm/font-types)
(media/validate-font-size!))
(assoc acc mtype tmp)))
{}
data)]
(assoc params :data data)))
(sv/defmethod ::create-font-variant
"Upload a font variant. Font data must be provided as an `:uploads`
map (keyed by mime-type, values are upload-session UUIDs from the
chunked-upload API)."
"Upload a font variant. Font data may be provided either as a
Transit-encoded `:data` map (keyed by mime-type) for small fonts, or
as an `:uploads` map (keyed by mime-type, values are upload-session
UUIDs from the chunked-upload API) for large fonts. Exactly one of
the two must be present."
{::doc/added "1.18"
::doc/changes [["2.16" "Add :uploads param for chunked upload support"]
["2.18" "Remove :data param, use :uploads exclusively"]]
::doc/changes ["2.16" "Add :uploads param for chunked upload support"]
::climit/id [[:process-font/by-profile ::rpc/profile-id]
[:process-font/global]]
::webhooks/event? true
::sm/params schema:create-font-variant}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
(teams/check-edition-permissions! pool profile-id team-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [params (db/tx-run! cfg prepare-font-data-from-uploads params)]
(let [params (if (some? uploads)
(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))))
(defn create-font-variant
@@ -191,7 +229,9 @@
(let [tpoint (ct/tpoint)
mtypes (vec (keys data))
total-size (reduce-kv (fn [acc _ content]
(+ acc (fs/size content)))
(+ acc (if (bytes? content)
(alength ^bytes content)
(fs/size content))))
0
data)]
@@ -330,7 +370,7 @@
(defn- make-temporal-storage-object
[cfg profile-id content]
(let [storage (sto/resolve cfg)
content (media.v/check-input content)
content (media/check-input content)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
@@ -360,7 +400,7 @@
::sm/params schema:download-font}
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(let [variant (db/get pool :team-font-variant {:id id})]
(teams/check-read-permissions! cfg profile-id (:team-id variant))
(teams/check-read-permissions! pool profile-id (:team-id variant))
;; Try to get the best available font format (prefer TTF for broader compatibility).
(let [media-id (or (:ttf-file-id variant)
@@ -392,7 +432,7 @@
(ex/raise :type :not-found
:code :object-not-found))
(teams/check-read-permissions! cfg profile-id (:team-id (first variants)))
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
(let [tempfile (tmp/tempfile :suffix ".zip")
ffamily (-> variants first :font-family)]
+1 -1
View File
@@ -176,7 +176,7 @@
;; profile-id is present; it can be ommited if this function is
;; called from SREPL helpers where no profile is available
(when (uuid? profile-id)
(teams/check-read-permissions! cfg profile-id team-id))
(teams/check-read-permissions! conn profile-id team-id))
(binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})]
(let [projs (bfc/get-team-projects cfg team-id)
+11 -22
View File
@@ -16,8 +16,6 @@
[app.db :as db]
[app.loggers.audit :as-alias audit]
[app.media :as media]
[app.media.svg :as svg]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.climit :as climit]
[app.rpc.commands.files :as files]
@@ -46,7 +44,7 @@
[:file-id ::sm/uuid]
[:is-local ::sm/boolean]
[:name [:string {:max 250}]]
[:content media.v/schema:upload]])
[:content media/schema:upload]])
(sv/defmethod ::upload-file-media-object
{::doc/added "1.17"
@@ -55,8 +53,8 @@
[:process-image/global]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
(files/check-edition-permissions! pool profile-id file-id)
(media.v/validate-media-type! content)
(media.v/validate-media-size! content)
(media/validate-media-type! content)
(media/validate-media-size! content)
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; We get the minimal file for proper checking if
@@ -115,22 +113,13 @@
(defn- process-main-image
[info]
(let [path (:path info)
mtype (:mtype info)
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))]
(let [hash (sto/calculate-hash (:path info))
data (-> (sto/content (:path info))
(sto/wrap-with-hash hash))]
{::sto/content data
::sto/deduplicate? true
::sto/touched-at (:ts info)
:content-type mtype
:content-type (:mtype info)
:bucket "file-media-object"}))
(defn- process-thumb-image
@@ -326,7 +315,7 @@
[:map {:title "upload-chunk"}
[:session-id ::sm/uuid]
[:index ::sm/int]
[:content media.v/schema:upload]])
[:content media/schema:upload]])
(def ^:private schema:upload-chunk-result
[:map {:title "upload-chunk-result"}
@@ -397,7 +386,7 @@
(defn assemble-chunks
"Validates that all expected chunks are present for `session-id` and
concatenates them into a single temporary file. Returns a map
conforming to `media.v/schema:upload` with `:filename`, `:path` and
conforming to `media/schema:upload` with `:filename`, `:path` and
`:size`.
Raises a :validation/:missing-chunks error when the number of stored
@@ -451,8 +440,8 @@
content (-> content
(assoc :filename (str "upload:" name))
(assoc :mtype mtype)
(media.v/validate-media-type!)
(media.v/validate-media-size!))
(media/validate-media-type!)
(media/validate-media-size!))
mobj (create-file-media-object cfg (assoc params
:id id
:from-chunks? true
+168 -222
View File
@@ -11,10 +11,9 @@
[app.auth.oidc :as oidc]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.config :as cf]
[app.db :as db]
[app.nitrate :as nitrate]
@@ -22,11 +21,9 @@
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.nitrate.emails-helper :as neh]
[app.rpc.nitrate.organization-helper :as noh]
[app.rpc.notifications :as notifications]
[app.util.services :as sv]
[buddy.core.codecs :as bc]))
[app.tokens :as tokens]
[app.util.services :as sv]))
(defn assert-is-owner [cfg profile-id team-id]
@@ -42,11 +39,11 @@
:code :cant-move-default-team))))
(defn assert-membership [cfg profile-id organization-id]
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})]
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id})]
(when-not (:organization-id membership)
(ex/raise :type :validation
:code :organization-does-not-exist))
:code :organization-doesnt-exists))
(when-not (:is-member membership)
(ex/raise :type :validation
@@ -116,35 +113,6 @@
:cause 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
"UPDATE team
SET name = ? || name,
@@ -181,7 +149,7 @@
{})))
{}))
(defn- build-leave-organization-plan
(defn- build-leave-org-plan
[{: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))
files-counts (get-team-files-counts conn all-teams)
@@ -194,18 +162,18 @@
{:deletable-team-ids deletable
:keep-default-team? keep-default?
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
:detach-from-organization-team-ids to-detach}))
:detach-from-org-team-ids to-detach}))
(defn get-leave-organization-summary
(defn get-leave-org-summary
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
(let [{:keys [deletable-team-ids detach-from-organization-team-ids]}
(build-leave-organization-plan cfg default-team-id teams-to-delete nil)]
(let [{:keys [deletable-team-ids detach-from-org-team-ids]}
(build-leave-org-plan cfg default-team-id teams-to-delete nil)]
{:teams-to-delete (count deletable-team-ids)
:teams-to-transfer teams-to-transfer-count
:teams-to-exit teams-to-exit-count
:teams-to-detach (count detach-from-organization-team-ids)}))
:teams-to-detach (count detach-from-org-team-ids)}))
(def ^:private schema:leave-organization
(def ^:private schema:leave-org
[:map
[:id ::sm/uuid]
[:name ::sm/text]
@@ -218,49 +186,47 @@
[:id ::sm/uuid]
[:reassign-to {:optional true} ::sm/uuid]]]]])
(def ^:private schema:get-leave-organization-summary-result
(def ^:private schema:get-leave-org-summary-result
[:map
[:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]
[:member-added-at [:maybe ct/schema:inst]]
[:organization-member-count-before ::sm/int]])
[:teams-to-detach ::sm/int]])
(def ^:private schema:get-leave-organization-summary
(def ^:private schema:get-leave-org-summary
[:map
[:id ::sm/uuid]
[:default-team-id ::sm/uuid]])
(defn- get-organization-teams-for-user
[{:keys [::db/conn] :as cfg} organization-summary profile-id]
(let [organization-team-ids (->> (:teams organization-summary)
(map :id))
ids-array (db/create-array conn "uuid" organization-team-ids)]
[{:keys [::db/conn] :as cfg} org-summary profile-id]
(let [org-team-ids (->> (:teams org-summary)
(map :id))
ids-array (db/create-array conn "uuid" org-team-ids)]
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
(defn- calculate-valid-teams
([organization-teams default-team-id]
([org-teams 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 %)) organization-teams)
valid-default-team (d/seek #(= default-team-id (:id %)) org-teams)
;; Remove your-penpot for the rest of validations
organization-teams (remove #(= default-team-id (:id %)) organization-teams)
org-teams (remove #(= default-team-id (:id %)) org-teams)
;; valid teams to delete are those that the user is owner, and only have one member
valid-teams-to-delete-ids (->> organization-teams
valid-teams-to-delete-ids (->> org-teams
(filter #(and (:is-owner %)
(= (:num-members %) 1)))
(map :id)
(into #{}))
;; valid teams to transfer are those that the user is owner, and have more than one member
valid-teams-to-transfer (->> organization-teams
valid-teams-to-transfer (->> org-teams
(filter #(and (:is-owner %)
(> (: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 (->> organization-teams
valid-teams-to-exit (->> org-teams
(filter #(and (not (:is-owner %))
(> (:num-members %) 1))))]
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
@@ -269,17 +235,17 @@
:valid-default-team valid-default-team})))
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)]
(calculate-valid-teams organization-teams default-team-id)))
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
org-teams (get-organization-teams-for-user cfg org-summary profile-id)]
(calculate-valid-teams org-teams default-team-id)))
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
org-teams (get-organization-teams-for-user cfg org-summary profile-id)
{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
valid-teams-to-exit
valid-default-team]} (calculate-valid-teams organization-teams default-team-id)
valid-default-team]} (calculate-valid-teams org-teams default-team-id)
@@ -297,7 +263,7 @@
;; - 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;
;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit
teams-by-id (d/index-by :id organization-teams)
teams-by-id (d/index-by :id org-teams)
valid-teams-to-leave? (and
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
(every? (fn [{:keys [id reassign-to]}]
@@ -308,10 +274,10 @@
(contains? members reassign-to)))
(contains? valid-teams-to-exit-ids id)))
teams-to-leave))]
;; the organization owner cannot leave
(when (= (:owner-id organization-summary) profile-id)
;; the org owner cannot leave
(when (= (:owner-id org-summary) profile-id)
(ex/raise :type :validation
:code :organization-owner-cannot-leave))
:code :org-owner-cannot-leave))
(when (or
(not valid-teams-to-delete?)
@@ -322,14 +288,13 @@
(defn leave-organization
(defn leave-org
[{: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?
user-who-delete-member deleted-by-role]}]
(let [organization-prefix (str "[" (d/sanitize-string name) "] ")
{: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) "] ")
{:keys [deletable-team-ids
keep-default-team?
detach-from-organization-team-ids]} (build-leave-organization-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
;; assert that the received teams are valid, checking the different constraints
(when-not skip-validation
@@ -346,104 +311,95 @@
(doseq [{:keys [id reassign-to]} teams-to-leave]
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete.
;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
(when default-team-id
(if keep-default-team?
(db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id])
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])
(teams/delete-team cfg {:profile-id profile-id
:team-id default-team-id})))
;; Detach retained owned teams from the organization in Nitrate.
;; Nitrate will rehome them to its fallback/default organization.
(doseq [team-id detach-from-organization-team-ids]
(nitrate/call cfg :remove-team-from-organization {:team-id team-id
:organization-id id}))
;; Nitrate will rehome them to its fallback/default org.
(doseq [team-id detach-from-org-team-ids]
(nitrate/call cfg :remove-team-from-org {:team-id team-id
:organization-id id}))
;; Api call to nitrate
(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})
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
nil))
(sv/defmethod ::leave-organization
(sv/defmethod ::leave-org
{::rpc/auth true
::doc/added "2.15"
::sm/params schema:leave-organization
::sm/params schema:leave-org
::db/transaction true}
[cfg {:keys [::rpc/profile-id] :as params}]
(leave-organization cfg (assoc params
:profile-id profile-id
:user-who-delete-member profile-id
:deleted-by-role "organization-member")))
(leave-org cfg (assoc params :profile-id profile-id)))
(sv/defmethod ::get-leave-organization-summary
(sv/defmethod ::get-leave-org-summary
{::rpc/auth true
::doc/added "2.18"
::sm/params schema:get-leave-organization-summary
::sm/result schema:get-leave-organization-summary-result
::sm/params schema:get-leave-org-summary
::sm/result schema:get-leave-org-summary-result
::db/transaction true}
[cfg {:keys [::rpc/profile-id id default-team-id]}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
valid-teams-to-exit
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-exit-count (count valid-teams-to-exit)]
(when-not valid-default-team
(ex/raise :type :validation
:code :not-valid-teams))
(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))))
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)))
(def ^:private schema:remove-team-from-organization
(def ^:private schema:remove-team-from-org
[:map
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]
[:organization-name ::sm/text]])
(sv/defmethod ::remove-team-from-organization
(sv/defmethod ::remove-team-from-org
{::doc/added "2.17"
::sm/params schema:remove-team-from-organization}
::sm/params schema:remove-team-from-org}
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
;; Check moveTeams permission on the source organization
(when (contains? cf/flags :admin-console)
(let [organization-perms (nitrate/call cfg :get-organization-permissions
{:organization-id organization-id})]
(if (nil? organization-perms)
(when (contains? cf/flags :nitrate)
(let [org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})]
(if (nil? org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(when-not (cto/allowed? :move-team
{:organization-perms organization-perms
:profile-id profile-id})
(when-not (nitrate-perms/allowed? :move-team
{:org-perms org-perms
:profile-id profile-id})
(ex/raise :type :validation
: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.")))))
;; Api call to nitrate
(nitrate/call cfg :remove-team-from-organization {:team-id team-id :organization-id organization-id})
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
;; Notify connected users
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-organization")
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
nil)
(def ^:private sql:get-team-invitation-emails
"SELECT email_to
FROM team_invitation
WHERE team_id = ?
AND valid_until > now()")
(def ^:private sql:delete-team-external-invitations
"DELETE FROM team_invitation
WHERE team_id = ?
@@ -457,22 +413,23 @@
AND deleted_at IS NULL")
(defn- get-external-invitation-info
"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 organization.
"Returns info about external (non-org-member) invitations pending for a team.
External invitations are those sent to users who are not members of the given org.
Returns {:allows-anybody bool :external-emails [...]}"
[{:keys [::db/conn] :as cfg} team-id organization-id]
(let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})
allows-anybody (cto/allowed? :add-anybody-to-team {:organization-perms organization-perms})]
(let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id})
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})]
(if allows-anybody
{:allows-anybody true :external-emails []}
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
(let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id])
emails (map :email-to invitation-emails)]
(if (empty? emails)
{:allows-anybody false :external-emails []}
(let [emails-array (db/create-array conn "text" (vec emails))
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
external-emails (->> profiles
(remove #(contains? organization-member-ids (:id %)))
(remove #(contains? org-member-ids (:id %)))
(map :email)
(vec))]
{:allows-anybody false :external-emails external-emails}))))))
@@ -487,142 +444,133 @@
::doc/added "2.17"
::sm/params schema:add-team-to-organization
::db/transaction true}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :admin-console)
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
team-with-organization (nitrate/call cfg :get-team-organization {:team-id team-id})
source-organization-id (get-in team-with-organization [:organization :id])
source-organization-perms (when source-organization-id
(nitrate/call cfg :get-organization-permissions
{:organization-id source-organization-id}))
target-organization-perms (nitrate/call cfg :get-organization-permissions
{:organization-id organization-id})
target-organization-same-owner? (and (some? source-organization-perms)
(some? target-organization-perms)
(= (:owner-id source-organization-perms)
(:owner-id target-organization-perms)))]
(when (nil? target-organization-perms)
(when (contains? cf/flags :nitrate)
(let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id})
source-org-id (get-in team-with-org [:organization :id])
source-org-perms (when source-org-id
(nitrate/call cfg :get-org-permissions
{:organization-id source-org-id}))
target-org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})
target-org-same-owner? (and (some? source-org-perms)
(some? target-org-perms)
(= (:owner-id source-org-perms)
(:owner-id target-org-perms)))]
(when (nil? target-org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions"))
;; Team already belongs to an organization: check move-teams on the source organization.
(when (some? source-organization-id)
(when (nil? source-organization-perms)
;; Team already belongs to an organization: check move-teams on source org.
(when (some? source-org-id)
(when (nil? source-org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions"))
(when-not (cto/allowed? :move-team
{:organization-perms source-organization-perms
:profile-id profile-id
:target-organization-same-owner? target-organization-same-owner?})
(when-not (nitrate-perms/allowed? :move-team
{:org-perms source-org-perms
:profile-id profile-id
:target-org-same-owner? target-org-same-owner?})
(ex/raise :type :validation
: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.")))
;; Always check target create-teams permission (new/add and move flows).
(when-not (cto/allowed? :create-team
{:organization-perms target-organization-perms
:profile-id profile-id})
(when-not (nitrate-perms/allowed? :create-team
{:org-perms target-org-perms
:profile-id profile-id})
(ex/raise :type :validation
: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 organization if needed
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
new-member-ids (->> team-members
(map :profile-id)
(remove #{profile-id})
(remove organization-member-ids-before))]
(doseq [member-id new-member-ids]
(teams/initialize-user-in-organization cfg member-id organization-id)))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
;; Add teammates to the org if needed
(doseq [{member-id :profile-id} team-members
:when (not= member-id profile-id)]
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-organization {:team-id team-id
:organization-id organization-id
:is-default false})]
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-organization"))
;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
;; 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)]
(when (and (not allows-anybody) (seq external-emails))
(let [conn (::db/conn cfg)
emails-array (db/create-array conn "text" external-emails)]
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
;; Send warnings via email if the organization has sso
(neh/send-organization-setup-sso-emails-for-team!
cfg organization-id team-id organization-member-ids-before)))
;; 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)]
(when (and (not allows-anybody) (seq external-emails))
(let [conn (::db/conn cfg)
emails-array (db/create-array conn "text" external-emails)]
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array])))))
nil)
(def ^:private schema:check-organization-members-params
[:map {:title "CheckOrganizationMembersParams"}
(def ^:private schema:check-org-members-params
[:map {:title "CheckOrgMembersParams"}
[:organization-id ::sm/uuid]
[:emails [:vector ::sm/email]]])
(sv/defmethod ::check-organization-members
(sv/defmethod ::check-org-members
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:check-organization-members-params
::sm/params schema:check-org-members-params
::sm/result [:map-of :string :boolean]
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
(or (when (contains? cf/flags :admin-console)
(or (when (contains? cf/flags :nitrate)
(assert-membership cfg profile-id organization-id)
(let [emails-array (db/create-array conn "text" emails)
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))]
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))]
(into {}
(map (fn [email]
(let [pid (get email->id email)]
[email (boolean (and pid (contains? organization-member-ids pid)))])))
[email (boolean (and pid (contains? org-member-ids pid)))])))
emails)))
{}))
(def ^:private schema:all-organization-members-in-team-params
[:map {:title "CheckOrganizationMembersInTeamParams"}
(def ^:private schema:all-org-members-in-team-params
[:map {:title "CheckOrgMembersInTeamParams"}
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]])
(sv/defmethod ::all-organization-members-in-team
(sv/defmethod ::all-org-members-in-team
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-organization-members-in-team-params
::sm/params schema:all-org-members-in-team-params
::sm/result ::sm/boolean}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :admin-console)
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
organization-member-ids (into #{} organization-members)
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
org-member-ids (into #{} org-members)
team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))]
(every? #(contains? team-member-ids %) organization-member-ids)))
(every? #(contains? team-member-ids %) org-member-ids)))
false))
(def ^:private schema:all-team-members-in-organizations-params
[:map {:title "CheckTeamMembersInOrganizationsParams"}
(def ^:private schema:all-team-members-in-orgs-params
[:map {:title "CheckTeamMembersInOrgsParams"}
[:team-id ::sm/uuid]
[:organization-ids [:vector ::sm/uuid]]])
(sv/defmethod ::all-team-members-in-organizations
(sv/defmethod ::all-team-members-in-orgs
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-team-members-in-organizations-params
::sm/params schema:all-team-members-in-orgs-params
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
(if (contains? cf/flags :admin-console)
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
@@ -630,15 +578,15 @@
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))]
;; Validate requester membership in all organizations before fetching members.
;; Validate requester membership in all orgs before fetching members.
(run! #(assert-membership cfg profile-id %) organization-ids)
(into {}
(map (fn [organization-id]
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
organization-member-ids (into #{} organization-members)]
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
org-member-ids (into #{} org-members)]
[organization-id
(every? #(contains? organization-member-ids %) team-member-ids)])))
(every? #(contains? org-member-ids %) team-member-ids)])))
organization-ids)))
{}))
@@ -659,7 +607,7 @@
::sm/result schema:check-team-external-invitations-result
::db/transaction true}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :admin-console)
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
@@ -673,17 +621,13 @@
(def ^:private schema:check-nitrate-sso
[:and
[:map {:title "CheckNitrateSsoParams"}
[:team-id {:optional true} ::sm/uuid]
[:organization-id {:optional true} ::sm/uuid]
[:url ::sm/uri]]
[::sm/contains-any #{:team-id :organization-id}]])
[:map {:title "AuthSsoParams"}
[:team-id ::sm/uuid]
[:url ::sm/uri]])
(sv/defmethod ::check-nitrate-sso
"Check if a user needs to login into the organization SSO.
Accepts either team-id (to look up the organization via the team) or organization-id directly.
Returns {:authorized true} when SSO is not active or the user cannot access the team.
Returns {:authorized true} when SSO is not active for the team.
Returns {:authorized false :redirect-uri <url>} when SSO is active;
the client must redirect there. The OIDC provider itself handles
re-authentication transparently if the user already has an active SSO session."
@@ -691,22 +635,24 @@
::doc/added "2.19"
::sm/params schema:check-nitrate-sso
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
(if (contains? cf/flags :admin-console)
(if (and team-id
(not (teams/has-read-permissions? cfg profile-id team-id)))
;; Let the destination RPC enforce its own permissions. Starting SSO before
;; access is established sends unrelated users through the organization's IdP.
{:authorized true}
(let [request (rph/get-request params)
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
(if authorized
{:authorized true}
(if (oidc/organization-sso-discovery-uri sso)
[cfg {:keys [team-id url] :as params}]
(if (contains? cf/flags :nitrate)
(let [request (rph/get-request params)
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)]
(if authorized
{:authorized true}
(if-let [issuer (or (:issuer sso) (:base-url sso))]
(let [oidc-provider (oidc/prepare-org-sso-provider cfg sso)
organization-id (:organization-id sso)
state-token (tokens/generate cfg {:iss "oidc"
:dest-url url
:team-id team-id
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})
redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)]
{: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}))))
:redirect-uri redirect-uri})
{:authorized false
:redirect-uri nil})))
{:authorized true}))
+13 -38
View File
@@ -7,7 +7,6 @@
(ns app.rpc.commands.profile
(:require
[app.auth :as auth]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -22,7 +21,6 @@
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media]
[app.media.validation :as media.v]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.climit :as climit]
@@ -47,17 +45,8 @@
[:email-comments [::sm/one-of #{:all :partial :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
[:map {:title "ProfileProps" :closed true}
[:map {:title "ProfileProps"}
[:plugins {:optional true} schema:plugin-registry]
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
[:mcp-enabled {:optional true} ::sm/boolean]
@@ -65,26 +54,18 @@
[:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid]
[: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]
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
[:release-notes-viewed {:optional true}
[::sm/text {:max 100}]]
[:notifications {:optional true} schema:props-notifications]
[: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]])
[:workspace-visited {:optional true} ::sm/boolean]])
(def schema:profile
[:map {:title "Profile"}
[:id ::sm/uuid]
[:fullname [::sm/word-string {:max 250}]]
[:email ::sm/email]
[:theme {:optional true} :string]
[:is-admin {:optional true} ::sm/boolean]
[:is-active {:optional true} ::sm/boolean]
[:is-blocked {:optional true} ::sm/boolean]
[:is-demo {:optional true} ::sm/boolean]
@@ -110,7 +91,7 @@
(defn- with-nitrate-licence
[profile cfg]
(if (contains? cf/flags :admin-console)
(if (contains? cf/flags :nitrate)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
@@ -165,9 +146,6 @@
;; it or not for explicit locking and avoid concurrent updates of
;; the same row/object.
(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
profile (-> profile
(assoc :fullname fullname)
@@ -213,9 +191,6 @@
:code :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))
(->> (rph/get-request params)
@@ -288,7 +263,7 @@
(def ^:private
schema:update-profile-photo
[:map {:title "update-profile-photo"}
[:file media.v/schema:upload]])
[:file media/schema:upload]])
(sv/defmethod ::update-profile-photo
{:doc/added "1.1"
@@ -296,8 +271,8 @@
::sm/result :nil}
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media.v/validate-media-size! file)
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media/validate-media-size! file)
(update-profile-photo cfg (assoc params :profile-id profile-id)))
(defn update-profile-photo
@@ -474,7 +449,7 @@
(assoc props k v))
props))
(:props profile)
(apply dissoc props system-managed-props))]
props)]
(db/update! conn :profile
{:props (db/tjson props)}
@@ -516,14 +491,14 @@
{:id profile-id})
;; Delete owned organizations on the fly (no grace period).
;; Nitrate iterates the user's owned organizations and, per organization, calls
;; Nitrate iterates the user's owned orgs and, per org, calls
;; Penpot back through two paths: ::notify-user-organizations-deletion
;; (during delete-owned-organizations) and ::notify-organization-deletion.
;; Both preserve organization teams unchanged and only prefix or delete
;; (during delete-owned-orgs) and ::notify-organization-deletion.
;; Both preserve org teams unchanged and only prefix or delete
;; imported "Your Penpot" teams according to whether they still have files.
;; Let Nitrate clean up the data associated with the deleted Penpot user:
;; owned organizations, remaining memberships, and subscription cancellation.
(when (contains? cf/flags :admin-console)
(when (contains? cf/flags :nitrate)
(nitrate/call cfg :cleanup-deleted-penpot-user
{:profile-id profile-id}))
@@ -582,8 +557,8 @@
{::doc/added "2.18"
::sm/result schema:get-owned-organizations-summary-result}
[cfg {:keys [::rpc/profile-id]}]
(if (contains? cf/flags :admin-console)
(or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) [])
(if (contains? cf/flags :nitrate)
(or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) [])
[]))
;; --- HELPERS
+7 -16
View File
@@ -6,12 +6,10 @@
(ns app.rpc.commands.projects
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[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.db.sql :as-alias sql]
[app.features.logical-deletion :as ldel]
@@ -58,16 +56,11 @@
:can-edit (or is-owner is-admin can-edit)
:can-read true})))
(defn- get-read-permissions
[cfg profile-id project-id]
(or (get-permissions cfg profile-id project-id)
(perms/get-organization-owner-permissions cfg profile-id :project-id project-id)))
(def has-edit-permissions?
(perms/make-edition-predicate-fn get-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn get-read-permissions))
(perms/make-read-predicate-fn get-permissions))
(def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?))
@@ -166,10 +159,10 @@
{::doc/added "1.18"
::rpc/id-type :project
::sm/params schema:get-project}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
(dm/with-open [conn (db/open pool)]
(let [project (db/get-by-id conn :project id)]
(check-read-permissions! cfg profile-id id)
(check-read-permissions! conn profile-id id)
project)))
@@ -186,8 +179,7 @@
timestamp (::rpc/request-at params)]
(teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel
{:id (uuid/next)
:project-id (:id project)
{:project-id (:id project)
:profile-id profile-id
:created-at timestamp
:modified-at timestamp
@@ -238,8 +230,8 @@
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
::webhooks/event? true
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
(check-read-permissions! cfg profile-id id)
[{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
(check-read-permissions! conn profile-id id)
(db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned])
nil)
@@ -260,8 +252,7 @@
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
(check-edition-permissions! conn profile-id id)
(let [project (db/get-by-id conn :project id ::sql/for-update true)
name (d/normalize-string name)]
(let [project (db/get-by-id conn :project id ::sql/for-update true)]
(db/update! conn :project
{:name name}
{:id id})
+2 -6
View File
@@ -6,11 +6,9 @@
(ns app.rpc.commands.search
(:require
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
@@ -68,13 +66,11 @@
(def ^:private schema:search-files
[:map {:title "search-files"}
[:team-id ::sm/uuid]
[:search-term {:optional true} [:string {:max 250}]]])
[:search-term {:optional true} :string]])
(sv/defmethod ::search-files
{::doc/added "1.17"
::doc/module :files
::sm/params schema:search-files}
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}]
(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))))
(some->> search-term (search-files pool profile-id team-id)))
+83 -118
View File
@@ -12,7 +12,7 @@
[app.common.features :as cfeat]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team]
[app.common.uuid :as uuid]
[app.config :as cf]
@@ -22,7 +22,7 @@
[app.features.logical-deletion :as ldel]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media.validation :as media.v]
[app.media :as media]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
@@ -60,11 +60,6 @@
:can-edit (or is-owner is-admin can-edit)
:can-read true})))
(defn get-read-permissions
[cfg profile-id team-id]
(or (get-permissions cfg profile-id team-id)
(perms/get-organization-owner-permissions cfg profile-id :team-id team-id)))
(def has-admin-permissions?
(perms/make-admin-predicate-fn get-permissions))
@@ -72,7 +67,7 @@
(perms/make-edition-predicate-fn get-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn get-read-permissions))
(perms/make-read-predicate-fn get-permissions))
(def check-admin-permissions!
(perms/make-check-fn has-admin-permissions?))
@@ -185,6 +180,7 @@
sql (if (contains? cf/flags :subscriptions)
sql:get-teams-with-permissions-and-subscription
sql:get-teams-with-permissions)]
(->> (db/exec! conn [sql (:default-team-id profile) profile-id])
(into [] xform:process-teams))))
@@ -197,9 +193,9 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id)
(contains? cf/flags :admin-console)
(map #(nitrate/add-organization-info-to-team cfg % params))
(contains? cf/flags :admin-console)
(contains? cf/flags :nitrate)
(map #(nitrate/add-org-info-to-team cfg % params))
(contains? cf/flags :nitrate)
(remove #(get-in % [:organization :expired-license])))))
(def ^:private sql:get-owned-teams
@@ -242,34 +238,19 @@
{::doc/added "1.17"
::rpc/id-type :team
::sm/params schema:get-team}
[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)]
(if (contains? cf/flags :admin-console)
(nitrate/add-organization-info-to-team cfg team params)
team)))
(defn- get-organization-owner-viewer-team
"When `profile-id` is a non-member owner of the organization that owns
the requested team, returns the team shaped with viewer permissions;
otherwise nil. `cfg` must carry the nitrate client."
[cfg profile-id default-team-id params]
(when-let [team-id (perms/resolve-team-id cfg params)]
(when (nitrate/organization-owner-of-team? cfg profile-id team-id)
(when-let [team (db/get* cfg :team {:id team-id})]
(when-not (db/is-row-deleted? team)
(-> team
(decode-row)
(merge perms/viewer-role-flags)
(assoc :is-default (= team-id default-team-id))
(process-permissions)))))))
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}]
(get-team pool :profile-id profile-id :team-id id :file-id file-id))
(defn get-team
[cfg & {:keys [profile-id team-id project-id file-id] :as params}]
[conn & {:keys [profile-id team-id project-id file-id] :as params}]
(assert (uuid? profile-id) "profile-id is mandatory")
(assert (or (db/connection? conn)
(db/pool? conn))
"connection or pool is mandatory")
(let [{:keys [default-team-id] :as profile}
(profile/get-profile cfg profile-id)
(profile/get-profile conn profile-id)
sql
(if (contains? cf/flags :subscriptions)
@@ -281,14 +262,14 @@
(some? team-id)
(let [sql (str "WITH teams AS (" sql ") "
"SELECT * FROM teams WHERE id=?")]
(db/exec-one! cfg [sql default-team-id profile-id team-id]))
(db/exec-one! conn [sql default-team-id profile-id team-id]))
(some? project-id)
(let [sql (str "WITH teams AS (" sql ") "
"SELECT t.* FROM teams AS t "
" JOIN project AS p ON (p.team_id = t.id) "
" WHERE p.id=?")]
(db/exec-one! cfg [sql default-team-id profile-id project-id]))
(db/exec-one! conn [sql default-team-id profile-id project-id]))
(some? file-id)
(let [sql (str "WITH teams AS (" sql ") "
@@ -296,18 +277,17 @@
" JOIN project AS p ON (p.team_id = t.id) "
" JOIN file AS f ON (f.project_id = p.id) "
" WHERE f.id=?")]
(db/exec-one! cfg [sql default-team-id profile-id file-id]))
(db/exec-one! conn [sql default-team-id profile-id file-id]))
:else
(throw (IllegalArgumentException. "invalid arguments")))]
(if result
(-> result
(decode-row)
(process-permissions))
(or (get-organization-owner-viewer-team cfg profile-id default-team-id params)
(ex/raise :type :not-found
:code :team-does-not-exist)))))
(when-not result
(ex/raise :type :not-found
:code :team-does-not-exist))
(-> result
(decode-row)
(process-permissions))))
;; --- Query: Team Members
@@ -336,7 +316,7 @@
::sm/params schema:get-team-memebrs}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! cfg profile-id team-id)
(check-read-permissions! conn profile-id team-id)
(get-team-members conn team-id)))
;; --- Query: Team Users
@@ -362,10 +342,10 @@
(dm/with-open [conn (db/open pool)]
(if team-id
(do
(check-read-permissions! cfg profile-id team-id)
(check-read-permissions! conn profile-id team-id)
(get-users conn team-id))
(let [{team-id :id} (get-team-for-file conn file-id)]
(check-read-permissions! cfg profile-id team-id)
(check-read-permissions! conn profile-id team-id)
(get-users conn team-id)))))
;; This is a similar query to team members but can contain more data
@@ -452,7 +432,7 @@
::sm/params schema:get-team-stats}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! cfg profile-id team-id)
(check-read-permissions! conn profile-id team-id)
(get-team-stats conn team-id)))
(def sql:team-stats
@@ -488,7 +468,7 @@
::sm/params schema:get-team-invitations}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! cfg profile-id team-id)
(check-read-permissions! conn profile-id team-id)
(get-team-invitations conn team-id)))
@@ -535,18 +515,18 @@
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
;; When creating inside an organization, verify the user has permission to do so.
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
(when (and organization-id (contains? cf/flags :admin-console))
(let [organization-perms (nitrate/call cfg :get-organization-permissions
{:organization-id organization-id})]
(if (nil? organization-perms)
;; When creating inside an org, verify the user has permission to do so.
;; Fail closed: if org permissions cannot be fetched, deny the operation.
(when (and organization-id (contains? cf/flags :nitrate))
(let [org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})]
(if (nil? org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(when-not (cto/allowed? :create-team
{:organization-perms organization-perms
:profile-id profile-id})
(when-not (nitrate-perms/allowed? :create-team
{:org-perms org-perms
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to create teams in this organization")))))
@@ -563,7 +543,7 @@
{::audit/props {:id (:id team)}})))
(defn create-default-organization-team
(defn create-default-org-team
[cfg profile-id organization-id]
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
@@ -579,37 +559,37 @@
team (create-team cfg params)]
(select-keys team [:id])))
(defn initialize-user-in-organization
(defn initialize-user-in-nitrate-org
"If needed, create a default team for the user on the organization,
and initialize the user in the organization."
and notify Nitrate that an user has been added to an org."
([cfg profile-id organization-id]
(initialize-user-in-organization cfg profile-id organization-id nil))
(initialize-user-in-nitrate-org cfg profile-id organization-id nil))
([cfg profile-id organization-id email]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(when (contains? cf/flags :admin-console)
(when (contains? cf/flags :nitrate)
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as tx-cfg}]
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})]
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id})]
;; Only when the user doesn't belong to the organization yet
(when (and
(some? (:organization-id membership)) ;; the organization exists
(not (:is-member membership))) ;; the user is not a member of the organization yet
(not (:is-member membership))) ;; the user is not a member of the org yet
(let [organization-id organization-id
default-team (create-default-organization-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
default-team-id (:id default-team)
result (nitrate/call tx-cfg :add-profile-to-organization (cond-> {:profile-id profile-id
:team-id default-team-id
:organization-id organization-id}
(some? email) (assoc :email email)))]
result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id
:team-id default-team-id
:organization-id organization-id}
(some? email) (assoc :email email)))]
(when (not (:is-member result))
(ex/raise :type :internal
:code :failed-add-profile-organization-nitrate
:code :failed-add-profile-org-nitrate
:context {:profile-id profile-id
:organization-id organization-id
:default-team-id default-team-id}))
@@ -621,14 +601,14 @@
([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(when (contains? cf/flags :admin-console)
(let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})]
(when (contains? cf/flags :nitrate)
(let [membership (nitrate/call cfg :get-org-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
(when (and
(some? (:organization-id membership)) ;; the team do belong to an organization
(not (:is-member membership))) ;; the user is not a member of the organization yet
(initialize-user-in-organization cfg profile-id (:organization-id membership)))))
(db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
(not (:is-member membership))) ;; the user is not a member of the org yet
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
(db/insert! conn :team-profile-rel params options)))
(defn create-team
"This is a complete team creation process, it creates the team
@@ -643,7 +623,7 @@
project (create-team-default-project conn params)]
(create-team-role cfg params)
;; Set team organization in Nitrate if organization-id is provided
(when (and (contains? cf/flags :admin-console) (:organization-id params))
(when (and (contains? cf/flags :nitrate) (:organization-id params))
(nitrate/set-team-organization cfg team params))
(assoc team :default-project-id (:id project))))
@@ -652,7 +632,6 @@
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)
features (db/create-array conn "text" features)
name (d/normalize-string name)
team (db/insert! conn :team
{:id id
:name name
@@ -689,7 +668,6 @@
[conn {:keys [id team-id name is-default created-at modified-at]}]
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)
name (d/normalize-string name)
params {:id id
:name name
:team-id team-id
@@ -701,8 +679,7 @@
(defn create-project-role
[conn profile-id project-id role]
(let [params {:project-id project-id
:profile-id profile-id
:id (uuid/next)}]
:profile-id profile-id}]
(->> (perms/assign-role-flags params role)
(db/insert! conn :project-profile-rel))))
@@ -720,10 +697,9 @@
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
(check-edition-permissions! conn profile-id id)
(let [name (d/normalize-string name)]
(db/update! conn :team
{:name name}
{:id id}))
(db/update! conn :team
{:name name}
{:id id})
nil)
@@ -806,19 +782,19 @@
[{: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)
team (if (contains? cf/flags :admin-console)
(nitrate/add-organization-info-to-team cfg team params)
team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team params)
team)
perms (get team :permissions)
organization (:organization team)
in-organization? (and (contains? cf/flags :admin-console) organization)
org (:organization team)
in-org? (and (contains? cf/flags :nitrate) org)
can-delete?
(if in-organization?
(cto/allowed? :delete-team
{:organization-perms {:owner-id (dm/get-in team [:organization :owner-id])
:permissions (dm/get-in team [:organization :permissions])}
:profile-id profile-id
:team-perms perms})
(if in-org?
(nitrate-perms/allowed? :delete-team
{:org-perms {:owner-id (dm/get-in team [:organization :owner-id])
:permissions (dm/get-in team [:organization :permissions])}
:profile-id profile-id
:team-perms perms})
(boolean (:is-owner perms)))]
(when-not can-delete?
@@ -826,8 +802,8 @@
:code :only-owner-can-delete-team))
;; Protect the user's personal default team from deletion.
;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
(when (and (:is-default team) (not in-organization?))
;; Org-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
(when (and (:is-default team) (not in-org?))
(ex/raise :type :validation
:code :non-deletable-team
:hint "impossible to delete default team"))
@@ -839,7 +815,7 @@
{::db/return-keys true})]
;; Api call to nitrate
(when (contains? cf/flags :admin-console)
(when (contains? cf/flags :nitrate)
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
(wrk/submit! {::db/conn conn
@@ -954,23 +930,12 @@
(db/delete! conn :team-profile-rel {:profile-id member-id
:team-id team-id})
;; A removed member that owns the organization of this team keeps
;; read-only access to it, so instead of kicking them out we degrade
;; their session to viewer, same as any other role change.
(if (nitrate/organization-owner-of-team? cfg member-id team-id)
(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)}))
(mbus/pub! msgbus
:topic member-id
:message {:type :team-membership-change
:change :removed
:team-id team-id
:team-name (:name team)})
nil))
@@ -982,7 +947,7 @@
(def ^:private schema:update-team-photo
[:map {:title "update-team-photo"}
[:team-id ::sm/uuid]
[:file media.v/schema:upload]])
[:file media/schema:upload]])
(sv/defmethod ::update-team-photo
{::doc/added "1.17"
@@ -990,8 +955,8 @@
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media.v/validate-media-size! file)
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(media/validate-media-size! file)
(update-team-photo cfg (assoc params :profile-id profile-id)))
(defn update-team-photo
@@ -14,7 +14,7 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team]
[app.common.uuid :as uuid]
[app.config :as cf]
@@ -44,31 +44,12 @@
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(def sql:upsert-organization-invitation
(def sql:upsert-org-invitation
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
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))))
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(defn- create-invitation-token
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
@@ -105,17 +86,15 @@
[:role types.team/schema:role]
[:email ::sm/email]])
(def ^:private schema:create-organization-invitation
[:map {:title "params:create-organization-invitation"}
(def ^:private schema:create-org-invitation
[:map {:title "params:create-org-invitation"}
[::rpc/profile-id ::sm/uuid]
[:organization
[:map
[:id ::sm/uuid]
[:name :string]
[:initials [:maybe :string]]
[:logo ::sm/uri]
[:avatar-bg-url [:maybe ::sm/uri]]
[:sso-active [:maybe ::sm/boolean]]]]
[:logo ::sm/uri]]]
[:profile
[:map
[:id ::sm/uuid]
@@ -126,8 +105,8 @@
(def ^:private check-create-invitation-params
(sm/check-fn schema:create-invitation))
(def ^:private check-create-organization-invitation-params
(sm/check-fn schema:create-organization-invitation))
(def ^:private check-create-org-invitation-params
(sm/check-fn schema:create-org-invitation))
(defn- allow-invitation-emails?
[member]
@@ -135,24 +114,23 @@
(not= :none (:email-invites notifications))))
(defn- assert-email-can-be-invited
"Asserts that member is an organization member when the organization
"Asserts that member is an org member when the org
restricts who can be added to teams."
[member organization-member-ids]
(when (some? organization-member-ids)
(let [is-member? (and (some? member) (contains? organization-member-ids (:id member)))]
[member org-member-ids]
(when (some? org-member-ids)
(let [is-member? (and (some? member) (contains? org-member-ids (:id member)))]
(when-not is-member?
(ex/raise :type :validation
:code :email-not-organization-member
:code :email-not-org-member
:hint "The invited email is not a member of the organization")))))
(defn- create-invitation
[{:keys [::db/conn] :as cfg}
{:keys [team organization profile role email organization-member-ids all-organization-member-ids] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(if organization
(assert (check-create-organization-invitation-params params))
(assert (check-create-org-invitation-params params))
(assert (check-create-invitation-params params)))
(let [email (profile/clean-email email)
@@ -164,11 +142,11 @@
:code :email-domain-is-not-allowed
:hint "email domain is in the blacklist"))
;; When nitrate is active and the team belongs to an organization, check that
;; the email is already an organization member unless the organization explicitly allows adding anybody.
(when (and (contains? cf/flags :admin-console)
;; When nitrate is active and the team belongs to an org, check that
;; the email is already an org member unless the org explicitly allows adding anybody.
(when (and (contains? cf/flags :nitrate)
(:organization team))
(assert-email-can-be-invited member organization-member-ids))
(assert-email-can-be-invited member org-member-ids))
;; When we have email verification disabled and invitation user is
@@ -184,9 +162,9 @@
(get types.team/permissions-for-role role))]
(if organization
;; Insert the invited member to the organization
(when (contains? cf/flags :admin-console)
(teams/initialize-user-in-organization cfg (:id member) (:id organization) email))
;; Insert the invited member to the org
(when (contains? cf/flags :nitrate)
(teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization) email))
;; Insert the invited member to the team
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
@@ -204,79 +182,57 @@
(teams/check-email-bounce conn email true)
(teams/check-email-spam conn email true)
(let [id (uuid/next)
expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days
recent? (recently-invited? cfg (:id team) (:id organization) email)
invitation (db/exec-one! conn (if organization
[sql:upsert-organization-invitation id
(:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation))
profile-id (:id profile)
team-organization-id (get-in team [:organization :id])
tprops {:profile-id profile-id
: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}
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)]
(let [id (uuid/next)
expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days
invitation (db/exec-one! conn (if organization
[sql:upsert-org-invitation id
(:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation))
profile-id (:id profile)
tprops {:profile-id profile-id
: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}
itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile-id)]
(when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken))
(let [props (audit/clean-props audit-props)
(let [props (-> (dissoc tprops :profile-id)
(audit/clean-props))
evname (cond
(and updated? organization) "update-organization-invitation"
(and updated? organization) "update-org-invitation"
updated? "update-team-invitation"
organization "create-organization-invitation"
organization "create-org-invitation"
:else "create-team-invitation")
event (-> (audit/event-from-rpc-params params)
(assoc :name evname)
(assoc :props props))]
(audit/submit cfg event))
(when (and (allow-invitation-emails? member)
(not recent?))
(when (allow-invitation-emails? member)
(if organization
(when (contains? cf/flags :admin-console)
(when (contains? cf/flags :nitrate)
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-organization
::eml/factory eml/invite-to-org
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
@@ -290,13 +246,13 @@
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (:organization team)
:organization (dm/get-in team [:organization :name])
:token itoken
:extra-data ptoken})))
itoken)))))
(defn create-organization-invitation
(defn create-org-invitation
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (db/get-by-id cfg :profile profile-id)]
(create-invitation cfg
@@ -366,21 +322,16 @@
- emails (set) + role (single role for all emails)
- invitations (vector of {:email :role} maps)"
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
(let [;; Enrich team with organization info once for all invitations when nitrate is active
team (if (contains? cf/flags :admin-console)
(nitrate/add-organization-info-to-team cfg team {})
(let [;; Enrich team with org info once for all invitations when nitrate is active
team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {})
team)
organization (:organization team)
organization-id (:id organization)
restricted? (and organization-id (not (cto/allowed? :add-anybody-to-team {:organization-perms organization})))
all-organization-member-ids
(when organization-id
(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)
org (:organization team)
org-id (:id org)
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
org-member-ids (when restricted?
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
params (assoc params :team team :org-member-ids org-member-ids)
;; Normalize input to a consistent format: [{:email :role}]
invitation-data (cond
@@ -588,7 +539,7 @@
::doc/module :teams
::sm/params schema:get-team-invitation-token}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
(teams/check-read-permissions! cfg profile-id team-id)
(teams/check-read-permissions! pool profile-id team-id)
(let [email (profile/clean-email email)
invit (-> (db/get pool :team-invitation
{:team-id team-id
+65 -148
View File
@@ -85,33 +85,13 @@
::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))
(defn- with-nitrate-licence
[profile cfg]
(if (contains? cf/flags :admin-console)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
(defmethod process-token :auth
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}]
(let [profile (-> (profile/get-profile conn profile-id)
(profile/strip-private-attrs)
(update :props profile/filter-props)
(with-nitrate-licence cfg))]
(let [profile (profile/get-profile conn profile-id)]
(assoc claims :profile profile)))
;; --- 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
[{:keys [::db/conn] :as cfg}
{:keys [team-id organization-id role member-email] :as claims} invitation member]
@@ -135,9 +115,9 @@
(get types.team/permissions-for-role role))
accepted-team-id (if organization-id
;; Insert the invited member to the organization
(when (contains? cf/flags :admin-console)
(teams/initialize-user-in-organization cfg id-member organization-id member-email))
;; Insert the invited member to the org
(when (contains? cf/flags :nitrate)
(teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email))
;; Insert the invited member to the team
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
team-id))]
@@ -156,11 +136,10 @@
{:id id-member}))
;; Delete the invitation
(if organization-id
(db/exec-one! conn [sql:delete-organization-invitation member-email organization-id])
(db/delete! conn :team-invitation
{:email-to member-email
:team-id team-id}))
(db/delete! conn :team-invitation
(cond-> {:email-to member-email}
team-id (assoc :team-id team-id)
organization-id (assoc :org-id organization-id)))
;; Delete any request (only applicable for team invitations)
(when team-id
@@ -196,17 +175,22 @@
:code :invalid-invitation-token
:hint "invitation token contains unexpected data"))
(let [invitation (if organization-id
(db/exec-one! conn [sql:get-organization-invitation member-email organization-id])
(db/get* conn :team-invitation
{:email-to member-email
:team-id team-id}))
(let [invitation (db/get* conn :team-invitation
(cond-> {:email-to member-email}
team-id (assoc :team-id team-id)
organization-id (assoc :org-id organization-id)))
profile (db/get* conn :profile
{:id profile-id}
{:columns [:id :email :default-team-id]})
registration-disabled? (not (contains? cf/flags :registration))
organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
;; Membership only makes sense for a logged-in profile; querying it for
;; an anonymous recipient would call nitrate with a nil profile-id and
;; mask the clean :invalid-token response with a generic error.
membership (when (and profile org-invitation?)
(nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id}))]
(if profile
(do
@@ -217,130 +201,62 @@
:reason :email-mismatch
:hint "logged-in user does not matches the invitation"))
(when (:is-member membership)
(ex/raise :type :validation
:code :already-an-org-member
:team-id (:default-team-id membership)
:hint "the user is already a member of the organization"))
(when (and org-invitation? (not (:organization-id membership)))
(ex/raise :type :validation
:code :org-not-found
:team-id (:default-team-id profile)
:hint "the organization doesn't exist"))
(when (nil? invitation)
(ex/raise :type :validation
:code (if organization-id :canceled-invitation :invalid-token)
:hint (if organization-id
"the invitation has been canceled"
"no invitation associated with the token")))
:code :invalid-token
:hint "no invitation associated with the token"))
;; Membership only makes sense for a logged-in profile with an
;; existing invitation; querying it when the invitation is absent
;; would call nitrate needlessly and could mask the clean
;; :canceled-invitation/:invalid-token response with a generic error.
(let [membership
(when (contains? cf/flags :admin-console)
(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})))
;; if we have logged-in user and it matches the invitation we proceed
;; with accepting the invitation and joining the current profile to the
;; invited team.
(let [props {:team-id (:team-id claims)
:role (:role claims)
:invitation-id (:id invitation)}]
organization-id-on-add
(when (and (:organization-id membership)
(not (:is-member membership)))
(:organization-id membership))
(audit/submit cfg
(-> (audit/event-from-rpc-params params)
(assoc :name "accept-team-invitation")
(assoc :props props)))
organization-add-source
(when organization-id-on-add
(if organization-id
"direct-organization-invitation"
"team-invitation"))
;; NOTE: Backward compatibility; old invitations can
;; have the `created-by` to be nil; so in this case we
;; don't submit this event to the audit-log
(when-let [created-by (:created-by invitation)]
(audit/submit cfg
(-> (audit/event-from-rpc-params params)
(assoc :profile-id created-by)
(assoc :name "accept-team-invitation-from")
(assoc :props (assoc props
:profile-id (:id profile)
:email (:email profile))))))
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 organization-invitation?
(ex/raise :type :validation
:code :already-an-organization-member
:team-id (:default-team-id membership)
:hint "the user is already a member of the organization")))
(when (and organization-invitation? (not (:organization-id membership)))
(ex/raise :type :validation
:code :organization-not-found
:team-id (:default-team-id profile)
:hint "the organization doesn't exist"))
;; if we have logged-in user and it matches the invitation we proceed
;; with accepting the invitation and joining the current profile to the
;; invited team.
(let [props {:team-id (:team-id claims)
:role (:role claims)
:invitation-id (:id invitation)}]
(when team-id
(audit/submit cfg
(-> (audit/event-from-rpc-params params)
(assoc :name "accept-team-invitation")
(assoc :props props)))
;; NOTE: Backward compatibility; old invitations can
;; have the `created-by` to be nil; so in this case we
;; don't submit this event to the audit-log
(when-let [created-by (:created-by invitation)]
(audit/submit cfg
(-> (audit/event-from-rpc-params params)
(assoc :profile-id created-by)
(assoc :name "accept-team-invitation-from")
(assoc :props (assoc props
:profile-id (:id profile)
:email (:email profile)))))))
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
(when organization-id-on-add
(audit/submit
cfg
(-> (audit/event-from-rpc-params params)
(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))}))))))
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
(cond-> (assoc claims :state :created)
;; when the invitation is to an org, instead of a team, add the
;; accepted-team-id as :org-team-id
(:organization-id claims)
(assoc :org-team-id accepted-team-id)))))
(do
;; 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
;; login with an appropriate message instead of showing the error page.
;; This only applies to organization invitations; team invitations keep the
;; existing :invalid-token behavior.
;; If the user is not logged-in and the token is invalid we throw the error
;; Taiga issue #14182
(when (nil? invitation)
(ex/raise :type :validation
:code (if organization-id :canceled-invitation :invalid-token)
:hint (if organization-id
"the invitation has been canceled"
"no invitation associated with the token")))
:code :invalid-token
:hint "no invitation associated with the token"))
;; If we have not logged-in user, and invitation comes with member-id we
;; redirect user to login, if no member-id is present and in the invitation
@@ -356,3 +272,4 @@
[_ _ _]
(ex/raise :type :validation
:code :invalid-token))
+4 -3
View File
@@ -16,7 +16,6 @@
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
[app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.util.services :as sv]
[cuerdas.core :as str]))
@@ -126,8 +125,8 @@
::sm/params schema:get-view-only-bundle}
[system {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! system
(fn [system]
(let [perms (perms/get-file-read-permissions system profile-id file-id share-id)
(fn [{:keys [::db/conn] :as system}]
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
params (-> params
(assoc ::perms perms)
(assoc :profile-id profile-id))]
@@ -140,3 +139,5 @@
:hint "object not found"))
(get-view-only-bundle system params)))))
+8 -6
View File
@@ -23,9 +23,11 @@
[cuerdas.core :as str]))
(defn get-webhooks-permissions
[conn profile-id team-id]
[conn profile-id team-id creator-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)))
(def has-webhook-edit-permissions?
@@ -118,7 +120,7 @@
{::doc/added "1.17"
::sm/params schema:create-webhook}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
(t/check-edition-permissions! pool profile-id team-id)
(check-webhook-edition-permissions! pool profile-id team-id profile-id)
(validate-quotes! cfg params)
(validate-webhook! cfg nil params)
(insert-webhook! cfg params))
@@ -135,7 +137,7 @@
::sm/params schema:update-webhook}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
(check-webhook-edition-permissions! pool profile-id (:team-id whook))
(check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook))
(validate-webhook! cfg whook params)
(update-webhook! cfg whook params)))
@@ -149,7 +151,7 @@
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
(check-webhook-edition-permissions! conn profile-id (:team-id whook))
(check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook))
(db/delete! conn :webhook {:id id})
nil))
@@ -170,6 +172,6 @@
::sm/params schema:get-webhooks}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! cfg profile-id team-id)
(check-read-permissions! conn profile-id team-id)
(->> (db/exec! conn [sql:get-webhooks team-id])
(mapv decode-row))))
+1 -1
View File
@@ -10,7 +10,7 @@
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.media.validation :refer [schema:upload]]
[app.media :refer [schema:upload]]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.storage :as sto]
+175 -359
View File
@@ -8,34 +8,27 @@
"Internal Nitrate HTTP RPC API. Provides authenticated access to
organization management and token validation endpoints."
(:require
[app.auth :as aauth]
[app.auth.oidc :as oidc]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar]]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.http :as-alias http]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.media.validation :as media.v]
[app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as rpc]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files :as files]
[app.rpc.commands.nitrate :as cnit]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.commands.teams-invitations :as ti]
[app.rpc.doc :as doc]
[app.rpc.nitrate.emails-helper :as neh]
[app.rpc.nitrate.organization-helper :as noh]
[app.rpc.notifications :as notifications]
[app.storage :as sto]
[app.util.services :as sv]
@@ -47,7 +40,6 @@
{:id (:id profile)
:name (:fullname profile)
:email (:email profile)
:created-at (:created-at profile)
:photo-url (files/resolve-public-uri (get profile :photo-id))})
;; ---- API: authenticate
@@ -56,8 +48,7 @@
"Authenticate the current user"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:profile
::nitrate/sso false}
::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
(-> (profile-to-map profile)
@@ -108,32 +99,30 @@
"List teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:get-teams-result
::nitrate/sso false}
::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name])))))
;; ---- API: upload-organization-logo
;; ---- API: upload-org-logo
(def ^:private schema:upload-organization-logo
(def ^:private schema:upload-org-logo
[:map
[:content media.v/schema:upload]
[:content media/schema:upload]
[:organization-id ::sm/uuid]
[:previous-id {:optional true} ::sm/uuid]])
(def ^:private schema:upload-organization-logo-result
(def ^:private schema:upload-org-logo-result
[:map [:id ::sm/uuid]])
(sv/defmethod ::upload-organization-logo
(sv/defmethod ::upload-org-logo
"Store an organization logo in penpot storage and return its ID.
Accepts an optional previous-id to mark the old logo for garbage
collection when replacing an existing one."
{::doc/added "2.17"
::sm/params schema:upload-organization-logo
::sm/result schema:upload-organization-logo-result
::nitrate/sso false}
::sm/params schema:upload-org-logo
::sm/result schema:upload-org-logo-result}
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
(when previous-id
(sto/touch-object! storage previous-id))
@@ -152,7 +141,7 @@
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate"
{::doc/added "2.14"
::sm/params cto/schema:team-with-organization
::sm/params schema:team-with-organization
::rpc/auth false}
[cfg team]
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
@@ -167,12 +156,12 @@
[:role ::sm/text]])
(sv/defmethod ::notify-user-added-to-organization
"Notify to Penpot that an user has joined an organization from nitrate"
"Notify to Penpot that an user has joined an org from nitrate"
{::doc/added "2.14"
::sm/params schema:notify-user-added-to-organization
::rpc/auth false}
[cfg {:keys [profile-id organization-id]}]
(db/tx-run! cfg teams/create-default-organization-team profile-id organization-id))
(db/tx-run! cfg teams/create-default-org-team profile-id organization-id))
;; ---- API: get-managed-profiles
@@ -201,8 +190,7 @@
"List profiles that belong to teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:managed-profile-result
::nitrate/sso false}
::sm/result schema:managed-profile-result}
[cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))
@@ -241,8 +229,7 @@
"Get summary information for a list of teams"
{::doc/added "2.15"
::sm/params schema:get-teams-summary-params
::sm/result schema:get-teams-summary-result
::nitrate/sso false}
::sm/result schema:get-teams-summary-result}
[cfg {:keys [ids]}]
(let [;; Handle one or multiple params
ids (cond
@@ -314,7 +301,7 @@ RETURNING id, deleted_at;")
nil)
(defn manage-deleted-organization-teams
"For a deleted organization, preserve organization teams unchanged and only prefix or
"For a deleted organization, preserve org teams unchanged and only prefix or
delete member Your Penpot teams depending on whether they still contain files."
[cfg {:keys [organization-id organization-name teams]}]
(let [all-team-ids (->> teams
@@ -329,7 +316,7 @@ RETURNING id, deleted_at;")
distinct
(into []))]
(when (seq all-team-ids)
(let [organization-prefix (str "[" (d/sanitize-string organization-name) "] ")]
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as cfg}]
@@ -343,11 +330,11 @@ RETURNING id, deleted_at;")
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
;; Organization teams move to the fallback organization unchanged. Only imported
;; Your Penpot teams keep the organization prefix when they still have files.
;; Org teams move to the fallback org unchanged. Only imported
;; Your Penpot teams keep the org prefix when they still have files.
(when (seq teams-to-prefix)
(db/exec! conn [sql:prefix-teams-name-and-unset-default
organization-prefix
org-prefix
(db/create-array conn "uuid" teams-to-prefix)]))
;; Empty imported Your Penpot teams disappear entirely.
@@ -358,16 +345,16 @@ RETURNING id, deleted_at;")
(sv/defmethod ::notify-organization-deletion
"For a deleted organization, preserve organization teams and only prefix or delete
"For a deleted organization, preserve org teams and only prefix or delete
imported Your Penpot teams before notifying connected users."
{::doc/added "2.15"
::sm/params schema:notify-organization-deletion
::rpc/auth false}
[cfg {:keys [organization-id]}]
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
teams (:teams organization-summary)]
(manage-deleted-organization-teams cfg {:organization-name (:name organization-summary)
:organization-id (:id organization-summary)
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
teams (:teams org-summary)]
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary)
:organization-id (:id org-summary)
:teams teams})
nil))
@@ -378,18 +365,17 @@ RETURNING id, deleted_at;")
[:profile-id ::sm/uuid]])
(sv/defmethod ::notify-user-organizations-deletion
"For a given user, find all owned organizations and apply the deleted-organization
"For a given user, find all owned organizations and apply the deleted-org
transfer rules to their imported Your Penpot teams."
{::doc/added "2.18"
::sm/params schema:notify-user-organizations-deletion
::nitrate/sso false}
::sm/params schema:notify-user-organizations-deletion}
[cfg {:keys [profile-id]}]
(let [owned-organizations (nitrate/call cfg :get-owned-organizations {:profile-id profile-id})]
(doseq [organization owned-organizations]
(let [organization-name (:name organization)
teams (:teams organization)]
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
(doseq [org owned-orgs]
(let [organization-name (:name org)
teams (:teams org)]
(manage-deleted-organization-teams cfg {:organization-name organization-name
:organization-id (:id organization)
:organization-id (:id org)
:teams teams}))))
nil)
@@ -408,8 +394,7 @@ RETURNING id, deleted_at;")
"Get profile by email"
{::doc/added "2.15"
::sm/params [:map [:email ::sm/email]]
::sm/result schema:profile
::nitrate/sso false}
::sm/result schema:profile}
[cfg {:keys [email]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
(when-not profile
@@ -432,8 +417,7 @@ RETURNING id, deleted_at;")
"Get profile by email"
{::doc/added "2.15"
::sm/params [:map [:id ::sm/uuid]]
::sm/result schema:profile
::nitrate/sso false}
::sm/result schema:profile}
[cfg {:keys [id]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
(when-not profile
@@ -444,9 +428,9 @@ RETURNING id, deleted_at;")
(profile-to-map profile)))
;; ---- API: get-organization-member-team-counts
;; ---- API: get-org-member-team-counts
(def ^:private sql:get-organization-member-team-counts
(def ^:private sql:get-org-member-team-counts
"SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
FROM team_profile_rel AS tpr
JOIN team AS t ON t.id = tpr.team_id
@@ -455,19 +439,19 @@ RETURNING id, deleted_at;")
AND t.is_default IS FALSE
GROUP BY tpr.profile_id;")
(def ^:private schema:get-organization-member-team-counts-params
(def ^:private schema:get-org-member-team-counts-params
[:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
(def ^:private schema:get-organization-member-team-counts-result
(def ^:private schema:get-org-member-team-counts-result
[:vector [:map
[:profile-id ::sm/uuid]
[:team-count ::sm/int]]])
(sv/defmethod ::get-organization-member-team-counts
(sv/defmethod ::get-org-member-team-counts
"Get the number of non-default teams each profile belongs to within a set of teams."
{::doc/added "2.15"
::sm/params schema:get-organization-member-team-counts-params
::sm/result schema:get-organization-member-team-counts-result
::sm/params schema:get-org-member-team-counts-params
::sm/result schema:get-org-member-team-counts-result
::rpc/auth false}
[cfg {:keys [team-ids]}]
(let [team-ids (cond
@@ -483,30 +467,46 @@ RETURNING id, deleted_at;")
[]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:get-organization-member-team-counts ids-array])))))))
(db/exec! conn [sql:get-org-member-team-counts ids-array])))))))
;; API: invite-to-organization
;; API: invite-to-org
(sv/defmethod ::invite-to-organization
(sv/defmethod ::invite-to-org
"Invite to organization"
{::doc/added "2.15"
::sm/params [:map
[:email ::sm/email]
[:organization cto/schema:organization-with-avatar]]
::nitrate/sso false}
[:organization schema:organization-with-avatar]]}
[cfg params]
(db/tx-run! cfg ti/create-organization-invitation params)
(db/tx-run! cfg ti/create-org-invitation params)
nil)
;; API: get-organization-invitations
;; API: get-org-invitations
(def ^:private schema:get-organization-invitations-params
(def ^:private sql:get-org-invitations
"SELECT DISTINCT ON (email_to)
ti.id,
ti.org_id AS organization_id,
ti.email_to AS email,
ti.created_at AS sent_at,
p.fullname AS name,
p.id AS profile_id,
p.photo_id
FROM team_invitation AS ti
LEFT JOIN profile AS p
ON p.email = ti.email_to
AND p.deleted_at IS NULL
WHERE ti.valid_until >= now()
AND (ti.org_id = ? OR ti.team_id = ANY(?))
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
(def ^:private schema:get-org-invitations-params
[:map
[:organization-id ::sm/uuid]])
(def ^:private schema:get-organization-invitations-result
(def ^:private schema:get-org-invitations-result
[:vector
[:map
[:id ::sm/uuid]
@@ -517,75 +517,84 @@ RETURNING id, deleted_at;")
[:profile-id {:optional true} [:maybe ::sm/uuid]]
[:photo-url {:optional true} ::sm/uri]]])
(sv/defmethod ::get-organization-invitations
(sv/defmethod ::get-org-invitations
"Get valid invitations for an organization, returning at most one invitation per email."
{::doc/added "2.16"
::sm/params schema:get-organization-invitations-params
::sm/result schema:get-organization-invitations-result
::nitrate/sso false}
::sm/params schema:get-org-invitations-params
::sm/result schema:get-org-invitations-result}
[cfg {:keys [organization-id]}]
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
team-ids (->> (:teams org-summary)
(map :id)
(filter uuid?)
(into []))]
(db/run! cfg (fn [{:keys [::db/conn]}]
(->> (noh/get-organization-invitations conn organization-id team-ids)
(mapv (fn [{:keys [photo-id] :as invitation}]
(cond-> (dissoc invitation :photo-id)
photo-id
(assoc :photo-url (files/resolve-public-uri photo-id))))))))))
(let [ids-array (db/create-array conn "uuid" team-ids)]
(->> (db/exec! conn [sql:get-org-invitations organization-id ids-array])
(mapv (fn [{:keys [photo-id] :as invitation}]
(cond-> (dissoc invitation :photo-id)
photo-id
(assoc :photo-url (files/resolve-public-uri photo-id)))))))))))
;; API: delete-organization-invitations
;; API: delete-org-invitations
(def ^:private sql:delete-organization-invitations
(def ^:private sql:delete-org-invitations
"DELETE FROM team_invitation AS ti
WHERE ti.email_to = ?
AND (ti.org_id = ? OR ti.team_id = ANY(?));")
(def ^:private schema:delete-organization-invitations-params
(def ^:private schema:delete-org-invitations-params
[:map
[:organization-id ::sm/uuid]
[:email ::sm/email]])
(sv/defmethod ::delete-organization-invitations
"Delete all invitations for one email in an organization scope (organization + organization teams)."
(sv/defmethod ::delete-org-invitations
"Delete all invitations for one email in an organization scope (org + org teams)."
{::doc/added "2.16"
::sm/params schema:delete-organization-invitations-params
::nitrate/sso false}
::sm/params schema:delete-org-invitations-params}
[cfg {:keys [organization-id email]}]
(let [clean-email (profile/clean-email email)
team-ids (noh/get-organization-team-ids cfg organization-id)]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
clean-email (profile/clean-email email)
team-ids (->> (:teams org-summary)
(map :id)
(filter uuid?)
(into []))]
(db/run! cfg (fn [{:keys [::db/conn]}]
(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-org-invitations clean-email organization-id ids-array]))))
nil))
;; API: delete-all-organization-invitations
;; API: delete-all-org-invitations
(def ^:private sql:delete-all-organization-invitations
(def ^:private sql:delete-all-org-invitations
"DELETE FROM team_invitation AS ti
WHERE ti.org_id = ?
OR ti.team_id = ANY(?);")
(def ^:private schema:delete-all-organization-invitations-params
(def ^:private schema:delete-all-org-invitations-params
[:map
[:organization-id ::sm/uuid]])
(sv/defmethod ::delete-all-organization-invitations
"Delete every pending invitation associated with an organization (organization-level + team-level).
(sv/defmethod ::delete-all-org-invitations
"Delete every pending invitation associated with an organization (org-level + team-level).
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."
{::doc/added "2.18"
::sm/params schema:delete-all-organization-invitations-params
::sm/params schema:delete-all-org-invitations-params
::rpc/auth false}
[cfg {:keys [organization-id]}]
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
team-ids (->> (:teams org-summary)
(map :id))]
(db/run! cfg (fn [{:keys [::db/conn]}]
(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-org-invitations organization-id ids-array]))))
nil))
;; API: remove-from-organization
;; API: remove-from-org
(def ^:private sql:get-reassign-to
"SELECT tpr.profile_id
@@ -610,7 +619,7 @@ RETURNING id, deleted_at;")
(assoc team-to-transfer :reassign-to reassign-to)))
(sv/defmethod ::remove-from-organization
(sv/defmethod ::remove-from-org
"Remove an user from an organization"
{::doc/added "2.17"
::sm/params [:map
@@ -618,14 +627,9 @@ RETURNING id, deleted_at;")
[:organization-id ::sm/uuid]
[:organization-name ::sm/text]
[:default-team-id ::sm/uuid]]
::db/transaction true
::nitrate/sso false}
[cfg {actor-profile-id ::rpc/profile-id
: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
::db/transaction true}
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
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)
@@ -633,28 +637,25 @@ RETURNING id, deleted_at;")
valid-teams-to-leave (into valid-teams-to-exit
(map add-reassign-to valid-teams-to-transfer))]
(cnit/leave-organization cfg (assoc params
:id organization-id
:name organization-name
:teams-to-delete valid-teams-to-delete-ids
:teams-to-leave valid-teams-to-leave
:skip-validation true
: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")
(cnit/leave-org cfg (assoc params
:id organization-id
:name organization-name
:teams-to-delete valid-teams-to-delete-ids
:teams-to-leave valid-teams-to-leave
:skip-validation true))
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
nil))
;; API: get-remove-from-organization-summary
;; API: get-remove-from-org-summary
(def ^:private schema:get-remove-from-organization-summary-result
(def ^:private schema:get-remove-from-org-summary-result
[:map
[:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]])
(sv/defmethod ::get-remove-from-organization-summary
(sv/defmethod ::get-remove-from-org-summary
"Get a summary of the teams that would be deleted, transferred, or exited
if the user were removed from the organization"
{::doc/added "2.17"
@@ -662,9 +663,8 @@ RETURNING id, deleted_at;")
[:profile-id ::sm/uuid]
[:organization-id ::sm/uuid]
[:default-team-id ::sm/uuid]]
::sm/result schema:get-remove-from-organization-summary-result
::db/transaction true
::nitrate/sso false}
::sm/result schema:get-remove-from-org-summary-result
::db/transaction true}
[cfg {:keys [profile-id organization-id default-team-id]}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
@@ -673,11 +673,11 @@ RETURNING id, deleted_at;")
(when-not valid-default-team
(ex/raise :type :validation
:code :not-valid-teams))
(cnit/get-leave-organization-summary cfg
default-team-id
valid-teams-to-delete-ids
(count valid-teams-to-transfer)
(count valid-teams-to-exit))))
(cnit/get-leave-org-summary cfg
default-team-id
valid-teams-to-delete-ids
(count valid-teams-to-transfer)
(count valid-teams-to-exit))))
;; API: send-renewal-email
@@ -688,7 +688,7 @@ RETURNING id, deleted_at;")
[:user-name [:maybe ::sm/text]]
[:renewal-date :string]
[:estimated-amount :double]
[:organizations [:vector cto/schema:organization-with-avatar]]])
[:organizations [:vector schema:organization-with-avatar]]])
(sv/defmethod ::send-renewal-email
"Send an Enterprise subscription renewal notice email to a user."
@@ -711,8 +711,8 @@ RETURNING id, deleted_at;")
:organizations organizations}))))
nil)
;; API: exists-organization-team-invitations-for-non-members /
;; delete-organization-team-invitations-for-non-members
;; API: exists-org-team-invitations-for-non-members /
;; delete-org-team-invitations-for-non-members
(def ^:private sql:get-profile-emails-by-ids
"SELECT email
@@ -720,7 +720,7 @@ RETURNING id, deleted_at;")
WHERE id = ANY(?)
AND deleted_at IS NULL")
(def ^:private sql:exists-non-member-organization-team-invitations
(def ^:private sql:exists-non-member-org-team-invitations
"SELECT EXISTS (
SELECT 1
FROM team_invitation
@@ -728,22 +728,22 @@ RETURNING id, deleted_at;")
AND email_to <> ALL(?)
) AS non_member")
(def ^:private sql:delete-non-member-organization-team-invitations
(def ^:private sql:delete-non-member-org-team-invitations
"DELETE FROM team_invitation
WHERE team_id = ANY(?)
AND email_to <> ALL(?)
RETURNING email_to")
(def ^:private schema:organization-team-invitations-for-non-members-params
(def ^:private schema:org-team-invitations-for-non-members-params
[:map
[:team-ids [:vector ::sm/uuid]]
[:member-ids [:vector ::sm/uuid]]])
(def ^:private schema:exists-organization-team-invitations-for-non-members-result
(def ^:private schema:exists-org-team-invitations-for-non-members-result
[:map [:exists ::sm/boolean]])
(defn- organization-team-invitations-for-non-members-arrays
"Member emails and PG arrays used by exists/delete organization team invitation endpoints."
(defn- org-team-invitations-for-non-members-arrays
"Member emails and PG arrays used by exists/delete org team invitation endpoints."
[conn {:keys [team-ids 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])
@@ -752,36 +752,34 @@ RETURNING id, deleted_at;")
{:emails-array (db/create-array conn "text" (vec member-emails))
:teams-array (db/create-array conn "uuid" team-ids)}))
(defn- non-member-organization-team-invitations-exist?
(defn- non-member-org-team-invitations-exist?
[conn params]
(let [{:keys [emails-array teams-array]}
(organization-team-invitations-for-non-members-arrays conn params)]
(-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations
(org-team-invitations-for-non-members-arrays conn params)]
(-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations
teams-array
emails-array])
:non-member)))
(sv/defmethod ::exists-organization-team-invitations-for-non-members
(sv/defmethod ::exists-org-team-invitations-for-non-members
"Return if there are any team invitations for emails that are not organization members."
{::doc/added "2.18"
::sm/params schema:organization-team-invitations-for-non-members-params
::sm/result schema:exists-organization-team-invitations-for-non-members-result
::nitrate/sso false}
::sm/params schema:org-team-invitations-for-non-members-params
::sm/result schema:exists-org-team-invitations-for-non-members-result}
[cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}]
{:exists (boolean (non-member-organization-team-invitations-exist? conn params))})))
{:exists (boolean (non-member-org-team-invitations-exist? conn params))})))
(sv/defmethod ::delete-organization-team-invitations-for-non-members
(sv/defmethod ::delete-org-team-invitations-for-non-members
"Delete team invitations for emails that are not organization members."
{::doc/added "2.18"
::sm/params schema:organization-team-invitations-for-non-members-params
::db/transaction true
::nitrate/sso false}
::sm/params schema:org-team-invitations-for-non-members-params
::db/transaction true}
[cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [emails-array teams-array]}
(organization-team-invitations-for-non-members-arrays conn params)]
(db/exec! conn [sql:delete-non-member-organization-team-invitations
(org-team-invitations-for-non-members-arrays conn params)]
(db/exec! conn [sql:delete-non-member-org-team-invitations
teams-array
emails-array])
nil))))
@@ -792,235 +790,53 @@ RETURNING id, deleted_at;")
[:map {:title "NitrateAuditEvent"}
[:name [:and [:string {:max 250}]
[:re #"[\d\w-]{1,50}"]]]
[:type {:optional true} ::sm/text]
[:profile-id ::sm/uuid]
[:props {:optional true} [:map-of :keyword :any]]
[:context {:optional true} [:map-of :keyword :any]]])
[:props {:optional true} [:map-of :keyword :any]]])
(def ^:private schema:push-audit-events-params
[:map {:title "PushAuditEventsParams"}
[:events [:vector schema:nitrate-audit-event]]])
(sv/defmethod ::push-audit-events
"Push audit events from nitrate (strictly for nitrate backend
events)"
(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
"Push audit events from Nitrate to Penpot audit log"
{::doc/added "2.19"
::audit/skip true
::sm/params schema:push-audit-events-params
::rpc/auth false}
[cfg {:keys [::rpc/request-at events] :as params}]
(let [request (-> params meta ::http/request)
context' (-> (audit/prepare-context-from-request request)
(assoc :request-id (::rpc/request-id params)))
ip-addr (::rpc/ip-addr params)]
(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)
[{:keys [::db/pool] :as cfg} {:keys [events]}]
(let [telemetry? (contains? cf/flags :telemetry)
audit-log? (contains? cf/flags :audit-log)
enabled? (and (not (db/read-only? pool))
(or audit-log? telemetry?))]
(when (and enabled? (seq events))
(run! (partial submit-nitrate-audit-event cfg) events))
nil))
;; ---- API: get-teams-detail
;; ---- API: notify-org-sso-change
(def ^:private sql:get-teams-detail
"SELECT
t.id,
t.name,
t.photo_id,
t.created_at,
(SELECT MAX(activity.modified_at)
FROM (
SELECT p2.modified_at
FROM project AS p2
WHERE p2.team_id = t.id
AND p2.deleted_at IS NULL
AND p2.is_default IS FALSE
UNION ALL
SELECT f.modified_at
FROM file AS f
JOIN project AS p ON p.id = f.project_id
WHERE p.team_id = t.id
AND p.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,
owner_tpr.profile_id AS owner_profile_id,
owner_p.fullname AS owner_name,
owner_p.photo_id AS owner_photo_id,
(SELECT COUNT(*)
FROM project AS p3
WHERE p3.team_id = t.id
AND p3.deleted_at IS NULL
AND p3.is_default IS FALSE) AS num_projects,
(SELECT COUNT(*)
FROM file AS f
JOIN project AS p4 ON p4.id = f.project_id
WHERE p4.team_id = t.id
AND f.deleted_at IS NULL
AND p4.deleted_at IS NULL) AS num_files,
(SELECT COUNT(*)
FROM team_profile_rel AS tpr
WHERE tpr.team_id = t.id) AS num_members
FROM team AS t
LEFT JOIN team_profile_rel AS owner_tpr
ON owner_tpr.team_id = t.id AND owner_tpr.is_owner IS TRUE
LEFT JOIN profile AS owner_p
ON owner_p.id = owner_tpr.profile_id
WHERE t.id = ANY(?)
AND t.deleted_at IS NULL
AND t.is_default IS FALSE
ORDER BY last_activity_at DESC NULLS LAST")
(def ^:private schema:get-teams-detail-params
[:map
[:organization-id ::sm/uuid]])
(def ^:private schema:get-teams-detail-result
[:vector
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:photo-url {:optional true} ::sm/uri]
[:created-at ::sm/inst]
[:last-activity-at {:optional true} [:maybe ::sm/inst]]
[:owner-profile-id {:optional true} [:maybe ::sm/uuid]]
[:owner-name {:optional true} [:maybe ::sm/text]]
[:owner-photo-url {:optional true} ::sm/uri]
[:num-projects ::sm/int]
[:num-files ::sm/int]
[:num-members ::sm/int]]])
(sv/defmethod ::get-teams-detail
"Get detailed information for all non-deleted teams in an organization,
including owner info and project/file/member counts."
{::doc/added "2.20"
::sm/params schema:get-teams-detail-params
::sm/result schema:get-teams-detail-result
::nitrate/sso false}
[cfg {:keys [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 organization-summary))]
(if (empty? team-ids)
[]
(db/run! cfg
(fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(->> (db/exec! conn [sql:get-teams-detail ids-array])
(mapv (fn [{:keys [photo-id owner-photo-id] :as row}]
(cond-> (dissoc row :photo-id :owner-photo-id)
photo-id (assoc :photo-url (files/resolve-public-uri photo-id))
owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id))))))))))))
;; ---- API: check-organization-sso
(def ^:private schema:check-organization-sso-result
[:map
[:valid ::sm/boolean]])
(sv/defmethod ::check-organization-sso
"Validate an organization SSO configuration by generating a login redirect URL.
Nitrate calls this while configuring SSO to verify client credentials and OIDC
discovery before saving the settings."
{::doc/added "2.20"
::sm/params cto/schema:nitrate-sso
::sm/result schema:check-organization-sso-result
::rpc/auth false}
[cfg params]
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
;; ---- API: notify-organization-sso-change
(sv/defmethod ::notify-organization-sso-change
(sv/defmethod ::notify-org-sso-change
"Nitrate notifies that an organization sso values have changed"
{::doc/added "2.19"
::sm/params [:map
[:organization-id ::sm/uuid]
[:updated-props ::sm/boolean]
[:announce-activation ::sm/boolean]]
[:updated-props ::sm/boolean]]
::rpc/auth false}
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}]
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}]
(when updated-props
(rpc/invalidate-organization-sso-cache-by-organization! organization-id)
(session/clear-organization-sso-sessions! pool organization-id))
(rpc/invalidate-org-sso-cache-by-org! organization-id)
(session/clear-org-sso-sessions! pool organization-id))
(notifications/notify-organization-change-sso cfg organization-id)
(when announce-activation
(neh/send-organization-setup-sso-emails! cfg organization-id))
nil)
;; ---- API: bulk-create-profiles
(def ^:private schema:bulk-create-profiles-params
[:map
[:password [::sm/word-string {:max 500}]]
[:emails [:vector ::sm/email]]])
(def ^:private schema:bulk-create-profiles-result
[:map
[:created [:vector ::sm/email]]
[:skipped [:vector ::sm/email]]])
(defn- create-active-profile!
"Create a single already-active profile (email pre-verified, onboarding
skipped) plus its default team. Returns nil; existence checks happen in the
caller so duplicates are skipped instead of aborting the whole batch."
[cfg email password]
(let [fullname (-> (str/split email "@") first)]
(->> {:email email
:fullname fullname
:password password
:is-active true
:props {:onboarding-viewed true}}
(auth/create-profile cfg)
(auth/create-profile-rels cfg))
nil))
(sv/defmethod ::bulk-create-profiles
"Create multiple already-active profiles that share a single password. The
created users skip email verification and onboarding. Emails that already
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
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"
::sm/params schema:bulk-create-profiles-params
::sm/result schema:bulk-create-profiles-result
::rpc/auth false}
[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)]
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as cfg}]
(reduce
(fn [acc email]
(let [email (eml/clean email)]
(if (profile/get-profile-by-email conn email)
(update acc :skipped conj email)
(do
(create-active-profile! cfg email derived)
(update acc :created conj email)))))
{:created [] :skipped []}
emails)))))
@@ -1,104 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.nitrate.emails-helper
"Helpers for organization SSO notification emails triggered by Nitrate integration."
(:require
[app.common.data :as d]
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.nitrate :as nitrate]
[app.rpc.commands.teams :as teams]
[app.rpc.nitrate.organization-helper :as neh]
[cuerdas.core :as str]))
(def ^:private sql:get-profile-emails-by-ids
"SELECT email
FROM profile
WHERE id = ANY(?)
AND deleted_at IS NULL")
(def ^:private sql:get-profiles-by-emails
"SELECT id, email, is_muted
FROM profile
WHERE email = ANY(?)
AND deleted_at IS NULL")
(defn- organization-sso-active?
"Return whether SSO is enabled for the organization."
[cfg organization-id]
(when (contains? cf/flags :admin-console)
(true? (:active (nitrate/call cfg :get-organization-sso {:organization-id organization-id})))))
(def ^:private xf:map-email (map :email))
(defn- recipients-by-emails
"Build `{:email :profile}` maps for a deduplicated email list."
[conn emails]
(let [profiles (if (seq emails)
(let [emails-array (db/create-array conn "text" emails)]
(db/exec! conn [sql:get-profiles-by-emails emails-array]))
[])
profile-by-email (d/index-by (comp str/lower :email) profiles)]
(map (fn [email]
(let [profile (get profile-by-email (str/lower email))]
{:email email
:profile profile}))
emails)))
(defn- send-organization-setup-sso-email!
"Send the organization SSO setup email to a single recipient, when allowed."
[conn organization-name {:keys [email profile]}]
(when (or (nil? profile)
(eml/allow-send-emails? conn profile))
(eml/send! {::eml/conn conn
::eml/factory eml/organization-setup-sso
:public-uri (cf/get :public-uri)
:to email
:organization-name organization-name})))
(defn- get-organization-sso-notify-recipients
"Unique organization members and pending organization/team invitees for SSO activation emails."
[conn cfg organization-id organization-summary]
(let [member-ids (nitrate/call cfg :get-organization-members {:organization-id organization-id})
team-ids (neh/get-organization-team-ids organization-summary)
member-emails (if (seq 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]))))
#{})
invite-emails (into #{} (map :email
(neh/get-organization-invitations conn organization-id team-ids)))
emails (into #{} (concat member-emails invite-emails))]
(recipients-by-emails conn emails)))
(defn- get-team-sso-notify-recipients
"Team members who are not in `organization-member-ids`, plus pending team invitations."
[conn team-id organization-member-ids]
(let [team-members (->> (teams/get-team-members conn team-id)
(remove #(contains? organization-member-ids (:id %))))
invitations (neh/get-team-invitation-emails conn team-id)]
(->> (sequence xf:map-email (concat team-members invitations))
(recipients-by-emails conn))))
(defn send-organization-setup-sso-emails!
"Notify all organization members and pending organization/team invitees that SSO is active."
[cfg organization-id]
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(doseq [recipient (get-organization-sso-notify-recipients conn cfg organization-id organization-summary)]
(send-organization-setup-sso-email! conn (:name organization-summary) recipient))))))
(defn send-organization-setup-sso-emails-for-team!
"Notify team members who are not in `organization-member-ids-before` and pending team invitees."
[cfg organization-id team-id organization-member-ids-before]
(when (organization-sso-active? cfg organization-id)
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(doseq [recipient (get-team-sso-notify-recipients conn team-id organization-member-ids-before)]
(send-organization-setup-sso-email! conn (:name organization-summary) recipient)))))))
@@ -1,60 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.nitrate.organization-helper
"Shared Nitrate organization query helpers."
(:require
[app.db :as db]
[app.nitrate :as nitrate]))
(def ^:private sql:get-organization-invitations
"SELECT DISTINCT ON (email_to)
ti.id,
ti.org_id AS organization_id,
ti.email_to AS email,
ti.created_at AS sent_at,
p.fullname AS name,
p.id AS profile_id,
p.photo_id
FROM team_invitation AS ti
LEFT JOIN profile AS p
ON p.email = ti.email_to
AND p.deleted_at IS NULL
WHERE ti.valid_until >= now()
AND (ti.org_id = ? OR ti.team_id = ANY(?))
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
(def ^:private sql:get-team-invitation-emails
"SELECT DISTINCT ON (email_to)
ti.email_to AS email
FROM team_invitation AS ti
WHERE ti.team_id = ?
AND ti.valid_until >= now()
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
(defn get-organization-team-ids
"Return team ids for an organization.
Accepts either `cfg` and `organization-id` (fetches the organization summary from
Nitrate) or an already-resolved organization summary map."
([cfg organization-id]
(get-organization-team-ids (nitrate/call cfg :get-organization-summary {:organization-id organization-id})))
([organization-summary]
(->> (:teams organization-summary)
(map :id)
(filter uuid?)
(vec))))
(defn get-organization-invitations
"Fetch valid organization-level and team-level invitations for an organization."
[conn organization-id team-ids]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:get-organization-invitations organization-id ids-array])))
(defn get-team-invitation-emails
"Return distinct valid team invitation recipient emails."
[conn team-id]
(db/exec! conn [sql:get-team-invitation-emails team-id]))
Loaded 100 of 834 files, more files were not shown because too many files have changed in this diff. Show more