Merge tag '2.14.0-RC2'

This commit is contained in:
Andrey Antukh 2026-03-10 14:38:58 +01:00
commit 68fbacf8b3
798 changed files with 108649 additions and 116521 deletions

View File

@ -2,6 +2,11 @@
:remove-multiple-non-indenting-spaces? false :remove-multiple-non-indenting-spaces? false
:remove-surrounding-whitespace? true :remove-surrounding-whitespace? true
:remove-consecutive-blank-lines? false :remove-consecutive-blank-lines? false
:indent-line-comments? true
:parallel? true
:align-form-columns? false
;; :align-map-columns? false
;; :align-single-column-lines? false
:extra-indents {rumext.v2/fnc [[:inner 0]] :extra-indents {rumext.v2/fnc [[:inner 0]]
cljs.test/async [[:inner 0]] cljs.test/async [[:inner 0]]
promesa.exec/thread [[:inner 0]] promesa.exec/thread [[:inner 0]]

View File

@ -1,21 +0,0 @@
name: _NITRATE MODULE
on:
schedule:
- cron: '36 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "nitrate-module"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: "nitrate-module"

View File

@ -87,7 +87,11 @@ jobs:
- name: Build runtime - name: Build runtime
working-directory: ./plugins working-directory: ./plugins
run: pnpm run build run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins - name: Build plugins
working-directory: ./plugins working-directory: ./plugins

6
.gitignore vendored
View File

@ -21,6 +21,7 @@
.rebel_readline_history .rebel_readline_history
.repl .repl
.shadow-cljs .shadow-cljs
.pnpm-store/
/*.jpg /*.jpg
/*.md /*.md
/*.png /*.png
@ -44,6 +45,7 @@
/backend/resources/public/media /backend/resources/public/media
/backend/target/ /backend/target/
/backend/experiments /backend/experiments
/backend/scripts/_env.local
/bundle* /bundle*
/cd.md /cd.md
/clj-profiler/ /clj-profiler/
@ -54,6 +56,8 @@
/exporter/target /exporter/target
/frontend/.storybook/preview-body.html /frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html /frontend/.storybook/preview-head.html
/frontend/playwright-report/
/frontend/text-editor/src/wasm/
/frontend/dist/ /frontend/dist/
/frontend/npm-debug.log /frontend/npm-debug.log
/frontend/out/ /frontend/out/
@ -62,6 +66,7 @@
/frontend/resources/public/* /frontend/resources/public/*
/frontend/storybook-static/ /frontend/storybook-static/
/frontend/target/ /frontend/target/
/frontend/test-results/
/other/ /other/
/scripts/ /scripts/
/telemetry/ /telemetry/
@ -72,6 +77,7 @@
/library/target/ /library/target/
/library/*.zip /library/*.zip
/external /external
/penpot-nitrate
clj-profiler/ clj-profiler/
node_modules node_modules

View File

@ -1,105 +0,0 @@
image:
file: docker/gitpod/Dockerfile
ports:
# nginx
- port: 3449
onOpen: open-preview
# frontend nREPL
- port: 3447
onOpen: ignore
visibility: private
# frontend shadow server
- port: 3448
onOpen: ignore
visibility: private
# backend
- port: 6060
onOpen: ignore
# exporter shadow server
- port: 9630
onOpen: ignore
visibility: private
# exporter http server
- port: 6061
onOpen: ignore
# mailhog web interface
- port: 8025
onOpen: ignore
# mailhog postfix
- port: 1025
onOpen: ignore
# postgres
- port: 5432
onOpen: ignore
# redis
- port: 6379
onOpen: ignore
# openldap
- port: 389
onOpen: ignore
tasks:
# https://github.com/gitpod-io/gitpod/issues/666#issuecomment-534347856
- name: gulp
command: >
cd $GITPOD_REPO_ROOT/frontend/;
yarn && gp sync-done 'frontend-yarn';
npx gulp --theme=${PENPOT_THEME} watch
- name: frontend shadow watch
command: >
cd $GITPOD_REPO_ROOT/frontend/;
gp sync-await 'frontend-yarn';
npx shadow-cljs watch main
- init: gp await-port 5432 && psql -f $GITPOD_REPO_ROOT/docker/gitpod/files/postgresql_init.sql
name: backend
command: >
cd $GITPOD_REPO_ROOT/backend/;
./scripts/start-dev
- name: exporter shadow watch
command:
cd $GITPOD_REPO_ROOT/exporter/;
gp sync-await 'frontend-yarn';
yarn && npx shadow-cljs watch main
- name: exporter web server
command: >
cd $GITPOD_REPO_ROOT/exporter/;
./scripts/wait-and-start.sh
- name: signed terminal
before: >
[[ ! -z ${GNUGPG} ]] &&
cd ~ &&
rm -rf .gnupg &&
echo ${GNUGPG} | base64 -d | tar --no-same-owner -xzvf -
init: >
[[ ! -z ${GNUGPG_KEY} ]] &&
git config --global commit.gpgsign true &&
git config --global user.signingkey ${GNUGPG_KEY}
command: cd $GITPOD_REPO_ROOT
- name: redis
command: redis-server
- before: go get github.com/mailhog/MailHog
name: mailhog
command: MailHog
- name: Nginx
command: >
nginx &&
multitail /var/log/nginx/access.log -I /var/log/nginx/error.log

View File

@ -1,11 +0,0 @@
enableGlobalCache: true
enableImmutableCache: false
enableImmutableInstalls: false
enableTelemetry: false
httpTimeout: 600000
nodeLinker: node-modules

View File

@ -1,26 +1,55 @@
# CHANGELOG # CHANGELOG
## 2.13.4 ## 2.14.0 (Unreleased)
### :boom: Breaking changes & Deprecations
- Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`.
### :sparkles: New features & Enhancements
- Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990)
- Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202)
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966)
- Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916)
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
### :bug: Bugs fixed ### :bug: Bugs fixed
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
- Fix prototype connections lost when switching between variants [Taiga #12812](https://tree.taiga.io/project/penpot/issue/12812)
- Fix wrong image in the onboarding invitation block [Taiga #13040](https://tree.taiga.io/project/penpot/issue/13040)
- Fix wrong register image [Taiga #12955](https://tree.taiga.io/project/penpot/task/12955)
- Fix error message on components doesn't close automatically [Taiga #12012](https://tree.taiga.io/project/penpot/issue/12012)
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix incorrect default option on tokens import dialog [Github #8051](https://github.com/penpot/penpot/pull/8051)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix displaying a hidden user avatar when there is only one more [Taiga #13058](https://tree.taiga.io/project/penpot/issue/13058)
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528)
- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984)
- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463) - Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463)
## 2.13.3 ## 2.13.3
### :bug: Bugs fixed ### :bug: Bugs fixed
- Revert yetti (http server) update, because that caused a regression on multipart uploads - Revert yetti (http server) update, because that caused a regression on multipart uploads
## 2.13.2 ## 2.13.2
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix modifying shapes by apply negative tokens to border radius [Taiga #13317](https://tree.taiga.io/project/penpot/issue/13317)
- Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2) - Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)
## 2.13.1 ## 2.13.1
### :bug: Bugs fixed ### :bug: Bugs fixed
@ -46,12 +75,14 @@
- Fix wrong board size presets in Android [Taiga #12339](https://tree.taiga.io/project/penpot/issue/12339) - Fix wrong board size presets in Android [Taiga #12339](https://tree.taiga.io/project/penpot/issue/12339)
- Fix problem with grid layout components and auto sizing [Github #7797](https://github.com/penpot/penpot/issues/7797) - Fix problem with grid layout components and auto sizing [Github #7797](https://github.com/penpot/penpot/issues/7797)
- Fix some alignments on inspect tab [Taiga #12915](https://tree.taiga.io/project/penpot/issue/12915) - Fix some alignments on inspect tab [Taiga #12915](https://tree.taiga.io/project/penpot/issue/12915)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
- Fix color assets from shared libraries not appearing as assets in Selected colors panel [Taiga #12957](https://tree.taiga.io/project/penpot/issue/12957) - Fix color assets from shared libraries not appearing as assets in Selected colors panel [Taiga #12957](https://tree.taiga.io/project/penpot/issue/12957)
- Fix CSS generated box-shadow property [Taiga #12997](https://tree.taiga.io/project/penpot/issue/12997) - Fix CSS generated box-shadow property [Taiga #12997](https://tree.taiga.io/project/penpot/issue/12997)
- Fix inner shadow selector on shadow token [Taiga #12951](https://tree.taiga.io/project/penpot/issue/12951) - Fix inner shadow selector on shadow token [Taiga #12951](https://tree.taiga.io/project/penpot/issue/12951)
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956) - Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959) - Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865) - Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110) - Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
- Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167) - Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167)
- Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171) - Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171)
@ -73,7 +104,6 @@
- Fix problem with style in fonts input [Taiga #12935](https://tree.taiga.io/project/penpot/issue/12935) - Fix problem with style in fonts input [Taiga #12935](https://tree.taiga.io/project/penpot/issue/12935)
- Fix problem with path editor and right click [Github #7917](https://github.com/penpot/penpot/issues/7917) - Fix problem with path editor and right click [Github #7917](https://github.com/penpot/penpot/issues/7917)
## 2.12.0 ## 2.12.0
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
@ -85,7 +115,6 @@ The backend RPC API URLS are changed from `/api/rpc/command/<name>` to
compatibility; however, if you are a user of this API, it is strongly compatibility; however, if you are a user of this API, it is strongly
recommended that you adapt your code to use the new PATH. recommended that you adapt your code to use the new PATH.
#### Updated SSO Callback URL #### Updated SSO Callback URL
The OAuth / Single Sign-On (SSO) callback endpoint has changed to The OAuth / Single Sign-On (SSO) callback endpoint has changed to
@ -118,7 +147,6 @@ This update standardizes all authentication flows under the single URL
and makis it more modular, enabling the ability to configure SSO auth and makis it more modular, enabling the ability to configure SSO auth
provider dinamically. provider dinamically.
#### Changes on default docker compose #### Changes on default docker compose
We have updated the `docker/images/docker-compose.yaml` with a small We have updated the `docker/images/docker-compose.yaml` with a small
@ -182,7 +210,6 @@ example. It's still usable as before, we just removed the example.
- Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be - Deprecated configuration variables with the prefix `PENPOT_ASSETS_*`, and will be
removed in future versions: removed in future versions:
- The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its - The `PENPOT_ASSETS_STORAGE_BACKEND` becomes `PENPOT_OBJECTS_STORAGE_BACKEND` and its
values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`) values passes from (`assets-fs` or `assets-s3`) to (`fs` or `s3`)
- The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY` - The `PENPOT_STORAGE_ASSETS_FS_DIRECTORY` becomes `PENPOT_OBJECTS_STORAGE_FS_DIRECTORY`

View File

@ -120,17 +120,12 @@ them on your system, you can run them with:
```bash ```bash
# Check formatting # Check formatting
yarn fmt:clj:check ./scripts/fmt
# Check and fix formatting # Lint
yarn fmt:clj ./scripts/lint
# Run the linter
yarn lint:clj
``` ```
There are more choices in `package.json`.
Ideally, you should run these commands as git pre-commit hooks. A convenient way Ideally, you should run these commands as git pre-commit hooks. A convenient way
of defining them is to use [Husky](https://typicode.github.io/husky/#/). of defining them is to use [Husky](https://typicode.github.io/husky/#/).

View File

@ -2,4 +2,30 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
Please report security issues to `support@penpot.app` We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue.
Please report vulnerabilities via email to: **[support@penpot.app]**
### What to include:
* A brief description of the vulnerability.
* Steps to reproduce the issue.
* Potential impact if exploited.
We appreciate your patience and your commitment to **responsible disclosure**.
---
## Security Contributors
We are incredibly grateful to the following individuals and
organizations for their help in keeping this project safe.
* **Ali Maharramli** for identifying critical path traversal vulnerability
> **Note:** This list is a work in progress. If you have contributed
> to the security of this project and would like to be recognized (or
> prefer to remain anonymous), please let us know.

7
backend/.gitignore vendored
View File

@ -1,7 +0,0 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -28,8 +28,8 @@
com.google.guava/guava {:mvn/version "33.4.8-jre"} com.google.guava/guava {:mvn/version "33.4.8-jre"}
funcool/yetti funcool/yetti
{:git/tag "v11.8" {:git/tag "v11.9"
:git/sha "1d1b33f" :git/sha "5fad7a9"
:git/url "https://github.com/funcool/yetti.git" :git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]} :exclusions [org.slf4j/slf4j-api]}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"author": "Kaleidos INC", "author": "Kaleidos INC",
"private": true, "private": true,
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c", "packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/penpot/penpot" "url": "https://github.com/penpot/penpot"

306
backend/pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,306 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
luxon:
specifier: ^3.4.4
version: 3.7.2
sax:
specifier: ^1.4.1
version: 1.4.3
devDependencies:
nodemon:
specifier: ^3.1.2
version: 3.1.11
source-map-support:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.17.0
version: 8.18.3
packages:
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nodemon@3.1.11:
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
engines: {node: '>=10'}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
sax@1.4.3:
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
touch@3.1.1:
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
hasBin: true
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
braces@3.0.3:
dependencies:
fill-range: 7.1.1
buffer-from@1.1.2: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
concat-map@0.0.1: {}
debug@4.4.3(supports-color@5.5.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 5.5.0
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
fsevents@2.3.3:
optional: true
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
has-flag@3.0.0: {}
ignore-by-default@1.0.1: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
luxon@3.7.2: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
ms@2.1.3: {}
nodemon@3.1.11:
dependencies:
chokidar: 3.6.0
debug: 4.4.3(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
semver: 7.7.3
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
undefsafe: 2.0.5
normalize-path@3.0.0: {}
picomatch@2.3.1: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
sax@1.4.3: {}
semver@7.7.3: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
touch@3.1.1: {}
undefsafe@2.0.5: {}
ws@8.18.3: {}

View File

View File

@ -5,7 +5,6 @@
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
<meta http-equiv="x-ua-compatible" content="ie=edge" /> <meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
<style> <style>
{% include "app/templates/styles.css" %} {% include "app/templates/styles.css" %}
</style> </style>

View File

@ -12,43 +12,22 @@ Debug Main Page
</nav> </nav>
<main class="dashboard"> <main class="dashboard">
<section class="widget"> <section class="widget">
<fieldset>
<legend>Error reports</legend>
<desc><a href="/dbg/error">CLICK HERE TO SEE THE ERROR REPORTS</a> </desc>
</fieldset>
<fieldset> <fieldset>
<legend>Profile Management</legend> <legend>CURRENT PROFILE</legend>
<form method="post" action="/dbg/actions/resend-email-verification"> <desc>
<div class="row"> <p>
<input type="email" name="email" placeholder="example@example.com" value="" /> Name: <b>{{profile.fullname}}</b> <br />
</div> Email: <b>{{profile.email}}</b>
</p>
<div class="row"> </desc>
<label for="force-verify">Are you sure?</label>
<input id="force-verify" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" name="resend" value="Resend Verification" />
<input type="submit" name="verify" value="Verify" />
</div>
<div class="row">
<input type="submit" class="danger" name="block" value="Block" />
<input type="submit" class="danger" name="unblock" value="Unblock" />
</div>
</form>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>VIRTUAL CLOCK</legend> <legend>VIRTUAL CLOCK</legend>
<desc> <desc>
<p><b>IMPORTANT:</b> The virtual clock is profile based and only affects the currently logged-in profile.</p>
<p> <p>
CURRENT CLOCK: <b>{{current-clock}}</b> CURRENT CLOCK: <b>{{current-clock}}</b>
<br /> <br />
@ -81,8 +60,93 @@ Debug Main Page
</form> </form>
</fieldset> </fieldset>
<fieldset>
<legend>ERROR REPORTS</legend>
<desc><a href="/dbg/error">CLICK HERE TO SEE THE ERROR REPORTS</a> </desc>
</fieldset>
</section> </section>
<section class="widget">
<fieldset>
<legend>Profile Management</legend>
<form method="post" action="/dbg/actions/resend-email-verification">
<div class="row">
<input type="email" name="email" placeholder="example@example.com" value="" />
</div>
<div class="row">
<label for="force-verify">Are you sure?</label>
<input id="force-verify" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" name="resend" value="Resend Verification" />
<input type="submit" name="verify" value="Verify" />
</div>
<div class="row">
<input type="submit" class="danger" name="block" value="Block" />
<input type="submit" class="danger" name="unblock" value="Unblock" />
</div>
</form>
</fieldset>
<fieldset>
<legend>Feature Flags for Team</legend>
<desc>Add a feature flag to a team</desc>
<form method="post" action="/dbg/actions/handle-team-features">
<div class="row">
<input type="text" style="width:300px" name="team-id" placeholder="team-id" />
</div>
<div class="row">
<select type="text" style="width:100px" name="feature">
{% for feature in supported-features %}
<option value="{{feature}}">{{feature}}</option>
{% endfor %}
</select>
</div>
<div class="row">
<select style="width:100px" name="action">
<option value="">Action...</option>
<option value="show">Show</option>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
</select>
</div>
<div class="row">
<label for="check-feature">Skip feature check</label>
<input id="check-feature" type="checkbox" name="skip-check" />
<br />
<small>
Do not check if the feature is supported
</small>
</div>
<div class="row">
<label for="force-version">Are you sure?</label>
<input id="force-version" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" value="Submit" />
</div>
</form>
</fieldset>
</section>
<section class="widget"> <section class="widget">
<fieldset> <fieldset>
@ -173,55 +237,5 @@ Debug Main Page
</form> </form>
</fieldset> </fieldset>
</section> </section>
<section class="widget">
<fieldset>
<legend>Feature Flags for Team</legend>
<desc>Add a feature flag to a team</desc>
<form method="post" action="/dbg/actions/handle-team-features">
<div class="row">
<input type="text" style="width:300px" name="team-id" placeholder="team-id" />
</div>
<div class="row">
<select type="text" style="width:100px" name="feature">
{% for feature in supported-features %}
<option value="{{feature}}">{{feature}}</option>
{% endfor %}
</select>
</div>
<div class="row">
<select style="width:100px" name="action">
<option value="">Action...</option>
<option value="show">Show</option>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
</select>
</div>
<div class="row">
<label for="check-feature">Skip feature check</label>
<input id="check-feature" type="checkbox" name="skip-check" />
<br />
<small>
Do not check if the feature is supported
</small>
</div>
<div class="row">
<label for="force-version">Are you sure?</label>
<input id="force-version" type="checkbox" name="force" />
<br />
<small>
This is a just a security double check for prevent non intentional submits.
</small>
</div>
<div class="row">
<input type="submit" value="Submit" />
</div>
</form>
</fieldset>
</section>
</main> </main>
{% endblock %} {% endblock %}

View File

@ -5,23 +5,26 @@ penpot - error list
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<nav> <nav>
<div class="title"> <div class="title">
<h1>Error reports (last 200) <a href="/dbg"> [BACK]</a>
<a href="/dbg">[GO BACK]</a> <h1>Error reports (last 300)</h1>
</h1>
</div> <a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
</nav> <a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
<main class="horizontal-list"> <a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
<ul> </div>
{% for item in items %} </nav>
<li> <main class="horizontal-list">
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a> <ul>
<a class="hint" href="/dbg/error/{{item.id}}"> {% for item in items %}
<span class="title">{{item.hint|abbreviate:150}}</span> <li>
</a> <a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
</li> <a class="hint" href="/dbg/error/{{item.id}}">
{% endfor %} <span class="title">{{item.hint|abbreviate:150}}</span>
</ul> </a>
</main> </li>
{% endfor %}
</ul>
</main>
{% endblock %} {% endblock %}

View File

@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
{% block content %} {% block content %}
<nav> <nav>
<div>[<a href="/dbg/error">⮜</a>]</div> <div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div> <div>[<a href="#head">head</a>]</div>
<div>[<a href="#props">props</a>]</div> <div>[<a href="#props">props</a>]</div>
<div>[<a href="#context">context</a>]</div> <div>[<a href="#context">context</a>]</div>

View File

@ -0,0 +1,46 @@
{% extends "app/templates/base.tmpl" %}
{% block title %}
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
{% endblock %}
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
{% if report %}
<div>[<a href="#report">report</a>]</div>
{% endif %}
</nav>
<main>
<div class="table">
<div class="table-row multiline">
<div id="head" class="table-key">HEAD</div>
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
</div>
</div>
<div class="table-row multiline">
<div id="context" class="table-key">CONTEXT: </div>
<div class="table-val">
<pre>{{context}}</pre>
</div>
</div>
{% if report %}
<div class="table-row multiline">
<div id="report" class="table-key">REPORT:</div>
<div class="table-val">
<pre>{{report}}</pre>
</div>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends "app/templates/base.tmpl" %}
{% block title %}
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
{% endblock %}
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
<div>[<a href="#result">result</a>]</div>
</nav>
<main>
<div class="table">
<div class="table-row multiline">
<div id="head" class="table-key">HEAD:</div>
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Report ID:</span> <br/> {{id}}</h2>
</div>
</div>
<div class="table-row multiline">
<div id="context" class="table-key">CONTEXT: </div>
<div class="table-val">
<pre>{{context}}</pre>
</div>
</div>
<div class="table-row multiline">
<div id="result" class="table-key">RESULT: </div>
<div class="table-val">
<pre>{{result}}</pre>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,5 +1,5 @@
* { * {
font-family: "JetBrains Mono", monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
} }
@ -36,6 +36,10 @@ small {
color: #888; color: #888;
} }
.strong {
font-weight: 900;
}
.not-important { .not-important {
color: #888; color: #888;
font-weight: 200; font-weight: 200;
@ -57,14 +61,26 @@ nav {
nav > .title { nav > .title {
display: flex; display: flex;
justify-content: center;
width: 100%; width: 100%;
} }
nav > .title > a {
color: black;
text-decoration: none;
}
nav > .title > a.strong {
text-decoration: underline;
}
nav > .title > h1 { nav > .title > h1 {
padding: 0px;
margin: 0px; margin: 0px;
font-size: 11px; font-size: 11px;
display: block;
}
nav > .title > * {
padding: 0px 6px;
} }
nav > div { nav > div {

View File

@ -3,9 +3,9 @@
{:default {:default
[[:default :window "200000/h"]] [[:default :window "200000/h"]]
;; #{:command/get-teams} ;; #{:main/get-teams}
;; [[:burst :bucket "5/5/5s"]] ;; [[:burst :bucket "5/5/5s"]]
;; #{:command/get-profile} ;; #{:main/get-profile}
;; [[:burst :bucket "60/60/1m"]] ;; [[:burst :bucket "60/60/1m"]]
} }

View File

@ -1,7 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key export PENPOT_SECRET_KEY=super-secret-devenv-key
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_HOST=devenv export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449 export PENPOT_PUBLIC_URI=https://localhost:3449
@ -13,6 +18,7 @@ export PENPOT_FLAGS="\
disable-login-with-google \ disable-login-with-google \
disable-login-with-github \ disable-login-with-github \
disable-login-with-gitlab \ disable-login-with-gitlab \
disable-telemetry \
enable-backend-worker \ enable-backend-worker \
enable-backend-asserts \ enable-backend-asserts \
disable-feature-fdata-pointer-map \ disable-feature-fdata-pointer-map \
@ -55,6 +61,8 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000 export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/control-center
export JAVA_OPTS="\ export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \ -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-Djdk.attach.allowAttachSelf \ -Djdk.attach.allowAttachSelf \

View File

@ -3,6 +3,10 @@
SCRIPT_DIR=$(dirname $0); SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/_env; source $SCRIPT_DIR/_env;
if [ -f $SCRIPT_DIR/_env.local ]; then
source $SCRIPT_DIR/_env.local;
fi
# Initialize MINIO config # Initialize MINIO config
setup_minio; setup_minio;

View File

@ -3,6 +3,11 @@
SCRIPT_DIR=$(dirname $0); SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/_env; source $SCRIPT_DIR/_env;
if [ -f $SCRIPT_DIR/_env.local ]; then
source $SCRIPT_DIR/_env.local;
fi
export OPTIONS="-A:dev" export OPTIONS="-A:dev"
entrypoint=${1:-app.main}; entrypoint=${1:-app.main};

View File

@ -3,6 +3,10 @@
SCRIPT_DIR=$(dirname $0); SCRIPT_DIR=$(dirname $0);
source $SCRIPT_DIR/_env; source $SCRIPT_DIR/_env;
if [ -f $SCRIPT_DIR/_env.local ]; then
source $SCRIPT_DIR/_env.local;
fi
# Initialize MINIO config # Initialize MINIO config
setup_minio; setup_minio;

View File

@ -98,10 +98,11 @@
[:http-server-port {:optional true} ::sm/int] [:http-server-port {:optional true} ::sm/int]
[:http-server-host {:optional true} :string] [:http-server-host {:optional true} :string]
[:http-server-max-body-size {:optional true} ::sm/int] [:http-server-max-body-size {:optional true} ::sm/int]
[:http-server-max-multipart-body-size {:optional true} ::sm/int]
[:http-server-io-threads {:optional true} ::sm/int] [:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int] [:http-server-max-worker-threads {:optional true} ::sm/int]
[:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string] [:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string] [:telemetry-uri {:optional true} :string]
@ -225,6 +226,8 @@
[:netty-io-threads {:optional true} ::sm/int] [:netty-io-threads {:optional true} ::sm/int]
[:executor-threads {:optional true} ::sm/int] [:executor-threads {:optional true} ::sm/int]
[:nitrate-backend-uri {:optional true} ::sm/uri]
;; DEPRECATED ;; DEPRECATED
[:assets-storage-backend {:optional true} :keyword] [:assets-storage-backend {:optional true} :keyword]
[:storage-assets-fs-directory {:optional true} :string] [:storage-assets-fs-directory {:optional true} :string]

View File

@ -42,8 +42,8 @@
(def default-params (def default-params
{::port 6060 {::port 6060
::host "0.0.0.0" ::host "0.0.0.0"
::max-body-size 31457280 ; default 30 MiB ::max-body-size 367001600 ; default 350 MiB
::max-multipart-body-size 367001600}) ; default 350 MiB })
(defmethod ig/expand-key ::server (defmethod ig/expand-key ::server
[k v] [k v]
@ -56,7 +56,6 @@
[::io-threads {:optional true} ::sm/int] [::io-threads {:optional true} ::sm/int]
[::max-worker-threads {:optional true} ::sm/int] [::max-worker-threads {:optional true} ::sm/int]
[::max-body-size {:optional true} ::sm/int] [::max-body-size {:optional true} ::sm/int]
[::max-multipart-body-size {:optional true} ::sm/int]
[::router {:optional true} [:fn r/router?]] [::router {:optional true} [:fn r/router?]]
[::handler {:optional true} ::sm/fn]]) [::handler {:optional true} ::sm/fn]])
@ -79,7 +78,7 @@
{:http/port port {:http/port port
:http/host host :http/host host
:http/max-body-size (::max-body-size cfg) :http/max-body-size (::max-body-size cfg)
:http/max-multipart-body-size (::max-multipart-body-size cfg) :http/max-multipart-body-size (::max-body-size cfg)
:xnio/direct-buffers false :xnio/direct-buffers false
:xnio/io-threads (::io-threads cfg) :xnio/io-threads (::io-threads cfg)
:xnio/max-worker-threads (::max-worker-threads cfg) :xnio/max-worker-threads (::max-worker-threads cfg)

View File

@ -49,13 +49,16 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn index-handler (defn index-handler
[_cfg _request] [cfg request]
(let [{:keys [clock offset]} @clock/current] (let [profile-id (::session/profile-id request)
offset (clock/get-offset profile-id)
profile (profile/get-profile cfg profile-id)]
{::yres/status 200 {::yres/status 200
::yres/headers {"content-type" "text/html"} ::yres/headers {"content-type" "text/html"}
::yres/body (-> (io/resource "app/templates/debug.tmpl") ::yres/body (-> (io/resource "app/templates/debug.tmpl")
(tmpl/render {:version (:full cf/version) (tmpl/render {:version (:full cf/version)
:current-clock (str clock) :profile profile
:current-clock ct/*clock*
:current-offset (if offset :current-offset (if offset
(ct/format-duration offset) (ct/format-duration offset)
"NO OFFSET") "NO OFFSET")
@ -229,13 +232,30 @@
(-> (io/resource "app/templates/error-report.v3.tmpl") (-> (io/resource "app/templates/error-report.v3.tmpl")
(tmpl/render (-> content (tmpl/render (-> content
(assoc :id id) (assoc :id id)
(assoc :version 3)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v4 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v4.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 4)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v5 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v5.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 5)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))] (assoc :created-at (ct/format-inst created-at :rfc1123))))))]
(if-let [report (get-report request)] (if-let [report (get-report request)]
(let [result (case (:version report) (let [result (case (:version report)
1 (render-template-v1 report) 1 (render-template-v1 report)
2 (render-template-v2 report) 2 (render-template-v2 report)
3 (render-template-v3 report))] 3 (render-template-v3 report)
4 (render-template-v4 report)
5 (render-template-v5 report))]
{::yres/status 200 {::yres/status 200
::yres/body result ::yres/body result
::yres/headers {"content-type" "text/html; charset=utf-8" ::yres/headers {"content-type" "text/html; charset=utf-8"
@ -243,20 +263,22 @@
{::yres/status 404 {::yres/status 404
::yres/body "not found"}))) ::yres/body "not found"})))
(def sql:error-reports (def ^:private sql:error-reports
"SELECT id, created_at, "SELECT id, created_at,
content->>'~:hint' AS hint content->>'~:hint' AS hint
FROM server_error_report FROM server_error_report
WHERE version = ?
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 200") LIMIT 300")
(defn error-list-handler (defn- error-list-handler
[{:keys [::db/pool]} _request] [{:keys [::db/pool]} {:keys [params]}]
(let [items (->> (db/exec! pool [sql:error-reports]) (let [version (or (some-> (get params :version) parse-long) 3)
(map #(update % :created-at ct/format-inst :rfc1123)))] items (->> (db/exec! pool [sql:error-reports version])
(map #(update % :created-at ct/format-inst :rfc1123)))]
{::yres/status 200 {::yres/status 200
::yres/body (-> (io/resource "app/templates/error-list.tmpl") ::yres/body (-> (io/resource "app/templates/error-list.tmpl")
(tmpl/render {:items items})) (tmpl/render {:items items :version version}))
::yres/headers {"content-type" "text/html; charset=utf-8" ::yres/headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}})) "x-robots-tag" "noindex"}}))
@ -447,15 +469,16 @@
(defn- set-virtual-clock (defn- set-virtual-clock
[_ {:keys [params] :as request}] [_ {:keys [params] :as request}]
(let [offset (some-> params :offset str/trim not-empty ct/duration) (let [offset (some-> params :offset str/trim not-empty ct/duration)
reset? (contains? params :reset)] profile-id (::session/profile-id request)
reset? (contains? params :reset)]
(if (= "production" (cf/get :tenant)) (if (= "production" (cf/get :tenant))
{::yres/status 501 {::yres/status 501
::yres/body "OPERATION NOT ALLOWED"} ::yres/body "OPERATION NOT ALLOWED"}
(do (do
(if (or reset? (zero? (inst-ms offset))) (if (or reset? (zero? (inst-ms offset)))
(clock/set-offset! nil) (clock/assign-offset profile-id nil)
(clock/set-offset! offset)) (clock/assign-offset profile-id offset))
{::yres/status 302 {::yres/status 302
::yres/headers {"location" "/dbg"}})))) ::yres/headers {"location" "/dbg"}}))))
@ -495,7 +518,7 @@
(defn authorized? (defn authorized?
[pool {:keys [::session/profile-id]}] [pool {:keys [::session/profile-id]}]
(or (= "devenv" (cf/get :host)) (or (and (= "devenv" (cf/get :host)) profile-id)
(let [profile (ex/ignoring (profile/get-profile pool profile-id)) (let [profile (ex/ignoring (profile/get-profile pool profile-id))
admins (or (cf/get :admins) #{})] admins (or (cf/get :admins) #{})]
(contains? admins (:email profile))))) (contains? admins (:email profile)))))

View File

@ -32,7 +32,7 @@
(assoc :request/ip-addr (inet/parse-request request)) (assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid)) (assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth) (assoc :request/auth-data auth)
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown"))))) (assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error (defmulti handle-error
(fn [cause _ _] (fn [cause _ _]

View File

@ -13,13 +13,13 @@
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http.middleware :as mw]
[app.main :as-alias main] [app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile] [app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[integrant.core :as ig] [integrant.core :as ig]
[yetti.request :as yreq]
[yetti.response :as-alias yres])) [yetti.response :as-alias yres]))
;; ---- ROUTES ;; ---- ROUTES
@ -49,28 +49,40 @@
(fn [cfg request] (fn [cfg request]
(db/tx-run! cfg handler request)))))}) (db/tx-run! cfg handler request)))))})
(def ^:private shared-key-auth
{:name ::shared-key-auth
:compile
(fn [_ _]
(fn [handler key]
(if key
(fn [request]
(if-let [key' (yreq/get-header request "x-shared-key")]
(if (= key key')
(handler request)
{::yres/status 403})
{::yres/status 403}))
(fn [_ _]
{::yres/status 403}))))})
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::setup/props] :as cfg}] [_ cfg]
(let [management-key (or (cf/get :management-api-key) ["" {:middleware [[shared-key-auth (cf/get :management-api-key)]
(get props :management-key))] [default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["" {:middleware [[mw/shared-key-auth management-key] ["/get-customer"
[default-system cfg] {:handler get-customer
[transaction]]} :transaction true
["/authenticate" :allowed-methods #{:post}}]
{:handler authenticate
:allowed-methods #{:post}}]
["/get-customer" ["/update-customer"
{:handler get-customer {:handler update-customer
:transaction true :allowed-methods #{:post}
:allowed-methods #{:post}}] :transaction true}]])
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
;; ---- HELPERS ;; ---- HELPERS

View File

@ -16,7 +16,6 @@
[app.http.errors :as errors] [app.http.errors :as errors]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.pointer-map :as pmap] [app.util.pointer-map :as pmap]
[buddy.core.codecs :as bc]
[cuerdas.core :as str] [cuerdas.core :as str]
[yetti.adapter :as yt] [yetti.adapter :as yt]
[yetti.middleware :as ymw] [yetti.middleware :as ymw]
@ -214,14 +213,14 @@
(assoc "access-control-allow-origin" origin) (assoc "access-control-allow-origin" origin)
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH") (assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
(assoc "access-control-allow-credentials" "true") (assoc "access-control-allow-credentials" "true")
(assoc "access-control-expose-headers" "x-requested-with, content-type, cookie") (assoc "access-control-expose-headers" "content-type, set-cookie")
(assoc "access-control-allow-headers" "x-frontend-version, content-type, accept, x-requested-width"))) (assoc "access-control-allow-headers" "x-frontend-version, x-client, x-requested-width, content-type, accept, cookie")))
(defn wrap-cors (defn wrap-cors
[handler] [handler]
(fn [request] (fn [request]
(let [response (if (= (yreq/method request) :options) (let [response (if (= (yreq/method request) :options)
{::yres/status 200} {::yres/status 204}
(handler request)) (handler request))
origin (yreq/get-header request "origin")] origin (yreq/get-header request "origin")]
(update response ::yres/headers with-cors-headers origin)))) (update response ::yres/headers with-cors-headers origin))))
@ -301,16 +300,20 @@
:compile (constantly wrap-auth)}) :compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth (defn- wrap-shared-key-auth
[handler shared-key] [handler keys]
(if shared-key (if (seq keys)
(let [shared-key (if (string? shared-key) (fn [request]
shared-key (if-let [[key-id key] (some-> (yreq/get-header request "x-shared-key")
(bc/bytes->b64-str shared-key true))] (str/split #"\s+" 2))]
(fn [request] (let [key-id (-> key-id str/lower keyword)]
(let [key (yreq/get-header request "x-shared-key")] (if (and (string? key)
(if (= key shared-key) (contains? keys key-id)
(handler (assoc request ::http/auth-with-shared-key true)) (= key (get keys key-id)))
{::yres/status 403})))) (-> request
(assoc ::http/auth-key-id key-id)
(handler))
{::yres/status 403}))
{::yres/status 403}))
(fn [_ _] (fn [_ _]
{::yres/status 403}))) {::yres/status 403})))

View File

@ -20,6 +20,7 @@
[app.http.session.tasks :as-alias tasks] [app.http.session.tasks :as-alias tasks]
[app.main :as-alias main] [app.main :as-alias main]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.setup.clock :as clock]
[app.tokens :as tokens] [app.tokens :as tokens]
[integrant.core :as ig] [integrant.core :as ig]
[yetti.request :as yreq] [yetti.request :as yreq]
@ -229,18 +230,22 @@
(let [{:keys [type token claims metadata]} (get request ::http/auth-data)] (let [{:keys [type token claims metadata]} (get request ::http/auth-data)]
(cond (cond
(= type :cookie) (= type :cookie)
(let [session (case (:ver metadata) (let [session
;; BACKWARD COMPATIBILITY WITH OLD TOKENS (case (:ver metadata)
0 (read-session manager token) ;; BACKWARD COMPATIBILITY WITH OLD TOKENS
1 (some->> (:sid claims) (read-session manager)) 0 (read-session manager token)
nil) 1 (some->> (:sid claims) (read-session manager))
nil)
request (cond-> request request
(some? session) (cond-> request
(-> (assoc ::profile-id (:profile-id session)) (some? session)
(assoc ::session session))) (-> (assoc ::profile-id (:profile-id session))
(assoc ::session session)))
response (handler request)] response
(binding [ct/*clock* (clock/get-clock (:profile-id session))]
(handler request))]
(if (and session (renew-session? session)) (if (and session (renew-session? session))
(let [session (->> session (let [session (->> session

View File

@ -6,7 +6,6 @@
(ns app.http.sse (ns app.http.sse
"SSE (server sent events) helpers" "SSE (server sent events) helpers"
(:refer-clojure :exclude [tap])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.logging :as l] [app.common.logging :as l]

View File

@ -9,6 +9,7 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.json :as json]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
@ -112,6 +113,8 @@
;; COLLECTOR API ;; COLLECTOR API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private prepare-context-from-request)
;; Defines a service that collects the audit/activity log using ;; Defines a service that collects the audit/activity log using
;; internal database. Later this audit log can be transferred to ;; internal database. Later this audit log can be transferred to
;; an external storage and data cleared. ;; an external storage and data cleared.
@ -125,6 +128,8 @@
[::props {:optional true} [:map-of :keyword :any]] [::props {:optional true} [:map-of :keyword :any]]
[::context {:optional true} [:map-of :keyword :any]] [::context {:optional true} [:map-of :keyword :any]]
[::tracked-at {:optional true} ::ct/inst] [::tracked-at {:optional true} ::ct/inst]
[::created-at {:optional true} ::ct/inst]
[::source {:optional true} ::sm/text]
[::webhooks/event? {:optional true} ::sm/boolean] [::webhooks/event? {:optional true} ::sm/boolean]
[::webhooks/batch-timeout {:optional true} ::ct/duration] [::webhooks/batch-timeout {:optional true} ::ct/duration]
[::webhooks/batch-key {:optional true} [::webhooks/batch-key {:optional true}
@ -133,32 +138,8 @@
(def ^:private check-event (def ^:private check-event
(sm/check-fn schema:event)) (sm/check-fn schema:event))
(defn- prepare-context-from-request (def valid-event?
[request] (sm/validator schema:event))
(let [client-event-origin (get-client-event-origin request)
client-version (get-client-version request)
client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request)
token-id (::actoken/id request)]
(d/without-nils
{:external-session-id session-id
:access-token-id (some-> token-id str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version
:version (:full cf/version)})))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context (some-> params meta ::http/request prepare-context-from-request)
event {::type "action"
::profile-id (or (::rpc/profile-id params) uuid/zero)
::ip-addr (::rpc/ip-addr params)}]
(cond-> event
(some? context)
(assoc ::context context))))
(defn prepare-event (defn prepare-event
[cfg mdata params result] [cfg mdata params result]
@ -170,20 +151,22 @@
uuid/zero) uuid/zero)
props (-> (or (::replace-props resultm) props (-> (or (::replace-props resultm)
(-> params (merge params (::props resultm)))
(merge (::props resultm))
(dissoc :profile-id)
(dissoc :type)))
(clean-props)) (clean-props))
context (merge (::context resultm) context (merge (::context resultm)
(prepare-context-from-request request)) (prepare-context-from-request request))
ip-addr (inet/parse-request request)] ip-addr (inet/parse-request request)
module (get cfg ::rpc/module)]
{::type (or (::type resultm) {::type (or (::type resultm)
(::rpc/type cfg)) (::rpc/type cfg))
::name (or (::name resultm) ::name (or (::name resultm)
(::sv/name mdata)) (let [sname (::sv/name mdata)]
(if (not= module "main")
(str module "-" sname)
sname)))
::profile-id profile-id ::profile-id profile-id
::ip-addr ip-addr ::ip-addr ip-addr
::props props ::props props
@ -207,6 +190,38 @@
(::webhooks/event? resultm) (::webhooks/event? resultm)
false)})) false)}))
(defn- prepare-context-from-request
"Prepare backend event context from request"
[request]
(let [client-event-origin (get-client-event-origin request)
client-version (get-client-version request)
client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request)
key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils
{:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version
:version (:full cf/version)})))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context (some-> params meta ::http/request prepare-context-from-request)
event {::type "action"
::profile-id (or (::rpc/profile-id params) uuid/zero)
::ip-addr (::rpc/ip-addr params)}]
(cond-> event
(some? context)
(assoc ::context context))))
(defn- event->params (defn- event->params
[event] [event]
(let [params {:id (uuid/next) (let [params {:id (uuid/next)
@ -223,7 +238,7 @@
(some? tnow) (some? tnow)
(assoc :tracked-at tnow)))) (assoc :tracked-at tnow))))
(defn- append-audit-entry! (defn- append-audit-entry
[cfg params] [cfg params]
(let [params (-> params (let [params (-> params
(update :props db/tjson) (update :props db/tjson)
@ -233,17 +248,26 @@
(defn- handle-event! (defn- handle-event!
[cfg event] [cfg event]
(let [params (event->params event) (let [tnow (ct/now)
tnow (ct/now)] params (-> (event->params event)
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(when (contains? cf/flags :audit-log-logger)
(l/log! ::l/logger "app.audit"
::l/level :info
:profile-id (str (::profile-id event))
:ip-addr (str (::ip-addr event))
:type (::type event)
:name (::name event)
:props (json/encode (::props event) :key-fn json/write-camel-key)
:context (json/encode (::context event) :key-fn json/write-camel-key)))
(when (contains? cf/flags :audit-log) (when (contains? cf/flags :audit-log)
;; NOTE: this operation may cause primary key conflicts on inserts ;; NOTE: this operation may cause primary key conflicts on inserts
;; because of the timestamp precission (two concurrent requests), in ;; because of the timestamp precission (two concurrent requests), in
;; this case we just retry the operation. ;; this case we just retry the operation.
(let [params (-> params (append-audit-entry cfg params))
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(append-audit-entry! cfg params)))
(when (and (or (contains? cf/flags :telemetry) (when (and (or (contains? cf/flags :telemetry)
(cf/get :telemetry-enabled)) (cf/get :telemetry-enabled))
@ -254,11 +278,9 @@
;; ;;
;; NOTE: this is only executed when general audit log is disabled ;; NOTE: this is only executed when general audit log is disabled
(let [params (-> params (let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow))
(assoc :props {}) (assoc :props {})
(assoc :context {}))] (assoc :context {}))]
(append-audit-entry! cfg params))) (append-audit-entry cfg params)))
(when (and (contains? cf/flags :webhooks) (when (and (contains? cf/flags :webhooks)
(::webhooks/event? event)) (::webhooks/event? event))
@ -312,4 +334,4 @@
params (-> (event->params event) params (-> (event->params event)
(assoc :created-at tnow) (assoc :created-at tnow)
(update :tracked-at #(or % tnow)))] (update :tracked-at #(or % tnow)))]
(append-audit-entry! cfg params))))))) (append-audit-entry cfg params)))))))

View File

@ -14,6 +14,8 @@
[app.common.schema :as sm] [app.common.schema :as sm]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.loggers.audit :as audit]
[app.rpc.rlimit :as-alias rlimit]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.exec :as px] [promesa.exec :as px]
@ -28,69 +30,145 @@
(defonce enabled (atom true)) (defonce enabled (atom true))
(defn- persist-on-database! (defn- persist-on-database!
[pool id report] [pool id version report]
(when-not (db/read-only? pool) (when-not (db/read-only? pool)
(db/insert! pool :server-error-report (db/insert! pool :server-error-report
{:id id {:id id
:version 3 :version version
:content (db/tjson report)}))) :content (db/tjson report)})))
(defn record->report (defn- concurrent-exception?
[cause]
(or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause)))
(defn- log-record->report
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}] [{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
(assert (l/valid-record? record) "expectd valid log record") (assert (l/valid-record? record) "expectd valid log record")
(if (or (instance? java.util.concurrent.CompletionException cause) (let [data (if (concurrent-exception? cause)
(instance? java.util.concurrent.ExecutionException cause)) (ex-data (ex-cause cause))
(-> record (ex-data cause))
(assoc ::trace (ex/format-throwable cause :data? true :explain? false :header? false :summary? false))
(assoc ::l/cause (ex-cause cause))
(record->report))
(let [data (ex-data cause) ctx (-> context
ctx (-> context (assoc :service/tenant (cf/get :tenant))
(assoc :tenant (cf/get :tenant)) (assoc :service/host (cf/get :host))
(assoc :host (cf/get :host)) (assoc :service/public-uri (str (cf/get :public-uri)))
(assoc :public-uri (str (cf/get :public-uri))) (assoc :backend/version (:full cf/version))
(assoc :logger/name logger) (assoc :logger/name logger)
(assoc :logger/level level) (assoc :logger/level level)
(dissoc :request/params :value :params :data))] (dissoc :request/params :value :params :data))]
(merge (merge
{:context (-> (into (sorted-map) ctx) {:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50)) (pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50) :props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)] :hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)] (if-let [props-hint (:hint props)]
(str props-hint ": " message) (str props-hint ": " message)
message)) message))
@message) @message)
:trace (or (::trace record) :trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))} (some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
(when-let [params (or (:request/params context) (:params context))] (when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)}) {:params (pp/pprint-str params :length 20 :level 20)})
(when-let [value (:value context)] (when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 13)}) {:value (pp/pprint-str value :length 30 :level 13)})
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))] (when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
{:data (pp/pprint-str data :length 30 :level 13)}) {:data (pp/pprint-str data :length 30 :level 13)})
(when-let [explain (ex/explain data :length 30 :level 13)] (when-let [explain (ex/explain data :length 30 :level 13)]
{:explain explain}))))) {:explain explain}))))
(defn error-record? (defn- handle-log-record
[{:keys [::l/level]}] "Convert the log record into a report object and persist it on the database"
(= :error level))
(defn- handle-event
[{:keys [::db/pool]} {:keys [::l/id] :as record}] [{:keys [::db/pool]} {:keys [::l/id] :as record}]
(try (try
(let [uri (cf/get :public-uri) (let [uri (cf/get :public-uri)
report (-> record record->report d/without-nils)] report (-> record log-record->report d/without-nils)]
(l/debug :hint "registering error on database" :id id (l/dbg :hint "registering error on database"
:uri (str uri "/dbg/error/" id)) :id (str id)
:src "logging"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 3 report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(persist-on-database! pool id report)) (defn- audit-event->report
[{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}]
(let [context
(reduce-kv (fn [context k v]
(let [k' (keyword "frontend" (name k))]
(-> context
(dissoc k)
(assoc k' v))))
context
context)
context
(-> context
(assoc :backend/tenant (cf/get :tenant))
(assoc :backend/host (cf/get :host))
(assoc :backend/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version))
(assoc :frontend/ip-addr ip-addr))]
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:origin (::audit/name record)
:href (get props :href)
:hint (get props :hint)
:report (get props :report)}))
(defn- handle-audit-event
"Convert the log record into a report object and persist it on the database"
[{:keys [::db/pool]} {:keys [::audit/id] :as event}]
(try
(let [uri (cf/get :public-uri)
report (-> event audit-event->report d/without-nils)]
(l/dbg :hint "registering error on database"
:id (str id)
:src "audit-log"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 4 report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(defn- rlimit-event->report
[event]
(let [context
(-> {}
(assoc :rlimit/uid (::rlimit/uid event))
(assoc :rlimit/method (::rlimit/method event))
(assoc :backend/tenant (cf/get :tenant))
(assoc :backend/host (cf/get :host))
(assoc :backend/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version)))
result
(->> (::rlimit/results event)
(mapv (fn [result]
(-> (into (sorted-map) result)
(dissoc ::rlimit/method)))))]
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:result (pp/pprint-str result :length 50)}))
(defn- handle-rlimit-event
"Convert the log record into a report object and persist it on the database"
[{:keys [::db/pool]} {:keys [::rlimit/id] :as event}]
(try
(let [uri (cf/get :public-uri)
report (-> event rlimit-event->report d/without-nils)]
(l/dbg :hint "registering rate limit rejection"
:id (str id)
:src "rlimit"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 5 report))
(catch Throwable cause (catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause)))) (l/warn :hint "unexpected exception on database error logger" :cause cause))))
@ -100,26 +178,52 @@
(defmethod ig/init-key ::reporter (defmethod ig/init-key ::reporter
[_ cfg] [_ cfg]
(let [input (sp/chan :buf (sp/sliding-buffer 64) (let [input (sp/chan :buf (sp/sliding-buffer 256))
:xf (filter error-record?))] thread (px/thread
(add-watch l/log-record ::reporter #(sp/put! input %4)) {:name "penpot/reporter/database"}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [item (sp/take! input)]
(cond
(::l/id item)
(handle-log-record cfg item)
(px/thread {:name "penpot/database-reporter"} (::audit/id item)
(l/info :hint "initializing database error persistence") (handle-audit-event cfg item)
(try
(loop [] (::rlimit/id item)
(when-let [record (sp/take! input)] (handle-rlimit-event cfg item)
(handle-event cfg record)
(recur))) :else
(catch InterruptedException _ (l/warn :hint "received unexpected item" :item item))
(l/debug :hint "reporter interrupted"))
(catch Throwable cause (recur)))
(l/error :hint "unexpected error" :cause cause))
(finally (catch InterruptedException _
(sp/close! input) (l/debug :hint "reporter interrupted"))
(remove-watch l/log-record ::reporter) (catch Throwable cause
(l/info :hint "reporter terminated")))))) (l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread}))
(defmethod ig/halt-key! ::reporter (defmethod ig/halt-key! ::reporter
[_ thread] [_ {:keys [::input ::thread]}]
(some-> thread px/interrupt!)) (remove-watch l/log-record ::reporter)
(sp/close! input)
(px/interrupt! thread))
(defn emit
"Emit an event/report into the database reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))

View File

@ -9,9 +9,12 @@
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.uri :as u]
[app.config :as cf] [app.config :as cf]
[app.http.client :as http] [app.http.client :as http]
[app.loggers.database :as ldb] [app.loggers.audit :as audit]
[app.rpc.rlimit :as-alias rlimit]
[app.util.json :as json] [app.util.json :as json]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.exec :as px] [promesa.exec :as px]
@ -20,24 +23,34 @@
(defonce enabled (atom true)) (defonce enabled (atom true))
(defn- send-mattermost-notification! (defn- send-mattermost-notification!
[cfg {:keys [id public-uri] :as report}] [cfg {:keys [id] :as report}]
(let [type (get report :type)
text (str "#" type " | " (get report :hint) "\n"
(when id
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
(let [text (str "Exception: " public-uri "/dbg/error/" id " "
(when-let [pid (:profile-id report)] (when-let [pid (:profile-id report)]
(str "(pid: #uuid-" pid ")")) (if (uuid? pid)
(str "(pid: #uuid-" pid ")")
(str "(pid: #ip-" pid ")")))
"\n" "\n"
"- host: #" (:host report) "\n" "- host: #" (:host report) "\n"
"- tenant: #" (:tenant report) "\n" "- tenant: #" (:tenant report) "\n"
"- logger: #" (:logger report) "\n" "- origin: #" (:origin report) "\n"
"- request-path: `" (:request-path report) "`\n" (when-let [href (get report :href)]
"- frontend-version: `" (:frontend-version report) "`\n" (str "- href: `" href "`\n"))
"- backend-version: `" (:backend-version report) "`\n" (when-let [version (get report :frontend-version)]
(str "- frontend-version: `" version "`\n"))
(when-let [version (get report :backend-version)]
(str "- backend-version: `" version "`\n"))
"\n" "\n"
"```\n" (when-let [info (:info report)]
"Trace:\n" (str "```\n" info "```"))
(:trace report) (when-let [trace (:trace report)]
"```") (str "```\n"
"Trace:\n"
trace
"```")))
resp (http/req! cfg resp (http/req! cfg
{:uri (cf/get :error-report-webhook) {:uri (cf/get :error-report-webhook)
@ -50,28 +63,70 @@
(l/warn :hint "error on sending data" (l/warn :hint "error on sending data"
:response (pr-str resp))))) :response (pr-str resp)))))
(defn record->report (defn- log-record->report
[{:keys [::l/context ::l/id ::l/cause] :as record}] [{:keys [::l/context ::l/id ::l/cause ::l/message] :as record}]
(assert (l/valid-record? record) "expectd valid log record") (assert (l/valid-record? record) "expectd valid log record")
(let [public-uri (cf/get :public-uri)]
{:id id
:type "exception"
:origin "logging"
:hint (or (some-> cause ex-message) @message)
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:frontend-version (:frontend/version context)
:profile-id (:request/profile-id context)
:href (-> public-uri
(assoc :path (:request/path context))
(str))
:trace (ex/format-throwable cause :detail? false :header? false)}))
(defn- audit-event->report
[{:keys [::audit/context ::audit/props ::audit/id] :as event}]
{:id id {:id id
:type "exception"
:origin "audit-log"
:hint (get props :hint)
:tenant (cf/get :tenant) :tenant (cf/get :tenant)
:host (cf/get :host) :host (cf/get :host)
:public-uri (cf/get :public-uri) :backend-version (:full cf/version)
:backend-version (or (:version/backend context) (:full cf/version)) :frontend-version (:version context)
:frontend-version (:version/frontend context) :profile-id (:audit/profile-id event)
:profile-id (:request/profile-id context) :href (get props :href)})
:request-path (:request/path context)
:logger (::l/logger record)
:trace (ex/format-throwable cause :detail? false :header? false)})
(defn handle-event (defn- rlimit-event->report
[cfg record] [event]
(when @enabled {:id (::rlimit/id event)
(try :type "notification"
(let [report (record->report record)] :origin "rlimit"
(send-mattermost-notification! cfg report)) :hint (str "rlimit reject of "
(catch Throwable cause (::rlimit/method event)
(l/warn :hint "unhandled error" :cause cause))))) " for "
(::rlimit/uid event))
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:profile-id (::rlimit/profile-id event)
:info (with-out-str
(println "Rejected by:")
(println "------------")
(println "Method: " (::rlimit/method event))
(println "Limit Name: " (::rlimit/name event))
(println "Limit Strategy:" (::rlimit/strategy event))
(println)
(println "Results & Config:")
(println "-----------------")
(doseq [result (::rlimit/results event)]
(pp/pprint (into (sorted-map) result))))})
(defn- handle-event
[cfg event event->report]
(try
(let [report (event->report event)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause))))
(defmethod ig/assert-key ::reporter (defmethod ig/assert-key ::reporter
[_ params] [_ params]
@ -80,27 +135,52 @@
(defmethod ig/init-key ::reporter (defmethod ig/init-key ::reporter
[_ cfg] [_ cfg]
(when-let [uri (cf/get :error-report-webhook)] (when-let [uri (cf/get :error-report-webhook)]
(px/thread (let [input (sp/chan :buf (sp/sliding-buffer 256))
{:name "penpot/mattermost-reporter" thread (px/thread
:virtual true} {:name "penpot/reporter/mattermost"}
(l/info :hint "initializing error reporter" :uri uri) (l/info :hint "initializing error reporter" :uri uri)
(let [input (sp/chan :buf (sp/sliding-buffer 128)
:xf (filter ldb/error-record?))] (try
(add-watch l/log-record ::reporter #(sp/put! input %4)) (loop []
(try (when-let [item (sp/take! input)]
(loop [] (when @enabled
(when-let [msg (sp/take! input)] (cond
(handle-event cfg msg) (::l/id item)
(recur))) (handle-event cfg item log-record->report)
(catch InterruptedException _
(l/debug :hint "reporter interrupted")) (::audit/id item)
(catch Throwable cause (handle-event cfg item audit-event->report)
(l/error :hint "unexpected error" :cause cause))
(finally (::rlimit/id item)
(sp/close! input) (handle-event cfg item rlimit-event->report)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated"))))))) :else
(l/warn :hint "received unexpected item" :item item)))
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread})))
(defmethod ig/halt-key! ::reporter (defmethod ig/halt-key! ::reporter
[_ thread] [_ {:keys [::input ::thread]}]
(remove-watch l/log-record ::reporter)
(some-> input sp/close!)
(some-> thread px/interrupt!)) (some-> thread px/interrupt!))
(defn emit
"Emit an event/report into the mattermost reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))

View File

@ -226,11 +226,10 @@
::http/server ::http/server
{::http/port (cf/get :http-server-port) {::http/port (cf/get :http-server-port)
::http/host (cf/get :http-server-host) ::http/host (cf/get :http-server-host)
::http/router (ig/ref ::http/router)
::http/io-threads (cf/get :http-server-io-threads) ::http/io-threads (cf/get :http-server-io-threads)
::http/max-worker-threads (cf/get :http-server-max-worker-threads) ::http/max-worker-threads (cf/get :http-server-max-worker-threads)
::http/max-body-size (cf/get :http-server-max-body-size) ::http/max-body-size (cf/get :http-server-max-body-size)
::http/max-multipart-body-size (cf/get :http-server-max-multipart-body-size) ::http/router (ig/ref ::http/router)
::mtx/metrics (ig/ref ::mtx/metrics)} ::mtx/metrics (ig/ref ::mtx/metrics)}
::ldap/provider ::ldap/provider
@ -317,12 +316,19 @@
::climit/enabled (contains? cf/flags :rpc-climit)} ::climit/enabled (contains? cf/flags :rpc-climit)}
:app.rpc/rlimit :app.rpc/rlimit
{::wrk/executor (ig/ref ::wrk/netty-executor)} {::wrk/executor (ig/ref ::wrk/netty-executor)
:app.loggers.mattermost/reporter
(ig/ref :app.loggers.mattermost/reporter)
:app.loggers.database/reporter
(ig/ref :app.loggers.database/reporter)}
:app.rpc/methods :app.rpc/methods
{::http.client/client (ig/ref ::http.client/client) {::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool) ::rds/pool (ig/ref ::rds/pool)
:app.nitrate/client (ig/ref :app.nitrate/client)
::wrk/executor (ig/ref ::wrk/netty-executor) ::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager) ::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider) ::ldap/provider (ig/ref ::ldap/provider)
@ -337,7 +343,17 @@
::setup/props (ig/ref ::setup/props) ::setup/props (ig/ref ::setup/props)
::email/blacklist (ig/ref ::email/blacklist) ::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)
:app.loggers.database/reporter
(ig/ref :app.loggers.database/reporter)
:app.loggers.mattermost/reporter
(ig/ref :app.loggers.mattermost/reporter)}
:app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
:app.rpc/management-methods :app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client) {::http.client/client (ig/ref ::http.client/client)
@ -348,17 +364,19 @@
::sto/storage (ig/ref ::sto/storage) ::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics) ::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus) ::mbus/msgbus (ig/ref ::mbus/msgbus)
:app.nitrate/client (ig/ref :app.nitrate/client)
::rds/client (ig/ref ::rds/client) ::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)} ::setup/props (ig/ref ::setup/props)}
::rpc/routes ::rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods) {::rpc/methods (ig/ref :app.rpc/methods)
::rpc/management-methods (ig/ref :app.rpc/management-methods) ::rpc/management-methods (ig/ref :app.rpc/management-methods)
;; FIXME: revisit if db/pool is necessary here ;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager) ::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)} ::setup/props (ig/ref ::setup/props)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
::wrk/registry ::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics) {::mtx/metrics (ig/ref ::mtx/metrics)
@ -446,6 +464,11 @@
;; module requires the migrations to run before initialize. ;; module requires the migrations to run before initialize.
::migrations (ig/ref :app.migrations/migrations)} ::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
:nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock ::setup/clock
{} {}

View File

@ -10,6 +10,7 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.db :as db] [app.db :as db]
[app.migrations.clj.migration-0023 :as mg0023] [app.migrations.clj.migration-0023 :as mg0023]
[app.migrations.clj.migration-0145 :as mg0145]
[app.util.migrations :as mg] [app.util.migrations :as mg]
[integrant.core :as ig])) [integrant.core :as ig]))
@ -456,7 +457,14 @@
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")} :fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
{:name "0143-http-session-v2-table" {:name "0143-http-session-v2-table"
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}]) :fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}
{:name "0144-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile"
:fn mg0145/migrate}])
(defn apply-migrations! (defn apply-migrations!
[pool name migrations] [pool name migrations]

View File

@ -0,0 +1,83 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.migrations.clj.migration-0145
"Migrate plugins references on profiles"
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.db :as db]
[cuerdas.core :as str]))
(def ^:private replacements
{"https://colors-to-tokens-plugin.pages.dev"
"https://colors-to-tokens.plugins.penpot.app"
"https://contrast-penpot-plugin.pages.dev"
"https://contrast.plugins.penpot.app"
"https://create-palette-penpot-plugin.pages.dev"
"https://create-palette.plugins.penpot.app"
"https://icons-penpot-plugin.pages.dev"
"https://icons.plugins.penpot.app"
"https://lorem-ipsum-penpot-plugin.pages.dev"
"https://lorem-ipsum.plugins.penpot.app"
"https://rename-layers-penpot-plugin.pages.dev"
"https://rename-layers.plugins.penpot.app"
"https://table-penpot-plugin.pages.dev"
"https://table.plugins.penpot.app"})
(defn- fix-url
[url]
(reduce-kv (fn [url prefix replacement]
(if (str/starts-with? url prefix)
(reduced (str replacement (subs url (count prefix))))
url))
url
replacements))
(defn- fix-manifest
[manifest]
(-> manifest
(d/update-when :url fix-url)
(d/update-when :host fix-url)))
(defn- fix-plugins-data
[props]
(d/update-in-when props [:plugins :data]
(fn [data]
(reduce-kv (fn [data id manifest]
(let [manifest' (fix-manifest manifest)]
(if (= manifest manifest')
data
(assoc data id manifest'))))
data
data))))
(def ^:private sql:get-profiles
"SELECT id, props FROM profile
WHERE props ?? '~:plugins'
ORDER BY created_at
FOR UPDATE")
(defn migrate
[conn]
(->> (db/plan conn [sql:get-profiles])
(run! (fn [{:keys [id props]}]
(when-let [props (some-> props db/decode-transit-pgobject)]
(let [props' (fix-plugins-data props)]
(when (not= props props')
(l/inf :hint "fixing plugins data on profile props" :profile-id (str id))
(db/update! conn :profile
{:props (db/tjson props')}
{:id id}
{::db/return-keys false}))))))))

View File

@ -0,0 +1,11 @@
ALTER TABLE server_error_report DROP CONSTRAINT server_error_report_pkey;
DELETE FROM server_error_report a
USING server_error_report b
WHERE a.id = b.id
AND a.ctid < b.ctid;
ALTER TABLE server_error_report ADD PRIMARY KEY (id);
CREATE INDEX server_error_report__version__idx
ON server_error_report ( version );

130
backend/src/app/nitrate.clj Normal file
View File

@ -0,0 +1,130 @@
(ns app.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.config :as cf]
[app.http.client :as http]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[app.util.json :as json]
[clojure.core :as c]
[integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- request-builder
[cfg method uri shared-key profile-id]
(fn []
(http/req! cfg {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1})))
(defn- with-retries
[handler max-retries]
(fn []
(loop [attempt 1]
(let [result (try
(handler)
(catch Exception e
(if (< attempt max-retries)
::retry
(do
;; TODO Error handling
(l/error :hint "request fail after multiple retries" :cause e)
nil))))]
(if (= result ::retry)
(recur (inc attempt))
result)))))
(defn- with-validate [handler uri schema]
(fn []
(let [coercer-http (sm/coercer schema
:type :validation
:hint (str "invalid data received calling " uri))]
(try
(coercer-http (-> (handler) :body json/decode))
(catch Exception e
;; TODO Error handling
(l/error :hint "error validating json response" :cause e)
nil)))))
(defn- request-to-nitrate
[cfg method uri schema {:keys [::rpc/profile-id] :as params}]
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
(with-retries 3)
(with-validate uri schema))]
(full-http-call)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn call
[cfg method params]
(when (contains? cf/flags :nitrate)
(let [client (get cfg ::client)
method (get client method)]
(method params))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:organization
[:map
[:id ::sm/text]
[:name ::sm/text]])
(def ^:private schema:user
[:map
[:valid ::sm/boolean]])
(defn- get-team-org
[cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params)))
(defn- is-valid-user
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client
[_ cfg]
(when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn add-nitrate-licence-to-profile
[cfg profile]
(try
(let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})]
(assoc profile :nitrate-licence (:valid nitrate-licence)))
(catch Throwable cause
(l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))
(defn add-org-to-team
[cfg team params]
(let [params (assoc (or params {}) :team-id (:id team))
org (call cfg :get-team-org params)]
(assoc team :organization-id (:id org) :organization-name (:name org))))

View File

@ -90,13 +90,13 @@
[methods] [methods]
(let [methods (update-vals methods peek)] (let [methods (update-vals methods peek)]
(fn [{:keys [params path-params method] :as request}] (fn [{:keys [params path-params method] :as request}]
(let [handler-name (:type path-params) (let [handler-name (:method-name path-params)
etag (yreq/get-header request "if-none-match") etag (yreq/get-header request "if-none-match")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request) profile-id (or (::session/profile-id request)
(::actoken/profile-id request) (::actoken/profile-id request)
(if (::http/auth-with-shared-key request) (if key-id uuid/zero nil))
uuid/zero
nil))
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
@ -227,8 +227,8 @@
(wrap-authentication cfg $ mdata))) (wrap-authentication cfg $ mdata)))
(defn- process-method (defn- process-method
[cfg module wrap-fn [f mdata]] [cfg wrap-fn [f mdata]]
(l/trc :hint "add method" :module module :name (::sv/name mdata)) (l/trc :hint "add method" :module (::module cfg) :type (::type cfg) :name (::sv/name mdata))
(let [f (wrap-fn cfg f mdata) (let [f (wrap-fn cfg f mdata)
k (keyword (::sv/name mdata))] k (keyword (::sv/name mdata))]
[k [mdata (partial f cfg)]])) [k [mdata (partial f cfg)]]))
@ -239,7 +239,7 @@
(defn- resolve-methods (defn- resolve-methods
[cfg] [cfg]
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)] (let [cfg (assoc cfg ::module "main" ::type "command" ::metrics-id :rpc-main-timing)]
(->> (sv/scan-ns (->> (sv/scan-ns
'app.rpc.commands.access-token 'app.rpc.commands.access-token
'app.rpc.commands.audit 'app.rpc.commands.audit
@ -266,7 +266,7 @@
'app.rpc.commands.verify-token 'app.rpc.commands.verify-token
'app.rpc.commands.viewer 'app.rpc.commands.viewer
'app.rpc.commands.webhooks) 'app.rpc.commands.webhooks)
(map (partial process-method cfg "rpc" wrap)) (map (partial process-method cfg wrap))
(into {})))) (into {}))))
(def ^:private schema:methods-params (def ^:private schema:methods-params
@ -298,11 +298,13 @@
(defn- resolve-management-methods (defn- resolve-management-methods
[cfg] [cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)] (let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
(->> (sv/scan-ns mods (cond->> (list 'app.rpc.management.exporter)
'app.rpc.management.subscription (contains? cf/flags :nitrate)
'app.rpc.management.exporter) (cons 'app.rpc.management.nitrate))]
(map (partial process-method cfg "management" wrap-management))
(->> (apply sv/scan-ns mods)
(map (partial process-method cfg wrap-management))
(into {})))) (into {}))))
(def ^:private schema:management-methods-params (def ^:private schema:management-methods-params
@ -345,23 +347,20 @@
(defmethod ig/assert-key ::routes (defmethod ig/assert-key ::routes
[_ params] [_ params]
(assert (map? (::setup/shared-keys params)))
(assert (db/pool? (::db/pool params)) "expect valid database pool") (assert (db/pool? (::db/pool params)) "expect valid database pool")
(assert (some? (::setup/props params)))
(assert (session/manager? (::session/manager params)) "expect valid session manager") (assert (session/manager? (::session/manager params)) "expect valid session manager")
(assert (valid-methods? (::methods params)) "expect valid methods map") (assert (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map")) (assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}] [_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
(let [public-uri (cf/get :public-uri)
management-key (or (cf/get :management-api-key)
(get props :management-key))]
(let [public-uri (cf/get :public-uri)]
["/api" ["/api"
["/management" ["/management"
["/methods/:type" ["/methods/:method-name"
{:middleware [[mw/shared-key-auth management-key] {:middleware [[mw/shared-key-auth shared-keys]
[session/authz cfg]] [session/authz cfg]]
:handler (make-rpc-handler management-methods)}] :handler (make-rpc-handler management-methods)}]
@ -371,7 +370,7 @@
:description "MANAGEMENT API")] :description "MANAGEMENT API")]
["/main" ["/main"
["/methods/:type" ["/methods/:method-name"
{:middleware [[mw/cors] {:middleware [[mw/cors]
[sec/client-header-check] [sec/client-header-check]
[session/authz cfg] [session/authz cfg]
@ -389,7 +388,7 @@
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}] ["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}] ["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
["/rpc/command/:type" ["/rpc/command/:method-name"
{:middleware [[mw/cors] {:middleware [[mw/cors]
[sec/client-header-check] [sec/client-header-check]
[session/authz cfg] [session/authz cfg]

View File

@ -16,6 +16,8 @@
[app.db :as db] [app.db :as db]
[app.http :as-alias http] [app.http :as-alias http]
[app.loggers.audit :as-alias audit] [app.loggers.audit :as-alias audit]
[app.loggers.database :as loggers.db]
[app.loggers.mattermost :as loggers.mm]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit] [app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
@ -36,52 +38,79 @@
:context]) :context])
(defn- event->row [event] (defn- event->row [event]
[(uuid/next) [(::audit/id event)
(:name event) (::audit/name event)
(:source event) (::audit/source event)
(:type event) (::audit/type event)
(:timestamp event) (::audit/tracked-at event)
(:created-at event) (::audit/created-at event)
(:profile-id event) (::audit/profile-id event)
(db/inet (:ip-addr event)) (db/inet (::audit/ip-addr event))
(db/tjson (:props event)) (db/tjson (::audit/props event))
(db/tjson (d/without-nils (:context event)))]) (db/tjson (d/without-nils (::audit/context event)))])
(defn- adjust-timestamp (defn- adjust-timestamp
[{:keys [timestamp created-at] :as event}] [{:keys [::audit/tracked-at ::audit/created-at] :as event}]
(let [margin (inst-ms (ct/diff timestamp created-at))] (let [margin (inst-ms (ct/diff tracked-at created-at))]
(if (or (neg? margin) (if (or (neg? margin)
(> margin 3600000)) (> margin 3600000))
;; If event is in future or lags more than 1 hour, we reasign ;; If event is in future or lags more than 1 hour, we reasign
;; timestamp to the server creation date ;; tracked-at to the server creation date
(-> event (-> event
(assoc :timestamp created-at) (assoc ::audit/tracked-at created-at)
(update :context assoc :original-timestamp timestamp)) (update ::audit/context assoc :original-tracked-at tracked-at))
event))) event)))
(defn- handle-events (defn- exception-event?
[{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}] [{:keys [::audit/type ::audit/name] :as ev}]
(and (= "action" type)
(or (= "unhandled-exception" name)
(= "exception-page" name))))
(def ^:private xf:map-event-row
(comp
(map adjust-timestamp)
(map event->row)))
(defn- get-events
[{:keys [::rpc/request-at ::rpc/profile-id events] :as params}]
(let [request (-> params meta ::http/request) (let [request (-> params meta ::http/request)
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
tnow (ct/now)
xform (comp xform (map (fn [event]
(map (fn [event] {::audit/id (uuid/next)
(-> event ::audit/type (:type event)
(assoc :created-at tnow) ::audit/name (:name event)
(assoc :profile-id profile-id) ::audit/props (:props event)
(assoc :ip-addr ip-addr) ::audit/context (:context event)
(assoc :source "frontend")))) ::audit/profile-id profile-id
(filter :profile-id) ::audit/ip-addr ip-addr
(map adjust-timestamp) ::audit/source "frontend"
(map event->row)) ::audit/tracked-at (:timestamp event)
events (sequence xform events)] ::audit/created-at request-at}))]
(sequence xform events)))
(defn- handle-events
[{:keys [::db/pool] :as cfg} params]
(let [events (get-events params)]
;; Look for error reports and save them on internal reports table
(when-let [events (->> events
(sequence (filter exception-event?))
(not-empty))]
(run! (partial loggers.db/emit cfg) events)
(run! (partial loggers.mm/emit cfg) events))
;; Process and save events
(when (seq events) (when (seq events)
(db/insert-many! pool :audit-log event-columns events)))) (let [rows (sequence xf:map-event-row events)]
(db/insert-many! pool :audit-log event-columns rows)))))
(def valid-event-types (def ^:private valid-event-types
#{"action" "identify"}) #{"action" "identify" "trigger"})
(def schema:event (def ^:private schema:frontend-event
[:map {:title "Event"} [:map {:title "Event"}
[:name [:name
[:and {:gen/elements ["update-file", "get-profile"]} [:and {:gen/elements ["update-file", "get-profile"]}
@ -93,12 +122,13 @@
[::sm/one-of {:format "string"} valid-event-types]]] [::sm/one-of {:format "string"} valid-event-types]]]
[:props [:props
[:map-of :keyword ::sm/any]] [:map-of :keyword ::sm/any]]
[:timestamp ::ct/inst]
[:context {:optional true} [:context {:optional true}
[:map-of :keyword ::sm/any]]]) [:map-of :keyword ::sm/any]]])
(def schema:push-audit-events (def ^:private schema:push-audit-events
[:map {:title "push-audit-events"} [:map {:title "push-audit-events"}
[:events [:vector schema:event]]]) [:events [:vector schema:frontend-event]]])
(sv/defmethod ::push-audit-events (sv/defmethod ::push-audit-events
{::climit/id :submit-audit-events/by-profile {::climit/id :submit-audit-events/by-profile

View File

@ -1005,19 +1005,19 @@
"Link a file to a library. Returns the recursive list of libraries used by that library" "Link a file to a library. Returns the recursive list of libraries used by that library"
{::doc/added "1.17" {::doc/added "1.17"
::webhooks/event? true ::webhooks/event? true
::sm/params schema:link-file-to-library} ::sm/params schema:link-file-to-library
[cfg {:keys [::rpc/profile-id file-id library-id] :as params}] ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
(when (= file-id library-id) (when (= file-id library-id)
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-library :code :invalid-library
:hint "A file cannot be linked to itself")) :hint "A file cannot be linked to itself"))
(db/tx-run! cfg (check-edition-permissions! conn profile-id file-id)
(fn [{:keys [::db/conn]}] (check-edition-permissions! conn profile-id library-id)
(check-edition-permissions! conn profile-id file-id) (link-file-to-library conn params)
(check-edition-permissions! conn profile-id library-id) (bfc/get-libraries cfg [library-id]))
(link-file-to-library conn params)
(bfc/get-libraries cfg [library-id]))))
;; --- MUTATION COMMAND: unlink-file-from-library ;; --- MUTATION COMMAND: unlink-file-from-library
@ -1037,8 +1037,9 @@
::webhooks/event? true ::webhooks/event? true
::sm/params schema:unlink-file-to-library ::sm/params schema:unlink-file-to-library
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(unlink-file-from-library conn params) (unlink-file-from-library conn params)
nil) nil)
@ -1062,8 +1063,9 @@
{::doc/added "1.17" {::doc/added "1.17"
::sm/params schema:update-file-library-sync-status ::sm/params schema:update-file-library-sync-status
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(update-sync conn params)) (update-sync conn params))
;; --- MUTATION COMMAND: ignore-sync ;; --- MUTATION COMMAND: ignore-sync

View File

@ -21,6 +21,7 @@
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.main :as-alias main] [app.main :as-alias main]
[app.media :as media] [app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.climit :as climit] [app.rpc.climit :as climit]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
@ -88,6 +89,8 @@
;; --- QUERY: Get profile (own) ;; --- QUERY: Get profile (own)
(sv/defmethod ::get-profile (sv/defmethod ::get-profile
{::rpc/auth false {::rpc/auth false
::doc/added "1.18" ::doc/added "1.18"
@ -98,9 +101,13 @@
;; no profile-id is in session, and when db call raises not found. In all other ;; no profile-id is in session, and when db call raises not found. In all other
;; cases we need to reraise the exception. ;; cases we need to reraise the exception.
(try (try
(-> (get-profile pool profile-id) (let [profile (-> (get-profile pool profile-id)
(strip-private-attrs) (strip-private-attrs)
(update :props filter-props)) (update :props filter-props))]
(if (contains? cf/flags :nitrate)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
(catch Throwable _ (catch Throwable _
{:id uuid/zero :fullname "Anonymous User"}))) {:id uuid/zero :fullname "Anonymous User"})))

View File

@ -23,6 +23,7 @@
[app.main :as-alias main] [app.main :as-alias main]
[app.media :as media] [app.media :as media]
[app.msgbus :as mbus] [app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
@ -190,7 +191,9 @@
::sm/params schema:get-teams} ::sm/params schema:get-teams}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(dm/with-open [conn (db/open pool)] (dm/with-open [conn (db/open pool)]
(get-teams conn profile-id))) (cond->> (get-teams conn profile-id)
(contains? cf/flags :nitrate)
(map #(nitrate/add-org-to-team cfg % params)))))
(def ^:private sql:get-owned-teams (def ^:private sql:get-owned-teams
"SELECT t.id, t.name, "SELECT t.id, t.name,

View File

@ -248,11 +248,11 @@
invitations (into #{} invitations (into #{}
(comp (comp
;; We don't re-send invitations to ;; We don't re-send invitations to
;; already existing members ;; already existing members
(remove #(contains? team-members (:email %))) (remove #(contains? team-members (:email %)))
;; We don't send invitations to ;; We don't send invitations to
;; join-requested members ;; join-requested members
(remove #(contains? join-requests (:email %))) (remove #(contains? join-requests (:email %)))
(map (fn [{:keys [email role]}] (map (fn [{:keys [email role]}]
(create-invitation cfg (create-invitation cfg

View File

@ -0,0 +1,114 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate
"Internal Nitrate HTTP RPC API. Provides authenticated access to
organization management and token validation endpoints."
(:require
[app.common.schema :as sm]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid]
[app.db :as db]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- API: authenticate
(sv/defmethod ::authenticate
"Authenticate the current user"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:photo-url (files/resolve-public-uri (get profile :photo-id))}))
;; ---- API: get-teams
(def ^:private sql:get-teams
"SELECT t.*
FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ?
AND tpr.is_owner IS TRUE
AND t.is_default IS FALSE
AND t.deleted_at IS NULL;")
(def ^:private schema:get-teams-result
[:vector schema:team])
(sv/defmethod ::get-teams
"List teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name])))))
;; ---- API: notify-team-change
(def ^:private schema:notify-team-change
[:map
[:id ::sm/uuid]
[:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate"
{::doc/added "2.14"
::sm/params schema:notify-team-change
::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}]
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team-id id
:organization-id organization-id
:organization-name organization-name})))
;; ---- API: get-managed-profiles
(def ^:private sql:get-managed-profiles
"SELECT DISTINCT p.id, p.fullname as name, p.email
FROM profile p
JOIN team_profile_rel tpr_member
ON tpr_member.profile_id = p.id
WHERE p.id <> ?
AND EXISTS (
SELECT 1
FROM team_profile_rel tpr_owner
JOIN team t
ON t.id = tpr_owner.team_id
WHERE tpr_owner.profile_id = ?
AND tpr_owner.team_id = tpr_member.team_id
AND tpr_owner.is_owner IS TRUE
AND t.is_default IS FALSE
AND t.deleted_at IS NULL);")
(def schema:managed-profile-result
[:vector schema:basic-profile])
(sv/defmethod ::get-managed-profiles
"List profiles that belong to teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:managed-profile-result}
[cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))

View File

@ -1,183 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.subscription
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- RPC METHOD: AUTHENTICATE
(def ^:private
schema:authenticate-params
[:map {:title "authenticate-params"}])
(def ^:private
schema:authenticate-result
[:map {:title "authenticate-result"}
[:profile-id ::sm/uuid]])
(sv/defmethod ::auth
{::doc/added "2.12"
::sm/params schema:authenticate-params
::sm/result schema:authenticate-result}
[_ {:keys [::rpc/profile-id]}]
{:profile-id profile-id})
;; ---- RPC METHOD: GET-CUSTOMER
;; FIXME: move to app.common.time
(def ^:private schema:timestamp
(sm/type-schema
{:type ::timestamp
:pred ct/inst?
:type-properties
{:title "inst"
:description "The same as :app.common.time/inst but encodes to epoch"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (ct/inst v))))
:decode/string #(some-> % ct/inst)
:encode/string #(some-> % inst-ms)
:decode/json #(some-> % ct/inst)
:encode/json #(some-> % inst-ms)}}))
(def ^:private schema:subscription
[:map {:title "Subscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"past_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
[:start-date [:maybe schema:timestamp]]
[:ended-at [:maybe schema:timestamp]]
[:trial-end [:maybe schema:timestamp]]
[:trial-start [:maybe schema:timestamp]]
[:cancel-at [:maybe schema:timestamp]]
[:canceled-at [:maybe schema:timestamp]]
[:current-period-end [:maybe schema:timestamp]]
[:current-period-start [:maybe schema:timestamp]]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private sql:get-customer-slots
"WITH teams AS (
SELECT tpr.team_id AS id,
tpr.profile_id AS profile_id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS true
AND tpr.profile_id = ?
), teams_with_slots AS (
SELECT tpr.team_id AS id,
count(*) AS total
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id FROM teams)
AND tpr.can_edit IS true
GROUP BY 1
ORDER BY 2
)
SELECT max(total) AS total FROM teams_with_slots;")
(defn- get-customer-slots
[cfg profile-id]
(let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])]
(:total result)))
(def ^:private schema:get-customer-params
[:map])
(def ^:private schema:get-customer-result
[:map
[:id ::sm/uuid]
[:name :string]
[:num-editors ::sm/int]
[:subscription {:optional true} schema:subscription]])
(sv/defmethod ::get-customer
{::doc/added "2.12"
::sm/params schema:get-customer-params
::sm/result schema:get-customer-result}
[cfg {:keys [::rpc/profile-id]}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:num-editors (get-customer-slots cfg profile-id)
:subscription (-> profile :props :subscription)}))
;; ---- RPC METHOD: GET-CUSTOMER
(def ^:private schema:update-customer-params
[:map
[:subscription [:maybe schema:subscription]]])
(def ^:private schema:update-customer-result
[:map])
(sv/defmethod ::update-customer
{::doc/added "2.12"
::sm/params schema:update-customer-params
::sm/result schema:update-customer-result}
[cfg {:keys [::rpc/profile-id subscription]}]
(let [{:keys [props] :as profile}
(profile/get-profile cfg profile-id ::db/for-update true)
props
(assoc props :subscription subscription)]
(l/dbg :hint "update customer"
:profile-id (str profile-id)
:subscription-type (get subscription :type)
:subscription-status (get subscription :status)
:subscription-quantity (get subscription :quantity))
(db/update! cfg :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))

View File

@ -52,6 +52,8 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.http :as-alias http] [app.http :as-alias http]
[app.loggers.database :as loggers.db]
[app.loggers.mattermost :as loggers.mm]
[app.redis :as rds] [app.redis :as rds]
[app.redis.script :as-alias rscript] [app.redis.script :as-alias rscript]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -171,9 +173,9 @@
:hint (str/ffmt "looks like '%' does not have a valid format" opts)))) :hint (str/ffmt "looks like '%' does not have a valid format" opts))))
(defmethod process-limit :bucket (defmethod process-limit :bucket
[rconn profile-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}] [rconn profile-id now {:keys [::key ::params ::method ::capacity ::interval ::rate] :as limit}]
(let [script (-> bucket-rate-limit-script (let [script (-> bucket-rate-limit-script
(assoc ::rscript/keys [(str key "." service "." profile-id)]) (assoc ::rscript/keys [(str key "." method "." profile-id)])
(assoc ::rscript/vals (conj params (->seconds now)))) (assoc ::rscript/vals (conj params (->seconds now))))
result (rds/eval rconn script) result (rds/eval rconn script)
allowed? (boolean (nth result 0)) allowed? (boolean (nth result 0))
@ -181,7 +183,7 @@
reset (* (/ (inst-ms interval) rate) reset (* (/ (inst-ms interval) rate)
(- capacity remaining))] (- capacity remaining))]
(l/trace :hint "limit processed" (l/trace :hint "limit processed"
:service service :method method
:limit (name (::name limit)) :limit (name (::name limit))
:strategy (name (::strategy limit)) :strategy (name (::strategy limit))
:opts (::opts limit) :opts (::opts limit)
@ -193,17 +195,17 @@
(assoc ::lresult/remaining remaining)))) (assoc ::lresult/remaining remaining))))
(defmethod process-limit :window (defmethod process-limit :window
[rconn profile-id now {:keys [::permits ::unit ::key ::service] :as limit}] [rconn uid now {:keys [::permits ::unit ::key ::method] :as limit}]
(let [ts (ct/truncate now unit) (let [ts (ct/truncate now unit)
ttl (ct/diff now (ct/plus ts {unit 1})) ttl (ct/diff now (ct/plus ts {unit 1}))
script (-> window-rate-limit-script script (-> window-rate-limit-script
(assoc ::rscript/keys [(str key "." service "." profile-id "." (ct/format-inst ts))]) (assoc ::rscript/keys [(str key "." method "." uid "." (ct/format-inst ts))])
(assoc ::rscript/vals [permits (->seconds ttl)])) (assoc ::rscript/vals [permits (->seconds ttl)]))
result (rds/eval rconn script) result (rds/eval rconn script)
allowed? (boolean (nth result 0)) allowed? (boolean (nth result 0))
remaining (nth result 1)] remaining (nth result 1)]
(l/trace :hint "limit processed" (l/trace :hint "limit processed"
:service service :method method
:name (name (::name limit)) :name (name (::name limit))
:strategy (name (::strategy limit)) :strategy (name (::strategy limit))
:opts (::opts limit) :opts (::opts limit)
@ -211,12 +213,13 @@
:remaining remaining) :remaining remaining)
(-> limit (-> limit
(assoc ::lresult/allowed allowed?) (assoc ::lresult/allowed allowed?)
(assoc ::lresult/timestamp ts)
(assoc ::lresult/remaining remaining) (assoc ::lresult/remaining remaining)
(assoc ::lresult/reset (ct/plus ts {unit 1}))))) (assoc ::lresult/reset (ct/plus ts {unit 1})))))
(defn- process-limits (defn- process-limits
[rconn profile-id limits now] [{:keys [::rds/conn] :as cfg} uid limits now]
(let [results (into [] (map (partial process-limit rconn profile-id now)) limits) (let [results (into [] (map (partial process-limit conn uid now)) limits)
remaining (->> results remaining (->> results
(d/index-by ::name ::lresult/remaining) (d/index-by ::name ::lresult/remaining)
(uri/map->query-string)) (uri/map->query-string))
@ -227,11 +230,22 @@
rejected (d/seek (complement ::lresult/allowed) results)] rejected (d/seek (complement ::lresult/allowed) results)]
(when rejected (when rejected
(l/warn :hint "rejected rate limit" (let [event {::id (uuid/next)
:profile-id (str profile-id) ::uid uid
:limit-service (-> rejected ::service name) ::method (-> rejected ::method name)
:limit-name (-> rejected ::name name) ::name (-> rejected ::name name)
:limit-strategy (-> rejected ::strategy name))) ::strategy (-> rejected ::strategy name)
::results results}]
(l/warn :hint "rejected rate limit"
:method (-> rejected ::method name)
:name (-> rejected ::name name)
:strategy (-> rejected ::strategy name)
:uid (str uid)
:report-id (:id event))
(loggers.mm/emit cfg event)
(loggers.db/emit cfg event)))
{::enabled true {::enabled true
::allowed (not (some? rejected)) ::allowed (not (some? rejected))
@ -244,7 +258,7 @@
[state skey sname] [state skey sname]
(when-let [limits (or (get-in @state [::limits skey]) (when-let [limits (or (get-in @state [::limits skey])
(get-in @state [::limits :default]))] (get-in @state [::limits :default]))]
(into [] (map #(assoc % ::service sname)) limits))) (into [] (map #(assoc % ::method sname)) limits)))
(defn- get-uid (defn- get-uid
[{:keys [::rpc/profile-id] :as params}] [{:keys [::rpc/profile-id] :as params}]
@ -254,10 +268,10 @@
uuid/zero))) uuid/zero)))
(defn- process-request' (defn- process-request'
[{:keys [::rds/conn] :as cfg} limits params] [cfg limits params]
(try (try
(let [uid (get-uid params) (let [uid (get-uid params)
result (process-limits conn uid limits (ct/now))] result (process-limits cfg uid limits (ct/now))]
(if (contains? cf/flags :soft-rpc-rlimit) (if (contains? cf/flags :soft-rpc-rlimit)
{::enabled false} {::enabled false}
result)) result))
@ -275,8 +289,8 @@
(assert (or (nil? rlimit) (valid-rlimit-instance? rlimit)) "expected a valid rlimit instance") (assert (or (nil? rlimit) (valid-rlimit-instance? rlimit)) "expected a valid rlimit instance")
(if rlimit (if rlimit
(let [skey (keyword (::rpc/type cfg) (->> mdata ::sv/spec name)) (let [skey (keyword (::rpc/module cfg) (->> mdata ::sv/spec name))
sname (str (::rpc/type cfg) "." (->> mdata ::sv/spec name)) sname (str (::rpc/module cfg) "." (->> mdata ::sv/spec name))
cfg (-> cfg cfg (-> cfg
(assoc ::skey skey) (assoc ::skey skey)
(assoc ::sname sname))] (assoc ::sname sname))]

View File

@ -17,6 +17,7 @@
[app.setup.templates] [app.setup.templates]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
[buddy.core.nonce :as bn] [buddy.core.nonce :as bn]
[cuerdas.core :as str]
[integrant.core :as ig])) [integrant.core :as ig]))
(defn- generate-random-key (defn- generate-random-key
@ -88,7 +89,38 @@
(-> (get-all-props conn) (-> (get-all-props conn)
(assoc :secret-key secret) (assoc :secret-key secret)
(assoc :tokens-key (keys/derive secret :salt "tokens")) (assoc :tokens-key (keys/derive secret :salt "tokens"))
(assoc :management-key (keys/derive secret :salt "management"))
(update :instance-id handle-instance-id conn (db/read-only? pool))))))) (update :instance-id handle-instance-id conn (db/read-only? pool)))))))
(sm/register! ::props [:map-of :keyword ::sm/any]) (sm/register! ::props [:map-of :keyword ::sm/any])
(defmethod ig/init-key ::shared-keys
[_ {:keys [::props] :as cfg}]
(let [secret (get props :secret-key)]
(d/without-nils
{:exporter
(let [key (or (get cfg :exporter)
(-> (keys/derive secret :salt "exporter")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "exporter key is disabled because empty string found")
nil)
(do
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key)))
:nitrate
(let [key (or (get cfg :nitrate)
(-> (keys/derive secret :salt "nitrate")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "nitrate key is disabled because empty string found")
nil)
(do
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
key)))})))

View File

@ -9,48 +9,35 @@
modification of time offset (useful for testing and time adjustments)." modification of time offset (useful for testing and time adjustments)."
(:require (:require
[app.common.logging :as l] [app.common.logging :as l]
[app.common.time :as ct] [app.common.time :as ct]))
[app.setup :as-alias setup]
[integrant.core :as ig])
(:import
java.time.Clock
java.time.Duration
java.time.Instant
java.time.ZoneId))
(defonce current (defonce state
(atom {:clock (Clock/systemDefaultZone) (atom {}))
:offset nil}))
(defmethod ig/init-key ::setup/clock (defn assign-offset
[_ _] "Assign virtual clock offset to a specific user. Is the responsability
(add-watch current ::common of RPC module to properly bind the correct clock to the user
(fn [_ _ _ {:keys [clock offset]}] request."
(let [clock (if (ct/duration? offset) [profile-id duration]
(Clock/offset ^Clock clock (swap! state (fn [state]
^Duration offset) (if (nil? duration)
clock)] (dissoc state profile-id)
(l/wrn :hint "altering clock" :clock (str clock)) (assoc state profile-id duration)))))
(alter-var-root #'ct/*clock* (constantly clock))))))
(defn get-offset
[profile-id]
(get @state profile-id))
(defmethod ig/halt-key! ::setup/clock (defn get-clock
[_ _] [profile-id]
(remove-watch current ::common)) (if-let [offset (get-offset profile-id)]
(ct/offset-clock offset)
(ct/get-system-clock)))
(defn fixed (defn set-global-clock
"Get fixed clock, mainly used in tests"
[instant]
(Clock/fixed ^Instant (ct/inst instant)
^ZoneId (ZoneId/of "Z")))
(defn set-offset!
[duration]
(swap! current assoc :offset (some-> duration ct/duration)))
(defn set-clock!
([] ([]
(swap! current assoc :clock (Clock/systemDefaultZone))) (set-global-clock (ct/get-system-clock)))
([clock] ([clock]
(when (instance? Clock clock) (assert (ct/clock? clock) "expected valid clock instance")
(swap! current assoc :clock clock)))) (l/wrn :hint "altering clock" :clock (str clock))
(alter-var-root #'ct/*clock* (constantly clock))))

View File

@ -33,6 +33,7 @@
java.util.Optional java.util.Optional
java.util.concurrent.atomic.AtomicLong java.util.concurrent.atomic.AtomicLong
org.reactivestreams.Subscriber org.reactivestreams.Subscriber
software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.core.ResponseBytes
software.amazon.awssdk.core.async.AsyncRequestBody software.amazon.awssdk.core.async.AsyncRequestBody
software.amazon.awssdk.core.async.AsyncResponseTransformer software.amazon.awssdk.core.async.AsyncResponseTransformer
@ -199,7 +200,8 @@
(defn- build-s3-client (defn- build-s3-client
[{:keys [::region ::endpoint ::wrk/netty-io-executor]}] [{:keys [::region ::endpoint ::wrk/netty-io-executor]}]
(let [aconfig (-> (ClientAsyncConfiguration/builder) (let [creds-provider (DefaultCredentialsProvider/create)
aconfig (-> (ClientAsyncConfiguration/builder)
(.build)) (.build))
sconfig (-> (S3Configuration/builder) sconfig (-> (S3Configuration/builder)
@ -221,6 +223,7 @@
builder (.asyncConfiguration ^S3AsyncClientBuilder builder ^ClientAsyncConfiguration aconfig) builder (.asyncConfiguration ^S3AsyncClientBuilder builder ^ClientAsyncConfiguration aconfig)
builder (.httpClient ^S3AsyncClientBuilder builder ^NettyNioAsyncHttpClient hclient) builder (.httpClient ^S3AsyncClientBuilder builder ^NettyNioAsyncHttpClient hclient)
builder (.region ^S3AsyncClientBuilder builder (lookup-region region)) builder (.region ^S3AsyncClientBuilder builder (lookup-region region))
builder (.credentialsProvider ^S3AsyncClientBuilder builder creds-provider)
builder (cond-> ^S3AsyncClientBuilder builder builder (cond-> ^S3AsyncClientBuilder builder
(some? endpoint) (some? endpoint)
(.endpointOverride (URI. (str endpoint))))] (.endpointOverride (URI. (str endpoint))))]
@ -237,7 +240,8 @@
(defn- build-s3-presigner (defn- build-s3-presigner
[{:keys [::region ::endpoint]}] [{:keys [::region ::endpoint]}]
(let [config (-> (S3Configuration/builder) (let [creds-provider (DefaultCredentialsProvider/create)
config (-> (S3Configuration/builder)
(cond-> (some? endpoint) (.pathStyleAccessEnabled true)) (cond-> (some? endpoint) (.pathStyleAccessEnabled true))
(.build))] (.build))]
@ -245,6 +249,7 @@
(cond-> (some? endpoint) (.endpointOverride (URI. (str endpoint)))) (cond-> (some? endpoint) (.endpointOverride (URI. (str endpoint))))
(.region (lookup-region region)) (.region (lookup-region region))
(.serviceConfiguration ^S3Configuration config) (.serviceConfiguration ^S3Configuration config)
(.credentialsProvider creds-provider)
(.build)))) (.build))))
(defn- write-input-stream (defn- write-input-stream

View File

@ -8,7 +8,7 @@
"A generic asynchronous events notifications subsystem; used mainly "A generic asynchronous events notifications subsystem; used mainly
for mark event points in functions and be able to attach listeners for mark event points in functions and be able to attach listeners
to them. Mainly used in http.sse for progress reporting." to them. Mainly used in http.sse for progress reporting."
(:refer-clojure :exclude [tap run!]) (:refer-clojure :exclude [run!])
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]

View File

@ -103,6 +103,14 @@
(assoc-in [::db/pool ::db/username] (:database-username config)) (assoc-in [::db/pool ::db/username] (:database-username config))
(assoc-in [::db/pool ::db/password] (:database-password config)) (assoc-in [::db/pool ::db/password] (:database-password config))
(assoc-in [:app.rpc/methods :app.setup/templates] templates) (assoc-in [:app.rpc/methods :app.setup/templates] templates)
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
(update :app.rpc/rlimit assoc
:app.loggers.mattermost/reporter nil
:app.loggers.database/reporter nil)
(update :app.rpc/methods assoc
:app.setup/templates templates
:app.loggers.mattermost/reporter nil
:app.loggers.database/reporter nil)
(dissoc :app.srepl/server (dissoc :app.srepl/server
:app.http/server :app.http/server
:app.http/route :app.http/route

View File

@ -86,7 +86,7 @@
(t/deftest shared-key-auth (t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth (let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200}) (fn [req] {::yres/status 200})
"secret-key")] {:test1 "secret-key"})]
(let [response (handler (->DummyRequest {} {}))] (let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
@ -95,6 +95,9 @@
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))] (let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
(t/is (= 200 (::yres/status response)))))) (t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz (t/deftest access-token-authz

View File

@ -17,7 +17,6 @@
[app.db.sql :as sql] [app.db.sql :as sql]
[app.http :as http] [app.http :as http]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto] [app.storage :as sto]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.test :as t] [clojure.test :as t]
@ -134,7 +133,7 @@
;; this will run pending task triggered by deleting user snapshot ;; this will run pending task triggered by deleting user snapshot
(th/run-pending-tasks!) (th/run-pending-tasks!)
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})] (let [res (th/run-task! :objects-gc {})]
;; delete 2 snapshots and 2 file data entries ;; delete 2 snapshots and 2 file data entries
(t/is (= 4 (:processed res))))))))) (t/is (= 4 (:processed res)))))))))

View File

@ -19,7 +19,6 @@
[app.http :as http] [app.http :as http]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.setup.clock :as clock]
[app.storage :as sto] [app.storage :as sto]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.test :as t] [clojure.test :as t]
@ -842,7 +841,7 @@
out (th/command! data) out (th/command! data)
error (:error out)] error (:error out)]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))) (t/is (th/ex-of-type? error :not-found))))
@ -864,10 +863,56 @@
out (th/command! data) out (th/command! data)
error (:error out)] error (:error out)]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))) (t/is (th/ex-of-type? error :not-found))))
(t/deftest permissions-checks-unlink-library
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)
file1 (th/create-file* 1 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)})]
(let [data {::th/type :unlink-file-from-library
::rpc/profile-id (:id profile2)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
error (:error out)]
;; (th/print-result! out)
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))
(t/deftest permissions-checks-update-file-library-status
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)
file1 (th/create-file* 1 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile1)
:profile-id (:id profile1)})]
(let [data {::th/type :update-file-library-sync-status
::rpc/profile-id (:id profile2)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
error (:error out)]
;; (th/print-result! out)
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))
(t/deftest deletion (t/deftest deletion
(let [profile1 (th/create-profile* 1) (let [profile1 (th/create-profile* 1)
file (th/create-file* 1 {:project-id (:default-project-id profile1) file (th/create-file* 1 {:project-id (:default-project-id profile1)
@ -922,7 +967,7 @@
(t/is (= 0 (:processed result)))) (t/is (= 0 (:processed result))))
;; run permanent deletion ;; run permanent deletion
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})] (let [result (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed result))))) (t/is (= 3 (:processed result)))))
@ -1262,7 +1307,7 @@
(t/is (= 1 (count rows))) (t/is (= 1 (count rows)))
(t/is (every? #(some? (:data %)) rows))) (t/is (every? #(some? (:data %)) rows)))
;; Mark the file ellegible again for GC ;; Mark the file ellegible again for GC
(th/db-update! :file (th/db-update! :file
{:has-media-trimmed false} {:has-media-trimmed false}
{:id (:id file)}) {:id (:id file)})
@ -1319,7 +1364,7 @@
{:file-id (:id file) {:file-id (:id file)
:type "fragment"} :type "fragment"}
{:order-by [:created-at]})] {:order-by [:created-at]})]
;; (pp/pprint rows) ;; (pp/pprint rows)
(t/is (= 2 (count rows))) (t/is (= 2 (count rows)))
(t/is (nil? (:data row1))) (t/is (nil? (:data row1)))
(t/is (= "storage" (:backend row1))) (t/is (= "storage" (:backend row1)))
@ -1875,7 +1920,7 @@
file-id (uuid/next) file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")] now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)] (binding [ct/*clock* (ct/fixed-clock now)]
(let [data {::th/type :create-file (let [data {::th/type :create-file
::rpc/profile-id (:id prof) ::rpc/profile-id (:id prof)
:project-id proj-id :project-id proj-id
@ -1937,7 +1982,7 @@
file-id (uuid/next) file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")] now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)] (binding [ct/*clock* (ct/fixed-clock now)]
(let [data {::th/type :create-file (let [data {::th/type :create-file
::rpc/profile-id (:id prof) ::rpc/profile-id (:id prof)
:project-id proj-id :project-id proj-id
@ -2000,7 +2045,7 @@
team-id (:default-team-id profile) team-id (:default-team-id profile)
now (ct/inst "2025-10-31T00:00:00Z")] now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)] (binding [ct/*clock* (ct/fixed-clock now)]
(let [project (th/create-project* 1 {:profile-id (:id profile) (let [project (th/create-project* 1 {:profile-id (:id profile)
:team-id team-id}) :team-id team-id})
file (th/create-file* 1 {:profile-id (:id profile) file (th/create-file* 1 {:profile-id (:id profile)

View File

@ -85,7 +85,7 @@
(t/is (map? (:result out)))) (t/is (map? (:result out))))
;; run the task again ;; run the task again
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))] (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))]
(th/run-task! "storage-gc-touched" {}))] (th/run-task! "storage-gc-touched" {}))]
(t/is (= 2 (:freeze res)))) (t/is (= 2 (:freeze res))))
@ -136,7 +136,7 @@
(t/is (some? (sto/get-object storage (:media-id row2)))) (t/is (some? (sto/get-object storage (:media-id row2))))
;; run the task again ;; run the task again
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))] (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))]
(th/run-task! :storage-gc-touched {}))] (th/run-task! :storage-gc-touched {}))]
(t/is (= 1 (:delete res))) (t/is (= 1 (:delete res)))
(t/is (= 0 (:freeze res)))) (t/is (= 0 (:freeze res))))
@ -147,7 +147,7 @@
;; Run the storage gc deleted task, it should permanently delete ;; Run the storage gc deleted task, it should permanently delete
;; all storage objects related to the deleted thumbnails ;; all storage objects related to the deleted thumbnails
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :storage-gc-deleted {})] (let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res))))) (t/is (= 1 (:deleted res)))))
@ -247,7 +247,7 @@
;; Run the storage gc deleted task, it should permanently delete ;; Run the storage gc deleted task, it should permanently delete
;; all storage objects related to the deleted thumbnails ;; all storage objects related to the deleted thumbnails
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [result (th/run-task! :storage-gc-deleted {})] (let [result (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted result))))) (t/is (= 1 (:deleted result)))))

View File

@ -12,7 +12,6 @@
[app.db :as db] [app.db :as db]
[app.http :as http] [app.http :as http]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto] [app.storage :as sto]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.test :as t] [clojure.test :as t]
@ -147,7 +146,7 @@
(t/is (= 0 (:freeze res))) (t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res)))) (t/is (= 0 (:delete res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})] (let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res)))) (t/is (= 2 (:processed res))))
@ -208,7 +207,7 @@
(t/is (= 0 (:freeze res))) (t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res)))) (t/is (= 0 (:delete res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})] (let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res)))) (t/is (= 1 (:processed res))))
@ -268,7 +267,7 @@
(t/is (= 0 (:freeze res))) (t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res)))) (t/is (= 0 (:delete res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})] (let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res)))) (t/is (= 1 (:processed res))))

View File

@ -536,7 +536,7 @@
:token rtoken} :token rtoken}
{:keys [result error] :as out} (th/command! data)] {:keys [result error] :as out} (th/command! data)]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? error)) (t/is (nil? error))
(t/is (map? result)) (t/is (map? result))
(t/is (string? (:invitation-token result)))))) (t/is (string? (:invitation-token result))))))

View File

@ -12,7 +12,6 @@
[app.db :as db] [app.db :as db]
[app.http :as http] [app.http :as http]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.test :as t])) [clojure.test :as t]))
@ -31,7 +30,7 @@
:team-id (:id team) :team-id (:id team)
:name "test project"} :name "test project"}
out (th/command! data)] out (th/command! data)]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(let [result (:result out)] (let [result (:result out)]
@ -94,7 +93,7 @@
:id project-id} :id project-id}
out (th/command! data)] out (th/command! data)]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(t/is (nil? (:result out)))) (t/is (nil? (:result out))))
@ -228,7 +227,7 @@
(t/is (= 0 (count result))))) (t/is (= 0 (count result)))))
;; run permanent deletion ;; run permanent deletion
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})] (let [result (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed result))))) (t/is (= 1 (:processed result)))))

View File

@ -13,7 +13,6 @@
[app.db :as db] [app.db :as db]
[app.http :as http] [app.http :as http]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto] [app.storage :as sto]
[app.tokens :as tokens] [app.tokens :as tokens]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
@ -526,7 +525,7 @@
(t/is (= :not-found (:type edata))))) (t/is (= :not-found (:type edata)))))
;; run permanent deletion ;; run permanent deletion
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})] (let [result (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed result))))) (t/is (= 2 (:processed result)))))
@ -583,7 +582,7 @@
(t/is (= 1 (count rows))) (t/is (= 1 (count rows)))
(t/is (ct/inst? (:deleted-at (first rows))))) (t/is (ct/inst? (:deleted-at (first rows)))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})] (let [result (th/run-task! :objects-gc {})]
(t/is (= 7 (:processed result))))))) (t/is (= 7 (:processed result)))))))

View File

@ -11,7 +11,6 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto] [app.storage :as sto]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.test :as t] [clojure.test :as t]
@ -99,14 +98,14 @@
::sto/expired-at (ct/in-future {:hours 1}) ::sto/expired-at (ct/in-future {:hours 1})
:content-type "text/plain"})] :content-type "text/plain"})]
(binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 0}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 0}))]
(let [res (th/run-task! :storage-gc-deleted {})] (let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res))))) (t/is (= 1 (:deleted res)))))
(let [res (th/db-exec-one! ["select count(*) from storage_object;"])] (let [res (th/db-exec-one! ["select count(*) from storage_object;"])]
(t/is (= 2 (:count res)))) (t/is (= 2 (:count res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 61}))] (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 61}))]
(let [res (th/run-task! :storage-gc-deleted {})] (let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res))))) (t/is (= 1 (:deleted res)))))
@ -331,22 +330,22 @@
:content-type "text/plain"})] :content-type "text/plain"})]
(binding [ct/*clock* (clock/fixed now)] (binding [ct/*clock* (ct/fixed-clock now)]
(let [res (th/run-task! :storage-gc-touched {})] (let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res))) (t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))) (t/is (= 0 (:delete res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:minutes 1}))] (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:minutes 1}))]
(let [res (th/run-task! :storage-gc-touched {})] (let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res))) (t/is (= 0 (:freeze res)))
(t/is (= 1 (:delete res))))) (t/is (= 1 (:delete res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 1}))] (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 1}))]
(let [res (th/run-task! :storage-gc-deleted {})] (let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res))))) (t/is (= 0 (:deleted res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 2}))] (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))]
(let [res (th/run-task! :storage-gc-deleted {})] (let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res))))))) (t/is (= 0 (:deleted res)))))))

File diff suppressed because it is too large Load Diff

7
common/.gitignore vendored
View File

@ -1,7 +0,0 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -59,7 +59,7 @@
thheller/shadow-cljs {:mvn/version "3.2.0"} thheller/shadow-cljs {:mvn/version "3.2.0"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"} com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"} com.bhauman/rebel-readline {:mvn/version "RELEASE"}
criterium/criterium {:mvn/version "RELEASE"} criterium/criterium {:mvn/version "0.4.6"}
mockery/mockery {:mvn/version "RELEASE"}} mockery/mockery {:mvn/version "RELEASE"}}
:extra-paths ["test" "dev"]} :extra-paths ["test" "dev"]}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"author": "Kaleidos INC", "author": "Kaleidos INC",
"private": true, "private": true,
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c", "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
@ -23,9 +23,9 @@
"fmt:clj:check": "cljfmt check --parallel=false src/ test/", "fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj": "cljfmt fix --parallel=true src/ test/",
"lint:clj": "clj-kondo --parallel=true --lint src/", "lint:clj": "clj-kondo --parallel=true --lint src/",
"lint": "yarn run lint:clj", "lint": "pnpm run lint:clj",
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test", "build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "yarn run build:test && node target/tests/test.js" "test": "pnpm run build:test && node target/tests/test.js"
} }
} }

489
common/pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,489 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
date-fns:
specifier: ^4.1.0
version: 4.1.0
devDependencies:
concurrently:
specifier: ^9.1.2
version: 9.2.1
nodemon:
specifier: ^3.1.10
version: 3.1.11
source-map-support:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.18.2
version: 8.18.3
packages:
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
hasBin: true
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nodemon@3.1.11:
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
engines: {node: '>=10'}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
touch@3.1.1:
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
hasBin: true
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
snapshots:
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
braces@3.0.3:
dependencies:
fill-range: 7.1.1
buffer-from@1.1.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concat-map@0.0.1: {}
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.8.3
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
date-fns@4.1.0: {}
debug@4.4.3(supports-color@5.5.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 5.5.0
emoji-regex@8.0.0: {}
escalade@3.2.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
fsevents@2.3.3:
optional: true
get-caller-file@2.0.5: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
has-flag@3.0.0: {}
has-flag@4.0.0: {}
ignore-by-default@1.0.1: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
ms@2.1.3: {}
nodemon@3.1.11:
dependencies:
chokidar: 3.6.0
debug: 4.4.3(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
semver: 7.7.3
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
undefsafe: 2.0.5
normalize-path@3.0.0: {}
picomatch@2.3.1: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
require-directory@2.1.1: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
semver@7.7.3: {}
shell-quote@1.8.3: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
touch@3.1.1: {}
tree-kill@1.2.2: {}
tslib@2.8.1: {}
undefsafe@2.0.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
ws@8.18.3: {}
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1

View File

View File

@ -3,5 +3,5 @@
set -ex set -ex
corepack enable; corepack enable;
corepack install; corepack install;
yarn install; pnpm install;
yarn run test; pnpm run test;

View File

@ -760,6 +760,21 @@
default default
v)))) v))))
(defn percent?
[v]
(str/numeric? (str/rtrim v "%")))
(defn parse-percent
([v]
(parse-percent v nil))
([v default]
(if (str/ends-with? v "%")
(let [v (impl-parse-double (str/trim v "%"))]
(if (or (nil? v) (nan? v))
default
(/ v 100)))
(parse-double v default))))
(defn parse-uuid (defn parse-uuid
[v] [v]
(try (try
@ -1092,9 +1107,9 @@
(if (number? num) (if (number? num)
(try (try
(let [num-str (mth/to-fixed num precision) (let [num-str (mth/to-fixed num precision)
;; Remove all trailing zeros after the comma 100.00000 ;; Remove all trailing zeros after the comma 100.00000
num-str (str/replace num-str trail-zeros-regex-1 "")] num-str (str/replace num-str trail-zeros-regex-1 "")]
;; Remove trailing zeros after a decimal number: 0.001|00| ;; Remove trailing zeros after a decimal number: 0.001|00|
(if-let [m (re-find trail-zeros-regex-2 num-str)] (if-let [m (re-find trail-zeros-regex-2 num-str)]
(str/replace num-str (first m) (second m)) (str/replace num-str (first m) (second m))
num-str)) num-str))

View File

@ -239,9 +239,68 @@
(recur cause))))))] (recur cause))))))]
(with-out-str (with-out-str
(print-all cause))))) (print-all cause))))
#?(:clj :cljs
(defn print-throwable (defn format-throwable
[cause & {:as opts}] [cause & {:as opts}]
(println (format-throwable cause opts)))) (with-out-str
(when-let [exdata (ex-data cause)]
(when-let [hint (get exdata :hint)]
(when (str/index-of hint "\n")
(println "Hint:")
(println "--------------------")
(println hint)
(println)))
(when-let [explain (get exdata ::sm/explain)]
(println "Explain:")
(println "--------------------")
(println (sm/humanize-explain explain))
(println))
(when-let [explain (get exdata :explain)]
(println "Server Explain:")
(println "--------------------")
(println explain))
(println "Data:")
(println "--------------------")
(pp/pprint (dissoc exdata ::sm/explain :explain))
(println))
(when-let [trace (.-stack cause)]
(println "Trace:")
(println "--------------------")
(println (.-stack cause))))))
(defn first-line
[s]
(let [break-index (str/index-of s "\n")]
(if (pos? break-index)
(subs s 0 break-index)
s)))
(defn print-throwable
[cause & {:as opts}]
#?(:clj
(println (format-throwable cause opts))
:cljs
(let [prefix (get opts :prefix)
data (ex-data cause)
title (cond->> (or (some-> (:hint data) first-line)
(ex-message cause))
(string? prefix)
(str prefix ": "))]
(js/console.group title)
(try
(js/console.log (format-throwable cause))
(finally
(js/console.groupEnd))))))
(defn get-hint
[cause]
(or (some-> (ex-data cause) (get :hint) first-line)
(some-> (ex-message cause) first-line)))

View File

@ -55,6 +55,7 @@
"design-tokens/v1" "design-tokens/v1"
"text-editor/v2-html-paste" "text-editor/v2-html-paste"
"text-editor/v2" "text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1" "render-wasm/v1"
"variants/v1"}) "variants/v1"})
@ -78,6 +79,7 @@
"plugins/runtime" "plugins/runtime"
"text-editor/v2-html-paste" "text-editor/v2-html-paste"
"text-editor/v2" "text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input" "tokens/numeric-input"
"render-wasm/v1"}) "render-wasm/v1"})
@ -127,6 +129,7 @@
:feature-design-tokens "design-tokens/v1" :feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2" :feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1" :feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1" :feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input" :feature-token-input "tokens/numeric-input"

View File

@ -27,6 +27,7 @@
[app.common.types.path :as path] [app.common.types.path :as path]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst] [app.common.types.shape-tree :as ctst]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob] [app.common.types.tokens-lib :as ctob]
[app.common.types.typographies-list :as ctyl] [app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as ctt] [app.common.types.typography :as ctt]
@ -378,7 +379,7 @@
[:type [:= :set-token]] [:type [:= :set-token]]
[:set-id ::sm/uuid] [:set-id ::sm/uuid]
[:token-id ::sm/uuid] [:token-id ::sm/uuid]
[:attrs [:maybe ctob/schema:token-attrs]]]] [:attrs [:maybe cto/schema:token-attrs]]]]
[:set-token-set [:set-token-set
[:map {:title "SetTokenSetChange"} [:map {:title "SetTokenSetChange"}

View File

@ -526,20 +526,25 @@
ids)) ids))
(defn clean-loops (defn clean-loops
"Clean a list of ids from circular references." "Clean a list of ids from circular references. Optimized fast-path for single selections."
[objects ids] [objects ids]
(let [parent-selected? (if (<= (count ids) 1)
(fn [id] ;; For single selection, there can't be circularity; return as ordered-set.
(let [parents (get-parent-ids objects id)] (into (d/ordered-set) ids)
(some ids parents))) (let [ids-set (if (set? ids) ids (set ids))
parent-selected?
(fn [id]
;; Stop early as soon as we find any selected parent
(let [parents (get-parent-ids objects id)]
(some #(contains? ids-set %) parents)))
add-element add-element
(fn [result id] (fn [result id]
(cond-> result (cond-> result
(not (parent-selected? id)) (not (parent-selected? id))
(conj id)))] (conj id)))]
(reduce add-element (d/ordered-set) ids))) (reduce add-element (d/ordered-set) ids))))
(defn- indexed-shapes (defn- indexed-shapes
"Retrieves a vector with the indexes for each element in the layer "Retrieves a vector with the indexes for each element in the layer

View File

@ -1766,6 +1766,26 @@
(update :pages-index d/update-vals update-container) (update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container)))) (d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0017-fix-layout-flex-dir"
[data _]
(let [fix-layout-flex-dir
(fn [value]
(if (= value :reverse-row)
:row-reverse
value))
update-object
(fn [object]
(d/update-when object :layout-flex-dir fix-layout-flex-dir))
update-container
(fn [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(def available-migrations (def available-migrations
(into (d/ordered-set) (into (d/ordered-set)
["legacy-2" ["legacy-2"
@ -1839,4 +1859,5 @@
"0014-clear-components-nil-objects" "0014-clear-components-nil-objects"
"0015-fix-text-attrs-blank-strings" "0015-fix-text-attrs-blank-strings"
"0015-clean-shadow-color" "0015-clean-shadow-color"
"0016-copy-fills-from-position-data-to-text-node"])) "0016-copy-fills-from-position-data-to-text-node"
"0017-fix-layout-flex-dir"]))

View File

@ -44,7 +44,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Set parent to root frame. ;; Set parent to root frame.
(log/debug :hint " -> set to " :parent-id uuid/zero) (log/debug :hint " -> set to " :parent-id uuid/zero)
(assoc shape :parent-id uuid/zero))] (assoc shape :parent-id uuid/zero))]
@ -57,7 +57,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [parent-shape] (fn [parent-shape]
; Add shape to parent's children list ;; Add shape to parent's children list
(log/debug :hint " -> add children to" :parent-id (:id parent-shape)) (log/debug :hint " -> add children to" :parent-id (:id parent-shape))
(update parent-shape :shapes conj (:id shape)))] (update parent-shape :shapes conj (:id shape)))]
@ -70,7 +70,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Remove duplicated ;; Remove duplicated
(log/debug :hint " -> remove duplicated children") (log/debug :hint " -> remove duplicated children")
(update shape :shapes distinct))] (update shape :shapes distinct))]
@ -102,7 +102,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Locate the first frame in parents and set frame-id to it. ;; Locate the first frame in parents and set frame-id to it.
(let [page (ctpl/get-page file-data page-id) (let [page (ctpl/get-page file-data page-id)
frame (cfh/get-frame (:objects page) (:parent-id shape)) frame (cfh/get-frame (:objects page) (:parent-id shape))
frame-id (or (:id frame) uuid/zero)] frame-id (or (:id frame) uuid/zero)]
@ -118,7 +118,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Locate the first frame in parents and set frame-id to it. ;; Locate the first frame in parents and set frame-id to it.
(let [page (ctpl/get-page file-data page-id) (let [page (ctpl/get-page file-data page-id)
frame (cfh/get-frame (:objects page) (:parent-id shape)) frame (cfh/get-frame (:objects page) (:parent-id shape))
frame-id (or (:id frame) uuid/zero)] frame-id (or (:id frame) uuid/zero)]
@ -134,7 +134,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Set the :shape as main instance root ;; Set the :shape as main instance root
(log/debug :hint " -> set :main-instance") (log/debug :hint " -> set :main-instance")
(assoc shape :main-instance true))] (assoc shape :main-instance true))]
@ -147,12 +147,13 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Set :component-file to local file ;; Set :component-file to local file
(log/debug :hint " -> set :component-file to local file") (log/debug :hint " -> set :component-file to local file")
(assoc shape :component-file (:id file-data)))] (assoc shape :component-file (:id file-data)))]
; There is no solution that may recover it with confidence
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; There is no solution that may recover it with confidence
;; shape)] ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)]
(log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id) (log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id) (-> (pcb/empty-changes nil page-id)
@ -166,12 +167,12 @@
repair-shape repair-shape
(fn [shape] (fn [shape]
; Detach the shape and convert it to non instance. ;; Detach the shape and convert it to non instance.
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
; There is no solution that may recover it with confidence ;; There is no solution that may recover it with confidence
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)] ;; shape)]
(log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id) (-> (pcb/empty-changes nil page-id)
@ -184,7 +185,7 @@
repair-component repair-component
(fn [component] (fn [component]
; Assign main instance in the component to current shape ;; Assign main instance in the component to current shape
(log/debug :hint " -> assign main-instance-id" :component-id (:id component)) (log/debug :hint " -> assign main-instance-id" :component-id (:id component))
(assoc component :main-instance-id (:id shape))) (assoc component :main-instance-id (:id shape)))
@ -207,7 +208,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-component (let [repair-component
(fn [component] (fn [component]
; Assign main instance in the component to current shape ;; Assign main instance in the component to current shape
(log/debug :hint " -> assign main-instance-page" :component-id (:id component)) (log/debug :hint " -> assign main-instance-page" :component-id (:id component))
(assoc component :main-instance-page page-id))] (assoc component :main-instance-page page-id))]
(log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) (log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
@ -219,7 +220,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; There is no solution that may recover it with confidence ;; There is no solution that may recover it with confidence
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)] shape)]
@ -232,7 +233,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Unset the :shape as main instance root ;; Unset the :shape as main instance root
(log/debug :hint " -> unset :main-instance") (log/debug :hint " -> unset :main-instance")
(dissoc shape :main-instance))] (dissoc shape :main-instance))]
@ -245,7 +246,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a top copy root. ;; Convert the shape in a top copy root.
(log/debug :hint " -> set :component-root") (log/debug :hint " -> set :component-root")
(assoc shape :component-root true))] (assoc shape :component-root true))]
@ -258,7 +259,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a nested copy root. ;; Convert the shape in a nested copy root.
(log/debug :hint " -> unset :component-root") (log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))] (dissoc shape :component-root))]
@ -307,8 +308,8 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
; If the shape still refers to the remote component, try to find the corresponding near one ;; If the shape still refers to the remote component, try to find the corresponding near one
; and link to it. If not, detach the shape. ;; and link to it. If not, detach the shape.
(log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(if (some? matching-shape) (if (some? matching-shape)
(-> (pcb/empty-changes nil page-id) (-> (pcb/empty-changes nil page-id)
@ -324,7 +325,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert shape in a normal copy, removing nested copy status ;; Convert shape in a normal copy, removing nested copy status
(log/debug :hint " -> unhead shape") (log/debug :hint " -> unhead shape")
(ctk/unhead-shape shape))] (ctk/unhead-shape shape))]
@ -337,7 +338,7 @@
[_ {:keys [shape page-id args] :as error} file-data _] [_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert shape in a nested head, adding component info ;; Convert shape in a nested head, adding component info
(log/debug :hint " -> reroot shape") (log/debug :hint " -> reroot shape")
(ctk/rehead-shape shape (:component-file args) (:component-id args)))] (ctk/rehead-shape shape (:component-file args) (:component-id args)))]
@ -350,8 +351,9 @@
[_ {:keys [shape args] :as error} file-data _] [_ {:keys [shape args] :as error} file-data _]
(let [repair-component (let [repair-component
(fn [component] (fn [component]
(let [objects (:objects component) ;; we only have encounter this on deleted components, (let [objects (:objects component)
;; so the relevant objects are inside the component ;; we only have encounter this on deleted components,
;; so the relevant objects are inside the component
to-detach (->> (:cycles-ids args) to-detach (->> (:cycles-ids args)
(map #(get objects %)) (map #(get objects %))
(map #(ctn/get-head-shape objects %)) (map #(ctn/get-head-shape objects %))
@ -378,7 +380,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Remove shape-ref ;; Remove shape-ref
(log/debug :hint " -> unset :shape-ref") (log/debug :hint " -> unset :shape-ref")
(dissoc shape :shape-ref))] (dissoc shape :shape-ref))]
@ -391,7 +393,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a nested main head. ;; Convert the shape in a nested main head.
(log/debug :hint " -> unset :component-root") (log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))] (dissoc shape :component-root))]
@ -404,7 +406,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a top main head. ;; Convert the shape in a top main head.
(log/debug :hint " -> set :component-root") (log/debug :hint " -> set :component-root")
(assoc shape :component-root true))] (assoc shape :component-root true))]
@ -418,7 +420,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a nested copy head. ;; Convert the shape in a nested copy head.
(log/debug :hint " -> unset :component-root") (log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))] (dissoc shape :component-root))]
@ -431,7 +433,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a top copy root. ;; Convert the shape in a top copy root.
(log/debug :hint " -> set :component-root") (log/debug :hint " -> set :component-root")
(assoc shape :component-root true))] (assoc shape :component-root true))]
@ -444,7 +446,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Detach the shape and convert it to non instance. ;; Detach the shape and convert it to non instance.
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
@ -457,7 +459,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Detach the shape and convert it to non instance. ;; Detach the shape and convert it to non instance.
(log/debug :hint " -> detach shape" :shape-id (:id shape)) (log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))] (ctk/detach-shape shape))]
@ -470,7 +472,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; There is no solution that may recover it with confidence ;; There is no solution that may recover it with confidence
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)] shape)]
@ -483,7 +485,7 @@
[_ {:keys [shape page-id] :as error} file-data _] [_ {:keys [shape page-id] :as error} file-data _]
(let [repair-shape (let [repair-shape
(fn [shape] (fn [shape]
; Convert the shape in a frame. ;; Convert the shape in a frame.
(log/debug :hint " -> set :type :frame") (log/debug :hint " -> set :type :frame")
(assoc shape :type :frame (assoc shape :type :frame
:fills [] :fills []
@ -502,7 +504,7 @@
[_ {:keys [shape] :as error} file-data _] [_ {:keys [shape] :as error} file-data _]
(let [repair-component (let [repair-component
(fn [component] (fn [component]
; Remove the objects key, or set it to {} if the component is deleted ;; Remove the objects key, or set it to {} if the component is deleted
(if (:deleted component) (if (:deleted component)
(do (do
(log/debug :hint " -> set :objects {}") (log/debug :hint " -> set :objects {}")

View File

@ -430,8 +430,8 @@
(assoc :frame-id frame-id) (assoc :frame-id frame-id)
(assoc :svg-viewbox vbox) (assoc :svg-viewbox vbox)
(assoc :svg-attrs props) (assoc :svg-attrs props)
;; We need to ensure fills are empty on import process ;; We need to ensure fills are empty on import process
;; because setup-shape assings one by default. ;; because setup-shape assings one by default.
(assoc :fills []) (assoc :fills [])
(merge radius-attrs))))) (merge radius-attrs)))))

View File

@ -8,8 +8,269 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.i18n :refer [tr]]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[clojure.set :as set] [clojure.set :as set]
[cuerdas.core :as str])) [cuerdas.core :as str]
[malli.core :as m]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HIGH LEVEL SCHEMAS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Token value
(defn- token-value-empty-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(def schema:token-value-generic
[::sm/text {:error/fn token-value-empty-fn}])
(def schema:token-value-numeric
[:and
[::sm/text {:error/fn token-value-empty-fn}]
[:fn {:error/fn #(tr "workspace.tokens.invalid-value" (:value %))}
(fn [value]
(if (str/numeric? value)
(let [n (d/parse-double value)]
(some? n))
true))]]) ;; Leave references or formulas to be checked by the resolver
(def schema:token-value-percent
[:and
[::sm/text {:error/fn token-value-empty-fn}]
[:fn {:error/fn #(tr "workspace.tokens.value-with-percent" (:value %))}
(fn [value]
(if (d/percent? value)
(let [v (d/parse-percent value)]
(some? v))
true))]]) ;; Leave references or formulas to be checked by the resolver
(def schema:token-value-composite-ref
[::sm/text {:error/fn token-value-empty-fn}])
(def schema:token-value-opacity
[:and
[::sm/text {:error/fn token-value-empty-fn}]
[:fn {:error/fn #(tr "workspace.tokens.opacity-range")}
(fn [opacity]
(if (str/numeric? opacity)
(let [n (d/parse-percent opacity)]
(and (some? n) (<= 0 n 1)))
true))]]) ;; Leave references or formulas to be checked by the resolver
(def schema:token-value-font-family
[:or
[:vector ::sm/text]
cto/schema:token-ref])
(def schema:token-value-font-weight
[:or
[:fn {:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value")}
cto/valid-font-weight-variant]
::sm/text]) ;; Leave references or formulas to be checked by the resolver
(def schema:token-value-typography-map
[:map
[:font-family {:optional true} schema:token-value-font-family]
[:font-size {:optional true} schema:token-value-numeric]
[:font-weight {:optional true} schema:token-value-font-weight]
[:line-height {:optional true} schema:token-value-percent]
[:letter-spacing {:optional true} schema:token-value-generic]
[:paragraph-spacing {:optional true} schema:token-value-generic]
[:text-decoration {:optional true} schema:token-value-generic]
[:text-case {:optional true} schema:token-value-generic]])
(def schema:token-value-typography
[:or
schema:token-value-typography-map
schema:token-value-composite-ref])
(def schema:token-value-shadow-vector
[:vector
[:map
[:offset-x :string]
[:offset-y :string]
[:blur
[:and
:string
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-blur-value-error")}
(fn [blur]
(let [n (d/parse-double blur)]
(or (nil? n) (not (< n 0)))))]]]
[:spread
[:and
:string
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-spread-value-error")}
(fn [spread]
(let [n (d/parse-double spread)]
(or (nil? n) (not (< n 0)))))]]]
[:color :string]
[:inset {:optional true} :boolean]]])
(def schema:token-value-shadow
[:or
schema:token-value-shadow-vector
schema:token-value-composite-ref])
(defn make-token-value-schema
[token-type]
[:multi {:dispatch (constantly token-type)
:title "Token Value"}
[:opacity schema:token-value-opacity]
[:font-family schema:token-value-font-family]
[:font-size schema:token-value-numeric]
[:font-weight schema:token-value-font-weight]
[:typography schema:token-value-typography]
[:shadow schema:token-value-shadow]
[::m/default schema:token-value-generic]])
;; Token
(defn make-token-name-schema
"Dynamically generates a schema to check a token name, adding translated error messages
and two additional validations:
- Min and max length.
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
[tokens-tree]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(-> cto/schema:token-name
(sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(and (some? tokens-tree)
(not (ctob/token-name-path-exists? % tokens-tree)))]])
(def schema:token-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-schema
[tokens-tree token-type]
[:and
(sm/merge
cto/schema:token-attrs
[:map
[:name (make-token-name-schema tokens-tree)]
[:value (make-token-value-schema token-type)]
[:description {:optional true} schema:token-description]])
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(not (cto/token-value-self-reference? name value))))]])
(defn convert-dtcg-token
"Convert token attributes as they come from a decoded json, with DTCG types, to internal types.
Eg. From this:
{'name' 'body-text'
'type' 'typography'
'value' {
'fontFamilies' ['Arial' 'Helvetica' 'sans-serif']
'fontSize' '16px'
'fontWeights' 'normal'}}
to this
{:name 'body-text'
:type :typography
:value {
:font-family ['Arial' 'Helvetica' 'sans-serif']
:font-size '16px'
:font-weight 'normal'}}"
[token-attrs]
(let [name (get token-attrs "name")
type (get token-attrs "type")
value (get token-attrs "value")
description (get token-attrs "description")
type (cto/dtcg-token-type->token-type type)
value (case type
:font-family (ctob/convert-dtcg-font-family value)
:typography (ctob/convert-dtcg-typography-composite value)
:shadow (ctob/convert-dtcg-shadow-composite value)
value)]
(d/without-nils {:name name
:type type
:value value
:description description})))
;; Token set
(defn make-token-set-name-schema
"Generates a dynamic schema to check a token set name:
- Validate name length.
- Checks if other token set with a path derived from the name already exists in the tokens lib."
[tokens-lib set-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-set-already-exists")}
(fn [name]
(or (nil? tokens-lib)
(let [set (ctob/get-set-by-name tokens-lib name)]
(or (nil? set) (= (ctob/get-id set) set-id)))))]])
(def schema:token-set-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-set-schema
[tokens-lib set-id]
(sm/merge
ctob/schema:token-set-attrs
[:map
[:name [:and (make-token-set-name-schema tokens-lib set-id)
[:fn #(ctob/normalized-set-name? %)]]]
[:description {:optional true} schema:token-set-description]]))
;; Token theme
(defn make-token-theme-group-schema
"Generates a dynamic schema to check a token theme group:
- Validate group length.
- Checks if other token theme with the same name already exists in the new group in the tokens lib."
[tokens-lib name theme-id]
[:and
[:string {:min 0 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists")}
(fn [group]
(or (nil? tokens-lib)
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id)))))]])
(defn make-token-theme-name-schema
"Generates a dynamic schema to check a token theme name:
- Validate name length.
- Checks if other token theme with the same name already exists in the same group in the tokens lib."
[tokens-lib group theme-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (str group "/" (:value %)))}
(fn [name]
(or (nil? tokens-lib)
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id)))))]])
(def schema:token-theme-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-theme-schema
[tokens-lib group name theme-id]
(sm/merge
ctob/schema:token-theme-attrs
[:map
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[:name (make-token-theme-name-schema tokens-lib group theme-id)]
[:description {:optional true} schema:token-theme-description]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def parseable-token-value-regexp (def parseable-token-value-regexp
"Regexp that can be used to parse a number value out of resolved token value. "Regexp that can be used to parse a number value out of resolved token value.
@ -39,12 +300,12 @@
(into {}))) (into {})))
(defn remove-attributes-for-token (defn remove-attributes-for-token
"Removes applied tokens with `token-id` for the given `attributes` set from `applied-tokens`." "Removes applied tokens with `token-name` for the given `attributes` set from `applied-tokens`."
[attributes token applied-tokens] [attributes token-name applied-tokens]
(let [attr? (set attributes)] (let [attr? (set attributes)]
(->> (remove (fn [[k v]] (->> (remove (fn [[k v]]
(and (attr? k) (and (attr? k)
(= v (or (token-identifier token) token)))) (= v token-name)))
applied-tokens) applied-tokens)
(into {})))) (into {}))))
@ -80,56 +341,6 @@
(defn shapes-applied-all? [ids-by-attributes shape-ids attributes] (defn shapes-applied-all? [ids-by-attributes shape-ids attributes]
(every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes)) (every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes))
(defn token-name->path
"Splits token-name into a path vector split by `.` characters.
Will concatenate multiple `.` characters into one."
[token-name]
(str/split token-name #"\.+"))
(defn token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (token-name->path token-name)
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name token-names-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
token-names-tree path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
(defn color-token? [token] (defn color-token? [token]
(= (:type token) :color)) (= (:type token) :color))

View File

@ -62,6 +62,7 @@
#{:audit-log #{:audit-log
:audit-log-archive :audit-log-archive
:audit-log-gc :audit-log-gc
:audit-log-logger
:auto-file-snapshot :auto-file-snapshot
;; enables the `/api/doc` endpoint that lists all the rpc methods available. ;; enables the `/api/doc` endpoint that lists all the rpc methods available.
:backend-api-doc :backend-api-doc
@ -123,6 +124,7 @@
:token-base-font-size :token-base-font-size
:token-color :token-color
:token-shadow :token-shadow
:token-tokenscript
:transit-readable-response :transit-readable-response
:user-feedback :user-feedback
;; TODO: remove this flag. ;; TODO: remove this flag.
@ -134,6 +136,8 @@
:subscriptions :subscriptions
:subscriptions-old :subscriptions-old
:inspect-styles :inspect-styles
;; Enable performance logs in devconsole (disabled by default)
:perf-logs
;; Security layer middleware that filters request by fetch ;; Security layer middleware that filters request by fetch
;; metadata headers ;; metadata headers
@ -145,7 +149,10 @@
;; A temporal flag, enables backend code use more extensivelly ;; A temporal flag, enables backend code use more extensivelly
;; redis for caching data ;; redis for caching data
:redis-cache}) :redis-cache
;; Activates the nitrate module
:nitrate})
(def all-flags (def all-flags
(set/union email login varia)) (set/union email login varia))

View File

@ -124,33 +124,51 @@
(defn adjust-to-viewport (defn adjust-to-viewport
([viewport srect] (adjust-to-viewport viewport srect nil)) ([viewport srect] (adjust-to-viewport viewport srect nil))
([viewport srect {:keys [padding] :or {padding 0}}] ([viewport srect {:keys [padding min-zoom] :or {padding 0 min-zoom nil}}]
(let [gprop (/ (:width viewport) (let [gprop (/ (:width viewport)
(:height viewport)) (:height viewport))
srect (-> srect srect-padded (-> srect
(update :x #(- % padding)) (update :x #(- % padding))
(update :y #(- % padding)) (update :y #(- % padding))
(update :width #(+ % padding padding)) (update :width #(+ % padding padding))
(update :height #(+ % padding padding))) (update :height #(+ % padding padding)))
width (:width srect) width (:width srect-padded)
height (:height srect) height (:height srect-padded)
lprop (/ width height)] lprop (/ width height)
(cond adjusted-rect
(> gprop lprop) (cond
(let [width' (* (/ width lprop) gprop) (> gprop lprop)
padding (/ (- width' width) 2)] (let [width' (* (/ width lprop) gprop)
(-> srect padding (/ (- width' width) 2)]
(update :x #(- % padding)) (-> srect-padded
(assoc :width width') (update :x #(- % padding))
(grc/update-rect :position))) (assoc :width width')
(grc/update-rect :position)))
(< gprop lprop) (< gprop lprop)
(let [height' (/ (* height lprop) gprop) (let [height' (/ (* height lprop) gprop)
padding (/ (- height' height) 2)] padding (/ (- height' height) 2)]
(-> srect (-> srect-padded
(update :y #(- % padding)) (update :y #(- % padding))
(assoc :height height') (assoc :height height')
(grc/update-rect :position))) (grc/update-rect :position)))
:else :else
(grc/update-rect srect :position))))) (grc/update-rect srect-padded :position))]
;; If min-zoom is specified and the resulting zoom would be below it,
;; return a rect with the original top-left corner centered in the viewport
;; instead of using the aspect-ratio-adjusted rect (which can push coords
;; extremely far with extreme aspect ratios).
(if (and (some? min-zoom)
(< (/ (:width viewport) (:width adjusted-rect)) min-zoom))
(let [anchor-x (:x srect)
anchor-y (:y srect)
vbox-width (/ (:width viewport) min-zoom)
vbox-height (/ (:height viewport) min-zoom)]
(-> adjusted-rect
(assoc :x (- anchor-x (/ vbox-width 2))
:y (- anchor-y (/ vbox-height 2))
:width vbox-width
:height vbox-height)
(grc/update-rect :position)))
adjusted-rect))))

View File

@ -0,0 +1,15 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.i18n
"Dummy i18n functions, to be used by code in common that needs translations.")
(defn tr
"This function will be monkeypatched at runtime with the real function in frontend i18n.
Here it just returns the key passed as argument. This way the result can be used in
unit tests or backend code for logs or error messages."
[key & _args]
key)

View File

@ -75,20 +75,25 @@
#?(:cljs #?(:cljs
(defn ->clj (defn ->clj
[o & {:keys [key-fn val-fn] :or {key-fn read-kebab-key val-fn identity}}] [o & {:keys [key-fn val-fn recursive] :or {key-fn read-kebab-key val-fn identity recursive true}}]
(let [f (fn this-fn [x] (let [f (fn this-fn [x]
(let [x (val-fn x)] (let [x (val-fn x)]
(cond (cond
(array? x) (array? x)
(persistent! (persistent!
(.reduce ^js/Array x (.reduce ^js/Array x
#(conj! %1 (this-fn %2)) #(conj! %1 (if recursive
(this-fn %2)
%2))
(transient []))) (transient [])))
(identical? (type x) js/Object) (identical? (type x) js/Object)
(persistent! (persistent!
(.reduce ^js/Array (js-keys x) (.reduce ^js/Array (js-keys x)
#(assoc! %1 (key-fn %2) (this-fn (unchecked-get x %2))) #(assoc! %1 (key-fn %2)
(if recursive
(this-fn (unchecked-get x %2))
(unchecked-get x %2)))
(transient {}))) (transient {})))
:else :else

View File

@ -128,7 +128,9 @@
:warn "#f5871f" :warn "#f5871f"
:info "#4271ae" :info "#4271ae"
:debug "#969896" :debug "#969896"
:trace "#8e908c")) :trace "#8e908c"
(let [hint (str "invalid level provided to `level->color` function: " (pr-str level))]
(throw (ex-info hint {:level level})))))
(defn- level->name (defn- level->name
[level] [level]
@ -137,7 +139,9 @@
:trace "TRC" :trace "TRC"
:info "INF" :info "INF"
:warn "WRN" :warn "WRN"
:error "ERR")) :error "ERR"
(let [hint (str "invalid level provided to `level->name` function: " (pr-str level))]
(throw (ex-info hint {:level level})))))
(defn level->int (defn level->int
[level] [level]
@ -146,7 +150,9 @@
:debug 20 :debug 20
:info 30 :info 30
:warn 40 :warn 40
:error 50)) :error 50
(let [hint (str "invalid level provided to `level->int` function: " (pr-str level))]
(throw (ex-info hint {:level level})))))
(defn build-message (defn build-message
[props] [props]

View File

@ -49,9 +49,9 @@
(def log-container-ids #{}) (def log-container-ids #{})
(def updatable-attrs (->> (seq (keys ctk/sync-attrs)) (def updatable-attrs (->> (seq (keys ctk/sync-attrs))
;; We don't update the flex-child attrs ;; We don't update the flex-child attrs
(remove ctk/swap-keep-attrs) (remove ctk/swap-keep-attrs)
;; We don't do automatic update of the `layout-grid-cells` property. ;; We don't do automatic update of the `layout-grid-cells` property.
(remove #(= :layout-grid-cells %)))) (remove #(= :layout-grid-cells %))))
(defn enabled-shape? (defn enabled-shape?
@ -1898,10 +1898,10 @@
(gsh/absolute-move shape new-pos))) (gsh/absolute-move shape new-pos)))
(defn- switch-path-change-value (defn- switch-path-change-value
[prev-shape ;; The shape before the switch [prev-shape ; The shape before the switch
current-shape ;; The shape after the switch (a clean copy) current-shape ; The shape after the switch (a clean copy)
ref-shape ;; The referenced shape on the main component ref-shape ; The referenced shape on the main component
;; before the switch ; before the switch
attr] attr]
(let [old-width (-> ref-shape :selrect :width) (let [old-width (-> ref-shape :selrect :width)
new-width (-> prev-shape :selrect :width) new-width (-> prev-shape :selrect :width)
@ -1918,10 +1918,9 @@
(defn- switch-text-change-value (defn- switch-text-change-value
[prev-content ;; The :content of the text before the switch [prev-content ; The :content of the text before the switch
current-content ;; The :content of the text after the switch (a clean copy) current-content ; The :content of the text after the switch (a clean copy)
ref-content touched] ;; The :content of the referenced text on the main component ref-content touched] ; The :content of the referenced text on the main component before the switch
;; before the switch
(let [;; We need the differences between the contents on the main (let [;; We need the differences between the contents on the main
;; components. current-content is the content of a clean copy, ;; components. current-content is the content of a clean copy,
;; so for all effects its the same as the content on its main ;; so for all effects its the same as the content on its main
@ -2003,6 +2002,61 @@
:else :else
current-content))) current-content)))
(defn- switch-fixed-layout-geom-change-value
[prev-shape ; The shape before the switch
current-shape ; The shape after the switch (a clean copy)
attr]
;; When there is a layout with fixed h or v sizing, we need
;; to keep the width/height (and recalculate selrect and points)
(let [prev-width (-> prev-shape :selrect :width)
current-width (-> current-shape :selrect :width)
prev-height (-> prev-shape :selrect :height)
current-height (-> current-shape :selrect :height)
x (-> current-shape :selrect :x)
y (-> current-shape :selrect :y)
h-sizing (:layout-item-h-sizing prev-shape)
v-sizing (:layout-item-v-sizing prev-shape)
final-width (if (= :fix h-sizing)
current-width
prev-width)
final-height (if (= :fix v-sizing)
current-height
prev-height)
selrect (assoc (:selrect current-shape)
:width final-width
:height final-height
:x x
:y y
:x1 x
:y1 y
:x2 (+ x final-width)
:y2 (+ y final-height))]
(case attr
:width
final-width
:height
final-height
:selrect
selrect
:points
(-> selrect
(grc/rect->points)
(gsh/transform-points
(grc/rect->center selrect)
(or (:transform current-shape) (gmt/matrix)))))))
(defn update-attrs-on-switch (defn update-attrs-on-switch
"Copy attributes that have changed in the shape previous to the switch "Copy attributes that have changed in the shape previous to the switch
to the current shape (post switch). Used only on variants switch" to the current shape (post switch). Used only on variants switch"
@ -2017,7 +2071,9 @@
(let [;; We need to sync only the position relative to the origin of the component. (let [;; We need to sync only the position relative to the origin of the component.
;; (see update-attrs for a full explanation) ;; (see update-attrs for a full explanation)
previous-shape (reposition-shape previous-shape prev-root current-root) previous-shape (reposition-shape previous-shape prev-root current-root)
touched (get previous-shape :touched #{})] touched (get previous-shape :touched #{})
text-auto? (and (cfh/text-shape? current-shape)
(contains? #{:auto-height :auto-width} (:grow-type current-shape)))]
(loop [attrs updatable-attrs (loop [attrs updatable-attrs
roperations [{:type :set-touched :touched (:touched previous-shape)}] roperations [{:type :set-touched :touched (:touched previous-shape)}]
@ -2026,6 +2082,10 @@
(let [attr-group (get ctk/sync-attrs attr) (let [attr-group (get ctk/sync-attrs attr)
skip-operations? skip-operations?
(or (or
;; For auto text, avoid copying geometry-driven attrs on switch.
(and text-auto?
(contains? #{:points :selrect :width :height :position-data} attr))
;; If the attribute is not valid for the destiny, don't copy it ;; If the attribute is not valid for the destiny, don't copy it
(not (cts/is-allowed-switch-keep-attr? attr (:type current-shape))) (not (cts/is-allowed-switch-keep-attr? attr (:type current-shape)))
@ -2105,6 +2165,11 @@
origin-ref-shape origin-ref-shape
attr) attr)
(and (or (= :fix (:layout-item-h-sizing previous-shape))
(= :fix (:layout-item-v-sizing previous-shape)))
(contains? #{:points :selrect :width :height} attr))
(switch-fixed-layout-geom-change-value previous-shape current-shape attr)
:else :else
(get previous-shape attr))) (get previous-shape attr)))
@ -2497,7 +2562,7 @@
(-> changes (-> changes
(cls/generate-delete-shapes (cls/generate-delete-shapes
file page objects (d/ordered-set (:id shape)) file page objects (d/ordered-set (:id shape))
{:allow-altering-copies true :ignore-children-fn ignore-swapped-fn :ignore-mask true})) {:allow-altering-copies true :ignore-children-fn ignore-swapped-fn :ignore-mask true :ignore-flows-for #{(:id shape)}}))
[new-shape changes] [new-shape changes]
(-> changes (-> changes
(generate-new-shape-for-swap shape file page libraries id-new-component index target-cell keep-props-values))] (generate-new-shape-for-swap shape file page libraries id-new-component index target-cell keep-props-values))]
@ -2845,8 +2910,8 @@
duplicating-component? duplicating-component?
true true
(and remove-swap-slot? (and remove-swap-slot?
;; only remove swap slot of children when the current shape ;; only remove swap slot of children when the current shape
;; is not a subinstance head nor a instance root ;; is not a subinstance head nor a instance root
(not subinstance-head?) (not subinstance-head?)
(not instance-root?)) (not instance-root?))
variant-props)) variant-props))
@ -2902,7 +2967,7 @@
variant-props) variant-props)
changes)) changes))
;; We need to check the changes to get the ids-map ;; We need to check the changes to get the ids-map
ids-map ids-map
(into {} (into {}
(comp (comp

View File

@ -58,7 +58,7 @@
(cto/shape-attr->token-attrs attr changed-sub-attr))] (cto/shape-attr->token-attrs attr changed-sub-attr))]
(if (some #(contains? tokens %) token-attrs) (if (some #(contains? tokens %) token-attrs)
(pcb/update-shapes changes [shape-id] #(cto/unapply-token-id % token-attrs)) (pcb/update-shapes changes [shape-id] #(cto/unapply-tokens-from-shape % token-attrs))
changes))) changes)))
check-shape check-shape
@ -124,9 +124,11 @@
;; on the deletion process. It should receive a shape and ;; on the deletion process. It should receive a shape and
;; return a boolean ;; return a boolean
ignore-children-fn ignore-children-fn
ignore-mask] ignore-mask
ignore-flows-for]
:or {ignore-children-fn (constantly false) :or {ignore-children-fn (constantly false)
ignore-mask false}}] ignore-mask false
ignore-flows-for #{}}}]
(let [objects (pcb/get-objects changes) (let [objects (pcb/get-objects changes)
data (pcb/get-library-data changes) data (pcb/get-library-data changes)
page-id (pcb/get-page-id changes) page-id (pcb/get-page-id changes)
@ -136,12 +138,12 @@
ids (cfh/clean-loops objects ids) ids (cfh/clean-loops objects ids)
in-component-copy? in-component-copy?
(fn [shape-id] (fn [shape-id]
;; Look for shapes that are inside a component copy, but are ;; Look for shapes that are inside a component copy, but are
;; not the root. In this case, they must not be deleted, ;; not the root. In this case, they must not be deleted,
;; but hidden (to be able to recover them more easily). ;; but hidden (to be able to recover them more easily).
;; If we want to specifically allow altering the copies, this is ;; If we want to specifically allow altering the copies, this is
;; a special case, like a component swap, in which case we want ;; a special case, like a component swap, in which case we want
;; to delete the old shape ;; to delete the old shape
(let [shape (get objects shape-id)] (let [shape (get objects shape-id)]
(and (ctn/has-any-copy-parent? objects shape) (and (ctn/has-any-copy-parent? objects shape)
(not allow-altering-copies)))) (not allow-altering-copies))))
@ -166,9 +168,9 @@
groups-to-unmask groups-to-unmask
(when-not ignore-mask (when-not ignore-mask
(reduce (fn [group-ids id] (reduce (fn [group-ids id]
;; When the shape to delete is the mask of a masked group, ;; When the shape to delete is the mask of a masked group,
;; the mask condition must be removed, and it must be ;; the mask condition must be removed, and it must be
;; converted to a normal group. ;; converted to a normal group.
(let [obj (lookup id) (let [obj (lookup id)
parent (lookup (:parent-id obj))] parent (lookup (:parent-id obj))]
(if (and (:masked-group parent) (if (and (:masked-group parent)
@ -181,8 +183,8 @@
interacting-shapes interacting-shapes
(filter (fn [shape] (filter (fn [shape]
;; If any of the deleted shapes is the destination of ;; If any of the deleted shapes is the destination of
;; some interaction, this must be deleted, too. ;; some interaction, this must be deleted, too.
(let [interactions (:interactions shape)] (let [interactions (:interactions shape)]
(some #(and (ctsi/has-destination %) (some #(and (ctsi/has-destination %)
(contains? ids-to-delete (:destination %))) (contains? ids-to-delete (:destination %)))
@ -194,7 +196,8 @@
(->> (:flows page) (->> (:flows page)
(reduce (reduce
(fn [changes [id flow]] (fn [changes [id flow]]
(if (id-to-delete? (:starting-frame flow)) (if (and (id-to-delete? (:starting-frame flow))
(not (contains? ignore-flows-for (:starting-frame flow))))
(-> changes (-> changes
(pcb/with-page page) (pcb/with-page page)
(pcb/set-flow id nil)) (pcb/set-flow id nil))
@ -204,7 +207,7 @@
all-parents all-parents
(reduce (fn [res id] (reduce (fn [res id]
;; All parents of any deleted shape must be resized. ;; All parents of any deleted shape must be resized.
(into res (cfh/get-parent-ids objects id))) (into res (cfh/get-parent-ids objects id)))
(d/ordered-set) (d/ordered-set)
(concat ids-to-delete ids-to-hide)) (concat ids-to-delete ids-to-hide))
@ -236,10 +239,10 @@
(recursive-find-empty-parents parents)))) (recursive-find-empty-parents parents))))
empty-parents empty-parents
;; Any parent whose children are all deleted, must be deleted too. ;; Any parent whose children are all deleted, must be deleted too.
;; If we want to specifically allow altering the copies, this is a special case, ;; If we want to specifically allow altering the copies, this is a special case,
;; for example during a component swap. in this case we are replacing a shape by ;; for example during a component swap. in this case we are replacing a shape by
;; other one, so must not delete empty parents. ;; other one, so must not delete empty parents.
(if-not allow-altering-copies (if-not allow-altering-copies
(into (d/ordered-set) (find-all-empty-parents #{})) (into (d/ordered-set) (find-all-empty-parents #{}))
#{}) #{})
@ -271,8 +274,8 @@
guides-to-delete) guides-to-delete)
changes (reduce (fn [changes component-id] changes (reduce (fn [changes component-id]
;; It's important to delete the component before the main instance, because we ;; It's important to delete the component before the main instance, because we
;; need to store the instance position if we want to restore it later. ;; need to store the instance position if we want to restore it later.
(pcb/delete-component changes component-id (:id page))) (pcb/delete-component changes component-id (:id page)))
changes changes
components-to-delete) components-to-delete)
@ -324,7 +327,7 @@
result #{}] result #{}]
(if-not current-id (if-not current-id
;; Base case, no next element ;; Base case, no next element
result result
(let [group (get objects current-id)] (let [group (get objects current-id)]
@ -332,14 +335,14 @@
(not= current-id parent-id) (not= current-id parent-id)
(empty? (remove removed-id? (:shapes group)))) (empty? (remove removed-id? (:shapes group))))
;; Adds group to the remove and check its parent ;; Adds group to the remove and check its parent
(let [to-check (concat to-check [(cfh/get-parent-id objects current-id)])] (let [to-check (concat to-check [(cfh/get-parent-id objects current-id)])]
(recur (first to-check) (recur (first to-check)
(rest to-check) (rest to-check)
(conj removed-id? current-id) (conj removed-id? current-id)
(conj result current-id))) (conj result current-id)))
;; otherwise recur ;; otherwise recur
(recur (first to-check) (recur (first to-check)
(rest to-check) (rest to-check)
removed-id? removed-id?

View File

@ -111,7 +111,7 @@
"Check if any ancestor of a shape (between base-parent-id and shape) was swapped" "Check if any ancestor of a shape (between base-parent-id and shape) was swapped"
[shape objects base-parent-id] [shape objects base-parent-id]
(let [ancestors (->> (ctn/get-parent-heads objects shape) (let [ancestors (->> (ctn/get-parent-heads objects shape)
;; Ignore ancestors ahead of base-parent ;; Ignore ancestors ahead of base-parent
(drop-while #(not= base-parent-id (:id %))) (drop-while #(not= base-parent-id (:id %)))
seq) seq)
num-ancestors (count ancestors) num-ancestors (count ancestors)

View File

@ -132,3 +132,94 @@ Some naming conventions:
(if-let [last-period (str/last-index-of s ".")] (if-let [last-period (str/last-index-of s ".")]
[(subs s 0 (inc last-period)) (subs s (inc last-period))] [(subs s 0 (inc last-period)) (subs s (inc last-period))]
[s ""])) [s ""]))
;; Tree building functions --------------------------------------------------
"Build tree structure from flat list of paths"
"`build-tree-root` is the main function to build the tree."
"Receives a list of segments with 'name' properties representing paths,
and a separator string."
"E.g segments = [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}]"
"Transforms into a tree structure like:
[{:name 'one'
:path 'one'
:depth 0
:leaf nil
:children-fn (fn [] [{:name 'two'
:path 'one.two'
:depth 1
:leaf nil
:children-fn (fn [] [{... :name 'three'} {... :name 'four'}])}
{:name 'five'
:path 'one.five'
:depth 1
:leaf {... :name 'five'}
...}])}]"
(defn- sort-by-children
"Sorts segments so that those with children come first."
[segments separator]
(sort-by (fn [segment]
(let [path (split-path (:name segment) :separator separator)
path-length (count path)]
(if (= path-length 1)
1
0)))
segments))
(defn- group-by-first-segment
"Groups segments by their first path segment and update segment name."
[segments separator]
(reduce (fn [acc segment]
(let [[first-segment & remaining-segments] (split-path (:name segment) :separator separator)
rest-path (when (seq remaining-segments) (join-path remaining-segments :separator separator :with-spaces? false))]
(update acc first-segment (fnil conj [])
(if rest-path
(assoc segment :name rest-path)
segment))))
{}
segments))
(defn- sort-and-group-segments
"Sorts elements and groups them by their first path segment."
[segments separator]
(let [sorted (sort-by-children segments separator)
grouped (group-by-first-segment sorted separator)]
(into (sorted-map) grouped)))
(defn- build-tree-node
"Builds a single tree node with lazy children."
[segment-name remaining-segments separator parent-path depth]
(let [current-path (if parent-path
(str parent-path "." segment-name)
segment-name)
is-leaf? (and (seq remaining-segments)
(every? (fn [segment]
(let [remaining-segment-name (first (split-path (:name segment) :separator separator))]
(= segment-name remaining-segment-name)))
remaining-segments))
leaf-segment (when is-leaf? (first remaining-segments))
node {:name segment-name
:path current-path
:depth depth
:leaf leaf-segment
:children-fn (when-not is-leaf?
(fn []
(let [grouped-elements (sort-and-group-segments remaining-segments separator)]
(mapv (fn [[child-segment-name remaining-child-segments]]
(build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth)))
grouped-elements))))}]
node))
(defn build-tree-root
"Builds the root level of the tree."
[segments separator]
(let [grouped-elements (sort-and-group-segments segments separator)]
(mapv (fn [[segment-name remaining-segments]]
(build-tree-node segment-name remaining-segments separator nil 0))
grouped-elements)))

View File

@ -11,6 +11,7 @@
#?(:clj [malli.dev.pretty :as mdp]) #?(:clj [malli.dev.pretty :as mdp])
#?(:clj [malli.dev.virhe :as v]) #?(:clj [malli.dev.virhe :as v])
[app.common.data :as d] [app.common.data :as d]
[app.common.json :as json]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.schema.generators :as sg] [app.common.schema.generators :as sg]
@ -92,6 +93,31 @@
[& items] [& items]
(apply mu/merge (map schema items))) (apply mu/merge (map schema items)))
(defn assoc-key
"Add a key & value to a schema of type [:map]. If the first level node of the schema
is not a map, will do a depth search to find the first map node and add the key there."
([s k v]
(assoc-key s k {} v))
([s k opts v] ;; change order of opts and v to match static schema defintions (e.g. [:something {:optional true} ::sm/integer])
(let [s (schema s)
v (schema v)]
(if (= (m/type s) :map)
(mu/assoc s k v opts)
(if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))]
(mu/assoc-in s (conj path k) v opts)
s)))))
(defn dissoc-key
"Remove a key from a schema of type [:map]. If the first level node of the schema
is not a map, will do a depth search to find the first map node and remove the key there."
[s k]
(let [s (schema s)]
(if (= (m/type s) :map)
(mu/dissoc s k)
(if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))]
(mu/update-in s path mu/dissoc k)
s))))
(defn ref? (defn ref?
[s] [s]
(m/-ref-schema? s)) (m/-ref-schema? s))
@ -270,6 +296,13 @@
(let [explain (fn [] (me/with-error-messages explain))] (let [explain (fn [] (me/with-error-messages explain))]
((mdp/prettifier variant message explain default-options))))) ((mdp/prettifier variant message explain default-options)))))
(defn validation-errors
"Checks a value against a schema. If valid, returns nil. If not, returns a list
of english error messages."
[value schema]
(let [explainer (explainer schema)]
(-> value explainer simplify not-empty)))
(defmacro ignoring (defmacro ignoring
[expr] [expr]
(if (:ns &env) (if (:ns &env)
@ -850,6 +883,32 @@
:encode/string str :encode/string str
::oapi/type "boolean"}}) ::oapi/type "boolean"}})
(defn parse-keyword
[v]
(if (string? v)
(-> v (json/read-kebab-key) (keyword))
v))
(defn format-keyword
[v]
(if (keyword? v)
(-> v (name) (json/write-camel-key))
v))
(register!
{:type ::keyword
:pred keyword?
:type-properties
{:title "keyword"
:description "keyword"
:error/message "expected keyword"
:error/code "errors.invalid-keyword"
:gen/gen sg/keyword
:decode/string parse-keyword
:decode/json parse-keyword
:encode/string format-keyword
::oapi/type "string"}})
(register! (register!
{:type ::contains-any {:type ::contains-any
:min 1 :min 1

View File

@ -222,7 +222,7 @@
:else :else
(cons [node-style (dm/str head-text "" (:text node))] (rest acc))) (cons [node-style (dm/str head-text "" (:text node))] (rest acc)))
;; We add an end-of-line when finish a paragraph ;; We add an end-of-line when finish a paragraph
new-acc new-acc
(if (= (:type node) "paragraph") (if (= (:type node) "paragraph")
(let [[hs ht] (first new-acc)] (let [[hs ht] (first new-acc)]

View File

@ -64,8 +64,33 @@
java.time.temporal.TemporalAmount java.time.temporal.TemporalAmount
java.time.temporal.TemporalUnit))) java.time.temporal.TemporalUnit)))
(declare inst)
#?(:clj (def ^:dynamic *clock* (Clock/systemDefaultZone))) #?(:clj (def ^:dynamic *clock* (Clock/systemDefaultZone)))
#?(:clj
(defn clock?
[o]
(instance? Clock o)))
#?(:clj
(defn get-system-clock
[]
(Clock/systemDefaultZone)))
#?(:clj
(defn offset-clock
[offset]
(Clock/offset ^Clock (Clock/systemDefaultZone) ^Duration offset)))
#?(:clj
(defn fixed-clock
[instant]
(Clock/fixed ^Instant (inst instant)
^ZoneId (ZoneId/of "Z"))))
(defn now (defn now
[] []
#?(:clj (Instant/now *clock*) #?(:clj (Instant/now *clock*)

View File

@ -140,7 +140,8 @@
:layout-item-min-w :layout-item-min-w
:layout-item-absolute :layout-item-absolute
:layout-item-z-index :layout-item-z-index
:layout-item-align-self}) :layout-item-align-self
:interactions})
(defn component-attr? (defn component-attr?
"Check if some attribute is one that is involved in component syncrhonization. "Check if some attribute is one that is involved in component syncrhonization.

View File

@ -458,13 +458,13 @@
(map #(cfh/components-nesting-loop? objects (:id %) (:id parent))) (map #(cfh/components-nesting-loop? objects (:id %) (:id parent)))
(every? nil?)))] (every? nil?)))]
(or (or
;;We don't want to change the structure of component copies ;;We don't want to change the structure of component copies
(ctk/in-component-copy? parent) (ctk/in-component-copy? parent)
(has-any-copy-parent? objects parent) (has-any-copy-parent? objects parent)
;; If we are moving something containing a main instance the container can't be part of a component (neither main nor copy) ;; If we are moving something containing a main instance the container can't be part of a component (neither main nor copy)
(and selected-main-instance? parent-in-component?) (and selected-main-instance? parent-in-component?)
;; Avoid placing a shape as a direct or indirect child of itself, ;; Avoid placing a shape as a direct or indirect child of itself,
;; or inside its main component if it's in a copy. ;; or inside its main component if it's in a copy.
comps-nesting-loop?))) comps-nesting-loop?)))
(defn find-valid-parent-and-frame-ids (defn find-valid-parent-and-frame-ids

View File

@ -775,9 +775,9 @@
file-data (cond-> file-data file-data (cond-> file-data
(d/not-empty? used-components) (d/not-empty? used-components)
(absorb-components used-components library-data)) (absorb-components used-components library-data))
;; Note that absorbed components may also be using colors ;; Note that absorbed components may also be using colors
;; and typographies. This is the reason of doing this first ;; and typographies. This is the reason of doing this first
;; and accumulating file data for the next ones. ;; and accumulating file data for the next ones.
used-colors (find-asset-type-usages file-data library-data :color) used-colors (find-asset-type-usages file-data library-data :color)
file-data (cond-> file-data file-data (cond-> file-data
@ -1017,7 +1017,7 @@
libs-to-show libs-to-show
(-> libs-to-show (-> libs-to-show
(add-component library-id component-id)))))) (add-component library-id component-id))))))
;; (find-used-components-cumulative page root) ;; (find-used-components-cumulative page root)
libs-to-show libs-to-show
components)) components))

View File

@ -306,7 +306,7 @@
(-write-to [_ heap offset] (-write-to [_ heap offset]
(let [buffer' (.-buffer ^js/DataView dbuffer) (let [buffer' (.-buffer ^js/DataView dbuffer)
;; Calculate byte size: 4 bytes header + (size * FILL-U8-SIZE) ;; Calculate byte size: 4 bytes header + (size * FILL-U8-SIZE)
byte-size (+ 4 (* size FILL-U8-SIZE)) byte-size (+ 4 (* size FILL-U8-SIZE))
;; Create Uint32Array with exact size needed (convert bytes to u32 elements) ;; Create Uint32Array with exact size needed (convert bytes to u32 elements)
u32-array (js/Uint32Array. buffer' 0 (/ byte-size 4))] u32-array (js/Uint32Array. buffer' 0 (/ byte-size 4))]

View File

@ -21,3 +21,10 @@
;; Only present on resolved profile objects, the resolve process ;; Only present on resolved profile objects, the resolve process
;; takes the photo-id or geneates an image from the name ;; takes the photo-id or geneates an image from the name
[:photo-url {:optional true} :string]]) [:photo-url {:optional true} :string]])
(def schema:basic-profile
[:map {:title "Basic profile"}
[:id ::sm/uuid]
[:name {:optional true} :string]
[:email {:optional true} :string]])

View File

@ -13,14 +13,15 @@
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.schema.generators :as sg])) [app.common.schema.generators :as sg]))
;; WARNING: options are not deleted when changing event or action type, so it can be ;; WARNING: options are not deleted when changing event or action
;; restored if the user changes it back later. ;; type, so it can be restored if the user changes it back later.
;; ;;
;; But that means that an interaction may have for example a delay or ;; But that means that an interaction may have for example a delay or
;; destination, even if its type does not require it (but a previous type did). ;; destination, even if its type does not require it (but a previous
;; type did).
;; ;;
;; So make sure to use has-delay/has-destination... functions, or similar, ;; So make sure to use has-delay/has-destination... functions, or
;; before reading them. ;; similar, before reading them.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA ;; SCHEMA
@ -452,16 +453,15 @@
(gpt/point 0 0))) (gpt/point 0 0)))
(defn calc-overlay-position (defn calc-overlay-position
[interaction ;; interaction data [interaction ; interaction data
shape ;; Shape with the interaction shape ; Shape with the interaction
objects ;; the objects tree objects ; the objects tree
relative-to-shape ;; the interaction position is realtive to this relative-to-shape ; the interaction position is realtive to this shape
;; sape base-frame ; the base frame of the current interaction
base-frame ;; the base frame of the current interaction dest-frame ; the frame to display with this interaction
dest-frame ;; the frame to display with this interaction frame-offset] ; if this interaction starts in a frame opened
frame-offset] ;; if this interaction starts in a frame opened ; on another interaction, this is the position
;; on another interaction, this is the position ; of that frame
;; of that frame
(assert (check-interaction interaction)) (assert (check-interaction interaction))
(assert (has-overlay-opts interaction) (assert (has-overlay-opts interaction)
"expected compatible interaction map") "expected compatible interaction map")

View File

@ -382,9 +382,9 @@
keep-ids? (:id shape) keep-ids? (:id shape)
:else (uuid/next)) :else (uuid/next))
;; Assign the correct frame-id for the given parent. It's the parent-id (if parent is frame) ;; Assign the correct frame-id for the given parent. It's the parent-id (if parent is frame)
;; or the parent's frame-id otherwise. Only for the first cloned shapes. In recursive calls ;; or the parent's frame-id otherwise. Only for the first cloned shapes. In recursive calls
;; this is not needed. ;; this is not needed.
frame-id (cond frame-id (cond
(and (nil? frame-id) (cfh/frame-shape? dest-objects parent-id)) (and (nil? frame-id) (cfh/frame-shape? dest-objects parent-id))
parent-id parent-id

Some files were not shown because too many files have changed in this diff Show More