mirror of
https://github.com/penpot/penpot.git
synced 2026-08-23 21:28:38 +00:00
Compare commits
210 Commits
2.18.0-RC1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64a52d6b04 | ||
|
|
dd6b521bc7 | ||
|
|
47d599fe34 | ||
|
|
8aefa2ddfd | ||
|
|
0cacf9bd99 | ||
|
|
7c85837290 | ||
|
|
dd4a163217 | ||
|
|
9fa07e7468 | ||
|
|
6d4a6f6a9a | ||
|
|
77971740e6 | ||
|
|
2318866f8d | ||
|
|
4cb9f951d2 | ||
|
|
5dab689a6e | ||
|
|
689d506788 | ||
|
|
ca72213cbb | ||
|
|
f29a94058a | ||
|
|
9f6878d118 | ||
|
|
2dcf1a8a0a | ||
|
|
209aea8365 | ||
|
|
c200a4d777 | ||
|
|
ed588d4500 | ||
|
|
a91c796b0e | ||
|
|
c378ec9218 | ||
|
|
4da6499197 | ||
|
|
aa3bc1ae98 | ||
|
|
60d87a6342 | ||
|
|
fda6d56139 | ||
|
|
1886697458 | ||
|
|
5080a90f76 | ||
|
|
4d90fe9126 | ||
|
|
54aaebee1e | ||
|
|
8da13b5fa1 | ||
|
|
ddc98bdd47 | ||
|
|
d826c7ac13 | ||
|
|
ddd32670b3 | ||
|
|
4339d8d244 | ||
|
|
b6c4cb48d7 | ||
|
|
df664fe96b | ||
|
|
7061ecae0a | ||
|
|
4ac14cfd08 | ||
|
|
1671cc4fcc | ||
|
|
e72c1869eb | ||
|
|
3be07ccced | ||
|
|
73c0668877 | ||
|
|
367e4d534c | ||
|
|
aa5545c258 | ||
|
|
3f09f161ae | ||
|
|
d3bee4ba9d | ||
|
|
162a381aed | ||
|
|
296dd748bd | ||
|
|
5b4a5776cb | ||
|
|
904570f970 | ||
|
|
d745dc4a3c | ||
|
|
b4bc3dfe6a | ||
|
|
c72bb331ef | ||
|
|
7f2dc66e86 | ||
|
|
9311737f66 | ||
|
|
7ac61e0597 | ||
|
|
8acb92b782 | ||
|
|
fb9f92ae6a | ||
|
|
c797656d17 | ||
|
|
f96d850049 | ||
|
|
ed04d509ed | ||
|
|
57c9c3f6a4 | ||
|
|
29dbf9ab12 | ||
|
|
509f5395cb | ||
|
|
4ecd8ffb89 | ||
|
|
0797d7235a | ||
|
|
5efd9cc3c5 | ||
|
|
e219ce20eb | ||
|
|
9e97477a98 | ||
|
|
c688cba8d8 | ||
|
|
68e1db984d | ||
|
|
3033da4409 | ||
|
|
aecfee0f02 | ||
|
|
59ef07633a | ||
|
|
ba235f46c9 | ||
|
|
e56c801820 | ||
|
|
a3bc4b0e3a | ||
|
|
6269fa7a3f | ||
|
|
350dc14632 | ||
|
|
136052c15e | ||
|
|
c7f036bed0 | ||
|
|
3db7548c19 | ||
|
|
cb57fd9dfa | ||
|
|
9528400c6e | ||
|
|
f7fc869e52 | ||
|
|
be83656d55 | ||
|
|
1c14c854ae | ||
|
|
af1537d071 | ||
|
|
ef26231b8f | ||
|
|
3b9e0782e4 | ||
|
|
93f02ea0b4 | ||
|
|
6d49fb2be0 | ||
|
|
201b51e8c5 | ||
|
|
fee416d275 | ||
|
|
986ee60cad | ||
|
|
e5c80edbf3 | ||
|
|
732162e720 | ||
|
|
be9df28b00 | ||
|
|
868340dfba | ||
|
|
9f17aa6216 | ||
|
|
290b14167a | ||
|
|
985d219810 | ||
|
|
044d7ac15f | ||
|
|
53985dc630 | ||
|
|
02c31e7348 | ||
|
|
4a1d6e6d57 | ||
|
|
69ef7e86cd | ||
|
|
d7daefafe2 | ||
|
|
c4d1a1bc94 | ||
|
|
0de47302a6 | ||
|
|
1e6d438257 | ||
|
|
d4294bbf1e | ||
|
|
83efa28b12 | ||
|
|
16e52b0494 | ||
|
|
0fd2a9d26f | ||
|
|
5d2cb22966 | ||
|
|
900a7ef498 | ||
|
|
86c563f11f | ||
|
|
d63d6370c0 | ||
|
|
b9c92496f1 | ||
|
|
fcd33340b3 | ||
|
|
e01b36b841 | ||
|
|
b5bec4f983 | ||
|
|
2f04fcddbf | ||
|
|
e2d429d283 | ||
|
|
30bc2a4bc3 | ||
|
|
bf9825fcfe | ||
|
|
a131e40a6d | ||
|
|
5571c53502 | ||
|
|
6951876c13 | ||
|
|
399b00b86d | ||
|
|
43b12bc4b9 | ||
|
|
e1c51442cd | ||
|
|
bc9319eac5 | ||
|
|
5359ff04cf | ||
|
|
88697794ce | ||
|
|
9875db2f82 | ||
|
|
1548748aed | ||
|
|
0702363b5c | ||
|
|
688c69b478 | ||
|
|
38b990ef90 | ||
|
|
a76401596e | ||
|
|
de8d8ca401 | ||
|
|
314a2a245f | ||
|
|
614d619173 | ||
|
|
229d24e8f2 | ||
|
|
fdf1684565 | ||
|
|
2392015c63 | ||
|
|
11fc090bc4 | ||
|
|
81e44afbe3 | ||
|
|
10a2c19f92 | ||
|
|
495e9f059e | ||
|
|
4b413299c2 | ||
|
|
31c9ab4701 | ||
|
|
8b64b0f84f | ||
|
|
a60b648c6c | ||
|
|
649f4bebef | ||
|
|
b6656ee8dd | ||
|
|
86aaf642b6 | ||
|
|
c4dd04353f | ||
|
|
0ac711aa68 | ||
|
|
bf62e59f73 | ||
|
|
5906312dff | ||
|
|
25066c2f46 | ||
|
|
3d176d5390 | ||
|
|
0481408531 | ||
|
|
689d3a1be2 | ||
|
|
fb07273897 | ||
|
|
9242556da6 | ||
|
|
4f7bb94bb1 | ||
|
|
5b26913cd3 | ||
|
|
36e76da26c | ||
|
|
49276886f3 | ||
|
|
35bdcde183 | ||
|
|
a2968defbe | ||
|
|
6df045b194 | ||
|
|
1b26b69b25 | ||
|
|
6628f0a134 | ||
|
|
6f2bfb617c | ||
|
|
636bc22cc4 | ||
|
|
aeedb96260 | ||
|
|
3e59754a25 | ||
|
|
c16b7919f9 | ||
|
|
34702fd46b | ||
|
|
83a3d099f6 | ||
|
|
8e713df5f0 | ||
|
|
3fba272848 | ||
|
|
648c8e2152 | ||
|
|
7ae57a035f | ||
|
|
23ea2bbad6 | ||
|
|
14a6ea5c52 | ||
|
|
ca29f734c7 | ||
|
|
b507a6b667 | ||
|
|
3865e29b65 | ||
|
|
43e05c38bf | ||
|
|
0811b1cda6 | ||
|
|
edbe9f8215 | ||
|
|
6e843faba3 | ||
|
|
319a2185c9 | ||
|
|
1136e5eda5 | ||
|
|
49119e0339 | ||
|
|
c6c8a38544 | ||
|
|
0fed63eeb3 | ||
|
|
79da4d274d | ||
|
|
2f535c3f3f | ||
|
|
c320cecf15 | ||
|
|
141cf7f79f | ||
|
|
767f90282c |
@ -88,6 +88,9 @@
|
|||||||
:dynamic-var-not-earmuffed
|
:dynamic-var-not-earmuffed
|
||||||
{:level :off}
|
{:level :off}
|
||||||
|
|
||||||
|
:type-mismatch
|
||||||
|
{:level :off}
|
||||||
|
|
||||||
:used-underscored-binding
|
:used-underscored-binding
|
||||||
{:level :warning}
|
{:level :warning}
|
||||||
|
|
||||||
|
|||||||
41
.github/scripts/playwright-summary.jq
vendored
Normal file
41
.github/scripts/playwright-summary.jq
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
def specs: [.. | objects | select(has("tests") and has("file"))];
|
||||||
|
def dur: [.tests[].results[]?.duration // 0] | add;
|
||||||
|
|
||||||
|
specs as $s
|
||||||
|
| ($s | map(select(any(.tests[]; .status == "unexpected")))) as $failed
|
||||||
|
| ($s | map(select(any(.tests[]; .status == "flaky")))) as $flaky
|
||||||
|
| ($s | map(select(any(.tests[]; .status == "skipped")))) as $skipped
|
||||||
|
| ($s | length) as $total
|
||||||
|
| ($s | map(dur) | add // 0 | . / 1000 | floor) as $cpu
|
||||||
|
| (if ($failed | length) > 0 then "❌"
|
||||||
|
elif ($flaky | length) > 0 then "⚠️"
|
||||||
|
else "✅" end) as $icon
|
||||||
|
|
||||||
|
| "## \($icon) Integration tests\n\n"
|
||||||
|
+ "| Total | Passed | Flaky | Failed | Skipped | Test time |\n"
|
||||||
|
+ "|---|---|---|---|---|---|\n"
|
||||||
|
+ "| \($total) | \($total - ($failed|length) - ($flaky|length) - ($skipped|length)) "
|
||||||
|
+ "| \($flaky|length) | \($failed|length) | \($skipped|length) | \($cpu / 60 | floor)m |\n"
|
||||||
|
|
||||||
|
+ (if ($failed | length) > 0 then
|
||||||
|
"\n### Failed\n\n"
|
||||||
|
+ ($failed | map("- `\(.file):\(.line)` — \(.title)") | join("\n")) + "\n"
|
||||||
|
else "" end)
|
||||||
|
|
||||||
|
+ (if ($flaky | length) > 0 then
|
||||||
|
"\n### Flaky (passed on retry)\n\n"
|
||||||
|
+ ($flaky
|
||||||
|
| map({ t: "`\(.file):\(.line)` — \(.title)",
|
||||||
|
r: ([.tests[].results[]? | select(.status == "failed")] | length) })
|
||||||
|
| sort_by(-.r)
|
||||||
|
| map("- \(.t) _(\(.r) \(if .r == 1 then "retry" else "retries" end))_")
|
||||||
|
| join("\n")) + "\n"
|
||||||
|
else "" end)
|
||||||
|
|
||||||
|
+ (if $total > 0 then
|
||||||
|
"\n<details><summary>Slowest specs</summary>\n\n"
|
||||||
|
+ ($s | map({ t: "`\(.file)` — \(.title)", d: (dur / 1000 | floor) })
|
||||||
|
| sort_by(-.d) | .[0:5]
|
||||||
|
| map("- \(.t) — \(.d)s") | join("\n"))
|
||||||
|
+ "\n\n</details>\n"
|
||||||
|
else "" end)
|
||||||
6
.github/workflows/build-bundle.yml
vendored
6
.github/workflows/build-bundle.yml
vendored
@ -25,7 +25,7 @@ jobs:
|
|||||||
# ── 1. Decide whether there is anything to build ───────────────────────
|
# ── 1. Decide whether there is anything to build ───────────────────────
|
||||||
check:
|
check:
|
||||||
name: Check current bundle
|
name: Check current bundle
|
||||||
runs-on: penpot-runner-01
|
runs-on: penpot-standar-runner
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
@ -75,7 +75,7 @@ jobs:
|
|||||||
# ── 2. Build and upload, only when needed ──────────────────────────────
|
# ── 2. Build and upload, only when needed ──────────────────────────────
|
||||||
build:
|
build:
|
||||||
name: Build and Upload Penpot Bundle
|
name: Build and Upload Penpot Bundle
|
||||||
runs-on: penpot-runner-01
|
runs-on: penpot-standar-runner
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
needs: check
|
needs: check
|
||||||
if: needs.check.outputs.exists == 'false'
|
if: needs.check.outputs.exists == 'false'
|
||||||
@ -116,7 +116,7 @@ jobs:
|
|||||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||||
notify:
|
notify:
|
||||||
name: Notify failure
|
name: Notify failure
|
||||||
runs-on: penpot-runner-01
|
runs-on: penpot-standar-runner
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
needs: [check, build]
|
needs: [check, build]
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
2
.github/workflows/build-develop.yml
vendored
2
.github/workflows/build-develop.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
gh_ref: "develop"
|
gh_ref: "develop"
|
||||||
|
|
||||||
build-admin-console-docker:
|
build-docker-admin-console:
|
||||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/build-docker-devenv.yml
vendored
2
.github/workflows/build-docker-devenv.yml
vendored
@ -6,7 +6,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
name: Build and push DevEnv Docker image
|
name: Build and push DevEnv Docker image
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set common environment variables
|
- name: Set common environment variables
|
||||||
|
|||||||
8
.github/workflows/build-docker.yml
vendored
8
.github/workflows/build-docker.yml
vendored
@ -32,7 +32,7 @@ jobs:
|
|||||||
# ── 1. Resolve the build key and check the whole set at once ───────────
|
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||||
prepare:
|
prepare:
|
||||||
name: Prepare
|
name: Prepare
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||||
@ -107,7 +107,7 @@ jobs:
|
|||||||
# ── 2. One build per image, in parallel, only when needed ──────────────
|
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.image }}
|
name: Build ${{ matrix.image }}
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.exists == 'false'
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
@ -220,7 +220,7 @@ jobs:
|
|||||||
# the S3 marker guarantees the branch tags were already moved.
|
# the S3 marker guarantees the branch tags were already moved.
|
||||||
promote:
|
promote:
|
||||||
name: Promote image set
|
name: Promote image set
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
needs: [prepare, build]
|
needs: [prepare, build]
|
||||||
|
|
||||||
@ -267,7 +267,7 @@ jobs:
|
|||||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||||
notify:
|
notify:
|
||||||
name: Notify failure
|
name: Notify failure
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
needs: [prepare, build, promote]
|
needs: [prepare, build, promote]
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
2
.github/workflows/build-staging.yml
vendored
2
.github/workflows/build-staging.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
gh_ref: "staging"
|
gh_ref: "staging"
|
||||||
|
|
||||||
build-admin-console-docker:
|
build-docker-admin-console:
|
||||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
|
|||||||
14
.github/workflows/build-tag.yml
vendored
14
.github/workflows/build-tag.yml
vendored
@ -20,10 +20,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
gh_ref: ${{ github.ref_name }}
|
gh_ref: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
build-docker-admin-console:
|
||||||
|
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: ${{ github.ref_name }}
|
||||||
|
|
||||||
notify:
|
notify:
|
||||||
name: Notifications
|
name: Notifications
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
|
- build-docker-admin-console
|
||||||
steps:
|
steps:
|
||||||
- name: Notify Mattermost
|
- name: Notify Mattermost
|
||||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||||
@ -37,7 +45,9 @@ jobs:
|
|||||||
|
|
||||||
publish-final-tag:
|
publish-final-tag:
|
||||||
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
|
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
|
- build-docker-admin-console
|
||||||
uses: ./.github/workflows/release.yml
|
uses: ./.github/workflows/release.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
|
|||||||
20
.github/workflows/build-tmp-tokens.yml
vendored
Normal file
20
.github/workflows/build-tmp-tokens.yml
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
name: _TMP TOKENS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '46 5-20 * * 1-5'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-bundle:
|
||||||
|
uses: ./.github/workflows/build-bundle.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: "hiru-tokens-in-libs"
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
needs: build-bundle
|
||||||
|
uses: ./.github/workflows/build-docker.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
gh_ref: "hiru-tokens-in-libs"
|
||||||
2
.github/workflows/plugins-deploy-package.yml
vendored
2
.github/workflows/plugins-deploy-package.yml
vendored
@ -34,7 +34,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: penpot-runner-01
|
runs-on: penpot-standar-runner
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|||||||
2
.github/workflows/tests-backend.yml
vendored
2
.github/workflows/tests-backend.yml
vendored
@ -32,7 +32,7 @@ jobs:
|
|||||||
test-backend:
|
test-backend:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Backend Tests"
|
name: "Backend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
2
.github/workflows/tests-common.yml
vendored
2
.github/workflows/tests-common.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
|||||||
test-common:
|
test-common:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Common Tests"
|
name: "Common Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
2
.github/workflows/tests-composable-suite.yml
vendored
2
.github/workflows/tests-composable-suite.yml
vendored
@ -38,7 +38,7 @@ jobs:
|
|||||||
composable-test-suite:
|
composable-test-suite:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Run composable test suite (mocked backend)"
|
name: "Run composable test suite (mocked backend)"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
58
.github/workflows/tests-exporter.yml
vendored
Normal file
58
.github/workflows/tests-exporter.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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
|
||||||
2
.github/workflows/tests-frontend.yml
vendored
2
.github/workflows/tests-frontend.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
|||||||
test-frontend:
|
test-frontend:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Frontend Tests"
|
name: "Frontend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
192
.github/workflows/tests-integration.yml
vendored
192
.github/workflows/tests-integration.yml
vendored
@ -5,11 +5,37 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
gh_ref:
|
||||||
|
description: 'Name of the branch or ref'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: 'develop'
|
||||||
|
|
||||||
|
shards:
|
||||||
|
description: 'Shard layout (JSON array)'
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: '[1, 2, 3, 4]'
|
||||||
|
options:
|
||||||
|
- '[1, 2, 3, 4]'
|
||||||
|
- '[1, 2, 3, 4, 5, 6]'
|
||||||
|
- '[1, 2]'
|
||||||
|
- '[1]'
|
||||||
|
|
||||||
|
workers:
|
||||||
|
description: 'Playwright workers per shard'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: '2'
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
- 'common/**'
|
- 'common/**'
|
||||||
- 'render-wasm/**'
|
- 'render-wasm/**'
|
||||||
|
- '.github/workflows/tests-integration.yml'
|
||||||
|
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
@ -25,25 +51,41 @@ on:
|
|||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
- 'common/**'
|
- 'common/**'
|
||||||
- 'render-wasm/**'
|
- 'render-wasm/**'
|
||||||
|
- '.github/workflows/tests-integration.yml'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-integration:
|
build-integration:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Build Integration Bundle"
|
name: "Build Integration Bundle"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
|
timeout-minutes: 30
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
- /var/cache/github-runner/m2:/root/.m2
|
- /var/cache/github-runner/m2:/root/.m2
|
||||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
bundle_key: ${{ steps.vars.outputs.bundle_key }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# An empty `ref` makes checkout fall back to its default (the PR merge
|
||||||
|
# ref on pull_request, the pushed ref on push).
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.gh_ref }}
|
||||||
|
|
||||||
|
# The cache key must come from the SHA actually checked out: on a manual
|
||||||
|
# run `github.sha` points at the dispatching ref, not at `gh_ref`.
|
||||||
|
- name: Extract cache key
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build Bundle
|
- name: Build Bundle
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
@ -53,41 +95,151 @@ jobs:
|
|||||||
- name: Store Bundle Cache
|
- name: Store Bundle Cache
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
key: ${{ steps.vars.outputs.bundle_key }}
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Integration Tests"
|
name: "Integration Tests (${{ matrix.shard }})"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
|
timeout-minutes: ${{ github.base_ref == 'staging' && 60 || 25 }}
|
||||||
|
|
||||||
|
needs: build-integration
|
||||||
|
|
||||||
|
# TEMPORARY (release stabilization): PRs targeting `staging` run on a
|
||||||
|
# single serial shard, so new flakes cannot block the release work.
|
||||||
|
# Remove the `github.base_ref` branch below to restore full parallelism.
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: ${{ fromJSON(inputs.shards || (github.base_ref == 'staging' && '[1]' || '[1, 2, 3, 4]')) }}
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: penpotapp/devenv:latest
|
||||||
|
volumes:
|
||||||
|
- /var/cache/github-runner/m2:/root/.m2
|
||||||
|
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||||
|
- /var/cache/github-runner/ms-playwright:/ms-playwright
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.gh_ref }}
|
||||||
|
|
||||||
|
- name: Restore Cache
|
||||||
|
uses: actions/cache/restore@v5
|
||||||
|
with:
|
||||||
|
key: ${{ needs.build-integration.outputs.bundle_key }}
|
||||||
|
path: frontend/resources/public
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: |
|
||||||
|
corepack enable;
|
||||||
|
corepack install;
|
||||||
|
pnpm install --frozen-lockfile;
|
||||||
|
|
||||||
|
# No-op once the shared volume is warm; keeps the first run working.
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
|
# `strategy.job-total` is the matrix size, so the shard denominator
|
||||||
|
# follows the `shards` input without being hardcoded.
|
||||||
|
- name: Run Tests
|
||||||
|
working-directory: ./frontend
|
||||||
|
env:
|
||||||
|
WORKERS: ${{ inputs.workers }}
|
||||||
|
BASE_REF: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
# TEMPORARY (release stabilization): see the note on the matrix above.
|
||||||
|
if [ -z "$WORKERS" ]; then
|
||||||
|
if [ "$BASE_REF" = "staging" ]; then WORKERS=1; else WORKERS=2; fi
|
||||||
|
fi
|
||||||
|
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
|
||||||
|
pnpm exec playwright test --project default \
|
||||||
|
--workers="$WORKERS" \
|
||||||
|
--shard=${{ matrix.shard }}/${{ strategy.job-total }} \
|
||||||
|
--reporter=blob
|
||||||
|
|
||||||
|
- name: Upload blob report
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: integration-blob-report-${{ matrix.shard }}
|
||||||
|
path: frontend/blob-report/
|
||||||
|
overwrite: true
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
- name: Upload test result
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: integration-tests-result-${{ matrix.shard }}
|
||||||
|
path: frontend/test-results/
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
merge-reports:
|
||||||
|
if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
|
||||||
|
name: "Merge Integration Reports"
|
||||||
|
runs-on: penpot-extended-runner
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
needs: test-integration
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
- /var/cache/github-runner/m2:/root/.m2
|
- /var/cache/github-runner/m2:/root/.m2
|
||||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||||
|
|
||||||
needs: build-integration
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Restore Cache
|
|
||||||
uses: actions/cache/restore@v5
|
|
||||||
with:
|
with:
|
||||||
key: "integration-bundle-${{ github.sha }}"
|
ref: ${{ inputs.gh_ref }}
|
||||||
path: frontend/resources/public
|
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Install deps
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/test-e2e
|
corepack enable;
|
||||||
|
corepack install;
|
||||||
|
pnpm install --frozen-lockfile;
|
||||||
|
|
||||||
- name: Upload test result
|
- name: Download blob reports
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
if: always()
|
|
||||||
with:
|
with:
|
||||||
name: integration-tests-result
|
path: frontend/all-blob-reports
|
||||||
path: frontend/test-results/
|
pattern: integration-blob-report-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Merge into HTML report
|
||||||
|
working-directory: ./frontend
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
|
||||||
|
run: |
|
||||||
|
pnpm exec playwright merge-reports \
|
||||||
|
--reporter=html,json,list ./all-blob-reports
|
||||||
|
|
||||||
|
- name: Test summary
|
||||||
|
if: always()
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: |
|
||||||
|
if [ ! -f report.json ]; then
|
||||||
|
echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Upload HTML report
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: integration-html-report
|
||||||
|
path: frontend/playwright-report/
|
||||||
overwrite: true
|
overwrite: true
|
||||||
retention-days: 3
|
retention-days: 7
|
||||||
|
|||||||
2
.github/workflows/tests-library.yml
vendored
2
.github/workflows/tests-library.yml
vendored
@ -32,7 +32,7 @@ jobs:
|
|||||||
test-library:
|
test-library:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Library Tests"
|
name: "Library Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
4
.github/workflows/tests-mcp.yml
vendored
4
.github/workflows/tests-mcp.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: "MCP CI"
|
name: "CI: MCP"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@ -28,7 +28,7 @@ jobs:
|
|||||||
test-mcp:
|
test-mcp:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Test MCP"
|
name: "Test MCP"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
4
.github/workflows/tests-plugin-api-suite.yml
vendored
4
.github/workflows/tests-plugin-api-suite.yml
vendored
@ -53,7 +53,7 @@ jobs:
|
|||||||
api-test-suite-mocked:
|
api-test-suite-mocked:
|
||||||
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
|
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
|
||||||
name: "Run Plugin API Test Suite (mocked)"
|
name: "Run Plugin API Test Suite (mocked)"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
@ -95,7 +95,7 @@ jobs:
|
|||||||
# api-test-suite-live:
|
# api-test-suite-live:
|
||||||
# if: ${{ github.event_name == 'workflow_dispatch' }}
|
# if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
# name: Run Plugin API Test Suite (live)
|
# name: Run Plugin API Test Suite (live)
|
||||||
# runs-on: penpot-runner-02
|
# runs-on: penpot-extended-runner
|
||||||
# container:
|
# container:
|
||||||
# image: penpotapp/devenv:latest
|
# image: penpotapp/devenv:latest
|
||||||
#
|
#
|
||||||
|
|||||||
2
.github/workflows/tests-plugins.yml
vendored
2
.github/workflows/tests-plugins.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
|||||||
test-plugins:
|
test-plugins:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: Plugins Runtime Linter & Tests
|
name: Plugins Runtime Linter & Tests
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
2
.github/workflows/tests-wasm.yml
vendored
2
.github/workflows/tests-wasm.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
|||||||
test-render-wasm:
|
test-render-wasm:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Render WASM Tests"
|
name: "Render WASM Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-extended-runner
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -58,6 +58,8 @@ opencode.json
|
|||||||
/docker/images/bundle*
|
/docker/images/bundle*
|
||||||
/exporter/target
|
/exporter/target
|
||||||
/exporter/.shadow-cljs
|
/exporter/.shadow-cljs
|
||||||
|
/exporter/resources/wasm/
|
||||||
|
/exporter/src/app/wasm/shared.js
|
||||||
/frontend/.storybook/preview-body.html
|
/frontend/.storybook/preview-body.html
|
||||||
/frontend/.storybook/preview-head.html
|
/frontend/.storybook/preview-head.html
|
||||||
/frontend/playwright-report/
|
/frontend/playwright-report/
|
||||||
@ -88,6 +90,7 @@ opencode.json
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/render-wasm/target/
|
/render-wasm/target/
|
||||||
|
/media-processor/dist/
|
||||||
/**/node_modules
|
/**/node_modules
|
||||||
/**/.yarn/*
|
/**/.yarn/*
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
name: commiter
|
|
||||||
description: Git commit assistant
|
|
||||||
mode: subagent
|
|
||||||
permission:
|
|
||||||
read: allow
|
|
||||||
glob: allow
|
|
||||||
grep: allow
|
|
||||||
edit: deny
|
|
||||||
webfetch: deny
|
|
||||||
websearch: deny
|
|
||||||
task: deny
|
|
||||||
skill: deny
|
|
||||||
lsp: deny
|
|
||||||
todowrite: deny
|
|
||||||
question: deny
|
|
||||||
external_directory: deny
|
|
||||||
bash: allow
|
|
||||||
---
|
|
||||||
|
|
||||||
## Role
|
|
||||||
|
|
||||||
You are the Penpot commit assistant. You produce git commits that follow the
|
|
||||||
repository's commit conventions. You do not implement features, review code, or
|
|
||||||
push branches — you commit.
|
|
||||||
|
|
||||||
## Required Reading
|
|
||||||
|
|
||||||
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
|
|
||||||
end-to-end**. It is the authoritative source for the commit message format, the
|
|
||||||
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
|
|
||||||
exactly — do not improvise the format and do not restate its contents here.
|
|
||||||
|
|
||||||
## Pre-commit Workflow
|
|
||||||
|
|
||||||
1. **Stage the files** specified by the calling agent. Do not ask for
|
|
||||||
confirmation — the calling agent knows exactly which files to commit.
|
|
||||||
2. Run `git diff --staged` to review the content. If you see secrets (API
|
|
||||||
keys, tokens, passwords, private keys, `.env` values), debug prints, or
|
|
||||||
anything that does not match the stated intent, STOP and tell the user
|
|
||||||
before committing.
|
|
||||||
3. Following the format in the doc, draft the message and run
|
|
||||||
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
|
|
||||||
unusual characters). The `AI-assisted-by` trailer value is provided by the
|
|
||||||
calling agent — use it verbatim.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Do not push. Pushing is a separate workflow handled by the user.
|
|
||||||
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
|
|
||||||
- Do not pass `--author`. Author identity comes from the local git config.
|
|
||||||
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
|
|
||||||
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
|
|
||||||
- Do not add untracked files that were not created in this session.
|
|
||||||
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
|
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
|
||||||
agent: build
|
agent: build
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -32,12 +32,11 @@ Implement the prepared plan from the session context. Work methodically, keeping
|
|||||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||||
step 4.
|
step 4.
|
||||||
|
|
||||||
## 4. Commit with the commiter subagent
|
## 4. Commit with the create-commit skill
|
||||||
|
|
||||||
After the implementation is complete, delegate the commit to the **`commiter`**
|
After the implementation is complete, load the **`create-commit`** skill and
|
||||||
subagent. Give it a brief summary of what was implemented and why, the issue
|
follow its workflow to commit the changes. Provide a brief summary of what was
|
||||||
reference (`issue-NNNN`), and the model name you are running as so it sets the
|
implemented and why, the issue reference (`issue-NNNN`), and the model name you
|
||||||
`AI-assisted-by` trailer correctly. The subagent owns the commit format and
|
are running as so the `AI-assisted-by` trailer is set correctly.
|
||||||
conventions.
|
|
||||||
|
|
||||||
Do not push. Pushing is handled separately by the user.
|
Do not push. Pushing is handled separately by the user.
|
||||||
|
|||||||
40
.opencode/commands/resolve-git-conflicts.md
Normal file
40
.opencode/commands/resolve-git-conflicts.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fix Git Conflicts
|
||||||
|
|
||||||
|
Resolve conflicts in the local repository. The user handles finishing the
|
||||||
|
rebase themselves — you must **never** run `git rebase --continue`,
|
||||||
|
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||||
|
|
||||||
|
## Phase 1 — Understand the problem (read-only)
|
||||||
|
|
||||||
|
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||||
|
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||||
|
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||||
|
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||||
|
- Identify what each side changed and why, and how they should be combined.
|
||||||
|
|
||||||
|
## Phase 2 — Present the resolution plan
|
||||||
|
|
||||||
|
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||||
|
- What each side changed and why.
|
||||||
|
- Your proposed resolution and the reasoning behind it.
|
||||||
|
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||||
|
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||||
|
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||||
|
|
||||||
|
## Phase 3 — Execute
|
||||||
|
|
||||||
|
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||||
|
|
||||||
|
## Phase 4 — Stage and verify
|
||||||
|
|
||||||
|
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||||
|
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||||
|
|
||||||
|
## Phase 5 — Report
|
||||||
|
|
||||||
|
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||||
@ -1,16 +1,24 @@
|
|||||||
Act as a senior software engineer and perform a thorough code review.
|
Act as a senior software engineer and perform a thorough review.
|
||||||
|
|
||||||
## Instructions
|
## Instructions
|
||||||
|
|
||||||
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
|
1. **Determine what is being reviewed** from the provided context:
|
||||||
2. Determine the diff or code to review from the provided context.
|
- **If it is a plan** (implementation plan, design document, task breakdown) → load the **`plan-review`** skill.
|
||||||
3. Read the diff and the surrounding context for each changed file.
|
- **If it is code** (diff, PR, code change) → load the **`code-review`** skill.
|
||||||
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.
|
|
||||||
|
|
||||||
Do not modify any code and do not create a commit — this command only reviews.
|
2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing.
|
||||||
|
|
||||||
|
3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
|
||||||
|
|
||||||
|
4. Follow the loaded skill's process and produce its output format.
|
||||||
|
|
||||||
|
## 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. Missing tests are an issue, not a suggestion. Report as a severity-tagged finding — never as a recommendation.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: code-review-and-quality
|
name: code-review
|
||||||
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
|
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
|
||||||
---
|
---
|
||||||
|
|
||||||
47
.opencode/skills/create-commit/SKILL.md
Normal file
47
.opencode/skills/create-commit/SKILL.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: create-commit
|
||||||
|
description: Stage, review, and commit files following Penpot commit conventions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: create-commit
|
||||||
|
|
||||||
|
Produce a git commit that follows Penpot's commit message conventions. This
|
||||||
|
skill owns the commit format, staging review, and safety checks — it does not
|
||||||
|
implement features or push.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- After code changes are complete and files need to be committed
|
||||||
|
- When delegated by a workflow step (e.g. implement-plan) to handle the commit
|
||||||
|
|
||||||
|
## Required Reading
|
||||||
|
|
||||||
|
Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
|
||||||
|
is the authoritative source for the commit message format, the emoji menu,
|
||||||
|
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Stage the files** specified by the calling context. Do not ask for
|
||||||
|
confirmation.
|
||||||
|
2. Run `git diff --staged` to review the content. If you see secrets (API keys,
|
||||||
|
tokens, passwords, private keys, `.env` values), debug prints, or anything
|
||||||
|
that does not match the stated intent, **STOP** and tell the user before
|
||||||
|
committing.
|
||||||
|
3. Draft the message following the format in the memory doc, wrapping the body
|
||||||
|
at 72 characters per line, and run:
|
||||||
|
```bash
|
||||||
|
git commit -m "<subject>" -m "<body>"
|
||||||
|
```
|
||||||
|
(or `git commit -F -` if the body has unusual characters).
|
||||||
|
4. The `AI-assisted-by` trailer value is provided by the calling context — use
|
||||||
|
it verbatim.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not push. Pushing is a separate workflow handled by the user.
|
||||||
|
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm`.
|
||||||
|
- Do not pass `--author`. Author identity comes from the local git config.
|
||||||
|
- Do not amend a commit you did not create in this session, unless explicitly asked.
|
||||||
|
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
|
||||||
|
- Do not add untracked files that were not created in this session.
|
||||||
315
.opencode/skills/plan-review/SKILL.md
Normal file
315
.opencode/skills/plan-review/SKILL.md
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
---
|
||||||
|
name: plan-review
|
||||||
|
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan Review
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality.
|
||||||
|
|
||||||
|
**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- After the planner skill produces a plan
|
||||||
|
- Before starting implementation on any non-trivial task
|
||||||
|
- When reviewing a plan written by another agent or a human
|
||||||
|
- When a plan feels too large, vague, or risky to start
|
||||||
|
|
||||||
|
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
|
||||||
|
|
||||||
|
## The Six-Axis Review
|
||||||
|
|
||||||
|
Every plan gets evaluated across these dimensions:
|
||||||
|
|
||||||
|
### 1. Completeness
|
||||||
|
|
||||||
|
Does the plan cover everything needed to implement successfully?
|
||||||
|
|
||||||
|
- Is the **context** clear? (What problem, why now, what's the goal?)
|
||||||
|
- Are **affected modules** identified with paths?
|
||||||
|
- Are **architecture decisions** documented with rationale?
|
||||||
|
- Is there a **testing strategy**?
|
||||||
|
- Are **verification commands** explicit (not "run the tests")?
|
||||||
|
- Are **open questions** listed (not buried in someone's head)?
|
||||||
|
- Is there a **parallelization** assessment for multi-task plans?
|
||||||
|
|
||||||
|
**Missing any of these is a gap, not a nit.**
|
||||||
|
|
||||||
|
### 2. Task Quality
|
||||||
|
|
||||||
|
Are the tasks well-defined and independently executable?
|
||||||
|
|
||||||
|
- Does every task have **acceptance criteria**? (Testable, not vague)
|
||||||
|
- Does every task have **verification steps**?
|
||||||
|
- Are tasks **sized appropriately**? (XS–M is ideal, L is acceptable, XL must be split)
|
||||||
|
- Are **dependencies** between tasks explicitly stated?
|
||||||
|
- Are **files likely touched** listed?
|
||||||
|
- Is each task a **single, self-contained change**? (Not "implement the whole feature")
|
||||||
|
- Could a skilled implementer pick up any task and execute it without asking clarifying questions?
|
||||||
|
|
||||||
|
### 3. Architecture & Sequencing
|
||||||
|
|
||||||
|
Is the plan structured so implementation flows correctly?
|
||||||
|
|
||||||
|
- Does implementation order follow the **dependency graph** (foundations first)?
|
||||||
|
- Are tasks **vertically sliced** (feature paths) rather than horizontally layered?
|
||||||
|
- Does each task leave the system in a **working state**?
|
||||||
|
- Are there **checkpoints** between major phases?
|
||||||
|
- Are **high-risk tasks early** (fail fast)?
|
||||||
|
- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans)
|
||||||
|
|
||||||
|
### 4. Risk Coverage
|
||||||
|
|
||||||
|
Are the hard parts acknowledged and mitigated?
|
||||||
|
|
||||||
|
- Are **edge cases** identified?
|
||||||
|
- Are **breaking changes** or **migration concerns** noted?
|
||||||
|
- Are **security implications** considered?
|
||||||
|
- Are **performance implications** considered?
|
||||||
|
- Are **external dependencies** or integration risks flagged?
|
||||||
|
- Is there a plan for **rollback** if something goes wrong?
|
||||||
|
- Are **data integrity** risks addressed (what happens if a migration fails mid-way)?
|
||||||
|
|
||||||
|
### 5. Actionability
|
||||||
|
|
||||||
|
Can an implementer actually execute this?
|
||||||
|
|
||||||
|
- Are **file paths** specific (not "update the relevant files")?
|
||||||
|
- Are **function/method names** mentioned where applicable?
|
||||||
|
- Are **verification commands** copy-pasteable (not "run the linter")?
|
||||||
|
- Are **test commands** project-specific (not generic)?
|
||||||
|
- Is the **code shape** described where the implementation isn't obvious?
|
||||||
|
- Are **conventions** referenced (naming, patterns, existing utilities to reuse)?
|
||||||
|
- Does the plan reference **existing code** the implementer should read first?
|
||||||
|
|
||||||
|
### 6. Proposed Code Quality *(when the plan includes implementation details)*
|
||||||
|
|
||||||
|
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
|
||||||
|
|
||||||
|
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
|
||||||
|
- **Readability:** Are proposed names descriptive and consistent with project conventions?
|
||||||
|
- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)?
|
||||||
|
- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design?
|
||||||
|
- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design?
|
||||||
|
|
||||||
|
**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis.
|
||||||
|
|
||||||
|
## Structural Remedies
|
||||||
|
|
||||||
|
When you flag a structural problem in a plan, propose the fix — not just the problem:
|
||||||
|
|
||||||
|
- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable.
|
||||||
|
- **Missing acceptance criteria:** Draft 2–3 specific, testable conditions for the task.
|
||||||
|
- **Wrong sequencing:** Identify the dependency and propose the correct order.
|
||||||
|
- **No checkpoints:** Suggest where checkpoints should go (typically after every 2–3 tasks).
|
||||||
|
- **Vague verification:** Replace "run tests" with the actual project command.
|
||||||
|
- **Horizontal slicing:** Restructure into vertical feature paths.
|
||||||
|
- **Missing risk section:** Draft the risks you can identify from the plan content.
|
||||||
|
|
||||||
|
Prefer the remedy that makes the plan immediately actionable over one that just flags the gap.
|
||||||
|
|
||||||
|
## Plan Sizing
|
||||||
|
|
||||||
|
Plans should be scoped to a single deliverable:
|
||||||
|
|
||||||
|
```
|
||||||
|
1–5 tasks → Good. A focused feature or bug fix.
|
||||||
|
6–10 tasks → Acceptable for a moderate feature.
|
||||||
|
11–15 tasks → Large. Consider splitting into phases.
|
||||||
|
15+ tasks → Too large. Split into multiple plans.
|
||||||
|
```
|
||||||
|
|
||||||
|
**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan.
|
||||||
|
|
||||||
|
## Categorize Findings
|
||||||
|
|
||||||
|
Label every comment with its severity so the author knows what's required vs optional:
|
||||||
|
|
||||||
|
| Prefix | Meaning | Author Action |
|
||||||
|
|--------|---------|---------------|
|
||||||
|
| *(no prefix)* | Required change | Must address before implementation starts |
|
||||||
|
| **Critical:** | Blocks implementation | Missing security consideration, data integrity risk, fundamentally wrong approach |
|
||||||
|
| **Nit:** | Minor, optional | Author may ignore — wording, formatting |
|
||||||
|
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
|
||||||
|
| **FYI** | Informational only | No action needed — context for future reference |
|
||||||
|
|
||||||
|
**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review.
|
||||||
|
|
||||||
|
## Review Process
|
||||||
|
|
||||||
|
### Step 1: Understand the Goal
|
||||||
|
|
||||||
|
Before evaluating structure, understand intent:
|
||||||
|
|
||||||
|
```
|
||||||
|
- What is this plan trying to accomplish?
|
||||||
|
- What problem does it solve?
|
||||||
|
- What does "done" look like?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Check Completeness First
|
||||||
|
|
||||||
|
Scan for missing sections before diving into content:
|
||||||
|
|
||||||
|
```
|
||||||
|
- Context present?
|
||||||
|
- Affected modules listed?
|
||||||
|
- Architecture decisions documented?
|
||||||
|
- Risks acknowledged?
|
||||||
|
- Testing strategy defined?
|
||||||
|
- Verification commands explicit?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Review Task Quality
|
||||||
|
|
||||||
|
Walk through each task:
|
||||||
|
|
||||||
|
```
|
||||||
|
For each task:
|
||||||
|
1. Can I tell exactly what to build?
|
||||||
|
2. Are acceptance criteria specific and testable?
|
||||||
|
3. Is the size reasonable (not XL)?
|
||||||
|
4. Are dependencies clear?
|
||||||
|
5. Would I know which files to touch?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Validate Sequencing
|
||||||
|
|
||||||
|
Check the dependency graph:
|
||||||
|
|
||||||
|
```
|
||||||
|
- Are foundations built first?
|
||||||
|
- Does each task leave the system working?
|
||||||
|
- Are checkpoints placed correctly?
|
||||||
|
- Are high-risk items early?
|
||||||
|
- Is it vertically sliced?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Assess Actionability
|
||||||
|
|
||||||
|
Put yourself in the implementer's shoes:
|
||||||
|
|
||||||
|
```
|
||||||
|
- Could I pick up task 1 and start coding without asking any questions?
|
||||||
|
- Are the verification commands copy-pasteable?
|
||||||
|
- Are file paths and function names specific?
|
||||||
|
- Is existing code referenced where I'd need to read it?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Verify the Verification Story
|
||||||
|
|
||||||
|
Check that the plan can actually confirm it worked:
|
||||||
|
|
||||||
|
```
|
||||||
|
- What tests should pass after implementation?
|
||||||
|
- What build/compile commands are relevant?
|
||||||
|
- What manual checks are needed?
|
||||||
|
- How do we know the feature works end-to-end?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Evaluate Proposed Code Quality *(if applicable)*
|
||||||
|
|
||||||
|
If the plan includes code snippets, types, or API designs:
|
||||||
|
|
||||||
|
```
|
||||||
|
- Load code-review skill for criteria
|
||||||
|
- Check proposed signatures for edge cases
|
||||||
|
- Verify naming follows project conventions
|
||||||
|
- Confirm abstractions follow existing patterns
|
||||||
|
- Scan for security vectors in proposed APIs
|
||||||
|
- Check for performance issues in proposed data structures
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Review: [Plan title]
|
||||||
|
|
||||||
|
### Completeness
|
||||||
|
- [ ] Context explains the problem and goal
|
||||||
|
- [ ] Affected modules are listed with paths
|
||||||
|
- [ ] Architecture decisions have rationale
|
||||||
|
- [ ] Testing strategy is defined
|
||||||
|
- [ ] Verification commands are explicit and project-specific
|
||||||
|
- [ ] Open questions are listed
|
||||||
|
|
||||||
|
### Task Quality
|
||||||
|
- [ ] Every task has acceptance criteria
|
||||||
|
- [ ] Every task has verification steps
|
||||||
|
- [ ] Tasks are sized XS–M (L acceptable, XL must be split)
|
||||||
|
- [ ] Task dependencies are stated
|
||||||
|
- [ ] Files likely touched are listed
|
||||||
|
|
||||||
|
### Architecture & Sequencing
|
||||||
|
- [ ] Order follows dependency graph (foundations first)
|
||||||
|
- [ ] Vertically sliced (not horizontal layers)
|
||||||
|
- [ ] Each task leaves system working
|
||||||
|
- [ ] Checkpoints exist between phases
|
||||||
|
- [ ] High-risk tasks are early
|
||||||
|
|
||||||
|
### Risk Coverage
|
||||||
|
- [ ] Edge cases identified
|
||||||
|
- [ ] Breaking changes / migrations noted
|
||||||
|
- [ ] Security implications considered
|
||||||
|
- [ ] Performance implications considered
|
||||||
|
- [ ] Rollback strategy exists (if applicable)
|
||||||
|
|
||||||
|
### Actionability
|
||||||
|
- [ ] File paths are specific
|
||||||
|
- [ ] Verification commands are copy-pasteable
|
||||||
|
- [ ] Existing code to read is referenced
|
||||||
|
- [ ] Conventions and patterns are noted
|
||||||
|
|
||||||
|
### Proposed Code Quality *(if plan includes implementation details)*
|
||||||
|
- [ ] Proposed types/signatures handle edge cases
|
||||||
|
- [ ] Proposed names follow project conventions
|
||||||
|
- [ ] Proposed abstractions follow existing patterns
|
||||||
|
- [ ] No security vectors in proposed APIs
|
||||||
|
- [ ] No performance issues in proposed structures
|
||||||
|
|
||||||
|
### Verdict
|
||||||
|
- [ ] **Approve** — Ready to implement
|
||||||
|
- [ ] **Request changes** — Gaps must be addressed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Rationalizations
|
||||||
|
|
||||||
|
| Rationalization | Reality |
|
||||||
|
|---|---|
|
||||||
|
| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. |
|
||||||
|
| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. |
|
||||||
|
| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. |
|
||||||
|
| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. |
|
||||||
|
| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. |
|
||||||
|
| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. |
|
||||||
|
| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. |
|
||||||
|
| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. |
|
||||||
|
|
||||||
|
## Red Flags
|
||||||
|
|
||||||
|
- No acceptance criteria on any task
|
||||||
|
- Tasks that say "implement the feature" without specifics
|
||||||
|
- No verification steps anywhere in the plan
|
||||||
|
- All tasks are XL-sized
|
||||||
|
- No checkpoints between phases
|
||||||
|
- Dependency order isn't considered (e.g., API handler before domain model)
|
||||||
|
- No testing strategy
|
||||||
|
- Verification commands are generic ("run tests") instead of project-specific
|
||||||
|
- Plan has 20+ tasks (scope too large for one plan)
|
||||||
|
- No risk section on a plan with migrations, breaking changes, or security implications
|
||||||
|
- Horizontal slicing (all domain, then all services, then all API)
|
||||||
|
- File paths are vague ("update the relevant files")
|
||||||
|
- Missing open questions section despite stated unknowns
|
||||||
|
- Proposed code ignores project conventions or existing patterns
|
||||||
|
- Proposed types use gratuitous `any`/`unknown`/optional without justification
|
||||||
|
- Proposed APIs don't validate input at boundaries
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- For producing plans, use the `planner` skill
|
||||||
|
- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
|
||||||
|
- For security-specific concerns, see `security-and-hardening`
|
||||||
|
- For testing strategy guidance, see `testing`
|
||||||
78
.opencode/skills/ste/SKILL.md
Normal file
78
.opencode/skills/ste/SKILL.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: ste
|
||||||
|
description: Write or rewrite text in ASD-STE100 Simplified Technical English. ONLY use this skill when the user explicitly invokes it by name — i.e. they type "/ste" or literally write "use the ste skill" / "apply ASD-STE100". Do NOT trigger it on paraphrased intent such as "simplify this", "make it clearer", "write technical documentation", or "shorter sentences please" — the user has deliberately scoped this skill to explicit invocation only. For those requests, respond normally without loading this skill unless they name it.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ASD-STE100 Simplified Technical English
|
||||||
|
|
||||||
|
Apply the ASD-STE100 standard to all prose you produce in this task. Do not announce that you use STE, do not name the standard, and do not explain the style unless the user asks. If the user later asks you to "write more naturally," ask one short question to confirm they want to leave STE before you drop it.
|
||||||
|
|
||||||
|
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
|
||||||
|
|
||||||
|
## Step 0 — Classify the text
|
||||||
|
|
||||||
|
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
|
||||||
|
|
||||||
|
## Core rules
|
||||||
|
|
||||||
|
### Sentences
|
||||||
|
- Procedural: maximum **20 words** per sentence.
|
||||||
|
- Descriptive: maximum **25 words** per sentence.
|
||||||
|
- Maximum **6 sentences** per paragraph. One topic per paragraph.
|
||||||
|
- One instruction per sentence. Two actions in one sentence only if they occur at the same time.
|
||||||
|
- Put a condition BEFORE its command: "If the pressure decreases, close the valve."
|
||||||
|
- Do not omit articles, subjects, or verbs to save words. "Ensure file exists" is wrong; "Make sure that the file exists" is correct. Keep the word "that" after verbs like "make sure."
|
||||||
|
- Numbers, units with numbers, abbreviations, quoted strings, code identifiers, and proper nouns each count as one word.
|
||||||
|
|
||||||
|
### Verbs
|
||||||
|
- Allowed forms only: infinitive, imperative, simple present, simple past, simple future, and past participle used as an adjective.
|
||||||
|
- Never use present perfect or continuous forms. "We have received" → "We received." "is being tested" → a simple form.
|
||||||
|
- Never use an -ing form as a verb. An -ing word is allowed only inside a technical name ("the mounting bracket," "logging").
|
||||||
|
- Active voice. Passive is allowed only in descriptive text when the agent is unknown or unimportant.
|
||||||
|
- Instructions use the imperative: "Open the panel," not "You must open the panel" or "The panel should be opened."
|
||||||
|
- Express actions as verbs, not nouns: "compress the file," not "perform compression of the file."
|
||||||
|
- Modals: use **can** (possibility), **will** (future), **must** (requirement). Do not use should, would, could, may, might. A hedge becomes a fact or a "can": "an explosion can occur."
|
||||||
|
- No phrasal verbs: "go down" → "decrease," "set up" → "install," "carry out" → "do."
|
||||||
|
|
||||||
|
### Words
|
||||||
|
- One word, one meaning, one part of speech, used consistently. Never rotate synonyms: pick one name for a thing and repeat it.
|
||||||
|
- Before drafting, replace unapproved vocabulary. Read `references/word-substitutions.md` and apply it; it is the working dictionary for this skill.
|
||||||
|
- Domain-specific nouns (part names, tool names, product names, UI labels) and domain verbs (drill, ream, boot, compile) are your **technical nouns/verbs** — keep them as-is, use each consistently, and do not verb a noun or noun a verb.
|
||||||
|
- Noun clusters: maximum **3 words** ("overhead panel light" is the limit). Longer clusters get decomposed with prepositions or hyphenated on first use: "main-gear-door retraction-winch handle."
|
||||||
|
- American English spelling.
|
||||||
|
- No Latin abbreviations: "e.g." → "for example," "i.e." → "that is," delete "etc."
|
||||||
|
|
||||||
|
### Punctuation
|
||||||
|
- No semicolons — write two sentences.
|
||||||
|
- Parentheses only for references, abbreviations, and item numbers.
|
||||||
|
- Hyphenate words that act as one unit; a hyphenated word counts as one word.
|
||||||
|
- No contractions.
|
||||||
|
|
||||||
|
### Warnings, cautions, notes
|
||||||
|
- **WARNING** = risk of injury or death. **CAUTION** = risk of damage. **NOTE** = information only, never an instruction.
|
||||||
|
- Start a warning or caution with the command or condition, then give the risk:
|
||||||
|
"WARNING: Do not touch the terminal. The terminal has a dangerous voltage."
|
||||||
|
- Notes obey the 25-word descriptive limit.
|
||||||
|
|
||||||
|
## Step 2 — Self-check pass
|
||||||
|
|
||||||
|
After drafting, scan your text once for each of these and fix every hit before you respond:
|
||||||
|
|
||||||
|
1. Any sentence over the 20/25-word limit for its type
|
||||||
|
2. Contractions, semicolons
|
||||||
|
3. "should," "would," "could," "may," "might"
|
||||||
|
4. "has been," "have been," "had been," "is being," "was being"
|
||||||
|
5. -ing words used as verbs
|
||||||
|
6. Missing articles (a/an/the/this) before nouns
|
||||||
|
7. Synonym rotation (the same object under two names)
|
||||||
|
8. Any word in the unapproved column of `references/word-substitutions.md`
|
||||||
|
9. Warnings that state the risk before the command
|
||||||
|
|
||||||
|
## Reference files
|
||||||
|
|
||||||
|
- `references/word-substitutions.md` — unapproved → approved word mappings and one-meaning rulings. Read it before drafting; it is short.
|
||||||
|
- `references/examples.md` — worked before/after rewrites (procedural, descriptive, warnings, common mistakes). Read it when rewriting existing text or when unsure how a rule applies.
|
||||||
|
|
||||||
|
## What NOT to touch
|
||||||
|
|
||||||
|
Code blocks, command strings, file paths, error messages, quoted UI text, and proper nouns stay exactly as written. STE applies to the prose around them.
|
||||||
67
.opencode/skills/ste/references/examples.md
Normal file
67
.opencode/skills/ste/references/examples.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# Worked before/after examples
|
||||||
|
|
||||||
|
## Verb forms
|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|---|---|
|
||||||
|
| We have received the technical reports from HQ. | We received the technical reports from HQ. |
|
||||||
|
| This device has been being used at Boeing since 2005. | Boeing started to use this device in 2005. |
|
||||||
|
| The test is continued by the operator. | Continue the test. |
|
||||||
|
| The screws should be replaced. | Replace the screws. |
|
||||||
|
| The system is currently running diagnostics. | The system does diagnostic tests now. |
|
||||||
|
|
||||||
|
## Vocabulary and phrasing
|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|---|---|
|
||||||
|
| Ensure file exists before running. | Make sure that the file exists before you run the command. |
|
||||||
|
| Rotate the cover until the jacks are accessible. | Turn the cover until you can get access to the jacks. |
|
||||||
|
| Extend the jack until the wheels are clear of the ground. | Extend the jack until the wheels do not touch the ground. |
|
||||||
|
| Make sure that these steps are followed. | Obey these steps. |
|
||||||
|
| Utilize approximately 3 liters of water. | Use about 3 liters of water. |
|
||||||
|
| Perform a compression of the log files prior to upload. | Compress the log files before you upload them. |
|
||||||
|
|
||||||
|
## Noun clusters
|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|---|---|
|
||||||
|
| Main gear door retraction winch handle | Main-gear-door retraction-winch handle |
|
||||||
|
| Runway light connection resistance calibration | Calibration of the resistance of the runway light connection |
|
||||||
|
| Remove the engine transmission housing attachment bolts. | Remove the bolts that attach the transmission housing to the engine. |
|
||||||
|
|
||||||
|
## Procedural rewrite (condition first, one instruction per sentence)
|
||||||
|
|
||||||
|
Before:
|
||||||
|
> You should check that the reservoir has been filled with the correct fluid and, after attaching a clear tube to the bleed screw while ensuring its free end is submerged, the pedal can be pumped and held so that opening the screw releases trapped air.
|
||||||
|
|
||||||
|
After:
|
||||||
|
> 1. Fill the reservoir with the correct fluid.
|
||||||
|
> 2. Attach a clear tube to the bleed screw.
|
||||||
|
> 3. Put the free end of the tube in a container of fluid.
|
||||||
|
> 4. Push the pedal three times. Hold the pedal down.
|
||||||
|
> 5. Open the bleed screw one half turn. Air and fluid flow into the tube.
|
||||||
|
> 6. Close the bleed screw. Release the pedal.
|
||||||
|
> 7. If air continues to come out, do steps 4 thru 6 again.
|
||||||
|
|
||||||
|
## Warnings and cautions (command first, then risk)
|
||||||
|
|
||||||
|
Before:
|
||||||
|
> Note that serious data loss may potentially occur if the --force flag is used against production.
|
||||||
|
|
||||||
|
After:
|
||||||
|
> CAUTION: Do not use the --force flag on the production database. The flag deletes the rows that do not match the source.
|
||||||
|
|
||||||
|
Before:
|
||||||
|
> Touching the terminal could result in electrocution.
|
||||||
|
|
||||||
|
After:
|
||||||
|
> WARNING: Do not touch the terminal. The terminal has a dangerous voltage.
|
||||||
|
|
||||||
|
## Common mistakes checklist
|
||||||
|
|
||||||
|
- Dropped articles: "Insert pin in bracket" → "Insert the pin in the bracket."
|
||||||
|
- Synonym rotation: check/verify/confirm for the same action → one term, everywhere.
|
||||||
|
- Hedges: "you may want to," "it is recommended that" → an imperative or "must."
|
||||||
|
- Instruction buried in a NOTE: notes never instruct. Move the instruction to a numbered step.
|
||||||
|
- Semicolon joining two clauses → two sentences.
|
||||||
|
- "There are three bolts on the panel" → "The panel has three bolts."
|
||||||
68
.opencode/skills/ste/references/word-substitutions.md
Normal file
68
.opencode/skills/ste/references/word-substitutions.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Word substitutions and one-meaning rulings
|
||||||
|
|
||||||
|
Compiled from public secondary sources (STEMG/ASD public pages, TechScribe, Acrolinx, training materials). This is a working approximation, not the official ASD dictionary. When a word is not listed here and feels formal or Latin-derived, prefer the shortest common alternative.
|
||||||
|
|
||||||
|
## Unapproved → approved
|
||||||
|
|
||||||
|
| Do not use | Use instead |
|
||||||
|
|---|---|
|
||||||
|
| utilize, leverage, employ | use |
|
||||||
|
| commence, initiate, begin, originate | start |
|
||||||
|
| terminate, cease, conclude | stop, end |
|
||||||
|
| ensure, verify, confirm, validate, check | make sure (that), examine |
|
||||||
|
| perform, conduct, execute, carry out | do |
|
||||||
|
| facilitate, assist | help |
|
||||||
|
| obtain, acquire, procure | get |
|
||||||
|
| sufficient, adequate | enough |
|
||||||
|
| approximately | about |
|
||||||
|
| prior to | before |
|
||||||
|
| subsequent to, following (prep.) | after |
|
||||||
|
| adjacent to | near |
|
||||||
|
| accomplish | do |
|
||||||
|
| additional, supplementary | more |
|
||||||
|
| attempt | try |
|
||||||
|
| require, necessitate | need, must |
|
||||||
|
| mandatory | necessary |
|
||||||
|
| indicate, signify | show |
|
||||||
|
| observe (=watch) | look at, examine |
|
||||||
|
| rotate | turn |
|
||||||
|
| deactivate | turn off, set to off |
|
||||||
|
| activate, energize (unless technical verb) | turn on, start |
|
||||||
|
| toxic | poisonous |
|
||||||
|
| in order to | to |
|
||||||
|
| via, by means of | through, with |
|
||||||
|
| due to, owing to | because of |
|
||||||
|
| in the event of/that | if |
|
||||||
|
| accessible | (rewrite: "you can get access to") |
|
||||||
|
| remainder | rest |
|
||||||
|
| demonstrate | show |
|
||||||
|
| modify, alter | change |
|
||||||
|
| construct, fabricate, build | assemble, make |
|
||||||
|
| retain | keep |
|
||||||
|
| locate (=find) | find |
|
||||||
|
| depress (a button) | push, press |
|
||||||
|
| proceed | continue, go |
|
||||||
|
|
||||||
|
## One meaning, one part of speech (canonical rulings)
|
||||||
|
|
||||||
|
- **close** — verb only: to move to a position that stops flow, or to operate a circuit breaker. The adjective is unapproved → use **near** ("do not go near the propeller").
|
||||||
|
- **test** — noun only: "do a test," never "test the system."
|
||||||
|
- **check** — do not use as a verb for verification → "make sure that" or "examine."
|
||||||
|
- **follow** — means only "come after." For rules and steps use **obey**: "Obey the safety instructions."
|
||||||
|
- **fall** — means only "move down by gravity." For quantities use **decrease**. Never the season.
|
||||||
|
- **oil** — noun only. "Oil the bearing" → "Put oil on the bearing" / "Lubricate the bearing."
|
||||||
|
- **right** — direction only, never "correct."
|
||||||
|
- **clear** — "without blockage." "Wheels are clear of the ground" → "wheels do not touch the ground."
|
||||||
|
- **help** — verb only; the noun is **aid** ("with the aid of a mirror").
|
||||||
|
- **above / below** — physical position only. For quantities: **more than / less than**.
|
||||||
|
- **about** — two approved senses: "approximately" and "on the subject of." Use carefully.
|
||||||
|
- **turn** — the general verb for rotation; "turn on / turn off" for power state is standard.
|
||||||
|
- **level** — approved as noun and adjective (documented exception to the one-POS rule).
|
||||||
|
|
||||||
|
## Frequent-offender function words
|
||||||
|
|
||||||
|
- **should / would / could / may / might** — never. Requirement → **must**. Possibility → **can**. Future → **will**.
|
||||||
|
- **etc.** — delete, or write the full list.
|
||||||
|
- **e.g. / i.e.** — "for example" / "that is."
|
||||||
|
- **any / appropriate / applicable / relevant** as hedges — replace with the specific thing meant.
|
||||||
|
- **there is / there are** openers — rewrite with a real subject: "There are three bolts on the panel" → "The panel has three bolts."
|
||||||
@ -5,7 +5,8 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
|
|||||||
## Focused memories
|
## Focused memories
|
||||||
|
|
||||||
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
|
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
|
||||||
- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties`
|
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
|
||||||
|
- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`.
|
||||||
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
|
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
|
||||||
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
|
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
|
||||||
|
|
||||||
@ -107,4 +108,3 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. J
|
|||||||
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
|
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
|
||||||
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
|
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
|
||||||
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
|
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
|
||||||
|
|
||||||
|
|||||||
@ -14,10 +14,7 @@
|
|||||||
|
|
||||||
## Storage and media
|
## Storage and media
|
||||||
|
|
||||||
- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`.
|
- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
|
||||||
- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes.
|
|
||||||
- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows.
|
|
||||||
- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code.
|
|
||||||
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
|
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
|
||||||
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
|
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
|
||||||
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
|
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
|
||||||
|
|||||||
83
.serena/memories/backend/storage.md
Normal file
83
.serena/memories/backend/storage.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Backend Storage
|
||||||
|
|
||||||
|
## Abstraction
|
||||||
|
|
||||||
|
- `app.storage` stores binary objects.
|
||||||
|
- Each object has a `storage_object` database row.
|
||||||
|
- The row stores the UUID, size, backend, timestamps, and Transit metadata.
|
||||||
|
- The backend stores the binary content.
|
||||||
|
- Supported backends are `:fs` and `:s3`.
|
||||||
|
- FS uses one root directory and a UUID-derived path.
|
||||||
|
- S3 uses one configured bucket and an optional prefix.
|
||||||
|
- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
|
||||||
|
- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
|
||||||
|
- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
|
||||||
|
- Deprecated asset-storage config keys remain supported for migration.
|
||||||
|
- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
|
||||||
|
|
||||||
|
## Object Lifecycle
|
||||||
|
|
||||||
|
- `put-object!` creates the database row before it writes backend content.
|
||||||
|
- Backend content is written only when the row is new.
|
||||||
|
- A failed backend write can leave an unreferenced database row.
|
||||||
|
- Callers often set `:touched-at` so garbage collection can remove such rows.
|
||||||
|
- `get-object` excludes rows with `deleted_at`.
|
||||||
|
- Existing object values can remain readable until physical deletion.
|
||||||
|
- `:expired-at` blocks reads after the expiration time.
|
||||||
|
- `del-object!` sets `deleted_at`. It does not remove backend content.
|
||||||
|
- `storage-gc-deleted` removes the database row and backend content after the deletion delay.
|
||||||
|
- `storage-gc-touched` finds references before it sets `deleted_at`.
|
||||||
|
- `objects-gc` removes deleted domain rows and touches their storage object IDs.
|
||||||
|
- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
|
||||||
|
|
||||||
|
## Deduplication
|
||||||
|
|
||||||
|
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
|
||||||
|
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
|
||||||
|
- The lookup does not include file ID, profile ID, team ID, or organization ID.
|
||||||
|
- Objects can therefore share content across users and files within one bucket.
|
||||||
|
- Deleted objects are not reused.
|
||||||
|
- `tempfile` objects never use deduplication, even when the caller requests it.
|
||||||
|
- Use `sto/wrap-with-hash` when the caller already calculated the content hash.
|
||||||
|
|
||||||
|
## Bucket Rules
|
||||||
|
|
||||||
|
| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. |
|
||||||
|
| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. |
|
||||||
|
| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. |
|
||||||
|
| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. |
|
||||||
|
| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
|
||||||
|
| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
|
||||||
|
| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
|
||||||
|
| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
|
||||||
|
| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
|
||||||
|
| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
|
||||||
|
|
||||||
|
- The valid bucket set lives in `app.storage/valid-buckets`.
|
||||||
|
- `file-media-object` is the default bucket for old rows without bucket metadata.
|
||||||
|
- Do not assign a new bucket without adding its access and cleanup behavior.
|
||||||
|
- The touched-object collector raises an internal error for an unknown bucket.
|
||||||
|
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
|
||||||
|
- It does not support `file-data-fragment` or `file-change`.
|
||||||
|
|
||||||
|
## Access Rules
|
||||||
|
|
||||||
|
- `app.http.assets` decides direct object authentication from the bucket.
|
||||||
|
- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`.
|
||||||
|
- Other valid buckets require a session or access-token profile ID.
|
||||||
|
- File-media routes also require file read permission.
|
||||||
|
- Non-public direct responses set `content-disposition: attachment`.
|
||||||
|
- FS responses use `x-accel-redirect` for the configured asset path.
|
||||||
|
- S3 responses use a presigned URL and an HTTP redirect.
|
||||||
|
|
||||||
|
## File Data
|
||||||
|
|
||||||
|
- `file-data-backend` accepts `legacy-db`, `db`, or `storage`.
|
||||||
|
- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`.
|
||||||
|
- `db` stores encoded data in `file_data.data`.
|
||||||
|
- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table.
|
||||||
|
- The `file_data.metadata.storage-ref-id` value points to the storage object.
|
||||||
|
- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row.
|
||||||
|
- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata.
|
||||||
@ -5,7 +5,7 @@
|
|||||||
## Stable namespace map
|
## Stable namespace map
|
||||||
|
|
||||||
- `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities.
|
- `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities.
|
||||||
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.nitrate-permissions` contains shared fail-closed Nitrate organization/team permission rules.
|
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.organization` contains organization schemas, `apply-organization`, and fail-closed organization/team permission rules (`allowed?`, `can-send-invitations?`).
|
||||||
- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic.
|
- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic.
|
||||||
- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc.
|
- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc.
|
||||||
- `app.common.geom.*`: geometry helpers and transformations.
|
- `app.common.geom.*`: geometry helpers and transformations.
|
||||||
|
|||||||
@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply
|
|||||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
||||||
- `library/`: design library workflows; core conventions: `mem:library/core`.
|
- `library/`: design library workflows; core conventions: `mem:library/core`.
|
||||||
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
|
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
|
||||||
|
- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`.
|
||||||
|
|
||||||
The memory is structured in a way that you can get the critical information about the
|
The memory is structured in a way that you can get the critical information about the
|
||||||
module. You can read it from `mem:<MODULE>/core`
|
module. You can read it from `mem:<MODULE>/core`
|
||||||
|
|||||||
@ -5,9 +5,10 @@
|
|||||||
## Layout and commands
|
## Layout and commands
|
||||||
|
|
||||||
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
|
- 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`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
|
- 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`.
|
||||||
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
|
- 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`.
|
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||||
|
- Exporter test conventions and CI: `mem:exporter/testing`.
|
||||||
|
|
||||||
## HTTP and browser pool
|
## HTTP and browser pool
|
||||||
|
|
||||||
|
|||||||
16
.serena/memories/exporter/testing.md
Normal file
16
.serena/memories/exporter/testing.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# 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.
|
||||||
100
.serena/memories/media-processor/core.md
Normal file
100
.serena/memories/media-processor/core.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Media Processor
|
||||||
|
|
||||||
|
Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Language: TypeScript
|
||||||
|
- Runtime: Node.js
|
||||||
|
- Framework: Express
|
||||||
|
- Image processing: sharp (libvips)
|
||||||
|
- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress
|
||||||
|
- Upload handling: multer (hybrid storage: memory for small, disk for large)
|
||||||
|
- Logging: pino (with optional Loki transport)
|
||||||
|
- Config validation: Zod
|
||||||
|
- Testing: Vitest
|
||||||
|
- Package Manager: pnpm
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
media-processor/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Express app setup, routes, middleware
|
||||||
|
│ ├── config.ts # Zod-validated env config, HKDF key derivation
|
||||||
|
│ ├── types.ts # TypeScript type definitions
|
||||||
|
│ ├── upload.ts # Multer configuration, getFileBuffer helper
|
||||||
|
│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold)
|
||||||
|
│ ├── logger.ts # Pino logger setup
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.ts # Timing-safe shared key authentication
|
||||||
|
│ │ ├── error-handler.ts # ProcessingError class, centralized error handling
|
||||||
|
│ │ └── timeout.ts # Request timeout middleware
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── health.ts # GET /api/health
|
||||||
|
│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail
|
||||||
|
│ │ └── font.ts # POST /api/font/convert
|
||||||
|
│ └── services/
|
||||||
|
│ ├── image.ts # sharp-based image info/thumbnail generation
|
||||||
|
│ ├── font.ts # FontForge/woff-tools font conversion
|
||||||
|
│ └── errors.ts # throwValidation, throwRestriction, throwProcessing
|
||||||
|
├── test/ # Vitest test files
|
||||||
|
├── vitest.config.ts # Test configuration
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
├── esbuild.config.mjs # Build configuration
|
||||||
|
└── package.json # Dependencies and scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- Requests authenticated via `x-shared-key` header using timing-safe comparison
|
||||||
|
- When no key configured, all requests rejected with 403
|
||||||
|
- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY`
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
- Image: max pixels, max width/height enforced before processing
|
||||||
|
- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits
|
||||||
|
- Concurrency: p-queue limits concurrent requests (default 10)
|
||||||
|
- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD`
|
||||||
|
- Max file size: configurable (default 350MB)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- `throwValidation(code, hint)` — 400 errors for invalid input
|
||||||
|
- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded
|
||||||
|
- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills)
|
||||||
|
|
||||||
|
### Image Processing
|
||||||
|
- EXIF orientation applied before dimension validation and thumbnail generation
|
||||||
|
- sharp caching disabled to prevent unbounded memory growth
|
||||||
|
- `withoutEnlargement: true` prevents upscaling small images
|
||||||
|
|
||||||
|
### Font Conversion
|
||||||
|
- Supported formats: TTF, OTF, WOFF, WOFF2
|
||||||
|
- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF)
|
||||||
|
- Temp files cleaned up in finally blocks (best-effort)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
All commands run from `media-processor/` directory:
|
||||||
|
|
||||||
|
- `pnpm run test` — Run Vitest test suite
|
||||||
|
- `pnpm run types:check` — TypeScript type checking (tsc --noEmit)
|
||||||
|
- `pnpm run fmt` — Format code with Prettier
|
||||||
|
- `pnpm run fmt:check` — Check formatting without modifying
|
||||||
|
- `pnpm run build` — Build for production (esbuild)
|
||||||
|
- `pnpm run start:dev` — Start development server (tsx)
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`)
|
||||||
|
- Must be deployed on internal Docker network only (not public-facing)
|
||||||
|
- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI`
|
||||||
|
|
||||||
|
## Testing Principles
|
||||||
|
|
||||||
|
Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||||
|
|
||||||
|
- Run `pnpm run test` after changes
|
||||||
|
- Run `pnpm run types:check` after TypeScript changes
|
||||||
|
- Run `pnpm run fmt:check` before commits
|
||||||
@ -6,7 +6,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose
|
|||||||
|
|
||||||
- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends.
|
- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends.
|
||||||
- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue:<tenant>:<queue>`. `PENPOT_REDIS_URI`.
|
- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue:<tenant>:<queue>`. `PENPOT_REDIS_URI`.
|
||||||
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`.
|
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`.
|
||||||
- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task).
|
- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task).
|
||||||
- **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`.
|
- **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`.
|
||||||
|
|
||||||
@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact
|
|||||||
## See also
|
## See also
|
||||||
|
|
||||||
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
|
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
|
||||||
- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`.
|
- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`.
|
||||||
|
|||||||
@ -17,9 +17,13 @@
|
|||||||
|
|
||||||
## Tile/render behavior
|
## Tile/render behavior
|
||||||
|
|
||||||
|
- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain
|
||||||
|
Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs).
|
||||||
- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame.
|
- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame.
|
||||||
- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately.
|
- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately.
|
||||||
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
|
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
|
||||||
- Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush.
|
- Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush.
|
||||||
- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters.
|
- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters.
|
||||||
- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
|
- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
|
||||||
|
- Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect +
|
||||||
|
blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true.
|
||||||
@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
|
|||||||
- Finding issues with no milestone.
|
- Finding issues with no milestone.
|
||||||
- Fetching PR details by number or by milestone.
|
- Fetching PR details by number or by milestone.
|
||||||
- Comparing milestone issues against CHANGES.md to find missing entries.
|
- Comparing milestone issues against CHANGES.md to find missing entries.
|
||||||
|
- Listing or inspecting GitHub Security Advisories (GHSA).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
|||||||
|
|
||||||
**Output**: JSON array to stdout; progress to stderr.
|
**Output**: JSON array to stdout; progress to stderr.
|
||||||
|
|
||||||
|
### `advisories`
|
||||||
|
|
||||||
|
List or inspect GitHub Security Advisories for the repository.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all advisories (summary view)
|
||||||
|
python3 scripts/gh.py advisories
|
||||||
|
|
||||||
|
# Filter by severity
|
||||||
|
python3 scripts/gh.py advisories --severity critical
|
||||||
|
|
||||||
|
# Filter by state
|
||||||
|
python3 scripts/gh.py advisories --state triage
|
||||||
|
|
||||||
|
# Get full detail for a single advisory
|
||||||
|
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
|
||||||
|
```
|
||||||
|
|
||||||
|
**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url.
|
||||||
|
|
||||||
|
**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps.
|
||||||
|
|
||||||
|
**Output**: JSON to stdout; progress to stderr.
|
||||||
|
|
||||||
## Key principles
|
## Key principles
|
||||||
|
|
||||||
- All output is JSON — pipe into `jq` or other tools for further processing.
|
- All output is JSON — pipe into `jq` or other tools for further processing.
|
||||||
|
|||||||
@ -14,6 +14,8 @@ automatically pull the identity from the local git config `user.name` and `user.
|
|||||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||||
|
|
||||||
Body explaining what changed and why.
|
Body explaining what changed and why.
|
||||||
|
Wrap lines at 72 characters — git log and tooling
|
||||||
|
render long lines poorly. Keep each line concise.
|
||||||
|
|
||||||
AI-assisted-by: model-name
|
AI-assisted-by: model-name
|
||||||
```
|
```
|
||||||
@ -25,3 +27,7 @@ AI-assisted-by: model-name
|
|||||||
## Commit Type Emojis
|
## Commit Type Emojis
|
||||||
|
|
||||||
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
|
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
|
||||||
|
|
||||||
|
## Referencing Issues
|
||||||
|
|
||||||
|
Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
|
||||||
|
|||||||
14
AGENTS.md
14
AGENTS.md
@ -34,6 +34,19 @@ Skipping this step is the #1 cause of incorrect or incomplete work.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Auto-triggers
|
||||||
|
|
||||||
|
- **Security advisory URL pasted** — When the user pastes a URL matching
|
||||||
|
`github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID
|
||||||
|
from the URL and run `python3 scripts/gh.py advisories <GHSA-ID>` to fetch
|
||||||
|
full advisory details before proceeding.
|
||||||
|
|
||||||
|
## Writing Rules
|
||||||
|
|
||||||
|
Use the `ste` skill when the user explicitly requests STE, `/ste`, or ASD-STE100.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Memory system
|
# Memory system
|
||||||
|
|
||||||
Memories are the **primary project guidance** — not docs or readme files.
|
Memories are the **primary project guidance** — not docs or readme files.
|
||||||
@ -113,4 +126,5 @@ precision while maintaining a strong focus on maintainability and performance.
|
|||||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||||
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
|
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
|
||||||
|
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
|
||||||
|
|
||||||
|
|||||||
21
CHANGES.md
21
CHANGES.md
@ -24,16 +24,31 @@
|
|||||||
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
|
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
|
||||||
|
|
||||||
|
|
||||||
## 2.17.1 (Unreleased)
|
## 2.17.1
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :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-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 malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
|
||||||
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
|
|
||||||
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
|
|
||||||
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
|
- Fix 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 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 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
|
## 2.17.0
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,7 @@
|
|||||||
|
|
||||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||||
|
org.passay/passay {:mvn/version "1.6.6"}
|
||||||
|
|
||||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||||
|
|
||||||
|
|||||||
@ -4,23 +4,25 @@
|
|||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"author": "Kaleidos INC Sucursal en España SL",
|
"author": "Kaleidos INC Sucursal en España SL",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
|
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/penpot/penpot"
|
"url": "https://github.com/penpot/penpot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"luxon": "^3.4.4",
|
"eventsource-parser": "^3.0.6",
|
||||||
"sax": "^1.6.0"
|
"luxon": "^3.7.2",
|
||||||
|
"sax": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
|
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
|
||||||
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
||||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/"
|
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||||
|
"test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/pnpm-lock.yaml
generated
41
backend/pnpm-lock.yaml
generated
@ -8,12 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
eventsource-parser:
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.1.0
|
||||||
luxon:
|
luxon:
|
||||||
specifier: ^3.4.4
|
specifier: ^3.7.2
|
||||||
version: 3.7.2
|
version: 3.7.2
|
||||||
sax:
|
sax:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.1
|
||||||
version: 1.6.0
|
version: 1.6.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
nodemon:
|
nodemon:
|
||||||
specifier: ^3.1.14
|
specifier: ^3.1.14
|
||||||
@ -22,8 +25,8 @@ importers:
|
|||||||
specifier: ^0.5.21
|
specifier: ^0.5.21
|
||||||
version: 0.5.21
|
version: 0.5.21
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.21.0
|
specifier: ^8.21.1
|
||||||
version: 8.21.0
|
version: 8.21.1
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -39,9 +42,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
brace-expansion@5.0.7:
|
brace-expansion@5.0.9:
|
||||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
@ -63,6 +66,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
eventsource-parser@3.1.0:
|
||||||
|
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -130,8 +137,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
sax@1.6.0:
|
sax@1.6.1:
|
||||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||||
engines: {node: '>=11.0.0'}
|
engines: {node: '>=11.0.0'}
|
||||||
|
|
||||||
semver@7.8.5:
|
semver@7.8.5:
|
||||||
@ -165,8 +172,8 @@ packages:
|
|||||||
undefsafe@2.0.5:
|
undefsafe@2.0.5:
|
||||||
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
||||||
|
|
||||||
ws@8.21.0:
|
ws@8.21.1:
|
||||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
bufferutil: ^4.0.1
|
bufferutil: ^4.0.1
|
||||||
@ -188,7 +195,7 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
brace-expansion@5.0.7:
|
brace-expansion@5.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
@ -216,6 +223,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 5.5.0
|
supports-color: 5.5.0
|
||||||
|
|
||||||
|
eventsource-parser@3.1.0: {}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@ -247,7 +256,7 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.7
|
brace-expansion: 5.0.9
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
@ -274,7 +283,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.2
|
picomatch: 2.3.2
|
||||||
|
|
||||||
sax@1.6.0: {}
|
sax@1.6.1: {}
|
||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
@ -301,4 +310,4 @@ snapshots:
|
|||||||
|
|
||||||
undefsafe@2.0.5: {}
|
undefsafe@2.0.5: {}
|
||||||
|
|
||||||
ws@8.21.0: {}
|
ws@8.21.1: {}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- brace-expansion@5.0.8 || 5.0.9
|
||||||
@ -39,4 +39,16 @@
|
|||||||
{:permits 3}
|
{:permits 3}
|
||||||
|
|
||||||
:create-file-snapshot/by-profile
|
:create-file-snapshot/by-profile
|
||||||
{:permits 1 :queue 2 :timeout 60000}}
|
{:permits 1 :queue 2 :timeout 60000}
|
||||||
|
|
||||||
|
:send-user-feedback/global
|
||||||
|
{:permits 4}
|
||||||
|
|
||||||
|
:send-user-feedback/by-profile
|
||||||
|
{:permits 1 :queue 3}
|
||||||
|
|
||||||
|
:import-binfile/global
|
||||||
|
{:permits 4}
|
||||||
|
|
||||||
|
:import-binfile/by-profile
|
||||||
|
{:permits 1 :queue 2}}
|
||||||
|
|||||||
@ -1,11 +1,308 @@
|
|||||||
;; Example rlimit.edn file
|
|
||||||
^{:refresh "30s"}
|
^{:refresh "30s"}
|
||||||
{:default
|
{:default
|
||||||
[[:default :window "200000/h"]]
|
[[:default :window "200000/h"]]
|
||||||
|
|
||||||
;; #{:main/get-teams}
|
;; ═══════════════════════════════════════════════
|
||||||
;; [[:burst :bucket "5/5/5s"]]
|
;; Auth & Identity — public, unauthenticated
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/login-with-password}
|
||||||
|
[[:auth-password :bucket "100/50/1m"]]
|
||||||
|
|
||||||
;; #{:main/get-profile}
|
#{:main/login-with-ldap}
|
||||||
;; [[:burst :bucket "60/60/1m"]]
|
[[:auth-ldap :bucket "20/10/5m"]]
|
||||||
}
|
|
||||||
|
#{:main/register-profile}
|
||||||
|
[[:auth-register :bucket "20/10/15m"]]
|
||||||
|
|
||||||
|
#{:main/request-profile-recovery
|
||||||
|
:main/prepare-register-profile}
|
||||||
|
[[:auth-recovery :bucket "100/50/5m"]]
|
||||||
|
|
||||||
|
#{:main/recover-profile
|
||||||
|
:main/verify-token}
|
||||||
|
[[:auth-token :bucket "100/50/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; SSRF vectors — URL fetch endpoints
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-file-media-object-from-url}
|
||||||
|
[[:url-fetch :bucket "100/50/5m"]]
|
||||||
|
|
||||||
|
#{:main/create-webhook
|
||||||
|
:main/update-webhook}
|
||||||
|
[[:webhook-validation :bucket "20/10/5m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Search — full sequential scan risk
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/search-files}
|
||||||
|
[[:search :bucket "60/30/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Feedback & Invitations — email-sending
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/send-user-feedback
|
||||||
|
:main/create-team-invitations}
|
||||||
|
[[:email-send :bucket "30/15/5m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Media & File heavy ops
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/upload-file-media-object}
|
||||||
|
[[:image-upload :bucket "200/100/1m"]]
|
||||||
|
|
||||||
|
#{:main/create-file-object-thumbnail
|
||||||
|
:main/delete-file-object-thumbnails
|
||||||
|
:main/get-file-object-thumbnails}
|
||||||
|
[[:thumbnail-ops :bucket "5000/3000/1m"]]
|
||||||
|
|
||||||
|
#{:main/get-file-data-for-thumbnail
|
||||||
|
:main/create-file-thumbnail}
|
||||||
|
[[:thumbnail-data :bucket "100/50/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; UI navigation reads — high frequency
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/get-teams}
|
||||||
|
[[:get-teams :bucket "5000/2500/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-team-members}
|
||||||
|
[[:get-team-members :bucket "4000/2000/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-profile}
|
||||||
|
[[:get-profile :bucket "500/250/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-font-variants}
|
||||||
|
[[:get-font-variants :bucket "250/125/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-comment-threads}
|
||||||
|
[[:get-comment-threads :bucket "500/250/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-profiles-for-file-comments}
|
||||||
|
[[:get-profiles-for-file-comments :bucket "300/150/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-file-libraries}
|
||||||
|
[[:get-file-libraries :bucket "200/100/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-projects}
|
||||||
|
[[:get-projects :bucket "120/60/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-team-recent-files
|
||||||
|
:main/get-unread-comment-threads}
|
||||||
|
[[:get-team-recent :bucket "120/60/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-page}
|
||||||
|
[[:get-page :bucket "150/75/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-access-tokens
|
||||||
|
:main/get-subscription-usage}
|
||||||
|
[[:get-access-tokens :bucket "150/75/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-enabled-flags}
|
||||||
|
[[:get-enabled-flags :bucket "250/125/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-builtin-templates}
|
||||||
|
[[:get-builtin-templates :bucket "200/100/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-project
|
||||||
|
:main/get-project-files}
|
||||||
|
[[:get-project-info :bucket "80/40/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-file}
|
||||||
|
[[:get-file :bucket "180/90/1m"]]
|
||||||
|
|
||||||
|
#{:main/get-team-shared-files
|
||||||
|
:main/get-team-info
|
||||||
|
:main/get-team-users
|
||||||
|
:main/get-team-invitations
|
||||||
|
:main/get-team-deleted-files
|
||||||
|
:main/get-sso-provider}
|
||||||
|
[[:get-team-info :bucket "60/30/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-comments
|
||||||
|
:main/get-file-snapshots
|
||||||
|
:main/get-library-usage
|
||||||
|
:main/has-file-libraries}
|
||||||
|
[[:get-misc-list :bucket "300/150/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-comment-thread
|
||||||
|
:main/get-library-file-references}
|
||||||
|
[[:get-misc-single :bucket "60/30/30s"]]
|
||||||
|
|
||||||
|
#{:main/get-file-info
|
||||||
|
:main/get-view-only-bundle
|
||||||
|
:main/get-all-projects
|
||||||
|
:main/get-owned-teams
|
||||||
|
:main/get-team-stats
|
||||||
|
:main/get-file-summary
|
||||||
|
:main/get-file-stats
|
||||||
|
:main/get-file-fragment}
|
||||||
|
[[:get-light :bucket "60/30/30s"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; File mutations — editing active
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/update-file}
|
||||||
|
[[:update-file :bucket "1000/500/1m"]]
|
||||||
|
|
||||||
|
#{:main/create-file
|
||||||
|
:main/rename-file
|
||||||
|
:main/duplicate-file
|
||||||
|
:main/move-files}
|
||||||
|
[[:file-create :bucket "60/30/1m"]]
|
||||||
|
|
||||||
|
#{:main/delete-file}
|
||||||
|
[[:file-delete :bucket "80/40/1m"]]
|
||||||
|
|
||||||
|
#{:main/set-file-shared
|
||||||
|
:main/update-file-library-sync-status
|
||||||
|
:main/ignore-file-library-sync-status
|
||||||
|
:main/link-file-to-library
|
||||||
|
:main/unlink-file-from-library
|
||||||
|
:main/create-file-snapshot
|
||||||
|
:main/restore-file-snapshot
|
||||||
|
:main/update-file-snapshot
|
||||||
|
:main/delete-file-snapshot
|
||||||
|
:main/lock-file-snapshot
|
||||||
|
:main/unlock-file-snapshot}
|
||||||
|
[[:file-mutations :bucket "80/40/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Project mutations
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-project}
|
||||||
|
[[:project-create :bucket "100/50/1m"]]
|
||||||
|
|
||||||
|
#{:main/delete-project
|
||||||
|
:main/rename-project
|
||||||
|
:main/duplicate-project
|
||||||
|
:main/move-project
|
||||||
|
:main/update-project-pin}
|
||||||
|
[[:project-mutations :bucket "40/20/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Team mutations
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-team
|
||||||
|
:main/update-team
|
||||||
|
:main/delete-team
|
||||||
|
:main/update-team-photo
|
||||||
|
:main/update-team-member-role
|
||||||
|
:main/delete-team-member
|
||||||
|
:main/leave-team
|
||||||
|
:main/create-team-with-invitations
|
||||||
|
:main/create-team-access-request
|
||||||
|
:main/permanently-delete-team-files
|
||||||
|
:main/restore-deleted-team-files}
|
||||||
|
[[:team-mutations :bucket "60/30/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Comment operations
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-comment-thread
|
||||||
|
:main/create-comment
|
||||||
|
:main/update-comment
|
||||||
|
:main/delete-comment
|
||||||
|
:main/mark-all-threads-as-read}
|
||||||
|
[[:comment-basic :bucket "30/15/1m"]]
|
||||||
|
|
||||||
|
#{:main/update-comment-thread
|
||||||
|
:main/update-comment-thread-status
|
||||||
|
:main/update-comment-thread-position
|
||||||
|
:main/update-comment-thread-frame
|
||||||
|
:main/delete-comment-thread}
|
||||||
|
[[:comment-thread :bucket "80/40/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Profile operations
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/update-profile
|
||||||
|
:main/update-profile-props
|
||||||
|
:main/update-profile-photo
|
||||||
|
:main/update-profile-password
|
||||||
|
:main/update-profile-notifications
|
||||||
|
:main/delete-profile
|
||||||
|
:main/delete-profile-photo
|
||||||
|
:main/request-email-change}
|
||||||
|
[[:profile-mutations :bucket "30/15/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Font operations
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-font-variant
|
||||||
|
:main/delete-font
|
||||||
|
:main/delete-font-variant
|
||||||
|
:main/update-font
|
||||||
|
:main/download-font
|
||||||
|
:main/download-font-family}
|
||||||
|
[[:font-ops :bucket "100/50/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Access tokens
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-access-token
|
||||||
|
:main/delete-access-token}
|
||||||
|
[[:access-token :bucket "60/30/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Export / Import
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/export-binfile
|
||||||
|
:main/import-binfile
|
||||||
|
:main/clone-template}
|
||||||
|
[[:export-import :bucket "80/40/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Upload sessions
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-upload-session
|
||||||
|
:main/upload-chunk
|
||||||
|
:main/assemble-file-media-object}
|
||||||
|
[[:upload-session :bucket "100/50/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Webhooks
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/get-webhooks
|
||||||
|
:main/delete-webhook}
|
||||||
|
[[:webhook-read :bucket "20/10/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Share links
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/create-share-link
|
||||||
|
:main/delete-share-link}
|
||||||
|
[[:share-link :bucket "10/5/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Organization operations
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/add-team-to-organization
|
||||||
|
:main/remove-team-from-org
|
||||||
|
:main/all-org-members-in-team
|
||||||
|
:main/all-team-members-in-orgs
|
||||||
|
:main/get-owned-organizations-summary
|
||||||
|
:main/get-leave-org-summary
|
||||||
|
:main/leave-org
|
||||||
|
:main/check-org-members
|
||||||
|
:main/get-team-invitation-token
|
||||||
|
:main/delete-team-invitation
|
||||||
|
:main/check-team-external-invitations}
|
||||||
|
[[:org-ops :bucket "20/10/1m"]]
|
||||||
|
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
;; Audit & stats
|
||||||
|
;; ═══════════════════════════════════════════════
|
||||||
|
#{:main/push-audit-events}
|
||||||
|
[[:audit-events :bucket "1000/500/1m"]]
|
||||||
|
|
||||||
|
#{:main/logout
|
||||||
|
:main/get-error-report
|
||||||
|
:main/get-error-reports
|
||||||
|
:main/get-current-mcp-token
|
||||||
|
:main/get-nitrate-connectivity
|
||||||
|
:main/check-nitrate-sso
|
||||||
|
:main/redeem-nitrate-activation-code
|
||||||
|
:main/create-demo-profile
|
||||||
|
:main/get-subscription-warning}
|
||||||
|
[[:misc-light :bucket "100/50/1m"]]}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key
|
||||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||||
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
||||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||||
|
export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key
|
||||||
|
|
||||||
# DEPRECATED: only used for subscriptions
|
# DEPRECATED: only used for subscriptions
|
||||||
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
||||||
@ -12,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
|||||||
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
|
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
|
||||||
# docker/devenv/defaults.env and injected via the main service's env block.
|
# docker/devenv/defaults.env and injected via the main service's env block.
|
||||||
|
|
||||||
|
if [ -f /home/selfsigned.crt ]; then
|
||||||
|
export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
|
||||||
|
fi
|
||||||
|
|
||||||
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
|
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
|
||||||
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
|
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
|
||||||
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
|
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
|
||||||
@ -21,6 +26,8 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then
|
|||||||
__worker_flag="enable-backend-worker"
|
__worker_flag="enable-backend-worker"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
|
||||||
|
|
||||||
export PENPOT_FLAGS="\
|
export PENPOT_FLAGS="\
|
||||||
$PENPOT_FLAGS \
|
$PENPOT_FLAGS \
|
||||||
enable-login-with-password \
|
enable-login-with-password \
|
||||||
@ -36,6 +43,7 @@ export PENPOT_FLAGS="\
|
|||||||
enable-feature-fdata-objects-map \
|
enable-feature-fdata-objects-map \
|
||||||
enable-audit-log \
|
enable-audit-log \
|
||||||
enable-transit-readable-response \
|
enable-transit-readable-response \
|
||||||
|
disable-remote-media-processing \
|
||||||
enable-demo-users \
|
enable-demo-users \
|
||||||
enable-user-feedback \
|
enable-user-feedback \
|
||||||
disable-secure-session-cookies \
|
disable-secure-session-cookies \
|
||||||
@ -71,7 +79,7 @@ export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
|
|||||||
|
|
||||||
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
|
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
|
||||||
|
|
||||||
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
|
export PENPOT_ADMIN_CONSOLE_URI=http://localhost:3000/admin-console
|
||||||
|
|
||||||
export JAVA_OPTS="\
|
export JAVA_OPTS="\
|
||||||
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||||
@ -97,5 +105,3 @@ function setup_minio() {
|
|||||||
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
|
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
|
||||||
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
|
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -620,9 +620,6 @@
|
|||||||
(some? (:external-session-id state))
|
(some? (:external-session-id state))
|
||||||
(assoc :external-session-id (:external-session-id state))
|
(assoc :external-session-id (:external-session-id state))
|
||||||
|
|
||||||
(some? (:token/expires-in tdata))
|
|
||||||
(assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)}))
|
|
||||||
|
|
||||||
;; If state token comes with props, merge them. The state token
|
;; If state token comes with props, merge them. The state token
|
||||||
;; props can contain pm_ and utm_ prefixed query params.
|
;; props can contain pm_ and utm_ prefixed query params.
|
||||||
(map? (:props state))
|
(map? (:props state))
|
||||||
@ -650,6 +647,15 @@
|
|||||||
(assoc :query (u/map->query-string params)))]
|
(assoc :query (u/map->query-string params)))]
|
||||||
(redirect-response uri))))
|
(redirect-response uri))))
|
||||||
|
|
||||||
|
(defn- redirect-with-organization-sso-error
|
||||||
|
[{:keys [dest-url organization-id organization-name]}]
|
||||||
|
(-> (str (or dest-url (cf/get :public-uri)))
|
||||||
|
(u/append-query-param :sso-error true)
|
||||||
|
(u/append-query-param :organization-id organization-id)
|
||||||
|
(cond-> organization-name
|
||||||
|
(u/append-query-param :organization-name organization-name))
|
||||||
|
(redirect-response)))
|
||||||
|
|
||||||
(defn- redirect-to-register
|
(defn- redirect-to-register
|
||||||
[cfg info provider]
|
[cfg info provider]
|
||||||
(let [info (assoc info
|
(let [info (assoc info
|
||||||
@ -765,6 +771,82 @@
|
|||||||
;; ORG SSO HELPERS
|
;; ORG SSO HELPERS
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn- organization-sso-oauth-failure-reason
|
||||||
|
[error]
|
||||||
|
(case (d/name error)
|
||||||
|
"access_denied" "access-denied"
|
||||||
|
("temporarily_unavailable" "server_error") "provider-unavailable"
|
||||||
|
("invalid_request" "unauthorized_client" "invalid_scope") "invalid-configuration"
|
||||||
|
"provider-error"))
|
||||||
|
|
||||||
|
(defn- organization-sso-exception-failure-reason
|
||||||
|
[cause]
|
||||||
|
(let [data (ex-data cause)
|
||||||
|
status (or (:response-status data)
|
||||||
|
(:response-status-code data)
|
||||||
|
(:http-status data))
|
||||||
|
network-error?
|
||||||
|
(loop [current cause]
|
||||||
|
(cond
|
||||||
|
(nil? current)
|
||||||
|
false
|
||||||
|
|
||||||
|
(or (instance? java.net.ConnectException current)
|
||||||
|
(instance? java.net.UnknownHostException current)
|
||||||
|
(instance? java.net.http.HttpTimeoutException current)
|
||||||
|
(instance? javax.net.ssl.SSLException current))
|
||||||
|
true
|
||||||
|
|
||||||
|
(identical? current (ex-cause current))
|
||||||
|
false
|
||||||
|
|
||||||
|
:else
|
||||||
|
(recur (ex-cause current))))]
|
||||||
|
(if (or network-error?
|
||||||
|
(and (number? status) (<= 500 status 599)))
|
||||||
|
"provider-unavailable"
|
||||||
|
(case (:code data)
|
||||||
|
:unable-to-fetch-access-token "token-exchange-failed"
|
||||||
|
:unable-to-retrieve-user-info "user-info-failed"
|
||||||
|
:incomplete-user-info "incomplete-user-info"
|
||||||
|
:invalid-sso-config "invalid-configuration"
|
||||||
|
:unable-to-fetch-sso-jwks "provider-unavailable"
|
||||||
|
:unable-to-auth "access-denied"
|
||||||
|
"unexpected-error"))))
|
||||||
|
|
||||||
|
(defn- submit-organization-sso-auth-event
|
||||||
|
[cfg request profile-id organization-id name & {:keys [failure-reason]}]
|
||||||
|
(audit/submit cfg {:type "action"
|
||||||
|
:name name
|
||||||
|
:profile-id profile-id
|
||||||
|
:ip-addr (inet/parse-request request)
|
||||||
|
:props (d/without-nils
|
||||||
|
{:organization-id organization-id
|
||||||
|
:failure-reason failure-reason})
|
||||||
|
:context (audit/prepare-context-from-request request)}))
|
||||||
|
|
||||||
|
(defn submit-organization-sso-auth-started-event
|
||||||
|
[cfg request profile-id organization-id]
|
||||||
|
(submit-organization-sso-auth-event
|
||||||
|
cfg request profile-id organization-id "organization-sso-auth-started"))
|
||||||
|
|
||||||
|
(defn submit-organization-sso-auth-failed-event
|
||||||
|
[cfg request profile-id organization-id cause]
|
||||||
|
(submit-organization-sso-auth-event
|
||||||
|
cfg request profile-id organization-id "organization-sso-auth-failed"
|
||||||
|
:failure-reason (organization-sso-exception-failure-reason cause)))
|
||||||
|
|
||||||
|
(defn- submit-organization-sso-oauth-failed-event
|
||||||
|
[cfg request state-token error]
|
||||||
|
(try
|
||||||
|
(let [state (tokens/verify cfg {:token state-token :iss "oidc"})]
|
||||||
|
(when (:dest-url state)
|
||||||
|
(submit-organization-sso-auth-event
|
||||||
|
cfg request (some-> (session/get-session request) :profile-id)
|
||||||
|
(:organization-id state) "organization-sso-auth-failed"
|
||||||
|
:failure-reason (organization-sso-oauth-failure-reason error))))
|
||||||
|
(catch Exception _ nil)))
|
||||||
|
|
||||||
(defn- non-blank-uri
|
(defn- non-blank-uri
|
||||||
[value]
|
[value]
|
||||||
(when-not (str/blank? value) value))
|
(when-not (str/blank? value) value))
|
||||||
@ -785,8 +867,7 @@
|
|||||||
:base-uri (some-> (non-blank-uri issuer)
|
:base-uri (some-> (non-blank-uri issuer)
|
||||||
(str/rtrim "/")
|
(str/rtrim "/")
|
||||||
(str "/"))
|
(str "/"))
|
||||||
:scopes default-oidc-scopes
|
:scopes default-oidc-scopes}))
|
||||||
:skip-ssrf-check? true}))
|
|
||||||
|
|
||||||
(defn build-organization-sso-auth-redirect-uri
|
(defn build-organization-sso-auth-redirect-uri
|
||||||
"Build the OIDC authorization redirect URI for an organization SSO config.
|
"Build the OIDC authorization redirect URI for an organization SSO config.
|
||||||
@ -888,10 +969,53 @@
|
|||||||
{::yres/status 200
|
{::yres/status 200
|
||||||
::yres/body {:redirect-uri uri}}))
|
::yres/body {:redirect-uri uri}}))
|
||||||
|
|
||||||
|
(defn- organization-sso-callback-handler
|
||||||
|
"Handle the organization-SSO branch of the OIDC callback: state carries
|
||||||
|
:dest-url — exchange the authorization code with the OIDC provider to
|
||||||
|
verify authentication actually occurred, then redirect back to dest-url."
|
||||||
|
[cfg request state code]
|
||||||
|
(let [dest-url (:dest-url state)]
|
||||||
|
(try
|
||||||
|
(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 {:minutes 15})]
|
||||||
|
(when (and session organization-id)
|
||||||
|
(let [props (-> (or (:props session) {})
|
||||||
|
(update :sso assoc organization-id exp))]
|
||||||
|
(session/update-session (::session/manager cfg) (assoc session :props props))))
|
||||||
|
(submit-organization-sso-auth-event
|
||||||
|
cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded")
|
||||||
|
(redirect-response dest-url))
|
||||||
|
(catch Throwable cause
|
||||||
|
(let [{:keys [code]} (ex-data cause)]
|
||||||
|
(binding [l/*context* (errors/request->context request)]
|
||||||
|
(if (some? code)
|
||||||
|
(l/warn :hint "organization sso callback failed"
|
||||||
|
:code code
|
||||||
|
:message (ex-message cause)
|
||||||
|
:organization-id (:organization-id state))
|
||||||
|
(l/err :hint "unexpected error on organization sso callback"
|
||||||
|
:organization-id (:organization-id state)
|
||||||
|
:cause cause))))
|
||||||
|
(submit-organization-sso-auth-failed-event
|
||||||
|
cfg request (some-> (session/get-session request) :profile-id)
|
||||||
|
(:organization-id state) cause)
|
||||||
|
(let [organization-id (:organization-id state)
|
||||||
|
organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))]
|
||||||
|
(redirect-with-organization-sso-error
|
||||||
|
{:dest-url dest-url
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name organization-name}))))))
|
||||||
|
|
||||||
(defn- callback-handler
|
(defn- callback-handler
|
||||||
[cfg {:keys [params] :as request}]
|
[cfg {:keys [params] :as request}]
|
||||||
(if-let [error (get params :error)]
|
(if-let [error (get params :error)]
|
||||||
(redirect-with-error "unable-to-auth" error)
|
(do
|
||||||
|
(submit-organization-sso-oauth-failed-event cfg request (:state params) error)
|
||||||
|
(redirect-with-error "unable-to-auth" error))
|
||||||
(try
|
(try
|
||||||
(let [code (get params :code)
|
(let [code (get params :code)
|
||||||
state (get params :state)
|
state (get params :state)
|
||||||
@ -899,18 +1023,8 @@
|
|||||||
|
|
||||||
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||||
;; code with the OIDC provider to verify authentication actually occurred.
|
;; code with the OIDC provider to verify authentication actually occurred.
|
||||||
(if-let [dest-url (:dest-url state)]
|
(if (:dest-url state)
|
||||||
(let [organization-id (:organization-id state)
|
(organization-sso-callback-handler cfg request state code)
|
||||||
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 (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))]
|
|
||||||
(session/update-session (::session/manager cfg) (assoc session :props props))))
|
|
||||||
(redirect-response dest-url))
|
|
||||||
|
|
||||||
(let [provider (resolve-provider cfg state)
|
(let [provider (resolve-provider cfg state)
|
||||||
info (get-info cfg provider state code)
|
info (get-info cfg provider state code)
|
||||||
|
|||||||
53
backend/src/app/auth/passwords.clj
Normal file
53
backend/src/app/auth/passwords.clj
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.auth.passwords
|
||||||
|
"Password strength validation using Passay library."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex])
|
||||||
|
(:import
|
||||||
|
[org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData]))
|
||||||
|
|
||||||
|
(defonce ^:private passay-code->translation-key
|
||||||
|
{"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
|
||||||
|
"INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase"
|
||||||
|
"INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits"
|
||||||
|
"INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
|
||||||
|
|
||||||
|
(defonce ^:private character-characteristics-rule
|
||||||
|
(doto (CharacterCharacteristicsRule.)
|
||||||
|
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
|
||||||
|
(CharacterRule. EnglishCharacterData/UpperCase 1)
|
||||||
|
(CharacterRule. EnglishCharacterData/Digit 1)
|
||||||
|
(CharacterRule. EnglishCharacterData/Special 1)])
|
||||||
|
(.setNumberOfCharacteristics 4)))
|
||||||
|
|
||||||
|
(defn validate-password
|
||||||
|
"Validates password strength.
|
||||||
|
Returns nil if valid, or raises exception if invalid.
|
||||||
|
Checks:
|
||||||
|
- Minimum length of 8 characters
|
||||||
|
- At least 1 lowercase letter
|
||||||
|
- At least 1 uppercase letter
|
||||||
|
- At least 1 digit
|
||||||
|
- At least 1 special character"
|
||||||
|
[password]
|
||||||
|
(when (< (count password) 8)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :weak-password
|
||||||
|
:hint "password must be at least 8 characters"
|
||||||
|
:details ["errors.weak-password.too-short"]))
|
||||||
|
|
||||||
|
(let [password-data (PasswordData. password)
|
||||||
|
char-result (.validate character-characteristics-rule password-data)]
|
||||||
|
(when-not (.isValid char-result)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :weak-password
|
||||||
|
:hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
|
||||||
|
:details (->> (.getDetails char-result)
|
||||||
|
(mapv #(.getErrorCode %))
|
||||||
|
(mapv passay-code->translation-key)
|
||||||
|
(filterv some?))))))
|
||||||
@ -723,6 +723,7 @@
|
|||||||
(-> (select-keys file file-attrs)
|
(-> (select-keys file file-attrs)
|
||||||
(assoc :data nil)
|
(assoc :data nil)
|
||||||
(dissoc :team-id)
|
(dissoc :team-id)
|
||||||
|
(dissoc :metadata)
|
||||||
(dissoc :migrations)))
|
(dissoc :migrations)))
|
||||||
|
|
||||||
(defn- file->file-data-params
|
(defn- file->file-data-params
|
||||||
@ -748,9 +749,17 @@
|
|||||||
(fmigr/upsert-migrations! conn file))
|
(fmigr/upsert-migrations! conn file))
|
||||||
|
|
||||||
(let [file (encode-file cfg file)]
|
(let [file (encode-file cfg file)]
|
||||||
|
(try
|
||||||
(db/insert! conn :file
|
(db/insert! conn :file
|
||||||
(file->params file)
|
(file->params file)
|
||||||
(assoc opts ::db/return-keys false))
|
(assoc opts ::db/return-keys false))
|
||||||
|
(catch org.postgresql.util.PSQLException cause
|
||||||
|
(if (db/duplicate-key-error? cause)
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "file already exists"
|
||||||
|
:cause cause)
|
||||||
|
(throw cause))))
|
||||||
|
|
||||||
(->> (file->file-data-params file)
|
(->> (file->file-data-params file)
|
||||||
(fdata/upsert! cfg))
|
(fdata/upsert! cfg))
|
||||||
|
|||||||
@ -174,6 +174,10 @@
|
|||||||
(assert-mark m :obj)
|
(assert-mark m :obj)
|
||||||
(let [size (read-long! input)]
|
(let [size (read-long! input)]
|
||||||
(assert (pos? size) "incorrect header size found on reading header")
|
(assert (pos? size) "incorrect header size found on reading header")
|
||||||
|
(when (> size bfc/max-object-size)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :max-file-size-reached
|
||||||
|
:hint (dm/str "unable to import object with size " size " bytes")))
|
||||||
(let [buff (byte-array size)]
|
(let [buff (byte-array size)]
|
||||||
(read-bytes! input buff)
|
(read-bytes! input buff)
|
||||||
(fres/decode buff)))))
|
(fres/decode buff)))))
|
||||||
|
|||||||
@ -392,7 +392,7 @@
|
|||||||
params {:type "penpot/export-files"
|
params {:type "penpot/export-files"
|
||||||
:version 1
|
:version 1
|
||||||
:generated-by (str "penpot/" (:full cf/version))
|
:generated-by (str "penpot/" (:full cf/version))
|
||||||
:refer "penpot"
|
:referer "penpot"
|
||||||
:files (vec (vals files))
|
:files (vec (vals files))
|
||||||
:relations rels}]
|
:relations rels}]
|
||||||
(write-entry! output "manifest.json" params))))
|
(write-entry! output "manifest.json" params))))
|
||||||
@ -734,7 +734,7 @@
|
|||||||
:plugin-data plugin-data}))
|
:plugin-data plugin-data}))
|
||||||
|
|
||||||
(defn- import-file
|
(defn- import-file
|
||||||
[{:keys [::db/conn ::bfc/project-id] :as cfg} {file-id :id file-name :name}]
|
[{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} {file-id :id file-name :name}]
|
||||||
(let [file-id' (bfc/lookup-index file-id)
|
(let [file-id' (bfc/lookup-index file-id)
|
||||||
file (read-file cfg file-id)
|
file (read-file cfg file-id)
|
||||||
media (read-file-media cfg file-id)
|
media (read-file-media cfg file-id)
|
||||||
@ -801,8 +801,10 @@
|
|||||||
(assoc :data data)
|
(assoc :data data)
|
||||||
(assoc :name file-name)
|
(assoc :name file-name)
|
||||||
(assoc :project-id project-id)
|
(assoc :project-id project-id)
|
||||||
|
(assoc :metadata (d/without-nils
|
||||||
|
{:generated-by (get manifest :generated-by)
|
||||||
|
:referer (or (get manifest :referer) (get manifest :refer))}))
|
||||||
(dissoc :options))
|
(dissoc :options))
|
||||||
|
|
||||||
file (bfc/process-file cfg file)
|
file (bfc/process-file cfg file)
|
||||||
file (ctf/check-file file)]
|
file (ctf/check-file file)]
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
:redis-uri "redis://redis/0"
|
:redis-uri "redis://redis/0"
|
||||||
|
|
||||||
:file-data-backend "legacy-db"
|
:file-data-backend "db"
|
||||||
|
|
||||||
:objects-storage-backend "fs"
|
:objects-storage-backend "fs"
|
||||||
:objects-storage-fs-directory "assets"
|
:objects-storage-fs-directory "assets"
|
||||||
@ -119,8 +119,9 @@
|
|||||||
[:allowed-origins {:optional true} [::sm/set :string]]
|
[:allowed-origins {:optional true} [::sm/set :string]]
|
||||||
|
|
||||||
[:exporter-shared-key {:optional true} :string]
|
[:exporter-shared-key {:optional true} :string]
|
||||||
[:nitrate-shared-key {:optional true} :string]
|
[:admin-console-shared-key {:optional true} :string]
|
||||||
[:nexus-shared-key {:optional true} :string]
|
[:nexus-shared-key {:optional true} :string]
|
||||||
|
[:media-processor-shared-key {:optional true} :string]
|
||||||
[:management-api-key {:optional true} :string]
|
[:management-api-key {:optional true} :string]
|
||||||
|
|
||||||
[:telemetry-uri {:optional true} :string]
|
[:telemetry-uri {:optional true} :string]
|
||||||
@ -147,6 +148,9 @@
|
|||||||
[:imagemagick-width-limit {:optional true} :string]
|
[:imagemagick-width-limit {:optional true} :string]
|
||||||
[:imagemagick-height-limit {:optional true} :string]
|
[:imagemagick-height-limit {:optional true} :string]
|
||||||
|
|
||||||
|
[:media-processing-service-uri {:optional true} ::sm/uri]
|
||||||
|
[:media-processing-service-timeout {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:deletion-delay {:optional true} ::ct/duration]
|
[:deletion-delay {:optional true} ::ct/duration]
|
||||||
[:file-clean-delay {:optional true} ::ct/duration]
|
[:file-clean-delay {:optional true} ::ct/duration]
|
||||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||||
@ -264,7 +268,7 @@
|
|||||||
|
|
||||||
[:netty-io-threads {:optional true} ::sm/int]
|
[:netty-io-threads {:optional true} ::sm/int]
|
||||||
|
|
||||||
[:nitrate-backend-uri {:optional true} ::sm/uri]
|
[:admin-console-uri {:optional true} ::sm/uri]
|
||||||
|
|
||||||
;; DEPRECATED
|
;; DEPRECATED
|
||||||
[:assets-storage-backend {:optional true} :keyword]
|
[:assets-storage-backend {:optional true} :keyword]
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
|
[app.common.types.file :as ctf]
|
||||||
[app.common.types.objects-map :as omap]
|
[app.common.types.objects-map :as omap]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
@ -159,15 +160,17 @@
|
|||||||
:content-type "application/octet-stream"
|
:content-type "application/octet-stream"
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
:id id})
|
:id id})
|
||||||
metadata {:storage-ref-id (:id sobject)}
|
metadata (-> (:metadata params)
|
||||||
|
(assoc :storage-ref-id (:id sobject)))
|
||||||
params (-> params
|
params (-> params
|
||||||
(assoc :metadata metadata)
|
(assoc :metadata metadata)
|
||||||
(assoc :data nil))]
|
(assoc :data nil))]
|
||||||
(upsert-in-database cfg params))
|
(upsert-in-database cfg params))
|
||||||
|
|
||||||
(= backend "db")
|
(= backend "db")
|
||||||
(->> (dissoc params :metadata)
|
(let [metadata (dissoc (:metadata params) :storage-ref-id)
|
||||||
(upsert-in-database cfg))
|
params (assoc params :metadata metadata)]
|
||||||
|
(upsert-in-database cfg params))
|
||||||
|
|
||||||
(= backend "legacy-db")
|
(= backend "legacy-db")
|
||||||
(cond
|
(cond
|
||||||
@ -213,18 +216,11 @@
|
|||||||
[backend]
|
[backend]
|
||||||
(or backend (cf/get :file-data-backend)))
|
(or backend (cf/get :file-data-backend)))
|
||||||
|
|
||||||
(def ^:private schema:metadata
|
|
||||||
[:map {:title "Metadata"}
|
|
||||||
[:storage-ref-id {:optional true} ::sm/uuid]])
|
|
||||||
|
|
||||||
(def decode-metadata-with-schema
|
|
||||||
(sm/decoder schema:metadata sm/json-transformer))
|
|
||||||
|
|
||||||
(defn decode-metadata
|
(defn decode-metadata
|
||||||
[metadata]
|
[metadata]
|
||||||
(some-> metadata
|
(some-> metadata
|
||||||
(db/decode-json-pgobject)
|
(db/decode-json-pgobject)
|
||||||
(decode-metadata-with-schema)))
|
(ctf/decode-file-metadata)))
|
||||||
|
|
||||||
(def ^:private schema:update-params
|
(def ^:private schema:update-params
|
||||||
[:map {:closed true}
|
[:map {:closed true}
|
||||||
@ -232,7 +228,7 @@
|
|||||||
[:type [:enum "main" "snapshot" "fragment"]]
|
[:type [:enum "main" "snapshot" "fragment"]]
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
[:backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
||||||
[:metadata {:optional true} [:maybe schema:metadata]]
|
[:metadata {:optional true} ctf/schema:file-metadata]
|
||||||
[:data {:optional true} bytes?]
|
[:data {:optional true} bytes?]
|
||||||
[:created-at {:optional true} ::ct/inst]
|
[:created-at {:optional true} ::ct/inst]
|
||||||
[:modified-at {:optional true} [:maybe ::ct/inst]]
|
[:modified-at {:optional true} [:maybe ::ct/inst]]
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.http.assets
|
(ns app.http.assets
|
||||||
"Assets related handlers."
|
"Assets related handlers."
|
||||||
(:require
|
(:require
|
||||||
|
[app.binfile.common :as bfc]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
@ -42,18 +43,30 @@
|
|||||||
|
|
||||||
(defn- get-file-media-object
|
(defn- get-file-media-object
|
||||||
[pool id]
|
[pool id]
|
||||||
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||||
|
|
||||||
(defn- serve-object-from-s3
|
(defn- serve-object-from-s3
|
||||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||||
cch-max-age (or cache-max-age default-cache-max-age)
|
cch-max-age (or cache-max-age default-cache-max-age)
|
||||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
|
bucket (-> obj meta :bucket)
|
||||||
{::yres/status 307
|
public? (contains? public-buckets bucket)
|
||||||
::yres/headers {"location" (str url)
|
;; The disposition is also signed into the presigned url: this
|
||||||
|
;; response is a redirect, so the header below applies to the
|
||||||
|
;; redirect itself and not to the bytes the client then fetches
|
||||||
|
;; from the object store.
|
||||||
|
{:keys [host port] :as url} (sto/get-object-url storage obj
|
||||||
|
(cond-> {:max-age sig-max-age}
|
||||||
|
(not public?)
|
||||||
|
(assoc :content-disposition "attachment")))
|
||||||
|
headers (cond-> {"location" (str url)
|
||||||
"x-host" (cond-> host port (str ":" port))
|
"x-host" (cond-> host port (str ":" port))
|
||||||
"x-mtype" (-> obj meta :content-type)
|
"x-mtype" (-> obj meta :content-type)
|
||||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||||
|
(not public?)
|
||||||
|
(assoc "content-disposition" "attachment"))]
|
||||||
|
{::yres/status 307
|
||||||
|
::yres/headers headers}))
|
||||||
|
|
||||||
(defn- serve-object-from-fs
|
(defn- serve-object-from-fs
|
||||||
[{:keys [::path ::cache-max-age]} obj]
|
[{:keys [::path ::cache-max-age]} obj]
|
||||||
@ -61,9 +74,12 @@
|
|||||||
purl (u/join (u/uri path)
|
purl (u/join (u/uri path)
|
||||||
(sto/object->relative-path obj))
|
(sto/object->relative-path obj))
|
||||||
mdata (meta obj)
|
mdata (meta obj)
|
||||||
headers {"x-accel-redirect" (:path purl)
|
bucket (:bucket mdata)
|
||||||
|
headers (cond-> {"x-accel-redirect" (:path purl)
|
||||||
"content-type" (:content-type mdata)
|
"content-type" (:content-type mdata)
|
||||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||||
|
(not (contains? public-buckets bucket))
|
||||||
|
(assoc "content-disposition" "attachment"))]
|
||||||
{::yres/status 204
|
{::yres/status 204
|
||||||
::yres/headers headers}))
|
::yres/headers headers}))
|
||||||
|
|
||||||
@ -111,11 +127,19 @@
|
|||||||
[{:keys [::sto/storage] :as cfg} request kf]
|
[{:keys [::sto/storage] :as cfg} request kf]
|
||||||
(let [pool (::db/pool storage)
|
(let [pool (::db/pool storage)
|
||||||
id (get-id request)
|
id (get-id request)
|
||||||
mobj (get-file-media-object pool id)
|
mobj (get-file-media-object pool id)]
|
||||||
sobj (sto/get-object storage (kf mobj))]
|
(if (nil? mobj)
|
||||||
|
{::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
|
(if sobj
|
||||||
(serve-object cfg sobj)
|
(serve-object cfg sobj)
|
||||||
{::yres/status 404})))
|
{::yres/status 404})))))))
|
||||||
|
|
||||||
(defn file-objects-handler
|
(defn file-objects-handler
|
||||||
"Handler that serves storage objects by file media id."
|
"Handler that serves storage objects by file media id."
|
||||||
|
|||||||
@ -34,6 +34,12 @@
|
|||||||
(assoc :request/auth-data (dissoc auth :token))
|
(assoc :request/auth-data (dissoc auth :token))
|
||||||
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||||
|
|
||||||
|
(defn- strip-internal-fields
|
||||||
|
"Remove fields that leak internal implementation details from error
|
||||||
|
response data. Full context is preserved in server-side logs."
|
||||||
|
[data]
|
||||||
|
(dissoc data :state :path :context))
|
||||||
|
|
||||||
(defmulti handle-error
|
(defmulti handle-error
|
||||||
(fn [cause _ _]
|
(fn [cause _ _]
|
||||||
(-> cause ex-data :type)))
|
(-> cause ex-data :type)))
|
||||||
@ -136,6 +142,7 @@
|
|||||||
(l/error :hint "assertion error" :cause cause)
|
(l/error :hint "assertion error" :cause cause)
|
||||||
{::yres/status 500
|
{::yres/status 500
|
||||||
::yres/body (-> data
|
::yres/body (-> data
|
||||||
|
(strip-internal-fields)
|
||||||
(assoc :type :server-error)
|
(assoc :type :server-error)
|
||||||
(assoc :code :assertion))})))))
|
(assoc :code :assertion))})))))
|
||||||
|
|
||||||
@ -161,9 +168,9 @@
|
|||||||
(l/error :hint "internal error" :cause cause)
|
(l/error :hint "internal error" :cause cause)
|
||||||
{::yres/status 500
|
{::yres/status 500
|
||||||
::yres/body (-> data
|
::yres/body (-> data
|
||||||
|
(strip-internal-fields)
|
||||||
(assoc :type :server-error)
|
(assoc :type :server-error)
|
||||||
(update :code #(or % :unhandled))
|
(update :code #(or % :unhandled)))})))
|
||||||
(assoc :hint (ex-message error)))})))
|
|
||||||
|
|
||||||
(defmethod handle-error :default
|
(defmethod handle-error :default
|
||||||
[error request parent-cause]
|
[error request parent-cause]
|
||||||
@ -178,6 +185,20 @@
|
|||||||
(handle-exception (:handling edata) request error)
|
(handle-exception (:handling edata) request error)
|
||||||
(handle-exception error request parent-cause))))
|
(handle-exception error request parent-cause))))
|
||||||
|
|
||||||
|
(defn- pgsql-state->message
|
||||||
|
"Map PostgreSQL SQLSTATE codes to safe, client-facing messages.
|
||||||
|
Returns a user-friendly string that conveys the nature of the error
|
||||||
|
without exposing table names, constraint names, or other internals."
|
||||||
|
[state]
|
||||||
|
(case state
|
||||||
|
"23505" "A conflicting entry already exists"
|
||||||
|
"23503" "The referenced item does not exist"
|
||||||
|
"23502" "A required field is missing"
|
||||||
|
"23514" "The value violates a data integrity constraint"
|
||||||
|
"57014" "The operation took too long and was cancelled"
|
||||||
|
"25P03" "The transaction was idle too long and was cancelled"
|
||||||
|
"A database error occurred"))
|
||||||
|
|
||||||
(defmethod handle-exception org.postgresql.util.PSQLException
|
(defmethod handle-exception org.postgresql.util.PSQLException
|
||||||
[error request parent-cause]
|
[error request parent-cause]
|
||||||
(let [state (.getSQLState ^java.sql.SQLException error)
|
(let [state (.getSQLState ^java.sql.SQLException error)
|
||||||
@ -190,20 +211,19 @@
|
|||||||
{::yres/status 504
|
{::yres/status 504
|
||||||
::yres/body {:type :server-error
|
::yres/body {:type :server-error
|
||||||
:code :statement-timeout
|
:code :statement-timeout
|
||||||
:hint (ex-message error)}}
|
:hint (pgsql-state->message state)}}
|
||||||
|
|
||||||
(= state "25P03")
|
(= state "25P03")
|
||||||
{::yres/status 504
|
{::yres/status 504
|
||||||
::yres/body {:type :server-error
|
::yres/body {:type :server-error
|
||||||
:code :idle-in-transaction-timeout
|
:code :idle-in-transaction-timeout
|
||||||
:hint (ex-message error)}}
|
:hint (pgsql-state->message state)}}
|
||||||
|
|
||||||
:else
|
:else
|
||||||
{::yres/status 500
|
{::yres/status 500
|
||||||
::yres/body {:type :server-error
|
::yres/body {:type :server-error
|
||||||
:code :unexpected
|
:code :database-error
|
||||||
:hint (ex-message error)
|
:hint (pgsql-state->message state)}}))))
|
||||||
:state state}}))))
|
|
||||||
|
|
||||||
(defmethod handle-exception :default
|
(defmethod handle-exception :default
|
||||||
[error request parent-cause]
|
[error request parent-cause]
|
||||||
@ -216,17 +236,16 @@
|
|||||||
(l/error :hint "unexpected error" :cause cause)
|
(l/error :hint "unexpected error" :cause cause)
|
||||||
{::yres/status 500
|
{::yres/status 500
|
||||||
::yres/body {:type :server-error
|
::yres/body {:type :server-error
|
||||||
:code :unexpected
|
:code :unexpected}})
|
||||||
:hint (ex-message error)}})
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(binding [l/*context* (request->context request)]
|
(binding [l/*context* (request->context request)]
|
||||||
(l/error :hint "unhandled error" :cause cause)
|
(l/error :hint "unhandled error" :cause cause)
|
||||||
{::yres/status 500
|
{::yres/status 500
|
||||||
::yres/body (-> edata
|
::yres/body (-> edata
|
||||||
|
(strip-internal-fields)
|
||||||
(assoc :type :server-error)
|
(assoc :type :server-error)
|
||||||
(update :code #(or % :unhandled))
|
(update :code #(or % :unhandled)))}))))
|
||||||
(assoc :hint (ex-message error)))}))))
|
|
||||||
|
|
||||||
(defmethod handle-exception java.io.IOException
|
(defmethod handle-exception java.io.IOException
|
||||||
[cause request _]
|
[cause request _]
|
||||||
@ -234,9 +253,7 @@
|
|||||||
(l/wrn :hint "io exception" :cause cause)
|
(l/wrn :hint "io exception" :cause cause)
|
||||||
{::yres/status 500
|
{::yres/status 500
|
||||||
::yres/body {:type :server-error
|
::yres/body {:type :server-error
|
||||||
:code :io-exception
|
:code :io-exception}}))
|
||||||
:hint (ex-message cause)
|
|
||||||
:path (:path request)}}))
|
|
||||||
|
|
||||||
(defmethod handle-exception java.util.concurrent.CompletionException
|
(defmethod handle-exception java.util.concurrent.CompletionException
|
||||||
[cause request _]
|
[cause request _]
|
||||||
|
|||||||
@ -24,7 +24,8 @@
|
|||||||
(:import
|
(:import
|
||||||
io.undertow.server.RequestTooBigException
|
io.undertow.server.RequestTooBigException
|
||||||
java.io.InputStream
|
java.io.InputStream
|
||||||
java.io.OutputStream))
|
java.io.OutputStream
|
||||||
|
java.security.MessageDigest))
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
(set! *warn-on-reflection* true)
|
||||||
|
|
||||||
@ -82,18 +83,18 @@
|
|||||||
(instance? IllegalArgumentException cause)
|
(instance? IllegalArgumentException cause)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :malformed-json
|
:code :malformed-json
|
||||||
:hint (ex-message cause)
|
:hint "invalid JSON in request body"
|
||||||
:cause cause)
|
:cause cause)
|
||||||
|
|
||||||
(instance? RequestTooBigException cause)
|
(instance? RequestTooBigException cause)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :request-body-too-large
|
:code :request-body-too-large
|
||||||
:hint (ex-message cause))
|
:hint "request body exceeds size limit")
|
||||||
|
|
||||||
(instance? java.io.EOFException cause)
|
(instance? java.io.EOFException cause)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :malformed-json
|
:code :malformed-json
|
||||||
:hint (ex-message cause)
|
:hint "unexpected end of request body"
|
||||||
:cause cause)
|
:cause cause)
|
||||||
|
|
||||||
(instance? RuntimeException cause)
|
(instance? RuntimeException cause)
|
||||||
@ -329,6 +330,11 @@
|
|||||||
{:name ::auth
|
{:name ::auth
|
||||||
:compile (constantly wrap-auth)})
|
:compile (constantly wrap-auth)})
|
||||||
|
|
||||||
|
(defn- constant-time-eq?
|
||||||
|
"Compare strings in constant time to prevent timing attacks."
|
||||||
|
[^String a ^String b]
|
||||||
|
(MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8")))
|
||||||
|
|
||||||
(defn- wrap-shared-key-auth
|
(defn- wrap-shared-key-auth
|
||||||
[handler keys]
|
[handler keys]
|
||||||
(if (seq keys)
|
(if (seq keys)
|
||||||
@ -338,7 +344,7 @@
|
|||||||
(let [key-id (-> key-id str/lower keyword)]
|
(let [key-id (-> key-id str/lower keyword)]
|
||||||
(if (and (string? key)
|
(if (and (string? key)
|
||||||
(contains? keys key-id)
|
(contains? keys key-id)
|
||||||
(= key (get keys key-id)))
|
(constant-time-eq? key (get keys key-id)))
|
||||||
(-> request
|
(-> request
|
||||||
(assoc ::http/auth-key-id key-id)
|
(assoc ::http/auth-key-id key-id)
|
||||||
(handler))
|
(handler))
|
||||||
|
|||||||
@ -204,7 +204,7 @@
|
|||||||
[{:keys [::manager]}]
|
[{:keys [::manager]}]
|
||||||
(assert (manager? manager) "expected valid session manager")
|
(assert (manager? manager) "expected valid session manager")
|
||||||
(fn [request response]
|
(fn [request response]
|
||||||
(some->> (get request ::id) (delete-session manager))
|
(some->> (get request ::session) :id (delete-session manager))
|
||||||
(clear-session-cookie response)))
|
(clear-session-cookie response)))
|
||||||
|
|
||||||
(defn decode-token
|
(defn decode-token
|
||||||
@ -226,6 +226,14 @@
|
|||||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||||
(db/get-update-count))))
|
(db/get-update-count))))
|
||||||
|
|
||||||
|
(defn invalidate-all
|
||||||
|
"Delete all sessions for a given profile. Used when a profile is deleted
|
||||||
|
to ensure immediate access revocation across all devices."
|
||||||
|
[cfg profile-id]
|
||||||
|
(let [sql "delete from http_session_v2 where profile_id = ?"]
|
||||||
|
(-> (db/exec-one! cfg [sql profile-id])
|
||||||
|
(db/get-update-count))))
|
||||||
|
|
||||||
(def ^:private sql:clear-organization-sso-sessions
|
(def ^:private sql:clear-organization-sso-sessions
|
||||||
(str "UPDATE http_session_v2 "
|
(str "UPDATE http_session_v2 "
|
||||||
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.http.websocket
|
(ns app.http.websocket
|
||||||
"A penpot notification service for file cooperative edition."
|
"A penpot notification service for file cooperative edition."
|
||||||
(:require
|
(:require
|
||||||
|
[app.binfile.common :as bfc]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.pprint :as pp]
|
[app.common.pprint :as pp]
|
||||||
@ -17,6 +18,8 @@
|
|||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
[app.metrics :as mtx]
|
[app.metrics :as mtx]
|
||||||
[app.msgbus :as mbus]
|
[app.msgbus :as mbus]
|
||||||
|
[app.rpc.commands.files :as files]
|
||||||
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.util.websocket :as ws]
|
[app.util.websocket :as ws]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[promesa.exec.csp :as sp]
|
[promesa.exec.csp :as sp]
|
||||||
@ -131,8 +134,9 @@
|
|||||||
(mbus/pub! msgbus :topic topic :message msg))))
|
(mbus/pub! msgbus :topic topic :message msg))))
|
||||||
|
|
||||||
(defmethod handle-message :subscribe-team
|
(defmethod handle-message :subscribe-team
|
||||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
|
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
|
||||||
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
|
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
|
||||||
|
(teams/check-read-permissions! pool profile-id team-id)
|
||||||
(let [prev-subs (get @state ::team-subscription)
|
(let [prev-subs (get @state ::team-subscription)
|
||||||
channel (sp/chan :buf (sp/dropping-buffer 64)
|
channel (sp/chan :buf (sp/dropping-buffer 64)
|
||||||
:xf (remove #(= (:session-id %) session-id)))]
|
:xf (remove #(= (:session-id %) session-id)))]
|
||||||
@ -150,8 +154,10 @@
|
|||||||
|
|
||||||
|
|
||||||
(defmethod handle-message :subscribe-file
|
(defmethod handle-message :subscribe-file
|
||||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||||
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
|
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
|
||||||
|
(bfc/check-file-exists pool file-id)
|
||||||
|
(files/check-read-permissions! pool profile-id file-id)
|
||||||
(let [psub (::file-subscription @state)
|
(let [psub (::file-subscription @state)
|
||||||
fch (sp/chan :buf (sp/dropping-buffer 64)
|
fch (sp/chan :buf (sp/dropping-buffer 64)
|
||||||
:xf (remove #(= (:session-id %) session-id)))]
|
:xf (remove #(= (:session-id %) session-id)))]
|
||||||
|
|||||||
@ -36,6 +36,16 @@
|
|||||||
(def ^:private filter-auth-events
|
(def ^:private filter-auth-events
|
||||||
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
|
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
|
||||||
|
|
||||||
|
(def ^:private organization-sso-failure-reasons
|
||||||
|
#{"access-denied"
|
||||||
|
"provider-unavailable"
|
||||||
|
"invalid-configuration"
|
||||||
|
"provider-error"
|
||||||
|
"token-exchange-failed"
|
||||||
|
"user-info-failed"
|
||||||
|
"incomplete-user-info"
|
||||||
|
"unexpected-error"})
|
||||||
|
|
||||||
(def ^:private safe-backend-context-keys
|
(def ^:private safe-backend-context-keys
|
||||||
#{:version
|
#{:version
|
||||||
:initiator
|
:initiator
|
||||||
@ -297,6 +307,14 @@
|
|||||||
(defn filter-telemetry-props
|
(defn filter-telemetry-props
|
||||||
[{:keys [source name props type] :as params}]
|
[{:keys [source name props type] :as params}]
|
||||||
(cond
|
(cond
|
||||||
|
(and (= source "backend")
|
||||||
|
(= name "organization-sso-auth-failed"))
|
||||||
|
(let [props' (into {} xf:filter-telemetry-props props)
|
||||||
|
props' (cond-> props'
|
||||||
|
(contains? organization-sso-failure-reasons (:failure-reason props))
|
||||||
|
(assoc :failure-reason (:failure-reason props)))]
|
||||||
|
(assoc params :props props'))
|
||||||
|
|
||||||
(or (and (= source "frontend")
|
(or (and (= source "frontend")
|
||||||
(= type "identify"))
|
(= type "identify"))
|
||||||
(and (= source "backend")
|
(and (= source "backend")
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.loggers.mattermost
|
(ns app.loggers.mattermost
|
||||||
"A mattermost integration for error reporting."
|
"A mattermost integration for error reporting."
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.pprint :as pp]
|
[app.common.pprint :as pp]
|
||||||
@ -25,7 +26,7 @@
|
|||||||
(defn- send-mattermost-notification!
|
(defn- send-mattermost-notification!
|
||||||
[cfg {:keys [id] :as report}]
|
[cfg {:keys [id] :as report}]
|
||||||
(let [type (get report :type)
|
(let [type (get report :type)
|
||||||
text (str "#" type " | " (get report :hint) "\n"
|
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
|
||||||
(when id
|
(when id
|
||||||
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
||||||
|
|
||||||
@ -38,7 +39,7 @@
|
|||||||
"- tenant: #" (:tenant report) "\n"
|
"- tenant: #" (:tenant report) "\n"
|
||||||
"- origin: #" (:origin report) "\n"
|
"- origin: #" (:origin report) "\n"
|
||||||
(when-let [href (get report :href)]
|
(when-let [href (get report :href)]
|
||||||
(str "- href: `" href "`\n"))
|
(str "- href: `" (d/escape-markdown href) "`\n"))
|
||||||
(when-let [version (get report :frontend-version)]
|
(when-let [version (get report :frontend-version)]
|
||||||
(str "- frontend-version: `" version "`\n"))
|
(str "- frontend-version: `" version "`\n"))
|
||||||
(when-let [version (get report :backend-version)]
|
(when-let [version (get report :backend-version)]
|
||||||
|
|||||||
@ -335,6 +335,7 @@
|
|||||||
::rpc/rlimit (ig/ref ::rpc/rlimit)
|
::rpc/rlimit (ig/ref ::rpc/rlimit)
|
||||||
::setup/templates (ig/ref ::setup/templates)
|
::setup/templates (ig/ref ::setup/templates)
|
||||||
::setup/props (ig/ref ::setup/props)
|
::setup/props (ig/ref ::setup/props)
|
||||||
|
::setup/shared-keys (ig/ref ::setup/shared-keys)
|
||||||
|
|
||||||
::email/blacklist (ig/ref ::email/blacklist)
|
::email/blacklist (ig/ref ::email/blacklist)
|
||||||
::email/whitelist (ig/ref ::email/whitelist)
|
::email/whitelist (ig/ref ::email/whitelist)
|
||||||
@ -469,8 +470,9 @@
|
|||||||
::setup/shared-keys
|
::setup/shared-keys
|
||||||
{::setup/props (ig/ref ::setup/props)
|
{::setup/props (ig/ref ::setup/props)
|
||||||
:nexus (cf/get :nexus-shared-key)
|
:nexus (cf/get :nexus-shared-key)
|
||||||
:nitrate (cf/get :nitrate-shared-key)
|
:admin-console (cf/get :admin-console-shared-key)
|
||||||
:exporter (cf/get :exporter-shared-key)}
|
:exporter (cf/get :exporter-shared-key)
|
||||||
|
:media-processor (cf/get :media-processor-shared-key)}
|
||||||
|
|
||||||
::setup/clock
|
::setup/clock
|
||||||
{}
|
{}
|
||||||
|
|||||||
@ -5,316 +5,37 @@
|
|||||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
(ns app.media
|
(ns app.media
|
||||||
"Media & Font postprocessing."
|
"Media & Font postprocessing.
|
||||||
|
|
||||||
|
This namespace is the dispatch layer only. Processing implementations
|
||||||
|
live in two separate namespaces, each owning their own defmulti:
|
||||||
|
|
||||||
|
app.media.local — shell/ImageMagick/FontForge implementations
|
||||||
|
app.media.remote — HTTP delegation to media-processor service
|
||||||
|
|
||||||
|
Validation and schemas live in app.media.validation (leaf namespace,
|
||||||
|
no circular dep). When adding a new :cmd type, add defmethods in
|
||||||
|
BOTH local and remote."
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as l]
|
|
||||||
[app.common.media :as cm]
|
|
||||||
[app.common.schema :as sm]
|
|
||||||
[app.common.schema.openapi :as-alias oapi]
|
|
||||||
[app.common.time :as ct]
|
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as-alias db]
|
[app.db :as-alias db]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
|
[app.media.local :as media.local]
|
||||||
|
[app.media.remote :as media.remote]
|
||||||
[app.media.sanitize :as sanitize]
|
[app.media.sanitize :as sanitize]
|
||||||
|
[app.media.validation :as validation]
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
[app.util.shell :as shell]
|
|
||||||
[buddy.core.bytes :as bb]
|
|
||||||
[buddy.core.codecs :as bc]
|
|
||||||
[clojure.string]
|
|
||||||
[clojure.xml :as xml]
|
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[datoteka.fs :as fs]
|
[datoteka.io :as io]))
|
||||||
[datoteka.io :as io])
|
|
||||||
(:import
|
|
||||||
clojure.lang.XMLHandler
|
|
||||||
java.io.InputStream
|
|
||||||
javax.xml.parsers.SAXParserFactory
|
|
||||||
javax.xml.XMLConstants
|
|
||||||
org.apache.commons.io.IOUtils))
|
|
||||||
|
|
||||||
(def schema:upload
|
|
||||||
[:map {:title "Upload"}
|
|
||||||
[:filename :string]
|
|
||||||
[:size ::sm/int]
|
|
||||||
[:path ::fs/path]
|
|
||||||
[:mtype {:optional true} :string]
|
|
||||||
[:headers {:optional true}
|
|
||||||
[:map-of :string :string]]])
|
|
||||||
|
|
||||||
(def ^:private schema:input
|
|
||||||
[:map {:title "Input"}
|
|
||||||
[:path ::fs/path]
|
|
||||||
[:mtype {:optional true} ::sm/text]])
|
|
||||||
|
|
||||||
(def check-input
|
|
||||||
(sm/check-fn schema:input))
|
|
||||||
|
|
||||||
(defn validate-media-type!
|
|
||||||
([upload] (validate-media-type! upload cm/image-types))
|
|
||||||
([upload allowed]
|
|
||||||
(when-not (contains? allowed (:mtype upload))
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :media-type-not-allowed
|
|
||||||
:hint "Seems like you are uploading an invalid media object"))
|
|
||||||
|
|
||||||
upload))
|
|
||||||
|
|
||||||
(defn validate-media-size!
|
|
||||||
[upload]
|
|
||||||
(let [max-size (cf/get :media-max-file-size)]
|
|
||||||
(when (> (:size upload) max-size)
|
|
||||||
(ex/raise :type :restriction
|
|
||||||
:code :media-max-file-size-reached
|
|
||||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
|
||||||
(:size upload)
|
|
||||||
max-size)))
|
|
||||||
upload))
|
|
||||||
|
|
||||||
(defn validate-font-size!
|
|
||||||
"Validates that the font file `upload` does not exceed the configured
|
|
||||||
`:font-max-file-size` limit. Accepts the same map shape as
|
|
||||||
`validate-media-size!` — requires a `:size` key in bytes."
|
|
||||||
[upload]
|
|
||||||
(let [max-size (cf/get :font-max-file-size)]
|
|
||||||
(when (> (:size upload) max-size)
|
|
||||||
(ex/raise :type :restriction
|
|
||||||
:code :font-max-file-size-reached
|
|
||||||
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
|
||||||
(:size upload)
|
|
||||||
max-size)))
|
|
||||||
upload))
|
|
||||||
|
|
||||||
(defmulti process (fn [_system params] (:cmd params)))
|
|
||||||
|
|
||||||
(defmethod process :default
|
|
||||||
[_system {:keys [cmd] :as params}]
|
|
||||||
(ex/raise :type :internal
|
|
||||||
:code :not-implemented
|
|
||||||
:hint (str/fmt "No impl found for process cmd: %s" cmd)))
|
|
||||||
|
|
||||||
(defn run
|
(defn run
|
||||||
[system params]
|
[system params]
|
||||||
(process system params))
|
(if (contains? cf/flags :remote-media-processing)
|
||||||
|
(media.remote/process system params)
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
(media.local/process system params)))
|
||||||
;; SVG PARSING
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(defn- secure-parser-factory
|
|
||||||
[^InputStream input ^XMLHandler handler]
|
|
||||||
(.. (doto (SAXParserFactory/newInstance)
|
|
||||||
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
|
||||||
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
|
||||||
(newSAXParser)
|
|
||||||
(parse input handler)))
|
|
||||||
|
|
||||||
(defn- strip-doctype
|
|
||||||
[data]
|
|
||||||
(cond-> data
|
|
||||||
(str/includes? data "<!DOCTYPE")
|
|
||||||
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
|
||||||
|
|
||||||
(defn- parse-svg
|
|
||||||
[text]
|
|
||||||
(let [text (strip-doctype text)]
|
|
||||||
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
|
||||||
(xml/parse istream secure-parser-factory))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
;; IMAGE THUMBNAILS
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(def ^:private schema:thumbnail-params
|
|
||||||
[:map {:title "ThumbnailParams"}
|
|
||||||
[:input schema:input]
|
|
||||||
[:format [:enum :jpeg :webp :png]]
|
|
||||||
[:quality [:int {:min 1 :max 100}]]
|
|
||||||
[:width :int]
|
|
||||||
[:height :int]])
|
|
||||||
|
|
||||||
(def ^:private check-thumbnail-params
|
|
||||||
(sm/check-fn schema:thumbnail-params))
|
|
||||||
|
|
||||||
;; Related info on how thumbnails generation
|
|
||||||
;; http://www.imagemagick.org/Usage/thumbnails/
|
|
||||||
|
|
||||||
(def ^:private imagemagick-default-env
|
|
||||||
"Default environment variables for ImageMagick resource limits.
|
|
||||||
These are the soft ceiling — policy.xml is the hard ceiling."
|
|
||||||
{"MAGICK_THREAD_LIMIT" "2"
|
|
||||||
"MAGICK_MEMORY_LIMIT" "256MiB"
|
|
||||||
"MAGICK_MAP_LIMIT" "512MiB"
|
|
||||||
"MAGICK_AREA_LIMIT" "128MP"
|
|
||||||
"MAGICK_DISK_LIMIT" "1GiB"
|
|
||||||
"MAGICK_TIME_LIMIT" "30"})
|
|
||||||
|
|
||||||
(defn- get-imagemagick-env
|
|
||||||
"Returns environment variables for ImageMagick commands.
|
|
||||||
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
|
|
||||||
[]
|
|
||||||
(let [thread (cf/get :imagemagick-thread-limit)
|
|
||||||
memory (cf/get :imagemagick-memory-limit)
|
|
||||||
map-l (cf/get :imagemagick-map-limit)
|
|
||||||
area (cf/get :imagemagick-area-limit)
|
|
||||||
disk (cf/get :imagemagick-disk-limit)
|
|
||||||
time (cf/get :imagemagick-time-limit)
|
|
||||||
width (cf/get :imagemagick-width-limit)
|
|
||||||
height (cf/get :imagemagick-height-limit)]
|
|
||||||
(cond-> imagemagick-default-env
|
|
||||||
thread (assoc "MAGICK_THREAD_LIMIT" thread)
|
|
||||||
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
|
|
||||||
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
|
|
||||||
area (assoc "MAGICK_AREA_LIMIT" area)
|
|
||||||
disk (assoc "MAGICK_DISK_LIMIT" disk)
|
|
||||||
time (assoc "MAGICK_TIME_LIMIT" time)
|
|
||||||
width (assoc "MAGICK_WIDTH_LIMIT" width)
|
|
||||||
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
|
|
||||||
|
|
||||||
(defn- exec-magick!
|
|
||||||
"Execute an ImageMagick command with resource limits.
|
|
||||||
`args` is a vector of string arguments to pass to `magick`."
|
|
||||||
[system args]
|
|
||||||
(let [cmd (into ["magick"] args)
|
|
||||||
result (shell/exec! system
|
|
||||||
:cmd cmd
|
|
||||||
:env (get-imagemagick-env)
|
|
||||||
:timeout 60)]
|
|
||||||
(when (not= 0 (:exit result))
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint (str "ImageMagick command failed: " (:err result))
|
|
||||||
:cmd cmd
|
|
||||||
:exit (:exit result)))
|
|
||||||
result))
|
|
||||||
|
|
||||||
(defn- generic-process
|
|
||||||
[system {:keys [input format convert-args] :as params}]
|
|
||||||
(let [{:keys [path mtype]} input
|
|
||||||
format (or format (cm/mtype->format mtype))
|
|
||||||
ext (cm/format->extension format)
|
|
||||||
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
|
||||||
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
|
|
||||||
(exec-magick! system args)
|
|
||||||
(assoc params
|
|
||||||
:format format
|
|
||||||
:mtype (cm/format->mtype format)
|
|
||||||
:size (fs/size tmp)
|
|
||||||
:data tmp)))
|
|
||||||
|
|
||||||
(defmethod process :generic-thumbnail
|
|
||||||
[system params]
|
|
||||||
(let [{:keys [quality width height] :as params}
|
|
||||||
(check-thumbnail-params params)]
|
|
||||||
(generic-process system
|
|
||||||
(assoc params
|
|
||||||
:convert-args ["-auto-orient" "-strip"
|
|
||||||
"-thumbnail" (str width "x" height ">")
|
|
||||||
"-quality" (str quality)]))))
|
|
||||||
|
|
||||||
(defmethod process :profile-thumbnail
|
|
||||||
[system params]
|
|
||||||
(let [{:keys [quality width height] :as params}
|
|
||||||
(check-thumbnail-params params)]
|
|
||||||
(generic-process system
|
|
||||||
(assoc params
|
|
||||||
:convert-args ["-auto-orient" "-strip"
|
|
||||||
"-thumbnail" (str width "x" height "^")
|
|
||||||
"-gravity" "center"
|
|
||||||
"-extent" (str width "x" height)
|
|
||||||
"-quality" (str quality)]))))
|
|
||||||
|
|
||||||
(defn get-basic-info-from-svg
|
|
||||||
[{:keys [tag attrs] :as data}]
|
|
||||||
(when (not= tag :svg)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :unable-to-parse-svg
|
|
||||||
:hint "uploaded svg has invalid content"))
|
|
||||||
(reduce (fn [default f]
|
|
||||||
(if-let [res (f attrs)]
|
|
||||||
(reduced res)
|
|
||||||
default))
|
|
||||||
{:width 100 :height 100}
|
|
||||||
[(fn parse-width-and-height
|
|
||||||
[{:keys [width height]}]
|
|
||||||
(when (and (string? width)
|
|
||||||
(string? height))
|
|
||||||
(let [width (d/parse-double width)
|
|
||||||
height (d/parse-double height)]
|
|
||||||
(when (and width height)
|
|
||||||
{:width (int width)
|
|
||||||
:height (int height)}))))
|
|
||||||
(fn parse-viewbox
|
|
||||||
[{:keys [viewBox]}]
|
|
||||||
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
|
||||||
(map d/parse-double))]
|
|
||||||
(when (and x y width height)
|
|
||||||
{:width (int width)
|
|
||||||
:height (int height)})))]))
|
|
||||||
|
|
||||||
(defn- get-dimensions-with-orientation [system ^String path]
|
|
||||||
;; Image magick doesn't give info about exif rotation so we use the identify command
|
|
||||||
;; If we are processing an animated gif we use the first frame with -scene 0
|
|
||||||
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
|
|
||||||
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
|
|
||||||
(when (= 0 (:exit dim-result))
|
|
||||||
(let [[w h] (-> (:out dim-result)
|
|
||||||
str/trim
|
|
||||||
(clojure.string/split #"\s+")
|
|
||||||
(->> (mapv #(Integer/parseInt %))))
|
|
||||||
orientation-exit (:exit orient-result)
|
|
||||||
orientation (-> orient-result :out str/trim)]
|
|
||||||
(if (= 0 orientation-exit)
|
|
||||||
(case orientation
|
|
||||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
|
||||||
{:width w :height h}) ; Normal or unknown orientation
|
|
||||||
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
|
||||||
|
|
||||||
(defmethod process :info
|
|
||||||
[system {:keys [input] :as params}]
|
|
||||||
(let [{:keys [path mtype] :as input} (check-input input)]
|
|
||||||
(if (= mtype "image/svg+xml")
|
|
||||||
(let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
|
|
||||||
(when-not info
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-svg-file
|
|
||||||
:hint "uploaded svg does not provides dimensions"))
|
|
||||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
|
||||||
|
|
||||||
(let [path-str (str path)
|
|
||||||
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
|
|
||||||
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
|
||||||
mtype' (if (zero? (:exit identify-res))
|
|
||||||
(-> identify-res
|
|
||||||
:out
|
|
||||||
str/trim
|
|
||||||
(str/split #"\s+" 2)
|
|
||||||
first
|
|
||||||
str/lower)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint "invalid image"))
|
|
||||||
{:keys [width height]}
|
|
||||||
(or (get-dimensions-with-orientation system path-str)
|
|
||||||
(do
|
|
||||||
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-image
|
|
||||||
:hint "invalid image")))]
|
|
||||||
(when (and (string? mtype)
|
|
||||||
(not= (str/lower mtype) mtype'))
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :media-type-mismatch
|
|
||||||
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
|
||||||
"Expected: " mtype ". Got: " mtype')))
|
|
||||||
(assoc input
|
|
||||||
:width width
|
|
||||||
:height height
|
|
||||||
:size (fs/size path)
|
|
||||||
:ts (ct/now))))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; IMAGE HELPERS
|
;; IMAGE HELPERS
|
||||||
@ -338,8 +59,8 @@
|
|||||||
:hint "seems like the url points to resource with unknown size"))
|
:hint "seems like the url points to resource with unknown size"))
|
||||||
|
|
||||||
(-> {:size size :mtype mtype}
|
(-> {:size size :mtype mtype}
|
||||||
(validate-media-type!)
|
(validation/validate-media-type!)
|
||||||
(validate-media-size!))))]
|
(validation/validate-media-size!))))]
|
||||||
|
|
||||||
(let [{:keys [body] :as response}
|
(let [{:keys [body] :as response}
|
||||||
(try
|
(try
|
||||||
@ -367,9 +88,11 @@
|
|||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :unable-to-download-image
|
:code :unable-to-download-image
|
||||||
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
|
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
|
||||||
:cause cause)))
|
:cause cause)))]
|
||||||
|
|
||||||
{:keys [size mtype]} (parse-and-validate response)
|
(if body
|
||||||
|
(with-open [body body]
|
||||||
|
(let [{:keys [size mtype]} (parse-and-validate response)
|
||||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||||
written (io/write* path body :size size)]
|
written (io/write* path body :size size)]
|
||||||
|
|
||||||
@ -382,173 +105,7 @@
|
|||||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||||
{:path path
|
{:path path
|
||||||
:mtype mtype
|
:mtype mtype
|
||||||
:size new-size}))))
|
:size new-size})))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;; No body - validation will raise appropriate error
|
||||||
;; FONTS
|
(parse-and-validate response)))))
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
|
|
||||||
(defn- get-font-prlimit
|
|
||||||
"Returns resource limits for font processing tools, read from config."
|
|
||||||
[]
|
|
||||||
{:mem (cf/get :font-process-mem)
|
|
||||||
:cpu (cf/get :font-process-cpu)})
|
|
||||||
|
|
||||||
(defn- get-font-timeout
|
|
||||||
"Returns the wall-clock timeout for font processing, read from config."
|
|
||||||
[]
|
|
||||||
(cf/get :font-process-timeout))
|
|
||||||
|
|
||||||
(defn- exec-font!
|
|
||||||
"Execute a font processing command with resource limits.
|
|
||||||
`args` is a vector of string arguments."
|
|
||||||
[system args]
|
|
||||||
(shell/exec! system
|
|
||||||
:cmd args
|
|
||||||
:prlimit (get-font-prlimit)
|
|
||||||
:timeout (get-font-timeout)))
|
|
||||||
|
|
||||||
(defmethod process :generate-fonts
|
|
||||||
[system {:keys [input] :as params}]
|
|
||||||
(letfn [(ttf->otf [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
|
||||||
foutput (fs/path (str finput ".otf"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
|
||||||
(str/fmt "Open('%s'); Generate('%s')"
|
|
||||||
(str finput)
|
|
||||||
(str foutput))])]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
foutput))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(otf->ttf [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
|
||||||
foutput (fs/path (str finput ".ttf"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
|
||||||
(str/fmt "Open('%s'); Generate('%s')"
|
|
||||||
(str finput)
|
|
||||||
(str foutput))])]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
foutput))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(ttf-or-otf->woff [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
|
||||||
foutput (fs/path (str finput ".woff"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
foutput))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(woff->sfnt [data]
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (shell/exec! system
|
|
||||||
:cmd ["woff2sfnt" (str finput)]
|
|
||||||
:out-enc :bytes
|
|
||||||
:prlimit (get-font-prlimit)
|
|
||||||
:timeout (get-font-timeout))]
|
|
||||||
(when (zero? (:exit res))
|
|
||||||
(:out res)))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
(woff2->sfnt [data]
|
|
||||||
;; woff2_decompress outputs to same directory with .ttf extension
|
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
|
||||||
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
|
||||||
(try
|
|
||||||
(io/write* finput data)
|
|
||||||
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
|
|
||||||
(if (zero? (:exit res))
|
|
||||||
foutput
|
|
||||||
(do
|
|
||||||
(when (fs/exists? foutput)
|
|
||||||
(fs/delete foutput))
|
|
||||||
nil)))
|
|
||||||
(finally
|
|
||||||
(fs/delete finput)))))
|
|
||||||
|
|
||||||
;; Documented here:
|
|
||||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
|
||||||
(get-sfnt-type [data]
|
|
||||||
(let [buff (bb/slice data 0 4)
|
|
||||||
type (bc/bytes->hex buff)]
|
|
||||||
(case type
|
|
||||||
"4f54544f" :otf
|
|
||||||
"00010000" :ttf
|
|
||||||
(ex/raise :type :internal
|
|
||||||
:code :unexpected-data
|
|
||||||
:hint "unexpected font data"))))
|
|
||||||
|
|
||||||
(gen-if-nil [val factory]
|
|
||||||
(if (nil? val)
|
|
||||||
(factory)
|
|
||||||
val))]
|
|
||||||
|
|
||||||
(let [current (into #{} (keys input))]
|
|
||||||
(cond
|
|
||||||
(contains? current "font/ttf")
|
|
||||||
(let [data (get input "font/ttf")]
|
|
||||||
(-> input
|
|
||||||
(update "font/otf" gen-if-nil #(ttf->otf data))
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
|
|
||||||
|
|
||||||
(contains? current "font/otf")
|
|
||||||
(let [data (get input "font/otf")]
|
|
||||||
(-> input
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
|
|
||||||
(assoc "font/ttf" (otf->ttf data))))
|
|
||||||
|
|
||||||
(contains? current "font/woff")
|
|
||||||
(let [data (get input "font/woff")
|
|
||||||
sfnt (woff->sfnt data)]
|
|
||||||
(when-not sfnt
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-woff-file
|
|
||||||
:hint "invalid woff file"))
|
|
||||||
(let [stype (get-sfnt-type sfnt)]
|
|
||||||
(cond-> input
|
|
||||||
true
|
|
||||||
(-> (assoc "font/woff" data))
|
|
||||||
|
|
||||||
(= stype :otf)
|
|
||||||
(-> (assoc "font/otf" sfnt)
|
|
||||||
(assoc "font/ttf" (otf->ttf sfnt)))
|
|
||||||
|
|
||||||
(= stype :ttf)
|
|
||||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
|
||||||
(assoc "font/ttf" sfnt)))))
|
|
||||||
|
|
||||||
(contains? current "font/woff2")
|
|
||||||
(let [data (get input "font/woff2")
|
|
||||||
foutput (woff2->sfnt data)]
|
|
||||||
(when-not foutput
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :invalid-woff2-file
|
|
||||||
:hint "invalid woff2 file"))
|
|
||||||
(try
|
|
||||||
(let [sfnt (io/read* foutput)
|
|
||||||
type (get-sfnt-type sfnt)]
|
|
||||||
(cond-> input
|
|
||||||
(= type :otf)
|
|
||||||
(-> (assoc "font/otf" sfnt)
|
|
||||||
(assoc "font/ttf" (otf->ttf sfnt))
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
|
||||||
|
|
||||||
(= type :ttf)
|
|
||||||
(-> (assoc "font/ttf" sfnt)
|
|
||||||
(assoc "font/otf" (ttf->otf sfnt))
|
|
||||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
|
||||||
(finally
|
|
||||||
(fs/delete foutput))))))))
|
|
||||||
|
|||||||
366
backend/src/app/media/local.clj
Normal file
366
backend/src/app/media/local.clj
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.local
|
||||||
|
"Local media processing via ImageMagick and FontForge shell commands."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[app.common.media :as cm]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.config :as cf]
|
||||||
|
[app.media.svg :as svg]
|
||||||
|
[app.media.validation :as validation]
|
||||||
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.shell :as shell]
|
||||||
|
[buddy.core.bytes :as bb]
|
||||||
|
[buddy.core.codecs :as bc]
|
||||||
|
[clojure.string]
|
||||||
|
[cuerdas.core :as str]
|
||||||
|
[datoteka.fs :as fs]
|
||||||
|
[datoteka.io :as io]))
|
||||||
|
|
||||||
|
(defmulti process (fn [_system params] (:cmd params)))
|
||||||
|
|
||||||
|
(defmethod process :default
|
||||||
|
[_system {:keys [cmd] :as params}]
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :not-implemented
|
||||||
|
:hint (str/fmt "No impl found for local process cmd: %s" cmd)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; IMAGE THUMBNAILS
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(def ^:private schema:thumbnail-params
|
||||||
|
[:map {:title "ThumbnailParams"}
|
||||||
|
[:input validation/schema:input]
|
||||||
|
[:format [:enum :jpeg :webp :png]]
|
||||||
|
[:quality [:int {:min 1 :max 100}]]
|
||||||
|
[:width :int]
|
||||||
|
[:height :int]])
|
||||||
|
|
||||||
|
(def ^:private check-thumbnail-params
|
||||||
|
(sm/check-fn schema:thumbnail-params))
|
||||||
|
|
||||||
|
;; Related info on how thumbnails generation
|
||||||
|
;; http://www.imagemagick.org/Usage/thumbnails/
|
||||||
|
|
||||||
|
(def ^:private imagemagick-default-env
|
||||||
|
"Default environment variables for ImageMagick resource limits.
|
||||||
|
These are the soft ceiling — policy.xml is the hard ceiling."
|
||||||
|
{"MAGICK_THREAD_LIMIT" "2"
|
||||||
|
"MAGICK_MEMORY_LIMIT" "256MiB"
|
||||||
|
"MAGICK_MAP_LIMIT" "512MiB"
|
||||||
|
"MAGICK_AREA_LIMIT" "128MP"
|
||||||
|
"MAGICK_DISK_LIMIT" "1GiB"
|
||||||
|
"MAGICK_TIME_LIMIT" "30"})
|
||||||
|
|
||||||
|
(defn- get-imagemagick-env
|
||||||
|
"Returns environment variables for ImageMagick commands.
|
||||||
|
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
|
||||||
|
[]
|
||||||
|
(let [thread (cf/get :imagemagick-thread-limit)
|
||||||
|
memory (cf/get :imagemagick-memory-limit)
|
||||||
|
map-l (cf/get :imagemagick-map-limit)
|
||||||
|
area (cf/get :imagemagick-area-limit)
|
||||||
|
disk (cf/get :imagemagick-disk-limit)
|
||||||
|
time (cf/get :imagemagick-time-limit)
|
||||||
|
width (cf/get :imagemagick-width-limit)
|
||||||
|
height (cf/get :imagemagick-height-limit)]
|
||||||
|
(cond-> imagemagick-default-env
|
||||||
|
thread (assoc "MAGICK_THREAD_LIMIT" thread)
|
||||||
|
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
|
||||||
|
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
|
||||||
|
area (assoc "MAGICK_AREA_LIMIT" area)
|
||||||
|
disk (assoc "MAGICK_DISK_LIMIT" disk)
|
||||||
|
time (assoc "MAGICK_TIME_LIMIT" time)
|
||||||
|
width (assoc "MAGICK_WIDTH_LIMIT" width)
|
||||||
|
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
|
||||||
|
|
||||||
|
(defn- exec-magick!
|
||||||
|
"Execute an ImageMagick command with resource limits.
|
||||||
|
`args` is a vector of string arguments to pass to `magick`."
|
||||||
|
[system args]
|
||||||
|
(let [cmd (into ["magick"] args)
|
||||||
|
result (shell/exec! system
|
||||||
|
:cmd cmd
|
||||||
|
:env (get-imagemagick-env)
|
||||||
|
:timeout 60)]
|
||||||
|
(when (not= 0 (:exit result))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint (str "ImageMagick command failed: " (:err result))
|
||||||
|
:cmd cmd
|
||||||
|
:exit (:exit result)))
|
||||||
|
result))
|
||||||
|
|
||||||
|
(defn- generic-process
|
||||||
|
[system {:keys [input format convert-args] :as params}]
|
||||||
|
(let [{:keys [path mtype]} input
|
||||||
|
format (or format (cm/mtype->format mtype))
|
||||||
|
ext (cm/format->extension format)
|
||||||
|
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||||
|
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
|
||||||
|
(exec-magick! system args)
|
||||||
|
(assoc params
|
||||||
|
:format format
|
||||||
|
:mtype (cm/format->mtype format)
|
||||||
|
:size (fs/size tmp)
|
||||||
|
:data tmp)))
|
||||||
|
|
||||||
|
(defmethod process :generic-thumbnail
|
||||||
|
[system params]
|
||||||
|
(let [{:keys [quality width height] :as params}
|
||||||
|
(check-thumbnail-params params)]
|
||||||
|
(generic-process system
|
||||||
|
(assoc params
|
||||||
|
:convert-args ["-auto-orient" "-strip"
|
||||||
|
"-thumbnail" (str width "x" height ">")
|
||||||
|
"-quality" (str quality)]))))
|
||||||
|
|
||||||
|
(defmethod process :profile-thumbnail
|
||||||
|
[system params]
|
||||||
|
(let [{:keys [quality width height] :as params}
|
||||||
|
(check-thumbnail-params params)]
|
||||||
|
(generic-process system
|
||||||
|
(assoc params
|
||||||
|
:convert-args ["-auto-orient" "-strip"
|
||||||
|
"-thumbnail" (str width "x" height "^")
|
||||||
|
"-gravity" "center"
|
||||||
|
"-extent" (str width "x" height)
|
||||||
|
"-quality" (str quality)]))))
|
||||||
|
|
||||||
|
(defn- get-dimensions-with-orientation [system ^String path]
|
||||||
|
;; Image magick doesn't give info about exif rotation so we use the identify command
|
||||||
|
;; If we are processing an animated gif we use the first frame with -scene 0
|
||||||
|
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
|
||||||
|
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
|
||||||
|
(when (= 0 (:exit dim-result))
|
||||||
|
(let [[w h] (-> (:out dim-result)
|
||||||
|
str/trim
|
||||||
|
(clojure.string/split #"\s+")
|
||||||
|
(->> (mapv #(Integer/parseInt %))))
|
||||||
|
orientation-exit (:exit orient-result)
|
||||||
|
orientation (-> orient-result :out str/trim)]
|
||||||
|
(if (= 0 orientation-exit)
|
||||||
|
(case orientation
|
||||||
|
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||||
|
{:width w :height h}) ; Normal or unknown orientation
|
||||||
|
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
||||||
|
|
||||||
|
(defmethod process :info
|
||||||
|
[system {:keys [input] :as params}]
|
||||||
|
(let [{:keys [path mtype] :as input} (validation/check-input input)]
|
||||||
|
(if (= mtype "image/svg+xml")
|
||||||
|
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||||
|
(when-not info
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "uploaded svg does not provides dimensions"))
|
||||||
|
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||||
|
|
||||||
|
(let [path-str (str path)
|
||||||
|
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
|
||||||
|
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
||||||
|
mtype' (if (zero? (:exit identify-res))
|
||||||
|
(-> identify-res
|
||||||
|
:out
|
||||||
|
str/trim
|
||||||
|
(str/split #"\s+" 2)
|
||||||
|
first
|
||||||
|
str/lower)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint "invalid image"))
|
||||||
|
{:keys [width height]}
|
||||||
|
(or (get-dimensions-with-orientation system path-str)
|
||||||
|
(do
|
||||||
|
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint "invalid image")))]
|
||||||
|
(when (and (string? mtype)
|
||||||
|
(not= (str/lower mtype) mtype'))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :media-type-mismatch
|
||||||
|
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
||||||
|
"Expected: " mtype ". Got: " mtype')))
|
||||||
|
(assoc input
|
||||||
|
:width width
|
||||||
|
:height height
|
||||||
|
:size (fs/size path)
|
||||||
|
:ts (ct/now))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; FONTS
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn- get-font-prlimit
|
||||||
|
"Returns resource limits for font processing tools, read from config."
|
||||||
|
[]
|
||||||
|
{:mem (cf/get :font-process-mem)
|
||||||
|
:cpu (cf/get :font-process-cpu)})
|
||||||
|
|
||||||
|
(defn- get-font-timeout
|
||||||
|
"Returns the wall-clock timeout for font processing, read from config."
|
||||||
|
[]
|
||||||
|
(cf/get :font-process-timeout))
|
||||||
|
|
||||||
|
(defn- exec-font!
|
||||||
|
"Execute a font processing command with resource limits.
|
||||||
|
`args` is a vector of string arguments."
|
||||||
|
[system args]
|
||||||
|
(shell/exec! system
|
||||||
|
:cmd args
|
||||||
|
:prlimit (get-font-prlimit)
|
||||||
|
:timeout (get-font-timeout)))
|
||||||
|
|
||||||
|
(defmethod process :generate-fonts
|
||||||
|
[system {:keys [input] :as params}]
|
||||||
|
(letfn [(ttf->otf [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
|
foutput (fs/path (str finput ".otf"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||||
|
(str/fmt "Open('%s'); Generate('%s')"
|
||||||
|
(str finput)
|
||||||
|
(str foutput))])]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(otf->ttf [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
|
foutput (fs/path (str finput ".ttf"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||||
|
(str/fmt "Open('%s'); Generate('%s')"
|
||||||
|
(str finput)
|
||||||
|
(str foutput))])]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(ttf-or-otf->woff [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
|
foutput (fs/path (str finput ".woff"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(woff->sfnt [data]
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (shell/exec! system
|
||||||
|
:cmd ["woff2sfnt" (str finput)]
|
||||||
|
:out-enc :bytes
|
||||||
|
:prlimit (get-font-prlimit)
|
||||||
|
:timeout (get-font-timeout))]
|
||||||
|
(when (zero? (:exit res))
|
||||||
|
(:out res)))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
(woff2->sfnt [data]
|
||||||
|
;; woff2_decompress outputs to same directory with .ttf extension
|
||||||
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
||||||
|
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
||||||
|
(try
|
||||||
|
(io/write* finput data)
|
||||||
|
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
|
||||||
|
(if (zero? (:exit res))
|
||||||
|
foutput
|
||||||
|
(do
|
||||||
|
(when (fs/exists? foutput)
|
||||||
|
(fs/delete foutput))
|
||||||
|
nil)))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
|
;; Documented here:
|
||||||
|
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||||
|
(get-sfnt-type [data]
|
||||||
|
(let [buff (bb/slice data 0 4)
|
||||||
|
type (bc/bytes->hex buff)]
|
||||||
|
(case type
|
||||||
|
"4f54544f" :otf
|
||||||
|
"00010000" :ttf
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :unexpected-data
|
||||||
|
:hint "unexpected font data"))))
|
||||||
|
|
||||||
|
(gen-if-nil [val factory]
|
||||||
|
(if (nil? val)
|
||||||
|
(factory)
|
||||||
|
val))]
|
||||||
|
|
||||||
|
(let [current (into #{} (keys input))]
|
||||||
|
(cond
|
||||||
|
(contains? current "font/ttf")
|
||||||
|
(let [data (get input "font/ttf")]
|
||||||
|
(-> input
|
||||||
|
(update "font/otf" gen-if-nil #(ttf->otf data))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
|
||||||
|
|
||||||
|
(contains? current "font/otf")
|
||||||
|
(let [data (get input "font/otf")]
|
||||||
|
(-> input
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
|
||||||
|
(assoc "font/ttf" (otf->ttf data))))
|
||||||
|
|
||||||
|
(contains? current "font/woff")
|
||||||
|
(let [data (get input "font/woff")
|
||||||
|
sfnt (woff->sfnt data)]
|
||||||
|
(when-not sfnt
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-woff-file
|
||||||
|
:hint "invalid woff file"))
|
||||||
|
(let [stype (get-sfnt-type sfnt)]
|
||||||
|
(cond-> input
|
||||||
|
true
|
||||||
|
(-> (assoc "font/woff" data))
|
||||||
|
|
||||||
|
(= stype :otf)
|
||||||
|
(-> (assoc "font/otf" sfnt)
|
||||||
|
(assoc "font/ttf" (otf->ttf sfnt)))
|
||||||
|
|
||||||
|
(= stype :ttf)
|
||||||
|
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||||
|
(assoc "font/ttf" sfnt)))))
|
||||||
|
|
||||||
|
(contains? current "font/woff2")
|
||||||
|
(let [data (get input "font/woff2")
|
||||||
|
foutput (woff2->sfnt data)]
|
||||||
|
(when-not foutput
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-woff2-file
|
||||||
|
:hint "invalid woff2 file"))
|
||||||
|
(try
|
||||||
|
(let [sfnt (io/read* foutput)
|
||||||
|
type (get-sfnt-type sfnt)]
|
||||||
|
(cond-> input
|
||||||
|
(= type :otf)
|
||||||
|
(-> (assoc "font/otf" sfnt)
|
||||||
|
(assoc "font/ttf" (otf->ttf sfnt))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
||||||
|
|
||||||
|
(= type :ttf)
|
||||||
|
(-> (assoc "font/ttf" sfnt)
|
||||||
|
(assoc "font/otf" (ttf->otf sfnt))
|
||||||
|
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
||||||
|
(finally
|
||||||
|
(fs/delete foutput))))))))
|
||||||
264
backend/src/app/media/remote.clj
Normal file
264
backend/src/app/media/remote.clj
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.remote
|
||||||
|
"Remote media processing via the media-processor HTTP service."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.media :as cm]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.common.uri :as uri]
|
||||||
|
[app.config :as cf]
|
||||||
|
[app.http.client :as http]
|
||||||
|
[app.media.svg :as svg]
|
||||||
|
[app.media.validation :as validation]
|
||||||
|
[app.setup :as-alias setup]
|
||||||
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.json :as json]
|
||||||
|
[cuerdas.core :as str]
|
||||||
|
[datoteka.fs :as fs]
|
||||||
|
[datoteka.io :as io])
|
||||||
|
(:import
|
||||||
|
java.io.ByteArrayInputStream
|
||||||
|
java.io.InputStream
|
||||||
|
java.io.SequenceInputStream
|
||||||
|
java.net.ConnectException
|
||||||
|
java.net.http.HttpTimeoutException
|
||||||
|
java.util.Collections))
|
||||||
|
|
||||||
|
(defn- service-base-url
|
||||||
|
"Returns the base URL of the media-processor service."
|
||||||
|
[]
|
||||||
|
(or (cf/get :media-processing-service-uri)
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :media-processor-not-configured
|
||||||
|
:hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured")))
|
||||||
|
|
||||||
|
(defn- service-timeout
|
||||||
|
"Returns the HTTP timeout (ms) for media-processor requests."
|
||||||
|
[]
|
||||||
|
(or (cf/get :media-processing-service-timeout)
|
||||||
|
120000))
|
||||||
|
|
||||||
|
(defn- get-shared-key
|
||||||
|
"Returns the shared key for authenticating with the media-processor."
|
||||||
|
[system]
|
||||||
|
(-> system ::setup/shared-keys :media-processor))
|
||||||
|
|
||||||
|
(defn- parse-json-response
|
||||||
|
"Parse a JSON response body."
|
||||||
|
[body]
|
||||||
|
(json/read! body))
|
||||||
|
|
||||||
|
(defn- translate-error
|
||||||
|
"Translate a media-processor error response into a Penpot exception."
|
||||||
|
[status body]
|
||||||
|
(let [code (or (:code body) "media-processor-error")
|
||||||
|
hint (or (:hint body) "media-processor request failed")]
|
||||||
|
(case status
|
||||||
|
400 {:type :validation :code (keyword code) :hint hint}
|
||||||
|
403 {:type :authorization :code :forbidden :hint hint}
|
||||||
|
413 {:type :restriction :code (keyword code) :hint hint}
|
||||||
|
504 {:type :internal :code :media-processor-timeout :hint hint}
|
||||||
|
{:type :internal :code (keyword code) :hint hint})))
|
||||||
|
|
||||||
|
(defn service-request
|
||||||
|
"Make an HTTP request to the media-processor service."
|
||||||
|
[system {:keys [method uri body headers timeout]}]
|
||||||
|
(let [client (::http/client system)
|
||||||
|
timeout (or timeout (service-timeout))]
|
||||||
|
(try
|
||||||
|
(let [resp (http/req client
|
||||||
|
{:method method
|
||||||
|
:uri uri
|
||||||
|
:body body
|
||||||
|
:headers headers}
|
||||||
|
{:response-type :input-stream
|
||||||
|
:skip-ssrf-check? true
|
||||||
|
:timeout timeout})
|
||||||
|
status (:status resp)]
|
||||||
|
(when (not (<= 200 status 299))
|
||||||
|
(let [body (:body resp)]
|
||||||
|
(try
|
||||||
|
(let [parsed (try (parse-json-response body) (catch Exception _ nil))
|
||||||
|
err (translate-error status parsed)]
|
||||||
|
(ex/raise :type (:type err) :code (:code err) :hint (:hint err)))
|
||||||
|
(finally
|
||||||
|
(.close body)))))
|
||||||
|
resp)
|
||||||
|
(catch ConnectException _cause
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :media-processor-unavailable
|
||||||
|
:hint "Cannot connect to media-processor service"))
|
||||||
|
(catch HttpTimeoutException _cause
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :media-processor-timeout
|
||||||
|
:hint "media-processor service request timed out")))))
|
||||||
|
|
||||||
|
(defn- multipart-boundary
|
||||||
|
[]
|
||||||
|
(str "----PenpotBoundary" (System/currentTimeMillis)))
|
||||||
|
|
||||||
|
(defn- build-multipart-stream
|
||||||
|
"Build a streaming multipart/form-data body with a single file field.
|
||||||
|
Returns an InputStream that lazily reads from the file on demand."
|
||||||
|
[^String boundary mtype ^InputStream file-stream]
|
||||||
|
(let [header (.getBytes (str "--" boundary "\r\n"
|
||||||
|
"Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
|
||||||
|
"Content-Type: " mtype "\r\n"
|
||||||
|
"\r\n")
|
||||||
|
"UTF-8")
|
||||||
|
footer (.getBytes (str "\r\n--" boundary "--\r\n")
|
||||||
|
"UTF-8")
|
||||||
|
parts (Collections/enumeration
|
||||||
|
[(ByteArrayInputStream. header)
|
||||||
|
file-stream
|
||||||
|
(ByteArrayInputStream. footer)])]
|
||||||
|
(SequenceInputStream. parts)))
|
||||||
|
|
||||||
|
(defn- service-multipart-request
|
||||||
|
"Send a multipart request to the media-processor service.
|
||||||
|
Accepts a file from disk via :path. The file stream is closed
|
||||||
|
after the HTTP request completes (success or failure)."
|
||||||
|
[system {:keys [endpoint path mtype query timeout]}]
|
||||||
|
(let [shared-key (get-shared-key system)
|
||||||
|
boundary (multipart-boundary)
|
||||||
|
ctype (or mtype "application/octet-stream")
|
||||||
|
base-url (service-base-url)
|
||||||
|
request-uri (cond-> (uri/join base-url endpoint)
|
||||||
|
(seq query)
|
||||||
|
(str "?" (uri/map->query-string query)))]
|
||||||
|
(with-open [file-stream (io/input-stream path)]
|
||||||
|
(let [body (build-multipart-stream boundary ctype file-stream)]
|
||||||
|
(service-request system
|
||||||
|
{:method :post
|
||||||
|
:uri request-uri
|
||||||
|
:body body
|
||||||
|
:headers {"Content-Type" (str "multipart/form-data; boundary=" boundary)
|
||||||
|
"x-shared-key" shared-key}
|
||||||
|
:timeout timeout})))))
|
||||||
|
|
||||||
|
(def ^:private known-font-types
|
||||||
|
"Priority-ordered list of font mime-types the system knows how to convert.
|
||||||
|
Order matters: when a font upload contains multiple variants, the first
|
||||||
|
match becomes the conversion source (ttf preferred for best coverage)."
|
||||||
|
["font/ttf" "font/otf" "font/woff" "font/woff2"])
|
||||||
|
|
||||||
|
(defn- font-convert
|
||||||
|
"Convert a font to the given target mime-type via the media-processor service.
|
||||||
|
Accepts source font data as a filesystem Path. Returns a tempfile Path."
|
||||||
|
[system source-mtype target-mtype data]
|
||||||
|
(let [resp (service-multipart-request system {:endpoint "api/font/convert"
|
||||||
|
:path data
|
||||||
|
:mtype source-mtype
|
||||||
|
:query {:target-type target-mtype}
|
||||||
|
:timeout 180000})
|
||||||
|
ext (cm/mtype->extension target-mtype)
|
||||||
|
tmp (tmp/tempfile :prefix "penpot.font." :suffix ext)
|
||||||
|
body (:body resp)]
|
||||||
|
(try
|
||||||
|
(io/write* tmp body)
|
||||||
|
(finally
|
||||||
|
(.close body)))
|
||||||
|
tmp))
|
||||||
|
|
||||||
|
(defn- font-missing-variants
|
||||||
|
"Return the set of target mime-types that should be generated for the given
|
||||||
|
source mime-type (excluding font/woff2, which is never generated)."
|
||||||
|
[source-mtype]
|
||||||
|
(case source-mtype
|
||||||
|
"font/ttf" #{"font/otf" "font/woff"}
|
||||||
|
"font/otf" #{"font/ttf" "font/woff"}
|
||||||
|
"font/woff" #{"font/ttf" "font/otf"}
|
||||||
|
"font/woff2" #{"font/ttf" "font/otf" "font/woff"}))
|
||||||
|
|
||||||
|
(defmulti process (fn [_system params] (:cmd params)))
|
||||||
|
|
||||||
|
(defmethod process :info
|
||||||
|
[system {:keys [input]}]
|
||||||
|
(let [{:keys [path mtype]} (validation/check-input input)]
|
||||||
|
(if (= mtype "image/svg+xml")
|
||||||
|
;; SVG: parse locally (Sharp doesn't support SVG)
|
||||||
|
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
||||||
|
(when-not info
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "uploaded svg does not provide dimensions"))
|
||||||
|
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||||
|
;; Raster: delegate to media-processor
|
||||||
|
(let [resp (service-multipart-request system {:endpoint "api/image/info"
|
||||||
|
:path path
|
||||||
|
:mtype mtype})
|
||||||
|
body (:body resp)]
|
||||||
|
(try
|
||||||
|
(let [info (parse-json-response body)
|
||||||
|
detected-mtype (:mtype info)]
|
||||||
|
(when (and (string? mtype)
|
||||||
|
(string? detected-mtype)
|
||||||
|
(not= (str/lower mtype) (str/lower detected-mtype)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :media-type-mismatch
|
||||||
|
:hint (str "File content does not match the declared type. "
|
||||||
|
"Expected: " mtype ". Got: " detected-mtype)))
|
||||||
|
(assoc input
|
||||||
|
:width (:width info)
|
||||||
|
:height (:height info)
|
||||||
|
:size (fs/size path)
|
||||||
|
:ts (ct/now)))
|
||||||
|
(finally
|
||||||
|
(.close body)))))))
|
||||||
|
|
||||||
|
(defn- thumbnail-request
|
||||||
|
"Shared implementation for generic-thumbnail and profile-thumbnail."
|
||||||
|
[system params mode]
|
||||||
|
(let [{:keys [input format quality width height]} params
|
||||||
|
{:keys [path mtype]} (validation/check-input input)
|
||||||
|
fmt (name (or format (cm/mtype->format mtype) :jpeg))
|
||||||
|
resp (service-multipart-request system {:endpoint "api/image/thumbnail"
|
||||||
|
:path path
|
||||||
|
:mtype mtype
|
||||||
|
:query {:width width
|
||||||
|
:height height
|
||||||
|
:quality quality
|
||||||
|
:format fmt
|
||||||
|
:mode mode}})
|
||||||
|
out-format (or format (cm/mtype->format mtype) :jpeg)
|
||||||
|
ext (cm/format->extension out-format)
|
||||||
|
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||||
|
body (:body resp)]
|
||||||
|
(try
|
||||||
|
(io/write* tmp body)
|
||||||
|
(finally
|
||||||
|
(.close body)))
|
||||||
|
(assoc params
|
||||||
|
:format out-format
|
||||||
|
:mtype (cm/format->mtype out-format)
|
||||||
|
:size (fs/size tmp)
|
||||||
|
:data tmp)))
|
||||||
|
|
||||||
|
(defmethod process :generic-thumbnail
|
||||||
|
[system params]
|
||||||
|
(thumbnail-request system params "fit"))
|
||||||
|
|
||||||
|
(defmethod process :profile-thumbnail
|
||||||
|
[system params]
|
||||||
|
(thumbnail-request system params "crop"))
|
||||||
|
|
||||||
|
(defmethod process :generate-fonts
|
||||||
|
[system {:keys [input]}]
|
||||||
|
(let [source-mtype (or (some #(when (contains? input %) %) known-font-types)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-font
|
||||||
|
:hint "No recognized font variant in input"))
|
||||||
|
data (get input source-mtype)
|
||||||
|
present (set (keys input))
|
||||||
|
targets (remove present (font-missing-variants source-mtype))]
|
||||||
|
(reduce (fn [acc target-mtype]
|
||||||
|
(assoc acc target-mtype
|
||||||
|
(font-convert system source-mtype target-mtype data)))
|
||||||
|
input
|
||||||
|
targets)))
|
||||||
|
|
||||||
130
backend/src/app/media/svg.clj
Normal file
130
backend/src/app/media/svg.clj
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.svg
|
||||||
|
"SVG parsing, sanitization, and info extraction.
|
||||||
|
Centralizes all SVG-related security concerns."
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[clojure.xml :as xml]
|
||||||
|
[cuerdas.core :as str])
|
||||||
|
(:import
|
||||||
|
clojure.lang.XMLHandler
|
||||||
|
java.io.InputStream
|
||||||
|
javax.xml.parsers.SAXParserFactory
|
||||||
|
javax.xml.XMLConstants
|
||||||
|
org.apache.commons.io.IOUtils))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; SVG PARSING
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn- secure-parser-factory
|
||||||
|
[^InputStream input ^XMLHandler handler]
|
||||||
|
(.. (doto (SAXParserFactory/newInstance)
|
||||||
|
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
||||||
|
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
||||||
|
(newSAXParser)
|
||||||
|
(parse input handler)))
|
||||||
|
|
||||||
|
(defn- strip-doctype
|
||||||
|
[data]
|
||||||
|
(cond-> data
|
||||||
|
(str/includes? data "<!DOCTYPE")
|
||||||
|
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
||||||
|
|
||||||
|
(defn parse-svg
|
||||||
|
[text]
|
||||||
|
(let [text (strip-doctype text)]
|
||||||
|
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
||||||
|
(xml/parse istream secure-parser-factory))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; SVG SANITIZATION
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$")
|
||||||
|
(def ^:private javascript-href-pattern #"(?i)^javascript:")
|
||||||
|
|
||||||
|
(defn- sanitize-svg-element
|
||||||
|
"Recursively sanitize an SVG element by removing dangerous tags and attributes."
|
||||||
|
[{:keys [tag attrs content] :as element}]
|
||||||
|
(when (and (map? element) tag)
|
||||||
|
(let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}]
|
||||||
|
(when-not (contains? dangerous-tags tag)
|
||||||
|
(let [clean-attrs (->> attrs
|
||||||
|
(remove (fn [[k v]]
|
||||||
|
(or (re-matches dangerous-attrs-pattern (name k))
|
||||||
|
(and (#{:href :xlink:href} k)
|
||||||
|
(string? v)
|
||||||
|
(re-find javascript-href-pattern (str/trim v))))))
|
||||||
|
(into {}))
|
||||||
|
clean-content (when content
|
||||||
|
(->> content
|
||||||
|
(filter #(or (string? %) (map? %)))
|
||||||
|
(map (fn [child]
|
||||||
|
(if (map? child)
|
||||||
|
(sanitize-svg-element child)
|
||||||
|
child)))
|
||||||
|
(filter some?)
|
||||||
|
vec))]
|
||||||
|
(cond-> {:tag tag :attrs clean-attrs}
|
||||||
|
(seq clean-content) (assoc :content clean-content)))))))
|
||||||
|
|
||||||
|
(defn sanitize-svg
|
||||||
|
"Sanitize SVG content by removing dangerous elements and attributes.
|
||||||
|
Removes <script> tags, <foreignObject> elements, event handlers (on*),
|
||||||
|
and javascript: URLs from href attributes."
|
||||||
|
[svg-text]
|
||||||
|
(try
|
||||||
|
(let [parsed (parse-svg svg-text)
|
||||||
|
sanitized (sanitize-svg-element parsed)]
|
||||||
|
(if sanitized
|
||||||
|
(with-out-str (xml/emit sanitized))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "SVG sanitization produced no output")))
|
||||||
|
(catch Exception e
|
||||||
|
(l/warn :hint "SVG sanitization failed, rejecting upload" :cause e)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-svg-file
|
||||||
|
:hint "SVG parsing failed during sanitization"
|
||||||
|
:cause e))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; SVG INFO EXTRACTION
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn get-basic-info-from-svg
|
||||||
|
[{:keys [tag attrs] :as data}]
|
||||||
|
(when (not= tag :svg)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :unable-to-parse-svg
|
||||||
|
:hint "uploaded svg has invalid content"))
|
||||||
|
(reduce (fn [default f]
|
||||||
|
(if-let [res (f attrs)]
|
||||||
|
(reduced res)
|
||||||
|
default))
|
||||||
|
{:width 100 :height 100}
|
||||||
|
[(fn parse-width-and-height
|
||||||
|
[{:keys [width height]}]
|
||||||
|
(when (and (string? width)
|
||||||
|
(string? height))
|
||||||
|
(let [width (d/parse-double width)
|
||||||
|
height (d/parse-double height)]
|
||||||
|
(when (and width height)
|
||||||
|
{:width (int width)
|
||||||
|
:height (int height)}))))
|
||||||
|
(fn parse-viewbox
|
||||||
|
[{:keys [viewBox]}]
|
||||||
|
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
||||||
|
(map d/parse-double))]
|
||||||
|
(when (and x y width height)
|
||||||
|
{:width (int width)
|
||||||
|
:height (int height)})))]))
|
||||||
68
backend/src/app/media/validation.clj
Normal file
68
backend/src/app/media/validation.clj
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.media.validation
|
||||||
|
"Schemas and validation functions for media uploads.
|
||||||
|
Leaf namespace — depends on app.common.* and app.config only."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.media :as cm]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.config :as cf]
|
||||||
|
[cuerdas.core :as str]
|
||||||
|
[datoteka.fs :as fs]))
|
||||||
|
|
||||||
|
(def schema:upload
|
||||||
|
[:map {:title "Upload"}
|
||||||
|
[:filename :string]
|
||||||
|
[:size ::sm/int]
|
||||||
|
[:path ::fs/path]
|
||||||
|
[:mtype {:optional true} :string]
|
||||||
|
[:headers {:optional true}
|
||||||
|
[:map-of :string :string]]])
|
||||||
|
|
||||||
|
(def schema:input
|
||||||
|
[:map {:title "Input"}
|
||||||
|
[:path ::fs/path]
|
||||||
|
[:mtype {:optional true} ::sm/text]])
|
||||||
|
|
||||||
|
(def check-input
|
||||||
|
(sm/check-fn schema:input))
|
||||||
|
|
||||||
|
(defn validate-media-type!
|
||||||
|
([upload] (validate-media-type! upload cm/image-types))
|
||||||
|
([upload allowed]
|
||||||
|
(when-not (contains? allowed (:mtype upload))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :media-type-not-allowed
|
||||||
|
:hint "Seems like you are uploading an invalid media object"))
|
||||||
|
|
||||||
|
upload))
|
||||||
|
|
||||||
|
(defn validate-media-size!
|
||||||
|
[upload]
|
||||||
|
(let [max-size (cf/get :media-max-file-size)]
|
||||||
|
(when (> (:size upload) max-size)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :media-max-file-size-reached
|
||||||
|
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||||
|
(:size upload)
|
||||||
|
max-size)))
|
||||||
|
upload))
|
||||||
|
|
||||||
|
(defn validate-font-size!
|
||||||
|
"Validates that the font file `upload` does not exceed the configured
|
||||||
|
`:font-max-file-size` limit. Accepts the same map shape as
|
||||||
|
`validate-media-size!` — requires a `:size` key in bytes."
|
||||||
|
[upload]
|
||||||
|
(let [max-size (cf/get :font-max-file-size)]
|
||||||
|
(when (> (:size upload) max-size)
|
||||||
|
(ex/raise :type :restriction
|
||||||
|
:code :font-max-file-size-reached
|
||||||
|
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
||||||
|
(:size upload)
|
||||||
|
max-size)))
|
||||||
|
upload))
|
||||||
@ -49,7 +49,7 @@
|
|||||||
"Joins relative path segments to the Nitrate backend URI.
|
"Joins relative path segments to the Nitrate backend URI.
|
||||||
Segments must not start with `/`"
|
Segments must not start with `/`"
|
||||||
[& segments]
|
[& segments]
|
||||||
(apply join-base-uri (cf/get :nitrate-backend-uri) segments))
|
(apply join-base-uri (cf/get :admin-console-uri) segments))
|
||||||
|
|
||||||
(defn- generate-public-uri
|
(defn- generate-public-uri
|
||||||
"Joins relative path segments to the public backend URI.
|
"Joins relative path segments to the public backend URI.
|
||||||
@ -143,7 +143,7 @@
|
|||||||
|
|
||||||
(defn- request-to-nitrate
|
(defn- request-to-nitrate
|
||||||
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
|
[cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}]
|
||||||
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
|
(let [shared-key (-> cfg ::setup/shared-keys :admin-console)
|
||||||
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
|
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
|
||||||
(with-retries 3)
|
(with-retries 3)
|
||||||
(with-validate uri schema :throw-on-error? throw-on-error?))]
|
(with-validate uri schema :throw-on-error? throw-on-error?))]
|
||||||
@ -155,7 +155,7 @@
|
|||||||
|
|
||||||
(defn call
|
(defn call
|
||||||
[cfg method params]
|
[cfg method params]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [client (get cfg ::client)
|
(let [client (get cfg ::client)
|
||||||
method (get client method)]
|
method (get client method)]
|
||||||
(method params))))
|
(method params))))
|
||||||
@ -167,6 +167,9 @@
|
|||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:name ::sm/text]
|
[:name ::sm/text]
|
||||||
[:owner-id ::sm/uuid]
|
[:owner-id ::sm/uuid]
|
||||||
|
[:logo-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||||
|
[:sso-active {:optional true} [:maybe ::sm/boolean]]
|
||||||
[:teams
|
[:teams
|
||||||
[:vector
|
[:vector
|
||||||
[:map
|
[:map
|
||||||
@ -259,6 +262,14 @@
|
|||||||
(generate-nitrate-uri "api/teams/" team-id)
|
(generate-nitrate-uri "api/teams/" team-id)
|
||||||
cto/schema:team-with-organization params))
|
cto/schema:team-with-organization params))
|
||||||
|
|
||||||
|
(defn- get-teams-organizations-api
|
||||||
|
[cfg {:keys [team-ids] :as params}]
|
||||||
|
(let [params (assoc params :request-params {:team-ids team-ids})]
|
||||||
|
(request-to-nitrate cfg :post
|
||||||
|
(generate-nitrate-uri "api/teams/organizations")
|
||||||
|
[:vector cto/schema:team-with-organization]
|
||||||
|
params)))
|
||||||
|
|
||||||
(defn- get-organization-membership-api
|
(defn- get-organization-membership-api
|
||||||
[cfg {:keys [profile-id organization-id] :as params}]
|
[cfg {:keys [profile-id organization-id] :as params}]
|
||||||
(request-to-nitrate cfg :get
|
(request-to-nitrate cfg :get
|
||||||
@ -487,8 +498,9 @@
|
|||||||
|
|
||||||
(defmethod ig/init-key ::client
|
(defmethod ig/init-key ::client
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
{:get-team-organization (partial get-team-organization-api cfg)
|
{:get-team-organization (partial get-team-organization-api cfg)
|
||||||
|
:get-teams-organizations (partial get-teams-organizations-api cfg)
|
||||||
:set-team-organization (partial set-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 (partial get-organization-membership-api cfg)
|
||||||
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
|
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
|
||||||
@ -549,7 +561,7 @@
|
|||||||
callers are unaffected. Returns false when the :nitrate flag is off."
|
callers are unaffected. Returns false when the :nitrate flag is off."
|
||||||
[cfg profile-id team-id]
|
[cfg profile-id team-id]
|
||||||
(boolean
|
(boolean
|
||||||
(when (and (contains? cf/flags :nitrate)
|
(when (and (contains? cf/flags :admin-console)
|
||||||
(nitrate-client? cfg)
|
(nitrate-client? cfg)
|
||||||
(some? team-id)
|
(some? team-id)
|
||||||
(some? profile-id))
|
(some? profile-id))
|
||||||
@ -596,22 +608,25 @@
|
|||||||
:cause cause)
|
:cause cause)
|
||||||
profile)))))
|
profile)))))
|
||||||
|
|
||||||
(defn add-organization-info-to-team
|
(defn- apply-organization-info-to-team
|
||||||
"Enriches a team map with organization information from Nitrate.
|
[team team-with-organization]
|
||||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
(let [organization (:organization team-with-organization)]
|
||||||
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-organization (call cfg :get-team-organization params)
|
|
||||||
organization (:organization team-with-organization)]
|
|
||||||
(if (some? organization)
|
(if (some? organization)
|
||||||
(-> (cto/apply-organization team (assoc organization :custom-photo
|
(-> (cto/apply-organization team (assoc organization :custom-photo
|
||||||
(when-let [logo-id (:logo-id organization)]
|
(when-let [logo-id (:logo-id organization)]
|
||||||
(generate-public-uri "assets/by-id/" logo-id))))
|
(generate-public-uri "assets/by-id/" logo-id))))
|
||||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
||||||
team))
|
team)))
|
||||||
|
|
||||||
|
(defn add-organization-info-to-team
|
||||||
|
"Enriches a team map with organization information from Nitrate.
|
||||||
|
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-organization (call cfg :get-team-organization params)]
|
||||||
|
(apply-organization-info-to-team team team-with-organization))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||||
(throw cause)
|
(throw cause)
|
||||||
@ -621,6 +636,23 @@
|
|||||||
:cause cause)
|
:cause cause)
|
||||||
team)))))
|
team)))))
|
||||||
|
|
||||||
|
(defn add-organization-info-to-teams
|
||||||
|
"Enriches teams with organization information using one batched Nitrate request.
|
||||||
|
Teams absent from the Nitrate response are returned unchanged.
|
||||||
|
Rejects the request when Nitrate does not return a valid batch response."
|
||||||
|
[cfg teams params]
|
||||||
|
(let [request-params (assoc (or params {}) :team-ids (mapv :id teams))
|
||||||
|
teams-with-organization (call cfg :get-teams-organizations request-params)]
|
||||||
|
(when (nil? teams-with-organization)
|
||||||
|
(ex/raise :type :nitrate-unavailable
|
||||||
|
:hint "nitrate did not return a valid teams organization response"))
|
||||||
|
(let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)]
|
||||||
|
(mapv (fn [{:keys [id] :as team}]
|
||||||
|
(if-let [team-with-organization (get organizations-by-team id)]
|
||||||
|
(apply-organization-info-to-team team team-with-organization)
|
||||||
|
team))
|
||||||
|
teams))))
|
||||||
|
|
||||||
(defn set-team-organization
|
(defn set-team-organization
|
||||||
"Associates a team with an organization in Nitrate.
|
"Associates a team with an organization in Nitrate.
|
||||||
Requires organization-id and is-default in params.
|
Requires organization-id and is-default in params.
|
||||||
@ -637,3 +669,17 @@
|
|||||||
:context {:team-id (:id team)
|
:context {:team-id (:id team)
|
||||||
:organization-id (:organization-id params)}))
|
:organization-id (:organization-id params)}))
|
||||||
team))
|
team))
|
||||||
|
|
||||||
|
(defn assert-membership
|
||||||
|
"Verifies that the user is a member of the organization.
|
||||||
|
Raises an exception if the organization doesn't exist or the user is not a member."
|
||||||
|
[cfg profile-id organization-id]
|
||||||
|
(let [membership (call cfg :get-organization-membership {:profile-id profile-id
|
||||||
|
:organization-id organization-id})]
|
||||||
|
(when-not (:organization-id membership)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :organization-does-not-exist))
|
||||||
|
|
||||||
|
(when-not (:is-member membership)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :user-doesnt-belong-organization))))
|
||||||
|
|||||||
@ -261,25 +261,30 @@
|
|||||||
(defn- wrap-nitrate-sso
|
(defn- wrap-nitrate-sso
|
||||||
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
||||||
|
|
||||||
Resolves the organization/team context from request params using priority order:
|
Resolves the organization/team context from request params:
|
||||||
1. Explicit :organization-id param
|
1. Explicit :organization-id param identifies the organization directly
|
||||||
2. Explicit :team-id param
|
2. The team comes from the first available of: explicit :team-id, explicit
|
||||||
3. Explicit :project-id param -> lookup project.team_id
|
:project-id -> lookup project.team_id, explicit :file-id -> lookup file's
|
||||||
4. Explicit :file-id param -> lookup file's team via join
|
team via join, or the :id param dispatched by ::rpc/id-type metadata
|
||||||
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
(:team, :project, or :file)
|
||||||
|
|
||||||
Once the context is resolved, checks if the user is authorized within that organization's
|
Once the context is resolved, checks if the user is authorized within that organization's
|
||||||
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
|
SSO session using nitrate/sso-session-authorized?, against the organization when it is
|
||||||
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
|
known and against the team otherwise. The team is resolved either way, so the raised
|
||||||
|
error can carry it. Authorized results are cached by [profile-id cache-ref] for 15
|
||||||
|
minutes to avoid repeated lookups.
|
||||||
|
|
||||||
Only activates when:
|
Only activates when:
|
||||||
- Nitrate flag is enabled
|
- Nitrate flag is enabled
|
||||||
- Endpoint requires authentication (::auth true by default)
|
- Endpoint requires authentication (::auth true by default)
|
||||||
- Endpoint is not marked with ::nitrate/organization-sso false
|
- Endpoint is not marked with ::nitrate/organization-sso false
|
||||||
|
|
||||||
Raises :nitrate-sso-required error if user is not authorized in the organization."
|
Raises :nitrate-sso-required error if user is not authorized in the organization.
|
||||||
|
The error carries the resolved :organization-id and :team-id so the client can
|
||||||
|
restart the SSO flow (via :check-nitrate-sso) instead of reporting a plain
|
||||||
|
permission failure."
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
(if (and (contains? cf/flags :nitrate)
|
(if (and (contains? cf/flags :admin-console)
|
||||||
(::auth mdata true) ;; only for endpoints that needs auth
|
(::auth mdata true) ;; only for endpoints that needs auth
|
||||||
(::nitrate/sso mdata true))
|
(::nitrate/sso mdata true))
|
||||||
(fn [cfg params]
|
(fn [cfg params]
|
||||||
@ -302,17 +307,22 @@
|
|||||||
cached (cache/get organization-sso-auth-cache cache-key)
|
cached (cache/get organization-sso-auth-cache cache-key)
|
||||||
result (if (some? cached)
|
result (if (some? cached)
|
||||||
cached
|
cached
|
||||||
(let [team-id (when-not organization-id
|
;; The team is resolved even when the organization is
|
||||||
(or team-id
|
;; already known: the client needs it to restart the
|
||||||
|
;; SSO flow without sending non-members through the
|
||||||
|
;; organization's identity provider.
|
||||||
|
(let [team-id (or team-id
|
||||||
(when project-id
|
(when project-id
|
||||||
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
|
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
|
||||||
|
(when file-id
|
||||||
(:id (teams/get-team-for-file cfg file-id))))
|
(:id (teams/get-team-for-file cfg file-id))))
|
||||||
request (-> (meta params) (get ::http/request))
|
request (-> (meta params) (get ::http/request))
|
||||||
{:keys [authorized sso]} (if organization-id
|
{:keys [authorized sso]} (if organization-id
|
||||||
(nitrate/sso-session-authorized? cfg organization-id nil request)
|
(nitrate/sso-session-authorized? cfg organization-id nil request)
|
||||||
(nitrate/sso-session-authorized? cfg nil team-id request))
|
(nitrate/sso-session-authorized? cfg nil team-id request))
|
||||||
entry {:authorized authorized
|
entry {:authorized authorized
|
||||||
:organization-id (:organization-id sso)}]
|
:organization-id (or (:organization-id sso) organization-id)
|
||||||
|
:team-id team-id}]
|
||||||
(when authorized
|
(when authorized
|
||||||
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
||||||
entry))]
|
entry))]
|
||||||
@ -320,6 +330,8 @@
|
|||||||
(f cfg params)
|
(f cfg params)
|
||||||
(ex/raise :type :authentication
|
(ex/raise :type :authentication
|
||||||
:code :nitrate-sso-required
|
:code :nitrate-sso-required
|
||||||
|
:organization-id (:organization-id result)
|
||||||
|
:team-id (:team-id result)
|
||||||
:hint "organization SSO authentication required")))
|
:hint "organization SSO authentication required")))
|
||||||
(f cfg params))))
|
(f cfg params))))
|
||||||
f))
|
f))
|
||||||
@ -430,7 +442,7 @@
|
|||||||
[cfg]
|
[cfg]
|
||||||
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
|
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
|
||||||
mods (cond->> (list 'app.rpc.management.exporter)
|
mods (cond->> (list 'app.rpc.management.exporter)
|
||||||
(contains? cf/flags :nitrate)
|
(contains? cf/flags :admin-console)
|
||||||
(cons 'app.rpc.management.nitrate))]
|
(cons 'app.rpc.management.nitrate))]
|
||||||
|
|
||||||
(->> (apply sv/scan-ns mods)
|
(->> (apply sv/scan-ns mods)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.auth :as auth]
|
[app.auth :as auth]
|
||||||
[app.auth.oidc :as oidc]
|
[app.auth.oidc :as oidc]
|
||||||
|
[app.auth.passwords :as passwords]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
@ -182,6 +183,7 @@
|
|||||||
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
|
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
|
||||||
nil))]
|
nil))]
|
||||||
|
|
||||||
|
(passwords/validate-password password)
|
||||||
(->> (validate-token token)
|
(->> (validate-token token)
|
||||||
(update-password conn))
|
(update-password conn))
|
||||||
|
|
||||||
@ -240,6 +242,9 @@
|
|||||||
:code :email-as-password
|
:code :email-as-password
|
||||||
:hint "you can't use your email as password"))
|
:hint "you can't use your email as password"))
|
||||||
|
|
||||||
|
;; Validate password strength against common password dictionary
|
||||||
|
(passwords/validate-password (:password params))
|
||||||
|
|
||||||
(when (eml/has-bounce-reports? cfg (:email params))
|
(when (eml/has-bounce-reports? cfg (:email params))
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :email-has-permanent-bounces
|
:code :email-has-permanent-bounces
|
||||||
@ -258,7 +263,8 @@
|
|||||||
(validate-register-attempt! cfg params)
|
(validate-register-attempt! cfg params)
|
||||||
|
|
||||||
(let [email (profile/clean-email email)
|
(let [email (profile/clean-email email)
|
||||||
profile (profile/get-profile-by-email pool email)]
|
profile (profile/get-profile-by-email pool email)
|
||||||
|
fullname (d/normalize-string fullname)]
|
||||||
|
|
||||||
;; SECURITY: refuse to issue a prepared-register token when an active
|
;; SECURITY: refuse to issue a prepared-register token when an active
|
||||||
;; profile already exists for this email.
|
;; profile already exists for this email.
|
||||||
@ -359,6 +365,9 @@
|
|||||||
is-active (:is-active params false)
|
is-active (:is-active params false)
|
||||||
theme (:theme params nil)
|
theme (:theme params nil)
|
||||||
email (str/lower email)
|
email (str/lower email)
|
||||||
|
fullname (d/normalize-string (:fullname params))
|
||||||
|
locale (d/normalize-string locale)
|
||||||
|
theme (some-> theme d/normalize-string not-empty)
|
||||||
|
|
||||||
photo-id (some->> (or (:oidc/picture props)
|
photo-id (some->> (or (:oidc/picture props)
|
||||||
(:google/picture props)
|
(:google/picture props)
|
||||||
@ -367,7 +376,7 @@
|
|||||||
(import-profile-picture cfg))
|
(import-profile-picture cfg))
|
||||||
|
|
||||||
params {:id id
|
params {:id id
|
||||||
:fullname (:fullname params)
|
:fullname fullname
|
||||||
:email email
|
:email email
|
||||||
:auth-backend backend
|
:auth-backend backend
|
||||||
:lang locale
|
:lang locale
|
||||||
|
|||||||
@ -19,8 +19,9 @@
|
|||||||
[app.http.sse :as sse]
|
[app.http.sse :as sse]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
[app.rpc.commands.media :as media-cmd]
|
[app.rpc.commands.media :as media-cmd]
|
||||||
[app.rpc.commands.projects :as projects]
|
[app.rpc.commands.projects :as projects]
|
||||||
@ -62,7 +63,8 @@
|
|||||||
:bucket "tempfile"})]
|
:bucket "tempfile"})]
|
||||||
|
|
||||||
(-> (cf/get :public-uri)
|
(-> (cf/get :public-uri)
|
||||||
(u/join "/assets/by-id/")
|
(u/ensure-path-slash)
|
||||||
|
(u/join "assets/by-id/")
|
||||||
(u/join (str (:id object)))))
|
(u/join (str (:id object)))))
|
||||||
|
|
||||||
(finally
|
(finally
|
||||||
@ -104,7 +106,11 @@
|
|||||||
(try
|
(try
|
||||||
(case (int version)
|
(case (int version)
|
||||||
1 (bf.v1/import-files! cfg)
|
1 (bf.v1/import-files! cfg)
|
||||||
3 (bf.v3/import-files! cfg))
|
3 (bf.v3/import-files! cfg)
|
||||||
|
(throw (ex-info (str "Unsupported binfile version: " version)
|
||||||
|
{:type :validation
|
||||||
|
:code :unsupported-version
|
||||||
|
:version version})))
|
||||||
(finally
|
(finally
|
||||||
(when owned?
|
(when owned?
|
||||||
(fs/delete input-path))))]
|
(fs/delete input-path))))]
|
||||||
@ -122,58 +128,56 @@
|
|||||||
[:name [:or [:string {:max 250}]
|
[:name [:or [:string {:max 250}]
|
||||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:file-id {:optional true} ::sm/uuid]
|
[:version {:optional true} [:enum 1 3]]
|
||||||
[:version {:optional true} ::sm/int]
|
[:file {:optional true} media.v/schema:upload]
|
||||||
[:file {:optional true} media/schema:upload]
|
|
||||||
[:upload-id {:optional true} ::sm/uuid]]
|
[:upload-id {:optional true} ::sm/uuid]]
|
||||||
[:fn {:error/message "one of :file or :upload-id is required"}
|
[:fn {:error/message "one of :file or :upload-id is required"}
|
||||||
(fn [{:keys [file upload-id]}]
|
(fn [{:keys [file upload-id]}]
|
||||||
(or (some? file) (some? upload-id)))]])
|
(or (some? file) (some? upload-id)))]])
|
||||||
|
|
||||||
(sv/defmethod ::import-binfile
|
(sv/defmethod ::import-binfile
|
||||||
"Import a penpot file in a binary format. If `file-id` is provided,
|
"Import a penpot file in a binary format.
|
||||||
an in-place import will be performed instead of creating a new file.
|
|
||||||
|
|
||||||
The in-place imports are only supported for binfile-v3 and when a
|
|
||||||
.penpot file only contains one penpot file.
|
|
||||||
|
|
||||||
The file content may be provided either as a multipart `file` upload
|
The file content may be provided either as a multipart `file` upload
|
||||||
or as an `upload-id` referencing a completed chunked-upload session,
|
or as an `upload-id` referencing a completed chunked-upload session,
|
||||||
which allows importing files larger than the multipart size limit.
|
which allows importing files larger than the multipart size limit.
|
||||||
"
|
"
|
||||||
{::doc/added "1.15"
|
{::doc/added "1.15"
|
||||||
::doc/changes ["1.20" "Add file-id param for in-place import"
|
::doc/changes [["1.20" "Set default version to 3"]
|
||||||
"1.20" "Set default version to 3"
|
["2.15" "Add upload-id param for chunked upload support"]]
|
||||||
"2.15" "Add upload-id param for chunked upload support"]
|
|
||||||
|
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sse/stream? true
|
::sse/stream? true
|
||||||
::sm/params schema:import-binfile}
|
::sm/params schema:import-binfile
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
|
::climit/id [[:import-binfile/by-profile ::rpc/profile-id]
|
||||||
|
[:import-binfile/global]]}
|
||||||
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
|
||||||
(projects/check-edition-permissions! pool profile-id project-id)
|
(projects/check-edition-permissions! pool profile-id project-id)
|
||||||
(let [version (or version 3)
|
(let [params (if (some? upload-id)
|
||||||
|
(let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)]
|
||||||
|
(assoc params :file file))
|
||||||
|
params)
|
||||||
|
|
||||||
|
version (or version
|
||||||
|
(case (bfc/parse-file-format (-> params :file :path))
|
||||||
|
:binfile-v1 1
|
||||||
|
:binfile-v3 3))
|
||||||
|
|
||||||
params (-> params
|
params (-> params
|
||||||
(assoc :profile-id profile-id)
|
(assoc :profile-id profile-id)
|
||||||
(assoc :version version))
|
(assoc :version version))
|
||||||
|
|
||||||
cfg (cond-> cfg
|
|
||||||
(uuid? file-id)
|
|
||||||
(assoc ::bfc/file-id file-id))
|
|
||||||
|
|
||||||
params
|
|
||||||
(if (some? upload-id)
|
|
||||||
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
|
|
||||||
(assoc params :file file))
|
|
||||||
params)
|
|
||||||
|
|
||||||
manifest
|
manifest
|
||||||
(case (int version)
|
(case (int version)
|
||||||
1 nil
|
1 nil
|
||||||
3 (bf.v3/get-manifest (-> params :file :path)))]
|
3 (bf.v3/get-manifest (-> params :file :path))
|
||||||
|
(throw (ex-info (str "Unsupported binfile version: " version)
|
||||||
|
{:type :validation
|
||||||
|
:code :unsupported-version
|
||||||
|
:version version})))]
|
||||||
|
|
||||||
(with-meta
|
(with-meta
|
||||||
(sse/response (partial import-binfile cfg params))
|
(sse/response (partial import-binfile cfg params))
|
||||||
{::audit/props {:file nil
|
{::audit/props {:file nil
|
||||||
:file-id file-id
|
|
||||||
:generated-by (:generated-by manifest)
|
:generated-by (:generated-by manifest)
|
||||||
:referer (:referer manifest)}})))
|
:referer (:referer manifest)}})))
|
||||||
|
|||||||
@ -14,22 +14,25 @@
|
|||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email :as eml]
|
[app.email :as eml]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
[app.util.services :as sv]))
|
[app.util.services :as sv]))
|
||||||
|
|
||||||
(declare ^:private send-user-feedback!)
|
(declare ^:private send-user-feedback!)
|
||||||
|
|
||||||
(def ^:private schema:send-user-feedback
|
(def schema:send-user-feedback
|
||||||
[:map {:title "send-user-feedback"}
|
[:map {:title "send-user-feedback"}
|
||||||
[:subject [:string {:max 500}]]
|
[:subject [:string {:max 500}]]
|
||||||
[:content [:string {:max 2500}]]
|
[:content [:string {:max 2500}]]
|
||||||
[:type {:optional true} :string]
|
[:type {:optional true} :string]
|
||||||
[:error-href {:optional true} [:string {:max 2500}]]
|
[:error-href {:optional true} [:string {:max 2500}]]
|
||||||
[:error-report {:optional true} :string]])
|
[:error-report {:optional true} [:string {:max 1048576}]]])
|
||||||
|
|
||||||
(sv/defmethod ::send-user-feedback
|
(sv/defmethod ::send-user-feedback
|
||||||
{::doc/added "1.18"
|
{::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id]
|
||||||
|
[:send-user-feedback/global]]
|
||||||
|
::doc/added "1.18"
|
||||||
::sm/params schema:send-user-feedback}
|
::sm/params schema:send-user-feedback}
|
||||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
|
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
|
||||||
(when-not (contains? cf/flags :user-feedback)
|
(when-not (contains? cf/flags :user-feedback)
|
||||||
|
|||||||
@ -1069,6 +1069,25 @@
|
|||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))
|
(db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
|
;; --- Library relation helpers
|
||||||
|
|
||||||
|
(defn- check-library-team-ownership!
|
||||||
|
"Verify that file and library belong to the same team.
|
||||||
|
Prevents cross-team library relation injection."
|
||||||
|
[conn file-id library-id]
|
||||||
|
(let [sql "SELECT EXISTS (
|
||||||
|
SELECT 1 FROM file AS f
|
||||||
|
JOIN project AS fp ON (fp.id = f.project_id)
|
||||||
|
JOIN file AS l ON (l.id = ?)
|
||||||
|
JOIN project AS lp ON (lp.id = l.project_id)
|
||||||
|
WHERE f.id = ? AND fp.team_id = lp.team_id
|
||||||
|
) AS ok"
|
||||||
|
row (db/exec-one! conn [sql library-id file-id])]
|
||||||
|
(when-not (:ok row)
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "file and library must belong to the same team"))))
|
||||||
|
|
||||||
;; --- MUTATION COMMAND: link-file-to-library
|
;; --- MUTATION COMMAND: link-file-to-library
|
||||||
|
|
||||||
(def sql:link-file-to-library
|
(def sql:link-file-to-library
|
||||||
@ -1104,6 +1123,7 @@
|
|||||||
|
|
||||||
(check-edition-permissions! conn profile-id file-id)
|
(check-edition-permissions! conn profile-id file-id)
|
||||||
(check-edition-permissions! conn profile-id library-id)
|
(check-edition-permissions! conn profile-id library-id)
|
||||||
|
(check-library-team-ownership! conn file-id library-id)
|
||||||
|
|
||||||
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
|
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
|
||||||
(when (contains? transitive-deps file-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}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||||
(check-edition-permissions! conn profile-id file-id)
|
(check-edition-permissions! conn profile-id file-id)
|
||||||
(check-edition-permissions! conn profile-id library-id)
|
(check-edition-permissions! conn profile-id library-id)
|
||||||
|
(check-library-team-ownership! conn file-id library-id)
|
||||||
(unlink-file-from-library conn params)
|
(unlink-file-from-library conn params)
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
@ -1159,6 +1180,7 @@
|
|||||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
|
||||||
(check-edition-permissions! conn profile-id file-id)
|
(check-edition-permissions! conn profile-id file-id)
|
||||||
(check-edition-permissions! conn profile-id library-id)
|
(check-edition-permissions! conn profile-id library-id)
|
||||||
|
(check-library-team-ownership! conn file-id library-id)
|
||||||
(update-sync conn params))
|
(update-sync conn params))
|
||||||
|
|
||||||
;; --- MUTATION COMMAND: ignore-sync
|
;; --- MUTATION COMMAND: ignore-sync
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
(ns app.rpc.commands.files-share
|
(ns app.rpc.commands.files-share
|
||||||
"Share link related rpc mutation methods."
|
"Share link related rpc mutation methods."
|
||||||
(:require
|
(:require
|
||||||
|
[app.binfile.common :as bfc]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
@ -43,7 +45,7 @@
|
|||||||
[conn {:keys [profile-id file-id pages who-comment who-inspect]}]
|
[conn {:keys [profile-id file-id pages who-comment who-inspect]}]
|
||||||
(let [pages (db/create-array conn "uuid" pages)
|
(let [pages (db/create-array conn "uuid" pages)
|
||||||
slink (db/insert! conn :share-link
|
slink (db/insert! conn :share-link
|
||||||
{:id (uuid/next)
|
{:id (uuid/random)
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
:who-comment who-comment
|
:who-comment who-comment
|
||||||
:who-inspect who-inspect
|
:who-inspect who-inspect
|
||||||
@ -66,5 +68,16 @@
|
|||||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}]
|
[{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}]
|
||||||
(let [slink (db/get-by-id conn :share-link id)]
|
(let [slink (db/get-by-id conn :share-link id)]
|
||||||
(files/check-edition-permissions! conn profile-id (:file-id slink))
|
(files/check-edition-permissions! conn profile-id (:file-id slink))
|
||||||
|
|
||||||
|
;; Verify caller owns this specific share-link, OR has admin access.
|
||||||
|
;; Note: :is-admin already includes :is-owner (see bfc/get-file-permissions),
|
||||||
|
;; so we only need to check :is-admin here.
|
||||||
|
(let [perms (bfc/get-file-permissions conn profile-id (:file-id slink))]
|
||||||
|
(when-not (or (= (:owner-id slink) profile-id)
|
||||||
|
(:is-admin perms))
|
||||||
|
(ex/raise :type :authorization
|
||||||
|
:code :not-share-link-owner
|
||||||
|
:hint "You can only delete share-links you created")))
|
||||||
|
|
||||||
(db/delete! conn :share-link {:id id})
|
(db/delete! conn :share-link {:id id})
|
||||||
nil))
|
nil))
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
[app.db.sql :as-alias sql]
|
[app.db.sql :as-alias sql]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as-alias climit]
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
@ -275,7 +275,7 @@
|
|||||||
[:map {:title "create-file-object-thumbnail"}
|
[:map {:title "create-file-object-thumbnail"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:object-id [:string {:max 250}]]
|
[:object-id [:string {:max 250}]]
|
||||||
[:media media/schema:upload]
|
[:media media.v/schema:upload]
|
||||||
[:tag {:optional true} [:string {:max 50}]]])
|
[:tag {:optional true} [:string {:max 50}]]])
|
||||||
|
|
||||||
(sv/defmethod ::create-file-object-thumbnail
|
(sv/defmethod ::create-file-object-thumbnail
|
||||||
@ -289,8 +289,8 @@
|
|||||||
::sm/params schema:create-file-object-thumbnail}
|
::sm/params schema:create-file-object-thumbnail}
|
||||||
|
|
||||||
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
|
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
|
||||||
(media/validate-media-type! media)
|
(media.v/validate-media-type! media)
|
||||||
(media/validate-media-size! media)
|
(media.v/validate-media-size! media)
|
||||||
|
|
||||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||||
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
|
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
|
||||||
@ -379,7 +379,7 @@
|
|||||||
[:map {:title "create-file-thumbnail"}
|
[:map {:title "create-file-thumbnail"}
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:revn ::sm/int]
|
[:revn ::sm/int]
|
||||||
[:media media/schema:upload]])
|
[:media media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::create-file-thumbnail
|
(sv/defmethod ::create-file-thumbnail
|
||||||
"Creates or updates the file thumbnail. Mainly used for paint the
|
"Creates or updates the file thumbnail. Mainly used for paint the
|
||||||
@ -394,8 +394,8 @@
|
|||||||
::sm/params schema:create-file-thumbnail}
|
::sm/params schema:create-file-thumbnail}
|
||||||
|
|
||||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||||
(media/validate-media-type! (:media params))
|
(media.v/validate-media-type! (:media params))
|
||||||
(media/validate-media-size! (:media params))
|
(media.v/validate-media-size! (:media params))
|
||||||
|
|
||||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as-alias climit]
|
[app.rpc.climit :as-alias climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
@ -38,10 +39,7 @@
|
|||||||
[datoteka.fs :as fs]
|
[datoteka.fs :as fs]
|
||||||
[datoteka.io :as io])
|
[datoteka.io :as io])
|
||||||
(:import
|
(:import
|
||||||
java.io.InputStream
|
|
||||||
java.io.OutputStream
|
java.io.OutputStream
|
||||||
java.io.SequenceInputStream
|
|
||||||
java.util.Collections
|
|
||||||
java.util.zip.ZipEntry
|
java.util.zip.ZipEntry
|
||||||
java.util.zip.ZipOutputStream))
|
java.util.zip.ZipOutputStream))
|
||||||
|
|
||||||
@ -95,31 +93,38 @@
|
|||||||
|
|
||||||
(declare create-font-variant)
|
(declare create-font-variant)
|
||||||
|
|
||||||
|
(defn- check-font-team-ownership!
|
||||||
|
"When font-id already has variants belonging to a different team,
|
||||||
|
raises :not-found to prevent cross-team font injection."
|
||||||
|
[conn team-id font-id]
|
||||||
|
(let [row (db/get* conn :team-font-variant
|
||||||
|
{:font-id font-id}
|
||||||
|
{::db/columns [:team-id]})]
|
||||||
|
(when (and row (not= (:team-id row) team-id))
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "font does not belong to this team"))))
|
||||||
|
|
||||||
(def ^:private schema:create-font-variant
|
(def ^:private schema:create-font-variant
|
||||||
[:and
|
|
||||||
[:map {:title "create-font-variant"}
|
[:map {:title "create-font-variant"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:font-id ::sm/uuid]
|
[:font-id ::sm/uuid]
|
||||||
[:font-family types.font/schema:font-family]
|
[:font-family types.font/schema:font-family]
|
||||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||||
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
[:font-style [::sm/one-of {:format "string"} valid-style]]
|
||||||
[:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]]
|
[:uploads [:map-of ::sm/text ::sm/uuid]]])
|
||||||
[:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]]
|
|
||||||
[:fn {:error/message "one of :data or :uploads is required"}
|
|
||||||
(fn [{:keys [data uploads]}]
|
|
||||||
(or (seq data) (seq uploads)))]])
|
|
||||||
|
|
||||||
(defn- prepare-font-data-from-uploads
|
(defn- prepare-font-data-from-uploads
|
||||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||||
session-id}` map) into a temp file, validates the media type and
|
session-id}` map) into a temp file, validates the media type and
|
||||||
size of every entry, and returns a `{mtype → path}` data map."
|
size of every entry, and returns a `{mtype → path}` data map."
|
||||||
[cfg {:keys [uploads] :as params}]
|
[cfg {:keys [::rpc/profile-id uploads] :as params}]
|
||||||
(let [data (reduce-kv
|
(let [data (reduce-kv
|
||||||
(fn [acc mtype session-id]
|
(fn [acc mtype session-id]
|
||||||
(let [assembled (assemble-chunks cfg session-id)]
|
(let [assembled (assemble-chunks cfg profile-id session-id)]
|
||||||
(-> {:mtype mtype :size (:size assembled)}
|
(-> {:mtype mtype :size (:size assembled)}
|
||||||
(media/validate-media-type! cm/font-types)
|
(media.v/validate-media-type! cm/font-types)
|
||||||
(media/validate-font-size!))
|
(media.v/validate-font-size!))
|
||||||
(assoc acc mtype (:path assembled))))
|
(assoc acc mtype (:path assembled))))
|
||||||
{}
|
{}
|
||||||
uploads)]
|
uploads)]
|
||||||
@ -128,54 +133,24 @@
|
|||||||
(assoc :data data)
|
(assoc :data data)
|
||||||
(dissoc :uploads))))
|
(dissoc :uploads))))
|
||||||
|
|
||||||
(defn- prepare-font-data-from-legacy
|
|
||||||
"Validates the media type and size of every entry in the legacy
|
|
||||||
`:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every
|
|
||||||
entry to a tempfile. Returns params with a normalised
|
|
||||||
`{mtype → path}` data map."
|
|
||||||
[{:keys [data] :as params}]
|
|
||||||
(let [data (reduce-kv
|
|
||||||
(fn [acc mtype content]
|
|
||||||
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
|
|
||||||
chunks (if (vector? content) content [content])
|
|
||||||
streams (map io/input-stream chunks)
|
|
||||||
streams (Collections/enumeration streams)]
|
|
||||||
|
|
||||||
;; Generate the tempfile from all chunks
|
|
||||||
(with-open [^OutputStream output (io/output-stream tmp)
|
|
||||||
^InputStream input (SequenceInputStream. streams)]
|
|
||||||
(io/copy input output))
|
|
||||||
|
|
||||||
;; Validate
|
|
||||||
(-> {:mtype mtype :size (fs/size tmp)}
|
|
||||||
(media/validate-media-type! cm/font-types)
|
|
||||||
(media/validate-font-size!))
|
|
||||||
|
|
||||||
(assoc acc mtype tmp)))
|
|
||||||
{}
|
|
||||||
data)]
|
|
||||||
(assoc params :data data)))
|
|
||||||
|
|
||||||
(sv/defmethod ::create-font-variant
|
(sv/defmethod ::create-font-variant
|
||||||
"Upload a font variant. Font data may be provided either as a
|
"Upload a font variant. Font data must be provided as an `:uploads`
|
||||||
Transit-encoded `:data` map (keyed by mime-type) for small fonts, or
|
map (keyed by mime-type, values are upload-session UUIDs from the
|
||||||
as an `:uploads` map (keyed by mime-type, values are upload-session
|
chunked-upload API)."
|
||||||
UUIDs from the chunked-upload API) for large fonts. Exactly one of
|
|
||||||
the two must be present."
|
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
::doc/changes ["2.16" "Add :uploads param for chunked upload support"]
|
::doc/changes [["2.16" "Add :uploads param for chunked upload support"]
|
||||||
|
["2.18" "Remove :data param, use :uploads exclusively"]]
|
||||||
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
::climit/id [[:process-font/by-profile ::rpc/profile-id]
|
||||||
[:process-font/global]]
|
[:process-font/global]]
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:create-font-variant}
|
::sm/params schema:create-font-variant}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id] :as params}]
|
||||||
(teams/check-edition-permissions! pool profile-id team-id)
|
(teams/check-edition-permissions! pool profile-id team-id)
|
||||||
|
(check-font-team-ownership! pool team-id font-id)
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/team-id team-id})
|
::quotes/team-id team-id})
|
||||||
(let [params (if (some? uploads)
|
(let [params (db/tx-run! cfg prepare-font-data-from-uploads params)]
|
||||||
(db/tx-run! cfg prepare-font-data-from-uploads params)
|
|
||||||
(prepare-font-data-from-legacy params))]
|
|
||||||
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
||||||
|
|
||||||
(defn create-font-variant
|
(defn create-font-variant
|
||||||
@ -229,9 +204,7 @@
|
|||||||
(let [tpoint (ct/tpoint)
|
(let [tpoint (ct/tpoint)
|
||||||
mtypes (vec (keys data))
|
mtypes (vec (keys data))
|
||||||
total-size (reduce-kv (fn [acc _ content]
|
total-size (reduce-kv (fn [acc _ content]
|
||||||
(+ acc (if (bytes? content)
|
(+ acc (fs/size content)))
|
||||||
(alength ^bytes content)
|
|
||||||
(fs/size content))))
|
|
||||||
0
|
0
|
||||||
data)]
|
data)]
|
||||||
|
|
||||||
@ -370,7 +343,7 @@
|
|||||||
(defn- make-temporal-storage-object
|
(defn- make-temporal-storage-object
|
||||||
[cfg profile-id content]
|
[cfg profile-id content]
|
||||||
(let [storage (sto/resolve cfg)
|
(let [storage (sto/resolve cfg)
|
||||||
content (media/check-input content)
|
content (media.v/check-input content)
|
||||||
hash (sto/calculate-hash (:path content))
|
hash (sto/calculate-hash (:path content))
|
||||||
data (-> (sto/content (:path content))
|
data (-> (sto/content (:path content))
|
||||||
(sto/wrap-with-hash hash))
|
(sto/wrap-with-hash hash))
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
|
[app.media.svg :as svg]
|
||||||
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as climit]
|
[app.rpc.climit :as climit]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
@ -44,7 +46,7 @@
|
|||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:is-local ::sm/boolean]
|
[:is-local ::sm/boolean]
|
||||||
[:name [:string {:max 250}]]
|
[:name [:string {:max 250}]]
|
||||||
[:content media/schema:upload]])
|
[:content media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::upload-file-media-object
|
(sv/defmethod ::upload-file-media-object
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
@ -53,8 +55,8 @@
|
|||||||
[:process-image/global]]}
|
[:process-image/global]]}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
||||||
(files/check-edition-permissions! pool profile-id file-id)
|
(files/check-edition-permissions! pool profile-id file-id)
|
||||||
(media/validate-media-type! content)
|
(media.v/validate-media-type! content)
|
||||||
(media/validate-media-size! content)
|
(media.v/validate-media-size! content)
|
||||||
|
|
||||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
;; We get the minimal file for proper checking if
|
;; We get the minimal file for proper checking if
|
||||||
@ -113,13 +115,22 @@
|
|||||||
|
|
||||||
(defn- process-main-image
|
(defn- process-main-image
|
||||||
[info]
|
[info]
|
||||||
(let [hash (sto/calculate-hash (:path info))
|
(let [path (:path info)
|
||||||
data (-> (sto/content (:path info))
|
mtype (:mtype info)
|
||||||
|
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/wrap-with-hash hash))]
|
||||||
{::sto/content data
|
{::sto/content data
|
||||||
::sto/deduplicate? true
|
::sto/deduplicate? true
|
||||||
::sto/touched-at (:ts info)
|
::sto/touched-at (:ts info)
|
||||||
:content-type (:mtype info)
|
:content-type mtype
|
||||||
:bucket "file-media-object"}))
|
:bucket "file-media-object"}))
|
||||||
|
|
||||||
(defn- process-thumb-image
|
(defn- process-thumb-image
|
||||||
@ -261,8 +272,13 @@
|
|||||||
(clone-file-media-object cfg params))
|
(clone-file-media-object cfg params))
|
||||||
|
|
||||||
(defn clone-file-media-object
|
(defn clone-file-media-object
|
||||||
[{:keys [::db/conn]} {:keys [id file-id is-local]}]
|
[{:keys [::db/conn] :as cfg} {:keys [id file-id is-local] :as params}]
|
||||||
(let [mobj (db/get-by-id conn :file-media-object id)]
|
(let [mobj (db/get-by-id conn :file-media-object id)]
|
||||||
|
(when-not mobj
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "source media object not found"))
|
||||||
|
(files/check-read-permissions! conn (::rpc/profile-id params) (:file-id mobj))
|
||||||
(db/insert! conn :file-media-object
|
(db/insert! conn :file-media-object
|
||||||
{:id (uuid/next)
|
{:id (uuid/next)
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
@ -278,7 +294,7 @@
|
|||||||
|
|
||||||
(def ^:private schema:create-upload-session
|
(def ^:private schema:create-upload-session
|
||||||
[:map {:title "create-upload-session"}
|
[:map {:title "create-upload-session"}
|
||||||
[:total-chunks ::sm/int]])
|
[:total-chunks [::sm/int {:min 1}]]])
|
||||||
|
|
||||||
(def ^:private schema:create-upload-session-result
|
(def ^:private schema:create-upload-session-result
|
||||||
[:map {:title "create-upload-session-result"}
|
[:map {:title "create-upload-session-result"}
|
||||||
@ -315,7 +331,7 @@
|
|||||||
[:map {:title "upload-chunk"}
|
[:map {:title "upload-chunk"}
|
||||||
[:session-id ::sm/uuid]
|
[:session-id ::sm/uuid]
|
||||||
[:index ::sm/int]
|
[:index ::sm/int]
|
||||||
[:content media/schema:upload]])
|
[:content media.v/schema:upload]])
|
||||||
|
|
||||||
(def ^:private schema:upload-chunk-result
|
(def ^:private schema:upload-chunk-result
|
||||||
[:map {:title "upload-chunk-result"}
|
[:map {:title "upload-chunk-result"}
|
||||||
@ -386,14 +402,15 @@
|
|||||||
(defn assemble-chunks
|
(defn assemble-chunks
|
||||||
"Validates that all expected chunks are present for `session-id` and
|
"Validates that all expected chunks are present for `session-id` and
|
||||||
concatenates them into a single temporary file. Returns a map
|
concatenates them into a single temporary file. Returns a map
|
||||||
conforming to `media/schema:upload` with `:filename`, `:path` and
|
conforming to `media.v/schema:upload` with `:filename`, `:path` and
|
||||||
`:size`.
|
`:size`.
|
||||||
|
|
||||||
Raises a :validation/:missing-chunks error when the number of stored
|
Raises a :validation/:missing-chunks error when the number of stored
|
||||||
chunks does not match `:total-chunks` recorded in the session row.
|
chunks does not match `:total-chunks` recorded in the session row.
|
||||||
|
Raises :not-found when the session does not belong to `profile-id`.
|
||||||
Deletes the session row from `upload_session` on success."
|
Deletes the session row from `upload_session` on success."
|
||||||
[{:keys [::db/conn] :as cfg} session-id]
|
[{:keys [::db/conn] :as cfg} profile-id session-id]
|
||||||
(let [session (db/get conn :upload-session {:id session-id})
|
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
|
||||||
chunks (get-upload-chunks conn session-id)]
|
chunks (get-upload-chunks conn session-id)]
|
||||||
|
|
||||||
(when (not= (count chunks) (:total-chunks session))
|
(when (not= (count chunks) (:total-chunks session))
|
||||||
@ -436,12 +453,12 @@
|
|||||||
|
|
||||||
(db/tx-run! cfg
|
(db/tx-run! cfg
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
(let [content (assemble-chunks cfg session-id)
|
(let [content (assemble-chunks cfg profile-id session-id)
|
||||||
content (-> content
|
content (-> content
|
||||||
(assoc :filename (str "upload:" name))
|
(assoc :filename (str "upload:" name))
|
||||||
(assoc :mtype mtype)
|
(assoc :mtype mtype)
|
||||||
(media/validate-media-type!)
|
(media.v/validate-media-type!)
|
||||||
(media/validate-media-size!))
|
(media.v/validate-media-size!))
|
||||||
mobj (create-file-media-object cfg (assoc params
|
mobj (create-file-media-object cfg (assoc params
|
||||||
:id id
|
:id id
|
||||||
:from-chunks? true
|
:from-chunks? true
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
[app.common.json :as json]
|
[app.common.json :as json]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
[app.common.types.organization :as cto]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
@ -41,17 +41,6 @@
|
|||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :cant-move-default-team))))
|
:code :cant-move-default-team))))
|
||||||
|
|
||||||
(defn assert-membership [cfg profile-id organization-id]
|
|
||||||
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
|
||||||
:organization-id organization-id})]
|
|
||||||
(when-not (:organization-id membership)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :organization-does-not-exist))
|
|
||||||
|
|
||||||
(when-not (:is-member membership)
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :user-doesnt-belong-organization))))
|
|
||||||
|
|
||||||
|
|
||||||
(def schema:connectivity
|
(def schema:connectivity
|
||||||
[:map {:title "nitrate-connectivity"}
|
[:map {:title "nitrate-connectivity"}
|
||||||
@ -59,7 +48,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-nitrate-connectivity
|
(sv/defmethod ::get-nitrate-connectivity
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.14"
|
::doc/added "2.18"
|
||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result schema:connectivity}
|
::sm/result schema:connectivity}
|
||||||
[cfg _params]
|
[cfg _params]
|
||||||
@ -75,7 +64,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-subscription-warning
|
(sv/defmethod ::get-subscription-warning
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.14"
|
::doc/added "2.18"
|
||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result schema:subscription-warning}
|
::sm/result schema:subscription-warning}
|
||||||
[cfg {:keys [::rpc/profile-id]}]
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
@ -91,7 +80,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::redeem-nitrate-activation-code
|
(sv/defmethod ::redeem-nitrate-activation-code
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.14"
|
::doc/added "2.18"
|
||||||
::sm/params schema:redeem-activation-code-params
|
::sm/params schema:redeem-activation-code-params
|
||||||
::sm/result schema:redeem-activation-code-result}
|
::sm/result schema:redeem-activation-code-result}
|
||||||
[cfg {:keys [::rpc/profile-id activation-code]}]
|
[cfg {:keys [::rpc/profile-id activation-code]}]
|
||||||
@ -112,6 +101,7 @@
|
|||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code (case status
|
:code (case status
|
||||||
410 :expired-activation-code
|
410 :expired-activation-code
|
||||||
|
409 :used-activation-code
|
||||||
:invalid-activation-code)
|
:invalid-activation-code)
|
||||||
:cause cause)
|
:cause cause)
|
||||||
(throw cause)))))))
|
(throw cause)))))))
|
||||||
@ -123,7 +113,7 @@
|
|||||||
"Returns a Base64-encoded JSON file requesting a Nitrate activation code.
|
"Returns a Base64-encoded JSON file requesting a Nitrate activation code.
|
||||||
Payload includes nitrateId, publicKey, email and iat."
|
Payload includes nitrateId, publicKey, email and iat."
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.20"
|
::doc/added "2.18"
|
||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result ::sm/text}
|
::sm/result ::sm/text}
|
||||||
[cfg {:keys [::rpc/profile-id]}]
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
@ -335,7 +325,7 @@
|
|||||||
(when-not skip-validation
|
(when-not skip-validation
|
||||||
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
|
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
|
||||||
|
|
||||||
(assert-membership cfg profile-id id)
|
(nitrate/assert-membership cfg profile-id id)
|
||||||
|
|
||||||
;; delete only eligible teams (non-protected and without files)
|
;; delete only eligible teams (non-protected and without files)
|
||||||
(doseq [id deletable-team-ids]
|
(doseq [id deletable-team-ids]
|
||||||
@ -371,7 +361,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::leave-organization
|
(sv/defmethod ::leave-organization
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.15"
|
::doc/added "2.18"
|
||||||
::sm/params schema:leave-organization
|
::sm/params schema:leave-organization
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
@ -415,22 +405,22 @@
|
|||||||
[:organization-name ::sm/text]])
|
[:organization-name ::sm/text]])
|
||||||
|
|
||||||
(sv/defmethod ::remove-team-from-organization
|
(sv/defmethod ::remove-team-from-organization
|
||||||
{::doc/added "2.17"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:remove-team-from-organization}
|
::sm/params schema:remove-team-from-organization}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
||||||
|
|
||||||
(assert-is-owner cfg profile-id team-id)
|
(assert-is-owner cfg profile-id team-id)
|
||||||
(assert-not-default-team cfg team-id)
|
(assert-not-default-team cfg team-id)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(nitrate/assert-membership cfg profile-id organization-id)
|
||||||
;; Check moveTeams permission on the source organization
|
;; Check moveTeams permission on the source organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||||
{:organization-id organization-id})]
|
{:organization-id organization-id})]
|
||||||
(if (nil? organization-perms)
|
(if (nil? organization-perms)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "Unable to verify organization permissions")
|
:hint "Unable to verify organization permissions")
|
||||||
(when-not (nitrate-perms/allowed? :move-team
|
(when-not (cto/allowed? :move-team
|
||||||
{:organization-perms organization-perms
|
{:organization-perms organization-perms
|
||||||
:profile-id profile-id})
|
:profile-id profile-id})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -462,7 +452,7 @@
|
|||||||
Returns {:allows-anybody bool :external-emails [...]}"
|
Returns {:allows-anybody bool :external-emails [...]}"
|
||||||
[{:keys [::db/conn] :as cfg} team-id organization-id]
|
[{:keys [::db/conn] :as cfg} team-id organization-id]
|
||||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})
|
(let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})
|
||||||
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization-perms})]
|
allows-anybody (cto/allowed? :add-anybody-to-team {:organization-perms organization-perms})]
|
||||||
(if allows-anybody
|
(if allows-anybody
|
||||||
{:allows-anybody true :external-emails []}
|
{:allows-anybody true :external-emails []}
|
||||||
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
|
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
|
||||||
@ -484,16 +474,16 @@
|
|||||||
|
|
||||||
(sv/defmethod ::add-team-to-organization
|
(sv/defmethod ::add-team-to-organization
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.18"
|
||||||
::sm/params schema:add-team-to-organization
|
::sm/params schema:add-team-to-organization
|
||||||
::db/transaction true}
|
::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-is-owner cfg profile-id team-id)
|
||||||
(assert-not-default-team cfg team-id)
|
(assert-not-default-team cfg team-id)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(nitrate/assert-membership cfg profile-id organization-id)
|
||||||
|
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
(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})
|
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-id (get-in team-with-organization [:organization :id])
|
||||||
@ -517,7 +507,7 @@
|
|||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "Unable to verify organization permissions"))
|
:hint "Unable to verify organization permissions"))
|
||||||
(when-not (nitrate-perms/allowed? :move-team
|
(when-not (cto/allowed? :move-team
|
||||||
{:organization-perms source-organization-perms
|
{:organization-perms source-organization-perms
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:target-organization-same-owner? target-organization-same-owner?})
|
:target-organization-same-owner? target-organization-same-owner?})
|
||||||
@ -526,7 +516,7 @@
|
|||||||
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))
|
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))
|
||||||
|
|
||||||
;; Always check target create-teams permission (new/add and move flows).
|
;; Always check target create-teams permission (new/add and move flows).
|
||||||
(when-not (nitrate-perms/allowed? :create-team
|
(when-not (cto/allowed? :create-team
|
||||||
{:organization-perms target-organization-perms
|
{:organization-perms target-organization-perms
|
||||||
:profile-id profile-id})
|
:profile-id profile-id})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -540,7 +530,7 @@
|
|||||||
(remove #{profile-id})
|
(remove #{profile-id})
|
||||||
(remove organization-member-ids-before))]
|
(remove organization-member-ids-before))]
|
||||||
(doseq [member-id new-member-ids]
|
(doseq [member-id new-member-ids]
|
||||||
(teams/initialize-user-in-nitrate-organization cfg member-id organization-id)))
|
(teams/initialize-user-in-organization cfg member-id organization-id)))
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(let [team (nitrate/call cfg :set-team-organization {:team-id team-id
|
(let [team (nitrate/call cfg :set-team-organization {:team-id team-id
|
||||||
@ -569,13 +559,13 @@
|
|||||||
|
|
||||||
(sv/defmethod ::check-organization-members
|
(sv/defmethod ::check-organization-members
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.18"
|
||||||
::sm/params schema:check-organization-members-params
|
::sm/params schema:check-organization-members-params
|
||||||
::sm/result [:map-of :string :boolean]
|
::sm/result [:map-of :string :boolean]
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
||||||
(or (when (contains? cf/flags :nitrate)
|
(or (when (contains? cf/flags :admin-console)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(nitrate/assert-membership cfg profile-id organization-id)
|
||||||
(let [emails-array (db/create-array conn "text" emails)
|
(let [emails-array (db/create-array conn "text" emails)
|
||||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||||
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
||||||
@ -594,16 +584,16 @@
|
|||||||
|
|
||||||
(sv/defmethod ::all-organization-members-in-team
|
(sv/defmethod ::all-organization-members-in-team
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.18"
|
||||||
::sm/params schema:all-organization-members-in-team-params
|
::sm/params schema:all-organization-members-in-team-params
|
||||||
::sm/result ::sm/boolean}
|
::sm/result ::sm/boolean}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :insufficient-permissions))
|
:code :insufficient-permissions))
|
||||||
(assert-membership cfg profile-id organization-id)
|
(nitrate/assert-membership cfg profile-id organization-id)
|
||||||
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||||
organization-member-ids (into #{} organization-members)
|
organization-member-ids (into #{} organization-members)
|
||||||
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||||
@ -618,11 +608,11 @@
|
|||||||
|
|
||||||
(sv/defmethod ::all-team-members-in-organizations
|
(sv/defmethod ::all-team-members-in-organizations
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.18"
|
||||||
::sm/params schema:all-team-members-in-organizations-params
|
::sm/params schema:all-team-members-in-organizations-params
|
||||||
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
|
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -631,7 +621,7 @@
|
|||||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||||
team-member-ids (into #{} (map :profile-id team-members))]
|
team-member-ids (into #{} (map :profile-id team-members))]
|
||||||
;; Validate requester membership in all organizations before fetching members.
|
;; Validate requester membership in all organizations before fetching members.
|
||||||
(run! #(assert-membership cfg profile-id %) organization-ids)
|
(run! #(nitrate/assert-membership cfg profile-id %) organization-ids)
|
||||||
|
|
||||||
(into {}
|
(into {}
|
||||||
(map (fn [organization-id]
|
(map (fn [organization-id]
|
||||||
@ -654,17 +644,17 @@
|
|||||||
|
|
||||||
(sv/defmethod ::check-team-external-invitations
|
(sv/defmethod ::check-team-external-invitations
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.17"
|
::doc/added "2.18"
|
||||||
::sm/params schema:check-team-external-invitations-params
|
::sm/params schema:check-team-external-invitations-params
|
||||||
::sm/result schema:check-team-external-invitations-result
|
::sm/result schema:check-team-external-invitations-result
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :insufficient-permissions))
|
:code :insufficient-permissions))
|
||||||
(assert-membership cfg profile-id organization-id)
|
(nitrate/assert-membership cfg profile-id organization-id)
|
||||||
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
||||||
{:has-external-invitations (boolean (seq external-emails))
|
{:has-external-invitations (boolean (seq external-emails))
|
||||||
:allows-anybody allows-anybody}))
|
:allows-anybody allows-anybody}))
|
||||||
@ -683,30 +673,44 @@
|
|||||||
(sv/defmethod ::check-nitrate-sso
|
(sv/defmethod ::check-nitrate-sso
|
||||||
"Check if a user needs to login into the organization SSO.
|
"Check if a user needs to login into the organization SSO.
|
||||||
Accepts either team-id (to look up the organization via the team) or organization-id directly.
|
Accepts either team-id (to look up the organization via the team) or organization-id directly.
|
||||||
Returns {:authorized true} when SSO is not active or the user cannot access the team.
|
Returns {:authorized true :reason :sso-satisfied} when SSO is not active or the
|
||||||
|
session already holds a valid entry for the organization, and
|
||||||
|
{:authorized true :reason :no-team-access} when the gate was skipped because the
|
||||||
|
user cannot access the team; the reason lets the client tell a usable session
|
||||||
|
apart from a plain permission failure.
|
||||||
Returns {:authorized false :redirect-uri <url>} when SSO is active;
|
Returns {:authorized false :redirect-uri <url>} when SSO is active;
|
||||||
the client must redirect there. The OIDC provider itself handles
|
the client must redirect there. The OIDC provider itself handles
|
||||||
re-authentication transparently if the user already has an active SSO session."
|
re-authentication transparently if the user already has an active SSO session.
|
||||||
|
A nil :redirect-uri means SSO is required but the provider is not usable."
|
||||||
{::rpc/auth true
|
{::rpc/auth true
|
||||||
::doc/added "2.19"
|
::doc/added "2.18"
|
||||||
::sm/params schema:check-nitrate-sso
|
::sm/params schema:check-nitrate-sso
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
|
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(if (and team-id
|
(if (and team-id
|
||||||
(not (teams/has-read-permissions? cfg profile-id team-id)))
|
(not (teams/has-read-permissions? cfg profile-id team-id)))
|
||||||
;; Let the destination RPC enforce its own permissions. Starting SSO before
|
;; Let the destination RPC enforce its own permissions. Starting SSO before
|
||||||
;; access is established sends unrelated users through the organization's IdP.
|
;; access is established sends unrelated users through the organization's IdP.
|
||||||
{:authorized true}
|
{:authorized true :reason :no-team-access}
|
||||||
(let [request (rph/get-request params)
|
(let [request (rph/get-request params)
|
||||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
||||||
(if authorized
|
(if authorized
|
||||||
{:authorized true}
|
{:authorized true :reason :sso-satisfied}
|
||||||
(if (oidc/organization-sso-discovery-uri sso)
|
(if (oidc/organization-sso-discovery-uri sso)
|
||||||
{:authorized false
|
(try
|
||||||
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
|
(let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri
|
||||||
|
cfg sso
|
||||||
:dest-url url
|
:dest-url url
|
||||||
:organization-id organization-id)}
|
:organization-id organization-id)
|
||||||
|
organization-id (or organization-id (:organization-id sso))]
|
||||||
|
(oidc/submit-organization-sso-auth-started-event
|
||||||
|
cfg request profile-id organization-id)
|
||||||
|
{:authorized false :redirect-uri redirect-uri})
|
||||||
|
(catch Throwable cause
|
||||||
|
(oidc/submit-organization-sso-auth-failed-event
|
||||||
|
cfg request profile-id (or organization-id (:organization-id sso)) cause)
|
||||||
|
(throw cause)))
|
||||||
{:authorized false
|
{:authorized false
|
||||||
:redirect-uri nil}))))
|
:redirect-uri nil}))))
|
||||||
{:authorized true}))
|
{:authorized true :reason :sso-satisfied}))
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.rpc.commands.profile
|
(ns app.rpc.commands.profile
|
||||||
(:require
|
(:require
|
||||||
[app.auth :as auth]
|
[app.auth :as auth]
|
||||||
|
[app.auth.passwords :as passwords]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
@ -21,6 +22,7 @@
|
|||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
|
[app.media.validation :as media.v]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.climit :as climit]
|
[app.rpc.climit :as climit]
|
||||||
@ -45,8 +47,17 @@
|
|||||||
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
[:email-invites [::sm/one-of #{:all :none}]]])
|
[:email-invites [::sm/one-of #{:all :none}]]])
|
||||||
|
|
||||||
|
(def schema:nudge
|
||||||
|
[:map {:title "Nudge"}
|
||||||
|
[:big {:optional true} ::sm/number]
|
||||||
|
[:small {:optional true} ::sm/number]])
|
||||||
|
|
||||||
|
(def system-managed-props
|
||||||
|
"Props keys managed by the system (not user-writable via RPC)."
|
||||||
|
#{:subscription})
|
||||||
|
|
||||||
(def schema:props
|
(def schema:props
|
||||||
[:map {:title "ProfileProps"}
|
[:map {:title "ProfileProps" :closed true}
|
||||||
[:plugins {:optional true} schema:plugin-registry]
|
[:plugins {:optional true} schema:plugin-registry]
|
||||||
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
||||||
[:mcp-enabled {:optional true} ::sm/boolean]
|
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||||
@ -54,6 +65,8 @@
|
|||||||
[:newsletter-news {:optional true} ::sm/boolean]
|
[:newsletter-news {:optional true} ::sm/boolean]
|
||||||
[:onboarding-team-id {:optional true} ::sm/uuid]
|
[:onboarding-team-id {:optional true} ::sm/uuid]
|
||||||
[:onboarding-viewed {:optional true} ::sm/boolean]
|
[:onboarding-viewed {:optional true} ::sm/boolean]
|
||||||
|
[:onboarding-questions {:optional true} [:map-of :keyword :string]]
|
||||||
|
[:onboarding-questions-answered {:optional true} ::sm/boolean]
|
||||||
[:nitrate-onboarding-viewed {:optional true} ::sm/boolean]
|
[:nitrate-onboarding-viewed {:optional true} ::sm/boolean]
|
||||||
[:v2-info-shown {:optional true} ::sm/boolean]
|
[:v2-info-shown {:optional true} ::sm/boolean]
|
||||||
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
||||||
@ -62,7 +75,8 @@
|
|||||||
[:notifications {:optional true} schema:props-notifications]
|
[:notifications {:optional true} schema:props-notifications]
|
||||||
[:workspace-visited {:optional true} ::sm/boolean]
|
[:workspace-visited {:optional true} ::sm/boolean]
|
||||||
[:custom-shortcuts {:optional true}
|
[:custom-shortcuts {:optional true}
|
||||||
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]])
|
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]
|
||||||
|
[:nudge {:optional true} schema:nudge]])
|
||||||
|
|
||||||
(def schema:profile
|
(def schema:profile
|
||||||
[:map {:title "Profile"}
|
[:map {:title "Profile"}
|
||||||
@ -96,7 +110,7 @@
|
|||||||
|
|
||||||
(defn- with-nitrate-licence
|
(defn- with-nitrate-licence
|
||||||
[profile cfg]
|
[profile cfg]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||||
profile))
|
profile))
|
||||||
|
|
||||||
@ -151,6 +165,13 @@
|
|||||||
;; it or not for explicit locking and avoid concurrent updates of
|
;; it or not for explicit locking and avoid concurrent updates of
|
||||||
;; the same row/object.
|
;; the same row/object.
|
||||||
(let [profile (get-profile conn profile-id ::db/for-update true)
|
(let [profile (get-profile conn profile-id ::db/for-update true)
|
||||||
|
fullname (d/normalize-string fullname)
|
||||||
|
lang (if (contains? params :lang)
|
||||||
|
(d/normalize-string lang)
|
||||||
|
(:lang profile))
|
||||||
|
theme (if (contains? params :theme)
|
||||||
|
(d/normalize-string theme)
|
||||||
|
(:theme profile))
|
||||||
;; Update the profile map with direct params
|
;; Update the profile map with direct params
|
||||||
profile (-> profile
|
profile (-> profile
|
||||||
(assoc :fullname fullname)
|
(assoc :fullname fullname)
|
||||||
@ -196,6 +217,9 @@
|
|||||||
:code :email-as-password
|
:code :email-as-password
|
||||||
:hint "you can't use your email as password"))
|
:hint "you can't use your email as password"))
|
||||||
|
|
||||||
|
;; Validate password strength against common password dictionary
|
||||||
|
(passwords/validate-password (:password params))
|
||||||
|
|
||||||
(update-profile-password! cfg (assoc profile :password password))
|
(update-profile-password! cfg (assoc profile :password password))
|
||||||
|
|
||||||
(->> (rph/get-request params)
|
(->> (rph/get-request params)
|
||||||
@ -268,7 +292,7 @@
|
|||||||
(def ^:private
|
(def ^:private
|
||||||
schema:update-profile-photo
|
schema:update-profile-photo
|
||||||
[:map {:title "update-profile-photo"}
|
[:map {:title "update-profile-photo"}
|
||||||
[:file media/schema:upload]])
|
[:file media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::update-profile-photo
|
(sv/defmethod ::update-profile-photo
|
||||||
{:doc/added "1.1"
|
{:doc/added "1.1"
|
||||||
@ -276,8 +300,8 @@
|
|||||||
::sm/result :nil}
|
::sm/result :nil}
|
||||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||||
;; Validate incoming mime type
|
;; Validate incoming mime type
|
||||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||||
(media/validate-media-size! file)
|
(media.v/validate-media-size! file)
|
||||||
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
(defn update-profile-photo
|
(defn update-profile-photo
|
||||||
@ -454,7 +478,7 @@
|
|||||||
(assoc props k v))
|
(assoc props k v))
|
||||||
props))
|
props))
|
||||||
(:props profile)
|
(:props profile)
|
||||||
props)]
|
(apply dissoc props system-managed-props))]
|
||||||
|
|
||||||
(db/update! conn :profile
|
(db/update! conn :profile
|
||||||
{:props (db/tjson props)}
|
{:props (db/tjson props)}
|
||||||
@ -503,7 +527,7 @@
|
|||||||
;; imported "Your Penpot" teams according to whether they still have files.
|
;; imported "Your Penpot" teams according to whether they still have files.
|
||||||
;; Let Nitrate clean up the data associated with the deleted Penpot user:
|
;; Let Nitrate clean up the data associated with the deleted Penpot user:
|
||||||
;; owned organizations, remaining memberships, and subscription cancellation.
|
;; owned organizations, remaining memberships, and subscription cancellation.
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(nitrate/call cfg :cleanup-deleted-penpot-user
|
(nitrate/call cfg :cleanup-deleted-penpot-user
|
||||||
{:profile-id profile-id}))
|
{:profile-id profile-id}))
|
||||||
|
|
||||||
@ -514,6 +538,10 @@
|
|||||||
:deleted-at deleted-at
|
:deleted-at deleted-at
|
||||||
:id profile-id}})
|
:id profile-id}})
|
||||||
|
|
||||||
|
;; Invalidate all sessions for this profile to ensure immediate
|
||||||
|
;; access revocation across all devices
|
||||||
|
(session/invalidate-all cfg profile-id)
|
||||||
|
|
||||||
(-> (rph/wrap nil)
|
(-> (rph/wrap nil)
|
||||||
(rph/with-transform (session/delete-fn cfg)))))
|
(rph/with-transform (session/delete-fn cfg)))))
|
||||||
|
|
||||||
@ -562,7 +590,7 @@
|
|||||||
{::doc/added "2.18"
|
{::doc/added "2.18"
|
||||||
::sm/result schema:get-owned-organizations-summary-result}
|
::sm/result schema:get-owned-organizations-summary-result}
|
||||||
[cfg {:keys [::rpc/profile-id]}]
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) [])
|
(or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) [])
|
||||||
[]))
|
[]))
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
(ns app.rpc.commands.projects
|
(ns app.rpc.commands.projects
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
@ -259,7 +260,8 @@
|
|||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
|
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
|
||||||
(check-edition-permissions! conn profile-id id)
|
(check-edition-permissions! conn profile-id id)
|
||||||
(let [project (db/get-by-id conn :project id ::sql/for-update true)]
|
(let [project (db/get-by-id conn :project id ::sql/for-update true)
|
||||||
|
name (d/normalize-string name)]
|
||||||
(db/update! conn :project
|
(db/update! conn :project
|
||||||
{:name name}
|
{:name name}
|
||||||
{:id id})
|
{:id id})
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
[app.common.types.organization :as cto]
|
||||||
[app.common.types.team :as types.team]
|
[app.common.types.team :as types.team]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
@ -22,7 +22,7 @@
|
|||||||
[app.features.logical-deletion :as ldel]
|
[app.features.logical-deletion :as ldel]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.msgbus :as mbus]
|
[app.msgbus :as mbus]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
@ -196,11 +196,11 @@
|
|||||||
::sm/params schema:get-teams}
|
::sm/params schema:get-teams}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||||
(dm/with-open [conn (db/open pool)]
|
(dm/with-open [conn (db/open pool)]
|
||||||
(cond->> (get-teams conn profile-id)
|
(let [teams (get-teams conn profile-id)]
|
||||||
(contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(map #(nitrate/add-organization-info-to-team cfg % params))
|
(->> (nitrate/add-organization-info-to-teams cfg teams params)
|
||||||
(contains? cf/flags :nitrate)
|
(remove #(get-in % [:organization :expired-license])))
|
||||||
(remove #(get-in % [:organization :expired-license])))))
|
teams))))
|
||||||
|
|
||||||
(def ^:private sql:get-owned-teams
|
(def ^:private sql:get-owned-teams
|
||||||
"SELECT t.id, t.name,
|
"SELECT t.id, t.name,
|
||||||
@ -244,7 +244,7 @@
|
|||||||
::sm/params schema:get-team}
|
::sm/params schema:get-team}
|
||||||
[cfg {:keys [::rpc/profile-id id file-id] :as params}]
|
[cfg {:keys [::rpc/profile-id id file-id] :as params}]
|
||||||
(let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)]
|
(let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-organization-info-to-team cfg team params)
|
(nitrate/add-organization-info-to-team cfg team params)
|
||||||
team)))
|
team)))
|
||||||
|
|
||||||
@ -537,14 +537,17 @@
|
|||||||
|
|
||||||
;; When creating inside an organization, verify the user has permission to do so.
|
;; When creating inside an organization, verify the user has permission to do so.
|
||||||
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
|
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
|
||||||
(when (and organization-id (contains? cf/flags :nitrate))
|
(when (and organization-id (contains? cf/flags :admin-console))
|
||||||
|
;; Verify caller is a member of the organization
|
||||||
|
(nitrate/assert-membership cfg profile-id organization-id)
|
||||||
|
|
||||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||||
{:organization-id organization-id})]
|
{:organization-id organization-id})]
|
||||||
(if (nil? organization-perms)
|
(if (nil? organization-perms)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-allowed
|
:code :not-allowed
|
||||||
:hint "Unable to verify organization permissions")
|
:hint "Unable to verify organization permissions")
|
||||||
(when-not (nitrate-perms/allowed? :create-team
|
(when-not (cto/allowed? :create-team
|
||||||
{:organization-perms organization-perms
|
{:organization-perms organization-perms
|
||||||
:profile-id profile-id})
|
:profile-id profile-id})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -579,15 +582,15 @@
|
|||||||
team (create-team cfg params)]
|
team (create-team cfg params)]
|
||||||
(select-keys team [:id])))
|
(select-keys team [:id])))
|
||||||
|
|
||||||
(defn initialize-user-in-nitrate-organization
|
(defn initialize-user-in-organization
|
||||||
"If needed, create a default team for the user on the organization,
|
"If needed, create a default team for the user on the organization,
|
||||||
and notify Nitrate that a user has been added to an organization."
|
and initialize the user in the organization."
|
||||||
([cfg profile-id organization-id]
|
([cfg profile-id organization-id]
|
||||||
(initialize-user-in-nitrate-organization cfg profile-id organization-id nil))
|
(initialize-user-in-organization cfg profile-id organization-id nil))
|
||||||
([cfg profile-id organization-id email]
|
([cfg profile-id organization-id email]
|
||||||
(assert (db/connection-map? cfg)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(db/tx-run!
|
(db/tx-run!
|
||||||
cfg
|
cfg
|
||||||
(fn [{:keys [::db/conn] :as tx-cfg}]
|
(fn [{:keys [::db/conn] :as tx-cfg}]
|
||||||
@ -621,13 +624,13 @@
|
|||||||
([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options]
|
([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options]
|
||||||
(assert (db/connection-map? cfg)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})]
|
(let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})]
|
||||||
;; Only when the team belong to an organization and the user is not a member
|
;; Only when the team belong to an organization and the user is not a member
|
||||||
(when (and
|
(when (and
|
||||||
(some? (:organization-id membership)) ;; the team do belong to an organization
|
(some? (:organization-id membership)) ;; the team do belong to an organization
|
||||||
(not (:is-member membership))) ;; the user is not a member of the organization yet
|
(not (:is-member membership))) ;; the user is not a member of the organization yet
|
||||||
(initialize-user-in-nitrate-organization cfg profile-id (:organization-id membership)))))
|
(initialize-user-in-organization cfg profile-id (:organization-id membership)))))
|
||||||
(db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
|
(db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
|
||||||
|
|
||||||
(defn create-team
|
(defn create-team
|
||||||
@ -643,7 +646,7 @@
|
|||||||
project (create-team-default-project conn params)]
|
project (create-team-default-project conn params)]
|
||||||
(create-team-role cfg params)
|
(create-team-role cfg params)
|
||||||
;; Set team organization in Nitrate if organization-id is provided
|
;; Set team organization in Nitrate if organization-id is provided
|
||||||
(when (and (contains? cf/flags :nitrate) (:organization-id params))
|
(when (and (contains? cf/flags :admin-console) (:organization-id params))
|
||||||
(nitrate/set-team-organization cfg team params))
|
(nitrate/set-team-organization cfg team params))
|
||||||
(assoc team :default-project-id (:id project))))
|
(assoc team :default-project-id (:id project))))
|
||||||
|
|
||||||
@ -652,6 +655,7 @@
|
|||||||
(let [id (or id (uuid/next))
|
(let [id (or id (uuid/next))
|
||||||
is-default (if (boolean? is-default) is-default false)
|
is-default (if (boolean? is-default) is-default false)
|
||||||
features (db/create-array conn "text" features)
|
features (db/create-array conn "text" features)
|
||||||
|
name (d/normalize-string name)
|
||||||
team (db/insert! conn :team
|
team (db/insert! conn :team
|
||||||
{:id id
|
{:id id
|
||||||
:name name
|
:name name
|
||||||
@ -688,6 +692,7 @@
|
|||||||
[conn {:keys [id team-id name is-default created-at modified-at]}]
|
[conn {:keys [id team-id name is-default created-at modified-at]}]
|
||||||
(let [id (or id (uuid/next))
|
(let [id (or id (uuid/next))
|
||||||
is-default (if (boolean? is-default) is-default false)
|
is-default (if (boolean? is-default) is-default false)
|
||||||
|
name (d/normalize-string name)
|
||||||
params {:id id
|
params {:id id
|
||||||
:name name
|
:name name
|
||||||
:team-id team-id
|
:team-id team-id
|
||||||
@ -718,9 +723,10 @@
|
|||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
||||||
(check-edition-permissions! conn profile-id id)
|
(check-edition-permissions! conn profile-id id)
|
||||||
|
(let [name (d/normalize-string name)]
|
||||||
(db/update! conn :team
|
(db/update! conn :team
|
||||||
{:name name}
|
{:name name}
|
||||||
{:id id})
|
{:id id}))
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
|
|
||||||
@ -803,15 +809,15 @@
|
|||||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
|
||||||
|
|
||||||
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
||||||
team (if (contains? cf/flags :nitrate)
|
team (if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-organization-info-to-team cfg team params)
|
(nitrate/add-organization-info-to-team cfg team params)
|
||||||
team)
|
team)
|
||||||
perms (get team :permissions)
|
perms (get team :permissions)
|
||||||
organization (:organization team)
|
organization (:organization team)
|
||||||
in-organization? (and (contains? cf/flags :nitrate) organization)
|
in-organization? (and (contains? cf/flags :admin-console) organization)
|
||||||
can-delete?
|
can-delete?
|
||||||
(if in-organization?
|
(if in-organization?
|
||||||
(nitrate-perms/allowed? :delete-team
|
(cto/allowed? :delete-team
|
||||||
{:organization-perms {:owner-id (dm/get-in team [:organization :owner-id])
|
{:organization-perms {:owner-id (dm/get-in team [:organization :owner-id])
|
||||||
:permissions (dm/get-in team [:organization :permissions])}
|
:permissions (dm/get-in team [:organization :permissions])}
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
@ -836,7 +842,7 @@
|
|||||||
{::db/return-keys true})]
|
{::db/return-keys true})]
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
|
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
|
||||||
|
|
||||||
(wrk/submit! {::db/conn conn
|
(wrk/submit! {::db/conn conn
|
||||||
@ -979,7 +985,7 @@
|
|||||||
(def ^:private schema:update-team-photo
|
(def ^:private schema:update-team-photo
|
||||||
[:map {:title "update-team-photo"}
|
[:map {:title "update-team-photo"}
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
[:file media/schema:upload]])
|
[:file media.v/schema:upload]])
|
||||||
|
|
||||||
(sv/defmethod ::update-team-photo
|
(sv/defmethod ::update-team-photo
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
@ -987,8 +993,8 @@
|
|||||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||||
;; Validate incoming mime type
|
;; Validate incoming mime type
|
||||||
|
|
||||||
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
(media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
|
||||||
(media/validate-media-size! file)
|
(media.v/validate-media-size! file)
|
||||||
(update-team-photo cfg (assoc params :profile-id profile-id)))
|
(update-team-photo cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
(defn update-team-photo
|
(defn update-team-photo
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
[app.common.types.organization :as cto]
|
||||||
[app.common.types.team :as types.team]
|
[app.common.types.team :as types.team]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
@ -51,6 +51,25 @@
|
|||||||
update set role = ?, valid_until = ?, updated_at = now()
|
update set role = ?, valid_until = ?, updated_at = now()
|
||||||
returning *")
|
returning *")
|
||||||
|
|
||||||
|
(def ^:private sql:check-recent-invitation
|
||||||
|
"SELECT 1 FROM team_invitation
|
||||||
|
WHERE team_id = ? AND email_to = ?
|
||||||
|
AND updated_at > now() - interval '5 minutes'
|
||||||
|
LIMIT 1")
|
||||||
|
|
||||||
|
(def ^:private sql:check-recent-org-invitation
|
||||||
|
"SELECT 1 FROM team_invitation
|
||||||
|
WHERE org_id = ? AND email_to = ?
|
||||||
|
AND updated_at > now() - interval '5 minutes'
|
||||||
|
LIMIT 1")
|
||||||
|
|
||||||
|
(defn- recently-invited?
|
||||||
|
[{:keys [::db/conn]} team-id org-id email]
|
||||||
|
(let [query (if org-id
|
||||||
|
[sql:check-recent-org-invitation org-id email]
|
||||||
|
[sql:check-recent-invitation team-id email])]
|
||||||
|
(some? (db/exec-one! conn query))))
|
||||||
|
|
||||||
(defn- create-invitation-token
|
(defn- create-invitation-token
|
||||||
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
||||||
(tokens/generate cfg
|
(tokens/generate cfg
|
||||||
@ -89,14 +108,7 @@
|
|||||||
(def ^:private schema:create-organization-invitation
|
(def ^:private schema:create-organization-invitation
|
||||||
[:map {:title "params:create-organization-invitation"}
|
[:map {:title "params:create-organization-invitation"}
|
||||||
[::rpc/profile-id ::sm/uuid]
|
[::rpc/profile-id ::sm/uuid]
|
||||||
[:organization
|
[:organization cto/schema:organization-with-avatar]
|
||||||
[:map
|
|
||||||
[:id ::sm/uuid]
|
|
||||||
[:name :string]
|
|
||||||
[:initials [:maybe :string]]
|
|
||||||
[:logo ::sm/uri]
|
|
||||||
[:avatar-bg-url [:maybe ::sm/uri]]
|
|
||||||
[:sso-active [:maybe ::sm/boolean]]]]
|
|
||||||
[:profile
|
[:profile
|
||||||
[:map
|
[:map
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
@ -147,7 +159,7 @@
|
|||||||
|
|
||||||
;; When nitrate is active and the team belongs to an organization, check that
|
;; 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.
|
;; the email is already an organization member unless the organization explicitly allows adding anybody.
|
||||||
(when (and (contains? cf/flags :nitrate)
|
(when (and (contains? cf/flags :admin-console)
|
||||||
(:organization team))
|
(:organization team))
|
||||||
(assert-email-can-be-invited member organization-member-ids))
|
(assert-email-can-be-invited member organization-member-ids))
|
||||||
|
|
||||||
@ -166,8 +178,8 @@
|
|||||||
|
|
||||||
(if organization
|
(if organization
|
||||||
;; Insert the invited member to the organization
|
;; Insert the invited member to the organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(teams/initialize-user-in-nitrate-organization cfg (:id member) (:id organization) email))
|
(teams/initialize-user-in-organization cfg (:id member) (:id organization) email))
|
||||||
;; Insert the invited member to the team
|
;; Insert the invited member to the team
|
||||||
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
|
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
|
||||||
|
|
||||||
@ -189,6 +201,7 @@
|
|||||||
expire (if organization
|
expire (if organization
|
||||||
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||||
(ct/in-future "168h")) ;; 7 days
|
(ct/in-future "168h")) ;; 7 days
|
||||||
|
recent? (recently-invited? cfg (:id team) (:id organization) email)
|
||||||
invitation (db/exec-one! conn (if organization
|
invitation (db/exec-one! conn (if organization
|
||||||
[sql:upsert-organization-invitation id
|
[sql:upsert-organization-invitation id
|
||||||
(:id organization)
|
(:id organization)
|
||||||
@ -251,9 +264,10 @@
|
|||||||
(assoc :props props))]
|
(assoc :props props))]
|
||||||
(audit/submit cfg event))
|
(audit/submit cfg event))
|
||||||
|
|
||||||
(when (allow-invitation-emails? member)
|
(when (and (allow-invitation-emails? member)
|
||||||
|
(not recent?))
|
||||||
(if organization
|
(if organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(eml/send! {::eml/conn conn
|
(eml/send! {::eml/conn conn
|
||||||
::eml/factory eml/invite-to-organization
|
::eml/factory eml/invite-to-organization
|
||||||
:public-uri (cf/get :public-uri)
|
:public-uri (cf/get :public-uri)
|
||||||
@ -346,12 +360,12 @@
|
|||||||
- invitations (vector of {:email :role} maps)"
|
- invitations (vector of {:email :role} maps)"
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
||||||
(let [;; Enrich team with organization info once for all invitations when nitrate is active
|
(let [;; Enrich team with organization info once for all invitations when nitrate is active
|
||||||
team (if (contains? cf/flags :nitrate)
|
team (if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-organization-info-to-team cfg team {})
|
(nitrate/add-organization-info-to-team cfg team {})
|
||||||
team)
|
team)
|
||||||
organization (:organization team)
|
organization (:organization team)
|
||||||
organization-id (:id organization)
|
organization-id (:id organization)
|
||||||
restricted? (and organization-id (not (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization})))
|
restricted? (and organization-id (not (cto/allowed? :add-anybody-to-team {:organization-perms organization})))
|
||||||
all-organization-member-ids
|
all-organization-member-ids
|
||||||
(when organization-id
|
(when organization-id
|
||||||
(into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})))
|
(into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})))
|
||||||
@ -446,6 +460,10 @@
|
|||||||
[cfg {:keys [::rpc/profile-id team-id role emails] :as params}]
|
[cfg {:keys [::rpc/profile-id team-id role emails] :as params}]
|
||||||
(let [perms (teams/get-permissions cfg profile-id team-id)
|
(let [perms (teams/get-permissions cfg profile-id team-id)
|
||||||
profile (db/get-by-id cfg :profile profile-id)
|
profile (db/get-by-id cfg :profile profile-id)
|
||||||
|
team (db/get-by-id cfg :team team-id)
|
||||||
|
team-with-org (when (contains? cf/flags :admin-console)
|
||||||
|
(nitrate/add-organization-info-to-team cfg team {}))
|
||||||
|
organization (:organization team-with-org)
|
||||||
;; Determine which format is being used
|
;; Determine which format is being used
|
||||||
using-emails-format? (and emails role)
|
using-emails-format? (and emails role)
|
||||||
;; Handle both parameter formats
|
;; Handle both parameter formats
|
||||||
@ -461,6 +479,24 @@
|
|||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :insufficient-permissions))
|
:code :insufficient-permissions))
|
||||||
|
|
||||||
|
(when (and (contains? cf/flags :admin-console)
|
||||||
|
organization
|
||||||
|
(not (cto/allowed? :send-invitations
|
||||||
|
{:organization-perms {:owner-id (:owner-id organization)
|
||||||
|
:permissions (:permissions organization)}
|
||||||
|
:profile-id profile-id
|
||||||
|
:team-perms perms})))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :insufficient-permissions
|
||||||
|
:hint "Organization policy does not allow you to send invitations"))
|
||||||
|
|
||||||
|
;; Don't allow promote to owner to admin users.
|
||||||
|
(when (and (not (:is-owner perms))
|
||||||
|
(or (= role :owner)
|
||||||
|
(some #(= :owner (:role %)) (:invitations params))))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :cant-promote-to-owner))
|
||||||
|
|
||||||
(when (> invitation-count max-invitations-by-request-threshold)
|
(when (> invitation-count max-invitations-by-request-threshold)
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :max-invitations-by-request
|
:code :max-invitations-by-request
|
||||||
@ -604,6 +640,11 @@
|
|||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :insufficient-permissions))
|
:code :insufficient-permissions))
|
||||||
|
|
||||||
|
;; Don't allow promote to owner to admin users.
|
||||||
|
(when (and (not (:is-owner perms)) (= role :owner))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :cant-promote-to-owner))
|
||||||
|
|
||||||
(db/update! conn :team-invitation
|
(db/update! conn :team-invitation
|
||||||
{:role (name role) :updated-at (ct/now)}
|
{:role (name role) :updated-at (ct/now)}
|
||||||
{:team-id team-id :email-to (profile/clean-email email)})
|
{:team-id team-id :email-to (profile/clean-email email)})
|
||||||
|
|||||||
@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
(defn- with-nitrate-licence
|
(defn- with-nitrate-licence
|
||||||
[profile cfg]
|
[profile cfg]
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :admin-console)
|
||||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||||
profile))
|
profile))
|
||||||
|
|
||||||
@ -136,8 +136,8 @@
|
|||||||
|
|
||||||
accepted-team-id (if organization-id
|
accepted-team-id (if organization-id
|
||||||
;; Insert the invited member to the organization
|
;; Insert the invited member to the organization
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(teams/initialize-user-in-nitrate-organization cfg id-member organization-id member-email))
|
(teams/initialize-user-in-organization cfg id-member organization-id member-email))
|
||||||
;; Insert the invited member to the team
|
;; Insert the invited member to the team
|
||||||
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
||||||
team-id))]
|
team-id))]
|
||||||
@ -206,7 +206,7 @@
|
|||||||
{:columns [:id :email :default-team-id]})
|
{:columns [:id :email :default-team-id]})
|
||||||
registration-disabled? (not (contains? cf/flags :registration))
|
registration-disabled? (not (contains? cf/flags :registration))
|
||||||
|
|
||||||
organization-invitation? (and (contains? cf/flags :nitrate) organization-id)]
|
organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
|
||||||
|
|
||||||
(if profile
|
(if profile
|
||||||
(do
|
(do
|
||||||
@ -229,7 +229,7 @@
|
|||||||
;; would call nitrate needlessly and could mask the clean
|
;; would call nitrate needlessly and could mask the clean
|
||||||
;; :canceled-invitation/:invalid-token response with a generic error.
|
;; :canceled-invitation/:invalid-token response with a generic error.
|
||||||
(let [membership
|
(let [membership
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(cond
|
(cond
|
||||||
organization-id
|
organization-id
|
||||||
(nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
(nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||||
|
|||||||
@ -23,11 +23,9 @@
|
|||||||
[cuerdas.core :as str]))
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
(defn get-webhooks-permissions
|
(defn get-webhooks-permissions
|
||||||
[conn profile-id team-id creator-id]
|
[conn profile-id team-id]
|
||||||
(let [permissions (t/get-permissions conn profile-id team-id)
|
(let [permissions (t/get-permissions conn profile-id team-id)
|
||||||
|
can-edit (boolean (:can-edit permissions))]
|
||||||
can-edit (boolean (or (:can-edit permissions)
|
|
||||||
(= profile-id creator-id)))]
|
|
||||||
(assoc permissions :can-edit can-edit)))
|
(assoc permissions :can-edit can-edit)))
|
||||||
|
|
||||||
(def has-webhook-edit-permissions?
|
(def has-webhook-edit-permissions?
|
||||||
@ -120,7 +118,7 @@
|
|||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:create-webhook}
|
::sm/params schema:create-webhook}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||||
(check-webhook-edition-permissions! pool profile-id team-id profile-id)
|
(t/check-edition-permissions! pool profile-id team-id)
|
||||||
(validate-quotes! cfg params)
|
(validate-quotes! cfg params)
|
||||||
(validate-webhook! cfg nil params)
|
(validate-webhook! cfg nil params)
|
||||||
(insert-webhook! cfg params))
|
(insert-webhook! cfg params))
|
||||||
@ -137,7 +135,7 @@
|
|||||||
::sm/params schema:update-webhook}
|
::sm/params schema:update-webhook}
|
||||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||||
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
|
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
|
||||||
(check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook))
|
(check-webhook-edition-permissions! pool profile-id (:team-id whook))
|
||||||
(validate-webhook! cfg whook params)
|
(validate-webhook! cfg whook params)
|
||||||
(update-webhook! cfg whook params)))
|
(update-webhook! cfg whook params)))
|
||||||
|
|
||||||
@ -151,7 +149,7 @@
|
|||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
|
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
|
||||||
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
|
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
|
||||||
(check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook))
|
(check-webhook-edition-permissions! conn profile-id (:team-id whook))
|
||||||
(db/delete! conn :webhook {:id id})
|
(db/delete! conn :webhook {:id id})
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,12 @@
|
|||||||
|
|
||||||
(ns app.rpc.management.exporter
|
(ns app.rpc.management.exporter
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.media :as cm]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uri :as u]
|
[app.common.uri :as u]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.media :refer [schema:upload]]
|
[app.media.validation :as media.v]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.doc :as doc]
|
[app.rpc.doc :as doc]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
@ -21,7 +22,7 @@
|
|||||||
(def ^:private
|
(def ^:private
|
||||||
schema:upload-tempfile-params
|
schema:upload-tempfile-params
|
||||||
[:map {:title "upload-templfile-params"}
|
[:map {:title "upload-templfile-params"}
|
||||||
[:content schema:upload]])
|
[:content media.v/schema:upload]])
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
schema:upload-tempfile-result
|
schema:upload-tempfile-result
|
||||||
@ -32,6 +33,7 @@
|
|||||||
::sm/params schema:upload-tempfile-params
|
::sm/params schema:upload-tempfile-params
|
||||||
::sm/result schema:upload-tempfile-result}
|
::sm/result schema:upload-tempfile-result}
|
||||||
[cfg {:keys [::rpc/profile-id content]}]
|
[cfg {:keys [::rpc/profile-id content]}]
|
||||||
|
(media.v/validate-media-type! content cm/tempfile-types)
|
||||||
(let [storage (sto/resolve cfg)
|
(let [storage (sto/resolve cfg)
|
||||||
hash (sto/calculate-hash (:path content))
|
hash (sto/calculate-hash (:path content))
|
||||||
data (-> (sto/content (:path content))
|
data (-> (sto/content (:path content))
|
||||||
@ -45,5 +47,6 @@
|
|||||||
object (sto/put-object! storage content)]
|
object (sto/put-object! storage content)]
|
||||||
{:id (:id object)
|
{:id (:id object)
|
||||||
:uri (-> (cf/get :public-uri)
|
:uri (-> (cf/get :public-uri)
|
||||||
(u/join "/assets/by-id/")
|
(u/ensure-path-slash)
|
||||||
|
(u/join "assets/by-id/")
|
||||||
(u/join (str (:id object))))}))
|
(u/join (str (:id object))))}))
|
||||||
|
|||||||
@ -12,11 +12,13 @@
|
|||||||
[app.auth.oidc :as oidc]
|
[app.auth.oidc :as oidc]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.media :as cm]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]]
|
[app.common.types.organization :as cto]
|
||||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||||
[app.common.types.team :refer [schema:team]]
|
[app.common.types.team :refer [schema:team]]
|
||||||
|
[app.common.uri :as u]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
@ -24,7 +26,7 @@
|
|||||||
[app.http :as-alias http]
|
[app.http :as-alias http]
|
||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
[app.media :as media]
|
[app.media.validation :as media.v]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as rpc]
|
[app.rpc :as rpc]
|
||||||
[app.rpc.commands.auth :as auth]
|
[app.rpc.commands.auth :as auth]
|
||||||
@ -54,7 +56,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::authenticate
|
(sv/defmethod ::authenticate
|
||||||
"Authenticate the current user"
|
"Authenticate the current user"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result schema:profile
|
::sm/result schema:profile
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -94,7 +96,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-penpot-version
|
(sv/defmethod ::get-penpot-version
|
||||||
"Get the current Penpot version"
|
"Get the current Penpot version"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result schema:get-penpot-version-result
|
::sm/result schema:get-penpot-version-result
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
@ -106,7 +108,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-teams
|
(sv/defmethod ::get-teams
|
||||||
"List teams for which current user is owner"
|
"List teams for which current user is owner"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result schema:get-teams-result
|
::sm/result schema:get-teams-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -119,7 +121,7 @@
|
|||||||
|
|
||||||
(def ^:private schema:upload-organization-logo
|
(def ^:private schema:upload-organization-logo
|
||||||
[:map
|
[:map
|
||||||
[:content media/schema:upload]
|
[:content media.v/schema:upload]
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
[:previous-id {:optional true} ::sm/uuid]])
|
[:previous-id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
@ -130,11 +132,12 @@
|
|||||||
"Store an organization logo in penpot storage and return its ID.
|
"Store an organization logo in penpot storage and return its ID.
|
||||||
Accepts an optional previous-id to mark the old logo for garbage
|
Accepts an optional previous-id to mark the old logo for garbage
|
||||||
collection when replacing an existing one."
|
collection when replacing an existing one."
|
||||||
{::doc/added "2.17"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:upload-organization-logo
|
::sm/params schema:upload-organization-logo
|
||||||
::sm/result schema:upload-organization-logo-result
|
::sm/result schema:upload-organization-logo-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||||
|
(media.v/validate-media-type! content cm/image-types)
|
||||||
(when previous-id
|
(when previous-id
|
||||||
(sto/touch-object! storage previous-id))
|
(sto/touch-object! storage previous-id))
|
||||||
(let [hash (sto/calculate-hash (:path content))
|
(let [hash (sto/calculate-hash (:path content))
|
||||||
@ -151,8 +154,8 @@
|
|||||||
|
|
||||||
(sv/defmethod ::notify-team-change
|
(sv/defmethod ::notify-team-change
|
||||||
"Notify to Penpot a team change from nitrate"
|
"Notify to Penpot a team change from nitrate"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:team-with-organization
|
::sm/params cto/schema:team-with-organization
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg team]
|
[cfg team]
|
||||||
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
||||||
@ -168,7 +171,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::notify-user-added-to-organization
|
(sv/defmethod ::notify-user-added-to-organization
|
||||||
"Notify to Penpot that an user has joined an organization from nitrate"
|
"Notify to Penpot that an user has joined an organization from nitrate"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:notify-user-added-to-organization
|
::sm/params schema:notify-user-added-to-organization
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [profile-id organization-id]}]
|
[cfg {:keys [profile-id organization-id]}]
|
||||||
@ -199,7 +202,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-managed-profiles
|
(sv/defmethod ::get-managed-profiles
|
||||||
"List profiles that belong to teams for which current user is owner"
|
"List profiles that belong to teams for which current user is owner"
|
||||||
{::doc/added "2.14"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map]
|
::sm/params [:map]
|
||||||
::sm/result schema:managed-profile-result
|
::sm/result schema:managed-profile-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -239,7 +242,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-teams-summary
|
(sv/defmethod ::get-teams-summary
|
||||||
"Get summary information for a list of teams"
|
"Get summary information for a list of teams"
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:get-teams-summary-params
|
::sm/params schema:get-teams-summary-params
|
||||||
::sm/result schema:get-teams-summary-result
|
::sm/result schema:get-teams-summary-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -360,7 +363,7 @@ RETURNING id, deleted_at;")
|
|||||||
(sv/defmethod ::notify-organization-deletion
|
(sv/defmethod ::notify-organization-deletion
|
||||||
"For a deleted organization, preserve organization teams and only prefix or delete
|
"For a deleted organization, preserve organization teams and only prefix or delete
|
||||||
imported Your Penpot teams before notifying connected users."
|
imported Your Penpot teams before notifying connected users."
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:notify-organization-deletion
|
::sm/params schema:notify-organization-deletion
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [organization-id]}]
|
[cfg {:keys [organization-id]}]
|
||||||
@ -406,7 +409,7 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
(sv/defmethod ::get-profile-by-email
|
(sv/defmethod ::get-profile-by-email
|
||||||
"Get profile by email"
|
"Get profile by email"
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map [:email ::sm/email]]
|
::sm/params [:map [:email ::sm/email]]
|
||||||
::sm/result schema:profile
|
::sm/result schema:profile
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -430,7 +433,7 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
(sv/defmethod ::get-profile-by-id
|
(sv/defmethod ::get-profile-by-id
|
||||||
"Get profile by email"
|
"Get profile by email"
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map [:id ::sm/uuid]]
|
::sm/params [:map [:id ::sm/uuid]]
|
||||||
::sm/result schema:profile
|
::sm/result schema:profile
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -465,7 +468,7 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
(sv/defmethod ::get-organization-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."
|
"Get the number of non-default teams each profile belongs to within a set of teams."
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:get-organization-member-team-counts-params
|
::sm/params schema:get-organization-member-team-counts-params
|
||||||
::sm/result schema:get-organization-member-team-counts-result
|
::sm/result schema:get-organization-member-team-counts-result
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
@ -488,15 +491,33 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
;; API: invite-to-organization
|
;; API: invite-to-organization
|
||||||
|
|
||||||
|
(defn- get-invitation-organization
|
||||||
|
[cfg profile-id organization-id]
|
||||||
|
(let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]}
|
||||||
|
(nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
|
||||||
|
(when-not (= profile-id owner-id)
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :object-not-found
|
||||||
|
:hint "not found"))
|
||||||
|
{:id id
|
||||||
|
:name name
|
||||||
|
:initials (if logo-id "" (d/get-initials name))
|
||||||
|
:logo (when logo-id (u/uri (files/resolve-public-uri logo-id)))
|
||||||
|
:avatar-bg-url (when-not logo-id avatar-bg-url)
|
||||||
|
:sso-active (true? sso-active)}))
|
||||||
|
|
||||||
(sv/defmethod ::invite-to-organization
|
(sv/defmethod ::invite-to-organization
|
||||||
"Invite to organization"
|
"Invite to organization"
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map
|
::sm/params [:map
|
||||||
[:email ::sm/email]
|
[:email ::sm/email]
|
||||||
[:organization schema:organization-with-avatar]]
|
[:organization cto/schema:organization-with-avatar]]
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg params]
|
[cfg {profile-id ::rpc/profile-id
|
||||||
(db/tx-run! cfg ti/create-organization-invitation params)
|
:keys [organization]
|
||||||
|
:as params}]
|
||||||
|
(let [organization (get-invitation-organization cfg profile-id (:id organization))]
|
||||||
|
(db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization)))
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
|
|
||||||
@ -519,7 +540,7 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
(sv/defmethod ::get-organization-invitations
|
(sv/defmethod ::get-organization-invitations
|
||||||
"Get valid invitations for an organization, returning at most one invitation per email."
|
"Get valid invitations for an organization, returning at most one invitation per email."
|
||||||
{::doc/added "2.16"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:get-organization-invitations-params
|
::sm/params schema:get-organization-invitations-params
|
||||||
::sm/result schema:get-organization-invitations-result
|
::sm/result schema:get-organization-invitations-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -547,7 +568,7 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
(sv/defmethod ::delete-organization-invitations
|
(sv/defmethod ::delete-organization-invitations
|
||||||
"Delete all invitations for one email in an organization scope (organization + organization teams)."
|
"Delete all invitations for one email in an organization scope (organization + organization teams)."
|
||||||
{::doc/added "2.16"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:delete-organization-invitations-params
|
::sm/params schema:delete-organization-invitations-params
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [organization-id email]}]
|
[cfg {:keys [organization-id email]}]
|
||||||
@ -612,7 +633,7 @@ RETURNING id, deleted_at;")
|
|||||||
|
|
||||||
(sv/defmethod ::remove-from-organization
|
(sv/defmethod ::remove-from-organization
|
||||||
"Remove an user from an organization"
|
"Remove an user from an organization"
|
||||||
{::doc/added "2.17"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map
|
::sm/params [:map
|
||||||
[:profile-id ::sm/uuid]
|
[:profile-id ::sm/uuid]
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
@ -657,7 +678,7 @@ RETURNING id, deleted_at;")
|
|||||||
(sv/defmethod ::get-remove-from-organization-summary
|
(sv/defmethod ::get-remove-from-organization-summary
|
||||||
"Get a summary of the teams that would be deleted, transferred, or exited
|
"Get a summary of the teams that would be deleted, transferred, or exited
|
||||||
if the user were removed from the organization"
|
if the user were removed from the organization"
|
||||||
{::doc/added "2.17"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map
|
::sm/params [:map
|
||||||
[:profile-id ::sm/uuid]
|
[:profile-id ::sm/uuid]
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
@ -688,11 +709,11 @@ RETURNING id, deleted_at;")
|
|||||||
[:user-name [:maybe ::sm/text]]
|
[:user-name [:maybe ::sm/text]]
|
||||||
[:renewal-date :string]
|
[:renewal-date :string]
|
||||||
[:estimated-amount :double]
|
[:estimated-amount :double]
|
||||||
[:organizations [:vector schema:organization-with-avatar]]])
|
[:organizations [:vector cto/schema:organization-with-avatar]]])
|
||||||
|
|
||||||
(sv/defmethod ::send-renewal-email
|
(sv/defmethod ::send-renewal-email
|
||||||
"Send an Enterprise subscription renewal notice email to a user."
|
"Send an Enterprise subscription renewal notice email to a user."
|
||||||
{::doc/added "2.17"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:send-renewal-email-params
|
::sm/params schema:send-renewal-email-params
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
|
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
|
||||||
@ -805,7 +826,7 @@ RETURNING id, deleted_at;")
|
|||||||
"Push audit events from nitrate (strictly for nitrate backend
|
"Push audit events from nitrate (strictly for nitrate backend
|
||||||
events)"
|
events)"
|
||||||
|
|
||||||
{::doc/added "2.19"
|
{::doc/added "2.18"
|
||||||
::audit/skip true
|
::audit/skip true
|
||||||
::sm/params schema:push-audit-events-params
|
::sm/params schema:push-audit-events-params
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
@ -912,7 +933,7 @@ RETURNING id, deleted_at;")
|
|||||||
(sv/defmethod ::get-teams-detail
|
(sv/defmethod ::get-teams-detail
|
||||||
"Get detailed information for all non-deleted teams in an organization,
|
"Get detailed information for all non-deleted teams in an organization,
|
||||||
including owner info and project/file/member counts."
|
including owner info and project/file/member counts."
|
||||||
{::doc/added "2.20"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:get-teams-detail-params
|
::sm/params schema:get-teams-detail-params
|
||||||
::sm/result schema:get-teams-detail-result
|
::sm/result schema:get-teams-detail-result
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
@ -940,8 +961,8 @@ RETURNING id, deleted_at;")
|
|||||||
"Validate an organization SSO configuration by generating a login redirect URL.
|
"Validate an organization SSO configuration by generating a login redirect URL.
|
||||||
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
||||||
discovery before saving the settings."
|
discovery before saving the settings."
|
||||||
{::doc/added "2.20"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:nitrate-sso
|
::sm/params cto/schema:nitrate-sso
|
||||||
::sm/result schema:check-organization-sso-result
|
::sm/result schema:check-organization-sso-result
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg params]
|
[cfg params]
|
||||||
@ -950,7 +971,7 @@ RETURNING id, deleted_at;")
|
|||||||
;; ---- API: notify-organization-sso-change
|
;; ---- API: notify-organization-sso-change
|
||||||
(sv/defmethod ::notify-organization-sso-change
|
(sv/defmethod ::notify-organization-sso-change
|
||||||
"Nitrate notifies that an organization sso values have changed"
|
"Nitrate notifies that an organization sso values have changed"
|
||||||
{::doc/added "2.19"
|
{::doc/added "2.18"
|
||||||
::sm/params [:map
|
::sm/params [:map
|
||||||
[:organization-id ::sm/uuid]
|
[:organization-id ::sm/uuid]
|
||||||
[:updated-props ::sm/boolean]
|
[:updated-props ::sm/boolean]
|
||||||
@ -997,17 +1018,17 @@ RETURNING id, deleted_at;")
|
|||||||
created users skip email verification and onboarding. Emails that already
|
created users skip email verification and onboarding. Emails that already
|
||||||
belong to an existing profile are skipped. Intended for the Nitrate admin
|
belong to an existing profile are skipped. Intended for the Nitrate admin
|
||||||
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
|
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
|
||||||
email allow-list. Requires the `nitrate-bulk-create-profiles` flag, disabled
|
email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled
|
||||||
by default so it is only available on test environments."
|
by default so it is only available on test environments."
|
||||||
{::doc/added "2.19"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:bulk-create-profiles-params
|
::sm/params schema:bulk-create-profiles-params
|
||||||
::sm/result schema:bulk-create-profiles-result
|
::sm/result schema:bulk-create-profiles-result
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [password emails]}]
|
[cfg {:keys [password emails]}]
|
||||||
|
|
||||||
(when-not (contains? cf/flags :nitrate-bulk-create-profiles)
|
(when-not (contains? cf/flags :admin-console-bulk-create-profiles)
|
||||||
(ex/raise :type :restriction
|
(ex/raise :type :restriction
|
||||||
:code :nitrate-bulk-create-profiles-not-allowed
|
:code :bulk-create-profiles-not-allowed
|
||||||
:hint "Bulk profile creation is disabled by config."))
|
:hint "Bulk profile creation is disabled by config."))
|
||||||
|
|
||||||
(let [derived (aauth/derive-password password)]
|
(let [derived (aauth/derive-password password)]
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
(defn- organization-sso-active?
|
(defn- organization-sso-active?
|
||||||
"Return whether SSO is enabled for the organization."
|
"Return whether SSO is enabled for the organization."
|
||||||
[cfg organization-id]
|
[cfg organization-id]
|
||||||
(when (contains? cf/flags :nitrate)
|
(when (contains? cf/flags :admin-console)
|
||||||
(true? (:active (nitrate/call cfg :get-organization-sso {:organization-id organization-id})))))
|
(true? (:active (nitrate/call cfg :get-organization-sso {:organization-id organization-id})))))
|
||||||
|
|
||||||
(def ^:private xf:map-email (map :email))
|
(def ^:private xf:map-email (map :email))
|
||||||
|
|||||||
@ -46,6 +46,7 @@
|
|||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
|
[app.common.math :as mth]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uri :as uri]
|
[app.common.uri :as uri]
|
||||||
@ -180,8 +181,8 @@
|
|||||||
result (rds/eval rconn script)
|
result (rds/eval rconn script)
|
||||||
allowed? (boolean (nth result 0))
|
allowed? (boolean (nth result 0))
|
||||||
remaining (nth result 1)
|
remaining (nth result 1)
|
||||||
reset (* (/ (inst-ms interval) rate)
|
reset (long (mth/ceil (double (* (/ (inst-ms interval) rate)
|
||||||
(- capacity remaining))]
|
(- capacity remaining)))))]
|
||||||
(l/trace :hint "limit processed"
|
(l/trace :hint "limit processed"
|
||||||
:method method
|
:method method
|
||||||
:limit (name (::name limit))
|
:limit (name (::name limit))
|
||||||
@ -190,6 +191,7 @@
|
|||||||
:allowed allowed?
|
:allowed allowed?
|
||||||
:remaining remaining)
|
:remaining remaining)
|
||||||
(-> limit
|
(-> limit
|
||||||
|
(assoc ::lresult/now now)
|
||||||
(assoc ::lresult/allowed allowed?)
|
(assoc ::lresult/allowed allowed?)
|
||||||
(assoc ::lresult/reset (ct/plus now reset))
|
(assoc ::lresult/reset (ct/plus now reset))
|
||||||
(assoc ::lresult/remaining remaining))))
|
(assoc ::lresult/remaining remaining))))
|
||||||
@ -212,6 +214,7 @@
|
|||||||
:allowed allowed?
|
:allowed allowed?
|
||||||
:remaining remaining)
|
:remaining remaining)
|
||||||
(-> limit
|
(-> limit
|
||||||
|
(assoc ::lresult/now now)
|
||||||
(assoc ::lresult/allowed allowed?)
|
(assoc ::lresult/allowed allowed?)
|
||||||
(assoc ::lresult/timestamp ts)
|
(assoc ::lresult/timestamp ts)
|
||||||
(assoc ::lresult/remaining remaining)
|
(assoc ::lresult/remaining remaining)
|
||||||
|
|||||||
@ -115,8 +115,9 @@
|
|||||||
(assoc keys id key)))))
|
(assoc keys id key)))))
|
||||||
{}
|
{}
|
||||||
[:exporter
|
[:exporter
|
||||||
:nitrate
|
:admin-console
|
||||||
:nexus])))
|
:nexus
|
||||||
|
:media-processor])))
|
||||||
|
|
||||||
(sm/register! ::props [:map-of :keyword ::sm/any])
|
(sm/register! ::props [:map-of :keyword ::sm/any])
|
||||||
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])
|
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])
|
||||||
|
|||||||
@ -346,13 +346,21 @@
|
|||||||
(ct/duration {:minutes 10}))
|
(ct/duration {:minutes 10}))
|
||||||
|
|
||||||
(defn- get-object-url
|
(defn- get-object-url
|
||||||
[{:keys [::presigner ::bucket ::prefix]} {:keys [id]} {:keys [max-age] :or {max-age default-max-age}}]
|
[{:keys [::presigner ::bucket ::prefix]} {:keys [id]}
|
||||||
|
{:keys [max-age content-disposition] :or {max-age default-max-age}}]
|
||||||
(assert (ct/duration? max-age) "expected valid duration instance")
|
(assert (ct/duration? max-age) "expected valid duration instance")
|
||||||
|
|
||||||
(let [gor (.. (GetObjectRequest/builder)
|
;; The content-disposition option is signed into the presigned url, so the
|
||||||
|
;; object store sets that header on the response the client fetches after
|
||||||
|
;; following the redirect. It is only set when asked for, so urls for
|
||||||
|
;; objects served inline stay byte identical to before.
|
||||||
|
(let [gorb (.. (GetObjectRequest/builder)
|
||||||
(bucket bucket)
|
(bucket bucket)
|
||||||
(key (dm/str prefix (impl/id->path id)))
|
(key (dm/str prefix (impl/id->path id))))
|
||||||
(build))
|
gorb (cond-> gorb
|
||||||
|
(some? content-disposition)
|
||||||
|
(.responseContentDisposition ^String content-disposition))
|
||||||
|
gor (.build gorb)
|
||||||
gopr (.. (GetObjectPresignRequest/builder)
|
gopr (.. (GetObjectPresignRequest/builder)
|
||||||
(signatureDuration ^Duration max-age)
|
(signatureDuration ^Duration max-age)
|
||||||
(getObjectRequest ^GetObjectRequest gor)
|
(getObjectRequest ^GetObjectRequest gor)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"A generic blob storage encoding. Mainly used for page data, page
|
"A generic blob storage encoding. Mainly used for page data, page
|
||||||
options and txlog payload storage."
|
options and txlog payload storage."
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
[app.common.fressian :as fres]
|
[app.common.fressian :as fres]
|
||||||
[app.common.transit :as t]
|
[app.common.transit :as t]
|
||||||
[app.config :as cf])
|
[app.config :as cf])
|
||||||
@ -58,12 +59,18 @@
|
|||||||
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) ^bytes (encode data opts))))
|
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) ^bytes (encode data opts))))
|
||||||
|
|
||||||
(defn decode
|
(defn decode
|
||||||
"A function used for decode persisted blobs in the database."
|
"A function used for decode persisted blobs in the database.
|
||||||
[^bytes data]
|
Accepts optional keyword arguments:
|
||||||
|
:max-size — maximum allowed uncompressed size in bytes"
|
||||||
|
[^bytes data & {:keys [max-size]}]
|
||||||
(with-open [bais (ByteArrayInputStream. data)
|
(with-open [bais (ByteArrayInputStream. data)
|
||||||
dis (DataInputStream. bais)]
|
dis (DataInputStream. bais)]
|
||||||
(let [version (.readShort dis)
|
(let [version (.readShort dis)
|
||||||
ulen (.readInt dis)]
|
ulen (.readInt dis)]
|
||||||
|
(when (and max-size (> ulen max-size))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :blob-too-large
|
||||||
|
:hint "blob uncompressed size exceeds limit"))
|
||||||
(case version
|
(case version
|
||||||
1 (decode-v1 data ulen)
|
1 (decode-v1 data ulen)
|
||||||
3 (decode-v3 data ulen)
|
3 (decode-v3 data ulen)
|
||||||
@ -72,9 +79,10 @@
|
|||||||
(throw (ex-info "unsupported version" {:version version}))))))
|
(throw (ex-info "unsupported version" {:version version}))))))
|
||||||
|
|
||||||
(defn decode-str
|
(defn decode-str
|
||||||
"Decode a URL-safe base64 string produced by `encode-str` back to data."
|
"Decode a URL-safe base64 string produced by `encode-str` back to data.
|
||||||
[^String s]
|
Accepts the same optional keyword arguments as `decode`."
|
||||||
(decode (.decode (Base64/getUrlDecoder) s)))
|
[^String s & {:as opts}]
|
||||||
|
(decode (.decode (Base64/getUrlDecoder) s) opts))
|
||||||
|
|
||||||
;; --- IMPL
|
;; --- IMPL
|
||||||
|
|
||||||
|
|||||||
@ -385,6 +385,9 @@
|
|||||||
(def ^:private test-profile-id
|
(def ^:private test-profile-id
|
||||||
#uuid "11111111-1111-1111-1111-111111111111")
|
#uuid "11111111-1111-1111-1111-111111111111")
|
||||||
|
|
||||||
|
(def ^:private test-organization-id
|
||||||
|
#uuid "22222222-2222-2222-2222-222222222222")
|
||||||
|
|
||||||
(def ^:private test-profile
|
(def ^:private test-profile
|
||||||
{:id test-profile-id
|
{:id test-profile-id
|
||||||
:is-active true
|
:is-active true
|
||||||
@ -518,3 +521,69 @@
|
|||||||
loc (redirect-location result)]
|
loc (redirect-location result)]
|
||||||
(t/is (= 302 (::yres/status result)))
|
(t/is (= 302 (::yres/status result)))
|
||||||
(t/is (.contains loc "error=unable-to-auth")))))))
|
(t/is (.contains loc "error=unable-to-auth")))))))
|
||||||
|
|
||||||
|
(t/deftest organization-sso-callback-success-emits-succeeded
|
||||||
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||||
|
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
||||||
|
:organization-id test-organization-id})
|
||||||
|
request (default-request cfg :state state)
|
||||||
|
events (atom [])]
|
||||||
|
(with-redefs [app.nitrate/call (constantly {:active true})
|
||||||
|
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
|
||||||
|
app.auth.oidc/get-info (constantly {})
|
||||||
|
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
||||||
|
(let [result (#'oidc/callback-handler cfg request)]
|
||||||
|
(t/is (= "https://penpot.example.com/#/workspace" (redirect-location result)))
|
||||||
|
(t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events)))
|
||||||
|
(t/is (= test-organization-id (get-in (first @events) [:props :organization-id])))))))
|
||||||
|
|
||||||
|
(t/deftest organization-sso-callback-error-emits-failed
|
||||||
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||||
|
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
||||||
|
:organization-id test-organization-id})
|
||||||
|
request (default-request cfg :state state)
|
||||||
|
events (atom [])]
|
||||||
|
(with-redefs [app.nitrate/call (fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-organization-sso {:active true}
|
||||||
|
:get-organization-summary {:name "Organization"}))
|
||||||
|
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
|
||||||
|
app.auth.oidc/get-info (fn [& _]
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :unable-to-retrieve-user-info))
|
||||||
|
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
||||||
|
(#'oidc/callback-handler cfg request)
|
||||||
|
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
|
||||||
|
(t/is (= {:organization-id test-organization-id
|
||||||
|
:failure-reason "user-info-failed"}
|
||||||
|
(:props (first @events)))))))
|
||||||
|
|
||||||
|
(t/deftest organization-sso-oauth-error-emits-failed-without-changing-redirect
|
||||||
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||||
|
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
||||||
|
:organization-id test-organization-id})
|
||||||
|
request (assoc-in (default-request cfg :state state) [:params :error] "access_denied")
|
||||||
|
events (atom [])]
|
||||||
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||||
|
(with-redefs [app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
||||||
|
(let [result (#'oidc/callback-handler cfg request)
|
||||||
|
loc (redirect-location result)]
|
||||||
|
(t/is (.contains loc "error=unable-to-auth"))
|
||||||
|
(t/is (.contains loc "hint=access_denied"))
|
||||||
|
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
|
||||||
|
(t/is (= {:organization-id test-organization-id
|
||||||
|
:failure-reason "access-denied"}
|
||||||
|
(:props (first @events)))))))))
|
||||||
|
|
||||||
|
(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check
|
||||||
|
(t/testing "organization SSO provider must use SSRF protection"
|
||||||
|
(let [captured-params (atom nil)]
|
||||||
|
(with-redefs [oidc/prepare-oidc-provider (fn [_cfg params]
|
||||||
|
(reset! captured-params params)
|
||||||
|
{:type "oidc" :id "test"})]
|
||||||
|
(#'oidc/prepare-organization-sso-provider {}
|
||||||
|
{:client-id "test-client"
|
||||||
|
:client-secret "test-secret"
|
||||||
|
:issuer "https://idp.example.com"})
|
||||||
|
(t/is (not (true? (:skip-ssrf-check? @captured-params)))
|
||||||
|
"SSRF protection must be disabled for organization SSO")))))
|
||||||
|
|||||||
@ -8,23 +8,30 @@
|
|||||||
"Internal binfile test, no RPC involved"
|
"Internal binfile test, no RPC involved"
|
||||||
(:require
|
(:require
|
||||||
[app.binfile.common :as bfc]
|
[app.binfile.common :as bfc]
|
||||||
|
[app.binfile.v1 :as v1]
|
||||||
[app.binfile.v3 :as v3]
|
[app.binfile.v3 :as v3]
|
||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
|
[app.common.files.validate :as cfv]
|
||||||
[app.common.pprint :as pp]
|
[app.common.pprint :as pp]
|
||||||
[app.common.thumbnails :as thc]
|
[app.common.thumbnails :as thc]
|
||||||
[app.common.types.shape :as cts]
|
[app.common.types.shape :as cts]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as sql]
|
[app.db.sql :as sql]
|
||||||
[app.http :as http]
|
[app.http :as http]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.binfile :as binfile]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
[backend-tests.helpers :as th]
|
[backend-tests.helpers :as th]
|
||||||
[clojure.test :as t]
|
[clojure.test :as t]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[datoteka.fs :as fs]
|
[datoteka.fs :as fs]
|
||||||
[datoteka.io :as io]))
|
[datoteka.io :as io])
|
||||||
|
(:import
|
||||||
|
java.io.ByteArrayInputStream
|
||||||
|
java.io.DataInputStream))
|
||||||
|
|
||||||
(t/use-fixtures :once th/state-init)
|
(t/use-fixtures :once th/state-init)
|
||||||
(t/use-fixtures :each th/database-reset)
|
(t/use-fixtures :each th/database-reset)
|
||||||
@ -86,6 +93,102 @@
|
|||||||
|
|
||||||
(dissoc file :data)))
|
(dissoc file :data)))
|
||||||
|
|
||||||
|
(def ^:private svg-raw-page-id (uuid/custom 1 1))
|
||||||
|
(def ^:private svg-raw-root-id (uuid/custom 3 1))
|
||||||
|
(def ^:private svg-raw-child-id (uuid/custom 3 2))
|
||||||
|
|
||||||
|
(defn- prepare-svg-raw-file
|
||||||
|
"A file containing an svg-raw subtree (an svg-raw parent with an
|
||||||
|
svg-raw child), which is what importing an SVG produces."
|
||||||
|
[profile]
|
||||||
|
(let [page-id svg-raw-page-id
|
||||||
|
root-id svg-raw-root-id
|
||||||
|
child-id svg-raw-child-id
|
||||||
|
|
||||||
|
file (th/create-file* 1 {:profile-id (:id profile)
|
||||||
|
:project-id (:default-project-id profile)
|
||||||
|
:is-shared false})]
|
||||||
|
(update-file!
|
||||||
|
:file-id (:id file)
|
||||||
|
:profile-id (:id profile)
|
||||||
|
:revn 0
|
||||||
|
:vern 0
|
||||||
|
:changes
|
||||||
|
[{:type :add-page
|
||||||
|
:name "page 1"
|
||||||
|
:id page-id}])
|
||||||
|
|
||||||
|
(update-file!
|
||||||
|
:file-id (:id file)
|
||||||
|
:profile-id (:id profile)
|
||||||
|
:revn 0
|
||||||
|
:vern 0
|
||||||
|
:changes
|
||||||
|
[{:type :add-obj
|
||||||
|
:page-id page-id
|
||||||
|
:id root-id
|
||||||
|
:parent-id uuid/zero
|
||||||
|
:frame-id uuid/zero
|
||||||
|
:components-v2 true
|
||||||
|
:obj (cts/setup-shape
|
||||||
|
{:id root-id
|
||||||
|
:name "svg-root"
|
||||||
|
:frame-id uuid/zero
|
||||||
|
:parent-id uuid/zero
|
||||||
|
:type :svg-raw
|
||||||
|
:content {:tag :svg :attrs {} :content []}})}
|
||||||
|
{:type :add-obj
|
||||||
|
:page-id page-id
|
||||||
|
:id child-id
|
||||||
|
:parent-id root-id
|
||||||
|
:frame-id uuid/zero
|
||||||
|
:components-v2 true
|
||||||
|
:obj (cts/setup-shape
|
||||||
|
{:id child-id
|
||||||
|
:name "svg-text"
|
||||||
|
:frame-id uuid/zero
|
||||||
|
:parent-id root-id
|
||||||
|
:type :svg-raw
|
||||||
|
:content {:tag :text :attrs {} :content []}})}])
|
||||||
|
|
||||||
|
(dissoc file :data)))
|
||||||
|
|
||||||
|
(t/deftest import-binfile-v3-preserves-svg-raw-children
|
||||||
|
(let [profile (th/create-profile* 1)
|
||||||
|
file (prepare-svg-raw-file profile)
|
||||||
|
output (tmp/tempfile :suffix ".zip")]
|
||||||
|
|
||||||
|
(v3/export-files!
|
||||||
|
(-> th/*system*
|
||||||
|
(assoc ::bfc/ids #{(:id file)})
|
||||||
|
(assoc ::bfc/embed-assets false)
|
||||||
|
(assoc ::bfc/include-libraries false))
|
||||||
|
(io/output-stream output))
|
||||||
|
|
||||||
|
(let [result (-> th/*system*
|
||||||
|
(assoc ::bfc/project-id (:default-project-id profile))
|
||||||
|
(assoc ::bfc/profile-id (:id profile))
|
||||||
|
(assoc ::bfc/input output)
|
||||||
|
(v3/import-files!))
|
||||||
|
imported (:result (th/command! {::th/type :get-file
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:id (first result)
|
||||||
|
:components-v2 true}))
|
||||||
|
root (get-in imported [:data :pages-index svg-raw-page-id
|
||||||
|
:objects svg-raw-root-id])]
|
||||||
|
|
||||||
|
(t/is (= (count result) 1))
|
||||||
|
|
||||||
|
;; The child ids of an svg-raw shape must survive the JSON round
|
||||||
|
;; trip as uuids; when they came back as plain strings they no
|
||||||
|
;; longer resolved against the objects map.
|
||||||
|
(t/is (every? uuid? (:shapes root)))
|
||||||
|
(t/is (= [svg-raw-child-id] (vec (:shapes root))))
|
||||||
|
|
||||||
|
;; ...so the imported file passes referential integrity instead
|
||||||
|
;; of failing with :child-not-found on the next update-file.
|
||||||
|
(t/is (nil? (cfv/validate-file imported []))))))
|
||||||
|
|
||||||
(t/deftest export-binfile-v3
|
(t/deftest export-binfile-v3
|
||||||
(let [profile (th/create-profile* 1)
|
(let [profile (th/create-profile* 1)
|
||||||
file (prepare-simple-file profile)
|
file (prepare-simple-file profile)
|
||||||
@ -105,3 +208,61 @@
|
|||||||
(v3/import-files!))]
|
(v3/import-files!))]
|
||||||
(t/is (= (count result) 1))
|
(t/is (= (count result) 1))
|
||||||
(t/is (every? uuid? result)))))
|
(t/is (every? uuid? result)))))
|
||||||
|
|
||||||
|
(t/deftest export-binfile-preserves-public-uri-subpath
|
||||||
|
(let [profile (th/create-profile* 1)
|
||||||
|
file (prepare-simple-file profile)
|
||||||
|
config (assoc cf/config :public-uri "https://example.com/penpot")
|
||||||
|
params {:file-id (:id file)
|
||||||
|
:include-libraries false
|
||||||
|
:embed-assets false}
|
||||||
|
uri (binding [cf/config config]
|
||||||
|
(#'binfile/export-binfile th/*system* params))]
|
||||||
|
(t/is (str/starts-with? (str uri)
|
||||||
|
"https://example.com/penpot/assets/by-id/"))))
|
||||||
|
|
||||||
|
(t/deftest import-binfile-v3-persists-manifest-metadata
|
||||||
|
(let [profile (th/create-profile* 1)
|
||||||
|
file (prepare-simple-file profile)
|
||||||
|
output (tmp/tempfile :suffix ".zip")]
|
||||||
|
|
||||||
|
(v3/export-files!
|
||||||
|
(-> th/*system*
|
||||||
|
(assoc ::bfc/ids #{(:id file)})
|
||||||
|
(assoc ::bfc/embed-assets false)
|
||||||
|
(assoc ::bfc/include-libraries false))
|
||||||
|
(io/output-stream output))
|
||||||
|
|
||||||
|
(let [result (-> th/*system*
|
||||||
|
(assoc ::bfc/project-id (:default-project-id profile))
|
||||||
|
(assoc ::bfc/profile-id (:id profile))
|
||||||
|
(assoc ::bfc/input output)
|
||||||
|
(v3/import-files!))
|
||||||
|
imported (bfc/get-file th/*system* (first result))]
|
||||||
|
|
||||||
|
(t/is (= (count result) 1))
|
||||||
|
(t/is (some? (get-in imported [:metadata :generated-by])))
|
||||||
|
(t/is (= "penpot" (get-in imported [:metadata :referer]))))))
|
||||||
|
|
||||||
|
(t/deftest read-obj-rejects-oversized-buffer
|
||||||
|
;; N1-07: read-obj! must reject objects exceeding max-object-size
|
||||||
|
;; before attempting to allocate the buffer
|
||||||
|
(let [size (+ bfc/max-object-size 1)
|
||||||
|
baos (java.io.ByteArrayOutputStream. 17)
|
||||||
|
dos (java.io.DataOutputStream. baos)]
|
||||||
|
(.writeByte dos 5)
|
||||||
|
(.writeLong dos (long size))
|
||||||
|
(.flush dos)
|
||||||
|
(let [input (java.io.DataInputStream.
|
||||||
|
(ByteArrayInputStream. (.toByteArray baos)))]
|
||||||
|
(binding [v1/*position* (atom 0)]
|
||||||
|
(let [out (try
|
||||||
|
(v1/read-obj! input)
|
||||||
|
nil
|
||||||
|
(catch clojure.lang.ExceptionInfo e
|
||||||
|
(ex-data e)))]
|
||||||
|
;; Without the guard, read-obj! will either OOM or proceed
|
||||||
|
;; to read-bytes! on a truncated stream (no :max-file-size-reached).
|
||||||
|
;; With the guard, it raises :validation :max-file-size-reached.
|
||||||
|
(t/is (= :validation (:type out)))
|
||||||
|
(t/is (= :max-file-size-reached (:code out))))))))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user