mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 11:54:36 -04:00
Compare commits
311
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc55bc2a48 | ||
|
|
eb34e1c118 | ||
|
|
81e44afbe3 | ||
|
|
495e9f059e | ||
|
|
a60b648c6c | ||
|
|
b6656ee8dd | ||
|
|
86aaf642b6 | ||
|
|
c4dd04353f | ||
|
|
0ac711aa68 | ||
|
|
bf62e59f73 | ||
|
|
5906312dff | ||
|
|
25066c2f46 | ||
|
|
3d176d5390 | ||
|
|
0481408531 | ||
|
|
689d3a1be2 | ||
|
|
fb07273897 | ||
|
|
9242556da6 | ||
|
|
4f7bb94bb1 | ||
|
|
49276886f3 | ||
|
|
a2968defbe | ||
|
|
6628f0a134 | ||
|
|
636bc22cc4 | ||
|
|
aeedb96260 | ||
|
|
3e59754a25 | ||
|
|
c16b7919f9 | ||
|
|
83a3d099f6 | ||
|
|
8e713df5f0 | ||
|
|
3fba272848 | ||
|
|
23ea2bbad6 | ||
|
|
b507a6b667 | ||
|
|
3865e29b65 | ||
|
|
43e05c38bf | ||
|
|
edbe9f8215 | ||
|
|
319a2185c9 | ||
|
|
1136e5eda5 | ||
|
|
49119e0339 | ||
|
|
79da4d274d | ||
|
|
2f535c3f3f | ||
|
|
c320cecf15 | ||
|
|
141cf7f79f | ||
|
|
767f90282c | ||
|
|
eacdca3c0a | ||
|
|
b72536d312 | ||
|
|
f023c09018 | ||
|
|
32520e66e5 | ||
|
|
5ad0ff752e | ||
|
|
3fc5360df5 | ||
|
|
b907bc4cd3 | ||
|
|
73519778ae | ||
|
|
97a833f1e7 | ||
|
|
e741313add | ||
|
|
896aef8c1b | ||
|
|
328fa0559a | ||
|
|
863671135d | ||
|
|
657b5bd94e | ||
|
|
5ea3a2efb8 | ||
|
|
94cfd6e60d | ||
|
|
600c573ccd | ||
|
|
4c388f6d8b | ||
|
|
4d07510581 | ||
|
|
5e8a2b0a03 | ||
|
|
588222d409 | ||
|
|
0139c0f0e3 | ||
|
|
1b69d8c6a2 | ||
|
|
d7b55a245a | ||
|
|
fdeff910fe | ||
|
|
3fa21180fd | ||
|
|
ecaf63b204 | ||
|
|
1074cb376d | ||
|
|
0aff047301 | ||
|
|
02b0f18a83 | ||
|
|
13b2954cbf | ||
|
|
0703a3fcc1 | ||
|
|
b294aecb7c | ||
|
|
620c26b304 | ||
|
|
1ac000bc27 | ||
|
|
9042bd5129 | ||
|
|
905c230e17 | ||
|
|
552cda9aae | ||
|
|
89029060e0 | ||
|
|
07efd8fb99 | ||
|
|
31158c319a | ||
|
|
ab1c70115b | ||
|
|
9bb5861322 | ||
|
|
36553a7c6f | ||
|
|
ff32f104b9 | ||
|
|
1744d07731 | ||
|
|
94f51afb20 | ||
|
|
a2231a8bf9 | ||
|
|
f5b17a5c75 | ||
|
|
e952d9c70a | ||
|
|
3fc1e6aadd | ||
|
|
0b5db57def | ||
|
|
7fa6631005 | ||
|
|
9b0be1c750 | ||
|
|
fe85a5717e | ||
|
|
370e91c3e3 | ||
|
|
31ccfa546f | ||
|
|
91d4b16171 | ||
|
|
c8867f8c3a | ||
|
|
014ec34249 | ||
|
|
e705b6e12c | ||
|
|
972353eccb | ||
|
|
3eb50ef50e | ||
|
|
a722a33503 | ||
|
|
03f9393200 | ||
|
|
be5d9f9e9c | ||
|
|
2cb8813862 | ||
|
|
69649cd608 | ||
|
|
e5d588ca54 | ||
|
|
485cb8e5ec | ||
|
|
0ed151d2ca | ||
|
|
3e7fb07931 | ||
|
|
056cd3d379 | ||
|
|
4cd34b22e2 | ||
|
|
e14e7bb616 | ||
|
|
66201ac437 | ||
|
|
70675919c7 | ||
|
|
9f92206a1c | ||
|
|
704cd40182 | ||
|
|
cb21f6401a | ||
|
|
0999bc31b2 | ||
|
|
343865cf27 | ||
|
|
e9603f00b0 | ||
|
|
65111195a9 | ||
|
|
6bbd3fa364 | ||
|
|
d4d22ff1a4 | ||
|
|
0c02121d5d | ||
|
|
7674229a5b | ||
|
|
af00860c13 | ||
|
|
7eb188b077 | ||
|
|
535ccfb930 | ||
|
|
68dccd6ac2 | ||
|
|
678e206fe6 | ||
|
|
ff16b8bbef | ||
|
|
5ce206f904 | ||
|
|
af51c897a8 | ||
|
|
44c15480f2 | ||
|
|
f60c82300a | ||
|
|
e17f0ea7ee | ||
|
|
bb8f965cf0 | ||
|
|
a749763fb1 | ||
|
|
2f8d71e143 | ||
|
|
66bb6a11df | ||
|
|
62880cb58c | ||
|
|
8142f4949e | ||
|
|
c10c7b08aa | ||
|
|
56c3e9777e | ||
|
|
8b485b4a8b | ||
|
|
3610b81e4b | ||
|
|
5de06e8f77 | ||
|
|
caacd482af | ||
|
|
4f46700c94 | ||
|
|
c467d98c9e | ||
|
|
6e85ce5d7a | ||
|
|
3fa7943344 | ||
|
|
19bdd7081b | ||
|
|
ed85d1d1af | ||
|
|
d42f78b80e | ||
|
|
09730d10d0 | ||
|
|
c57f90d4a2 | ||
|
|
4900d4b24a | ||
|
|
4383cf183a | ||
|
|
7b9896ab32 | ||
|
|
ef3511e519 | ||
|
|
396d799c71 | ||
|
|
f20e4280fc | ||
|
|
9cb039070f | ||
|
|
9f029de578 | ||
|
|
3a0aca52c3 | ||
|
|
f9439d2942 | ||
|
|
08e42687d1 | ||
|
|
d4e87ec59d | ||
|
|
b8e3089ae7 | ||
|
|
1c917951b6 | ||
|
|
5dff551f31 | ||
|
|
fa996ab240 | ||
|
|
0ff73ae711 | ||
|
|
6c5d01283a | ||
|
|
2de08f00e8 | ||
|
|
4ea56e0b89 | ||
|
|
27392abd49 | ||
|
|
6c5618025d | ||
|
|
f2a9dd1a08 | ||
|
|
f06339fb87 | ||
|
|
c6de3ce47b | ||
|
|
a0b5c23f99 | ||
|
|
51c0129485 | ||
|
|
4c21923c4a | ||
|
|
5f35fdf217 | ||
|
|
5dadda0f2b | ||
|
|
5acc3aac99 | ||
|
|
8d98877ba7 | ||
|
|
826072c65d | ||
|
|
cddbd8e897 | ||
|
|
29ed79fcdc | ||
|
|
9aac607552 | ||
|
|
4ea83d31fc | ||
|
|
33d478b532 | ||
|
|
d9511db585 | ||
|
|
a4347451d0 | ||
|
|
7e36192034 | ||
|
|
5cc6477e3f | ||
|
|
17c344b8f5 | ||
|
|
001dba1bef | ||
|
|
c50ec233ae | ||
|
|
bdc078d5ea | ||
|
|
792d88dc4f | ||
|
|
2203482531 | ||
|
|
167aa7410f | ||
|
|
c119622ad9 | ||
|
|
95cfbf5f7c | ||
|
|
c11d3aaaa7 | ||
|
|
a006a12ab6 | ||
|
|
2c15dcdb84 | ||
|
|
176a813fb9 | ||
|
|
fd8cb957d1 | ||
|
|
89551b2415 | ||
|
|
3469867cf5 | ||
|
|
3ee5b50007 | ||
|
|
508569437a | ||
|
|
1311bafec4 | ||
|
|
f84a8687f6 | ||
|
|
ebfcb716ac | ||
|
|
80d688be93 | ||
|
|
54bef496f1 | ||
|
|
fb2ec4c617 | ||
|
|
a004f7cd84 | ||
|
|
5d933fd770 | ||
|
|
cc454de9ce | ||
|
|
0de8bce895 | ||
|
|
d4aa512247 | ||
|
|
0e25f7d8ad | ||
|
|
4eaefec43e | ||
|
|
f51db39b8d | ||
|
|
a58c0e1407 | ||
|
|
3e2826de9e | ||
|
|
ac31edab14 | ||
|
|
83d2edf256 | ||
|
|
a4ba5fc2e0 | ||
|
|
04f6549e82 | ||
|
|
0182f239ae | ||
|
|
2c4a9e0f82 | ||
|
|
841b47736b | ||
|
|
0387cdf7e4 | ||
|
|
2e32a8743e | ||
|
|
b82ab0c830 | ||
|
|
30e17eb076 | ||
|
|
40091924e2 | ||
|
|
b3585428bd | ||
|
|
6d458c80a1 | ||
|
|
64ba70e6f3 | ||
|
|
215de966ae | ||
|
|
51877c4bca | ||
|
|
782adffec8 | ||
|
|
c4e72fd7f9 | ||
|
|
8d5b16295f | ||
|
|
46f5346045 | ||
|
|
b3b3ea97db | ||
|
|
e6a49adfbc | ||
|
|
557a267176 | ||
|
|
f05fb70a8a | ||
|
|
2d4a24bf97 | ||
|
|
a9f3949abc | ||
|
|
785c5ffdfc | ||
|
|
3369abd988 | ||
|
|
5212e2202b | ||
|
|
6e61e3304b | ||
|
|
f8974288e7 | ||
|
|
d328cb4a9e | ||
|
|
14fb211733 | ||
|
|
ce1191d86f | ||
|
|
f996ef372d | ||
|
|
adbb5a8b5f | ||
|
|
2a5b6a69ad | ||
|
|
2eb9423963 | ||
|
|
15a336a249 | ||
|
|
28f3b8048a | ||
|
|
d58d816310 | ||
|
|
aedb7f9195 | ||
|
|
a6c7bd28e8 | ||
|
|
b9136e6fda | ||
|
|
3036ef473e | ||
|
|
673c87cf33 | ||
|
|
403e1ec604 | ||
|
|
0270c2a90f | ||
|
|
5ef8d35683 | ||
|
|
a530cf0dec | ||
|
|
d757f96633 | ||
|
|
8fa15f240f | ||
|
|
fa9012e55f | ||
|
|
06e6671813 | ||
|
|
1a88864cbb | ||
|
|
30be0cae71 | ||
|
|
cf4fb8d4a2 | ||
|
|
af83a0c7f7 | ||
|
|
1b3a3b4cdb | ||
|
|
121c76235f | ||
|
|
fc07a6467b | ||
|
|
46abf1c968 | ||
|
|
66c8ebf198 | ||
|
|
ea20291d2a | ||
|
|
0dbc2c54d6 | ||
|
|
4c5991514a | ||
|
|
a0d9603243 | ||
|
|
22cf4917d8 | ||
|
|
4bbf9a6617 | ||
|
|
f2bf5a3111 | ||
|
|
7c19ace0f0 | ||
|
|
e8e0d68019 | ||
|
|
5f8d9740d6 |
No files matched your search
@@ -88,6 +88,9 @@
|
||||
:dynamic-var-not-earmuffed
|
||||
{:level :off}
|
||||
|
||||
:type-mismatch
|
||||
{:level :off}
|
||||
|
||||
:used-underscored-binding
|
||||
{:level :warning}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Auto Label and Add to Project
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -9,16 +9,6 @@ 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:
|
||||
@@ -26,29 +16,21 @@ 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:
|
||||
build-bundle:
|
||||
name: Build and Upload Penpot Bundle
|
||||
# ── 1. Decide whether there is anything to build ───────────────────────
|
||||
check:
|
||||
name: Check current bundle
|
||||
runs-on: penpot-runner-01
|
||||
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 }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -63,10 +45,52 @@ 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: ${{ inputs.build_wasm }}
|
||||
BUILD_STORYBOOK: ${{ inputs.build_storybook }}
|
||||
BUILD_WASM: 'yes'
|
||||
BUILD_STORYBOOK: 'yes'
|
||||
run: ./manage.sh build-bundle
|
||||
|
||||
- name: Prepare directories for zipping
|
||||
@@ -80,18 +104,32 @@ 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-${{ steps.vars.outputs.gh_ref }}.zip --metadata bundle-version=${{ steps.vars.outputs.bundle_version }}
|
||||
aws s3 cp zips/penpot.zip \
|
||||
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
|
||||
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
|
||||
|
||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-runner-01
|
||||
timeout-minutes: 5
|
||||
needs: [check, build]
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 📦 *[PENPOT] Error building penpot bundles.*
|
||||
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
|
||||
Bundle version: `${{ steps.vars.outputs.bundle_version }}`
|
||||
📄 Triggered from ref: `${{ needs.check.outputs.gh_ref || inputs.gh_ref }}`
|
||||
Bundle version: `${{ needs.check.outputs.bundle_version || 'n/a' }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
@@ -11,8 +11,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
build_wasm: "yes"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -20,3 +18,9 @@ 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"
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Admin Console Docker Builder
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch or ref to build in penpot-nitrate'
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
dispatch_ref:
|
||||
description: 'Branch of penpot-nitrate from which the workflow definition is read'
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch or ref to build in penpot-nitrate'
|
||||
type: string
|
||||
required: true
|
||||
dispatch_ref:
|
||||
description: 'Branch of penpot-nitrate from which the workflow definition is read'
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
secrets:
|
||||
ORG_WORKFLOW_TOKEN:
|
||||
description: 'Token with Actions write access on penpot-nitrate'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-nitrate-docker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }}
|
||||
REPO: penpot/penpot-nitrate
|
||||
WORKFLOW: build-docker-admin-console.yml
|
||||
GH_REF: ${{ inputs.gh_ref }}
|
||||
DISPATCH_REF: ${{ inputs.dispatch_ref }}
|
||||
steps:
|
||||
- name: Trigger nitrate docker build
|
||||
id: dispatch
|
||||
run: |
|
||||
DISTINCT_ID="${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
CALLER_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
|
||||
-f gh_ref="$GH_REF" \
|
||||
-f caller_run_id="$DISTINCT_ID" \
|
||||
-f caller_run_url="$CALLER_URL"
|
||||
|
||||
# Locate the dispatched run using the correlation id embedded in its run-name
|
||||
RUN_ID=""
|
||||
for i in $(seq 1 24); do
|
||||
sleep 5
|
||||
RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" \
|
||||
--limit 10 --json databaseId,displayTitle \
|
||||
--jq ".[] | select(.displayTitle | contains(\"$DISTINCT_ID\")) | .databaseId" \
|
||||
| head -n1)
|
||||
[ -n "$RUN_ID" ] && break
|
||||
done
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::Could not locate the dispatched run in $REPO"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID"
|
||||
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "run_url=$RUN_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Nitrate docker build::$RUN_URL"
|
||||
|
||||
- name: Wait for nitrate docker build
|
||||
run: |
|
||||
gh run watch "${{ steps.dispatch.outputs.run_id }}" \
|
||||
--repo "$REPO" \
|
||||
--interval 30 \
|
||||
--exit-status
|
||||
|
||||
- name: Report result
|
||||
if: always() && steps.dispatch.outputs.run_id != ''
|
||||
run: |
|
||||
CONCLUSION=$(gh run view "${{ steps.dispatch.outputs.run_id }}" \
|
||||
--repo "$REPO" --json conclusion --jq '.conclusion')
|
||||
{
|
||||
echo "### 🐳 Nitrate docker build"
|
||||
echo ""
|
||||
echo "- Result: \`${CONCLUSION:-in_progress}\`"
|
||||
echo "- Run: ${{ steps.dispatch.outputs.run_url }}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -20,12 +20,19 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Registry
|
||||
- name: Login to Docker Registry (push destination)
|
||||
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:
|
||||
@@ -35,12 +42,14 @@ 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@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
+210
-111
@@ -20,55 +20,117 @@ 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:
|
||||
build-and-push:
|
||||
name: Build and Push Penpot Docker Images
|
||||
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||
prepare:
|
||||
name: Prepare
|
||||
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 }}" >> $GITHUB_ENV
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $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:
|
||||
@@ -85,103 +147,140 @@ 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:
|
||||
frontend
|
||||
backend
|
||||
exporter
|
||||
storybook
|
||||
mcp
|
||||
images: ${{ matrix.image }}
|
||||
labels: |
|
||||
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
||||
bundle_version=${{ needs.prepare.outputs.bundle_version }}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_IMAGE: 'backend'
|
||||
BUNDLE_PATH: './bundle-backend'
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.backend
|
||||
file: ./docker/images/Dockerfile.${{ matrix.image }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
# Immutable tag only; branch tags are moved atomically for the
|
||||
# whole image set by the `promote` job.
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_IMAGE: 'frontend'
|
||||
BUNDLE_PATH: './bundle-frontend'
|
||||
# ── 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:
|
||||
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
|
||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Exporter Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
- 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
|
||||
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
|
||||
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"
|
||||
|
||||
- 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
|
||||
# ── 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()
|
||||
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🐳 *[PENPOT] Error building penpot docker images.*
|
||||
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
|
||||
📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}`
|
||||
❌ 🐳 *[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' }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
@@ -11,8 +11,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
build_wasm: "yes"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -20,3 +18,9 @@ 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"
|
||||
@@ -12,8 +12,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
build_wasm: "yes"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -26,10 +24,9 @@ jobs:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-docker
|
||||
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
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@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: "CI: Composable Test Suite"
|
||||
|
||||
# Runs the composable component test suite (it exercises component semantics
|
||||
# through the real Plugin API against the full frontend, so it needs the
|
||||
# frontend bundle + the plugin runtime, but no backend): the driver serves the
|
||||
# prebuilt frontend bundle and intercepts every backend RPC with Playwright
|
||||
# fixtures. See plugins/apps/composable-test-suite/README.md ("Running in CI").
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'plugins/**'
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
paths:
|
||||
- 'plugins/**'
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
composable-test-suite:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Run composable test suite (mocked backend)"
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# The driver serves the prebuilt bundle from frontend/resources/public.
|
||||
- name: Build frontend bundle
|
||||
working-directory: ./frontend
|
||||
run: ./scripts/build
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: ./plugins
|
||||
run: pnpm --filter composable-test-suite exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run composable test suite (mocked)
|
||||
working-directory: ./plugins
|
||||
run: pnpm --filter composable-test-suite run test:ci
|
||||
@@ -1,58 +0,0 @@
|
||||
name: "CI: Exporter"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-exporter:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Exporter Tests"
|
||||
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:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run lint:clj
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
./scripts/test
|
||||
@@ -88,6 +88,7 @@ opencode.json
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/render-wasm/target/
|
||||
/media-processor/dist/
|
||||
/**/node_modules
|
||||
/**/.yarn/*
|
||||
/.pnpm-store
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
name: commiter
|
||||
description: Git commit assistant
|
||||
mode: subagent
|
||||
permission:
|
||||
read: allow
|
||||
glob: allow
|
||||
grep: allow
|
||||
edit: deny
|
||||
webfetch: deny
|
||||
websearch: deny
|
||||
task: deny
|
||||
skill: deny
|
||||
lsp: deny
|
||||
todowrite: deny
|
||||
question: deny
|
||||
external_directory: deny
|
||||
bash: allow
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are the Penpot commit assistant. You produce git commits that follow the
|
||||
repository's commit conventions. You do not implement features, review code, or
|
||||
push branches — you commit.
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
|
||||
end-to-end**. It is the authoritative source for the commit message format, the
|
||||
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
|
||||
exactly — do not improvise the format and do not restate its contents here.
|
||||
|
||||
## Pre-commit Workflow
|
||||
|
||||
1. **Stage the files** specified by the calling agent. Do not ask for
|
||||
confirmation — the calling agent knows exactly which files to commit.
|
||||
2. Run `git diff --staged` to review the content. If you see secrets (API
|
||||
keys, tokens, passwords, private keys, `.env` values), debug prints, or
|
||||
anything that does not match the stated intent, STOP and tell the user
|
||||
before committing.
|
||||
3. Following the format in the doc, draft the message and run
|
||||
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
|
||||
unusual characters). The `AI-assisted-by` trailer value is provided by the
|
||||
calling agent — use it verbatim.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not push. Pushing is a separate workflow handled by the user.
|
||||
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
|
||||
- Do not pass `--author`. Author identity comes from the local git config.
|
||||
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
|
||||
- Do not add untracked files that were not created in this session.
|
||||
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
@@ -32,12 +32,11 @@ Implement the prepared plan from the session context. Work methodically, keeping
|
||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||
step 4.
|
||||
|
||||
## 4. Commit with the commiter subagent
|
||||
## 4. Commit with the create-commit skill
|
||||
|
||||
After the implementation is complete, delegate the commit to the **`commiter`**
|
||||
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.
|
||||
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.
|
||||
|
||||
Do not push. Pushing is handled separately by the user.
|
||||
@@ -4,14 +4,76 @@ 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. 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.
|
||||
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?
|
||||
|
||||
Do not modify any code and do not create a commit — this command only reviews.
|
||||
## 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.
|
||||
|
||||
## 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]
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: create-commit
|
||||
description: Stage, review, and commit files following Penpot commit conventions.
|
||||
---
|
||||
|
||||
# Skill: create-commit
|
||||
|
||||
Produce a git commit that follows Penpot's commit message conventions. This
|
||||
skill owns the commit format, staging review, and safety checks — it does not
|
||||
implement features or push.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After code changes are complete and files need to be committed
|
||||
- When delegated by a workflow step (e.g. implement-plan) to handle the commit
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
|
||||
is the authoritative source for the commit message format, the emoji menu,
|
||||
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Stage the files** specified by the calling context. Do not ask for
|
||||
confirmation.
|
||||
2. Run `git diff --staged` to review the content. If you see secrets (API keys,
|
||||
tokens, passwords, private keys, `.env` values), debug prints, or anything
|
||||
that does not match the stated intent, **STOP** and tell the user before
|
||||
committing.
|
||||
3. Draft the message following the format in the memory doc, wrapping the body
|
||||
at 72 characters per line, and run:
|
||||
```bash
|
||||
git commit -m "<subject>" -m "<body>"
|
||||
```
|
||||
(or `git commit -F -` if the body has unusual characters).
|
||||
4. The `AI-assisted-by` trailer value is provided by the calling context — use
|
||||
it verbatim.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not push. Pushing is a separate workflow handled by the user.
|
||||
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm`.
|
||||
- Do not pass `--author`. Author identity comes from the local git config.
|
||||
- Do not amend a commit you did not create in this session, unless explicitly asked.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
|
||||
- Do not add untracked files that were not created in this session.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: testing
|
||||
description: Enforce TDD workflow and testing best practices for Penpot. Use when implementing features, fixing bugs, or modifying behavior. Reads testing memory for full guidance.
|
||||
---
|
||||
|
||||
# Testing Skill
|
||||
|
||||
Enforces test-driven development and Penpot testing conventions.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Implementing new logic or behavior
|
||||
- Fixing any bug (reproduction test required)
|
||||
- Modifying existing functionality
|
||||
- Adding edge case handling
|
||||
|
||||
**Skip:** Pure configuration changes, documentation updates, or static content with no behavioral impact.
|
||||
|
||||
## Workflow
|
||||
|
||||
Follow TDD (Red → Green → Refactor) whenever practical:
|
||||
|
||||
1. **RED** — Write a failing test first
|
||||
2. **GREEN** — Write minimal code to pass
|
||||
3. **REFACTOR** — Clean up while tests stay green
|
||||
|
||||
For bug fixes, use the Prove-It Pattern: write a test that reproduces the bug, confirm it fails, implement the fix, confirm it passes.
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before writing any test, read:
|
||||
|
||||
1. `.serena/memories/testing.md` — cross-cutting testing principles, TDD workflow, anti-patterns, execution discipline
|
||||
2. Module-specific testing memory for the affected module:
|
||||
- `mem:common/testing` — CLJC unit tests
|
||||
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E
|
||||
- `mem:backend/core` — JVM clojure.test conventions
|
||||
|
||||
## Key Rules
|
||||
|
||||
- Every behavior change needs a test
|
||||
- Test state, not interactions
|
||||
- DAMP over DRY — tests are specifications; duplication is OK if each test is self-contained and readable
|
||||
- Prefer Real > Fake > Stub > Mock
|
||||
- Arrange-Act-Assert structure
|
||||
- One assertion per concept
|
||||
- Never pipe test output to filters — redirect to file first
|
||||
- Register new test files in the module's runner/entrypoint
|
||||
|
||||
## Verification
|
||||
|
||||
After completing implementation:
|
||||
|
||||
- [ ] Every new behavior has a test
|
||||
- [ ] All tests pass for touched modules
|
||||
- [ ] Bug fixes include a reproduction test
|
||||
- [ ] Lint/formatter passes
|
||||
@@ -212,37 +212,6 @@ superseded it:
|
||||
|
||||
Replace the reference in the changelog entry with the correct merged PR number.
|
||||
|
||||
### 5b. Security advisory (GHSA) entries
|
||||
|
||||
Security advisories fixed in a release are documented in the changelog even
|
||||
though they are **neither milestone issues nor PRs**. The GHSA ID and its
|
||||
description are supplied by the user or the release notes — they never come
|
||||
from the milestone fetch in step 2.
|
||||
|
||||
**Format** (matches the existing precedent in `CHANGES.md`, e.g. the
|
||||
`create-font-variant` arbitrary file read advisory):
|
||||
|
||||
```markdown
|
||||
- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link** —
|
||||
only the advisory URL.
|
||||
- The advisory may be **draft/unpublished** at changelog time (the URL 404s
|
||||
publicly). Do **not** web-fetch or verify the URL, and do **not** drop the
|
||||
entry because of that. Rely on the GHSA ID provided by the user.
|
||||
- Derive the description from the supplied advisory title, imperative mood and
|
||||
user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`).
|
||||
- These entries are **invisible to the automation**: they are not returned by
|
||||
`gh.py issues`, not matched by `--compare` (step 3), not part of the PR
|
||||
cross-reference (step 10), and not scanned by the anomaly-report regexes
|
||||
(step 11, which only match `issues/` and `pull/` links). Add them manually.
|
||||
- During pre-flight checks (step 6a) apply only the **backport/duplicate**
|
||||
check: if the same GHSA already appears in an earlier version section, remove
|
||||
it from the current section. Their absence from milestone cross-references
|
||||
is expected, not an anomaly.
|
||||
|
||||
### 6. Read the current CHANGES.md
|
||||
|
||||
Read the top of `CHANGES.md` to understand the existing format and find the
|
||||
@@ -431,8 +400,6 @@ if closed:
|
||||
- ✅ Every merged milestone PR is either in the changelog or excluded by label
|
||||
- ✅ PR and issue counts are internally consistent
|
||||
- ✅ No false-positive PR-to-issue associations
|
||||
- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the
|
||||
cross-reference is intentional (see step 5b)
|
||||
|
||||
## Version section template
|
||||
|
||||
@@ -443,12 +410,8 @@ if closed:
|
||||
|
||||
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See
|
||||
step 5b.
|
||||
|
||||
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
|
||||
|
||||
After all edits and cross-referencing are complete, generate a structured
|
||||
@@ -769,14 +732,6 @@ self-contained and clickable in any Markdown viewer.
|
||||
Taiga description text or by searching GitHub PRs that reference the Taiga
|
||||
URL. Replace the Taiga reference with the GitHub issue link and add the PR
|
||||
reference if applicable.
|
||||
- **Security advisory (GHSA) entries.** Advisories fixed in the release are
|
||||
listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or
|
||||
PR link**, even though they are not in the milestone. The GHSA ID and
|
||||
description come from the user — do **not** fetch or verify the URL, and do
|
||||
not drop a draft (unpublished) advisory. Precedent:
|
||||
`- Fix arbitrary file read security issue on create-font-variant rpc method
|
||||
(https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`.
|
||||
See step 5b.
|
||||
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
|
||||
before making edits, don't rely on cached data.
|
||||
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
|
||||
@@ -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:** `clj-kondo --lint ../common/src/ src/`.
|
||||
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
|
||||
* **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.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits), run
|
||||
`scripts/paren-repair` on the affected files first. Delimiter errors produce
|
||||
|
||||
@@ -24,6 +24,12 @@ Variant masters are main instances and component roots. Their descendants may th
|
||||
|
||||
Masters are not normally touched through `set-shape-attr`, but touched flags can appear on master shapes through cloning/duplication paths. `add-touched-from-ref-chain` in `app.common.logic.variants` unions touched flags from ancestors into the copy being processed, so upstream/master touched state can affect downstream switch behavior.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.nitrate-permissions` contains shared fail-closed Nitrate organization/team permission rules.
|
||||
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.organization` contains organization schemas, `apply-organization`, and fail-closed organization/team permission rules (`allowed?`, `can-send-invitations?`).
|
||||
- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic.
|
||||
- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc.
|
||||
- `app.common.geom.*`: geometry helpers and transformations.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- `set-shape-attr` treats `:position-data` as derived and never touched. Geometry/content-path changes use approximate equality; geometry differences under about 1px can be ignored for touched purposes.
|
||||
- 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
|
||||
|
||||
@@ -19,6 +21,7 @@
|
||||
- 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,6 +8,9 @@
|
||||
## 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.
|
||||
@@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply
|
||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
||||
- `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`
|
||||
|
||||
@@ -25,7 +25,9 @@ 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. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
||||
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
||||
|
||||
Each workspace is independent and can be started/stopped in any order. Shared infra (postgres, minio, etc.) is shut down only when no instances remain running.
|
||||
|
||||
## Port layout
|
||||
|
||||
@@ -63,8 +65,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). 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 --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`: 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`.)
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
## Layout and commands
|
||||
|
||||
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
|
||||
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- Exporter test conventions and CI: `mem:exporter/testing`.
|
||||
|
||||
## HTTP and browser pool
|
||||
|
||||
@@ -32,4 +31,4 @@
|
||||
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
|
||||
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
|
||||
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Exporter Testing
|
||||
|
||||
- READ `mem:testing` first.
|
||||
- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`.
|
||||
- Register every test namespace in `exporter-tests.runner`.
|
||||
- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests.
|
||||
- From `exporter/`: `pnpm run test` builds and runs tests with full output.
|
||||
- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output.
|
||||
- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`.
|
||||
- For iterative focused runs, build once and reuse the compiled bundle.
|
||||
- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`.
|
||||
- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`.
|
||||
- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`).
|
||||
- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs.
|
||||
- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting.
|
||||
- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting.
|
||||
@@ -0,0 +1,152 @@
|
||||
# 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`.
|
||||
@@ -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`.
|
||||
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. After running `fmt:*`, `check-fmt:*` is redundant.
|
||||
- Translation formatting after i18n edits: `pnpm run translations`.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits, or
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Media Processor
|
||||
|
||||
Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Language: TypeScript
|
||||
- Runtime: Node.js
|
||||
- Framework: Express
|
||||
- Image processing: sharp (libvips)
|
||||
- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress
|
||||
- Upload handling: multer (hybrid storage: memory for small, disk for large)
|
||||
- Logging: pino (with optional Loki transport)
|
||||
- Config validation: Zod
|
||||
- Testing: Vitest
|
||||
- Package Manager: pnpm
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
media-processor/
|
||||
├── src/
|
||||
│ ├── index.ts # Express app setup, routes, middleware
|
||||
│ ├── config.ts # Zod-validated env config, HKDF key derivation
|
||||
│ ├── types.ts # TypeScript type definitions
|
||||
│ ├── upload.ts # Multer configuration, getFileBuffer helper
|
||||
│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold)
|
||||
│ ├── logger.ts # Pino logger setup
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.ts # Timing-safe shared key authentication
|
||||
│ │ ├── error-handler.ts # ProcessingError class, centralized error handling
|
||||
│ │ └── timeout.ts # Request timeout middleware
|
||||
│ ├── routes/
|
||||
│ │ ├── health.ts # GET /api/health
|
||||
│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail
|
||||
│ │ └── font.ts # POST /api/font/convert
|
||||
│ └── services/
|
||||
│ ├── image.ts # sharp-based image info/thumbnail generation
|
||||
│ ├── font.ts # FontForge/woff-tools font conversion
|
||||
│ └── errors.ts # throwValidation, throwRestriction, throwProcessing
|
||||
├── test/ # Vitest test files
|
||||
├── vitest.config.ts # Test configuration
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── esbuild.config.mjs # Build configuration
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Auth
|
||||
- Requests authenticated via `x-shared-key` header using timing-safe comparison
|
||||
- When no key configured, all requests rejected with 403
|
||||
- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY`
|
||||
|
||||
### Resource Limits
|
||||
- Image: max pixels, max width/height enforced before processing
|
||||
- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits
|
||||
- Concurrency: p-queue limits concurrent requests (default 10)
|
||||
- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD`
|
||||
- Max file size: configurable (default 350MB)
|
||||
|
||||
### Error Handling
|
||||
- `throwValidation(code, hint)` — 400 errors for invalid input
|
||||
- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded
|
||||
- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills)
|
||||
|
||||
### Image Processing
|
||||
- EXIF orientation applied before dimension validation and thumbnail generation
|
||||
- sharp caching disabled to prevent unbounded memory growth
|
||||
- `withoutEnlargement: true` prevents upscaling small images
|
||||
|
||||
### Font Conversion
|
||||
- Supported formats: TTF, OTF, WOFF, WOFF2
|
||||
- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF)
|
||||
- Temp files cleaned up in finally blocks (best-effort)
|
||||
|
||||
## Commands
|
||||
|
||||
All commands run from `media-processor/` directory:
|
||||
|
||||
- `pnpm run test` — Run Vitest test suite
|
||||
- `pnpm run types:check` — TypeScript type checking (tsc --noEmit)
|
||||
- `pnpm run fmt` — Format code with Prettier
|
||||
- `pnpm run fmt:check` — Check formatting without modifying
|
||||
- `pnpm run build` — Build for production (esbuild)
|
||||
- `pnpm run start:dev` — Start development server (tsx)
|
||||
|
||||
## Docker
|
||||
|
||||
- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`)
|
||||
- Must be deployed on internal Docker network only (not public-facing)
|
||||
- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI`
|
||||
|
||||
## Testing Principles
|
||||
|
||||
Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
|
||||
- Run `pnpm run test` after changes
|
||||
- Run `pnpm run types:check` after TypeScript changes
|
||||
- Run `pnpm run fmt:check` before commits
|
||||
@@ -14,6 +14,8 @@ automatically pull the identity from the local git config `user.name` and `user.
|
||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||
|
||||
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
|
||||
```
|
||||
@@ -25,3 +27,7 @@ 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.
|
||||
@@ -42,15 +42,15 @@ PR descriptions follow this structure:
|
||||
|
||||
## What
|
||||
|
||||
<one paragraph: the problem or feature, user-facing impact>
|
||||
<the problem or feature and its user-facing impact — short bullet items where there is more than one point>
|
||||
|
||||
## Why
|
||||
|
||||
<root cause or motivation, why this change was necessary>
|
||||
<root cause or motivation — a short paragraph or bullets>
|
||||
|
||||
## How
|
||||
|
||||
<high-level approach, key technical decisions>
|
||||
<high-level approach and key decisions — bullet items, grouped by area (bold lead-ins) for larger PRs>
|
||||
```
|
||||
|
||||
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
|
||||
@@ -59,6 +59,8 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
|
||||
|
||||
- **Write for humans.** The diff shows what changed. The description explains why.
|
||||
- **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
|
||||
|
||||
+60
-23
@@ -1,26 +1,31 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||
project_name: "penpot"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al ansible bash clojure cpp
|
||||
# cpp_ccls crystal csharp csharp_omnisharp dart
|
||||
# elixir elm erlang fortran fsharp
|
||||
# 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
|
||||
# go groovy haskell haxe hlsl
|
||||
# java json julia kotlin lean4
|
||||
# lua luau markdown matlab msl
|
||||
# nix ocaml pascal perl php
|
||||
# php_phpactor powershell python python_jedi python_ty
|
||||
# r rego ruby ruby_solargraph rust
|
||||
# scala solidity swift systemverilog terraform
|
||||
# toml typescript typescript_vts vue yaml
|
||||
# zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# 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.)
|
||||
# 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.
|
||||
@@ -54,12 +59,19 @@ ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
# 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
|
||||
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: []
|
||||
|
||||
@@ -130,13 +142,38 @@ 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 (e.g. in monorepos).
|
||||
# list of additional workspace folder paths for cross-package reference support.
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# 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.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
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
|
||||
@@ -8,6 +8,9 @@
|
||||
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)
|
||||
|
||||
+22
-21
@@ -1,38 +1,39 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.17.2
|
||||
## 2.18.0 (Unreleased)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272))
|
||||
- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366))
|
||||
- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86)
|
||||
- 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))
|
||||
|
||||
## 2.17.1
|
||||
### :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 overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
|
||||
- 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 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 frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718))
|
||||
- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721))
|
||||
- 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 dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715))
|
||||
- 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 import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782))
|
||||
- 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))
|
||||
- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808))
|
||||
- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812))
|
||||
- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845))
|
||||
- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852))
|
||||
- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870))
|
||||
- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881))
|
||||
- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898))
|
||||
- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
|
||||
## 2.17.0
|
||||
|
||||
|
||||
+10
-9
@@ -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-11"}
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-12"}
|
||||
|
||||
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
||||
@@ -34,27 +34,28 @@
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
com.github.seancorfield/next.jdbc
|
||||
{:mvn/version "1.3.1108"}
|
||||
{:mvn/version "1.3.1118"}
|
||||
|
||||
metosin/reitit-core {:mvn/version "0.10.1"}
|
||||
nrepl/nrepl {:mvn/version "1.7.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.12"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
|
||||
org.postgresql/postgresql {:mvn/version "42.7.13"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||
com.zaxxer/HikariCP {:mvn/version "7.1.0"}
|
||||
|
||||
io.whitfin/siphash {:mvn/version "2.0.0"}
|
||||
|
||||
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.22.2"}
|
||||
org.jsoup/jsoup {:mvn/version "1.23.1"}
|
||||
|
||||
at.yawk.lz4/lz4-java
|
||||
{:mvn/version "1.11.0"}
|
||||
{:mvn/version "1.11.1"}
|
||||
|
||||
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
|
||||
|
||||
@@ -63,8 +64,8 @@
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.46.18"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.46.18"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
|
||||
@@ -4,23 +4,25 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
|
||||
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
},
|
||||
"dependencies": {
|
||||
"luxon": "^3.4.4",
|
||||
"sax": "^1.6.0"
|
||||
"eventsource-parser": "^3.0.6",
|
||||
"luxon": "^3.7.2",
|
||||
"sax": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ws": "^8.21.0"
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "clj-kondo --parallel --lint ../common/src src/",
|
||||
"check-fmt": "cljfmt check --parallel=true src/ test/",
|
||||
"fmt": "cljfmt fix --parallel=true src/ test/"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Generated
+25
-16
@@ -8,12 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
eventsource-parser:
|
||||
specifier: ^3.0.6
|
||||
version: 3.1.0
|
||||
luxon:
|
||||
specifier: ^3.4.4
|
||||
specifier: ^3.7.2
|
||||
version: 3.7.2
|
||||
sax:
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1
|
||||
devDependencies:
|
||||
nodemon:
|
||||
specifier: ^3.1.14
|
||||
@@ -22,8 +25,8 @@ importers:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
ws:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
specifier: ^8.21.1
|
||||
version: 8.21.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -39,9 +42,9 @@ packages:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
brace-expansion@5.0.7:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -63,6 +66,10 @@ 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'}
|
||||
@@ -130,8 +137,8 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
sax@1.6.0:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
sax@1.6.1:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
semver@7.8.5:
|
||||
@@ -165,8 +172,8 @@ packages:
|
||||
undefsafe@2.0.5:
|
||||
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
ws@8.21.1:
|
||||
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@@ -188,7 +195,7 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
brace-expansion@5.0.7:
|
||||
brace-expansion@5.0.9:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -216,6 +223,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
eventsource-parser@3.1.0: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
@@ -247,7 +256,7 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.7
|
||||
brace-expansion: 5.0.9
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
@@ -274,7 +283,7 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
|
||||
sax@1.6.0: {}
|
||||
sax@1.6.1: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
@@ -301,4 +310,4 @@ snapshots:
|
||||
|
||||
undefsafe@2.0.5: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
ws@8.21.1: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- brace-expansion@5.0.8 || 5.0.9
|
||||
@@ -1,10 +0,0 @@
|
||||
Hello!
|
||||
|
||||
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”.
|
||||
|
||||
Accept invitation using this link:
|
||||
|
||||
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
||||
|
||||
Enjoy!
|
||||
The Penpot team.
|
||||
+29
-5
@@ -195,21 +195,45 @@
|
||||
<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;">
|
||||
File renamed without changes.
@@ -0,0 +1,17 @@
|
||||
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,10 +186,31 @@
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
|
||||
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:50 }}”{% if
|
||||
organization %}
|
||||
part of the organization “{{ organization.name|abbreviate:50 }}”{% 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,6 +1,13 @@
|
||||
Hello!
|
||||
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate: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 %}
|
||||
|
||||
Accept invitation using this link:
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<!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>
|
||||
@@ -0,0 +1 @@
|
||||
“{{ organization-name|abbreviate:25 }}” uses single sign-on
|
||||
@@ -0,0 +1,8 @@
|
||||
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.
|
||||
@@ -39,4 +39,10 @@
|
||||
{:permits 3}
|
||||
|
||||
:create-file-snapshot/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}}
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:send-user-feedback/global
|
||||
{:permits 4}
|
||||
|
||||
:send-user-feedback/by-profile
|
||||
{:permits 1 :queue 3}}
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
||||
export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key
|
||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||
export PENPOT_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
|
||||
@@ -21,6 +22,8 @@ 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 \
|
||||
@@ -36,6 +39,7 @@ 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 \
|
||||
@@ -71,7 +75,7 @@ export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
|
||||
|
||||
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
|
||||
|
||||
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
|
||||
export PENPOT_ADMIN_CONSOLE_URI=http://localhost:3000/admin-console
|
||||
|
||||
export JAVA_OPTS="\
|
||||
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||
|
||||
+111
-19
@@ -459,9 +459,10 @@
|
||||
(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)}]
|
||||
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)}]
|
||||
(l/trc :hint "access token fetched"
|
||||
:token-id (:token/id data)
|
||||
:token-type (:token/type data)
|
||||
@@ -619,6 +620,9 @@
|
||||
(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))
|
||||
@@ -761,20 +765,110 @@
|
||||
;; ORG SSO HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(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]}]
|
||||
(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]}]
|
||||
(prepare-oidc-provider cfg
|
||||
{:type "oidc"
|
||||
:client-id client-id
|
||||
:client-secret client-secret
|
||||
:base-uri (some-> (or base-url issuer)
|
||||
:base-uri (some-> (non-blank-uri issuer)
|
||||
(str/rtrim "/")
|
||||
(str "/"))
|
||||
:scopes (into default-oidc-scopes (or scopes #{}))
|
||||
:skip-ssrf-check? true}))
|
||||
: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)))
|
||||
|
||||
(defn- auth-handler
|
||||
[cfg {:keys [params] :as request}]
|
||||
@@ -802,17 +896,15 @@
|
||||
state (get params :state)
|
||||
state (tokens/verify cfg {:token state :iss "oidc"})]
|
||||
|
||||
;; Org SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; code with the OIDC provider to verify authentication actually occurred.
|
||||
(if-let [dest-url (:dest-url state)]
|
||||
(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)
|
||||
(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)
|
||||
session (session/get-session request)
|
||||
exp (ct/in-future {:hours 48})]
|
||||
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.auth.passwords
|
||||
"Password strength validation using Passay library."
|
||||
(:require
|
||||
[app.common.exceptions :as ex])
|
||||
(:import
|
||||
[org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData]))
|
||||
|
||||
(defonce ^:private passay-code->translation-key
|
||||
{"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
|
||||
"INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase"
|
||||
"INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits"
|
||||
"INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
|
||||
|
||||
(defonce ^:private character-characteristics-rule
|
||||
(doto (CharacterCharacteristicsRule.)
|
||||
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
|
||||
(CharacterRule. EnglishCharacterData/UpperCase 1)
|
||||
(CharacterRule. EnglishCharacterData/Digit 1)
|
||||
(CharacterRule. EnglishCharacterData/Special 1)])
|
||||
(.setNumberOfCharacteristics 4)))
|
||||
|
||||
(defn validate-password
|
||||
"Validates password strength.
|
||||
Returns nil if valid, or raises exception if invalid.
|
||||
Checks:
|
||||
- Minimum length of 8 characters
|
||||
- At least 1 lowercase letter
|
||||
- At least 1 uppercase letter
|
||||
- At least 1 digit
|
||||
- At least 1 special character"
|
||||
[password]
|
||||
(when (< (count password) 8)
|
||||
(ex/raise :type :validation
|
||||
:code :weak-password
|
||||
:hint "password must be at least 8 characters"
|
||||
:details ["errors.weak-password.too-short"]))
|
||||
|
||||
(let [password-data (PasswordData. password)
|
||||
char-result (.validate character-characteristics-rule password-data)]
|
||||
(when-not (.isValid char-result)
|
||||
(ex/raise :type :validation
|
||||
:code :weak-password
|
||||
:hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
|
||||
:details (->> (.getDetails char-result)
|
||||
(mapv #(.getErrorCode %))
|
||||
(mapv passay-code->translation-key)
|
||||
(filterv some?))))))
|
||||
@@ -748,9 +748,17 @@
|
||||
(fmigr/upsert-migrations! conn file))
|
||||
|
||||
(let [file (encode-file cfg file)]
|
||||
(db/insert! conn :file
|
||||
(file->params file)
|
||||
(assoc opts ::db/return-keys false))
|
||||
(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))))
|
||||
|
||||
(->> (file->file-data-params file)
|
||||
(fdata/upsert! cfg))
|
||||
|
||||
@@ -174,6 +174,10 @@
|
||||
(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)))))
|
||||
|
||||
@@ -119,8 +119,9 @@
|
||||
[:allowed-origins {:optional true} [::sm/set :string]]
|
||||
|
||||
[:exporter-shared-key {:optional true} :string]
|
||||
[:nitrate-shared-key {:optional true} :string]
|
||||
[:admin-console-shared-key {:optional true} :string]
|
||||
[:nexus-shared-key {:optional true} :string]
|
||||
[:media-processor-shared-key {:optional true} :string]
|
||||
[:management-api-key {:optional true} :string]
|
||||
|
||||
[:telemetry-uri {:optional true} :string]
|
||||
@@ -147,6 +148,9 @@
|
||||
[: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]
|
||||
@@ -264,7 +268,7 @@
|
||||
|
||||
[:netty-io-threads {:optional true} ::sm/int]
|
||||
|
||||
[:nitrate-backend-uri {:optional true} ::sm/uri]
|
||||
[:admin-console-uri {:optional true} ::sm/uri]
|
||||
|
||||
;; DEPRECATED
|
||||
[:assets-storage-backend {:optional true} :keyword]
|
||||
|
||||
+22
-12
@@ -419,10 +419,19 @@
|
||||
: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
|
||||
@@ -431,27 +440,28 @@
|
||||
:id ::invite-to-team
|
||||
:schema schema:invite-to-team))
|
||||
|
||||
(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
|
||||
(def ^:private schema:invite-to-organization
|
||||
[:map
|
||||
[:invited-by ::sm/text]
|
||||
[:user-name [:maybe ::sm/text]]
|
||||
[:token ::sm/text]
|
||||
[:organization schema:organization-data]])
|
||||
|
||||
(def invite-to-org
|
||||
"Org member invitation email."
|
||||
(def invite-to-organization
|
||||
"Organization member invitation email."
|
||||
(template-factory
|
||||
:id ::invite-to-org
|
||||
:schema schema:invite-to-org))
|
||||
:id ::invite-to-organization
|
||||
:schema schema:invite-to-organization))
|
||||
|
||||
(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
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(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]
|
||||
@@ -31,7 +32,8 @@
|
||||
#{"file-media-object"
|
||||
"file-object-thumbnail"
|
||||
"team-font-variant"
|
||||
"file-data-fragment"})
|
||||
"file-data-fragment"
|
||||
"organization"})
|
||||
|
||||
(defn get-id
|
||||
[{:keys [path-params]}]
|
||||
@@ -41,7 +43,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]
|
||||
@@ -108,13 +110,21 @@
|
||||
(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)
|
||||
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)]
|
||||
(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})))))))
|
||||
|
||||
(defn file-objects-handler
|
||||
"Handler that serves storage objects by file media id."
|
||||
|
||||
@@ -226,19 +226,19 @@
|
||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||
(db/get-update-count))))
|
||||
|
||||
(def ^:private sql:clear-org-sso-sessions
|
||||
(def ^:private sql:clear-organization-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-org-sso-sessions!
|
||||
(defn clear-organization-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 [org-key (str "~u" organization-id)]
|
||||
(db/exec! pool [sql:clear-org-sso-sessions org-key org-key])))
|
||||
(let [organization-key (str "~u" organization-id)]
|
||||
(db/exec! pool [sql:clear-organization-sso-sessions organization-key organization-key])))
|
||||
|
||||
(defn- renew-session?
|
||||
[{:keys [id modified-at] :as session}]
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
#{:session-id
|
||||
:password
|
||||
:old-password
|
||||
:token})
|
||||
:token
|
||||
:client-secret})
|
||||
|
||||
(defn extract-utm-params
|
||||
"Extracts additional data from params and namespace them under
|
||||
@@ -153,7 +154,7 @@
|
||||
;; COLLECTOR API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare ^:private prepare-context-from-request)
|
||||
(declare prepare-context-from-request)
|
||||
|
||||
;; Defines a service that collects the audit/activity log using
|
||||
;; internal database. Later this audit log can be transferred to
|
||||
@@ -182,7 +183,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)
|
||||
@@ -413,7 +414,7 @@
|
||||
(update :ip-addr d/nilv "0.0.0.0")
|
||||
(update :props d/nilv {})
|
||||
(update :context d/nilv {})
|
||||
(assoc :source "backend")
|
||||
(update :source d/nilv "backend")
|
||||
(d/without-nils))]
|
||||
(submit* cfg event)))
|
||||
|
||||
@@ -430,7 +431,7 @@
|
||||
(update :profile-id d/nilv uuid/zero)
|
||||
(update :props d/nilv {})
|
||||
(update :context d/nilv {})
|
||||
(assoc :source "backend")
|
||||
(update :source d/nilv "backend")
|
||||
(select-keys event-keys)
|
||||
(check-event))]
|
||||
(db/run! cfg append-audit-entry event))))
|
||||
@@ -335,6 +335,7 @@
|
||||
::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)
|
||||
@@ -467,10 +468,11 @@
|
||||
::migrations (ig/ref :app.migrations/migrations)}
|
||||
|
||||
::setup/shared-keys
|
||||
{::setup/props (ig/ref ::setup/props)
|
||||
:nexus (cf/get :nexus-shared-key)
|
||||
:nitrate (cf/get :nitrate-shared-key)
|
||||
:exporter (cf/get :exporter-shared-key)}
|
||||
{::setup/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/clock
|
||||
{}
|
||||
|
||||
+37
-480
@@ -5,316 +5,37 @@
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.media
|
||||
"Media & Font postprocessing."
|
||||
"Media & Font postprocessing.
|
||||
|
||||
This namespace is the dispatch layer only. Processing implementations
|
||||
live in two separate namespaces, each owning their own defmulti:
|
||||
|
||||
app.media.local — shell/ImageMagick/FontForge implementations
|
||||
app.media.remote — HTTP delegation to media-processor service
|
||||
|
||||
Validation and schemas live in app.media.validation (leaf namespace,
|
||||
no circular dep). When adding a new :cmd type, add defmethods in
|
||||
BOTH local and remote."
|
||||
(:require
|
||||
[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.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)))
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(defn run
|
||||
[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))))))
|
||||
(if (contains? cf/flags :remote-media-processing)
|
||||
(media.remote/process system params)
|
||||
(media.local/process system params)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; IMAGE HELPERS
|
||||
@@ -338,8 +59,8 @@
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(-> {:size size :mtype mtype}
|
||||
(validate-media-type!)
|
||||
(validate-media-size!))))]
|
||||
(validation/validate-media-type!)
|
||||
(validation/validate-media-size!))))]
|
||||
|
||||
(let [{:keys [body] :as response}
|
||||
(try
|
||||
@@ -367,188 +88,24 @@
|
||||
(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)))]
|
||||
|
||||
{:keys [size mtype]} (parse-and-validate response)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write* path body :size size)]
|
||||
(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)]
|
||||
|
||||
(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})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; 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))))))))
|
||||
;; No body - validation will raise appropriate error
|
||||
(parse-and-validate response)))))
|
||||
@@ -0,0 +1,366 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.media.local
|
||||
"Local media processing via ImageMagick and FontForge shell commands."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.media.svg :as svg]
|
||||
[app.media.validation :as validation]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.shell :as shell]
|
||||
[buddy.core.bytes :as bb]
|
||||
[buddy.core.codecs :as bc]
|
||||
[clojure.string]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(defmulti process (fn [_system params] (:cmd params)))
|
||||
|
||||
(defmethod process :default
|
||||
[_system {:keys [cmd] :as params}]
|
||||
(ex/raise :type :internal
|
||||
:code :not-implemented
|
||||
:hint (str/fmt "No impl found for local process cmd: %s" cmd)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; IMAGE THUMBNAILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:thumbnail-params
|
||||
[:map {:title "ThumbnailParams"}
|
||||
[:input validation/schema:input]
|
||||
[:format [:enum :jpeg :webp :png]]
|
||||
[:quality [:int {:min 1 :max 100}]]
|
||||
[:width :int]
|
||||
[:height :int]])
|
||||
|
||||
(def ^:private check-thumbnail-params
|
||||
(sm/check-fn schema:thumbnail-params))
|
||||
|
||||
;; Related info on how thumbnails generation
|
||||
;; http://www.imagemagick.org/Usage/thumbnails/
|
||||
|
||||
(def ^:private imagemagick-default-env
|
||||
"Default environment variables for ImageMagick resource limits.
|
||||
These are the soft ceiling — policy.xml is the hard ceiling."
|
||||
{"MAGICK_THREAD_LIMIT" "2"
|
||||
"MAGICK_MEMORY_LIMIT" "256MiB"
|
||||
"MAGICK_MAP_LIMIT" "512MiB"
|
||||
"MAGICK_AREA_LIMIT" "128MP"
|
||||
"MAGICK_DISK_LIMIT" "1GiB"
|
||||
"MAGICK_TIME_LIMIT" "30"})
|
||||
|
||||
(defn- get-imagemagick-env
|
||||
"Returns environment variables for ImageMagick commands.
|
||||
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
|
||||
[]
|
||||
(let [thread (cf/get :imagemagick-thread-limit)
|
||||
memory (cf/get :imagemagick-memory-limit)
|
||||
map-l (cf/get :imagemagick-map-limit)
|
||||
area (cf/get :imagemagick-area-limit)
|
||||
disk (cf/get :imagemagick-disk-limit)
|
||||
time (cf/get :imagemagick-time-limit)
|
||||
width (cf/get :imagemagick-width-limit)
|
||||
height (cf/get :imagemagick-height-limit)]
|
||||
(cond-> imagemagick-default-env
|
||||
thread (assoc "MAGICK_THREAD_LIMIT" thread)
|
||||
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
|
||||
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
|
||||
area (assoc "MAGICK_AREA_LIMIT" area)
|
||||
disk (assoc "MAGICK_DISK_LIMIT" disk)
|
||||
time (assoc "MAGICK_TIME_LIMIT" time)
|
||||
width (assoc "MAGICK_WIDTH_LIMIT" width)
|
||||
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
|
||||
|
||||
(defn- exec-magick!
|
||||
"Execute an ImageMagick command with resource limits.
|
||||
`args` is a vector of string arguments to pass to `magick`."
|
||||
[system args]
|
||||
(let [cmd (into ["magick"] args)
|
||||
result (shell/exec! system
|
||||
:cmd cmd
|
||||
:env (get-imagemagick-env)
|
||||
:timeout 60)]
|
||||
(when (not= 0 (:exit result))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint (str "ImageMagick command failed: " (:err result))
|
||||
:cmd cmd
|
||||
:exit (:exit result)))
|
||||
result))
|
||||
|
||||
(defn- generic-process
|
||||
[system {:keys [input format convert-args] :as params}]
|
||||
(let [{:keys [path mtype]} input
|
||||
format (or format (cm/mtype->format mtype))
|
||||
ext (cm/format->extension format)
|
||||
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
|
||||
(exec-magick! system args)
|
||||
(assoc params
|
||||
:format format
|
||||
:mtype (cm/format->mtype format)
|
||||
:size (fs/size tmp)
|
||||
:data tmp)))
|
||||
|
||||
(defmethod process :generic-thumbnail
|
||||
[system params]
|
||||
(let [{:keys [quality width height] :as params}
|
||||
(check-thumbnail-params params)]
|
||||
(generic-process system
|
||||
(assoc params
|
||||
:convert-args ["-auto-orient" "-strip"
|
||||
"-thumbnail" (str width "x" height ">")
|
||||
"-quality" (str quality)]))))
|
||||
|
||||
(defmethod process :profile-thumbnail
|
||||
[system params]
|
||||
(let [{:keys [quality width height] :as params}
|
||||
(check-thumbnail-params params)]
|
||||
(generic-process system
|
||||
(assoc params
|
||||
:convert-args ["-auto-orient" "-strip"
|
||||
"-thumbnail" (str width "x" height "^")
|
||||
"-gravity" "center"
|
||||
"-extent" (str width "x" height)
|
||||
"-quality" (str quality)]))))
|
||||
|
||||
(defn- get-dimensions-with-orientation [system ^String path]
|
||||
;; Image magick doesn't give info about exif rotation so we use the identify command
|
||||
;; If we are processing an animated gif we use the first frame with -scene 0
|
||||
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
|
||||
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
|
||||
(when (= 0 (:exit dim-result))
|
||||
(let [[w h] (-> (:out dim-result)
|
||||
str/trim
|
||||
(clojure.string/split #"\s+")
|
||||
(->> (mapv #(Integer/parseInt %))))
|
||||
orientation-exit (:exit orient-result)
|
||||
orientation (-> orient-result :out str/trim)]
|
||||
(if (= 0 orientation-exit)
|
||||
(case orientation
|
||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||
{:width w :height h}) ; Normal or unknown orientation
|
||||
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
||||
|
||||
(defmethod process :info
|
||||
[system {:keys [input] :as params}]
|
||||
(let [{:keys [path mtype] :as input} (validation/check-input input)]
|
||||
(if (= mtype "image/svg+xml")
|
||||
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||
(when-not info
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "uploaded svg does not provides dimensions"))
|
||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||
|
||||
(let [path-str (str path)
|
||||
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
|
||||
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
||||
mtype' (if (zero? (:exit identify-res))
|
||||
(-> identify-res
|
||||
:out
|
||||
str/trim
|
||||
(str/split #"\s+" 2)
|
||||
first
|
||||
str/lower)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image"))
|
||||
{:keys [width height]}
|
||||
(or (get-dimensions-with-orientation system path-str)
|
||||
(do
|
||||
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image")))]
|
||||
(when (and (string? mtype)
|
||||
(not= (str/lower mtype) mtype'))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-mismatch
|
||||
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
||||
"Expected: " mtype ". Got: " mtype')))
|
||||
(assoc input
|
||||
:width width
|
||||
:height height
|
||||
:size (fs/size path)
|
||||
:ts (ct/now))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; FONTS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- get-font-prlimit
|
||||
"Returns resource limits for font processing tools, read from config."
|
||||
[]
|
||||
{:mem (cf/get :font-process-mem)
|
||||
:cpu (cf/get :font-process-cpu)})
|
||||
|
||||
(defn- get-font-timeout
|
||||
"Returns the wall-clock timeout for font processing, read from config."
|
||||
[]
|
||||
(cf/get :font-process-timeout))
|
||||
|
||||
(defn- exec-font!
|
||||
"Execute a font processing command with resource limits.
|
||||
`args` is a vector of string arguments."
|
||||
[system args]
|
||||
(shell/exec! system
|
||||
:cmd args
|
||||
:prlimit (get-font-prlimit)
|
||||
:timeout (get-font-timeout)))
|
||||
|
||||
(defmethod process :generate-fonts
|
||||
[system {:keys [input] :as params}]
|
||||
(letfn [(ttf->otf [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||
foutput (fs/path (str finput ".otf"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||
(str/fmt "Open('%s'); Generate('%s')"
|
||||
(str finput)
|
||||
(str foutput))])]
|
||||
(when (zero? (:exit res))
|
||||
foutput))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(otf->ttf [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||
foutput (fs/path (str finput ".ttf"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||
(str/fmt "Open('%s'); Generate('%s')"
|
||||
(str finput)
|
||||
(str foutput))])]
|
||||
(when (zero? (:exit res))
|
||||
foutput))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(ttf-or-otf->woff [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||
foutput (fs/path (str finput ".woff"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
|
||||
(when (zero? (:exit res))
|
||||
foutput))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(woff->sfnt [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (shell/exec! system
|
||||
:cmd ["woff2sfnt" (str finput)]
|
||||
:out-enc :bytes
|
||||
:prlimit (get-font-prlimit)
|
||||
:timeout (get-font-timeout))]
|
||||
(when (zero? (:exit res))
|
||||
(:out res)))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(woff2->sfnt [data]
|
||||
;; woff2_decompress outputs to same directory with .ttf extension
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
||||
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
|
||||
(if (zero? (:exit res))
|
||||
foutput
|
||||
(do
|
||||
(when (fs/exists? foutput)
|
||||
(fs/delete foutput))
|
||||
nil)))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
;; Documented here:
|
||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||
(get-sfnt-type [data]
|
||||
(let [buff (bb/slice data 0 4)
|
||||
type (bc/bytes->hex buff)]
|
||||
(case type
|
||||
"4f54544f" :otf
|
||||
"00010000" :ttf
|
||||
(ex/raise :type :internal
|
||||
:code :unexpected-data
|
||||
:hint "unexpected font data"))))
|
||||
|
||||
(gen-if-nil [val factory]
|
||||
(if (nil? val)
|
||||
(factory)
|
||||
val))]
|
||||
|
||||
(let [current (into #{} (keys input))]
|
||||
(cond
|
||||
(contains? current "font/ttf")
|
||||
(let [data (get input "font/ttf")]
|
||||
(-> input
|
||||
(update "font/otf" gen-if-nil #(ttf->otf data))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
|
||||
|
||||
(contains? current "font/otf")
|
||||
(let [data (get input "font/otf")]
|
||||
(-> input
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
|
||||
(assoc "font/ttf" (otf->ttf data))))
|
||||
|
||||
(contains? current "font/woff")
|
||||
(let [data (get input "font/woff")
|
||||
sfnt (woff->sfnt data)]
|
||||
(when-not sfnt
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-woff-file
|
||||
:hint "invalid woff file"))
|
||||
(let [stype (get-sfnt-type sfnt)]
|
||||
(cond-> input
|
||||
true
|
||||
(-> (assoc "font/woff" data))
|
||||
|
||||
(= stype :otf)
|
||||
(-> (assoc "font/otf" sfnt)
|
||||
(assoc "font/ttf" (otf->ttf sfnt)))
|
||||
|
||||
(= stype :ttf)
|
||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||
(assoc "font/ttf" sfnt)))))
|
||||
|
||||
(contains? current "font/woff2")
|
||||
(let [data (get input "font/woff2")
|
||||
foutput (woff2->sfnt data)]
|
||||
(when-not foutput
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-woff2-file
|
||||
:hint "invalid woff2 file"))
|
||||
(try
|
||||
(let [sfnt (io/read* foutput)
|
||||
type (get-sfnt-type sfnt)]
|
||||
(cond-> input
|
||||
(= type :otf)
|
||||
(-> (assoc "font/otf" sfnt)
|
||||
(assoc "font/ttf" (otf->ttf sfnt))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
||||
|
||||
(= type :ttf)
|
||||
(-> (assoc "font/ttf" sfnt)
|
||||
(assoc "font/otf" (ttf->otf sfnt))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
||||
(finally
|
||||
(fs/delete foutput))))))))
|
||||
@@ -0,0 +1,264 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.media.remote
|
||||
"Remote media processing via the media-processor HTTP service."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as uri]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.media.svg :as svg]
|
||||
[app.media.validation :as validation]
|
||||
[app.setup :as-alias setup]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.json :as json]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
java.io.ByteArrayInputStream
|
||||
java.io.InputStream
|
||||
java.io.SequenceInputStream
|
||||
java.net.ConnectException
|
||||
java.net.http.HttpTimeoutException
|
||||
java.util.Collections))
|
||||
|
||||
(defn- service-base-url
|
||||
"Returns the base URL of the media-processor service."
|
||||
[]
|
||||
(or (cf/get :media-processing-service-uri)
|
||||
(ex/raise :type :internal
|
||||
:code :media-processor-not-configured
|
||||
:hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured")))
|
||||
|
||||
(defn- service-timeout
|
||||
"Returns the HTTP timeout (ms) for media-processor requests."
|
||||
[]
|
||||
(or (cf/get :media-processing-service-timeout)
|
||||
120000))
|
||||
|
||||
(defn- get-shared-key
|
||||
"Returns the shared key for authenticating with the media-processor."
|
||||
[system]
|
||||
(-> system ::setup/shared-keys :media-processor))
|
||||
|
||||
(defn- parse-json-response
|
||||
"Parse a JSON response body."
|
||||
[body]
|
||||
(json/read! body))
|
||||
|
||||
(defn- translate-error
|
||||
"Translate a media-processor error response into a Penpot exception."
|
||||
[status body]
|
||||
(let [code (or (:code body) "media-processor-error")
|
||||
hint (or (:hint body) "media-processor request failed")]
|
||||
(case status
|
||||
400 {:type :validation :code (keyword code) :hint hint}
|
||||
403 {:type :authorization :code :forbidden :hint hint}
|
||||
413 {:type :restriction :code (keyword code) :hint hint}
|
||||
504 {:type :internal :code :media-processor-timeout :hint hint}
|
||||
{:type :internal :code (keyword code) :hint hint})))
|
||||
|
||||
(defn service-request
|
||||
"Make an HTTP request to the media-processor service."
|
||||
[system {:keys [method uri body headers timeout]}]
|
||||
(let [client (::http/client system)
|
||||
timeout (or timeout (service-timeout))]
|
||||
(try
|
||||
(let [resp (http/req client
|
||||
{:method method
|
||||
:uri uri
|
||||
:body body
|
||||
:headers headers}
|
||||
{:response-type :input-stream
|
||||
:skip-ssrf-check? true
|
||||
:timeout timeout})
|
||||
status (:status resp)]
|
||||
(when (not (<= 200 status 299))
|
||||
(let [body (:body resp)]
|
||||
(try
|
||||
(let [parsed (try (parse-json-response body) (catch Exception _ nil))
|
||||
err (translate-error status parsed)]
|
||||
(ex/raise :type (:type err) :code (:code err) :hint (:hint err)))
|
||||
(finally
|
||||
(.close body)))))
|
||||
resp)
|
||||
(catch ConnectException _cause
|
||||
(ex/raise :type :internal
|
||||
:code :media-processor-unavailable
|
||||
:hint "Cannot connect to media-processor service"))
|
||||
(catch HttpTimeoutException _cause
|
||||
(ex/raise :type :internal
|
||||
:code :media-processor-timeout
|
||||
:hint "media-processor service request timed out")))))
|
||||
|
||||
(defn- multipart-boundary
|
||||
[]
|
||||
(str "----PenpotBoundary" (System/currentTimeMillis)))
|
||||
|
||||
(defn- build-multipart-stream
|
||||
"Build a streaming multipart/form-data body with a single file field.
|
||||
Returns an InputStream that lazily reads from the file on demand."
|
||||
[^String boundary mtype ^InputStream file-stream]
|
||||
(let [header (.getBytes (str "--" boundary "\r\n"
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
|
||||
"Content-Type: " mtype "\r\n"
|
||||
"\r\n")
|
||||
"UTF-8")
|
||||
footer (.getBytes (str "\r\n--" boundary "--\r\n")
|
||||
"UTF-8")
|
||||
parts (Collections/enumeration
|
||||
[(ByteArrayInputStream. header)
|
||||
file-stream
|
||||
(ByteArrayInputStream. footer)])]
|
||||
(SequenceInputStream. parts)))
|
||||
|
||||
(defn- service-multipart-request
|
||||
"Send a multipart request to the media-processor service.
|
||||
Accepts a file from disk via :path. The file stream is closed
|
||||
after the HTTP request completes (success or failure)."
|
||||
[system {:keys [endpoint path mtype query timeout]}]
|
||||
(let [shared-key (get-shared-key system)
|
||||
boundary (multipart-boundary)
|
||||
ctype (or mtype "application/octet-stream")
|
||||
base-url (service-base-url)
|
||||
request-uri (cond-> (uri/join base-url endpoint)
|
||||
(seq query)
|
||||
(str "?" (uri/map->query-string query)))]
|
||||
(with-open [file-stream (io/input-stream path)]
|
||||
(let [body (build-multipart-stream boundary ctype file-stream)]
|
||||
(service-request system
|
||||
{:method :post
|
||||
:uri request-uri
|
||||
:body body
|
||||
:headers {"Content-Type" (str "multipart/form-data; boundary=" boundary)
|
||||
"x-shared-key" shared-key}
|
||||
:timeout timeout})))))
|
||||
|
||||
(def ^:private known-font-types
|
||||
"Priority-ordered list of font mime-types the system knows how to convert.
|
||||
Order matters: when a font upload contains multiple variants, the first
|
||||
match becomes the conversion source (ttf preferred for best coverage)."
|
||||
["font/ttf" "font/otf" "font/woff" "font/woff2"])
|
||||
|
||||
(defn- font-convert
|
||||
"Convert a font to the given target mime-type via the media-processor service.
|
||||
Accepts source font data as a filesystem Path. Returns a tempfile Path."
|
||||
[system source-mtype target-mtype data]
|
||||
(let [resp (service-multipart-request system {:endpoint "api/font/convert"
|
||||
:path data
|
||||
:mtype source-mtype
|
||||
:query {:target-type target-mtype}
|
||||
:timeout 180000})
|
||||
ext (cm/mtype->extension target-mtype)
|
||||
tmp (tmp/tempfile :prefix "penpot.font." :suffix ext)
|
||||
body (:body resp)]
|
||||
(try
|
||||
(io/write* tmp body)
|
||||
(finally
|
||||
(.close body)))
|
||||
tmp))
|
||||
|
||||
(defn- font-missing-variants
|
||||
"Return the set of target mime-types that should be generated for the given
|
||||
source mime-type (excluding font/woff2, which is never generated)."
|
||||
[source-mtype]
|
||||
(case source-mtype
|
||||
"font/ttf" #{"font/otf" "font/woff"}
|
||||
"font/otf" #{"font/ttf" "font/woff"}
|
||||
"font/woff" #{"font/ttf" "font/otf"}
|
||||
"font/woff2" #{"font/ttf" "font/otf" "font/woff"}))
|
||||
|
||||
(defmulti process (fn [_system params] (:cmd params)))
|
||||
|
||||
(defmethod process :info
|
||||
[system {:keys [input]}]
|
||||
(let [{:keys [path mtype]} (validation/check-input input)]
|
||||
(if (= mtype "image/svg+xml")
|
||||
;; SVG: parse locally (Sharp doesn't support SVG)
|
||||
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||
(when-not info
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "uploaded svg does not provide dimensions"))
|
||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||
;; Raster: delegate to media-processor
|
||||
(let [resp (service-multipart-request system {:endpoint "api/image/info"
|
||||
:path path
|
||||
:mtype mtype})
|
||||
body (:body resp)]
|
||||
(try
|
||||
(let [info (parse-json-response body)
|
||||
detected-mtype (:mtype info)]
|
||||
(when (and (string? mtype)
|
||||
(string? detected-mtype)
|
||||
(not= (str/lower mtype) (str/lower detected-mtype)))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-mismatch
|
||||
:hint (str "File content does not match the declared type. "
|
||||
"Expected: " mtype ". Got: " detected-mtype)))
|
||||
(assoc input
|
||||
:width (:width info)
|
||||
:height (:height info)
|
||||
:size (fs/size path)
|
||||
:ts (ct/now)))
|
||||
(finally
|
||||
(.close body)))))))
|
||||
|
||||
(defn- thumbnail-request
|
||||
"Shared implementation for generic-thumbnail and profile-thumbnail."
|
||||
[system params mode]
|
||||
(let [{:keys [input format quality width height]} params
|
||||
{:keys [path mtype]} (validation/check-input input)
|
||||
fmt (name (or format (cm/mtype->format mtype) :jpeg))
|
||||
resp (service-multipart-request system {:endpoint "api/image/thumbnail"
|
||||
:path path
|
||||
:mtype mtype
|
||||
:query {:width width
|
||||
:height height
|
||||
:quality quality
|
||||
:format fmt
|
||||
:mode mode}})
|
||||
out-format (or format (cm/mtype->format mtype) :jpeg)
|
||||
ext (cm/format->extension out-format)
|
||||
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||
body (:body resp)]
|
||||
(try
|
||||
(io/write* tmp body)
|
||||
(finally
|
||||
(.close body)))
|
||||
(assoc params
|
||||
:format out-format
|
||||
:mtype (cm/format->mtype out-format)
|
||||
:size (fs/size tmp)
|
||||
:data tmp)))
|
||||
|
||||
(defmethod process :generic-thumbnail
|
||||
[system params]
|
||||
(thumbnail-request system params "fit"))
|
||||
|
||||
(defmethod process :profile-thumbnail
|
||||
[system params]
|
||||
(thumbnail-request system params "crop"))
|
||||
|
||||
(defmethod process :generate-fonts
|
||||
[system {:keys [input]}]
|
||||
(let [source-mtype (or (some #(when (contains? input %) %) known-font-types)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-font
|
||||
:hint "No recognized font variant in input"))
|
||||
data (get input source-mtype)
|
||||
present (set (keys input))
|
||||
targets (remove present (font-missing-variants source-mtype))]
|
||||
(reduce (fn [acc target-mtype]
|
||||
(assoc acc target-mtype
|
||||
(font-convert system source-mtype target-mtype data)))
|
||||
input
|
||||
targets)))
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.media.svg
|
||||
"SVG parsing, sanitization, and info extraction.
|
||||
Centralizes all SVG-related security concerns."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[clojure.xml :as xml]
|
||||
[cuerdas.core :as str])
|
||||
(:import
|
||||
clojure.lang.XMLHandler
|
||||
java.io.InputStream
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
javax.xml.XMLConstants
|
||||
org.apache.commons.io.IOUtils))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG PARSING
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- secure-parser-factory
|
||||
[^InputStream input ^XMLHandler handler]
|
||||
(.. (doto (SAXParserFactory/newInstance)
|
||||
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
||||
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
||||
(newSAXParser)
|
||||
(parse input handler)))
|
||||
|
||||
(defn- strip-doctype
|
||||
[data]
|
||||
(cond-> data
|
||||
(str/includes? data "<!DOCTYPE")
|
||||
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
||||
|
||||
(defn parse-svg
|
||||
[text]
|
||||
(let [text (strip-doctype text)]
|
||||
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
||||
(xml/parse istream secure-parser-factory))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG SANITIZATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$")
|
||||
(def ^:private javascript-href-pattern #"(?i)^javascript:")
|
||||
|
||||
(defn- sanitize-svg-element
|
||||
"Recursively sanitize an SVG element by removing dangerous tags and attributes."
|
||||
[{:keys [tag attrs content] :as element}]
|
||||
(when (and (map? element) tag)
|
||||
(let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}]
|
||||
(when-not (contains? dangerous-tags tag)
|
||||
(let [clean-attrs (->> attrs
|
||||
(remove (fn [[k v]]
|
||||
(or (re-matches dangerous-attrs-pattern (name k))
|
||||
(and (#{:href :xlink:href} k)
|
||||
(string? v)
|
||||
(re-find javascript-href-pattern (str/trim v))))))
|
||||
(into {}))
|
||||
clean-content (when content
|
||||
(->> content
|
||||
(filter #(or (string? %) (map? %)))
|
||||
(map (fn [child]
|
||||
(if (map? child)
|
||||
(sanitize-svg-element child)
|
||||
child)))
|
||||
(filter some?)
|
||||
vec))]
|
||||
(cond-> {:tag tag :attrs clean-attrs}
|
||||
(seq clean-content) (assoc :content clean-content)))))))
|
||||
|
||||
(defn sanitize-svg
|
||||
"Sanitize SVG content by removing dangerous elements and attributes.
|
||||
Removes <script> tags, <foreignObject> elements, event handlers (on*),
|
||||
and javascript: URLs from href attributes."
|
||||
[svg-text]
|
||||
(try
|
||||
(let [parsed (parse-svg svg-text)
|
||||
sanitized (sanitize-svg-element parsed)]
|
||||
(if sanitized
|
||||
(with-out-str (xml/emit sanitized))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "SVG sanitization produced no output")))
|
||||
(catch Exception e
|
||||
(l/warn :hint "SVG sanitization failed, rejecting upload" :cause e)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "SVG parsing failed during sanitization"
|
||||
:cause e))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG INFO EXTRACTION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn get-basic-info-from-svg
|
||||
[{:keys [tag attrs] :as data}]
|
||||
(when (not= tag :svg)
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-parse-svg
|
||||
:hint "uploaded svg has invalid content"))
|
||||
(reduce (fn [default f]
|
||||
(if-let [res (f attrs)]
|
||||
(reduced res)
|
||||
default))
|
||||
{:width 100 :height 100}
|
||||
[(fn parse-width-and-height
|
||||
[{:keys [width height]}]
|
||||
(when (and (string? width)
|
||||
(string? height))
|
||||
(let [width (d/parse-double width)
|
||||
height (d/parse-double height)]
|
||||
(when (and width height)
|
||||
{:width (int width)
|
||||
:height (int height)}))))
|
||||
(fn parse-viewbox
|
||||
[{:keys [viewBox]}]
|
||||
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
||||
(map d/parse-double))]
|
||||
(when (and x y width height)
|
||||
{:width (int width)
|
||||
:height (int height)})))]))
|
||||
@@ -0,0 +1,68 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.media.validation
|
||||
"Schemas and validation functions for media uploads.
|
||||
Leaf namespace — depends on app.common.* and app.config only."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.config :as cf]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]))
|
||||
|
||||
(def schema:upload
|
||||
[:map {:title "Upload"}
|
||||
[:filename :string]
|
||||
[:size ::sm/int]
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} :string]
|
||||
[:headers {:optional true}
|
||||
[:map-of :string :string]]])
|
||||
|
||||
(def schema:input
|
||||
[:map {:title "Input"}
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} ::sm/text]])
|
||||
|
||||
(def check-input
|
||||
(sm/check-fn schema:input))
|
||||
|
||||
(defn validate-media-type!
|
||||
([upload] (validate-media-type! upload cm/image-types))
|
||||
([upload allowed]
|
||||
(when-not (contains? allowed (:mtype upload))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "Seems like you are uploading an invalid media object"))
|
||||
|
||||
upload))
|
||||
|
||||
(defn validate-media-size!
|
||||
[upload]
|
||||
(let [max-size (cf/get :media-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :media-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defn validate-font-size!
|
||||
"Validates that the font file `upload` does not exceed the configured
|
||||
`:font-max-file-size` limit. Accepts the same map shape as
|
||||
`validate-media-size!` — requires a `:size` key in bytes."
|
||||
[upload]
|
||||
(let [max-size (cf/get :font-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :font-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
max-size)))
|
||||
upload))
|
||||
@@ -495,6 +495,9 @@
|
||||
{: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")}])
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 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();
|
||||
+264
-204
@@ -14,19 +14,49 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as sg]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.organization :as cto]
|
||||
[app.common.types.organization :as cto
|
||||
:refer [schema:nitrate-sso]]
|
||||
[app.common.uri :as u]
|
||||
[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 []
|
||||
@@ -113,7 +143,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 :nitrate)
|
||||
(let [shared-key (-> cfg ::setup/shared-keys :admin-console)
|
||||
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?))]
|
||||
@@ -125,14 +155,14 @@
|
||||
|
||||
(defn call
|
||||
[cfg method params]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(let [client (get cfg ::client)
|
||||
method (get client method)]
|
||||
(method params))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:org-summary
|
||||
(def ^:private schema:organization-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
@@ -143,13 +173,6 @@
|
||||
[: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
|
||||
@@ -166,6 +189,13 @@
|
||||
: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]
|
||||
@@ -223,60 +253,52 @@
|
||||
[:map
|
||||
[:licenses ::sm/boolean]])
|
||||
|
||||
(defn- get-team-org-api
|
||||
(defn- get-team-organization-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id)
|
||||
cto/schema:team-with-organization params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/teams/" team-id)
|
||||
cto/schema:team-with-organization params))
|
||||
|
||||
(defn- get-org-membership-api
|
||||
(defn- get-organization-membership-api
|
||||
[cfg {:keys [profile-id organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/members/"
|
||||
profile-id)
|
||||
schema:profile-org params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"members/"
|
||||
profile-id)
|
||||
schema:profile-organization params))
|
||||
|
||||
(defn- get-org-membership-by-team-api
|
||||
(defn- get-organization-membership-by-team-api
|
||||
[cfg {:keys [profile-id team-id] :as params}]
|
||||
(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)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/teams/"
|
||||
team-id
|
||||
"users/"
|
||||
profile-id)
|
||||
schema:profile-organization params))
|
||||
|
||||
|
||||
(defn- get-org-summary-api
|
||||
(defn- get-organization-summary-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/summary")
|
||||
schema:org-summary params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"summary")
|
||||
schema:organization-summary params))
|
||||
|
||||
(defn- get-owned-orgs-api
|
||||
(defn- get-owned-organizations-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/users/"
|
||||
profile-id
|
||||
"/owned-organizations")
|
||||
[:vector schema:org-summary]
|
||||
params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/users/"
|
||||
profile-id
|
||||
"owned-organizations")
|
||||
[:vector schema:organization-summary]
|
||||
params))
|
||||
|
||||
(def ^:private schema:org-summary-counts
|
||||
(def ^:private schema:organization-summary-counts
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
@@ -286,101 +308,94 @@
|
||||
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
|
||||
|
||||
(defn- get-owned-orgs-summary-api
|
||||
(defn- get-owned-organizations-summary-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(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)))
|
||||
(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)))
|
||||
|
||||
(defn- cleanup-deleted-penpot-user-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/users/"
|
||||
profile-id
|
||||
"/cleanup-after-deletion")
|
||||
nil params)))
|
||||
(request-to-nitrate cfg :post
|
||||
(generate-nitrate-uri
|
||||
"api/users/"
|
||||
profile-id
|
||||
"cleanup-after-deletion")
|
||||
nil params))
|
||||
|
||||
(defn- set-team-org-api
|
||||
(defn- set-team-organization-api
|
||||
[cfg {:keys [organization-id team-id is-default] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
params (assoc params :request-params {:team-id team-id
|
||||
(let [params (assoc params :request-params {:team-id team-id
|
||||
:is-your-penpot (true? is-default)})
|
||||
team (request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/add-team")
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"add-team")
|
||||
cto/schema:team-with-organization params)
|
||||
custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])]
|
||||
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
|
||||
(generate-public-uri "assets/by-id/" logo-id))]
|
||||
(cond-> team
|
||||
custom-photo
|
||||
(assoc-in [:organization :custom-photo] custom-photo))))
|
||||
|
||||
(defn- add-profile-to-org-api
|
||||
(defn- add-profile-to-organization-api
|
||||
[cfg {:keys [profile-id organization-id team-id email] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
request-params (cond-> {:user-id profile-id :team-id team-id}
|
||||
(let [request-params (cond-> {:user-id profile-id :team-id team-id}
|
||||
(some? email) (assoc :email email))
|
||||
params (assoc params :request-params request-params)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/add-user")
|
||||
schema:profile-org params)))
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"add-user")
|
||||
schema:profile-organization 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})]
|
||||
(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)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/remove-user")
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"remove-user")
|
||||
nil params)))
|
||||
|
||||
(defn- remove-team-from-org-api
|
||||
(defn- remove-team-from-organization-api
|
||||
[cfg {:keys [team-id organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
params (assoc params :request-params {:team-id team-id})]
|
||||
(let [params (assoc params :request-params {:team-id team-id})]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/remove-team")
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"remove-team")
|
||||
nil params)))
|
||||
|
||||
(defn- delete-team-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :delete
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id)
|
||||
nil params)))
|
||||
(request-to-nitrate cfg :delete
|
||||
(generate-nitrate-uri "api/teams/" team-id)
|
||||
nil params))
|
||||
|
||||
(defn- get-subscription-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/subscriptions/"
|
||||
profile-id)
|
||||
schema:subscription params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/subscriptions/" profile-id)
|
||||
schema:subscription params))
|
||||
|
||||
(def ^:private schema:subscription-warning
|
||||
[:maybe
|
||||
@@ -392,80 +407,79 @@
|
||||
|
||||
(defn- get-subscription-warning-api
|
||||
[cfg {:keys [penpot-id profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
penpot-id (or penpot-id profile-id)]
|
||||
(let [penpot-id (or penpot-id profile-id)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/subscription-warning/"
|
||||
penpot-id)
|
||||
(generate-nitrate-uri "api/subscription-warning/" penpot-id)
|
||||
schema:subscription-warning params)))
|
||||
|
||||
(defn- get-connectivity-api
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/connectivity")
|
||||
schema:connectivity params)))
|
||||
(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))
|
||||
|
||||
(def ^:private schema:redeem-result
|
||||
[:map
|
||||
[:cancel-at [:maybe schema:timestamp]]])
|
||||
|
||||
(defn- get-org-permissions-api
|
||||
(defn- get-organization-permissions-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/permissions")
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:owner-id ::sm/uuid]
|
||||
[:permissions [:map-of :keyword :string]]]
|
||||
params)))
|
||||
(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))
|
||||
|
||||
(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-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))
|
||||
|
||||
(defn- get-org-sso-by-team-api
|
||||
(defn- get-organization-sso-by-team-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id
|
||||
"/sso")
|
||||
schema:nitrate-sso
|
||||
params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/teams/" team-id "sso")
|
||||
schema:nitrate-sso
|
||||
params))
|
||||
|
||||
(defn- get-org-members-api
|
||||
(defn- get-organization-members-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/members-list")
|
||||
[:vector ::sm/uuid]
|
||||
params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"members-list")
|
||||
[:vector ::sm/uuid]
|
||||
params))
|
||||
|
||||
(defn- redeem-activation-code-api
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri "/api/activation-codes/redeem")
|
||||
schema:redeem-result
|
||||
(assoc params :throw-on-error? true))))
|
||||
(request-to-nitrate cfg :post
|
||||
(generate-nitrate-uri "api/activation-codes/redeem")
|
||||
schema:redeem-result
|
||||
(assoc params :throw-on-error? true)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INITIALIZATION
|
||||
@@ -473,40 +487,86 @@
|
||||
|
||||
(defmethod ig/init-key ::client
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
{:get-team-org (partial get-team-org-api cfg)
|
||||
:set-team-org (partial set-team-org-api cfg)
|
||||
:get-org-membership (partial get-org-membership-api cfg)
|
||||
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
|
||||
:get-org-summary (partial get-org-summary-api cfg)
|
||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
||||
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
|
||||
:get-org-members (partial get-org-members-api 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)
|
||||
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-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)
|
||||
: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)
|
||||
:delete-team (partial delete-team-api cfg)
|
||||
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
||||
:remove-team-from-organization (partial remove-team-from-organization-api cfg)
|
||||
:get-subscription (partial get-subscription-api cfg)
|
||||
:get-subscription-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 org-SSO config for the given team and checks whether
|
||||
the HTTP request has a valid session entry for it. Returns a map
|
||||
"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
|
||||
with :authorized and :sso keys."
|
||||
[cfg team-id request]
|
||||
(let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})]
|
||||
[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}))]
|
||||
(if-not (:active sso)
|
||||
{:authorized true :sso sso}
|
||||
(if (or (:issuer sso) (:base-url sso))
|
||||
(if-not (str/blank? (:issuer sso))
|
||||
(let [props (:props session)
|
||||
sso-map (get props :sso {})
|
||||
organization-id (:organization-id sso)
|
||||
@@ -536,21 +596,21 @@
|
||||
:cause cause)
|
||||
profile)))))
|
||||
|
||||
(defn add-org-info-to-team
|
||||
(defn add-organization-info-to-team
|
||||
"Enriches a team map with organization information from Nitrate.
|
||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||
Returns the original team unchanged if the request fails or org data is nil.
|
||||
Returns the original team unchanged if the request fails or organization data is nil.
|
||||
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
||||
[cfg team params]
|
||||
(try
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
team-with-org (call cfg :get-team-org params)
|
||||
org (:organization team-with-org)]
|
||||
(if (some? org)
|
||||
(-> (cto/apply-organization team (assoc org :custom-photo
|
||||
(when-let [logo-id (:logo-id org)]
|
||||
(str (cf/get :public-uri) "/assets/by-id/" logo-id))))
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
|
||||
team-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))
|
||||
(catch Throwable cause
|
||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||
@@ -570,10 +630,10 @@
|
||||
:team-id (:id team)
|
||||
:organization-id (:organization-id params)
|
||||
:is-default (:is-default params))
|
||||
result (call cfg :set-team-org params)]
|
||||
result (call cfg :set-team-organization params)]
|
||||
(when (nil? result)
|
||||
(ex/raise :type :internal
|
||||
:code :failed-to-set-team-org
|
||||
:code :failed-to-set-team-organization
|
||||
:context {:team-id (:id team)
|
||||
:organization-id (:organization-id params)}))
|
||||
team))
|
||||
+41
-34
@@ -250,64 +250,71 @@
|
||||
f))
|
||||
|
||||
|
||||
(defonce ^:private org-sso-auth-cache
|
||||
(defonce ^:private organization-sso-auth-cache
|
||||
(cache/create :expire "15m" :max-size 1024))
|
||||
|
||||
(defn invalidate-org-sso-cache-by-org!
|
||||
"Invalidates all org-SSO authorization cache entries for the given organization-id."
|
||||
(defn invalidate-organization-sso-cache-by-organization!
|
||||
"Invalidates all organization-SSO authorization cache entries for the given organization-id."
|
||||
[organization-id]
|
||||
(cache/invalidate-if org-sso-auth-cache #(= (:organization-id %) organization-id)))
|
||||
(cache/invalidate-if organization-sso-auth-cache #(= (:organization-id %) organization-id)))
|
||||
|
||||
(defn- wrap-nitrate-sso
|
||||
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Only activates when:
|
||||
- Nitrate flag is enabled
|
||||
- Endpoint requires authentication (::auth true by default)
|
||||
- Endpoint is not marked with ::nitrate/org-sso false
|
||||
- Endpoint is not marked with ::nitrate/organization-sso false
|
||||
|
||||
Raises :nitrate-sso-required error if user is not authorized in the org."
|
||||
Raises :nitrate-sso-required error if user is not authorized in the organization."
|
||||
[_ f mdata]
|
||||
(if (and (contains? cf/flags :nitrate)
|
||||
(if (and (contains? cf/flags :admin-console)
|
||||
(::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 [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)
|
||||
(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)
|
||||
|
||||
cache-key [profile-id cache-ref]
|
||||
cached (cache/get org-sso-auth-cache cache-key)
|
||||
cached (cache/get organization-sso-auth-cache cache-key)
|
||||
result (if (some? cached)
|
||||
cached
|
||||
(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)))
|
||||
(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))))
|
||||
request (-> (meta params) (get ::http/request))
|
||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id 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))
|
||||
entry {:authorized authorized
|
||||
:organization-id (:organization-id sso)}]
|
||||
(when authorized
|
||||
(cache/get org-sso-auth-cache cache-key (constantly entry)))
|
||||
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
||||
entry))]
|
||||
(if (:authorized result)
|
||||
(f cfg params)
|
||||
@@ -423,7 +430,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 :nitrate)
|
||||
(contains? cf/flags :admin-console)
|
||||
(cons 'app.rpc.management.nitrate))]
|
||||
|
||||
(->> (apply sv/scan-ns mods)
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
created-at (ct/now)
|
||||
token (tokens/generate cfg {:iss "access-token"
|
||||
token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token")
|
||||
token (tokens/generate cfg {:iss token-iss
|
||||
:uid profile-id
|
||||
:iat created-at
|
||||
:tid token-id})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(: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]
|
||||
@@ -182,6 +183,7 @@
|
||||
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
|
||||
nil))]
|
||||
|
||||
(passwords/validate-password password)
|
||||
(->> (validate-token token)
|
||||
(update-password conn))
|
||||
|
||||
@@ -240,6 +242,9 @@
|
||||
: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
|
||||
@@ -258,7 +263,8 @@
|
||||
(validate-register-attempt! cfg params)
|
||||
|
||||
(let [email (profile/clean-email email)
|
||||
profile (profile/get-profile-by-email pool email)]
|
||||
profile (profile/get-profile-by-email pool email)
|
||||
fullname (d/normalize-string fullname)]
|
||||
|
||||
;; SECURITY: refuse to issue a prepared-register token when an active
|
||||
;; profile already exists for this email.
|
||||
@@ -359,6 +365,9 @@
|
||||
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)
|
||||
@@ -367,7 +376,7 @@
|
||||
(import-profile-picture cfg))
|
||||
|
||||
params {:id id
|
||||
:fullname (:fullname params)
|
||||
:fullname fullname
|
||||
:email email
|
||||
:auth-backend backend
|
||||
:lang locale
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
[app.http.sse :as sse]
|
||||
[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.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}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(files/check-read-permissions! cfg profile-id file-id)
|
||||
(sse/response (partial export-binfile cfg params)))
|
||||
|
||||
;; --- Command: import-binfile
|
||||
@@ -118,48 +118,38 @@
|
||||
|
||||
(def ^:private schema:import-binfile
|
||||
[:and
|
||||
[:map {:title "import-binfile"}
|
||||
[:map {:title "import-binfile" :closed true}
|
||||
[:name [:or [:string {:max 250}]
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:project-id ::sm/uuid]
|
||||
[:file-id {:optional true} ::sm/uuid]
|
||||
[:version {:optional true} ::sm/int]
|
||||
[:file {:optional true} media/schema:upload]
|
||||
[:file {:optional true} media.v/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. 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.
|
||||
"Import a penpot file in a binary format.
|
||||
|
||||
The file content may be provided either as a multipart `file` upload
|
||||
or as an `upload-id` referencing a completed chunked-upload session,
|
||||
which allows importing files larger than the multipart size limit.
|
||||
"
|
||||
{::doc/added "1.15"
|
||||
::doc/changes ["1.20" "Add file-id param for in-place import"
|
||||
"1.20" "Set default version to 3"
|
||||
"2.15" "Add upload-id param for chunked upload support"]
|
||||
::doc/changes [["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 file-id upload-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
|
||||
(projects/check-edition-permissions! pool profile-id project-id)
|
||||
(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)]
|
||||
@@ -174,6 +164,5 @@
|
||||
(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)}})))
|
||||
@@ -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]}]
|
||||
(files/check-comment-permissions! conn 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-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]}]
|
||||
(files/check-comment-permissions! conn 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)
|
||||
(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]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [{:keys [file-id]} (get-comment-thread conn thread-id)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(get-comments conn thread-id)))))
|
||||
|
||||
(def sql:get-comments
|
||||
@@ -406,8 +406,8 @@
|
||||
::doc/changes ["1.15" "Imported from queries and renamed."]
|
||||
::sm/params schema:get-profiles-for-file-comments}
|
||||
[cfg {:keys [::rpc/profile-id file-id share-id]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(files/check-comment-permissions! conn 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))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -534,9 +534,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread-status
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
|
||||
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(upsert-comment-thread-status! conn profile-id id)))
|
||||
|
||||
;; --- COMMAND: Update Comment Thread
|
||||
@@ -552,9 +552,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id is-resolved share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {: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! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:is-resolved is-resolved}
|
||||
{:id id})
|
||||
@@ -582,7 +582,7 @@
|
||||
{:keys [team-id project-id] :as file}
|
||||
(get-file cfg file-id page-id)]
|
||||
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
|
||||
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
|
||||
::quotes/profile-id profile-id
|
||||
@@ -653,7 +653,7 @@
|
||||
{:keys [file-id page-id] :as thread}
|
||||
(get-comment-thread conn thread-id ::sql/for-update true)]
|
||||
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
|
||||
;; Don't allow edit comments to not owners
|
||||
(when-not (= owner-id profile-id)
|
||||
@@ -690,9 +690,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:delete-comment-thread
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {: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! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
@@ -713,14 +713,14 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:delete-comment
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {: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! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
@@ -743,9 +743,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread-position
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {: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! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at request-at
|
||||
:position (db/pgpoint position)
|
||||
@@ -767,9 +767,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread-frame
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {: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! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at request-at
|
||||
:frame-id frame-id}
|
||||
|
||||
@@ -14,22 +14,25 @@
|
||||
[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 ^:private schema:send-user-feedback
|
||||
(def 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]])
|
||||
[:error-report {:optional true} [:string {:max 1048576}]]])
|
||||
|
||||
(sv/defmethod ::send-user-feedback
|
||||
{::doc/added "1.18"
|
||||
{::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id]
|
||||
[:send-user-feedback/global]]
|
||||
::doc/added "1.18"
|
||||
::sm/params schema:send-user-feedback}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
|
||||
(when-not (contains? cf/flags :user-feedback)
|
||||
|
||||
@@ -84,10 +84,10 @@
|
||||
(perms/make-edition-predicate-fn bfc/get-file-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn bfc/get-file-permissions))
|
||||
(perms/make-read-predicate-fn perms/get-file-read-permissions))
|
||||
|
||||
(def has-comment-permissions?
|
||||
(perms/make-comment-predicate-fn bfc/get-file-permissions))
|
||||
(perms/make-comment-predicate-fn perms/get-file-read-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!
|
||||
[conn profile-id file-id share-id]
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
[cfg profile-id file-id share-id]
|
||||
(let [perms (perms/get-file-read-permissions cfg 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,7 +152,7 @@
|
||||
(defn- get-minimal-file-with-perms
|
||||
[cfg {:keys [:id ::rpc/profile-id]}]
|
||||
(let [mfile (get-minimal-file cfg id)
|
||||
perms (bfc/get-file-permissions cfg profile-id id)]
|
||||
perms (perms/get-file-read-permissions cfg profile-id id)]
|
||||
(assoc mfile :permissions perms)))
|
||||
|
||||
(defn get-file-etag
|
||||
@@ -173,7 +173,7 @@
|
||||
::sm/params schema:get-file
|
||||
::sm/result schema:file-with-permissions
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}]
|
||||
[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 +181,10 @@
|
||||
;; will be already prefetched and we just reuse them instead
|
||||
;; of making an additional database queries.
|
||||
(let [perms (or (:permissions (::cond/object params))
|
||||
(bfc/get-file-permissions conn profile-id id))]
|
||||
(perms/get-file-read-permissions cfg profile-id id))]
|
||||
(check-read-permissions! perms)
|
||||
|
||||
(let [team (teams/get-team conn
|
||||
(let [team (teams/get-team cfg
|
||||
:profile-id profile-id
|
||||
:project-id project-id
|
||||
:file-id id)
|
||||
@@ -244,7 +244,7 @@
|
||||
::sm/result schema:file-fragment}
|
||||
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
|
||||
(db/run! cfg (fn [cfg]
|
||||
(let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)]
|
||||
(let [perms (perms/get-file-read-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 +288,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! pool profile-id project-id)
|
||||
(projects/check-read-permissions! cfg profile-id project-id)
|
||||
(get-project-files pool project-id))
|
||||
|
||||
;; --- COMMAND QUERY: has-file-libraries
|
||||
@@ -306,7 +306,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! pool profile-id file-id)
|
||||
(check-read-permissions! cfg profile-id file-id)
|
||||
(get-has-file-libraries conn file-id)))
|
||||
|
||||
(def ^:private sql:has-file-libraries
|
||||
@@ -339,7 +339,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! pool profile-id file-id)
|
||||
(check-read-permissions! cfg profile-id file-id)
|
||||
(get-library-usage conn file-id)))
|
||||
|
||||
(def ^:private sql:get-library-usage
|
||||
@@ -389,7 +389,7 @@
|
||||
:code :params-validation
|
||||
:hint "page-id is required when object-id is provided"))
|
||||
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
(let [perms (perms/get-file-read-permissions cfg 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 +440,8 @@
|
||||
::sm/params schema:get-page}
|
||||
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(check-read-permissions! conn profile-id file-id share-id)
|
||||
(fn [cfg]
|
||||
(check-read-permissions! cfg profile-id file-id share-id)
|
||||
(get-page cfg (assoc params :profile-id profile-id)))))
|
||||
|
||||
;; --- COMMAND QUERY: get-team-shared-files
|
||||
@@ -564,7 +564,7 @@
|
||||
|
||||
(defn- get-team-shared-files
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
|
||||
(let [process-row
|
||||
(fn [{:keys [id library-file-ids]}]
|
||||
@@ -677,8 +677,8 @@
|
||||
::sm/params schema:get-file-stats
|
||||
::sm/result schema:get-file-stats-result
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(check-read-permissions! conn profile-id id)
|
||||
[cfg {:keys [::rpc/profile-id id]}]
|
||||
(check-read-permissions! cfg profile-id id)
|
||||
(get-file-stats cfg id))
|
||||
|
||||
|
||||
@@ -721,7 +721,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! conn profile-id file-id)
|
||||
(check-read-permissions! cfg profile-id file-id)
|
||||
(get-library-file-references conn file-id)))
|
||||
|
||||
;; --- COMMAND QUERY: get-team-recent-files
|
||||
@@ -765,7 +765,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! conn profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-recent-files conn team-id)))
|
||||
|
||||
|
||||
@@ -810,8 +810,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]}]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-deleted-files conn team-id))))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-info
|
||||
@@ -1069,6 +1069,25 @@
|
||||
[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
|
||||
@@ -1104,6 +1123,7 @@
|
||||
|
||||
(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)
|
||||
@@ -1135,6 +1155,7 @@
|
||||
[{: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)
|
||||
|
||||
@@ -1159,6 +1180,7 @@
|
||||
[{: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,6 +22,7 @@
|
||||
[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]))
|
||||
|
||||
@@ -33,8 +34,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]}]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-read-permissions! cfg profile-id file-id)
|
||||
(fsnap/get-visible-snapshots conn file-id))))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-snapshot
|
||||
@@ -52,8 +53,8 @@
|
||||
::sm/params schema:get-file-snapshot
|
||||
::sm/result files/schema:file-with-permissions
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
|
||||
[cfg {:keys [::rpc/profile-id file-id id] :as params}]
|
||||
(let [perms (perms/get-file-read-permissions cfg 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 :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]
|
||||
@@ -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! conn profile-id file-id)
|
||||
(files/check-read-permissions! cfg 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 [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(let [team (teams/get-team conn
|
||||
(db/run! cfg (fn [cfg]
|
||||
(files/check-read-permissions! cfg profile-id file-id)
|
||||
(let [team (teams/get-team cfg
|
||||
: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/schema:upload]
|
||||
[:media media.v/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/validate-media-type! media)
|
||||
(media/validate-media-size! media)
|
||||
(media.v/validate-media-type! media)
|
||||
(media.v/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})]
|
||||
@@ -379,7 +379,7 @@
|
||||
[:map {:title "create-file-thumbnail"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:revn ::sm/int]
|
||||
[:media media/schema:upload]])
|
||||
[:media media.v/schema:upload]])
|
||||
|
||||
(sv/defmethod ::create-file-thumbnail
|
||||
"Creates or updates the file thumbnail. Mainly used for paint the
|
||||
@@ -394,8 +394,8 @@
|
||||
::sm/params schema:create-file-thumbnail}
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(media/validate-media-type! (:media params))
|
||||
(media/validate-media-size! (:media 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)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
(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]
|
||||
@@ -22,6 +21,7 @@
|
||||
[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,6 +30,7 @@
|
||||
[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]
|
||||
@@ -38,10 +39,7 @@
|
||||
[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))
|
||||
|
||||
@@ -71,14 +69,14 @@
|
||||
(cond
|
||||
(uuid? team-id)
|
||||
(do
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(teams/check-read-permissions! cfg 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! conn profile-id project-id)
|
||||
(projects/check-read-permissions! cfg profile-id project-id)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id (:team-id project)
|
||||
:deleted-at nil}))
|
||||
@@ -86,7 +84,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 (bfc/get-file-permissions conn profile-id file-id share-id)]
|
||||
perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
|
||||
(files/check-read-permissions! perms)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id (:team-id project)
|
||||
@@ -96,18 +94,13 @@
|
||||
(declare create-font-variant)
|
||||
|
||||
(def ^:private schema:create-font-variant
|
||||
[: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)))]])
|
||||
[: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]]])
|
||||
|
||||
(defn- prepare-font-data-from-uploads
|
||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||
@@ -118,8 +111,8 @@
|
||||
(fn [acc mtype session-id]
|
||||
(let [assembled (assemble-chunks cfg session-id)]
|
||||
(-> {:mtype mtype :size (:size assembled)}
|
||||
(media/validate-media-type! cm/font-types)
|
||||
(media/validate-font-size!))
|
||||
(media.v/validate-media-type! cm/font-types)
|
||||
(media.v/validate-font-size!))
|
||||
(assoc acc mtype (:path assembled))))
|
||||
{}
|
||||
uploads)]
|
||||
@@ -128,54 +121,23 @@
|
||||
(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 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."
|
||||
"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)."
|
||||
{::doc/added "1.18"
|
||||
::doc/changes ["2.16" "Add :uploads param for chunked upload support"]
|
||||
::doc/changes [["2.16" "Add :uploads param for chunked upload support"]
|
||||
["2.18" "Remove :data param, use :uploads exclusively"]]
|
||||
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
||||
[:process-font/global]]
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-font-variant}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :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 (if (some? uploads)
|
||||
(db/tx-run! cfg prepare-font-data-from-uploads params)
|
||||
(prepare-font-data-from-legacy params))]
|
||||
(let [params (db/tx-run! cfg prepare-font-data-from-uploads params)]
|
||||
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
||||
|
||||
(defn create-font-variant
|
||||
@@ -229,9 +191,7 @@
|
||||
(let [tpoint (ct/tpoint)
|
||||
mtypes (vec (keys data))
|
||||
total-size (reduce-kv (fn [acc _ content]
|
||||
(+ acc (if (bytes? content)
|
||||
(alength ^bytes content)
|
||||
(fs/size content))))
|
||||
(+ acc (fs/size content)))
|
||||
0
|
||||
data)]
|
||||
|
||||
@@ -370,7 +330,7 @@
|
||||
(defn- make-temporal-storage-object
|
||||
[cfg profile-id content]
|
||||
(let [storage (sto/resolve cfg)
|
||||
content (media/check-input content)
|
||||
content (media.v/check-input content)
|
||||
hash (sto/calculate-hash (:path content))
|
||||
data (-> (sto/content (:path content))
|
||||
(sto/wrap-with-hash hash))
|
||||
@@ -400,7 +360,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! pool profile-id (:team-id variant))
|
||||
(teams/check-read-permissions! cfg 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)
|
||||
@@ -432,7 +392,7 @@
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found))
|
||||
|
||||
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
|
||||
(teams/check-read-permissions! cfg profile-id (:team-id (first variants)))
|
||||
|
||||
(let [tempfile (tmp/tempfile :suffix ".zip")
|
||||
ffamily (-> variants first :font-family)]
|
||||
|
||||
@@ -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! conn profile-id team-id))
|
||||
(teams/check-read-permissions! cfg profile-id team-id))
|
||||
|
||||
(binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})]
|
||||
(let [projs (bfc/get-team-projects cfg team-id)
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
[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]
|
||||
@@ -44,7 +46,7 @@
|
||||
[:file-id ::sm/uuid]
|
||||
[:is-local ::sm/boolean]
|
||||
[:name [:string {:max 250}]]
|
||||
[:content media/schema:upload]])
|
||||
[:content media.v/schema:upload]])
|
||||
|
||||
(sv/defmethod ::upload-file-media-object
|
||||
{::doc/added "1.17"
|
||||
@@ -53,8 +55,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/validate-media-type! content)
|
||||
(media/validate-media-size! content)
|
||||
(media.v/validate-media-type! content)
|
||||
(media.v/validate-media-size! content)
|
||||
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
;; We get the minimal file for proper checking if
|
||||
@@ -113,13 +115,22 @@
|
||||
|
||||
(defn- process-main-image
|
||||
[info]
|
||||
(let [hash (sto/calculate-hash (:path info))
|
||||
data (-> (sto/content (:path info))
|
||||
(sto/wrap-with-hash hash))]
|
||||
(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))]
|
||||
{::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at (:ts info)
|
||||
:content-type (:mtype info)
|
||||
:content-type mtype
|
||||
:bucket "file-media-object"}))
|
||||
|
||||
(defn- process-thumb-image
|
||||
@@ -315,7 +326,7 @@
|
||||
[:map {:title "upload-chunk"}
|
||||
[:session-id ::sm/uuid]
|
||||
[:index ::sm/int]
|
||||
[:content media/schema:upload]])
|
||||
[:content media.v/schema:upload]])
|
||||
|
||||
(def ^:private schema:upload-chunk-result
|
||||
[:map {:title "upload-chunk-result"}
|
||||
@@ -386,7 +397,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/schema:upload` with `:filename`, `:path` and
|
||||
conforming to `media.v/schema:upload` with `:filename`, `:path` and
|
||||
`:size`.
|
||||
|
||||
Raises a :validation/:missing-chunks error when the number of stored
|
||||
@@ -440,8 +451,8 @@
|
||||
content (-> content
|
||||
(assoc :filename (str "upload:" name))
|
||||
(assoc :mtype mtype)
|
||||
(media/validate-media-type!)
|
||||
(media/validate-media-size!))
|
||||
(media.v/validate-media-type!)
|
||||
(media.v/validate-media-size!))
|
||||
mobj (create-file-media-object cfg (assoc params
|
||||
:id id
|
||||
:from-chunks? true
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
[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.nitrate-permissions :as nitrate-perms]
|
||||
[app.common.types.organization :as cto]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.nitrate :as nitrate]
|
||||
@@ -21,9 +22,11 @@
|
||||
[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.tokens :as tokens]
|
||||
[app.util.services :as sv]))
|
||||
[app.util.services :as sv]
|
||||
[buddy.core.codecs :as bc]))
|
||||
|
||||
|
||||
(defn assert-is-owner [cfg profile-id team-id]
|
||||
@@ -39,11 +42,11 @@
|
||||
:code :cant-move-default-team))))
|
||||
|
||||
(defn assert-membership [cfg profile-id organization-id]
|
||||
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(when-not (:organization-id membership)
|
||||
(ex/raise :type :validation
|
||||
:code :organization-doesnt-exists))
|
||||
:code :organization-does-not-exist))
|
||||
|
||||
(when-not (:is-member membership)
|
||||
(ex/raise :type :validation
|
||||
@@ -113,6 +116,35 @@
|
||||
: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,
|
||||
@@ -149,7 +181,7 @@
|
||||
{})))
|
||||
{}))
|
||||
|
||||
(defn- build-leave-org-plan
|
||||
(defn- build-leave-organization-plan
|
||||
[{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?]
|
||||
(let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
|
||||
files-counts (get-team-files-counts conn all-teams)
|
||||
@@ -162,18 +194,18 @@
|
||||
{:deletable-team-ids deletable
|
||||
:keep-default-team? keep-default?
|
||||
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
|
||||
:detach-from-org-team-ids to-detach}))
|
||||
:detach-from-organization-team-ids to-detach}))
|
||||
|
||||
(defn get-leave-org-summary
|
||||
(defn get-leave-organization-summary
|
||||
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
|
||||
(let [{:keys [deletable-team-ids detach-from-org-team-ids]}
|
||||
(build-leave-org-plan cfg default-team-id teams-to-delete nil)]
|
||||
(let [{:keys [deletable-team-ids detach-from-organization-team-ids]}
|
||||
(build-leave-organization-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-org-team-ids)}))
|
||||
:teams-to-detach (count detach-from-organization-team-ids)}))
|
||||
|
||||
(def ^:private schema:leave-org
|
||||
(def ^:private schema:leave-organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
@@ -186,47 +218,49 @@
|
||||
[:id ::sm/uuid]
|
||||
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
||||
|
||||
(def ^:private schema:get-leave-org-summary-result
|
||||
(def ^:private schema:get-leave-organization-summary-result
|
||||
[:map
|
||||
[:teams-to-delete ::sm/int]
|
||||
[:teams-to-transfer ::sm/int]
|
||||
[:teams-to-exit ::sm/int]
|
||||
[:teams-to-detach ::sm/int]])
|
||||
[:teams-to-detach ::sm/int]
|
||||
[:member-added-at [:maybe ct/schema:inst]]
|
||||
[:organization-member-count-before ::sm/int]])
|
||||
|
||||
(def ^:private schema:get-leave-org-summary
|
||||
(def ^:private schema:get-leave-organization-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:default-team-id ::sm/uuid]])
|
||||
|
||||
|
||||
(defn- get-organization-teams-for-user
|
||||
[{: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)]
|
||||
[{: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)]
|
||||
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
|
||||
|
||||
(defn- calculate-valid-teams
|
||||
([org-teams default-team-id]
|
||||
([organization-teams default-team-id]
|
||||
(let [;; valid default team is the one which id is default-team-id
|
||||
valid-default-team (d/seek #(= default-team-id (:id %)) org-teams)
|
||||
valid-default-team (d/seek #(= default-team-id (:id %)) organization-teams)
|
||||
|
||||
;; Remove your-penpot for the rest of validations
|
||||
org-teams (remove #(= default-team-id (:id %)) org-teams)
|
||||
organization-teams (remove #(= default-team-id (:id %)) organization-teams)
|
||||
|
||||
;; valid teams to delete are those that the user is owner, and only have one member
|
||||
valid-teams-to-delete-ids (->> org-teams
|
||||
valid-teams-to-delete-ids (->> organization-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 (->> org-teams
|
||||
valid-teams-to-transfer (->> organization-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 (->> org-teams
|
||||
valid-teams-to-exit (->> organization-teams
|
||||
(filter #(and (not (:is-owner %))
|
||||
(> (:num-members %) 1))))]
|
||||
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
|
||||
@@ -235,17 +269,17 @@
|
||||
:valid-default-team valid-default-team})))
|
||||
|
||||
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
org-teams (get-organization-teams-for-user cfg org-summary profile-id)]
|
||||
(calculate-valid-teams org-teams 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)))
|
||||
|
||||
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
org-teams (get-organization-teams-for-user cfg org-summary profile-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)
|
||||
{:keys [valid-teams-to-delete-ids
|
||||
valid-teams-to-transfer
|
||||
valid-teams-to-exit
|
||||
valid-default-team]} (calculate-valid-teams org-teams default-team-id)
|
||||
valid-default-team]} (calculate-valid-teams organization-teams default-team-id)
|
||||
|
||||
|
||||
|
||||
@@ -263,7 +297,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 org-teams)
|
||||
teams-by-id (d/index-by :id organization-teams)
|
||||
valid-teams-to-leave? (and
|
||||
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
|
||||
(every? (fn [{:keys [id reassign-to]}]
|
||||
@@ -274,10 +308,10 @@
|
||||
(contains? members reassign-to)))
|
||||
(contains? valid-teams-to-exit-ids id)))
|
||||
teams-to-leave))]
|
||||
;; the org owner cannot leave
|
||||
(when (= (:owner-id org-summary) profile-id)
|
||||
;; the organization owner cannot leave
|
||||
(when (= (:owner-id organization-summary) profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :org-owner-cannot-leave))
|
||||
:code :organization-owner-cannot-leave))
|
||||
|
||||
(when (or
|
||||
(not valid-teams-to-delete?)
|
||||
@@ -288,13 +322,14 @@
|
||||
|
||||
|
||||
|
||||
(defn leave-org
|
||||
(defn leave-organization
|
||||
[{: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?]}]
|
||||
(let [org-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?
|
||||
user-who-delete-member deleted-by-role]}]
|
||||
(let [organization-prefix (str "[" (d/sanitize-string name) "] ")
|
||||
{:keys [deletable-team-ids
|
||||
keep-default-team?
|
||||
detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
||||
detach-from-organization-team-ids]} (build-leave-organization-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
||||
|
||||
;; assert that the received teams are valid, checking the different constraints
|
||||
(when-not skip-validation
|
||||
@@ -311,95 +346,104 @@
|
||||
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
||||
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
||||
|
||||
;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||
;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||
(when default-team-id
|
||||
(if keep-default-team?
|
||||
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])
|
||||
(db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id])
|
||||
(teams/delete-team cfg {:profile-id profile-id
|
||||
:team-id default-team-id})))
|
||||
|
||||
;; Detach retained owned teams from the organization in Nitrate.
|
||||
;; 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}))
|
||||
;; 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}))
|
||||
|
||||
;; Api call to nitrate
|
||||
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
|
||||
(nitrate/call cfg :remove-profile-from-organization
|
||||
{:profile-id profile-id
|
||||
:organization-id id
|
||||
:user-who-delete-member user-who-delete-member
|
||||
:deleted-by-role deleted-by-role})
|
||||
|
||||
nil))
|
||||
|
||||
|
||||
(sv/defmethod ::leave-org
|
||||
(sv/defmethod ::leave-organization
|
||||
{::rpc/auth true
|
||||
::doc/added "2.15"
|
||||
::sm/params schema:leave-org
|
||||
::sm/params schema:leave-organization
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(leave-org cfg (assoc params :profile-id profile-id)))
|
||||
(leave-organization cfg (assoc params
|
||||
:profile-id profile-id
|
||||
:user-who-delete-member profile-id
|
||||
:deleted-by-role "organization-member")))
|
||||
|
||||
|
||||
(sv/defmethod ::get-leave-org-summary
|
||||
(sv/defmethod ::get-leave-organization-summary
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:get-leave-org-summary
|
||||
::sm/result schema:get-leave-org-summary-result
|
||||
::sm/params schema:get-leave-organization-summary
|
||||
::sm/result schema:get-leave-organization-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))
|
||||
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)))
|
||||
(assoc
|
||||
(get-leave-organization-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
|
||||
:member-added-at (:created-at membership)
|
||||
:organization-member-count-before (count organization-members))))
|
||||
|
||||
|
||||
(def ^:private schema:remove-team-from-org
|
||||
(def ^:private schema:remove-team-from-organization
|
||||
[:map
|
||||
[:team-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
[:organization-name ::sm/text]])
|
||||
|
||||
(sv/defmethod ::remove-team-from-org
|
||||
(sv/defmethod ::remove-team-from-organization
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:remove-team-from-org}
|
||||
::sm/params schema:remove-team-from-organization}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
||||
|
||||
(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 :nitrate)
|
||||
(let [org-perms (nitrate/call cfg :get-org-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? org-perms)
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? organization-perms)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions")
|
||||
(when-not (nitrate-perms/allowed? :move-team
|
||||
{:org-perms org-perms
|
||||
:profile-id profile-id})
|
||||
(when-not (cto/allowed? :move-team
|
||||
{:organization-perms organization-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-org {:team-id team-id :organization-id organization-id})
|
||||
(nitrate/call cfg :remove-team-from-organization {:team-id team-id :organization-id organization-id})
|
||||
|
||||
;; Notify connected users
|
||||
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
|
||||
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-organization")
|
||||
nil)
|
||||
|
||||
(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 = ?
|
||||
@@ -413,23 +457,22 @@
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(defn- get-external-invitation-info
|
||||
"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 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 {:allows-anybody bool :external-emails [...]}"
|
||||
[{:keys [::db/conn] :as cfg} team-id organization-id]
|
||||
(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})]
|
||||
(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})]
|
||||
(if allows-anybody
|
||||
{:allows-anybody true :external-emails []}
|
||||
(let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id])
|
||||
emails (map :email-to invitation-emails)]
|
||||
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
|
||||
(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])
|
||||
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
|
||||
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||
external-emails (->> profiles
|
||||
(remove #(contains? org-member-ids (:id %)))
|
||||
(remove #(contains? organization-member-ids (:id %)))
|
||||
(map :email)
|
||||
(vec))]
|
||||
{:allows-anybody false :external-emails external-emails}))))))
|
||||
@@ -444,133 +487,142 @@
|
||||
::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 :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)
|
||||
(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)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions"))
|
||||
|
||||
;; Team already belongs to an organization: check move-teams on source org.
|
||||
(when (some? source-org-id)
|
||||
(when (nil? source-org-perms)
|
||||
;; Team already belongs to an organization: check move-teams on the source organization.
|
||||
(when (some? source-organization-id)
|
||||
(when (nil? source-organization-perms)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions"))
|
||||
(when-not (nitrate-perms/allowed? :move-team
|
||||
{:org-perms source-org-perms
|
||||
:profile-id profile-id
|
||||
:target-org-same-owner? target-org-same-owner?})
|
||||
(when-not (cto/allowed? :move-team
|
||||
{:organization-perms source-organization-perms
|
||||
:profile-id profile-id
|
||||
:target-organization-same-owner? target-organization-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 (nitrate-perms/allowed? :create-team
|
||||
{:org-perms target-org-perms
|
||||
:profile-id profile-id})
|
||||
(when-not (cto/allowed? :create-team
|
||||
{:organization-perms target-organization-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"))
|
||||
|
||||
(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)))
|
||||
;; 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)))
|
||||
|
||||
;; Api call to nitrate
|
||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
||||
;; 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"))
|
||||
|
||||
;; Notify connected users
|
||||
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
||||
;; 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]))))
|
||||
|
||||
;; 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])))))
|
||||
;; 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)))
|
||||
|
||||
nil)
|
||||
|
||||
(def ^:private schema:check-org-members-params
|
||||
[:map {:title "CheckOrgMembersParams"}
|
||||
(def ^:private schema:check-organization-members-params
|
||||
[:map {:title "CheckOrganizationMembersParams"}
|
||||
[:organization-id ::sm/uuid]
|
||||
[:emails [:vector ::sm/email]]])
|
||||
|
||||
(sv/defmethod ::check-org-members
|
||||
(sv/defmethod ::check-organization-members
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:check-org-members-params
|
||||
::sm/params schema:check-organization-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 :nitrate)
|
||||
(or (when (contains? cf/flags :admin-console)
|
||||
(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)
|
||||
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))]
|
||||
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))]
|
||||
(into {}
|
||||
(map (fn [email]
|
||||
(let [pid (get email->id email)]
|
||||
[email (boolean (and pid (contains? org-member-ids pid)))])))
|
||||
[email (boolean (and pid (contains? organization-member-ids pid)))])))
|
||||
emails)))
|
||||
{}))
|
||||
|
||||
(def ^:private schema:all-org-members-in-team-params
|
||||
[:map {:title "CheckOrgMembersInTeamParams"}
|
||||
(def ^:private schema:all-organization-members-in-team-params
|
||||
[:map {:title "CheckOrganizationMembersInTeamParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::all-org-members-in-team
|
||||
(sv/defmethod ::all-organization-members-in-team
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:all-org-members-in-team-params
|
||||
::sm/params schema:all-organization-members-in-team-params
|
||||
::sm/result ::sm/boolean}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
||||
org-member-ids (into #{} org-members)
|
||||
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||
organization-member-ids (into #{} organization-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 %) org-member-ids)))
|
||||
(every? #(contains? team-member-ids %) organization-member-ids)))
|
||||
false))
|
||||
|
||||
(def ^:private schema:all-team-members-in-orgs-params
|
||||
[:map {:title "CheckTeamMembersInOrgsParams"}
|
||||
(def ^:private schema:all-team-members-in-organizations-params
|
||||
[:map {:title "CheckTeamMembersInOrganizationsParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:organization-ids [:vector ::sm/uuid]]])
|
||||
|
||||
(sv/defmethod ::all-team-members-in-orgs
|
||||
(sv/defmethod ::all-team-members-in-organizations
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:all-team-members-in-orgs-params
|
||||
::sm/params schema:all-team-members-in-organizations-params
|
||||
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
@@ -578,15 +630,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 orgs before fetching members.
|
||||
;; Validate requester membership in all organizations before fetching members.
|
||||
(run! #(assert-membership cfg profile-id %) organization-ids)
|
||||
|
||||
(into {}
|
||||
(map (fn [organization-id]
|
||||
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
||||
org-member-ids (into #{} org-members)]
|
||||
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||
organization-member-ids (into #{} organization-members)]
|
||||
[organization-id
|
||||
(every? #(contains? org-member-ids %) team-member-ids)])))
|
||||
(every? #(contains? organization-member-ids %) team-member-ids)])))
|
||||
organization-ids)))
|
||||
{}))
|
||||
|
||||
@@ -607,7 +659,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 :nitrate)
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
@@ -621,13 +673,17 @@
|
||||
|
||||
|
||||
(def ^:private schema:check-nitrate-sso
|
||||
[:map {:title "AuthSsoParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:url ::sm/uri]])
|
||||
[: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}]])
|
||||
|
||||
(sv/defmethod ::check-nitrate-sso
|
||||
"Check if a user needs to login into the organization SSO.
|
||||
Returns {:authorized true} when SSO is not active for the team.
|
||||
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 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."
|
||||
@@ -635,24 +691,22 @@
|
||||
::doc/added "2.19"
|
||||
::sm/params schema:check-nitrate-sso
|
||||
::nitrate/sso false}
|
||||
[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)]
|
||||
[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)
|
||||
{:authorized false
|
||||
:redirect-uri redirect-uri})
|
||||
{:authorized false
|
||||
:redirect-uri nil})))
|
||||
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
|
||||
:dest-url url
|
||||
:organization-id organization-id)}
|
||||
{:authorized false
|
||||
:redirect-uri nil}))))
|
||||
{:authorized true}))
|
||||
@@ -7,6 +7,7 @@
|
||||
(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]
|
||||
@@ -21,6 +22,7 @@
|
||||
[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]
|
||||
@@ -45,8 +47,17 @@
|
||||
[: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"}
|
||||
[:map {:title "ProfileProps" :closed true}
|
||||
[:plugins {:optional true} schema:plugin-registry]
|
||||
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
||||
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||
@@ -54,18 +65,26 @@
|
||||
[: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]])
|
||||
[:workspace-visited {:optional true} ::sm/boolean]
|
||||
[:custom-shortcuts {:optional true}
|
||||
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]
|
||||
[:nudge {:optional true} schema:nudge]])
|
||||
|
||||
(def schema:profile
|
||||
[: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]
|
||||
@@ -91,7 +110,7 @@
|
||||
|
||||
(defn- with-nitrate-licence
|
||||
[profile cfg]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||
profile))
|
||||
|
||||
@@ -146,6 +165,9 @@
|
||||
;; 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)
|
||||
@@ -191,6 +213,9 @@
|
||||
: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)
|
||||
@@ -263,7 +288,7 @@
|
||||
(def ^:private
|
||||
schema:update-profile-photo
|
||||
[:map {:title "update-profile-photo"}
|
||||
[:file media/schema:upload]])
|
||||
[:file media.v/schema:upload]])
|
||||
|
||||
(sv/defmethod ::update-profile-photo
|
||||
{:doc/added "1.1"
|
||||
@@ -271,8 +296,8 @@
|
||||
::sm/result :nil}
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media/validate-media-size! file)
|
||||
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media.v/validate-media-size! file)
|
||||
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
||||
|
||||
(defn update-profile-photo
|
||||
@@ -449,7 +474,7 @@
|
||||
(assoc props k v))
|
||||
props))
|
||||
(:props profile)
|
||||
props)]
|
||||
(apply dissoc props system-managed-props))]
|
||||
|
||||
(db/update! conn :profile
|
||||
{:props (db/tjson props)}
|
||||
@@ -491,14 +516,14 @@
|
||||
{:id profile-id})
|
||||
|
||||
;; Delete owned organizations on the fly (no grace period).
|
||||
;; Nitrate iterates the user's owned orgs and, per org, calls
|
||||
;; Nitrate iterates the user's owned organizations and, per organization, calls
|
||||
;; Penpot back through two paths: ::notify-user-organizations-deletion
|
||||
;; (during delete-owned-orgs) and ::notify-organization-deletion.
|
||||
;; Both preserve org teams unchanged and only prefix or delete
|
||||
;; (during delete-owned-organizations) and ::notify-organization-deletion.
|
||||
;; Both preserve organization 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 :nitrate)
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(nitrate/call cfg :cleanup-deleted-penpot-user
|
||||
{:profile-id profile-id}))
|
||||
|
||||
@@ -557,8 +582,8 @@
|
||||
{::doc/added "2.18"
|
||||
::sm/result schema:get-owned-organizations-summary-result}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) [])
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) [])
|
||||
[]))
|
||||
|
||||
;; --- HELPERS
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
(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]
|
||||
@@ -56,11 +58,16 @@
|
||||
: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-permissions))
|
||||
(perms/make-read-predicate-fn get-read-permissions))
|
||||
|
||||
(def check-edition-permissions!
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
@@ -159,10 +166,10 @@
|
||||
{::doc/added "1.18"
|
||||
::rpc/id-type :project
|
||||
::sm/params schema:get-project}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(let [project (db/get-by-id conn :project id)]
|
||||
(check-read-permissions! conn profile-id id)
|
||||
(check-read-permissions! cfg profile-id id)
|
||||
project)))
|
||||
|
||||
|
||||
@@ -179,7 +186,8 @@
|
||||
timestamp (::rpc/request-at params)]
|
||||
(teams/create-project-role conn profile-id (:id project) :owner)
|
||||
(db/insert! conn :team-project-profile-rel
|
||||
{:project-id (:id project)
|
||||
{:id (uuid/next)
|
||||
:project-id (:id project)
|
||||
:profile-id profile-id
|
||||
:created-at timestamp
|
||||
:modified-at timestamp
|
||||
@@ -230,8 +238,8 @@
|
||||
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
||||
::webhooks/event? true
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
|
||||
(check-read-permissions! conn profile-id id)
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
|
||||
(check-read-permissions! cfg profile-id id)
|
||||
(db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned])
|
||||
nil)
|
||||
|
||||
@@ -252,7 +260,8 @@
|
||||
::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)]
|
||||
(let [project (db/get-by-id conn :project id ::sql/for-update true)
|
||||
name (d/normalize-string name)]
|
||||
(db/update! conn :project
|
||||
{:name name}
|
||||
{:id id})
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||
[app.common.types.organization :as cto]
|
||||
[app.common.types.team :as types.team]
|
||||
[app.common.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 :as media]
|
||||
[app.media.validation :as media.v]
|
||||
[app.msgbus :as mbus]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
@@ -60,6 +60,11 @@
|
||||
: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))
|
||||
|
||||
@@ -67,7 +72,7 @@
|
||||
(perms/make-edition-predicate-fn get-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn get-permissions))
|
||||
(perms/make-read-predicate-fn get-read-permissions))
|
||||
|
||||
(def check-admin-permissions!
|
||||
(perms/make-check-fn has-admin-permissions?))
|
||||
@@ -180,7 +185,6 @@
|
||||
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))))
|
||||
|
||||
@@ -193,9 +197,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 :nitrate)
|
||||
(map #(nitrate/add-org-info-to-team cfg % params))
|
||||
(contains? cf/flags :nitrate)
|
||||
(contains? cf/flags :admin-console)
|
||||
(map #(nitrate/add-organization-info-to-team cfg % params))
|
||||
(contains? cf/flags :admin-console)
|
||||
(remove #(get-in % [:organization :expired-license])))))
|
||||
|
||||
(def ^:private sql:get-owned-teams
|
||||
@@ -238,19 +242,34 @@
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :team
|
||||
::sm/params schema:get-team}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}]
|
||||
(get-team pool :profile-id profile-id :team-id id :file-id file-id))
|
||||
[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)))))))
|
||||
|
||||
(defn get-team
|
||||
[conn & {:keys [profile-id team-id project-id file-id] :as params}]
|
||||
[cfg & {: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 conn profile-id)
|
||||
(profile/get-profile cfg profile-id)
|
||||
|
||||
sql
|
||||
(if (contains? cf/flags :subscriptions)
|
||||
@@ -262,14 +281,14 @@
|
||||
(some? team-id)
|
||||
(let [sql (str "WITH teams AS (" sql ") "
|
||||
"SELECT * FROM teams WHERE id=?")]
|
||||
(db/exec-one! conn [sql default-team-id profile-id team-id]))
|
||||
(db/exec-one! cfg [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! conn [sql default-team-id profile-id project-id]))
|
||||
(db/exec-one! cfg [sql default-team-id profile-id project-id]))
|
||||
|
||||
(some? file-id)
|
||||
(let [sql (str "WITH teams AS (" sql ") "
|
||||
@@ -277,17 +296,18 @@
|
||||
" 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! conn [sql default-team-id profile-id file-id]))
|
||||
(db/exec-one! cfg [sql default-team-id profile-id file-id]))
|
||||
|
||||
:else
|
||||
(throw (IllegalArgumentException. "invalid arguments")))]
|
||||
|
||||
(when-not result
|
||||
(ex/raise :type :not-found
|
||||
:code :team-does-not-exist))
|
||||
(-> result
|
||||
(decode-row)
|
||||
(process-permissions))))
|
||||
(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)))))
|
||||
|
||||
;; --- Query: Team Members
|
||||
|
||||
@@ -316,7 +336,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! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-members conn team-id)))
|
||||
|
||||
;; --- Query: Team Users
|
||||
@@ -342,10 +362,10 @@
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(if team-id
|
||||
(do
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-users conn team-id))
|
||||
(let [{team-id :id} (get-team-for-file conn file-id)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-users conn team-id)))))
|
||||
|
||||
;; This is a similar query to team members but can contain more data
|
||||
@@ -432,7 +452,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! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-stats conn team-id)))
|
||||
|
||||
(def sql:team-stats
|
||||
@@ -468,7 +488,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! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-invitations conn team-id)))
|
||||
|
||||
|
||||
@@ -515,18 +535,18 @@
|
||||
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
|
||||
;; 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)
|
||||
;; 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)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions")
|
||||
(when-not (nitrate-perms/allowed? :create-team
|
||||
{:org-perms org-perms
|
||||
:profile-id profile-id})
|
||||
(when-not (cto/allowed? :create-team
|
||||
{:organization-perms organization-perms
|
||||
:profile-id profile-id})
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "You are not allowed to create teams in this organization")))))
|
||||
@@ -543,7 +563,7 @@
|
||||
{::audit/props {:id (:id team)}})))
|
||||
|
||||
|
||||
(defn create-default-org-team
|
||||
(defn create-default-organization-team
|
||||
[cfg profile-id organization-id]
|
||||
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
@@ -559,37 +579,37 @@
|
||||
team (create-team cfg params)]
|
||||
(select-keys team [:id])))
|
||||
|
||||
(defn initialize-user-in-nitrate-org
|
||||
(defn initialize-user-in-organization
|
||||
"If needed, create a default team for the user on the organization,
|
||||
and notify Nitrate that an user has been added to an org."
|
||||
and initialize the user in the organization."
|
||||
([cfg profile-id organization-id]
|
||||
(initialize-user-in-nitrate-org cfg profile-id organization-id nil))
|
||||
(initialize-user-in-organization cfg profile-id organization-id nil))
|
||||
([cfg profile-id organization-id email]
|
||||
(assert (db/connection-map? cfg)
|
||||
"expected cfg with valid connection")
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as tx-cfg}]
|
||||
|
||||
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(let [membership (nitrate/call cfg :get-organization-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 org yet
|
||||
(not (:is-member membership))) ;; the user is not a member of the organization yet
|
||||
|
||||
|
||||
(let [organization-id organization-id
|
||||
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
|
||||
default-team (create-default-organization-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
|
||||
default-team-id (:id default-team)
|
||||
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)))]
|
||||
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)))]
|
||||
(when (not (:is-member result))
|
||||
(ex/raise :type :internal
|
||||
:code :failed-add-profile-org-nitrate
|
||||
:code :failed-add-profile-organization-nitrate
|
||||
:context {:profile-id profile-id
|
||||
:organization-id organization-id
|
||||
:default-team-id default-team-id}))
|
||||
@@ -601,14 +621,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 :nitrate)
|
||||
(let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})]
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})]
|
||||
;; Only when the team belong to an organization and the user is not a member
|
||||
(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 org yet
|
||||
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
|
||||
(db/insert! conn :team-profile-rel params options)))
|
||||
(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)))
|
||||
|
||||
(defn create-team
|
||||
"This is a complete team creation process, it creates the team
|
||||
@@ -623,7 +643,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 :nitrate) (:organization-id params))
|
||||
(when (and (contains? cf/flags :admin-console) (:organization-id params))
|
||||
(nitrate/set-team-organization cfg team params))
|
||||
(assoc team :default-project-id (:id project))))
|
||||
|
||||
@@ -632,6 +652,7 @@
|
||||
(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
|
||||
@@ -668,6 +689,7 @@
|
||||
[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
|
||||
@@ -679,7 +701,8 @@
|
||||
(defn create-project-role
|
||||
[conn profile-id project-id role]
|
||||
(let [params {:project-id project-id
|
||||
:profile-id profile-id}]
|
||||
:profile-id profile-id
|
||||
:id (uuid/next)}]
|
||||
(->> (perms/assign-role-flags params role)
|
||||
(db/insert! conn :project-profile-rel))))
|
||||
|
||||
@@ -697,9 +720,10 @@
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
||||
(check-edition-permissions! conn profile-id id)
|
||||
(db/update! conn :team
|
||||
{:name name}
|
||||
{:id id})
|
||||
(let [name (d/normalize-string name)]
|
||||
(db/update! conn :team
|
||||
{:name name}
|
||||
{:id id}))
|
||||
nil)
|
||||
|
||||
|
||||
@@ -782,19 +806,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 :nitrate)
|
||||
(nitrate/add-org-info-to-team cfg team params)
|
||||
team (if (contains? cf/flags :admin-console)
|
||||
(nitrate/add-organization-info-to-team cfg team params)
|
||||
team)
|
||||
perms (get team :permissions)
|
||||
org (:organization team)
|
||||
in-org? (and (contains? cf/flags :nitrate) org)
|
||||
organization (:organization team)
|
||||
in-organization? (and (contains? cf/flags :admin-console) organization)
|
||||
can-delete?
|
||||
(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})
|
||||
(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})
|
||||
(boolean (:is-owner perms)))]
|
||||
|
||||
(when-not can-delete?
|
||||
@@ -802,8 +826,8 @@
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
;; Protect the user's personal default team from deletion.
|
||||
;; Org-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
||||
(when (and (:is-default team) (not in-org?))
|
||||
;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
||||
(when (and (:is-default team) (not in-organization?))
|
||||
(ex/raise :type :validation
|
||||
:code :non-deletable-team
|
||||
:hint "impossible to delete default team"))
|
||||
@@ -815,7 +839,7 @@
|
||||
{::db/return-keys true})]
|
||||
|
||||
;; Api call to nitrate
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
|
||||
|
||||
(wrk/submit! {::db/conn conn
|
||||
@@ -930,12 +954,23 @@
|
||||
|
||||
(db/delete! conn :team-profile-rel {:profile-id member-id
|
||||
:team-id team-id})
|
||||
(mbus/pub! msgbus
|
||||
:topic member-id
|
||||
:message {:type :team-membership-change
|
||||
:change :removed
|
||||
:team-id team-id
|
||||
:team-name (:name team)})
|
||||
|
||||
;; 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)}))
|
||||
|
||||
nil))
|
||||
|
||||
@@ -947,7 +982,7 @@
|
||||
(def ^:private schema:update-team-photo
|
||||
[:map {:title "update-team-photo"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:file media/schema:upload]])
|
||||
[:file media.v/schema:upload]])
|
||||
|
||||
(sv/defmethod ::update-team-photo
|
||||
{::doc/added "1.17"
|
||||
@@ -955,8 +990,8 @@
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
|
||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media/validate-media-size! file)
|
||||
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media.v/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.nitrate-permissions :as nitrate-perms]
|
||||
[app.common.types.organization :as cto]
|
||||
[app.common.types.team :as types.team]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
@@ -44,12 +44,31 @@
|
||||
update set role = ?, valid_until = ?, updated_at = now()
|
||||
returning *")
|
||||
|
||||
(def sql:upsert-org-invitation
|
||||
(def sql:upsert-organization-invitation
|
||||
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
||||
values (?, null, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, email_to) where team_id is null do
|
||||
update set role = ?, valid_until = ?, updated_at = now()
|
||||
returning *")
|
||||
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))))
|
||||
|
||||
(defn- create-invitation-token
|
||||
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
||||
@@ -86,15 +105,17 @@
|
||||
[:role types.team/schema:role]
|
||||
[:email ::sm/email]])
|
||||
|
||||
(def ^:private schema:create-org-invitation
|
||||
[:map {:title "params:create-org-invitation"}
|
||||
(def ^:private schema:create-organization-invitation
|
||||
[:map {:title "params:create-organization-invitation"}
|
||||
[::rpc/profile-id ::sm/uuid]
|
||||
[:organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo ::sm/uri]]]
|
||||
[:logo ::sm/uri]
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]
|
||||
[:sso-active [:maybe ::sm/boolean]]]]
|
||||
[:profile
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
@@ -105,8 +126,8 @@
|
||||
(def ^:private check-create-invitation-params
|
||||
(sm/check-fn schema:create-invitation))
|
||||
|
||||
(def ^:private check-create-org-invitation-params
|
||||
(sm/check-fn schema:create-org-invitation))
|
||||
(def ^:private check-create-organization-invitation-params
|
||||
(sm/check-fn schema:create-organization-invitation))
|
||||
|
||||
(defn- allow-invitation-emails?
|
||||
[member]
|
||||
@@ -114,23 +135,24 @@
|
||||
(not= :none (:email-invites notifications))))
|
||||
|
||||
(defn- assert-email-can-be-invited
|
||||
"Asserts that member is an org member when the org
|
||||
"Asserts that member is an organization member when the organization
|
||||
restricts who can be added to teams."
|
||||
[member org-member-ids]
|
||||
(when (some? org-member-ids)
|
||||
(let [is-member? (and (some? member) (contains? org-member-ids (:id member)))]
|
||||
[member organization-member-ids]
|
||||
(when (some? organization-member-ids)
|
||||
(let [is-member? (and (some? member) (contains? organization-member-ids (:id member)))]
|
||||
(when-not is-member?
|
||||
(ex/raise :type :validation
|
||||
:code :email-not-org-member
|
||||
:code :email-not-organization-member
|
||||
:hint "The invited email is not a member of the organization")))))
|
||||
|
||||
(defn- create-invitation
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}]
|
||||
[{:keys [::db/conn] :as cfg}
|
||||
{:keys [team organization profile role email organization-member-ids all-organization-member-ids] :as params}]
|
||||
|
||||
(assert (db/connection-map? cfg)
|
||||
"expected cfg with valid connection")
|
||||
(if organization
|
||||
(assert (check-create-org-invitation-params params))
|
||||
(assert (check-create-organization-invitation-params params))
|
||||
(assert (check-create-invitation-params params)))
|
||||
|
||||
(let [email (profile/clean-email email)
|
||||
@@ -142,11 +164,11 @@
|
||||
:code :email-domain-is-not-allowed
|
||||
:hint "email domain is in the blacklist"))
|
||||
|
||||
;; 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)
|
||||
;; 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)
|
||||
(:organization team))
|
||||
(assert-email-can-be-invited member org-member-ids))
|
||||
(assert-email-can-be-invited member organization-member-ids))
|
||||
|
||||
|
||||
;; When we have email verification disabled and invitation user is
|
||||
@@ -162,9 +184,9 @@
|
||||
(get types.team/permissions-for-role role))]
|
||||
|
||||
(if organization
|
||||
;; 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 organization
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(teams/initialize-user-in-organization 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}))
|
||||
|
||||
@@ -182,57 +204,79 @@
|
||||
(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
|
||||
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)]
|
||||
(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)]
|
||||
|
||||
(when (contains? cf/flags :log-invitation-tokens)
|
||||
(l/info :hint "invitation token" :token itoken))
|
||||
|
||||
(let [props (-> (dissoc tprops :profile-id)
|
||||
(audit/clean-props))
|
||||
(let [props (audit/clean-props audit-props)
|
||||
evname (cond
|
||||
(and updated? organization) "update-org-invitation"
|
||||
(and updated? organization) "update-organization-invitation"
|
||||
updated? "update-team-invitation"
|
||||
organization "create-org-invitation"
|
||||
organization "create-organization-invitation"
|
||||
:else "create-team-invitation")
|
||||
event (-> (audit/event-from-rpc-params params)
|
||||
(assoc :name evname)
|
||||
(assoc :props props))]
|
||||
(audit/submit cfg event))
|
||||
|
||||
(when (allow-invitation-emails? member)
|
||||
(when (and (allow-invitation-emails? member)
|
||||
(not recent?))
|
||||
(if organization
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-org
|
||||
::eml/factory eml/invite-to-organization
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
@@ -246,13 +290,13 @@
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:organization (dm/get-in team [:organization :name])
|
||||
:organization (:organization team)
|
||||
:token itoken
|
||||
:extra-data ptoken})))
|
||||
|
||||
itoken)))))
|
||||
|
||||
(defn create-org-invitation
|
||||
(defn create-organization-invitation
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(let [profile (db/get-by-id cfg :profile profile-id)]
|
||||
(create-invitation cfg
|
||||
@@ -322,16 +366,21 @@
|
||||
- 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 org info once for all invitations when nitrate is active
|
||||
team (if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-org-info-to-team cfg team {})
|
||||
(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 {})
|
||||
team)
|
||||
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)
|
||||
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)
|
||||
|
||||
;; Normalize input to a consistent format: [{:email :role}]
|
||||
invitation-data (cond
|
||||
@@ -539,7 +588,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! pool profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(let [email (profile/clean-email email)
|
||||
invit (-> (db/get pool :team-invitation
|
||||
{:team-id team-id
|
||||
|
||||
@@ -85,13 +85,33 @@
|
||||
::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)]
|
||||
(let [profile (-> (profile/get-profile conn profile-id)
|
||||
(profile/strip-private-attrs)
|
||||
(update :props profile/filter-props)
|
||||
(with-nitrate-licence cfg))]
|
||||
(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]
|
||||
@@ -115,9 +135,9 @@
|
||||
(get types.team/permissions-for-role role))
|
||||
|
||||
accepted-team-id (if organization-id
|
||||
;; 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 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 team
|
||||
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
||||
team-id))]
|
||||
@@ -136,10 +156,11 @@
|
||||
{:id id-member}))
|
||||
|
||||
;; Delete the invitation
|
||||
(db/delete! conn :team-invitation
|
||||
(cond-> {:email-to member-email}
|
||||
team-id (assoc :team-id team-id)
|
||||
organization-id (assoc :org-id organization-id)))
|
||||
(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}))
|
||||
|
||||
;; Delete any request (only applicable for team invitations)
|
||||
(when team-id
|
||||
@@ -175,22 +196,17 @@
|
||||
:code :invalid-invitation-token
|
||||
:hint "invitation token contains unexpected data"))
|
||||
|
||||
(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)))
|
||||
(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}))
|
||||
profile (db/get* conn :profile
|
||||
{:id profile-id}
|
||||
{:columns [:id :email :default-team-id]})
|
||||
registration-disabled? (not (contains? cf/flags :registration))
|
||||
|
||||
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}))]
|
||||
organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
|
||||
|
||||
(if profile
|
||||
(do
|
||||
@@ -201,62 +217,130 @@
|
||||
: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 :invalid-token
|
||||
:hint "no invitation associated with the token"))
|
||||
:code (if organization-id :canceled-invitation :invalid-token)
|
||||
:hint (if organization-id
|
||||
"the invitation has been canceled"
|
||||
"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})
|
||||
|
||||
;; 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)}]
|
||||
team-id
|
||||
(nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id
|
||||
:team-id team-id})))
|
||||
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :name "accept-team-invitation")
|
||||
(assoc :props props)))
|
||||
organization-id-on-add
|
||||
(when (and (:organization-id membership)
|
||||
(not (:is-member membership)))
|
||||
(:organization-id membership))
|
||||
|
||||
;; 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-add-source
|
||||
(when organization-id-on-add
|
||||
(if organization-id
|
||||
"direct-organization-invitation"
|
||||
"team-invitation"))
|
||||
|
||||
(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)))))
|
||||
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))}))))))
|
||||
|
||||
(do
|
||||
;; If the user is not logged-in and the token is invalid we throw the error
|
||||
;; Taiga issue #14182
|
||||
;; 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.
|
||||
(when (nil? invitation)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token
|
||||
:hint "no invitation associated with the token"))
|
||||
:code (if organization-id :canceled-invitation :invalid-token)
|
||||
:hint (if organization-id
|
||||
"the invitation has been canceled"
|
||||
"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
|
||||
@@ -272,4 +356,3 @@
|
||||
[_ _ _]
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token))
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[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]))
|
||||
|
||||
@@ -125,8 +126,8 @@
|
||||
::sm/params schema:get-view-only-bundle}
|
||||
[system {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(db/run! system
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
(fn [system]
|
||||
(let [perms (perms/get-file-read-permissions system profile-id file-id share-id)
|
||||
params (-> params
|
||||
(assoc ::perms perms)
|
||||
(assoc :profile-id profile-id))]
|
||||
@@ -139,5 +140,3 @@
|
||||
:hint "object not found"))
|
||||
|
||||
(get-view-only-bundle system params)))))
|
||||
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn get-webhooks-permissions
|
||||
[conn profile-id team-id creator-id]
|
||||
[conn profile-id team-id]
|
||||
(let [permissions (t/get-permissions conn profile-id team-id)
|
||||
|
||||
can-edit (boolean (or (:can-edit permissions)
|
||||
(= profile-id creator-id)))]
|
||||
can-edit (boolean (:can-edit permissions))]
|
||||
(assoc permissions :can-edit can-edit)))
|
||||
|
||||
(def has-webhook-edit-permissions?
|
||||
@@ -120,7 +118,7 @@
|
||||
{::doc/added "1.17"
|
||||
::sm/params schema:create-webhook}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(check-webhook-edition-permissions! pool profile-id team-id profile-id)
|
||||
(t/check-edition-permissions! pool profile-id team-id)
|
||||
(validate-quotes! cfg params)
|
||||
(validate-webhook! cfg nil params)
|
||||
(insert-webhook! cfg params))
|
||||
@@ -137,7 +135,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) (:profile-id whook))
|
||||
(check-webhook-edition-permissions! pool profile-id (:team-id whook))
|
||||
(validate-webhook! cfg whook params)
|
||||
(update-webhook! cfg whook params)))
|
||||
|
||||
@@ -151,7 +149,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) (:profile-id whook))
|
||||
(check-webhook-edition-permissions! conn profile-id (:team-id whook))
|
||||
(db/delete! conn :webhook {:id id})
|
||||
nil))
|
||||
|
||||
@@ -172,6 +170,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! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(->> (db/exec! conn [sql:get-webhooks team-id])
|
||||
(mapv decode-row))))
|
||||
@@ -10,7 +10,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.media :refer [schema:upload]]
|
||||
[app.media.validation :refer [schema:upload]]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.storage :as sto]
|
||||
|
||||
@@ -8,27 +8,34 @@
|
||||
"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 :refer [schema:team-with-organization schema:organization-with-avatar]]
|
||||
[app.common.types.organization :as cto]
|
||||
[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 :as media]
|
||||
[app.media.validation :as media.v]
|
||||
[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]
|
||||
@@ -40,6 +47,7 @@
|
||||
{: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
|
||||
@@ -48,7 +56,8 @@
|
||||
"Authenticate the current user"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:profile}
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(let [profile (profile/get-profile cfg profile-id)]
|
||||
(-> (profile-to-map profile)
|
||||
@@ -99,30 +108,32 @@
|
||||
"List teams for which current user is owner"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-teams-result}
|
||||
::sm/result schema:get-teams-result
|
||||
::nitrate/sso false}
|
||||
[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-org-logo
|
||||
;; ---- API: upload-organization-logo
|
||||
|
||||
(def ^:private schema:upload-org-logo
|
||||
(def ^:private schema:upload-organization-logo
|
||||
[:map
|
||||
[:content media/schema:upload]
|
||||
[:content media.v/schema:upload]
|
||||
[:organization-id ::sm/uuid]
|
||||
[:previous-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(def ^:private schema:upload-org-logo-result
|
||||
(def ^:private schema:upload-organization-logo-result
|
||||
[:map [:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::upload-org-logo
|
||||
(sv/defmethod ::upload-organization-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-org-logo
|
||||
::sm/result schema:upload-org-logo-result}
|
||||
::sm/params schema:upload-organization-logo
|
||||
::sm/result schema:upload-organization-logo-result
|
||||
::nitrate/sso false}
|
||||
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||
(when previous-id
|
||||
(sto/touch-object! storage previous-id))
|
||||
@@ -141,7 +152,7 @@
|
||||
(sv/defmethod ::notify-team-change
|
||||
"Notify to Penpot a team change from nitrate"
|
||||
{::doc/added "2.14"
|
||||
::sm/params schema:team-with-organization
|
||||
::sm/params cto/schema:team-with-organization
|
||||
::rpc/auth false}
|
||||
[cfg team]
|
||||
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
||||
@@ -156,12 +167,12 @@
|
||||
[:role ::sm/text]])
|
||||
|
||||
(sv/defmethod ::notify-user-added-to-organization
|
||||
"Notify to Penpot that an user has joined an org from nitrate"
|
||||
"Notify to Penpot that an user has joined an organization from nitrate"
|
||||
{::doc/added "2.14"
|
||||
::sm/params schema:notify-user-added-to-organization
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [profile-id organization-id]}]
|
||||
(db/tx-run! cfg teams/create-default-org-team profile-id organization-id))
|
||||
(db/tx-run! cfg teams/create-default-organization-team profile-id organization-id))
|
||||
|
||||
|
||||
;; ---- API: get-managed-profiles
|
||||
@@ -190,7 +201,8 @@
|
||||
"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}
|
||||
::sm/result schema:managed-profile-result
|
||||
::nitrate/sso false}
|
||||
[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])))
|
||||
@@ -229,7 +241,8 @@
|
||||
"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}
|
||||
::sm/result schema:get-teams-summary-result
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [ids]}]
|
||||
(let [;; Handle one or multiple params
|
||||
ids (cond
|
||||
@@ -301,7 +314,7 @@ RETURNING id, deleted_at;")
|
||||
nil)
|
||||
|
||||
(defn manage-deleted-organization-teams
|
||||
"For a deleted organization, preserve org teams unchanged and only prefix or
|
||||
"For a deleted organization, preserve organization teams unchanged and only prefix or
|
||||
delete member Your Penpot teams depending on whether they still contain files."
|
||||
[cfg {:keys [organization-id organization-name teams]}]
|
||||
(let [all-team-ids (->> teams
|
||||
@@ -316,7 +329,7 @@ RETURNING id, deleted_at;")
|
||||
distinct
|
||||
(into []))]
|
||||
(when (seq all-team-ids)
|
||||
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||
(let [organization-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
@@ -330,11 +343,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 []))]
|
||||
|
||||
;; Org teams move to the fallback org unchanged. Only imported
|
||||
;; Your Penpot teams keep the org prefix when they still have files.
|
||||
;; Organization teams move to the fallback organization unchanged. Only imported
|
||||
;; Your Penpot teams keep the organization prefix when they still have files.
|
||||
(when (seq teams-to-prefix)
|
||||
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
||||
org-prefix
|
||||
organization-prefix
|
||||
(db/create-array conn "uuid" teams-to-prefix)]))
|
||||
|
||||
;; Empty imported Your Penpot teams disappear entirely.
|
||||
@@ -345,16 +358,16 @@ RETURNING id, deleted_at;")
|
||||
|
||||
|
||||
(sv/defmethod ::notify-organization-deletion
|
||||
"For a deleted organization, preserve org teams and only prefix or delete
|
||||
"For a deleted organization, preserve organization teams and only prefix or delete
|
||||
imported Your Penpot teams before notifying connected users."
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:notify-organization-deletion
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(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)
|
||||
(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)
|
||||
:teams teams})
|
||||
nil))
|
||||
|
||||
@@ -365,17 +378,18 @@ 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-org
|
||||
"For a given user, find all owned organizations and apply the deleted-organization
|
||||
transfer rules to their imported Your Penpot teams."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-user-organizations-deletion}
|
||||
::sm/params schema:notify-user-organizations-deletion
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [profile-id]}]
|
||||
(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)]
|
||||
(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)]
|
||||
(manage-deleted-organization-teams cfg {:organization-name organization-name
|
||||
:organization-id (:id org)
|
||||
:organization-id (:id organization)
|
||||
:teams teams}))))
|
||||
nil)
|
||||
|
||||
@@ -394,7 +408,8 @@ RETURNING id, deleted_at;")
|
||||
"Get profile by email"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map [:email ::sm/email]]
|
||||
::sm/result schema:profile}
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [email]}]
|
||||
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
|
||||
(when-not profile
|
||||
@@ -417,7 +432,8 @@ RETURNING id, deleted_at;")
|
||||
"Get profile by email"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map [:id ::sm/uuid]]
|
||||
::sm/result schema:profile}
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [id]}]
|
||||
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
|
||||
(when-not profile
|
||||
@@ -428,9 +444,9 @@ RETURNING id, deleted_at;")
|
||||
(profile-to-map profile)))
|
||||
|
||||
|
||||
;; ---- API: get-org-member-team-counts
|
||||
;; ---- API: get-organization-member-team-counts
|
||||
|
||||
(def ^:private sql:get-org-member-team-counts
|
||||
(def ^:private sql:get-organization-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
|
||||
@@ -439,19 +455,19 @@ RETURNING id, deleted_at;")
|
||||
AND t.is_default IS FALSE
|
||||
GROUP BY tpr.profile_id;")
|
||||
|
||||
(def ^:private schema:get-org-member-team-counts-params
|
||||
(def ^:private schema:get-organization-member-team-counts-params
|
||||
[:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
|
||||
|
||||
(def ^:private schema:get-org-member-team-counts-result
|
||||
(def ^:private schema:get-organization-member-team-counts-result
|
||||
[:vector [:map
|
||||
[:profile-id ::sm/uuid]
|
||||
[:team-count ::sm/int]]])
|
||||
|
||||
(sv/defmethod ::get-org-member-team-counts
|
||||
(sv/defmethod ::get-organization-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-org-member-team-counts-params
|
||||
::sm/result schema:get-org-member-team-counts-result
|
||||
::sm/params schema:get-organization-member-team-counts-params
|
||||
::sm/result schema:get-organization-member-team-counts-result
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [team-ids]}]
|
||||
(let [team-ids (cond
|
||||
@@ -467,46 +483,30 @@ 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-org-member-team-counts ids-array])))))))
|
||||
(db/exec! conn [sql:get-organization-member-team-counts ids-array])))))))
|
||||
|
||||
|
||||
;; API: invite-to-org
|
||||
;; API: invite-to-organization
|
||||
|
||||
(sv/defmethod ::invite-to-org
|
||||
(sv/defmethod ::invite-to-organization
|
||||
"Invite to organization"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map
|
||||
[:email ::sm/email]
|
||||
[:organization schema:organization-with-avatar]]}
|
||||
[:organization cto/schema:organization-with-avatar]]
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/tx-run! cfg ti/create-org-invitation params)
|
||||
(db/tx-run! cfg ti/create-organization-invitation params)
|
||||
nil)
|
||||
|
||||
|
||||
;; API: get-org-invitations
|
||||
;; API: get-organization-invitations
|
||||
|
||||
(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
|
||||
(def ^:private schema:get-organization-invitations-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(def ^:private schema:get-org-invitations-result
|
||||
(def ^:private schema:get-organization-invitations-result
|
||||
[:vector
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
@@ -517,84 +517,75 @@ LEFT JOIN profile AS p
|
||||
[:profile-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:photo-url {:optional true} ::sm/uri]]])
|
||||
|
||||
(sv/defmethod ::get-org-invitations
|
||||
(sv/defmethod ::get-organization-invitations
|
||||
"Get valid invitations for an organization, returning at most one invitation per email."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-org-invitations-params
|
||||
::sm/result schema:get-org-invitations-result}
|
||||
::sm/params schema:get-organization-invitations-params
|
||||
::sm/result schema:get-organization-invitations-result
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [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 []))]
|
||||
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(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)))))))))))
|
||||
(->> (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))))))))))
|
||||
|
||||
|
||||
;; API: delete-org-invitations
|
||||
;; API: delete-organization-invitations
|
||||
|
||||
(def ^:private sql:delete-org-invitations
|
||||
(def ^:private sql:delete-organization-invitations
|
||||
"DELETE FROM team_invitation AS ti
|
||||
WHERE ti.email_to = ?
|
||||
AND (ti.org_id = ? OR ti.team_id = ANY(?));")
|
||||
|
||||
(def ^:private schema:delete-org-invitations-params
|
||||
(def ^:private schema:delete-organization-invitations-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:email ::sm/email]])
|
||||
|
||||
(sv/defmethod ::delete-org-invitations
|
||||
"Delete all invitations for one email in an organization scope (org + org teams)."
|
||||
(sv/defmethod ::delete-organization-invitations
|
||||
"Delete all invitations for one email in an organization scope (organization + organization teams)."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:delete-org-invitations-params}
|
||||
::sm/params schema:delete-organization-invitations-params
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [organization-id email]}]
|
||||
(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 []))]
|
||||
(let [clean-email (profile/clean-email email)
|
||||
team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||
(db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array]))))
|
||||
(db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array]))))
|
||||
nil))
|
||||
|
||||
|
||||
;; API: delete-all-org-invitations
|
||||
;; API: delete-all-organization-invitations
|
||||
|
||||
(def ^:private sql:delete-all-org-invitations
|
||||
(def ^:private sql:delete-all-organization-invitations
|
||||
"DELETE FROM team_invitation AS ti
|
||||
WHERE ti.org_id = ?
|
||||
OR ti.team_id = ANY(?);")
|
||||
|
||||
(def ^:private schema:delete-all-org-invitations-params
|
||||
(def ^:private schema:delete-all-organization-invitations-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::delete-all-org-invitations
|
||||
"Delete every pending invitation associated with an organization (org-level + team-level).
|
||||
(sv/defmethod ::delete-all-organization-invitations
|
||||
"Delete every pending invitation associated with an organization (organization-level + team-level).
|
||||
Called from Nitrate when an organization is about to be deleted, so users that click
|
||||
their invitation token hit the existing invalid-token landing page."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:delete-all-org-invitations-params
|
||||
::sm/params schema:delete-all-organization-invitations-params
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
team-ids (->> (:teams org-summary)
|
||||
(map :id))]
|
||||
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||
(db/exec! conn [sql:delete-all-org-invitations organization-id ids-array]))))
|
||||
(db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array]))))
|
||||
nil))
|
||||
|
||||
|
||||
;; API: remove-from-org
|
||||
;; API: remove-from-organization
|
||||
|
||||
(def ^:private sql:get-reassign-to
|
||||
"SELECT tpr.profile_id
|
||||
@@ -619,7 +610,7 @@ LEFT JOIN profile AS p
|
||||
|
||||
(assoc team-to-transfer :reassign-to reassign-to)))
|
||||
|
||||
(sv/defmethod ::remove-from-org
|
||||
(sv/defmethod ::remove-from-organization
|
||||
"Remove an user from an organization"
|
||||
{::doc/added "2.17"
|
||||
::sm/params [:map
|
||||
@@ -627,9 +618,14 @@ LEFT JOIN profile AS p
|
||||
[:organization-id ::sm/uuid]
|
||||
[:organization-name ::sm/text]
|
||||
[:default-team-id ::sm/uuid]]
|
||||
::db/transaction true}
|
||||
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
|
||||
(let [{:keys [valid-teams-to-delete-ids
|
||||
::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
|
||||
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)
|
||||
@@ -637,25 +633,28 @@ LEFT JOIN profile AS p
|
||||
valid-teams-to-leave (into valid-teams-to-exit
|
||||
(map add-reassign-to valid-teams-to-transfer))]
|
||||
|
||||
(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")
|
||||
(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")
|
||||
nil))
|
||||
|
||||
;; API: get-remove-from-org-summary
|
||||
;; API: get-remove-from-organization-summary
|
||||
|
||||
(def ^:private schema:get-remove-from-org-summary-result
|
||||
(def ^:private schema:get-remove-from-organization-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-org-summary
|
||||
(sv/defmethod ::get-remove-from-organization-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"
|
||||
@@ -663,8 +662,9 @@ LEFT JOIN profile AS p
|
||||
[:profile-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
[:default-team-id ::sm/uuid]]
|
||||
::sm/result schema:get-remove-from-org-summary-result
|
||||
::db/transaction true}
|
||||
::sm/result schema:get-remove-from-organization-summary-result
|
||||
::db/transaction true
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [profile-id organization-id default-team-id]}]
|
||||
(let [{:keys [valid-teams-to-delete-ids
|
||||
valid-teams-to-transfer
|
||||
@@ -673,11 +673,11 @@ LEFT JOIN profile AS p
|
||||
(when-not valid-default-team
|
||||
(ex/raise :type :validation
|
||||
:code :not-valid-teams))
|
||||
(cnit/get-leave-org-summary cfg
|
||||
default-team-id
|
||||
valid-teams-to-delete-ids
|
||||
(count valid-teams-to-transfer)
|
||||
(count valid-teams-to-exit))))
|
||||
(cnit/get-leave-organization-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 @@ LEFT JOIN profile AS p
|
||||
[:user-name [:maybe ::sm/text]]
|
||||
[:renewal-date :string]
|
||||
[:estimated-amount :double]
|
||||
[:organizations [:vector schema:organization-with-avatar]]])
|
||||
[:organizations [:vector cto/schema:organization-with-avatar]]])
|
||||
|
||||
(sv/defmethod ::send-renewal-email
|
||||
"Send an Enterprise subscription renewal notice email to a user."
|
||||
@@ -711,8 +711,8 @@ LEFT JOIN profile AS p
|
||||
:organizations organizations}))))
|
||||
nil)
|
||||
|
||||
;; API: exists-org-team-invitations-for-non-members /
|
||||
;; delete-org-team-invitations-for-non-members
|
||||
;; API: exists-organization-team-invitations-for-non-members /
|
||||
;; delete-organization-team-invitations-for-non-members
|
||||
|
||||
(def ^:private sql:get-profile-emails-by-ids
|
||||
"SELECT email
|
||||
@@ -720,7 +720,7 @@ LEFT JOIN profile AS p
|
||||
WHERE id = ANY(?)
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(def ^:private sql:exists-non-member-org-team-invitations
|
||||
(def ^:private sql:exists-non-member-organization-team-invitations
|
||||
"SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM team_invitation
|
||||
@@ -728,22 +728,22 @@ LEFT JOIN profile AS p
|
||||
AND email_to <> ALL(?)
|
||||
) AS non_member")
|
||||
|
||||
(def ^:private sql:delete-non-member-org-team-invitations
|
||||
(def ^:private sql:delete-non-member-organization-team-invitations
|
||||
"DELETE FROM team_invitation
|
||||
WHERE team_id = ANY(?)
|
||||
AND email_to <> ALL(?)
|
||||
RETURNING email_to")
|
||||
|
||||
(def ^:private schema:org-team-invitations-for-non-members-params
|
||||
(def ^:private schema:organization-team-invitations-for-non-members-params
|
||||
[:map
|
||||
[:team-ids [:vector ::sm/uuid]]
|
||||
[:member-ids [:vector ::sm/uuid]]])
|
||||
|
||||
(def ^:private schema:exists-org-team-invitations-for-non-members-result
|
||||
(def ^:private schema:exists-organization-team-invitations-for-non-members-result
|
||||
[:map [:exists ::sm/boolean]])
|
||||
|
||||
(defn- org-team-invitations-for-non-members-arrays
|
||||
"Member emails and PG arrays used by exists/delete org team invitation endpoints."
|
||||
(defn- organization-team-invitations-for-non-members-arrays
|
||||
"Member emails and PG arrays used by exists/delete organization 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,34 +752,36 @@ LEFT JOIN profile AS p
|
||||
{:emails-array (db/create-array conn "text" (vec member-emails))
|
||||
:teams-array (db/create-array conn "uuid" team-ids)}))
|
||||
|
||||
(defn- non-member-org-team-invitations-exist?
|
||||
(defn- non-member-organization-team-invitations-exist?
|
||||
[conn params]
|
||||
(let [{:keys [emails-array teams-array]}
|
||||
(org-team-invitations-for-non-members-arrays conn params)]
|
||||
(-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations
|
||||
(organization-team-invitations-for-non-members-arrays conn params)]
|
||||
(-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations
|
||||
teams-array
|
||||
emails-array])
|
||||
:non-member)))
|
||||
|
||||
(sv/defmethod ::exists-org-team-invitations-for-non-members
|
||||
(sv/defmethod ::exists-organization-team-invitations-for-non-members
|
||||
"Return if there are any team invitations for emails that are not organization members."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:org-team-invitations-for-non-members-params
|
||||
::sm/result schema:exists-org-team-invitations-for-non-members-result}
|
||||
::sm/params schema:organization-team-invitations-for-non-members-params
|
||||
::sm/result schema:exists-organization-team-invitations-for-non-members-result
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
{:exists (boolean (non-member-org-team-invitations-exist? conn params))})))
|
||||
{:exists (boolean (non-member-organization-team-invitations-exist? conn params))})))
|
||||
|
||||
(sv/defmethod ::delete-org-team-invitations-for-non-members
|
||||
(sv/defmethod ::delete-organization-team-invitations-for-non-members
|
||||
"Delete team invitations for emails that are not organization members."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:org-team-invitations-for-non-members-params
|
||||
::db/transaction true}
|
||||
::sm/params schema:organization-team-invitations-for-non-members-params
|
||||
::db/transaction true
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [{:keys [emails-array teams-array]}
|
||||
(org-team-invitations-for-non-members-arrays conn params)]
|
||||
(db/exec! conn [sql:delete-non-member-org-team-invitations
|
||||
(organization-team-invitations-for-non-members-arrays conn params)]
|
||||
(db/exec! conn [sql:delete-non-member-organization-team-invitations
|
||||
teams-array
|
||||
emails-array])
|
||||
nil))))
|
||||
@@ -790,53 +792,235 @@ LEFT JOIN profile AS p
|
||||
[: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]]])
|
||||
[:props {:optional true} [:map-of :keyword :any]]
|
||||
[:context {:optional true} [:map-of :keyword :any]]])
|
||||
|
||||
(def ^:private schema:push-audit-events-params
|
||||
[:map {:title "PushAuditEventsParams"}
|
||||
[:events [:vector schema:nitrate-audit-event]]])
|
||||
|
||||
(defn- submit-nitrate-audit-event
|
||||
[cfg {:keys [name profile-id props]}]
|
||||
(let [now (ct/now)]
|
||||
(audit/submit* cfg {:type "action"
|
||||
:name name
|
||||
:profile-id profile-id
|
||||
:props (or props {})
|
||||
:context {}
|
||||
:tracked-at now
|
||||
:created-at now
|
||||
:source "nitrate"
|
||||
:ip-addr "0.0.0.0"})))
|
||||
|
||||
(sv/defmethod ::push-audit-events
|
||||
"Push audit events from Nitrate to Penpot audit log"
|
||||
"Push audit events from nitrate (strictly for nitrate backend
|
||||
events)"
|
||||
|
||||
{::doc/added "2.19"
|
||||
::audit/skip true
|
||||
::sm/params schema:push-audit-events-params
|
||||
::rpc/auth false}
|
||||
[{: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))
|
||||
[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)
|
||||
|
||||
nil))
|
||||
|
||||
|
||||
;; ---- API: notify-org-sso-change
|
||||
;; ---- API: get-teams-detail
|
||||
|
||||
(sv/defmethod ::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
|
||||
"Nitrate notifies that an organization sso values have changed"
|
||||
{::doc/added "2.19"
|
||||
::sm/params [:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:updated-props ::sm/boolean]]
|
||||
[:updated-props ::sm/boolean]
|
||||
[:announce-activation ::sm/boolean]]
|
||||
::rpc/auth false}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}]
|
||||
(when updated-props
|
||||
(rpc/invalidate-org-sso-cache-by-org! organization-id)
|
||||
(session/clear-org-sso-sessions! pool organization-id))
|
||||
(rpc/invalidate-organization-sso-cache-by-organization! organization-id)
|
||||
(session/clear-organization-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)))))
|
||||
@@ -0,0 +1,104 @@
|
||||
;; 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)))))))
|
||||
@@ -0,0 +1,60 @@
|
||||
;; 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]))
|
||||
@@ -16,17 +16,17 @@
|
||||
;;TODO There is a bug on dashboard with teams notifications.
|
||||
;;For now we send it to uuid/zero instead of team-id
|
||||
:topic uuid/zero
|
||||
:message {:type :team-org-change
|
||||
:message {:type :team-organization-change
|
||||
:team team
|
||||
:notification notification})))
|
||||
|
||||
|
||||
(defn notify-user-org-change
|
||||
(defn notify-user-organization-change
|
||||
[cfg profile-id organization-id organization-name notification]
|
||||
(let [msgbus (::mbus/msgbus cfg)]
|
||||
(mbus/pub! msgbus
|
||||
:topic profile-id
|
||||
:message {:type :user-org-change
|
||||
:message {:type :user-organization-change
|
||||
:topic profile-id
|
||||
:organization-id organization-id
|
||||
:organization-name organization-name
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
(ns app.rpc.permissions
|
||||
"A permission checking helper factories."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]))
|
||||
[app.common.schema :as sm]
|
||||
[app.db :as db]
|
||||
[app.nitrate :as nitrate]))
|
||||
|
||||
(def schema:permissions
|
||||
[:map {:title "Permissions"}
|
||||
@@ -89,3 +92,64 @@
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))))
|
||||
|
||||
;; --- Organization owner (Nitrate) viewer access
|
||||
;;
|
||||
;; Read-permission helpers that augment normal Penpot membership with
|
||||
;; Nitrate organization-owner viewer access. Edit/admin permission
|
||||
;; providers intentionally stay membership-only.
|
||||
|
||||
(def viewer-role-flags
|
||||
"Role flags granted to a non-member organization owner: read-only.
|
||||
Shared so callers that build full team/file rows shape permissions the
|
||||
same way the permission lookups do."
|
||||
{:is-owner false
|
||||
:is-admin false
|
||||
:can-edit false})
|
||||
|
||||
(def ^:private sql:get-team-id-for-project
|
||||
"SELECT team_id FROM project WHERE id = ?")
|
||||
|
||||
(def ^:private sql:get-team-id-for-file
|
||||
"SELECT p.team_id
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE f.id = ?")
|
||||
|
||||
(defn get-team-id-for-project
|
||||
[cfg project-id]
|
||||
(some-> (db/exec-one! cfg [sql:get-team-id-for-project project-id])
|
||||
(:team-id)))
|
||||
|
||||
(defn get-team-id-for-file
|
||||
[cfg file-id]
|
||||
(some-> (db/exec-one! cfg [sql:get-team-id-for-file file-id])
|
||||
(:team-id)))
|
||||
|
||||
(defn resolve-team-id
|
||||
[cfg {:keys [team-id project-id file-id]}]
|
||||
(cond
|
||||
(some? team-id) team-id
|
||||
(some? project-id) (get-team-id-for-project cfg project-id)
|
||||
(some? file-id) (get-team-id-for-file cfg file-id)))
|
||||
|
||||
(defn get-organization-owner-permissions
|
||||
"When `profile-id` is a non-member owner of the organization that owns
|
||||
the team/project/file referenced by `params`, returns read-only viewer
|
||||
permissions; otherwise nil."
|
||||
[cfg profile-id & {:as params}]
|
||||
(when-let [team-id (resolve-team-id cfg params)]
|
||||
(when (nitrate/organization-owner-of-team? cfg profile-id team-id)
|
||||
(assoc viewer-role-flags
|
||||
:can-read true
|
||||
:type :membership
|
||||
:is-logged (some? profile-id)))))
|
||||
|
||||
(defn get-file-read-permissions
|
||||
([cfg profile-id file-id]
|
||||
(or (bfc/get-file-permissions cfg profile-id file-id)
|
||||
(get-organization-owner-permissions cfg profile-id :file-id file-id)))
|
||||
|
||||
([cfg profile-id file-id share-id]
|
||||
(or (bfc/get-file-permissions cfg profile-id file-id share-id)
|
||||
(get-organization-owner-permissions cfg profile-id :file-id file-id))))
|
||||
@@ -115,8 +115,9 @@
|
||||
(assoc keys id key)))))
|
||||
{}
|
||||
[:exporter
|
||||
:nitrate
|
||||
:nexus])))
|
||||
:admin-console
|
||||
:nexus
|
||||
:media-processor])))
|
||||
|
||||
(sm/register! ::props [:map-of :keyword ::sm/any])
|
||||
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])
|
||||
|
||||
Loaded 100 of 815 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user