mirror of
https://github.com/penpot/penpot.git
synced 2026-08-25 14:18:40 +00:00
Compare commits
219 Commits
2.18.0-RC1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a3352927d | ||
|
|
57c0e81616 | ||
|
|
81c3b3cd56 | ||
|
|
a83a5e2eff | ||
|
|
c31236947c | ||
|
|
52573be074 | ||
|
|
316b58bc75 | ||
|
|
e93c419785 | ||
|
|
7732c93541 | ||
|
|
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
|
||||
{:level :off}
|
||||
|
||||
:type-mismatch
|
||||
{:level :off}
|
||||
|
||||
:used-underscored-binding
|
||||
{: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 ───────────────────────
|
||||
check:
|
||||
name: Check current bundle
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
@ -75,7 +75,7 @@ jobs:
|
||||
# ── 2. Build and upload, only when needed ──────────────────────────────
|
||||
build:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 90
|
||||
needs: check
|
||||
if: needs.check.outputs.exists == 'false'
|
||||
@ -116,7 +116,7 @@ jobs:
|
||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 5
|
||||
needs: [check, build]
|
||||
if: failure()
|
||||
|
||||
2
.github/workflows/build-develop.yml
vendored
2
.github/workflows/build-develop.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
|
||||
build-admin-console-docker:
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
2
.github/workflows/build-docker-devenv.yml
vendored
2
.github/workflows/build-docker-devenv.yml
vendored
@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
|
||||
steps:
|
||||
- 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 ───────────
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
@ -107,7 +107,7 @@ jobs:
|
||||
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 60
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
@ -220,7 +220,7 @@ jobs:
|
||||
# the S3 marker guarantees the branch tags were already moved.
|
||||
promote:
|
||||
name: Promote image set
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 10
|
||||
needs: [prepare, build]
|
||||
|
||||
@ -267,7 +267,7 @@ jobs:
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 5
|
||||
needs: [prepare, build, promote]
|
||||
if: failure()
|
||||
|
||||
2
.github/workflows/build-staging.yml
vendored
2
.github/workflows/build-staging.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
|
||||
build-admin-console-docker:
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
14
.github/workflows/build-tag.yml
vendored
14
.github/workflows/build-tag.yml
vendored
@ -20,10 +20,18 @@ jobs:
|
||||
with:
|
||||
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:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-docker
|
||||
- build-docker-admin-console
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
@ -37,7 +45,9 @@ jobs:
|
||||
|
||||
publish-final-tag:
|
||||
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
|
||||
secrets: inherit
|
||||
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:
|
||||
deploy:
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
steps:
|
||||
- name: Checkout
|
||||
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:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Backend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
2
.github/workflows/tests-common.yml
vendored
2
.github/workflows/tests-common.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
test-common:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Common Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
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:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Run composable test suite (mocked backend)"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
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:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Frontend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
192
.github/workflows/tests-integration.yml
vendored
192
.github/workflows/tests-integration.yml
vendored
@ -5,11 +5,37 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
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:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
- '.github/workflows/tests-integration.yml'
|
||||
|
||||
types:
|
||||
- opened
|
||||
@ -25,25 +51,41 @@ on:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
- '.github/workflows/tests-integration.yml'
|
||||
|
||||
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
|
||||
|
||||
jobs:
|
||||
build-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Build Integration Bundle"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
outputs:
|
||||
bundle_key: ${{ steps.vars.outputs.bundle_key }}
|
||||
|
||||
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
|
||||
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
|
||||
working-directory: ./frontend
|
||||
@ -53,41 +95,151 @@ jobs:
|
||||
- name: Store Bundle Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
key: ${{ steps.vars.outputs.bundle_key }}
|
||||
path: frontend/resources/public
|
||||
|
||||
test-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests"
|
||||
runs-on: penpot-runner-02
|
||||
name: "Integration Tests (${{ matrix.shard }})"
|
||||
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:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Run Tests
|
||||
- name: Install deps
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install --frozen-lockfile;
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: integration-tests-result
|
||||
path: frontend/test-results/
|
||||
path: frontend/all-blob-reports
|
||||
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
|
||||
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:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Library Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
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:
|
||||
pull_request:
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
test-mcp:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Test MCP"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
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:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
|
||||
name: "Run Plugin API Test Suite (mocked)"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
@ -95,7 +95,7 @@ jobs:
|
||||
# api-test-suite-live:
|
||||
# if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
# name: Run Plugin API Test Suite (live)
|
||||
# runs-on: penpot-runner-02
|
||||
# runs-on: penpot-extended-runner
|
||||
# container:
|
||||
# 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:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Plugins Runtime Linter & Tests
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
2
.github/workflows/tests-wasm.yml
vendored
2
.github/workflows/tests-wasm.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
test-render-wasm:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Render WASM Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -58,6 +58,8 @@ opencode.json
|
||||
/docker/images/bundle*
|
||||
/exporter/target
|
||||
/exporter/.shadow-cljs
|
||||
/exporter/resources/wasm/
|
||||
/exporter/src/app/wasm/shared.js
|
||||
/frontend/.storybook/preview-body.html
|
||||
/frontend/.storybook/preview-head.html
|
||||
/frontend/playwright-report/
|
||||
@ -88,6 +90,7 @@ opencode.json
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/render-wasm/target/
|
||||
/media-processor/dist/
|
||||
/**/node_modules
|
||||
/**/.yarn/*
|
||||
/.pnpm-store
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
---
|
||||
name: commiter
|
||||
description: Git commit assistant
|
||||
mode: subagent
|
||||
permission:
|
||||
read: allow
|
||||
glob: allow
|
||||
grep: allow
|
||||
edit: deny
|
||||
webfetch: deny
|
||||
websearch: deny
|
||||
task: deny
|
||||
skill: deny
|
||||
lsp: deny
|
||||
todowrite: deny
|
||||
question: deny
|
||||
external_directory: deny
|
||||
bash: allow
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are the Penpot commit assistant. You produce git commits that follow the
|
||||
repository's commit conventions. You do not implement features, review code, or
|
||||
push branches — you commit.
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
|
||||
end-to-end**. It is the authoritative source for the commit message format, the
|
||||
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
|
||||
exactly — do not improvise the format and do not restate its contents here.
|
||||
|
||||
## Pre-commit Workflow
|
||||
|
||||
1. **Stage the files** specified by the calling agent. Do not ask for
|
||||
confirmation — the calling agent knows exactly which files to commit.
|
||||
2. Run `git diff --staged` to review the content. If you see secrets (API
|
||||
keys, tokens, passwords, private keys, `.env` values), debug prints, or
|
||||
anything that does not match the stated intent, STOP and tell the user
|
||||
before committing.
|
||||
3. Following the format in the doc, draft the message and run
|
||||
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
|
||||
unusual characters). The `AI-assisted-by` trailer value is provided by the
|
||||
calling agent — use it verbatim.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not push. Pushing is a separate workflow handled by the user.
|
||||
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
|
||||
- Do not pass `--author`. Author identity comes from the local git config.
|
||||
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
|
||||
- Do not add untracked files that were not created in this session.
|
||||
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
@ -32,12 +32,11 @@ Implement the prepared plan from the session context. Work methodically, keeping
|
||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||
step 4.
|
||||
|
||||
## 4. Commit with the commiter subagent
|
||||
## 4. Commit with the create-commit skill
|
||||
|
||||
After the implementation is complete, delegate the commit to the **`commiter`**
|
||||
subagent. Give it a brief summary of what was implemented and why, the issue
|
||||
reference (`issue-NNNN`), and the model name you are running as so it sets the
|
||||
`AI-assisted-by` trailer correctly. The subagent owns the commit format and
|
||||
conventions.
|
||||
After the implementation is complete, load the **`create-commit`** skill and
|
||||
follow its workflow to commit the changes. Provide a brief summary of what was
|
||||
implemented and why, the issue reference (`issue-NNNN`), and the model name you
|
||||
are running as so the `AI-assisted-by` trailer is set correctly.
|
||||
|
||||
Do not push. Pushing is handled separately by the user.
|
||||
|
||||
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
|
||||
|
||||
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
|
||||
2. Determine the diff or code to review from the provided context.
|
||||
3. Read the diff and the surrounding context for each changed file.
|
||||
4. Review across all five axes: correctness, readability, architecture, security, performance.
|
||||
5. Produce the review using the **Review Output** format from the skill (Summary → Critical/High → Other Findings → Refactoring → Testing Recommendations → Positive Observations → Final Verdict).
|
||||
6. For each finding: state the severity (Critical / High / Medium / Low / Suggestion), identify the file and line, describe failure circumstances, and propose a concrete fix.
|
||||
7. Do not invent problems. Every finding must be real and actionable.
|
||||
1. **Determine what is being reviewed** from the provided context:
|
||||
- **If it is a plan** (implementation plan, design document, task breakdown) → load the **`plan-review`** skill.
|
||||
- **If it is code** (diff, PR, code change) → load the **`code-review`** skill.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -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.
|
||||
---
|
||||
|
||||
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
|
||||
|
||||
- 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`
|
||||
- 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.
|
||||
* **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`.
|
||||
|
||||
|
||||
@ -14,10 +14,7 @@
|
||||
|
||||
## 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`.
|
||||
- `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.
|
||||
- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
|
||||
- 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.
|
||||
- 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.
|
||||
@ -28,4 +25,4 @@
|
||||
- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data.
|
||||
- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob.
|
||||
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written.
|
||||
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
|
||||
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
|
||||
|
||||
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
|
||||
|
||||
- `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities.
|
||||
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.nitrate-permissions` contains shared fail-closed Nitrate organization/team permission rules.
|
||||
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.organization` contains organization schemas, `apply-organization`, and fail-closed organization/team permission rules (`allowed?`, `can-send-invitations?`).
|
||||
- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic.
|
||||
- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc.
|
||||
- `app.common.geom.*`: geometry helpers and transformations.
|
||||
|
||||
@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply
|
||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
||||
- `library/`: design library workflows; core conventions: `mem:library/core`.
|
||||
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
|
||||
- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`.
|
||||
|
||||
The memory is structured in a way that you can get the critical information about the
|
||||
module. You can read it from `mem:<MODULE>/core`
|
||||
|
||||
@ -5,9 +5,10 @@
|
||||
## Layout and commands
|
||||
|
||||
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; 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/`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- Exporter test conventions and CI: `mem:exporter/testing`.
|
||||
|
||||
## HTTP and browser pool
|
||||
|
||||
@ -31,4 +32,4 @@
|
||||
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
|
||||
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
|
||||
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
|
||||
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.
|
||||
- **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).
|
||||
- **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
|
||||
|
||||
- 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,16 @@
|
||||
|
||||
## 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).
|
||||
- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring
|
||||
work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is
|
||||
compose+present only.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- Fetching PR details by number or by milestone.
|
||||
- Comparing milestone issues against CHANGES.md to find missing entries.
|
||||
- Listing or inspecting GitHub Security Advisories (GHSA).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
|
||||
**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
|
||||
|
||||
- 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)
|
||||
|
||||
Body explaining what changed and why.
|
||||
Wrap lines at 72 characters — git log and tooling
|
||||
render long lines poorly. Keep each line concise.
|
||||
|
||||
AI-assisted-by: model-name
|
||||
```
|
||||
@ -25,3 +27,7 @@ AI-assisted-by: model-name
|
||||
## Commit Type Emojis
|
||||
|
||||
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
|
||||
|
||||
## Referencing Issues
|
||||
|
||||
Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
|
||||
|
||||
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
|
||||
|
||||
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-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/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))
|
||||
|
||||
|
||||
## 2.17.1 (Unreleased)
|
||||
## 2.17.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
|
||||
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
|
||||
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
|
||||
- Fix 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 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 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
|
||||
|
||||
|
||||
@ -160,6 +160,6 @@ 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
|
||||
Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
```
|
||||
Penpot is a Kaleidos’ [open source project](https://kaleidos.net/)
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||
org.passay/passay {:mvn/version "1.6.6"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
;; This is an example on how it can be executed:
|
||||
;; clojure -Scp $(cat classpath) -M dev/script-fix-sobjects.clj
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns user
|
||||
(:require
|
||||
|
||||
@ -4,23 +4,25 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
|
||||
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
},
|
||||
"dependencies": {
|
||||
"luxon": "^3.4.4",
|
||||
"sax": "^1.6.0"
|
||||
"eventsource-parser": "^3.0.6",
|
||||
"luxon": "^3.7.2",
|
||||
"sax": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ws": "^8.21.0"
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"scripts": {
|
||||
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
|
||||
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/"
|
||||
"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:
|
||||
eventsource-parser:
|
||||
specifier: ^3.0.6
|
||||
version: 3.1.0
|
||||
luxon:
|
||||
specifier: ^3.4.4
|
||||
specifier: ^3.7.2
|
||||
version: 3.7.2
|
||||
sax:
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1
|
||||
devDependencies:
|
||||
nodemon:
|
||||
specifier: ^3.1.14
|
||||
@ -22,8 +25,8 @@ importers:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
ws:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
specifier: ^8.21.1
|
||||
version: 8.21.1
|
||||
|
||||
packages:
|
||||
|
||||
@ -39,9 +42,9 @@ packages:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
brace-expansion@5.0.7:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@ -63,6 +66,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
eventsource-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@ -130,8 +137,8 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
sax@1.6.0:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
sax@1.6.1:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
semver@7.8.5:
|
||||
@ -165,8 +172,8 @@ packages:
|
||||
undefsafe@2.0.5:
|
||||
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
ws@8.21.1:
|
||||
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@ -188,7 +195,7 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
brace-expansion@5.0.7:
|
||||
brace-expansion@5.0.9:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@ -216,6 +223,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
eventsource-parser@3.1.0: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
@ -247,7 +256,7 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.7
|
||||
brace-expansion: 5.0.9
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
@ -274,7 +283,7 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
|
||||
sax@1.6.0: {}
|
||||
sax@1.6.1: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
@ -301,4 +310,4 @@ snapshots:
|
||||
|
||||
undefsafe@2.0.5: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
ws@8.21.1: {}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- brace-expansion@5.0.8 || 5.0.9
|
||||
@ -39,4 +39,16 @@
|
||||
{:permits 3}
|
||||
|
||||
:create-file-snapshot/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}}
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:send-user-feedback/global
|
||||
{:permits 4}
|
||||
|
||||
:send-user-feedback/by-profile
|
||||
{:permits 1 :queue 3}
|
||||
|
||||
:import-binfile/global
|
||||
{:permits 4}
|
||||
|
||||
:import-binfile/by-profile
|
||||
{:permits 1 :queue 2}}
|
||||
|
||||
@ -1,11 +1,308 @@
|
||||
;; Example rlimit.edn file
|
||||
^{:refresh "30s"}
|
||||
{:default
|
||||
[[: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}
|
||||
;; [[:burst :bucket "60/60/1m"]]
|
||||
}
|
||||
#{:main/login-with-ldap}
|
||||
[[: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
|
||||
|
||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
||||
export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key
|
||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||
export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key
|
||||
|
||||
# DEPRECATED: only used for subscriptions
|
||||
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
||||
@ -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
|
||||
# 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+
|
||||
# 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
|
||||
@ -21,6 +26,8 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then
|
||||
__worker_flag="enable-backend-worker"
|
||||
fi
|
||||
|
||||
export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
|
||||
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-login-with-password \
|
||||
@ -36,6 +43,7 @@ export PENPOT_FLAGS="\
|
||||
enable-feature-fdata-objects-map \
|
||||
enable-audit-log \
|
||||
enable-transit-readable-response \
|
||||
disable-remote-media-processing \
|
||||
enable-demo-users \
|
||||
enable-user-feedback \
|
||||
disable-secure-session-cookies \
|
||||
@ -71,7 +79,7 @@ export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
|
||||
|
||||
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
|
||||
|
||||
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
|
||||
export PENPOT_ADMIN_CONSOLE_URI=http://localhost:3000/admin-console
|
||||
|
||||
export JAVA_OPTS="\
|
||||
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||
@ -97,5 +105,3 @@ function setup_minio() {
|
||||
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
|
||||
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
# 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
|
||||
# Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.auth
|
||||
(:require
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.auth.ldap
|
||||
(:require
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.auth.oidc
|
||||
"OIDC client implementation."
|
||||
@ -620,9 +620,6 @@
|
||||
(some? (:external-session-id state))
|
||||
(assoc :external-session-id (:external-session-id state))
|
||||
|
||||
(some? (:token/expires-in tdata))
|
||||
(assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)}))
|
||||
|
||||
;; If state token comes with props, merge them. The state token
|
||||
;; props can contain pm_ and utm_ prefixed query params.
|
||||
(map? (:props state))
|
||||
@ -650,6 +647,15 @@
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
(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
|
||||
[cfg info provider]
|
||||
(let [info (assoc info
|
||||
@ -765,6 +771,82 @@
|
||||
;; 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
|
||||
[value]
|
||||
(when-not (str/blank? value) value))
|
||||
@ -776,7 +858,7 @@
|
||||
|
||||
(defn prepare-organization-sso-provider
|
||||
"Build an OIDC provider map dynamically from the Nitrate organization SSO config.
|
||||
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
|
||||
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
|
||||
[cfg {:keys [client-id client-secret issuer]}]
|
||||
(prepare-oidc-provider cfg
|
||||
{:type "oidc"
|
||||
@ -785,8 +867,7 @@
|
||||
:base-uri (some-> (non-blank-uri issuer)
|
||||
(str/rtrim "/")
|
||||
(str "/"))
|
||||
:scopes default-oidc-scopes
|
||||
:skip-ssrf-check? true}))
|
||||
:scopes default-oidc-scopes}))
|
||||
|
||||
(defn build-organization-sso-auth-redirect-uri
|
||||
"Build the OIDC authorization redirect URI for an organization SSO config.
|
||||
@ -888,10 +969,53 @@
|
||||
{::yres/status 200
|
||||
::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
|
||||
[cfg {:keys [params] :as request}]
|
||||
(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
|
||||
(let [code (get params :code)
|
||||
state (get params :state)
|
||||
@ -899,18 +1023,8 @@
|
||||
|
||||
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; code with the OIDC provider to verify authentication actually occurred.
|
||||
(if-let [dest-url (:dest-url state)]
|
||||
(let [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 (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))
|
||||
(if (:dest-url state)
|
||||
(organization-sso-callback-handler cfg request state code)
|
||||
|
||||
(let [provider (resolve-provider cfg state)
|
||||
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 SUBSIDIARY 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?))))))
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.binfile.cleaner
|
||||
"A collection of helpers for perform cleaning of artifacts; mainly
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.binfile.common
|
||||
"A binfile related file processing common code, used for different
|
||||
@ -723,6 +723,7 @@
|
||||
(-> (select-keys file file-attrs)
|
||||
(assoc :data nil)
|
||||
(dissoc :team-id)
|
||||
(dissoc :metadata)
|
||||
(dissoc :migrations)))
|
||||
|
||||
(defn- file->file-data-params
|
||||
@ -748,9 +749,17 @@
|
||||
(fmigr/upsert-migrations! conn file))
|
||||
|
||||
(let [file (encode-file cfg file)]
|
||||
(db/insert! conn :file
|
||||
(file->params file)
|
||||
(assoc opts ::db/return-keys false))
|
||||
(try
|
||||
(db/insert! conn :file
|
||||
(file->params file)
|
||||
(assoc opts ::db/return-keys false))
|
||||
(catch org.postgresql.util.PSQLException cause
|
||||
(if (db/duplicate-key-error? cause)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "file already exists"
|
||||
:cause cause)
|
||||
(throw cause))))
|
||||
|
||||
(->> (file->file-data-params file)
|
||||
(fdata/upsert! cfg))
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.binfile.migrations
|
||||
"A binfile related migrations handling"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.binfile.v1
|
||||
"A custom, perfromance and efficiency focused binfile format impl"
|
||||
@ -174,6 +174,10 @@
|
||||
(assert-mark m :obj)
|
||||
(let [size (read-long! input)]
|
||||
(assert (pos? size) "incorrect header size found on reading header")
|
||||
(when (> size bfc/max-object-size)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (dm/str "unable to import object with size " size " bytes")))
|
||||
(let [buff (byte-array size)]
|
||||
(read-bytes! input buff)
|
||||
(fres/decode buff)))))
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.binfile.v2
|
||||
"A sqlite3 based binary file exportation with support for exportation
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.binfile.v3
|
||||
"A ZIP based binary file exportation"
|
||||
@ -392,7 +392,7 @@
|
||||
params {:type "penpot/export-files"
|
||||
:version 1
|
||||
:generated-by (str "penpot/" (:full cf/version))
|
||||
:refer "penpot"
|
||||
:referer "penpot"
|
||||
:files (vec (vals files))
|
||||
:relations rels}]
|
||||
(write-entry! output "manifest.json" params))))
|
||||
@ -734,7 +734,7 @@
|
||||
:plugin-data plugin-data}))
|
||||
|
||||
(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)
|
||||
file (read-file cfg file-id)
|
||||
media (read-file-media cfg file-id)
|
||||
@ -801,8 +801,10 @@
|
||||
(assoc :data data)
|
||||
(assoc :name file-name)
|
||||
(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))
|
||||
|
||||
file (bfc/process-file cfg file)
|
||||
file (ctf/check-file file)]
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.config
|
||||
(:refer-clojure :exclude [get])
|
||||
@ -52,7 +52,7 @@
|
||||
|
||||
:redis-uri "redis://redis/0"
|
||||
|
||||
:file-data-backend "legacy-db"
|
||||
:file-data-backend "db"
|
||||
|
||||
:objects-storage-backend "fs"
|
||||
:objects-storage-fs-directory "assets"
|
||||
@ -119,8 +119,9 @@
|
||||
[:allowed-origins {:optional true} [::sm/set :string]]
|
||||
|
||||
[:exporter-shared-key {:optional true} :string]
|
||||
[:nitrate-shared-key {:optional true} :string]
|
||||
[:admin-console-shared-key {:optional true} :string]
|
||||
[:nexus-shared-key {:optional true} :string]
|
||||
[:media-processor-shared-key {:optional true} :string]
|
||||
[:management-api-key {:optional true} :string]
|
||||
|
||||
[:telemetry-uri {:optional true} :string]
|
||||
@ -147,6 +148,9 @@
|
||||
[:imagemagick-width-limit {:optional true} :string]
|
||||
[:imagemagick-height-limit {:optional true} :string]
|
||||
|
||||
[:media-processing-service-uri {:optional true} ::sm/uri]
|
||||
[:media-processing-service-timeout {:optional true} ::sm/int]
|
||||
|
||||
[:deletion-delay {:optional true} ::ct/duration]
|
||||
[:file-clean-delay {:optional true} ::ct/duration]
|
||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||
@ -264,7 +268,7 @@
|
||||
|
||||
[:netty-io-threads {:optional true} ::sm/int]
|
||||
|
||||
[:nitrate-backend-uri {:optional true} ::sm/uri]
|
||||
[:admin-console-uri {:optional true} ::sm/uri]
|
||||
|
||||
;; DEPRECATED
|
||||
[:assets-storage-backend {:optional true} :keyword]
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.db
|
||||
(:refer-clojure :exclude [get run!])
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.db.sql
|
||||
(:refer-clojure :exclude [update])
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.email
|
||||
"Main api for send emails."
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.email.blacklist
|
||||
"Email blacklist provider"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.email.whitelist
|
||||
"Email whitelist provider"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.features.fdata
|
||||
"A `fdata/*` related feature migration helpers"
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.objects-map :as omap]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@ -159,15 +160,17 @@
|
||||
:content-type "application/octet-stream"
|
||||
:file-id file-id
|
||||
:id id})
|
||||
metadata {:storage-ref-id (:id sobject)}
|
||||
metadata (-> (:metadata params)
|
||||
(assoc :storage-ref-id (:id sobject)))
|
||||
params (-> params
|
||||
(assoc :metadata metadata)
|
||||
(assoc :data nil))]
|
||||
(upsert-in-database cfg params))
|
||||
|
||||
(= backend "db")
|
||||
(->> (dissoc params :metadata)
|
||||
(upsert-in-database cfg))
|
||||
(let [metadata (dissoc (:metadata params) :storage-ref-id)
|
||||
params (assoc params :metadata metadata)]
|
||||
(upsert-in-database cfg params))
|
||||
|
||||
(= backend "legacy-db")
|
||||
(cond
|
||||
@ -213,18 +216,11 @@
|
||||
[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
|
||||
[metadata]
|
||||
(some-> metadata
|
||||
(db/decode-json-pgobject)
|
||||
(decode-metadata-with-schema)))
|
||||
(ctf/decode-file-metadata)))
|
||||
|
||||
(def ^:private schema:update-params
|
||||
[:map {:closed true}
|
||||
@ -232,7 +228,7 @@
|
||||
[:type [:enum "main" "snapshot" "fragment"]]
|
||||
[:file-id ::sm/uuid]
|
||||
[: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?]
|
||||
[:created-at {:optional true} ::ct/inst]
|
||||
[:modified-at {:optional true} [:maybe ::ct/inst]]
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.features.file-migrations
|
||||
"Backend specific code for file migrations. Implemented as permanent feature of files."
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.features.file-snapshots
|
||||
(:require
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.features.logical-deletion
|
||||
"A code related to handle logical deletion mechanism"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http
|
||||
(:require
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.access-token
|
||||
(:require
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.assets
|
||||
"Assets related handlers."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
@ -42,18 +43,30 @@
|
||||
|
||||
(defn- get-file-media-object
|
||||
[pool id]
|
||||
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||
|
||||
(defn- serve-object-from-s3
|
||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||
cch-max-age (or cache-max-age default-cache-max-age)
|
||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
|
||||
bucket (-> obj meta :bucket)
|
||||
public? (contains? public-buckets bucket)
|
||||
;; 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-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||
(not public?)
|
||||
(assoc "content-disposition" "attachment"))]
|
||||
{::yres/status 307
|
||||
::yres/headers {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
||||
::yres/headers headers}))
|
||||
|
||||
(defn- serve-object-from-fs
|
||||
[{:keys [::path ::cache-max-age]} obj]
|
||||
@ -61,9 +74,12 @@
|
||||
purl (u/join (u/uri path)
|
||||
(sto/object->relative-path obj))
|
||||
mdata (meta obj)
|
||||
headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
||||
bucket (:bucket mdata)
|
||||
headers (cond-> {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||
(not (contains? public-buckets bucket))
|
||||
(assoc "content-disposition" "attachment"))]
|
||||
{::yres/status 204
|
||||
::yres/headers headers}))
|
||||
|
||||
@ -109,13 +125,21 @@
|
||||
(defn- generic-handler
|
||||
"A generic handler helper/common code for file-media based handlers."
|
||||
[{:keys [::sto/storage] :as cfg} request kf]
|
||||
(let [pool (::db/pool storage)
|
||||
id (get-id request)
|
||||
mobj (get-file-media-object pool id)
|
||||
sobj (sto/get-object storage (kf mobj))]
|
||||
(if sobj
|
||||
(serve-object cfg sobj)
|
||||
{::yres/status 404})))
|
||||
(let [pool (::db/pool storage)
|
||||
id (get-id request)
|
||||
mobj (get-file-media-object pool id)]
|
||||
(if (nil? mobj)
|
||||
{::yres/status 404}
|
||||
(let [file-id (:file-id mobj)
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request))
|
||||
perms (bfc/get-file-permissions pool profile-id file-id)]
|
||||
(if-not (:can-read perms)
|
||||
{::yres/status 404}
|
||||
(let [sobj (sto/get-object storage (kf mobj))]
|
||||
(if sobj
|
||||
(serve-object cfg sobj)
|
||||
{::yres/status 404})))))))
|
||||
|
||||
(defn file-objects-handler
|
||||
"Handler that serves storage objects by file media id."
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.awsns
|
||||
"AWS SNS webhook handler for bounces."
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.client
|
||||
"Http client abstraction layer.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.debug
|
||||
(:refer-clojure :exclude [error-handler])
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.errors
|
||||
"A errors handling for the http server."
|
||||
@ -34,6 +34,12 @@
|
||||
(assoc :request/auth-data (dissoc auth :token))
|
||||
(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
|
||||
(fn [cause _ _]
|
||||
(-> cause ex-data :type)))
|
||||
@ -136,6 +142,7 @@
|
||||
(l/error :hint "assertion error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body (-> data
|
||||
(strip-internal-fields)
|
||||
(assoc :type :server-error)
|
||||
(assoc :code :assertion))})))))
|
||||
|
||||
@ -161,9 +168,9 @@
|
||||
(l/error :hint "internal error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body (-> data
|
||||
(strip-internal-fields)
|
||||
(assoc :type :server-error)
|
||||
(update :code #(or % :unhandled))
|
||||
(assoc :hint (ex-message error)))})))
|
||||
(update :code #(or % :unhandled)))})))
|
||||
|
||||
(defmethod handle-error :default
|
||||
[error request parent-cause]
|
||||
@ -178,6 +185,20 @@
|
||||
(handle-exception (:handling edata) request error)
|
||||
(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
|
||||
[error request parent-cause]
|
||||
(let [state (.getSQLState ^java.sql.SQLException error)
|
||||
@ -190,20 +211,19 @@
|
||||
{::yres/status 504
|
||||
::yres/body {:type :server-error
|
||||
:code :statement-timeout
|
||||
:hint (ex-message error)}}
|
||||
:hint (pgsql-state->message state)}}
|
||||
|
||||
(= state "25P03")
|
||||
{::yres/status 504
|
||||
::yres/body {:type :server-error
|
||||
:code :idle-in-transaction-timeout
|
||||
:hint (ex-message error)}}
|
||||
:hint (pgsql-state->message state)}}
|
||||
|
||||
:else
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)
|
||||
:state state}}))))
|
||||
:code :database-error
|
||||
:hint (pgsql-state->message state)}}))))
|
||||
|
||||
(defmethod handle-exception :default
|
||||
[error request parent-cause]
|
||||
@ -216,17 +236,16 @@
|
||||
(l/error :hint "unexpected error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)}})
|
||||
:code :unexpected}})
|
||||
|
||||
:else
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/error :hint "unhandled error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body (-> edata
|
||||
(strip-internal-fields)
|
||||
(assoc :type :server-error)
|
||||
(update :code #(or % :unhandled))
|
||||
(assoc :hint (ex-message error)))}))))
|
||||
(update :code #(or % :unhandled)))}))))
|
||||
|
||||
(defmethod handle-exception java.io.IOException
|
||||
[cause request _]
|
||||
@ -234,9 +253,7 @@
|
||||
(l/wrn :hint "io exception" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :io-exception
|
||||
:hint (ex-message cause)
|
||||
:path (:path request)}}))
|
||||
:code :io-exception}}))
|
||||
|
||||
(defmethod handle-exception java.util.concurrent.CompletionException
|
||||
[cause request _]
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.management
|
||||
"Internal mangement HTTP API"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.middleware
|
||||
(:require
|
||||
@ -24,7 +24,8 @@
|
||||
(:import
|
||||
io.undertow.server.RequestTooBigException
|
||||
java.io.InputStream
|
||||
java.io.OutputStream))
|
||||
java.io.OutputStream
|
||||
java.security.MessageDigest))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@ -82,18 +83,18 @@
|
||||
(instance? IllegalArgumentException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint (ex-message cause)
|
||||
:hint "invalid JSON in request body"
|
||||
:cause cause)
|
||||
|
||||
(instance? RequestTooBigException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :request-body-too-large
|
||||
:hint (ex-message cause))
|
||||
:hint "request body exceeds size limit")
|
||||
|
||||
(instance? java.io.EOFException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint (ex-message cause)
|
||||
:hint "unexpected end of request body"
|
||||
:cause cause)
|
||||
|
||||
(instance? RuntimeException cause)
|
||||
@ -329,6 +330,11 @@
|
||||
{:name ::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
|
||||
[handler keys]
|
||||
(if (seq keys)
|
||||
@ -338,7 +344,7 @@
|
||||
(let [key-id (-> key-id str/lower keyword)]
|
||||
(if (and (string? key)
|
||||
(contains? keys key-id)
|
||||
(= key (get keys key-id)))
|
||||
(constant-time-eq? key (get keys key-id)))
|
||||
(-> request
|
||||
(assoc ::http/auth-key-id key-id)
|
||||
(handler))
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.security
|
||||
"Additional security layer middlewares"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.session
|
||||
(:refer-clojure :exclude [read])
|
||||
@ -204,7 +204,7 @@
|
||||
[{:keys [::manager]}]
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(fn [request response]
|
||||
(some->> (get request ::id) (delete-session manager))
|
||||
(some->> (get request ::session) :id (delete-session manager))
|
||||
(clear-session-cookie response)))
|
||||
|
||||
(defn decode-token
|
||||
@ -226,6 +226,14 @@
|
||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||
(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
|
||||
(str "UPDATE http_session_v2 "
|
||||
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.sse
|
||||
"SSE (server sent events) helpers"
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.websocket
|
||||
"A penpot notification service for file cooperative edition."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
@ -17,6 +18,8 @@
|
||||
[app.http.session :as session]
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.util.websocket :as ws]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec.csp :as sp]
|
||||
@ -131,8 +134,9 @@
|
||||
(mbus/pub! msgbus :topic topic :message msg))))
|
||||
|
||||
(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)
|
||||
(teams/check-read-permissions! pool profile-id team-id)
|
||||
(let [prev-subs (get @state ::team-subscription)
|
||||
channel (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
@ -150,8 +154,10 @@
|
||||
|
||||
|
||||
(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)
|
||||
(bfc/check-file-exists pool file-id)
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
(let [psub (::file-subscription @state)
|
||||
fch (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.loggers.audit
|
||||
"Services related to the user activity (audit log)."
|
||||
@ -36,6 +36,16 @@
|
||||
(def ^:private filter-auth-events
|
||||
#{"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
|
||||
#{:version
|
||||
:initiator
|
||||
@ -297,6 +307,14 @@
|
||||
(defn filter-telemetry-props
|
||||
[{:keys [source name props type] :as params}]
|
||||
(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")
|
||||
(= type "identify"))
|
||||
(and (= source "backend")
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.loggers.audit.archive-task
|
||||
(:require
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.loggers.audit.gc-task
|
||||
(:require
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.loggers.database
|
||||
"A specific logger impl that persists errors on the database."
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.loggers.mattermost
|
||||
"A mattermost integration for error reporting."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
@ -25,7 +26,7 @@
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [id] :as report}]
|
||||
(let [type (get report :type)
|
||||
text (str "#" type " | " (get report :hint) "\n"
|
||||
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
|
||||
(when id
|
||||
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
||||
|
||||
@ -38,7 +39,7 @@
|
||||
"- tenant: #" (:tenant report) "\n"
|
||||
"- origin: #" (:origin report) "\n"
|
||||
(when-let [href (get report :href)]
|
||||
(str "- href: `" href "`\n"))
|
||||
(str "- href: `" (d/escape-markdown href) "`\n"))
|
||||
(when-let [version (get report :frontend-version)]
|
||||
(str "- frontend-version: `" version "`\n"))
|
||||
(when-let [version (get report :backend-version)]
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.loggers.webhooks
|
||||
"A mattermost integration for error reporting."
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.main
|
||||
(:require
|
||||
@ -335,6 +335,7 @@
|
||||
::rpc/rlimit (ig/ref ::rpc/rlimit)
|
||||
::setup/templates (ig/ref ::setup/templates)
|
||||
::setup/props (ig/ref ::setup/props)
|
||||
::setup/shared-keys (ig/ref ::setup/shared-keys)
|
||||
|
||||
::email/blacklist (ig/ref ::email/blacklist)
|
||||
::email/whitelist (ig/ref ::email/whitelist)
|
||||
@ -467,10 +468,11 @@
|
||||
::migrations (ig/ref :app.migrations/migrations)}
|
||||
|
||||
::setup/shared-keys
|
||||
{::setup/props (ig/ref ::setup/props)
|
||||
:nexus (cf/get :nexus-shared-key)
|
||||
:nitrate (cf/get :nitrate-shared-key)
|
||||
:exporter (cf/get :exporter-shared-key)}
|
||||
{::setup/props (ig/ref ::setup/props)
|
||||
:nexus (cf/get :nexus-shared-key)
|
||||
:admin-console (cf/get :admin-console-shared-key)
|
||||
:exporter (cf/get :exporter-shared-key)
|
||||
:media-processor (cf/get :media-processor-shared-key)}
|
||||
|
||||
::setup/clock
|
||||
{}
|
||||
|
||||
@ -2,319 +2,40 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.media
|
||||
"Media & Font postprocessing."
|
||||
"Media & Font postprocessing.
|
||||
|
||||
This namespace is the dispatch layer only. Processing implementations
|
||||
live in two separate namespaces, each owning their own defmulti:
|
||||
|
||||
app.media.local — shell/ImageMagick/FontForge implementations
|
||||
app.media.remote — HTTP delegation to media-processor service
|
||||
|
||||
Validation and schemas live in app.media.validation (leaf namespace,
|
||||
no circular dep). When adding a new :cmd type, add defmethods in
|
||||
BOTH local and remote."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.openapi :as-alias oapi]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.http.client :as http]
|
||||
[app.media.local :as media.local]
|
||||
[app.media.remote :as media.remote]
|
||||
[app.media.sanitize :as sanitize]
|
||||
[app.media.validation :as validation]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.shell :as shell]
|
||||
[buddy.core.bytes :as bb]
|
||||
[buddy.core.codecs :as bc]
|
||||
[clojure.string]
|
||||
[clojure.xml :as xml]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
clojure.lang.XMLHandler
|
||||
java.io.InputStream
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
javax.xml.XMLConstants
|
||||
org.apache.commons.io.IOUtils))
|
||||
|
||||
(def schema:upload
|
||||
[:map {:title "Upload"}
|
||||
[:filename :string]
|
||||
[:size ::sm/int]
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} :string]
|
||||
[:headers {:optional true}
|
||||
[:map-of :string :string]]])
|
||||
|
||||
(def ^:private schema:input
|
||||
[:map {:title "Input"}
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} ::sm/text]])
|
||||
|
||||
(def check-input
|
||||
(sm/check-fn schema:input))
|
||||
|
||||
(defn validate-media-type!
|
||||
([upload] (validate-media-type! upload cm/image-types))
|
||||
([upload allowed]
|
||||
(when-not (contains? allowed (:mtype upload))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "Seems like you are uploading an invalid media object"))
|
||||
|
||||
upload))
|
||||
|
||||
(defn validate-media-size!
|
||||
[upload]
|
||||
(let [max-size (cf/get :media-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :media-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defn validate-font-size!
|
||||
"Validates that the font file `upload` does not exceed the configured
|
||||
`:font-max-file-size` limit. Accepts the same map shape as
|
||||
`validate-media-size!` — requires a `:size` key in bytes."
|
||||
[upload]
|
||||
(let [max-size (cf/get :font-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :font-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defmulti process (fn [_system params] (:cmd params)))
|
||||
|
||||
(defmethod process :default
|
||||
[_system {:keys [cmd] :as params}]
|
||||
(ex/raise :type :internal
|
||||
:code :not-implemented
|
||||
:hint (str/fmt "No impl found for process cmd: %s" cmd)))
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(defn run
|
||||
[system params]
|
||||
(process system params))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG PARSING
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- secure-parser-factory
|
||||
[^InputStream input ^XMLHandler handler]
|
||||
(.. (doto (SAXParserFactory/newInstance)
|
||||
(.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
|
||||
(.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
|
||||
(newSAXParser)
|
||||
(parse input handler)))
|
||||
|
||||
(defn- strip-doctype
|
||||
[data]
|
||||
(cond-> data
|
||||
(str/includes? data "<!DOCTYPE")
|
||||
(str/replace #"<\!DOCTYPE[^>]*>" "")))
|
||||
|
||||
(defn- parse-svg
|
||||
[text]
|
||||
(let [text (strip-doctype text)]
|
||||
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
|
||||
(xml/parse istream secure-parser-factory))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; IMAGE THUMBNAILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:thumbnail-params
|
||||
[:map {:title "ThumbnailParams"}
|
||||
[:input schema:input]
|
||||
[:format [:enum :jpeg :webp :png]]
|
||||
[:quality [:int {:min 1 :max 100}]]
|
||||
[:width :int]
|
||||
[:height :int]])
|
||||
|
||||
(def ^:private check-thumbnail-params
|
||||
(sm/check-fn schema:thumbnail-params))
|
||||
|
||||
;; Related info on how thumbnails generation
|
||||
;; http://www.imagemagick.org/Usage/thumbnails/
|
||||
|
||||
(def ^:private imagemagick-default-env
|
||||
"Default environment variables for ImageMagick resource limits.
|
||||
These are the soft ceiling — policy.xml is the hard ceiling."
|
||||
{"MAGICK_THREAD_LIMIT" "2"
|
||||
"MAGICK_MEMORY_LIMIT" "256MiB"
|
||||
"MAGICK_MAP_LIMIT" "512MiB"
|
||||
"MAGICK_AREA_LIMIT" "128MP"
|
||||
"MAGICK_DISK_LIMIT" "1GiB"
|
||||
"MAGICK_TIME_LIMIT" "30"})
|
||||
|
||||
(defn- get-imagemagick-env
|
||||
"Returns environment variables for ImageMagick commands.
|
||||
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
|
||||
[]
|
||||
(let [thread (cf/get :imagemagick-thread-limit)
|
||||
memory (cf/get :imagemagick-memory-limit)
|
||||
map-l (cf/get :imagemagick-map-limit)
|
||||
area (cf/get :imagemagick-area-limit)
|
||||
disk (cf/get :imagemagick-disk-limit)
|
||||
time (cf/get :imagemagick-time-limit)
|
||||
width (cf/get :imagemagick-width-limit)
|
||||
height (cf/get :imagemagick-height-limit)]
|
||||
(cond-> imagemagick-default-env
|
||||
thread (assoc "MAGICK_THREAD_LIMIT" thread)
|
||||
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
|
||||
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
|
||||
area (assoc "MAGICK_AREA_LIMIT" area)
|
||||
disk (assoc "MAGICK_DISK_LIMIT" disk)
|
||||
time (assoc "MAGICK_TIME_LIMIT" time)
|
||||
width (assoc "MAGICK_WIDTH_LIMIT" width)
|
||||
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
|
||||
|
||||
(defn- exec-magick!
|
||||
"Execute an ImageMagick command with resource limits.
|
||||
`args` is a vector of string arguments to pass to `magick`."
|
||||
[system args]
|
||||
(let [cmd (into ["magick"] args)
|
||||
result (shell/exec! system
|
||||
:cmd cmd
|
||||
:env (get-imagemagick-env)
|
||||
:timeout 60)]
|
||||
(when (not= 0 (:exit result))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint (str "ImageMagick command failed: " (:err result))
|
||||
:cmd cmd
|
||||
:exit (:exit result)))
|
||||
result))
|
||||
|
||||
(defn- generic-process
|
||||
[system {:keys [input format convert-args] :as params}]
|
||||
(let [{:keys [path mtype]} input
|
||||
format (or format (cm/mtype->format mtype))
|
||||
ext (cm/format->extension format)
|
||||
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
|
||||
(exec-magick! system args)
|
||||
(assoc params
|
||||
:format format
|
||||
:mtype (cm/format->mtype format)
|
||||
:size (fs/size tmp)
|
||||
:data tmp)))
|
||||
|
||||
(defmethod process :generic-thumbnail
|
||||
[system params]
|
||||
(let [{:keys [quality width height] :as params}
|
||||
(check-thumbnail-params params)]
|
||||
(generic-process system
|
||||
(assoc params
|
||||
:convert-args ["-auto-orient" "-strip"
|
||||
"-thumbnail" (str width "x" height ">")
|
||||
"-quality" (str quality)]))))
|
||||
|
||||
(defmethod process :profile-thumbnail
|
||||
[system params]
|
||||
(let [{:keys [quality width height] :as params}
|
||||
(check-thumbnail-params params)]
|
||||
(generic-process system
|
||||
(assoc params
|
||||
:convert-args ["-auto-orient" "-strip"
|
||||
"-thumbnail" (str width "x" height "^")
|
||||
"-gravity" "center"
|
||||
"-extent" (str width "x" height)
|
||||
"-quality" (str quality)]))))
|
||||
|
||||
(defn get-basic-info-from-svg
|
||||
[{:keys [tag attrs] :as data}]
|
||||
(when (not= tag :svg)
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-parse-svg
|
||||
:hint "uploaded svg has invalid content"))
|
||||
(reduce (fn [default f]
|
||||
(if-let [res (f attrs)]
|
||||
(reduced res)
|
||||
default))
|
||||
{:width 100 :height 100}
|
||||
[(fn parse-width-and-height
|
||||
[{:keys [width height]}]
|
||||
(when (and (string? width)
|
||||
(string? height))
|
||||
(let [width (d/parse-double width)
|
||||
height (d/parse-double height)]
|
||||
(when (and width height)
|
||||
{:width (int width)
|
||||
:height (int height)}))))
|
||||
(fn parse-viewbox
|
||||
[{:keys [viewBox]}]
|
||||
(let [[x y width height] (->> (str/split viewBox #"\s+" 4)
|
||||
(map d/parse-double))]
|
||||
(when (and x y width height)
|
||||
{:width (int width)
|
||||
:height (int height)})))]))
|
||||
|
||||
(defn- get-dimensions-with-orientation [system ^String path]
|
||||
;; Image magick doesn't give info about exif rotation so we use the identify command
|
||||
;; If we are processing an animated gif we use the first frame with -scene 0
|
||||
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
|
||||
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
|
||||
(when (= 0 (:exit dim-result))
|
||||
(let [[w h] (-> (:out dim-result)
|
||||
str/trim
|
||||
(clojure.string/split #"\s+")
|
||||
(->> (mapv #(Integer/parseInt %))))
|
||||
orientation-exit (:exit orient-result)
|
||||
orientation (-> orient-result :out str/trim)]
|
||||
(if (= 0 orientation-exit)
|
||||
(case orientation
|
||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||
{:width w :height h}) ; Normal or unknown orientation
|
||||
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
||||
|
||||
(defmethod process :info
|
||||
[system {:keys [input] :as params}]
|
||||
(let [{:keys [path mtype] :as input} (check-input input)]
|
||||
(if (= mtype "image/svg+xml")
|
||||
(let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
|
||||
(when-not info
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "uploaded svg does not provides dimensions"))
|
||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||
|
||||
(let [path-str (str path)
|
||||
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
|
||||
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
||||
mtype' (if (zero? (:exit identify-res))
|
||||
(-> identify-res
|
||||
:out
|
||||
str/trim
|
||||
(str/split #"\s+" 2)
|
||||
first
|
||||
str/lower)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image"))
|
||||
{:keys [width height]}
|
||||
(or (get-dimensions-with-orientation system path-str)
|
||||
(do
|
||||
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image")))]
|
||||
(when (and (string? mtype)
|
||||
(not= (str/lower mtype) mtype'))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-mismatch
|
||||
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
||||
"Expected: " mtype ". Got: " mtype')))
|
||||
(assoc input
|
||||
:width width
|
||||
:height height
|
||||
:size (fs/size path)
|
||||
:ts (ct/now))))))
|
||||
(if (contains? cf/flags :remote-media-processing)
|
||||
(media.remote/process system params)
|
||||
(media.local/process system params)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; IMAGE HELPERS
|
||||
@ -338,8 +59,8 @@
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(-> {:size size :mtype mtype}
|
||||
(validate-media-type!)
|
||||
(validate-media-size!))))]
|
||||
(validation/validate-media-type!)
|
||||
(validation/validate-media-size!))))]
|
||||
|
||||
(let [{:keys [body] :as response}
|
||||
(try
|
||||
@ -367,188 +88,24 @@
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-download-image
|
||||
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
|
||||
:cause cause)))
|
||||
:cause cause)))]
|
||||
|
||||
{:keys [size mtype]} (parse-and-validate response)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write* path body :size size)]
|
||||
(if body
|
||||
(with-open [body body]
|
||||
(let [{:keys [size mtype]} (parse-and-validate response)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write* path body :size size)]
|
||||
|
||||
(when (not= written size)
|
||||
(ex/raise :type :internal
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
(when (not= written size)
|
||||
(ex/raise :type :internal
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
;; Sanitize: strip trailing data after image EOF markers
|
||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||
{:path path
|
||||
:mtype mtype
|
||||
:size new-size}))))
|
||||
;; Sanitize: strip trailing data after image EOF markers
|
||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||
{:path path
|
||||
:mtype mtype
|
||||
:size new-size})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; FONTS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- get-font-prlimit
|
||||
"Returns resource limits for font processing tools, read from config."
|
||||
[]
|
||||
{:mem (cf/get :font-process-mem)
|
||||
:cpu (cf/get :font-process-cpu)})
|
||||
|
||||
(defn- get-font-timeout
|
||||
"Returns the wall-clock timeout for font processing, read from config."
|
||||
[]
|
||||
(cf/get :font-process-timeout))
|
||||
|
||||
(defn- exec-font!
|
||||
"Execute a font processing command with resource limits.
|
||||
`args` is a vector of string arguments."
|
||||
[system args]
|
||||
(shell/exec! system
|
||||
:cmd args
|
||||
:prlimit (get-font-prlimit)
|
||||
:timeout (get-font-timeout)))
|
||||
|
||||
(defmethod process :generate-fonts
|
||||
[system {:keys [input] :as params}]
|
||||
(letfn [(ttf->otf [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||
foutput (fs/path (str finput ".otf"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||
(str/fmt "Open('%s'); Generate('%s')"
|
||||
(str finput)
|
||||
(str foutput))])]
|
||||
(when (zero? (:exit res))
|
||||
foutput))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(otf->ttf [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||
foutput (fs/path (str finput ".ttf"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||
(str/fmt "Open('%s'); Generate('%s')"
|
||||
(str finput)
|
||||
(str foutput))])]
|
||||
(when (zero? (:exit res))
|
||||
foutput))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(ttf-or-otf->woff [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||
foutput (fs/path (str finput ".woff"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
|
||||
(when (zero? (:exit res))
|
||||
foutput))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(woff->sfnt [data]
|
||||
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (shell/exec! system
|
||||
:cmd ["woff2sfnt" (str finput)]
|
||||
:out-enc :bytes
|
||||
:prlimit (get-font-prlimit)
|
||||
:timeout (get-font-timeout))]
|
||||
(when (zero? (:exit res))
|
||||
(:out res)))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
(woff2->sfnt [data]
|
||||
;; woff2_decompress outputs to same directory with .ttf extension
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
||||
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
|
||||
(if (zero? (:exit res))
|
||||
foutput
|
||||
(do
|
||||
(when (fs/exists? foutput)
|
||||
(fs/delete foutput))
|
||||
nil)))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
;; Documented here:
|
||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||
(get-sfnt-type [data]
|
||||
(let [buff (bb/slice data 0 4)
|
||||
type (bc/bytes->hex buff)]
|
||||
(case type
|
||||
"4f54544f" :otf
|
||||
"00010000" :ttf
|
||||
(ex/raise :type :internal
|
||||
:code :unexpected-data
|
||||
:hint "unexpected font data"))))
|
||||
|
||||
(gen-if-nil [val factory]
|
||||
(if (nil? val)
|
||||
(factory)
|
||||
val))]
|
||||
|
||||
(let [current (into #{} (keys input))]
|
||||
(cond
|
||||
(contains? current "font/ttf")
|
||||
(let [data (get input "font/ttf")]
|
||||
(-> input
|
||||
(update "font/otf" gen-if-nil #(ttf->otf data))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
|
||||
|
||||
(contains? current "font/otf")
|
||||
(let [data (get input "font/otf")]
|
||||
(-> input
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
|
||||
(assoc "font/ttf" (otf->ttf data))))
|
||||
|
||||
(contains? current "font/woff")
|
||||
(let [data (get input "font/woff")
|
||||
sfnt (woff->sfnt data)]
|
||||
(when-not sfnt
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-woff-file
|
||||
:hint "invalid woff file"))
|
||||
(let [stype (get-sfnt-type sfnt)]
|
||||
(cond-> input
|
||||
true
|
||||
(-> (assoc "font/woff" data))
|
||||
|
||||
(= stype :otf)
|
||||
(-> (assoc "font/otf" sfnt)
|
||||
(assoc "font/ttf" (otf->ttf sfnt)))
|
||||
|
||||
(= stype :ttf)
|
||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||
(assoc "font/ttf" sfnt)))))
|
||||
|
||||
(contains? current "font/woff2")
|
||||
(let [data (get input "font/woff2")
|
||||
foutput (woff2->sfnt data)]
|
||||
(when-not foutput
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-woff2-file
|
||||
:hint "invalid woff2 file"))
|
||||
(try
|
||||
(let [sfnt (io/read* foutput)
|
||||
type (get-sfnt-type sfnt)]
|
||||
(cond-> input
|
||||
(= type :otf)
|
||||
(-> (assoc "font/otf" sfnt)
|
||||
(assoc "font/ttf" (otf->ttf sfnt))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
||||
|
||||
(= type :ttf)
|
||||
(-> (assoc "font/ttf" sfnt)
|
||||
(assoc "font/otf" (ttf->otf sfnt))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
||||
(finally
|
||||
(fs/delete foutput))))))))
|
||||
;; No body - validation will raise appropriate error
|
||||
(parse-and-validate response)))))
|
||||
|
||||
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 SUBSIDIARY 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))))))))
|
||||
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