Compare commits

...

730 Commits

Author SHA1 Message Date
Andrey Antukh
69c880d00e 🐛 Fix importmap usage on firefox 2025-12-23 13:10:58 +01:00
Andrey Antukh
9eebc467ef Preload default translations 2025-12-23 13:10:58 +01:00
Andrey Antukh
b77712ce73 Move frontend/vendor to frontend/packages 2025-12-23 13:10:58 +01:00
Andrey Antukh
3d3e81f314 Replace tubax with more modern tooling 2025-12-23 13:10:58 +01:00
Andrey Antukh
fe6441bb24 Replace hightlight.js internal bundle with direct npm use 2025-12-23 13:10:58 +01:00
Andrey Antukh
e15f0baf30 Replace direct draft-js usage with internal module 2025-12-23 13:10:58 +01:00
Andrey Antukh
c040cbb784 🔥 Remove old gulp related dependencies 2025-12-23 13:10:58 +01:00
Andrey Antukh
7f674b78a9 📎 Move all deps to dev-dependencies on frontend package.json
All they only needed for build process.
2025-12-23 13:10:58 +01:00
Andrey Antukh
099b78affd 📎 Update frontend yarn.lock file 2025-12-23 13:10:58 +01:00
Andrey Antukh
78cc3f0aa4 📎 Add immutable dependency to vendor/draft-js 2025-12-23 13:10:58 +01:00
Andrey Antukh
76f5f12808 ⬆️ Update dependencies on exporter 2025-12-23 13:10:58 +01:00
Andrey Antukh
047483a70a 🐛 Fix deleted files thumbnails generation 2025-12-22 20:20:43 +01:00
Andrey Antukh
8cb2f27de8 ♻️ Move file permissions to binfile common ns 2025-12-22 20:16:41 +01:00
Andrey Antukh
0433336fc9 📎 Use correct criterium version on frontend deps 2025-12-22 20:16:41 +01:00
Andrey Antukh
ce234fbeda Allow get thumbnails for deleted files 2025-12-22 20:16:41 +01:00
Andrey Antukh
fc4d31eed7 Add minor efficiency improvements to deleted dashboard page 2025-12-22 20:16:41 +01:00
María Valderrama
c670aac339 🎉 Added deleted files to dashboard 2025-12-22 20:16:41 +01:00
Andrés Moya
1d3fb5434f Enable shadow tokens by default (#7996) 2025-12-22 18:17:29 +01:00
Andrey Antukh
f478399ae0 Merge remote-tracking branch 'origin/staging-render' into develop 2025-12-22 17:28:18 +01:00
Andrey Antukh
6a1854f180 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-22 17:28:01 +01:00
Andrés Moya
0858e297e5 🎉 Add composite tokens to plugins API (#7992) 2025-12-22 17:14:54 +01:00
Alejandro Alonso
5780a43fe0 🐛 Fix object added in different page (#7988) 2025-12-22 16:59:47 +01:00
Alejandro Alonso
737eceda3a 🐛 Fix unmasking shapes (#7989) 2025-12-22 16:59:04 +01:00
Alonso Torres
923c3c2dbd 🐛 Fix font weight token (#7991) 2025-12-22 16:58:26 +01:00
Alejandro Alonso
a14b4561e7 🐛 Fix comment bubbles (#7990) 2025-12-22 16:57:45 +01:00
Andrey Antukh
bb5568e15a 🎉 Enable hindi translations on the application 2025-12-22 16:57:00 +01:00
Pablo Alba
5cbcec3db6 🐛 Fix "maximum call stack size exceeded" crash on variant 2025-12-22 16:57:00 +01:00
Anonymous
105e1fe86c 🌐 Add translations for: Spanish
Currently translated at 97.1% (1940 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es/
2025-12-22 16:34:43 +01:00
Yaron Shahrabani
3e0a916883 🌐 Add translations for: Hebrew
Currently translated at 99.3% (1985 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/he/
2025-12-22 16:34:42 +01:00
Ahmad HosseinBor
4f80238bc2 🌐 Add translations for: Persian
Currently translated at 39.2% (783 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fa/
2025-12-22 16:34:42 +01:00
Alejandro Alonso
5156cc5d9a 🌐 Add translations for: Yoruba
Currently translated at 58.8% (1176 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/yo/
2025-12-22 16:34:42 +01:00
Yessenia Villarte Vaca
42c46b6cfc 🌐 Add translations for: Spanish (Latin America)
Currently translated at 6.5% (131 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es_419/
2025-12-22 16:34:41 +01:00
VKing9
8b3c40b35e 🌐 Add translations for: Hindi
Currently translated at 99.6% (1991 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/hi/
2025-12-22 16:34:41 +01:00
Andy Li
d3996e5fb1 🌐 Add translations for: Chinese (Traditional Han script)
Currently translated at 80.1% (1601 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hant/
2025-12-22 16:34:40 +01:00
Anonymous
0c42bca866 🌐 Add translations for: German
Currently translated at 95.5% (1908 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-22 16:34:40 +01:00
Marius
e5685c1f1c 🌐 Add translations for: German
Currently translated at 95.5% (1908 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-22 16:34:40 +01:00
Anonymous
2784209bde 🌐 Add translations for: Turkish
Currently translated at 99.5% (1989 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2025-12-22 16:34:40 +01:00
Alejandro Alonso
024f460e99 🌐 Add translations for: Igbo
Currently translated at 25.6% (512 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ig/
2025-12-22 16:34:39 +01:00
Anonymous
1d9b76b62a 🌐 Add translations for: Romanian
Currently translated at 97.2% (1942 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ro/
2025-12-22 16:34:39 +01:00
Shuaib Zahda
7e17a75b7d 🌐 Add translations for: Arabic
Currently translated at 56.4% (1127 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ar/
2025-12-22 16:34:39 +01:00
Anonymous
ca093d6fae 🌐 Add translations for: French
Currently translated at 96.8% (1934 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-22 16:34:38 +01:00
Alexandre Pawlak
0f0b7562b5 🌐 Add translations for: French
Currently translated at 96.8% (1934 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-22 16:34:38 +01:00
Anonymous
9cdc694697 🌐 Add translations for: Chinese (Simplified Han script)
Currently translated at 90.3% (1804 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hans/
2025-12-22 16:34:37 +01:00
Dário
b972a4033b 🌐 Add translations for: Chinese (Simplified Han script)
Currently translated at 90.3% (1804 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hans/
2025-12-22 16:34:37 +01:00
Anonymous
cbe9f4da51 🌐 Add translations for: Swedish
Currently translated at 99.3% (1985 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2025-12-22 16:34:37 +01:00
Anonymous
c583bde9e3 🌐 Add translations for: Russian
Currently translated at 76.8% (1534 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2025-12-22 16:34:37 +01:00
Vint Prox
3911ebdc4e 🌐 Add translations for: Russian
Currently translated at 76.8% (1534 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2025-12-22 16:34:36 +01:00
Anonymous
3e3b18667b 🌐 Add translations for: Portuguese (Brazil)
Currently translated at 69.9% (1396 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt_BR/
2025-12-22 16:34:36 +01:00
Anonymous
ed81c9b8df 🌐 Add translations for: Dutch
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-12-22 16:34:36 +01:00
Sebastiaan Pasma
fbdf98d29c 🌐 Add translations for: Dutch
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-12-22 16:34:36 +01:00
Anonymous
e603825a55 🌐 Add translations for: Italian
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-12-22 16:34:35 +01:00
Valentina Chapellu
1d724783e6 🌐 Add translations for: Italian
Currently translated at 99.5% (1988 of 1997 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-12-22 16:34:35 +01:00
Hosted Weblate
e0abe7dcb5 🌐 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/
2025-12-22 16:33:05 +01:00
Andrey Antukh
5c1bbf5be8 Merge remote-tracking branch 'weblate/develop' into develop 2025-12-22 16:32:30 +01:00
Pablo Alba
bbb0d58190 🐛 Fix "maximum call stack size exceeded" crash on variant 2025-12-22 16:27:10 +01:00
Andrey Antukh
88dcf9d1fe 🐛 Mark rpc calls as authenticated when shared key is used (#7901) 2025-12-22 12:18:36 +01:00
Alejandro Alonso
fe44c14bac Merge pull request #7982 from penpot/niwinz-staging-import-bucket
🐛 Prefill storage object bucket if it comes nil on import binfile
2025-12-22 12:17:16 +01:00
Belén Albeza
20061067ad 🐛 Fix text editor not getting focus back after font variant change 2025-12-22 11:18:25 +01:00
Andrey Antukh
336173645e 🐛 Fix regression on export shape on plungins API 2025-12-22 10:41:42 +01:00
Andrey Antukh
2acf15958b Merge branch 'staging-render' into develop 2025-12-22 09:24:04 +01:00
Andrey Antukh
08267de242 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-22 09:23:48 +01:00
Pablo Alba
35fb376a78 Add proxypass to caddyfile on devenv (#7985) 2025-12-22 09:21:22 +01:00
Dalai Felinto
13fcf3a9bb 💄 Set import Tokens default option to be Single JSON value (#7918)
This patches makes the default Tokens importing option to match the
current default Tokens exporting option (single JSON value). This way it
is more obvious and quick to export the tokens from a file and import
in new one,

---

While testing our design system we are often re-exporting and
re-importing the Tokens to the files using the design system components.

I'm aware that this may be addressed in the future so the Tokens are
brought in together with the library. Meanwhile (and even in the future)
I think it is sensible to have a symmetry between the export and import
defeault options.

Co-authored-by: Dalai Felinto <dalai@blender.org>
2025-12-19 10:44:05 +01:00
Andrey Antukh
83bb4bf221 🐛 Prefill storage object bucket if it comes nil on import binfile 2025-12-19 09:32:51 +01:00
Henrik Steffens
dba6ae2820 🌐 Add translations for: German
Currently translated at 95.8% (1911 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-17 13:00:25 +01:00
Marius
ada101c236 🌐 Add translations for: German
Currently translated at 95.8% (1911 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-17 13:00:24 +01:00
Marius
ea48fb5825 🌐 Add translations for: German
Currently translated at 90.4% (1804 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-12-16 11:00:25 +01:00
Alejandro Alonso
15ed25ca79 Merge pull request #7966 from penpot/niwinz-staging-abrreviate
🐛 Fix incorrect string truncation with abbreviate template filter
2025-12-12 13:53:33 +01:00
Andrey Antukh
9aa387a473 🐛 Fix incorrect string truncation with abbreviate template filter 2025-12-12 13:50:46 +01:00
Alejandro Alonso
67ba91b4b9 Merge pull request #7971 from penpot/niwinz-staging-bugfix-6
🐛 Fix tokens-lib encoding when value is nilable
2025-12-12 13:46:06 +01:00
Alejandro Alonso
f67f1a6a0e Merge pull request #7972 from penpot/niwinz-staging-bugfix-7
🐛 Fix exception on assinging gradient to shadow on multiple selection
2025-12-12 13:42:39 +01:00
Alejandro Alonso
82d3e2024e Merge pull request #7973 from penpot/niwinz-staging-worker-scheduler
🐛 Fix incorrect redis connection error handling
2025-12-12 13:23:49 +01:00
Alejandro Alonso
4bd846c16d Merge pull request #7969 from penpot/niwinz-staging-fix-ratelimit
🐛 Fix issue on reading rlimit config
2025-12-12 13:22:53 +01:00
alonso.torres
8fde6b28ed 🐛 Fix problems with alignments and margins 2025-12-12 13:21:04 +01:00
alonso.torres
63325ec796 🐛 Fix problem with flex fill size distribution 2025-12-12 13:21:04 +01:00
alonso.torres
84415476d0 🐛 Fix problem with reflow layout 2025-12-12 13:21:04 +01:00
Andrey Antukh
94f95ca6b8 🐛 Fix incorrect redis connection error handling 2025-12-12 12:33:38 +01:00
Andrey Antukh
33c786498d Merge remote-tracking branch 'origin/staging-render' into develop 2025-12-12 12:19:49 +01:00
Andrey Antukh
1f886b1f88 Merge remote-tracking branch 'origin/staging' into develop 2025-12-12 12:16:41 +01:00
Aitor Moreno
5a922c6bd6 Merge pull request #7960 from penpot/superalex-fix-too-many-active-webgl-contexts
🐛 Fix too many active WEBGL contexts
2025-12-12 12:03:46 +01:00
Andrey Antukh
507bf7445b 🐛 Fix tokens-lib encoding when value is nilable 2025-12-12 11:42:15 +01:00
Andrey Antukh
81b72c5acd 🐛 Fix exception on assinging gradient to shadow on multiple selection 2025-12-12 11:24:53 +01:00
Alejandro Alonso
1388865cfc 🐛 Fix too many active WEBGL contexts 2025-12-12 11:16:47 +01:00
Andrey Antukh
1738847694 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-12 10:20:21 +01:00
Aitor Moreno
ca1c3c799d Merge pull request #7968 from penpot/alotor-fix-border-radius
🐛 Fix problem with border radius to path
2025-12-12 10:18:07 +01:00
Andrey Antukh
974495e08f Reduce log level for profile picture download error
Because it is not blocking operation and does not provents user
to proceed.
2025-12-12 08:17:13 +01:00
Andrey Antukh
2ed39e43c3 🐛 Fix issue on reading rlimit config 2025-12-11 23:50:01 +01:00
alonso.torres
ce5006ae84 🐛 Fix problem with border radius to path 2025-12-11 22:40:44 +01:00
Eva Marco
50dbe6ab12 🐛 Fix horizontal scroll on layer panel (#7956) 2025-12-11 21:34:18 +01:00
Belén Albeza
0a7a65af5d ♻️ Make SerializableResult to depend on From traits 2025-12-11 16:00:03 +01:00
alonso.torres
ea4d0e1238 Calculate position data in wasm 2025-12-11 16:00:03 +01:00
Elena Torro
b705cf953a 🐛 Set layout data from set-object 2025-12-11 14:52:32 +01:00
Alejandro Alonso
90ce1f56e7 Merge pull request #7958 from penpot/superalex-fix-svg-extract-ids
🐛 Fix svg extract ids
2025-12-11 14:02:05 +01:00
Alejandro Alonso
ab0438cc6f 🐛 Fix svg extract ids 2025-12-11 13:47:00 +01:00
Aitor Moreno
c6aa9cc4b7 Merge pull request #7950 from penpot/ladybenko-12851-fix-text-selection
🐛 Fix text selection when editor regains focus
2025-12-11 13:45:29 +01:00
Andrey Antukh
5779adef33 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-11 13:30:59 +01:00
Andrey Antukh
2f46cbc0d4 Make render wasm import on worker http cache aware 2025-12-11 13:27:20 +01:00
Elena Torró
ebf1758958 Merge pull request #7935 from penpot/superalex-improve-svg-import
🎉 Improve svg import
2025-12-11 13:21:29 +01:00
Elena Torró
e94c56bfa7 Merge pull request #7954 from penpot/azazeln28-fix-font-weight-mixed-value
🐛 Fix font weight mixed value
2025-12-11 12:43:53 +01:00
Andrey Antukh
53be6f996b 🐛 Fix issues on build processs related to render-wasm 2025-12-11 12:41:19 +01:00
Alejandro Alonso
89d9591011 🎉 Improve svg import 2025-12-11 12:02:34 +01:00
Andrey Antukh
5a260294a1 🔧 Update build-tag.yml github workflow 2025-12-11 12:00:42 +01:00
Andrey Antukh
3becfcd723 🔧 Update build-tag.yml github workflow 2025-12-11 11:59:16 +01:00
Andrey Antukh
3f6e44316e 🐛 Add missing node depes install on render-wasm 2025-12-11 11:51:47 +01:00
Aitor Moreno
5501a2815f 🐛 Fix font-variant-id mixed value 2025-12-11 11:32:27 +01:00
Eva Marco
77ef8e6fe6 🐛 Fix scroll on move library modal (#7952) 2025-12-11 10:46:54 +01:00
Alejandro Alonso
1066438b02 Merge pull request #7922 from penpot/elenatorro-12855-improve-pan-rendering
🔧 Improve pan rendering
2025-12-10 15:58:59 +01:00
Alejandro Alonso
3b23a3ad19 Merge pull request #7947 from penpot/elenatorro-12880-fix-variant-ui
🔧 Support variants interactivity on the new render's UI
2025-12-10 15:27:48 +01:00
Andrey Antukh
7396f4bfb6 Merge remote-tracking branch 'origin/staging' into develop 2025-12-10 15:17:50 +01:00
Alejandro Alonso
916b7709dc Update Pencil Penpot Design System System template in carousel (#7948) 2025-12-10 15:09:28 +01:00
Belén Albeza
5cf51f3d26 🐛 Fix text selection not being restore if it was only 1 word 2025-12-10 15:05:13 +01:00
Belén Albeza
25acad5154 🔧 Add formatting rules to the TextEditor 2025-12-10 15:04:34 +01:00
Elena Torro
0a212b6291 🔧 Support variants interactivity on the new render's UI 2025-12-10 14:39:59 +01:00
Eva Marco
443e41fea4 🐛 Fix multiple selection with color tokens (#7941) 2025-12-10 14:36:08 +01:00
Alejandro Alonso
c7c9b04095 Merge pull request #7944 from penpot/niwinz-staging-exporter-fix
🐛 Fix incorrect resource lifetime handling on exporter
2025-12-10 14:35:20 +01:00
Eva Marco
c61a0c0332 📚 Add line to changelog (#7945) 2025-12-10 13:58:18 +01:00
Andrey Antukh
eb1eeb4750 Merge remote-tracking branch 'origin/staging-render' into niwinz-develop-merge 2025-12-10 13:53:15 +01:00
Andrey Antukh
a78477592b Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-10 13:36:23 +01:00
Eva Marco
8707ff6511 🎉 Add spanish translation 2025-12-10 13:12:30 +01:00
Florian Schroedl
3d8a251741 🐛 Disallow font-family referencing composite token 2025-12-10 13:12:30 +01:00
Andrey Antukh
34e84ee3c8 🐛 Fix incorrect resource lifetime handling on exporter 2025-12-10 13:02:31 +01:00
Xaviju
0956b66281 💄 Group tokens by name path (#7775)
* 💄 Group tokens by name path
2025-12-10 12:34:19 +01:00
Luis de Dios
007b3f11f9 🐛 Fix pass new icons to radio buttons (#7939) 2025-12-10 12:28:27 +01:00
Alejandro Alonso
e8201402a7 Merge pull request #7938 from penpot/niwinz-staging-bugfix-5
🐛 Fix several issues
2025-12-10 12:05:42 +01:00
Aitor Moreno
8a22477b96 Merge pull request #7932 from penpot/niwinz-staging-worker-wasm-load
🐛 Fix WASM loading strategy on worker
2025-12-10 11:47:31 +01:00
Elena Torro
a661b2564f 🐛 Fix default case on vertical align 2025-12-10 10:59:27 +01:00
Elena Torro
2c3732f3f4 🔧 Fix line height calculation 2025-12-10 10:59:27 +01:00
Andrey Antukh
e16645227b Merge branch 'staging-render' into develop 2025-12-10 10:10:44 +01:00
Andrey Antukh
45665a3c21 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-10 10:09:58 +01:00
Alejandro Alonso
3e684ea54f ⬆️ Update svgo dependency on frontend (#7936) 2025-12-10 10:07:02 +01:00
Eva Marco
179e6a195d 🎉 Add test for token creation (#7915) 2025-12-10 09:56:21 +01:00
Andrey Antukh
98039f13d8 🐛 Fix main toolbar z-index 2025-12-10 09:47:40 +01:00
Alejandro Alonso
40c27591f6 🐛 Fix svg import (#7925) 2025-12-10 08:36:54 +01:00
Andrey Antukh
91d20a46d1 💄 Add cosmetic changes to exports assets progress component 2025-12-10 08:23:05 +01:00
Andrey Antukh
50bead7c56 🐛 Fix react warning on having p inside p on assets export progress 2025-12-10 08:22:41 +01:00
Andrey Antukh
b75b999903 📎 Fix devenv jvm warning 2025-12-10 08:22:05 +01:00
Andrey Antukh
810f1721c8 🐛 Fix recursion render on subscription modal 2025-12-10 07:54:52 +01:00
Stephan Paternotte
b45bdd723f 🌐 Add translations for: Dutch
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-12-09 22:00:21 +00:00
Ingrid Pigueron
8696044620 🌐 Add translations for: French
Currently translated at 97.1% (1937 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-09 22:00:19 +00:00
Andrey Antukh
8a8f360c7f Merge remote-tracking branch 'origin/staging' into develop 2025-12-09 19:53:38 +01:00
Andrey Antukh
a4646373cf ♻️ Refactor wasm loading strategy on worker 2025-12-09 19:41:19 +01:00
Andrey Antukh
f111cbb2a4 Add better cache config on devenv nginx 2025-12-09 19:38:30 +01:00
Luis de Dios
e35fc85c3d 🎉 Create new empty-state component (#7903) 2025-12-09 16:48:12 +01:00
Aitor Moreno
a614207f7e 🐛 Fix exporter failing with HTTPS 2025-12-09 16:08:20 +01:00
Elena Torro
81bc1bb0af 🔧 Log performance when building using profile-macros 2025-12-09 15:25:13 +01:00
Yamila Moreno
1798461d21 🐳 Add override for assets (#7926) 2025-12-09 14:55:21 +01:00
Luis de Dios
6ce3249c6d 🐛 Fix color format does not switch in the view mode (#7923)
* 🐛 Fix color format does not switch in the inspect mode of the view mode

* ♻️ Update components
2025-12-09 14:38:15 +01:00
Yamila Moreno
dde0fddd6f 🐳 Add missing override to Dockerfile.frontend (#7920) 2025-12-09 12:08:46 +01:00
Aitor Moreno
7d36bc4025 Merge pull request #7907 from penpot/alotor-fix-export-text
🐛 Fix problem when exporting texts
2025-12-09 11:28:47 +01:00
Elena Torro
b8feb6374d 🔧 Rebuild indices on zoom change, not pan 2025-12-09 11:26:03 +01:00
Elena Torro
0889df8e08 🔧 Skip slow operations on fast render 2025-12-09 11:26:03 +01:00
Andrey Antukh
4637aced8c Add support auto decoding and validation syntax for obj/reify 2025-12-09 11:13:06 +01:00
Andrey Antukh
9dfe5b0865 🐛 Fix inconsistencies on using obj/reify on plugins 2025-12-09 11:13:06 +01:00
Andrey Antukh
33bcc9544a Update frontend repl script 2025-12-09 11:13:06 +01:00
Andrey Antukh
babd481b7f Make sm/coercer lazy 2025-12-09 11:13:06 +01:00
Andrey Antukh
a9733c792d Make check-fn completly lazy 2025-12-09 11:13:06 +01:00
Belén Albeza
7be8ac3fd7 🐛 Fix internal error while importing a library 2025-12-09 11:10:32 +01:00
Pablo Alba
b0351be724 🐛 Fix switch variants with paths 2025-12-09 11:08:55 +01:00
Elena Torro
9216d965ef 🔧 Update rendering settings to smooth render 2025-12-09 10:43:33 +01:00
Andrey Antukh
d04fdb5fbd Make the dist bundle use consistent and cache-aware uris (#7911) 2025-12-09 08:05:28 +01:00
Ingrid Pigueron
4f3ca6422c 🌐 Add translations for: French
Currently translated at 96.8% (1931 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-12-08 19:40:14 +01:00
Nicola Bortoletto
1c03457fda 🌐 Add translations for: Italian
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-12-06 07:00:19 +00:00
Eva Marco
81e0e4f222 ♻️ Replace token form files (#7896)
* ♻️ Replace shadow form

* ♻️ Rename files and components

* ♻️ Replace offsetx and offsety names

* ♻️ Replace form file for new form component using new form system

* ♻️ Rename files and props
2025-12-05 17:04:07 +01:00
Andrey Antukh
b8392b3731 🐛 Fix regression on sending team invitations (#7912) 2025-12-05 12:36:06 +01:00
Andrey Antukh
77dba477ca 🔧 Backport build-tag github workflow from develop 2025-12-05 10:25:03 +01:00
Eva Marco
b6598d1f07 🐛 Fix scrollbar on color modal (#7906) 2025-12-05 09:55:41 +01:00
Yamila Moreno
f13b3c8737 🔧 Fix bug in Github Actions (#7908) 2025-12-04 20:24:33 +01:00
alonso.torres
520e979363 🐛 Fix problem when exporting texts 2025-12-04 17:32:54 +01:00
Yamila Moreno
a0f8559ffc 🔧 Add ci/cd for nitrate-module (#7905) 2025-12-04 16:02:29 +01:00
Xaviju
bf1dc21c75 💄 Hide themes & sets panels when none active (#7902) 2025-12-04 14:11:57 +01:00
Alejandro Alonso
46c20a993f Merge pull request #7904 from penpot/niwinz-staging-fix-invitation-resend
🐛 Fix exception on resending invitation
2025-12-04 11:56:07 +01:00
Andrey Antukh
0e0106f69a 🐛 Add correct assertion on create-invitation fn 2025-12-04 11:38:32 +01:00
Andrey Antukh
19bb69cc60 Improve invalid schema error report 2025-12-04 11:38:16 +01:00
Alejandro Alonso
504eb70988 Merge pull request #7885 from penpot/niwinz-staging-bugfix-2
🐛 Make workspace palette reposition on left sidebar collapse
2025-12-04 11:19:20 +01:00
Andrey Antukh
a38f425dd3 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-04 11:06:48 +01:00
Xaviju
75a2331edf 💄 Set low-emphasis color for both light/dark modes (#7884) 2025-12-04 11:04:07 +01:00
VKing9
74d4b9b045 🌐 Add translations for: Hindi
Currently translated at 100.0% (1994 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/hi/
2025-12-04 11:00:32 +01:00
Alejandro Alonso
c2b4c9907d Merge pull request #7886 from penpot/niwinz-staging-bugfix-3
🐛 Fix casing on a translation of export files modal option
2025-12-04 10:59:51 +01:00
Alejandro Alonso
bd5bbcae26 Merge pull request #7894 from penpot/niwinz-staging-bugfix-4
🐛 Fix incorrect interaction betwen hower and scroll on assets sidebar
2025-12-04 10:58:54 +01:00
Andrey Antukh
84273508ad 🐛 Fix incorrect interaction betwen hower and scroll on assets sidebar 2025-12-04 10:56:29 +01:00
Andrey Antukh
9245ba6bc2 💄 Adapt component style for assets-local-library on sidebar assets 2025-12-04 10:55:57 +01:00
Andrey Antukh
4be046406d Pass direct args instead of a vector to toggle-values on sidebar assets 2025-12-04 10:55:57 +01:00
Alejandro Alonso
84c747cd31 Merge pull request #7883 from penpot/niwinz-staging-bugfix
🐛 Fix exception on paste text on comments input
2025-12-04 10:32:07 +01:00
Alejandro Alonso
0036a9a0cd Merge pull request #7865 from penpot/niwinz-staging-audit
 Add minor improvements to the audit module
2025-12-04 10:04:00 +01:00
Alejandro Alonso
2105c3a68c Merge pull request #7866 from penpot/niwinz-staging-fix-emails
🐛 Change internal ordering on how email parts are assembled
2025-12-04 09:56:22 +01:00
Belén Albeza
38efa88460 🐛 Fix unpublish library modal not scrolling file list (#7892)
* 🐛 Fix unpublish library modal not scrolling when the linked files list is too long

* 💄 Remove deprecated tokens in unpublish library modal

* 🔧 Update CHANGELOG
2025-12-03 22:41:20 +01:00
Pablo Alba
6e254c2cf4 🐛 Fix change of library on swap (#7898) 2025-12-03 22:40:23 +01:00
Andrey Antukh
416980f063 🐛 Fix issue on render template on dist bundle (#7899) 2025-12-03 20:48:02 +01:00
Andrey Antukh
f76710296c Merge remote-tracking branch 'origin/staging' into develop 2025-12-03 18:52:28 +01:00
Andrey Antukh
6251fa6b22 🐛 Close other open context menus on open a context menu (#7895) 2025-12-03 18:50:00 +01:00
alonso.torres
aedd8cc11e 🐛 Fix problem when renaming variants in plugins 2025-12-03 17:42:17 +01:00
Andrey Antukh
d1379c55f6 Make i18n translation files load on demand 2025-12-03 16:44:37 +01:00
Andrey Antukh
b125c7b5a3 Merge remote-tracking branch 'origin/staging' into develop 2025-12-03 13:55:01 +01:00
Andrey Antukh
496d37795b Adapt docker images nginx config template to latest changes (#7891) 2025-12-03 13:45:18 +01:00
Alonso Torres
2f0853f5cc 🐛 Fix problem with variant plugins api (#7890) 2025-12-03 13:27:32 +01:00
Juan de la Cruz
648e660bcf 🎉 Add new content and images for the slides of 2.12 (#7874)
* 🎉 Add new slide's content

* 🎉 Add new slides images

* 📎 Fix clj fmt

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2025-12-03 13:26:55 +01:00
Andrey Antukh
9f6899007a Merge remote-tracking branch 'origin/staging' into develop 2025-12-03 13:10:30 +01:00
Slava "nerfur" Voronzoff
bee2f70bfa 🐛 Add missing amd64 option on arch detection code on mange.sh script
Signed-off-by: Slava "nerfur" Voronzoff <nerfur@gmail.com>
2025-12-03 13:09:49 +01:00
Pablo Alba
00f8eac8fa 🐛 Fix can't delete unsaved variant prop (#7878) 2025-12-03 13:03:17 +01:00
Dalai Felinto
df7caacb45 🐛 Fix crash in token grid view due to tooltip validation (#7887)
The color tokens in grid view have a tooltip which is a map.
This is done so the frontend can render:

```
Name: foo
Resolved value: #000000
```

However the validation scheme for tooltips was only accepting functions
and strings.

---

How to reproduce the original (unreported) crash:
* Create a color token
* Create a shape, add a fill
* Pick a color, chose the Token options
* Click on the Grid View

Crash: `{:hint "invalid props on component tooltip*\n\n  -> 'content'
    should be a string\n"}`

Signed-off-by: Dalai Felinto <dalai@blender.org>
Co-authored-by: Dalai Felinto <dalai@blender.org>
2025-12-03 13:01:36 +01:00
Marina López
641df77834 🐛 Fix wrong board size presets in Android (#7888) 2025-12-03 12:52:47 +01:00
Marina López
49bbdfb257 🐛 Fix U and E icon displayed in project list (#7875)
* 🐛 Fix U and E icon displayed in project lis

* 🐛 Fix U and E icon displayed in project list
2025-12-03 12:50:51 +01:00
Aitor Moreno
4e84deca44 Merge pull request #7879 from penpot/elenatorro-12797-fix-update-spans
🐛 Fix paragraph with text spans with multiple styles
2025-12-03 11:30:17 +01:00
Aitor Moreno
0d21e52068 🐛 Fix applyStylesTo entire selection 2025-12-03 11:07:33 +01:00
alonso.torres
1b29e9a50f 🐛 Fix race condition with fix fonts patch 2025-12-03 10:39:05 +01:00
Andrey Antukh
94af978be8 🐛 Fix casing on a translation of export files modal option 2025-12-03 10:22:45 +01:00
Andrey Antukh
feababe2a8 🐛 Make workspace palette reposition on left sidebar collapse 2025-12-03 09:56:14 +01:00
Andrey Antukh
5ef06685fc 💄 Add cosmetic improvements to workspace palette component 2025-12-03 09:38:23 +01:00
Elena Torro
9f567c3bf4 🐛 Fix italic variant 2025-12-03 08:59:25 +01:00
Elena Torro
1ba15e5d10 🐛 Do not merge fill styles 2025-12-03 08:55:11 +01:00
Andrey Antukh
57fcec5afc 🐛 Make from-synthetic-clipboard-event function return always a stream
Causes an execption on steam processing when it returns nil
2025-12-03 08:32:38 +01:00
Andrey Antukh
58f82da61e 🐛 Fix exception on paste text on comments input 2025-12-03 08:20:58 +01:00
Andrey Antukh
a28c5b61ca 💄 Adapt viewport paste code codestyle
And remove some not necessary constructions
2025-12-03 08:09:13 +01:00
Yaron Shahrabani
60df56caa3 🌐 Add translations for: Hebrew
Currently translated at 99.6% (1988 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/he/
2025-12-02 21:00:32 +00:00
Andrey Antukh
53aad7bc15 Merge remote-tracking branch 'origin/staging' into develop 2025-12-02 17:43:34 +01:00
Andrey Antukh
9123d199b7 🐛 Fix scripts/fmt 2025-12-02 17:43:21 +01:00
alonso.torres
37e45a8bbf 🐛 Fix race condition with text and type 2025-12-02 17:28:20 +01:00
alonso.torres
3471d40f46 🐛 Fix problem with boolean shapes updates 2025-12-02 17:28:20 +01:00
Elena Torro
c6b64a8e39 🐛 Fix selectAll on mixed span styles 2025-12-02 16:50:48 +01:00
Elena Torro
511e80c948 🐛 Fix merge fill styles when there are multiple fills 2025-12-02 16:50:04 +01:00
Elena Torró
f5a640d104 Merge pull request #7876 from penpot/ladybenko-12805-slow-loading
🐛 Fix viewport not being fully drawn on first load until a mouse …
2025-12-02 15:31:43 +01:00
Belén Albeza
3ae7c514e4 🐛 Fix viewport not being fully drawn on first load until a mouse hover 2025-12-02 15:06:28 +01:00
Andrey Antukh
57297741f5 Merge remote-tracking branch 'origin/staging' into develop 2025-12-02 13:28:50 +01:00
Andrey Antukh
eeaf28bb25 📎 Disable caddy logging 2025-12-02 13:27:09 +01:00
Dalai Felinto
d63d692d34 🐛 Fix mask issues with component swap #7675
The logic to swap a component would delete the swapped out component
first before bringing in the new one.

In the process of doing so, the sanitization code would unmask the
group, now orphan of its mask shape component, when it was the first
element of the group.

The fix  was to pass an optional argument to the generate-delete-shapes
function to ignore mask in special cases like this.

Signed-off-by: Dalai Felinto <dalai@blender.org>
2025-12-02 12:31:44 +01:00
alonso.torres
fad9ed1c48 🐛 Fix problem with reordering layers 2025-12-02 12:27:00 +01:00
alonso.torres
0caaefefea 🐛 Fix outline with single click text creation 2025-12-02 11:08:58 +01:00
Elena Torro
b179aa79b1 🐛 Fix create empty text on click regression 2025-12-02 11:08:58 +01:00
Andrey Antukh
6b8091bb90 Make devenv https and http2 capable (#7871)
Making it more similar on how it runs on production
environments and improves large amount of files loading
thanks to http2.
2025-12-02 10:49:37 +01:00
Andrey Antukh
fe72d0af82 Add self-signed cert to caddy (#7872) 2025-12-02 10:45:26 +01:00
Aitor Moreno
405ddb60d8 🐛 Fix letter spacing applied to paragraph 2025-12-02 10:45:19 +01:00
Luis de Dios
ef68081d1d 🎉 Add prototype tab UI tweaks (#7832)
* 🎉 Add prototype tab UI tweaks

* 📎 PR changes
2025-12-02 10:44:16 +01:00
Madalena Melo
bba02473d5 📚 Update subtitles in the new user guide cards (#7823)
Co-authored-by: Andres Gonzalez <andres.gonzalez79@gmail.com>
2025-12-02 09:21:05 +01:00
Andrey Antukh
4ed49cdc5d Make devenv https and http2 capable (#7871)
Making it more similar on how it runs on production
environments and improves large amount of files loading
thanks to http2.
2025-12-01 20:43:23 +01:00
Elena Torró
95c0d42d5b Merge pull request #7868 from penpot/alotor-fix-flex-tools
🐛 Fix visual feedback on padding/margin/gaps modified
2025-12-01 17:51:44 +01:00
alonso.torres
721b337511 🐛 Fix visual feedback on padding/margin/gaps modified 2025-12-01 16:31:15 +01:00
Elena Torró
359379be09 Merge pull request #7867 from penpot/azazeln28-add-text-editor-v2-tests-to-staging
 Add text editor v2 integration tests
2025-12-01 16:11:25 +01:00
Aitor Moreno
876d5783cf Add text editor v2 integration tests 2025-12-01 15:56:52 +01:00
Elena Torro
786f73767b 🔧 Normalize font attributes to support old formats 2025-12-01 14:59:24 +01:00
Andrey Antukh
50f9eedcdf Merge remote-tracking branch 'origin/staging' into develop 2025-12-01 14:33:38 +01:00
Andrey Antukh
77c9d8a2c8 🐛 Revert exporter dockerfile changes 2025-12-01 14:32:00 +01:00
Andrey Antukh
95b7784a42 🐛 Change internal ordering on how email parts are assembled
This fixes the html email rendering on gmail. Other clients (like proton,
emailcatcher) properly renders html independently of the order of parts
on the multipart email structure but gmail requires that html should be
the last one.
2025-12-01 14:27:21 +01:00
Andrey Antukh
4690f740b9 Add minor improvements to the audit module 2025-12-01 13:57:55 +01:00
Xaviju
529c4eb38a 💄 Avoid code tab overflow (#7854) 2025-12-01 11:37:37 +01:00
Andrey Antukh
c3a9919c4d 🐛 Fix typo on exporter dockerfile 2025-12-01 11:19:41 +01:00
Eva Marco
efe74e62e8 🎉 Replace font family form (#7825) 2025-12-01 11:17:25 +01:00
Juanfran
10a2732a55 Merge pull request #7863 from penpot/niwinz-staging-improve-yarn-independency
 Use setup script on exporter instead of direct commands
2025-12-01 10:13:58 +01:00
Eva Marco
456afe46de 🎉 Replace font family form (#7784) 2025-12-01 10:11:29 +01:00
Andrey Antukh
4282cdcd2c Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-01 10:11:06 +01:00
Andrey Antukh
964ef799c2 🔥 Remove core.spec usage on common and frontend 2025-12-01 09:30:21 +01:00
Andrey Antukh
d34b6b88b6 Remove malli dev stuff from cljs build
It only used on backend.
2025-12-01 09:30:21 +01:00
Andrey Antukh
9a58f0e954 🔧 Disable code motion on shadow config 2025-12-01 09:30:21 +01:00
Andrey Antukh
adaf8be56d Use sm/coercer on app.render entry point 2025-12-01 09:30:21 +01:00
Andrey Antukh
2f1b99fa53 ♻️ Use ESM target for build frontend 2025-12-01 09:30:21 +01:00
Andrey Antukh
5080fcc594 🔥 Remove unused require of edn reader on loggin ns 2025-12-01 09:30:21 +01:00
Andrey Antukh
ea2d3758f0 Merge remote-tracking branch 'origin/staging' into develop 2025-12-01 09:28:49 +01:00
Andrey Antukh
40e3617138 Use setup script on exporter instead of direct commands 2025-12-01 09:23:11 +01:00
Alejandro Alonso
e889413f26 🐛 Fix nested shadows clipping 2025-12-01 09:22:23 +01:00
Andrey Antukh
b18c421415 📎 Update .gitignore 2025-12-01 09:20:33 +01:00
Andrey Antukh
e7029f2182 Make automatic workflows not dependent on yarn 2025-12-01 08:17:52 +01:00
Elena Torró
115273b478 Merge pull request #7852 from penpot/alotor-flex-issues
🐛 Fix flex problems in new render
2025-11-28 14:10:42 +01:00
Elena Torró
fdddd3284a Merge pull request #7859 from penpot/ladybenko-12801-fix-mismatched-fonts
🐛 Fix mismatch between fonts for rendered and selected text when no fallback fonts apply
2025-11-28 14:10:17 +01:00
Belén Albeza
51385a04a0 🐛 Fix mismatch between fonts for rendered and selected text when no fallback fonts apply 2025-11-28 13:54:17 +01:00
Alonso Torres
2c3becb408 🐛 Fix problem with plugins content attribute (#7835) 2025-11-28 13:41:27 +01:00
Belén Albeza
f96ed8ccd6 Fix playwright tests 2025-11-28 13:25:13 +01:00
Belén Albeza
bda5de5c1b 🔧 Update google fonts list 2025-11-28 13:25:13 +01:00
Juanfran
94c15916e2 Merge pull request #7857 from penpot/niwinz-develop-prepare-for-pnpm
 Make automatic workflows not dependent on yarn
2025-11-28 13:07:30 +01:00
Andrey Antukh
ed0f3c3595 Make automatic workflows not dependent on yarn 2025-11-28 12:26:56 +01:00
alonso.torres
59f3b4db4c 🐛 Fix problem with auto-size and element margins 2025-11-28 12:12:19 +01:00
alonso.torres
7ee03ad911 🐛 Fix problem with grid layout editor 2025-11-28 12:12:09 +01:00
alonso.torres
130b8c8214 🐛 Fix problems with flex layout in new render 2025-11-28 10:49:55 +01:00
alonso.torres
0198d41757 🐛 Fix crash when cleanup 2025-11-28 10:44:54 +01:00
alonso.torres
567a955151 🐛 Fix problem with change gap/margin/padding 2025-11-28 10:44:38 +01:00
Xaviju
a4e6aa0588 💄 Limit inspect layer info message to avoid overflow (#7847) 2025-11-28 10:19:02 +01:00
Nicola Bortoletto
34da754357 🌐 Add translations for: Italian
Currently translated at 98.9% (1973 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-11-28 05:00:28 +00:00
alonso.torres
c2014a37b4 🐛 Fix problem when pasting elements in reverse flex layout 2025-11-27 18:02:34 +01:00
alonso.torres
6611fbd13b 🐛 Fix problem when drag+duplicate a full grid 2025-11-27 18:02:34 +01:00
Andrey Antukh
b5a6867058 Merge remote-tracking branch 'origin/staging' into develop 2025-11-27 18:01:08 +01:00
Andrey Antukh
7fe20b65dc 🔧 Add more cache efficient configuration for devenv nginx 2025-11-27 17:59:12 +01:00
Andrey Antukh
e5638cd769 ⬆️ Update clojure tools version on devenv 2025-11-27 17:58:56 +01:00
Eva Marco
8e79dfcb82 🐛 Fix input variant 2025-11-27 17:54:11 +01:00
Eva Marco
508db99a57 🐛 Restore empty field error on dimension, text-case and color forms 2025-11-27 17:54:11 +01:00
Andrey Antukh
3c6c9894da 🐛 Restore empty value error on border radius token form 2025-11-27 17:54:11 +01:00
Andrey Antukh
972b23e6c0 🐛 Fix incorect pred build on ::sm/text schema 2025-11-27 17:54:11 +01:00
Andrey Antukh
28f550d533 🔥 Remove commented code 2025-11-27 17:54:11 +01:00
Elena Torró
2b20f75fd4 Merge pull request #7837 from penpot/ladybenko-12719-fix-editor-unicode-fonts
🐛 Fix editor not using fallback fonts for selected text
2025-11-27 17:37:00 +01:00
Belén Albeza
4d6d7a6a3d 🐛 Fix emoji font not being used as fallback in text editor dom 2025-11-27 17:23:20 +01:00
Andrey Antukh
0f88253dd5 Merge remote-tracking branch 'origin/staging' into develop 2025-11-27 16:11:36 +01:00
Andrey Antukh
db1ab7be69 📎 Run worker bundling serially on devenv 2025-11-27 16:09:15 +01:00
Andrey Antukh
fcbe9d92dc 🐛 Fix unexpected exception on rendering feedback email
Looks like a bug on selmer library
2025-11-27 16:09:15 +01:00
Andrey Antukh
9998ce0bb4 🔥 Remove fipps direct dependency 2025-11-27 16:09:15 +01:00
Andrey Antukh
6061391c89 Don't require cljs.analyzer api under cljs on data.macros
Reduces the final production bundle size
2025-11-27 16:09:15 +01:00
Andrey Antukh
eabf6e36ed Remove a level of indentation on subscriptions-dashboard tests 2025-11-27 16:09:15 +01:00
Andrey Antukh
04274e53fa 📎 Fix advanced compilation warnings related to jsdoc 2025-11-27 16:09:15 +01:00
Andrey Antukh
52dd9271a9 🐛 Encode header values as strings on audit archive task 2025-11-27 16:09:15 +01:00
andrés gonzález
8f5a81e179 📚 Add info about boolean variants (#7828) 2025-11-27 16:03:11 +01:00
Alonso Torres
a940c08da9 🐛 Fix problem with worker bundling in development (#7844) 2025-11-27 14:13:48 +01:00
Alejandro Alonso
3de4473251 Merge pull request #7845 from penpot/elenatorro-fix-case
🐛 Fix editor vertical align default case
2025-11-27 14:00:12 +01:00
Andrey Antukh
0735140f07 🔧 Change concurrency rules on tests github workflow 2025-11-27 13:46:48 +01:00
Elena Torro
dc8a07099d 🐛 Fix vertical align default case 2025-11-27 13:38:51 +01:00
Andrey Antukh
8e3996fbb0 🔧 Change concirrency rules on tests github workflow 2025-11-27 13:16:08 +01:00
Alonso Torres
67762d9450 🐛 Fix problem with worker bundling in development (#7844) 2025-11-27 13:02:47 +01:00
Elena Torró
90dcf04fb0 Merge pull request #7841 from penpot/superalex-fix-boolean-operators-no-selection
🐛 Fix boolean operators no selection
2025-11-27 12:50:16 +01:00
Belén Albeza
f84c236e02 🐛 Fix text editor v2 not using fallback fonts for selected text 2025-11-27 12:26:39 +01:00
Alejandro Alonso
63959a22cc 🐛 Fix svg attrs 2025-11-27 12:23:46 +01:00
Alejandro Alonso
8840246425 🐛 Fix bleeding masks 2025-11-27 12:23:46 +01:00
Alejandro Alonso
62ec66cd15 🔧 Adding more e2e tests for nested frames with clipping 2025-11-27 12:23:46 +01:00
Alejandro Alonso
e3b87390f6 🐛 Fix nested shadows clipping 2025-11-27 12:23:46 +01:00
Alejandro Alonso
d9ab28e6ed 🐛 Fix nested clipping 2025-11-27 12:23:46 +01:00
Belén Albeza
9183dbbc43 🔧 Fix lint error (rust) 2025-11-27 11:51:05 +01:00
Andrey Antukh
74d00473e9 Add missing render-wasm to the ci workflow 2025-11-27 11:51:05 +01:00
Alejandro Alonso
1c70f5a36b 🐛 Fix boolean operatos shown when there is no selection 2025-11-27 11:22:15 +01:00
Andrey Antukh
b23e0c0642 Add tempfile storage bucket handler test case (#7839) 2025-11-27 10:27:57 +01:00
Andrey Antukh
7f62652870 Merge remote-tracking branch 'origin/staging' into develop 2025-11-27 09:24:40 +01:00
Marina López
db0cbbbc2e 🐛 Fix logic preventing incorrect trial flow in subscription modal (#7831) 2025-11-26 12:08:02 +01:00
alonso.torres
48304bd26f 🐛 Fix issue when exporting files 2025-11-26 12:04:34 +01:00
Elena Torro
60e32bbc71 🐛 Fix text editor vertical align 2025-11-26 11:46:47 +01:00
André Carvalhais
54451608dc 💄 Fix spelling of 'smtp' in email configuration section
Corrected the spelling of 'smtp' in the documentation.

Signed-off-by: André Carvalhais <carvalhais@live.com>
2025-11-26 08:11:27 +01:00
Andrey Antukh
78d31ab11a 🐳 Update devenv docker and compose files
Reuse the already builded imagemagick instead of building
it again on the devenv.
2025-11-26 07:44:56 +01:00
Andrey Antukh
0a80c47901 Merge remote-tracking branch 'origin/staging' into develop 2025-11-26 07:30:42 +01:00
Alejandro Alonso
b7727122d5 Merge pull request #7829 from penpot/alotor-fixes
🐛 Fix problem with thumbnails in parallel
2025-11-26 07:21:49 +01:00
alonso.torres
8880f07a6a 🐛 Fix problem with thumbnails in parallel 2025-11-25 17:56:00 +01:00
Oğuz Ersen
39eafae251 🌐 Add translations for: Turkish
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2025-11-25 16:51:25 +00:00
Edgars Andersons
e1e09b7f96 🌐 Add translations for: Latvian
Currently translated at 94.0% (1876 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lv/
2025-11-25 16:51:24 +00:00
Stephan Paternotte
3b39980f2f 🌐 Add translations for: Dutch
Currently translated at 99.8% (1991 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-11-25 16:51:23 +00:00
Anton Palmqvist
223b12d2c7 🌐 Add translations for: Swedish
Currently translated at 99.6% (1987 of 1994 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2025-11-25 16:51:21 +00:00
andrés gonzález
aaca2c41d8 📚 Add metadescriptions to some help center pages (#7821) 2025-11-25 17:00:14 +01:00
Yamila Moreno
77f1046fc8 🔧 Add MT notification when a docker image with final tag is built (#7824) 2025-11-25 16:39:42 +01:00
Belén Albeza
33417a4b20 🐛 Fix svg attrs stroke-linecap stroke-linejoin fill-rule 2025-11-25 12:43:40 +01:00
Andrés Moya
2640889dc8 🐛 Fix backwards compatibility importing files with token themes 2025-11-25 10:56:33 +01:00
alonso.torres
dd5f3396d1 🐛 Fix problem with layout z-index 2025-11-24 17:48:58 +01:00
Andrey Antukh
dedeae8641 🐛 Fix incorrect subscription fetching after profile registration 2025-11-24 14:36:46 +01:00
Andrey Antukh
a7552d412a Add explicit network asingation and alias on devenv compose 2025-11-24 14:36:46 +01:00
Aitor Moreno
f58475a7c9 🐛 Fix pasting application/transit+json (#7812) 2025-11-24 14:36:24 +01:00
Marina López
00bbb0bfb6 ♻️ Add format and refactor payments 2025-11-24 11:41:03 +01:00
Andrey Antukh
d93fe89c12 📎 Backport CI github workflog from develop 2025-11-24 10:48:51 +01:00
Andrey Antukh
553b73a83c ♻️ Replace CircleCI with Github Actions (#7789)
* ♻️ Replace circleci with github actions

* 📎 Add integration test sharding

* 📎 Reuse single build for integration tests shards
2025-11-24 10:44:04 +01:00
Andrey Antukh
00a45cb274 📎 Bump new version on changelog 2025-11-24 09:47:00 +01:00
Andrey Antukh
6e44330af4 Merge remote-tracking branch 'origin/develop' into staging 2025-11-24 09:42:45 +01:00
Andrey Antukh
624805fd6b Merge remote-tracking branch 'weblate/develop' into develop 2025-11-24 09:32:06 +01:00
Eva Marco
9b6bb77422 Materialize several tokens related flags (#7773)
* 📚 Add line to changelog

* ♻️ Remove typography types flag

* ♻️ Remove composite typography token flag

* ♻️ Remove token units flag

* 🎉 Activate by default two token flags

* ♻️ Update inspect tab tests to navigate to the right info tab

* 🐛 Fix test

---------

Co-authored-by: Xavier Julian <xavier.julian@kaleidos.net>
2025-11-24 09:26:05 +01:00
Yamila Moreno
9b8e04bb3c 🐳 Remove minio service from docker-compose.yml (#7809) 2025-11-24 08:15:36 +01:00
Edgars Andersons
2e919809c9 🌐 Add translations for: Latvian
Currently translated at 94.1% (1873 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lv/
2025-11-23 12:51:20 +00:00
Nicola Bortoletto
645e123e3a 🌐 Add translations for: Italian
Currently translated at 98.8% (1967 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2025-11-23 12:51:17 +00:00
Oğuz Ersen
cfb94d17b6 🌐 Add translations for: Turkish
Currently translated at 99.8% (1987 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2025-11-22 10:51:22 +00:00
Keunes
e9cb409ca4 🌐 Add translations for: Dutch
Currently translated at 99.8% (1987 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-11-22 10:51:19 +00:00
jonnysemon
8a0cd75257 🌐 Add translations for: Arabic
Currently translated at 56.6% (1128 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ar/
2025-11-22 10:51:17 +00:00
Pablo Alba
fae488b15a 🐛 Fix after changing a variant property value, the value appears as empty (#7791) 2025-11-21 17:51:12 +01:00
Elena Torró
b82828632e Merge pull request #7807 from penpot/alotor-fix-hover-text
🐛 Fix hover text
2025-11-21 15:35:41 +01:00
alonso.torres
bf24e22588 🐛 Fix hover text 2025-11-21 14:27:15 +01:00
Alejandro Alonso
7399b4d423 📚 Remove wrong line on CHANGES 2025-11-21 14:21:14 +01:00
Alejandro Alonso
77b9eee6bd 🐛 Fix svg fills defined in svg-attrs with url or color format 2025-11-21 14:15:27 +01:00
Elena Torro
55896db49e 🔧 Check for emtpy/nil attrs when getting inline style 2025-11-21 14:10:23 +01:00
Elena Torró
f4c569d619 Merge pull request #7802 from penpot/alotor-fix-text-data-problem
🐛 Fix problems with text editor size
2025-11-21 13:41:38 +01:00
alonso.torres
ca2cf18a49 🐛 Fix problems with text editor size 2025-11-21 13:17:43 +01:00
Andrey Antukh
6e352c167c 🐛 Fix dev build of frontend 2025-11-21 13:02:44 +01:00
Andrey Antukh
3ec001de44 🔧 Add nitrate url to devenv nginx (#7800) 2025-11-21 12:30:49 +01:00
Elena Torró
a1f11c89f2 Merge pull request #7799 from penpot/alotor-fix-text-data-problem
🐛 Fix problem with text data serialization
2025-11-21 12:30:35 +01:00
alonso.torres
33d70f0e45 🐛 Fix problem with text data serialization 2025-11-21 12:07:01 +01:00
Elena Torró
4f24a8f5f1 Merge pull request #7770 from penpot/ladybenko-12587-fix-text-editor-crash-empty
🐛 Fix crash when using a font family with a number in its name
2025-11-21 12:02:40 +01:00
Andrey Antukh
b03cfffb9e Restore the dashboard thumbnail rendering using wasm (#7796)
* Revert "🐛 Rollback esm worker (#7792)"

This reverts commit 0120a5335b.

* 🐛 Fix incorrect manifest reading on building worker
2025-11-21 11:42:40 +01:00
Elena Torró
956ad88e51 Merge pull request #7795 from penpot/alotor-fix-paste-crash
🐛 Fix paste crash
2025-11-21 11:00:00 +01:00
Belén Albeza
76f5c73de6 Remove leftover console.log/trace 2025-11-21 10:59:15 +01:00
Belén Albeza
c6dd3e0eeb Add missing param to cut handler 2025-11-21 10:28:48 +01:00
alonso.torres
fde73f30b9 🐛 Fix paste crash 2025-11-21 09:51:54 +01:00
Edgars Andersons
9d35a4317c 🌐 Add translations for: Latvian
Currently translated at 93.6% (1864 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lv/
2025-11-21 05:51:27 +00:00
jonnysemon
e7ccfeccbf 🌐 Add translations for: Arabic
Currently translated at 56.6% (1128 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ar/
2025-11-21 05:51:26 +00:00
Stephan Paternotte
aa043d284f 🌐 Add translations for: Dutch
Currently translated at 99.8% (1987 of 1990 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-11-21 05:51:21 +00:00
Alejandro Alonso
537dd171c0 Merge pull request #7793 from penpot/alotor-tiles-improvement
 Improve cache rendering
2025-11-20 18:50:24 +01:00
alonso.torres
c2026918a4 Improve cache rendering 2025-11-20 17:33:37 +01:00
Alonso Torres
0120a5335b 🐛 Rollback esm worker (#7792) 2025-11-20 16:07:22 +01:00
Belén Albeza
d0d2f43ca1 🐛 Fix text editor crash with font families with a number in their name 2025-11-20 15:22:40 +01:00
Alejandro Alonso
7e33a7c1a7 Merge pull request #7666 from penpot/azazeln28-feat-allow-disabling-rich-paste
🎉 Add an option to enable and disable HTML paste
2025-11-20 14:17:16 +01:00
Elena Torró
c13b58f42a Merge pull request #7764 from penpot/superalex-fix-blurs
🐛 Fix shadows and blurs
2025-11-20 13:37:57 +01:00
alonso.torres
a5c9f9e454 📚 Adds contributor to the changelog 2025-11-20 13:35:43 +01:00
Aitor Moreno
d73be5832b 🎉 Add an option to enable and disable HTML paste 2025-11-20 13:33:51 +01:00
Alejandro Alonso
e1f2fca4af Merge pull request #7771 from penpot/elenatorro-12541-improve-text-selection-and-cursor
 Improve text shape selection
2025-11-20 13:33:48 +01:00
Diana Veiga
37d5a31589 Drop zoom snap (#7774)
*  Remove const `zoom-half-pixel-precision`

* ♻️ Adjust usages
2025-11-20 13:28:45 +01:00
Luis de Dios
177bdaa72c 🐛 Fix variant toggle does not work for uppercase or mixed case (#7716)
* 🐛 Fix variant toggle does not work for uppercase or mixed case

* 📎 PR changes
2025-11-20 13:27:04 +01:00
Aitor Moreno
38ab2c61b9 Merge pull request #7782 from penpot/alotor-wasm-thumbnails
 Render WASM dashboard thumbnails
2025-11-20 13:12:26 +01:00
Marina López
cc32b22e8a Add improvements to the payment flow (#7776)
*  Add improvements payment flow

* 📎 PR feedback

* 📎 Fix conflicts
2025-11-20 13:07:57 +01:00
Alejandro Alonso
d331c5ad83 Merge pull request #7769 from penpot/niwinz-develop-exporter-refactor
 Remove exporter dependency on shared-fs on scaling
2025-11-20 12:44:34 +01:00
iPagar
6c6c2c3012 📚 Update copyright year on doc (#7502)
Signed-off-by: iPagar <iPagar@users.noreply.github.com>
2025-11-20 12:38:31 +01:00
Andrey Antukh
81632a03dd ♻️ Make exporter upload resources using backend management api
Instead of custon shared fs approach. This commit fixes the main
scalability issue of exporter removing the need of shared-fs
for make it work with multiple instances.
2025-11-20 12:20:13 +01:00
Andrey Antukh
4fddf3d986 ♻️ Make management key derivable from secret key
Still preserves the ability to set management
2025-11-20 12:20:13 +01:00
Andrey Antukh
57aa9a585b 🔧 Add explicit network alias for minio on devenv 2025-11-20 12:20:13 +01:00
Andrey Antukh
f71f491590 🐛 Fix incorrect bearer token decoding 2025-11-20 12:20:13 +01:00
Andrey Antukh
6ae2401c5e ♻️ Change how shapes are validated after changes apply operation 2025-11-20 12:08:48 +01:00
Andrey Antukh
53d8a2d6d7 🔥 Remove obsolete code on :move-objects related to old components 2025-11-20 12:08:48 +01:00
Andrey Antukh
bd65f3932e 🐛 Fix a race condition on move-object
That happens when an in-flight move-object change tries
to move object to an already deleted parent
2025-11-20 12:08:48 +01:00
alonso.torres
59845b756f Render WASM dashboard thumbnails 2025-11-20 11:56:25 +01:00
Alejandro Alonso
b8c0c5c310 Merge pull request #7742 from penpot/alotor-plugins-improvements
 Plugin API improvements with images and indexes
2025-11-20 11:47:50 +01:00
Alejandro Alonso
cfa8c21ee6 Merge pull request #7788 from penpot/elenatorro-fix-insert-shape-on-empty-frame
🐛 Fix insert shape on empty frame
2025-11-20 11:44:13 +01:00
Elena Torro
624bdaec88 Show text cursor in the entire text rect 2025-11-20 11:42:07 +01:00
Alejandro Alonso
24745bed40 🐛 Fix shadows and blurs for high levels of zoom 2025-11-20 11:25:23 +01:00
Eva Marco
d26c08f8e2 ♻️ Replace token forms (#7759)
* 🎉 Create dimensions form

* 🎉 Create text-case form

* 🎉 Create color form

* ♻️ Remove unused code on form file
2025-11-20 11:04:39 +01:00
Elena Torro
36adbd9118 🐛 Fix insert shape on empty frame 2025-11-20 10:59:44 +01:00
Elena Torró
0a3fe9836a Merge pull request #7777 from penpot/superalex-fix-extrect-calculation
🐛 Fix extrect calculation
2025-11-20 09:57:59 +01:00
Andrey Antukh
fef0c11503 🔧 Update tests github flow 2025-11-20 09:37:38 +01:00
Alejandro Alonso
7e858784a1 Merge pull request #7785 from penpot/niwinz-develop-binary-fills
🐛 Fix invalid fills schema when binary fills are used
2025-11-20 09:06:45 +01:00
Miguel de Benito Delgado
203368c2ee Add parameter to openPage to toggle new window behaviour (#7753)
*  Add parameter to openPage() to toggle opening a new tab/window

* 💄 Fix formatting
2025-11-20 08:05:08 +01:00
Alejandro Alonso
4f54469629 Merge pull request #7747 from penpot/niwinz-develop-storage-changes
 Make the binfile exportation process more reliable
2025-11-20 07:58:57 +01:00
Andrey Antukh
5343e799f8 🐛 Fix invalid fills schema when binary fills are used 2025-11-20 07:45:37 +01:00
Andrey Antukh
51e54a6bad 🐛 Fix incorrect project restoration on restoring file (#7778) 2025-11-19 18:24:24 +01:00
Aitor Moreno
f609747322 🐛 Fix inert element error 2025-11-19 18:23:44 +01:00
Andrey Antukh
26ad039d99 ⬆️ Update playwright dependency on frontend 2025-11-19 18:23:44 +01:00
Andrey Antukh
3136096123 🔧 Add general improvements to integration tests
This commit marks as skip (temporal) several flaky/randomly-failing
tests.

It also moves the integration test execution from circleci to github
actions.
2025-11-19 18:23:44 +01:00
Andrey Antukh
122d3bc41c 💄 Add code formatting for js on frontend 2025-11-19 18:23:44 +01:00
Andrey Antukh
3b52051113 Fix closure compiler issues on clipboard js impl
With minor naming fixes
2025-11-19 18:23:44 +01:00
Aitor Moreno
32e1b55658 ♻️ Refactor clipboard 2025-11-19 18:23:44 +01:00
Andrey Antukh
e9d177eae3 Make the binfile export process more resilent to errors
The current binfile export process uses a streaming technique. The
major problem with the streaming approach is the case when an error
happens on the middle of generation, because we have no way to
notify the user about the error (because the response is already
is sent and contents are streaming directly to the user
client/browser).

This commit replaces the streaming with temporal files and SSE
encoded response for emit the export progress events; once the
exportation is finished, a temporal uri to the exported artifact
is emited to the user via "end" event and the frontend code
will automatically trigger the download.

Using the SSE approach removes possible transport timeouts on export
large files by sending progress data over the open connection.

This commit also removes obsolete code related to old binfile
formats.
2025-11-19 17:28:55 +01:00
Andrey Antukh
d42c65b9ca Improve logging on shape detach operation 2025-11-19 17:28:55 +01:00
Andrey Antukh
86ad56797b Simplify tempfile deletion handling
Mainly removes the jvm on-exit hook usage because it can lead
to slow stops and unnecesary memory consumption over the time
the jvm is running.
2025-11-19 17:28:55 +01:00
Andrey Antukh
63497b8930 Add tempfile bucket to the storage subsystem
This enables storing temporal files under storage subsystem. The
temporal objects (the objects that uses templfile bucket) will
always evaluate to "for deletion" after touched garbage collection;
and the deletion threshold will be 2 hours (the threshold is always
calculated from the instant when the touched garbage collector is
running).
2025-11-19 17:28:55 +01:00
Andrey Antukh
94719eebf8 ♻️ Make storage and other objects deletion task vclock aware
This simplifes the mental model on how it works and simplifies testing
of the related code.

This also normalizes storage object deletion in the same way as the
rest of objects in penpot (now future deletion date on storage object
also means storage object to be deleted).
2025-11-19 17:28:55 +01:00
Andrey Antukh
9532dea2c6 📎 Skip inspect integration tests (#7781) 2025-11-19 17:26:40 +01:00
Andrey Antukh
40e1e27bf0 🐛 Fix not covered case on schema decode fn on tokens-lib 2025-11-19 15:04:49 +01:00
Andrés Moya
4338f97e9f 🐛 Allow deleting the library in the undo change of add tokens-lib 2025-11-19 15:04:49 +01:00
Andrey Antukh
2c4ec43d5f 🐛 Fix invalid syntax on translation files 2025-11-19 15:03:26 +01:00
Andrey Antukh
3d782a322d 🐛 Fix issue related to labels.code on translations 2025-11-19 14:53:13 +01:00
Andrey Antukh
407d28d187 🌐 Rehash and sync translation files 2025-11-19 14:18:41 +01:00
Andrey Antukh
bf582ec55f 🌐 Add several fixes on weblate merge 2025-11-19 13:25:11 +01:00
Andrey Antukh
858bc05ed5 Merge remote-tracking branch 'weblate/develop' into develop 2025-11-19 13:11:07 +01:00
Andrey Antukh
cd01386210 📎 Set version 1.1.0 final to sdk/library 2025-11-19 13:04:29 +01:00
Xaviju
3b2bb5f225 ♻️ Follow translations guidelines on several inspect components (#7766)
Signed-off-by: Xaviju <xavier.julian@kaleidos.net>
2025-11-19 13:03:25 +01:00
Alejandro Alonso
fe3bc96d0d Merge pull request #7772 from penpot/niwinz-develop-auth-bugfix
 Improvements to the auth internal flows changes
2025-11-19 12:46:10 +01:00
Alejandro Alonso
28f23f397e 🐛 Fix extrect calculation 2025-11-19 12:38:01 +01:00
Andrey Antukh
a487dfe004 Add better approach for cookie token decoding
Remove unnecesary decoding for old tokens and add key identifier
and versioning to cookie tokens for handle future changes.
2025-11-19 07:47:52 +01:00
Andrey Antukh
4f29156929 📎 Add better formatting of public-uri on db report 2025-11-18 20:35:26 +01:00
Andrey Antukh
ce2d3d1652 🐛 Fix incorrect handling of session renewal
A regression introduced in the prev auth refactor.
2025-11-18 20:35:16 +01:00
Andrey Antukh
3639ff9dbc 🔧 Update devenv logging configuration 2025-11-18 20:34:17 +01:00
Andrey Antukh
ca5ec734a0 Merge remote-tracking branch 'origin/staging' into develop 2025-11-18 18:19:36 +01:00
Andrey Antukh
b08da4c3ff Merge remote-tracking branch 'origin/main' into staging 2025-11-18 18:19:11 +01:00
Yamila Moreno
c9bec3924d 🐳 Use the secret key both in the backend and the exporter (#7746) 2025-11-18 18:18:49 +01:00
Yamila Moreno
6e725a75e1 🐳 Use the secret key both in the backend and the exporter (#7746) 2025-11-18 18:17:34 +01:00
Anton Palmqvist
81c3b84972 🌐 Add translations for: Swedish
Currently translated at 99.8% (1953 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2025-11-18 14:52:28 +01:00
jonnysemon
5868f7f6b2 🌐 Add translations for: Arabic
Currently translated at 57.7% (1130 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ar/
2025-11-18 14:52:25 +01:00
Tiago José
653567d7de 🌐 Add translations for: Portuguese (Brazil)
Currently translated at 71.6% (1402 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt_BR/
2025-11-18 14:51:51 +01:00
Alejandro Alonso
ce651fa0a9 Merge pull request #7767 from penpot/alotor-fix-problem-compatibility
🐛 Fix problem with tainted canvas in thumbnails
2025-11-18 14:15:06 +01:00
alonso.torres
e8a26ef83b 🐛 Fix problem with tainted canvas in thumbnails 2025-11-18 13:05:56 +01:00
alonso.torres
8fd17c9c84 🐛 Fix problem not checking feature flag 2025-11-18 13:05:29 +01:00
Xaviju
64b892f82d ♻️ Copy shorthands using user selected color space (#7752)
* ♻️ Copy shorthands using user selected color space

* ♻️ Add tests to ensure color space changes affect all properties
2025-11-18 10:54:10 +01:00
Alejandro Alonso
04185b3544 Merge pull request #7762 from penpot/alotor-fix-selection
🐛 Fix problem with selection and text shapes for new render
2025-11-18 10:39:36 +01:00
alonso.torres
0a01fc8af9 🐛 Fix problem with selection and text shapes for new render 2025-11-18 09:34:17 +01:00
Alejandro Alonso
ae624b3728 Merge pull request #7760 from penpot/elenatorro-12533-fix-selection-and-paste-and-word-deletion
🐛 Fix text editor select all functionality and inner paste corner cases
2025-11-18 09:31:57 +01:00
Alejandro Alonso
a48b719966 Merge pull request #7748 from penpot/elenatorro-12586-fix-offset-y-on-new-lines
🐛 Fix new lines spacing between paragraphs
2025-11-18 09:23:22 +01:00
Elena Torró
6425c0cb7d Merge pull request #7757 from penpot/superalex-fix-apply-shadow-and-blur-bounds
🐛 Fix apply shadow and blur bounds
2025-11-17 16:50:15 +01:00
Elena Torro
368f4cfe81 🐛 Fix text editor select all functionality and inner paste corner cases 2025-11-17 16:24:52 +01:00
Alejandro Alonso
fdffa14d75 🐛 Fix apply shadow and blur bounds 2025-11-17 15:20:22 +01:00
Eva Marco
7fe965a870 🎉 Add new form system on workspace (#7738)
* 🎉 Add new form system on border-radius token modals

* ♻️ Create new namespace and separate components

* ♻️ Refactor submit button

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2025-11-17 13:44:56 +01:00
Anton Palmqvist
d03f5c10fb 🌐 Add translations for: Swedish
Currently translated at 99.8% (1953 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2025-11-15 20:51:48 +00:00
Anton Palmqvist
3eb0f1c225 🌐 Add translations for: Swedish
Currently translated at 88.9% (1740 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2025-11-14 18:51:24 +01:00
Elena Torro
127fa931c7 🐛 Fix new lines spacing between paragraphs 2025-11-14 12:00:39 +01:00
Andrey Antukh
30413dbc66 Add small changes to the auth/login button label (#7754)
* 📎 Update changelog

*  Update login button label

* 📎 Adapt playwright tests
2025-11-14 11:35:10 +01:00
Andrey Antukh
2810ae681f ⬆️ Update yarn requirement on library module 2025-11-14 11:15:26 +01:00
Andrey Antukh
d706bb7c8d 🐛 Fix validation issues with dtcg-node schema 2025-11-14 11:15:26 +01:00
Andrey Antukh
ef271db879 🎉 Add addTokensLib method to the library 2025-11-14 11:15:26 +01:00
Andrey Antukh
ec5e814a72 ⬆️ Update npm deps on library 2025-11-14 11:15:26 +01:00
Andrey Antukh
c44fd2dd1d 💄 Use correct comments style on tokens-lib 2025-11-14 11:15:26 +01:00
Andrey Antukh
6aa797f51b Normalize token theme serialization to JSON 2025-11-14 11:15:26 +01:00
Andrés Moya
3cc54fd988 🎉 Add design tokens to plugins API (#7602)
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2025-11-14 11:14:56 +01:00
Xaviju
2233f34a15 🎉 Set default button behaviour as type button instead of submit (#7741) 2025-11-14 10:25:38 +01:00
Andrey Antukh
839bb470df Merge remote-tracking branch 'origin/staging' into develop 2025-11-14 09:55:14 +01:00
Eva Marco
450ce869ba 🐛 Fix gap on export section on sidebar 2025-11-14 09:08:33 +01:00
Xaviju
665587d492 ♻️ Review inspect tab UI (#7727)
* ♻️ Review inspect tab UI

* ♻️ Capitalize English strings and remove from styles

* ♻️ Set a minimum size por color space selector and adjust visually the UI

* 🐛 Fix error on hooks order when selecting texts

* 🐛 Set minim size to inspect tab element

* 🐛 Fix broken typography panel

* ♻️ Design review
2025-11-13 22:19:43 +01:00
Elena Torró
8aaa953604 Merge pull request #7730 from penpot/alotor-fixes-layouts
 Fix new render problems with layout
2025-11-13 16:38:20 +01:00
Marina López
a2cb84ba0d Add improvements payment flow 2025-11-13 13:48:27 +01:00
alonso.torres
639952abc8 🐛 Fix problems with text positioning in layout 2025-11-13 12:31:26 +01:00
alonso.torres
2d63730bfa Improved performance in modifiers 2025-11-13 12:31:26 +01:00
alonso.torres
c1638817b2 🐛 Fix problem with frame titles not moving 2025-11-13 12:31:26 +01:00
alonso.torres
76f6f71e02 🐛 Fix z-ordering for flex elements 2025-11-13 12:31:26 +01:00
alonso.torres
0a700864c9 🐛 Fix problem with grid layout modifiers 2025-11-13 12:31:26 +01:00
Yamila Moreno
04ce4c3233 🔧 Fix repository name in release.yml (#7731) 2025-11-13 11:42:33 +01:00
Andrey Antukh
befcca86df 📚 Update changelog 2025-11-12 21:37:16 +01:00
Andrey Antukh
b7bae3850b 🐛 Fix webp exportation on exporter docker image (#7739) 2025-11-12 21:31:19 +01:00
Elena Torró
3f05dae455 Merge pull request #7735 from penpot/superalex-fix-create-empty-text
🐛 Fix some text issues
2025-11-12 17:48:41 +01:00
alonso.torres
48c9fb5690 Add methods to plugins for modifying indices 2025-11-12 17:07:38 +01:00
alonso.torres
4cdf1eed0c 🐛 Add method to retrieve image data in plugins 2025-11-12 17:07:38 +01:00
Aitor Moreno
4a887840c6 Merge pull request #7737 from penpot/sueralex-fix-shadows-clipping
🐛 Fix shadows clipping
2025-11-12 16:58:06 +01:00
Elena Torró
10cf2c7f35 Merge pull request #7729 from penpot/ladybenko-12514-fix-font-variants
🐛 Fix downloading wrong font variant
2025-11-12 15:30:08 +01:00
Belén Albeza
d048a251f1 🐛 Fix render of text baseline (wasm) 2025-11-12 14:59:57 +01:00
Belén Albeza
0b3fc6a663 🔧 Fix broken playwright tests (wasm render) 2025-11-12 14:48:31 +01:00
Andrey Antukh
363b4e3778 ♻️ Make the SSO code more modular (#7575)
* 📎 Disable by default social auth on devenv

* 🎉 Add the ability to import profile picture from SSO provider

* 📎 Add srepl helper for insert custom sso config

* 🎉 Add custom SSO auth flow
2025-11-12 12:49:10 +01:00
Andrey Antukh
f248ab5644 🐛 Relax schema for importing plain path data related to curve-to command 2025-11-12 12:13:17 +01:00
Alejandro Alonso
33da6fbec2 🐛 Fix shadows clipping 2025-11-12 11:47:53 +01:00
Belén Albeza
07bede8ba2 🐛 Fix unicode ranges for codepoints that need surrogate pairs 2025-11-12 10:11:19 +01:00
Eva Marco
05bea14a88 🐛 Fix review selected colors (#7715)
* 🐛 Fix gap between token sets

* 🐛 Show token selected on color selecction modal
2025-11-12 10:04:29 +01:00
Alejandro Alonso
718f42aa94 🐛 Fix deselect and delete events for empty texts 2025-11-12 08:33:17 +01:00
Alejandro Alonso
f2f8a488ad Merge pull request #7724 from penpot/elenatorro-12551-fix-blurs-and-shadows-bounding-box
🐛 Fix extrect calculation for shadows and blurs depending on the scale
2025-11-12 08:25:50 +01:00
Alejandro Alonso
7594f1883b 🐛 Fix create empty text 2025-11-12 08:20:58 +01:00
Belén Albeza
5c2dde7308 🐛 Fix font family not being updated when changed from dropdown 2025-11-11 15:52:18 +01:00
Belén Albeza
483a1bd703 🐛 Fix downloading wrong font variant 2025-11-11 14:44:56 +01:00
Andrey Antukh
e1a275c7a9 Merge remote-tracking branch 'origin/staging' into develop 2025-11-11 14:07:07 +01:00
Andrey Antukh
96d9724516 📎 Update changelog 2025-11-11 14:04:04 +01:00
Andrey Antukh
8158f2956f Backport github release workflow from develop 2025-11-11 14:01:25 +01:00
Eva Marco
e45994e836 🐛 Fix color row opacity (#7550) 2025-11-11 13:30:08 +01:00
Xaviju
83da59e03c Add composite shadow token to inspect tab (#7703) 2025-11-11 13:28:11 +01:00
Yamila Moreno
fb21a98b0c Merge pull request #7706 from penpot/yms-fix-release-docker-images
🚧 Fix docker images arch during release
2025-11-11 13:21:21 +01:00
Elena Torro
23baf6d18b 🐛 Fix extrect calculation for shadows and blurs depending on the scale 2025-11-11 12:50:15 +01:00
Andrey Antukh
28cf67e7ff 🎉 Add management RPC API (#7700)
* 🎉 Add management RPC API

And refactor internal http auth flow

* 📎 Adjust final url namings

* 📚 Update changelog
2025-11-10 17:10:59 +01:00
Elena Torro
1b50c13c4d 🐛 Render shadows on nested shapes 2025-11-10 14:13:49 +01:00
Pablo Alba
7de95e108b 🐛 Fix crash when using decimal values for X/Y or width/height (#7722) 2025-11-10 11:28:00 +01:00
Luis de Dios
c6b907d05c 📚 Improve switch component documentation (#7714) 2025-11-10 11:00:44 +01:00
Pablo Alba
ffb4d6a890 🐛 Fix input confirmation behavior is not uniform 2025-11-10 09:50:26 +01:00
Ahmad HosseinBor
69c4a8932a 🌐 Add translations for: Persian
Currently translated at 40.2% (787 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fa/
2025-11-10 08:51:23 +01:00
Luis de Dios
fa25307c05 🐛 Fix correct alignment of property names (#7717) 2025-11-09 17:52:11 +01:00
Xaviju
43a136a9e9 💄 Fix minor style details on DS select ghost variant (#7707) 2025-11-07 22:46:57 +01:00
Yamila Moreno
3ec4c96b48 🚧 Fix docker images arch during release 2025-11-07 17:50:09 +01:00
Eva Marco
2eaeb8e9a5 🐛 Fix flex children subgrid gap 2025-11-07 13:49:45 +01:00
Andrey Antukh
604f6ca024 🐛 Fix incorrect value coercing on legacy select component (#7710)
on managing values with select
2025-11-07 13:16:39 +01:00
Andrey Antukh
e3cf70d3a8 Add URI to the report.txt (#7709) 2025-11-07 13:16:21 +01:00
Alejandro Alonso
6aedac35f2 🐛 Fix wasm erros when images are not found 2025-11-07 13:08:41 +01:00
Alejandro Alonso
a11b0f54d7 🐛 Fix changing properties resizes the text box height 2025-11-07 12:34:51 +01:00
Belén Albeza
ec0dc2931c Update copyright string in static page (#7701) 2025-11-07 10:54:27 +01:00
Andrey Antukh
9d65d11c91 Merge remote-tracking branch 'origin/staging' into develop 2025-11-07 10:43:27 +01:00
Luis de Dios
f00fd1d5a8 🎉 Use toggle for switching boolean variant property names (#7564) 2025-11-07 09:47:57 +01:00
Alejandro Alonso
d796dbb572 Merge pull request #7705 from penpot/niwinz-staging-fix-shadows
🐛 Restrict shadow colors to plain colors only
2025-11-06 16:10:02 +01:00
Andrey Antukh
e979476b0e 🐛 Restrict shadow colors to plain colors only
Previously, shadows used a general-purpose color schema that allowed
to have gradients and images on the data structure. This commit fixes
that using a specific schema for shadow colors that only allows plain
colors.

A migration is added to clean up existing shadows with non-plain
colors.
2025-11-06 15:54:50 +01:00
Andrey Antukh
097897d8da Add better sse parser for backend tests 2025-11-06 15:54:50 +01:00
Alejandro Alonso
ba092f03e1 🎉 Use Vec instead of Indexset 2025-11-06 14:16:07 +01:00
Alejandro Alonso
61202e1cab Merge pull request #7698 from penpot/elenatorro-fix-word-breaking-different-browsers
🔧 Fix cross-browser text issues
2025-11-06 12:34:22 +01:00
Elena Torro
f496ba78f3 🔧 Fix cross-browser text issues 2025-11-06 12:20:02 +01:00
Alejandro Alonso
b9a0c6d932 Merge pull request #7702 from penpot/alotor-tiles-fixes
 Removed some artifacts when tile rendering
2025-11-06 12:11:23 +01:00
alonso.torres
a59ce2ed16 Removed some artifacts when tile rendering 2025-11-06 11:46:02 +01:00
Xaviju
c221b9366f Add e2e tests to inspect tab (#7685) 2025-11-06 10:07:50 +01:00
Alejandro Alonso
8e0aa683a1 Merge pull request #7583 from penpot/niwinz-develop-backend-access-deleted-files
 Add RPC methods for enable access to deleted but recoverable projects and files
2025-11-06 06:44:56 +01:00
Alejandro Alonso
445d40b71c Merge pull request #7691 from penpot/alotor-improved-render-tiling
 Improve tile rendering updating
2025-11-05 17:29:25 +01:00
Alejandro Alonso
7889578ced 🎉 Use textures directly for images 2025-11-05 17:16:06 +01:00
alonso.torres
a230d2fcf6 Improve tile rendering updating 2025-11-05 17:16:06 +01:00
Belén Albeza
78fde35df9 🔧 Upgrade storybook (#7693)
* 🔧 Upgrade to storybook 9.x

* 🔧 Upgrade to storybook 10.x

* 🔧 Update watch:storybook script so it builds its assets dependencies first

* 🔧 Use vitest for storybook tests (test-storybook was deprecated)
2025-11-05 17:15:19 +01:00
Eva Marco
bb65782d08 🎉 Add sidebar css variables (#7645)
* 🎉 Add sidebar css variables

* 🎉 Explain more in depth the grid structure
2025-11-05 14:06:26 +01:00
Alejandro Alonso
02a1992a0a Merge pull request #7694 from penpot/niwinz-staging-runner-fixes
🐛 Fix precision issues on worker task scheduling mechanism
2025-11-05 12:18:23 +01:00
Andrey Antukh
1cce82f958 Merge remote-tracking branch 'origin/staging' into develop 2025-11-05 12:15:15 +01:00
Alejandro Alonso
a576c0404a 🐛 Fix focus mode across page and file navigation (#7695) 2025-11-05 12:05:00 +01:00
Andrey Antukh
7d5c1c9b5f Make file-gc-scheduler task compatible with virtual clock
And simplify implementation
2025-11-05 10:47:31 +01:00
Andrey Antukh
cd53d3659c 🐛 Truncate worker scheduled-at to milliseconds
The nanosecond precision has the problem with transit serialization
roundtrip used for pass data on the worker scheduler throught redis
and generates unnecesary rescheduling.
2025-11-05 10:47:31 +01:00
Andrey Antukh
132f7d6d3e ♻️ Add minor refactor on tokens main form (#7690) 2025-11-05 10:37:38 +01:00
Alejandro Alonso
b2a9c55874 Merge pull request #7674 from penpot/elenatorro-12478-fix-new-lines
🐛 Fix new lines issues
2025-11-05 10:13:41 +01:00
Alejandro Alonso
d610e7c892 Merge pull request #7671 from penpot/niwinz-develop-path-data-fix
🐛 Relax schema for importing plain path data related to curve-to command
2025-11-05 10:04:03 +01:00
Alejandro Alonso
1b5557759a Merge pull request #7687 from penpot/ladybenko-12440-fix-corrupt-files
🐛 Fix wasm crash when loading a file with missing font assets
2025-11-05 07:57:39 +01:00
Belén Albeza
8148da58ed 🐛 Fix wasm crash when loading a file with missing font assets 2025-11-05 07:47:16 +01:00
Alejandro Alonso
537f681944 Merge pull request #7692 from penpot/niwinz-develop-logging-improvements
 Remove unnecesary report on duplicate email error validation
2025-11-05 07:46:16 +01:00
Alejandro Alonso
9e7ec594ca Merge pull request #7680 from penpot/niwinz-staging-file-export-fix
🐛 Fix race condition on file export process
2025-11-05 07:45:26 +01:00
Alejandro Alonso
7c529eedd4 Merge pull request #7682 from penpot/niwinz-staging-worker-runner-exceptions
🐛 Fix incorrect status return on worker runner
2025-11-05 07:44:28 +01:00
Alejandro Alonso
500c5c81d4 Merge pull request #7686 from penpot/elenatorro-12499-fix-nested-blur
🐛 Fix children blur rendering
2025-11-05 07:27:43 +01:00
Alejandro Alonso
6ea69c94ee 🎉 Improve big images performance 2025-11-04 22:02:34 +01:00
Andrey Antukh
9b3f68ad14 Remove unnecesary report on duplicate email error validation 2025-11-04 20:34:25 +01:00
Andrey Antukh
34363320ae Merge branch 'main' into staging 2025-11-04 16:49:53 +01:00
Andrey Antukh
092a5139e3 🐛 Fix incorrect token sets migration (#7673) 2025-11-04 16:49:08 +01:00
Andrey Antukh
4a01121043 Merge tag '2.11.0-RC3' 2025-11-04 16:43:32 +01:00
Elena Torro
564ad8adba 🐛 Fix children blur rendering 2025-11-04 15:37:49 +01:00
Andrey Antukh
78e2d6fec3 🐛 Relax schema for importing plain path data related to curve-to command 2025-11-04 12:59:26 +01:00
Andrey Antukh
c850f101d3 Merge remote-tracking branch 'origin/staging' into develop 2025-11-04 12:49:57 +01:00
Andrey Antukh
49721c0bcd Add better logging context report on worker runner 2025-11-04 12:44:38 +01:00
Andrey Antukh
c214cc1544 🐛 Do not process runner result if no result returned 2025-11-04 12:44:38 +01:00
Andrey Antukh
eaabe54c4b 💄 Check the runner task exists as first condition 2025-11-04 12:44:38 +01:00
Eva Marco
21fb38e5bd 🐛 Fixes some problems with dropdowns and token inputs (#7640)
* 🐛 Fix apply color token on strokes

* 🐛 Fix size and position of some numeric inputs

* 🐛 Fix padding token application

* ♻️ Fix ci

* 🐛 Fix selected color tick

* 🐛 Fix comments and design review
2025-11-04 12:39:41 +01:00
Luis de Dios
37aa59b164 🐛 Fix hidden advanced frame grid options menu (#7681) 2025-11-04 11:57:52 +01:00
Elena Torro
24e4ece323 🐛 Fix line-height rendering on empty lines 2025-11-04 11:25:14 +01:00
Andrey Antukh
cbae3dca34 Simplify the approach for return streamable body
Removing unnecesary syntax overhead with simplier abstraction
2025-11-04 10:56:05 +01:00
Andrey Antukh
8307b699bf 🐛 Remove a race condition on file export
Caused when file is deleted in the middle of an exportation. The
current export process is not transactional, and on file deletion
several queries can start return not-found exception because of
concurrent file deletion.

With the changes on this PR we allow query deleted files internally
on the exportation process and make it resilent to possible
concurrent deletion.
2025-11-04 10:56:05 +01:00
Andrey Antukh
cd6865f54b ⬆️ Update yetti dependency
Bugfixes
2025-11-04 10:56:05 +01:00
Elena Torro
e673035817 🔧 Filter out empty paragraph content 2025-11-04 09:57:13 +01:00
Stas Haas
f6e77c09b3 🌐 Add translations for: German
Currently translated at 90.4% (1770 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-11-03 21:51:14 +01:00
Elena Torro
87fc71b55d 🐛 Ignore non-frequent chars 2025-11-03 17:08:38 +01:00
Elena Torro
b76bfa2197 🐛 Fix width on rotation 2025-11-03 16:37:17 +01:00
Andrey Antukh
88493f6805 🐛 Fix incorrect query for subscription editors (#7672)
Default teams should be present on the query results
2025-11-03 16:14:24 +01:00
Pablo Alba
69bbdad570 🐛 Fix nested variant in a component doesn't keep inherited overrides (3) 2025-11-03 15:36:37 +01:00
Pablo Alba
df4279bdee Revert "🐛 Fix nested variant in a component doesn't keep inherited overrides (2)"
This reverts commit 7c6515aa7b.
2025-11-03 15:36:37 +01:00
Alejandro Alonso
c8c901ee4c Merge pull request #7670 from penpot/ladybenko-fix-broken-wasm-test
 Fix broken wasm test
2025-11-03 14:49:29 +01:00
Belén Albeza
8f0e5e36e9 Fix broken wasm test 2025-11-03 14:18:15 +01:00
Andrés Moya
a5e9f7229b 💄 Fix tests nesting 2025-11-03 14:02:29 +01:00
Andrés Moya
5f22220a8b 🐛 Add test to catch a fixed bug and avoid regressions 2025-11-03 14:02:29 +01:00
Luis de Dios
6c7661b04d 🐛 Fix add missing use in SCSS 2025-11-03 12:47:25 +01:00
Alejandro Alonso
b867f276f2 Merge pull request #7665 from penpot/superalex-fix-texts-migrations
🐛 Fix texts migrations
2025-11-03 12:20:16 +01:00
Alejandro Alonso
da8d7a78cf 🐛 Add migration for texts with fills only in position-data 2025-11-03 12:06:41 +01:00
Alejandro Alonso
ec4936f5fe 🐛 Fix 0006 migration for strings that should be uuids 2025-11-03 12:06:41 +01:00
Alejandro Alonso
dd9ec54bd1 Merge pull request #7664 from penpot/alotor-performance-improvements
 Improve boolean calculations
2025-11-03 12:04:36 +01:00
Alejandro Alonso
3ad4b0a453 Merge pull request #7657 from penpot/elenatorro-12448-fix-text-tabs
🐛 Fix tabs rendering
2025-11-03 11:53:20 +01:00
Belén Albeza
83cd9c3db6 🔧 Fix rust linter errors 2025-11-03 11:45:05 +01:00
Andrey Antukh
399feec032 ⬆️ Update rust to 1.91 2025-11-03 11:45:00 +01:00
Andrey Antukh
481fa44f18 Merge remote-tracking branch 'origin/staging' into develop 2025-11-03 11:41:13 +01:00
Andrey Antukh
42c9f2123d Merge pull request #7663 from penpot/niwinz-staging-update-rust
⬆️ Update rust to 1.91
2025-11-03 11:26:01 +01:00
Elena Torro
d18a018236 🔧 Fix tab rendering with the text editor 2025-11-03 11:02:28 +01:00
Belén Albeza
4ab6ecec21 🔧 Fix rust linter errors 2025-11-03 10:43:35 +01:00
alonso.torres
b39c00fbf6 Improve boolean calculations 2025-11-03 09:50:29 +01:00
Andrey Antukh
8a0fddf1e4 ⬆️ Update rust to 1.91 2025-11-03 09:10:40 +01:00
Luis de Dios
95fdd75030 🐛 Fix misaligned right sidebar menus 2025-11-03 08:34:09 +01:00
Andrey Antukh
54489c4285 🔧 Add better regex for commit checker 2025-11-01 18:46:26 +01:00
Stas Haas
e7b8ad8ee2 🌐 Add translations for: German
Currently translated at 89.3% (1747 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2025-11-01 10:51:52 +00:00
Andrey Antukh
6815806669 Merge remote-tracking branch 'origin/staging' into develop 2025-10-31 18:15:12 +01:00
Andrey Antukh
febe87aa7b 🐛 Fix incorrect checksum of the jdk on dockerfiles 2025-10-31 18:01:55 +01:00
Andrey Antukh
83763b46ce Add RPC methods for manage deleted files
This includes: get already deletedf files, restore deleted files
and permanently delete files marked for deletion.
2025-10-31 16:07:23 +01:00
Andrey Antukh
1ddc196484 Make the get-projects return deleted projects
And adapt the frontend code to properly filter
deleted projects on appropriate pages
2025-10-31 16:07:23 +01:00
Andrey Antukh
37d4844518 💄 Add minor cosmetic changes to get-project-files rpc method 2025-10-31 16:07:23 +01:00
Andrey Antukh
76e610dd06 🔥 Remove duplicated functions from file tests namespace 2025-10-31 16:07:23 +01:00
Andrés Moya
99e8b22672 🐛 Fix theme validation when still no tokens library exists 2025-10-31 16:04:50 +01:00
Andrey Antukh
65adbfaadb Merge remote-tracking branch 'origin/staging' into develop 2025-10-31 14:50:59 +01:00
Andrey Antukh
0581c60800 ⬆️ Update jdk and node on docker images 2025-10-31 14:50:12 +01:00
Andrey Antukh
7e92408807 ⬆️ Update jdk and node on devenv 2025-10-31 14:50:12 +01:00
Marina López
03eeeda44f Add improvements payment flow 2025-10-31 14:15:22 +01:00
Alejandro Alonso
2f33009e69 Merge pull request #7655 from penpot/superalex-add-migration-to-fix-text-attrs-with-blank-strings
📎 Add migration to fix text attrs with blank strings
2025-10-31 13:58:42 +01:00
Alejandro Alonso
1d5c407456 📎 Add migration to fix text attrs with blank strings 2025-10-31 13:30:46 +01:00
Alejandro Alonso
aa15232cc7 Merge pull request #7648 from penpot/alotor-performance-improvements
 Add performance improvements for wasm render
2025-10-31 12:22:14 +01:00
Andrey Antukh
f53935f5df Merge remote-tracking branch 'origin/staging' into develop 2025-10-31 12:13:29 +01:00
alonso.torres
de04026dc8 After review changes 2025-10-31 12:04:52 +01:00
alonso.torres
f3b914534f Add scale_content to shapes_pool 2025-10-31 11:56:28 +01:00
alonso.torres
fcc9282304 Fix problems with SVGraw and modifiers 2025-10-31 11:56:28 +01:00
alonso.torres
122619b197 Support for booleans dynamic transforms 2025-10-31 11:56:28 +01:00
alonso.torres
dbf9bdceb5 Removed modifiers from code 2025-10-31 11:56:28 +01:00
Alejandro Alonso
f6eb492329 🐛 Fig shapes pool extending size 2025-10-31 11:56:28 +01:00
Alejandro Alonso
c66a8f5dc5 Improve shapes pool performance 2025-10-31 11:56:28 +01:00
alonso.torres
ed4df73e42 Changes to modifiers 2025-10-31 11:56:28 +01:00
alonso.torres
59e745e9ab Improve performance of group bounds 2025-10-31 11:56:28 +01:00
alonso.torres
d4b4d943c6 Store bounds inside the shape 2025-10-31 11:56:28 +01:00
alonso.torres
e4b4f1bd08 Removed all_ancestors traversals 2025-10-31 11:56:28 +01:00
alonso.torres
e58b2453b1 Removed method set_selrect_for_current_shape 2025-10-31 11:56:28 +01:00
alonso.torres
e9230b8b54 Change internal data type for tiles 2025-10-31 11:56:28 +01:00
alonso.torres
9d7cac5e73 Improved performance of children ancestors 2025-10-31 11:56:28 +01:00
alonso.torres
17fefcf0bc Changes WASM serialization mechanism 2025-10-31 11:56:28 +01:00
Alejandro Alonso
4367bd2dc6 Merge pull request #7651 from penpot/niwinz-email-bugfix-1
🐛 Fix regression on sending quote notification email
2025-10-31 11:23:39 +01:00
Miguel de Benito Delgado
6e2b2e8924 📚 Update increase/decrease font shortcuts (#7652) 2025-10-31 11:21:53 +01:00
Andrey Antukh
f3805e3b70 🐛 Fix regression on sending quote notification email 2025-10-31 10:56:33 +01:00
David Barragán Merino
262937c421 📚 Add recommendations for valkey/redis configuration 2025-10-31 10:45:33 +01:00
Alejandro Alonso
15ee75a692 Merge pull request #7647 from penpot/elenatorro-remove-unnecessary-text-properties
🔧 Remove unused text attrs
2025-10-31 09:56:12 +01:00
Alonso Torres
942e3300dd 🐛 Fix problem when checking usage with removed teams (#7638) 2025-10-31 09:22:31 +01:00
Elena Torro
eaa3904a3a 🔧 Remove unused text attrs 2025-10-31 09:22:01 +01:00
Alejandro Alonso
0c66b5db73 📎 Set stronger text validation (#7646) 2025-10-31 09:19:53 +01:00
Elena Torró
cc40448cb5 Merge pull request #7644 from penpot/azazeln28-fix-empty-text-attr-defaults
🐛 Fix empty text attr defaults
2025-10-31 09:00:19 +01:00
Alejandro Alonso
6a2029ca3b 🐛 Fix error comment message after the demo account creation (#7615) 2025-10-31 08:56:34 +01:00
David Barragán Merino
f32913adcf 📚 Adapt doc with the storage settings changes (#7607) 2025-10-31 08:56:06 +01:00
Juan de la Cruz
d906f05a6f 🎉 Add 2.11 release slides and images (#7606) 2025-10-31 08:54:19 +01:00
Yamila Moreno
2402334fb2 Merge pull request #7641 from penpot/yms-add-bundle-version-to-docker-metadata
🚧 Add bundle version to Docker metadata
2025-10-30 15:12:46 +01:00
Elena Torró
c3e2621ed5 Merge pull request #7643 from penpot/superalex-serialize-font-weight-properly
🐛 Serialize font weight properly
2025-10-30 14:59:47 +01:00
Yamila Moreno
d37695d7a5 🚧 Add bundle version to Docker metadata 2025-10-30 14:36:23 +01:00
Aitor Moreno
fadbe24aaa 🐛 Fix empty text attr defaults 2025-10-30 14:16:30 +01:00
Alejandro Alonso
9d29d5e8cc 🐛 Serialize font weight properly 2025-10-30 14:15:57 +01:00
Florian Schroedl
e681f95a70 Add box shadow token 2025-10-30 14:05:42 +01:00
Alejandro Alonso
5c8b401037 Merge pull request #7637 from penpot/elenatorro-12439-fix-fonts-default-values
🐛 Fix default font size in text spans
2025-10-30 11:14:20 +01:00
Elena Torro
9dfb0ebe84 🐛 Fix default font size in text spans 2025-10-29 17:23:29 +01:00
Alejandro Alonso
08162c825d Merge pull request #7633 from penpot/superalex-options-button-does-not-work-for-comments-created-in-the-lower-part-of-the-screen-with-an-active-reply-field
🐛 Fix options button does not work for comments created in the lower part of the screen
2025-10-29 16:18:21 +01:00
Alejandro Alonso
bc700334ca 🐛 Fix options button does not work for comments created in the lower part of the screen 2025-10-29 16:17:57 +01:00
Alejandro Alonso
133590f19c Merge pull request #7635 from penpot/alotor-fix-paste-position
🐛 Fix paste without selection sends the new element in the back
2025-10-29 16:16:17 +01:00
alonso.torres
66c5a0570e 🐛 Fix paste without selection sends the new element in the back 2025-10-29 16:15:55 +01:00
Andrés Moya
94cbf9d8f2 🎉 Add integration test to check new validation 2025-10-29 15:40:45 +01:00
Andrés Moya
70143f8ae3 🐛 Fix theme renaming and small refactor tokens forms validation 2025-10-29 15:40:45 +01:00
Xaviju
6c824651df 🎉 Add copy shortands button to panels (#7580)
* 🎉 Add copy shorthands button to panels
* 🎉 Add shorthand for strokes
* 🎉 Add shorthand for fonts
* 🎉 Add shorthand for borders
* 🎉 Add shorthand for padding
* 🎉 Add shorthand for grid
* 🎉 Add shorthand for layout element
* 🐛 Refactor to fix hook rendering
2025-10-29 13:51:36 +01:00
David Barragán Merino
1b81ddebb4 🐛 Fix some paths and add missed nginx config file for the storybook docker image 2025-10-29 13:46:29 +01:00
David Barragán Merino
6076df5c80 🎉 Detach storybook from the frontend build process 2025-10-29 13:45:54 +01:00
Alejandro Alonso
6d2d66a079 Merge pull request #7634 from penpot/alotor-fix-editable-label
🐛 Fix problem with certain text input and drag/drop
2025-10-29 12:50:03 +01:00
Alejandro Alonso
239af4fb82 🐛 Fix problem with text grow types 2025-10-29 12:40:11 +01:00
alonso.torres
0ad4a9ca7e 🐛 Fix problem with certain text input and drag/drop 2025-10-29 12:35:13 +01:00
David Barragán Merino
034463e63a 🐛 Fix some paths and add missed nginx config file for the storybook docker image 2025-10-29 12:02:32 +01:00
Eva Marco
aadc1aac1c 🐛 Fix some error translations 2025-10-29 11:14:20 +01:00
Elena Torró
2cdc76f1af Merge pull request #7573 from penpot/superalex-select-boards-to-export-to-pdf
 Select boards to export to PDF
2025-10-29 10:13:32 +01:00
alonso.torres
23f49237f8 🐛 Fix problem with plugins generating code for pages different than current one 2025-10-29 10:08:41 +01:00
Xaviju
93fb54c116 Enable single color-space selector for styles and computed… (#7525)
*  Enable single color-space selector for styles and computed tab
2025-10-29 09:53:37 +01:00
Alejandro Alonso
7565bb8d24 Merge pull request #7466 from penpot/bameda-storybook-detachment
🎉 Detach storybook from the frontend build process
2025-10-29 09:53:18 +01:00
Alejandro Alonso
0d394ee962 Merge pull request #7593 from penpot/eva-fix-some-translations
🐛 Fix some error translations
2025-10-29 09:36:06 +01:00
Alejandro Alonso
c4bebc1b0a Merge pull request #7625 from penpot/elenatorro-12374-fix-remove-selection
🐛 Fix text selection
2025-10-29 09:35:56 +01:00
Elena Torro
6edc29dce2 🐛 Fix text selection 2025-10-29 09:20:51 +01:00
Eva Marco
d773e3a966 🐛 Fix some error translations 2025-10-29 09:20:07 +01:00
Alejandro Alonso
e18aef1d39 Merge pull request #7610 from mdbenito/fix/conflicting-selection-shortcut-text-shape
🐛 Fix conflicting shortcut in text editor
2025-10-29 08:45:05 +01:00
Alejandro Alonso
b033690239 Merge pull request #7618 from penpot/andy-docs-typography-token
📚 Add typography token to the user guide
2025-10-29 08:22:20 +01:00
Alejandro Alonso
9f732eb45a Merge pull request #7595 from penpot/esther-moreno-user-guide-new-architecture
📚 New architecture in user guide
2025-10-29 08:02:45 +01:00
Alejandro Alonso
474453a503 Merge pull request #7594 from penpot/eva-fix-dropdown-submenu
🐛 Fix submenu visibility
2025-10-29 07:53:06 +01:00
Alejandro Alonso
c3d40659a9 Merge pull request #7600 from penpot/elenatorro-12344-fix-different-text-span-font-sizes
🔧 Add support for text spans of different sizes
2025-10-29 07:25:49 +01:00
David Barragán Merino
15e2b35afc 🎉 Detach storybook from the frontend build process 2025-10-28 20:58:50 +01:00
Elena Torró
ad15887d57 Merge pull request #7623 from penpot/superalex-fix-nested-fills-for-shapes-with-svg-attrs
🐛 Fix nested fills for shapes with svg attrs
2025-10-28 16:01:27 +01:00
Elena Torró
d01f921344 Merge pull request #7624 from penpot/superalex-fix-prevent-rendering-of-unused-fill-slots-in-shapes
🐛 Prevent rendering of unused fill slots in shapes
2025-10-28 15:54:51 +01:00
Alejandro Alonso
9e035ec4fe Merge pull request #7605 from penpot/ladybenko-fix-text-playground-crash
🔧 Fix text-related playgrounds (wasm)
2025-10-28 14:11:52 +01:00
Alejandro Alonso
fbacdf0351 🔧 Fix shapes-related playgrounds (wasm) 2025-10-28 14:09:24 +01:00
Alejandro Alonso
3f4d699395 🐛 Prevent rendering of unused fill slots in shapes 2025-10-28 13:35:34 +01:00
Alejandro Alonso
1626371337 Merge pull request #7619 from penpot/elenatorro-11889-fix-text-span-selection
🐛 Fix data-itype for text spans
2025-10-28 13:33:19 +01:00
Marina López
4d8a70f1fa Improvements payments 2025-10-28 12:36:32 +01:00
Alejandro Alonso
14d5de29da 🐛 Fix nested fills for shapes with svg attrs 2025-10-28 12:25:57 +01:00
Esther Moreno
df718c940f 📚 New architecture in user guide 2025-10-28 11:04:09 +01:00
Elena Torro
80c78d9cd4 🐛 Fix pasting text within an existing text 2025-10-28 09:39:52 +01:00
Pablo Alba
e2ce226814 🐛 Fix remove flex button doesn’t work within variant 2025-10-28 09:38:38 +01:00
Elena Torro
28c4c1a286 🐛 Fix data-itype for text spans 2025-10-27 16:55:51 +01:00
alonso.torres
f64105ad08 🐛 Fix problem with changing gap in flex layout 2025-10-27 16:29:59 +01:00
Andres Gonzalez
a346d29d76 📚 Add typography token to the user guide 2025-10-27 15:18:14 +01:00
Ingrid Pigueron
ccb7b41b3a 🌐 Add translations for: French
Currently translated at 98.3% (1923 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2025-10-27 12:02:59 +00:00
Elena Torro
2c37c5c8ed 🔧 Add support for text spans of different sizes 2025-10-27 12:43:41 +01:00
Andrés Moya
ed767d9a5b 🐛 Fix library update notificacions showing when they should not 2025-10-27 11:14:41 +01:00
Miguel de Benito Delgado
57bfca4062 🐛 Maintain selection after font change 2025-10-26 12:02:20 +00:00
Miguel de Benito Delgado
e9dcd64463 🐛 Fix conflicting shortcut in text editor 2025-10-26 11:11:50 +00:00
Belén Albeza
b498056c01 🔧 Fix text-related playgrounds (wasm) 2025-10-24 14:35:28 +02:00
Andrés Moya
81f851cad4 🔧 Deactivate debug traces for fonts module 2025-10-24 11:49:43 +02:00
Pablo Alba
245190f4f9 🐛 Fix variant validation when nil 2025-10-24 10:59:16 +02:00
Alejandro Alonso
479ce99b32 Improve setting svg attrs in wasm 2025-10-24 10:35:30 +02:00
Alejandro Alonso
6290b88d2e Merge pull request #7601 from penpot/alotor-fix-text-grow-type-problem
🐛 Fix problem with text grow types
2025-10-24 09:45:47 +02:00
Elena Torró
dba718b850 Merge pull request #7484 from penpot/superalex-fix-text-line-height-values-are-wrong
🐛 Text line-height values are wrong
2025-10-24 08:49:35 +02:00
alonso.torres
7c1205018b 🐛 Fix problem with text grow types 2025-10-23 17:39:18 +02:00
Elena Torró
89763d7c5a Merge pull request #7554 from penpot/azazeln28-fix-text-editor-v2-issues
🐛 Fix text editor v2 render integration issues
2025-10-23 15:55:54 +02:00
Aitor Moreno
7f6af6179b 🐛 Fix paste when collpaseNode is a br 2025-10-23 15:06:32 +02:00
Aitor Moreno
ceb184782f 🐛 Fix text editor paste inline/paragraph 2025-10-23 15:06:01 +02:00
Aitor Moreno
247c5c3700 Merge pull request #7588 from penpot/elenatorro-fix-text-tests
🔧 Fix text align selrect
2025-10-23 15:04:48 +02:00
Pablo Alba
0882c448f6 📎 Cleanup log files 2025-10-23 14:34:26 +02:00
Belén Albeza
f8cebb9d63 🐛 Fix scroll bar in design tab (#7582)
* 🐛 Fix scroll bar in design tab

* ♻️ Remove deprecated css tokens in options.scss
2025-10-23 14:11:11 +02:00
Alejandro Alonso
1e248c7177 🐛 Fix demo accounts creation 2025-10-23 13:45:11 +02:00
Elena Torró
351a35dad6 Merge pull request #7574 from penpot/azazeln28-refactor-text-struct-naming
♻️ Rename textleafs and inline to textspan
2025-10-23 13:39:36 +02:00
Aitor Moreno
eb088c31c1 🔧 Rename textleafs and inlines to keep coherence between render and editor 2025-10-23 13:04:21 +02:00
Belén Albeza
45af469a11 🐛 Fix invite selection copy
* 🐛 Fix selected invitations copy not being localized/pluralized

*  Add integration test for team invites + fixes unaccessible dom
2025-10-23 12:04:34 +02:00
Eva Marco
232f2271d3 🐛 Fix submenu visibility 2025-10-23 11:52:03 +02:00
Elena Torro
a30315c91c 🔧 Fix text align selrect and update regression tests 2025-10-23 11:44:40 +02:00
Pablo Alba
04542e1e66 Add variants to plugins API 2025-10-23 10:52:10 +02:00
Alejandro Alonso
36c986d8e8 🐛 Fix file doesn’t open after deleting the library used in it 2025-10-23 09:51:10 +02:00
Alejandro Alonso
38c3b2eaba Merge pull request #7584 from penpot/alotor-fix-flex-issue
🐛 Fix problem with flex type conversions
2025-10-22 16:49:56 +02:00
alonso.torres
98e91ecda5 🐛 Fix problem with flex type conversions 2025-10-22 16:03:51 +02:00
Alejandro Alonso
54ac64db4b Merge pull request #7578 from penpot/supealex-fix-selected-colors-children-shapes-in-multiple-selection
🐛 Fix selected colors not showing colors from children shapes in multiple selection
2025-10-22 15:18:58 +02:00
Alejandro Alonso
30ca6bf6ff 🐛 Fix selected colors not showing colors from children shapes in multiple selection 2025-10-22 14:53:06 +02:00
David Barragán Merino
81a364dfc4 🐳 Set default values for maxmemory and maxmemory-policy in Valkey 2025-10-22 13:43:30 +02:00
Pablo Alba
c6b9954af8 🐛 Fix nested variant in a component doesn't keep inherited overrides 2025-10-22 13:35:22 +02:00
Alejandro Alonso
f120cf82d3 Select boards to export to PDF 2025-10-22 13:19:10 +02:00
Belén Albeza
7ec335ae96 🐛 Fix export element crashing the app 2025-10-22 13:02:55 +02:00
Eva Marco
8dcc46aba8 🐛 Fix color row opacity (#7550) 2025-10-22 12:20:58 +02:00
Xaviju
058a555594 🎉 Add shadow panel to inspect styles tab (#7566) 2025-10-22 12:17:14 +02:00
Luis de Dios
e073b89604 🐛 Fix property input remains editable after keeping default property name (#7549)
* 🐛 Fix property input remains focused when keeping default property name

* 📎 PR changes
2025-10-22 10:48:03 +02:00
Pablo Alba
5e6af5aea9 🐛 Fix text override is lost after switch 2025-10-22 09:43:12 +02:00
Pablo Alba
fd596a1371 🐛 Fix incorrect behavior of Alt + Drag for variants 2025-10-21 17:02:10 +02:00
Eva Marco
ca21e7e8b4 🐛 Fix font size placeholder 2025-10-21 12:27:15 +02:00
AlexTECPlayz
597fba79cc 🌐 Add translations for: Romanian
Currently translated at 99.8% (1953 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ro/
2025-10-17 13:07:28 +02:00
AlexTECPlayz
43b03b9714 🌐 Add translations for: Romanian
Currently translated at 99.8% (1953 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ro/
2025-10-16 12:07:26 +02:00
Stephan Paternotte
4739c4730c 🌐 Add translations for: Dutch
Currently translated at 99.8% (1953 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2025-10-14 19:08:02 +02:00
Edgars Andersons
603bb860ba 🌐 Add translations for: Latvian
Currently translated at 95.3% (1866 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lv/
2025-10-14 19:08:01 +02:00
Yaron Shahrabani
55d9ca1439 🌐 Add translations for: Hebrew
Currently translated at 99.4% (1945 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/he/
2025-10-14 19:07:59 +02:00
Oğuz Ersen
a2f397c329 🌐 Add translations for: Turkish
Currently translated at 99.8% (1953 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2025-10-14 19:07:57 +02:00
Roman D
ada4e72c27 🌐 Add translations for: Russian
Currently translated at 78.2% (1530 of 1956 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2025-10-14 19:07:54 +02:00
Alejandro Alonso
b4cd955484 🐛 Text line-height values are wrong 2025-10-14 07:53:37 +02:00
895 changed files with 86296 additions and 63175 deletions

View File

@@ -1,351 +0,0 @@
version: 2.1
jobs:
lint:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: medium+
steps:
- checkout
- run:
name: "fmt check"
working_directory: "."
command: |
yarn install
yarn run fmt:clj:check
- run:
name: "lint clj common"
working_directory: "."
command: |
yarn run lint:clj:common
- run:
name: "lint clj frontend"
working_directory: "."
command: |
yarn run lint:clj:frontend
- run:
name: "lint clj backend"
working_directory: "."
command: |
yarn run lint:clj:backend
- run:
name: "lint clj exporter"
working_directory: "."
command: |
yarn run lint:clj:exporter
- run:
name: "lint clj library"
working_directory: "."
command: |
yarn run lint:clj:library
test-common:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: medium+
environment:
JAVA_OPTS: -Xmx4g -Xms100m -XX:+UseSerialGC
NODE_OPTIONS: --max-old-space-size=4096
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "common/deps.edn"}}-{{ checksum "common/yarn.lock" }}
- run:
name: "JVM tests"
working_directory: "./common"
command: |
clojure -M:dev:test
- run:
name: "NODE tests"
working_directory: "./common"
command: |
yarn install
yarn run test
- save_cache:
paths:
- ~/.m2
- ~/.yarn
- ~/.gitlibs
- ~/.cache/ms-playwright
key: v1-dependencies-{{ checksum "common/deps.edn"}}-{{ checksum "common/yarn.lock" }}
test-frontend:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: medium+
environment:
JAVA_OPTS: -Xmx4g -Xms100m -XX:+UseSerialGC
NODE_OPTIONS: --max-old-space-size=4096
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "frontend/deps.edn"}}-{{ checksum "frontend/yarn.lock" }}
- run:
name: "install dependencies"
working_directory: "./frontend"
# We install playwright here because the dependent tasks
# uses the same cache as this task so we prepopulate it
command: |
yarn install
yarn run playwright install chromium
- run:
name: "lint scss on frontend"
working_directory: "./frontend"
command: |
yarn run lint:scss
- run:
name: "unit tests"
working_directory: "./frontend"
command: |
yarn run test
- save_cache:
paths:
- ~/.m2
- ~/.yarn
- ~/.gitlibs
- ~/.cache/ms-playwright
key: v1-dependencies-{{ checksum "frontend/deps.edn"}}-{{ checksum "frontend/yarn.lock" }}
test-library:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: medium+
environment:
JAVA_OPTS: -Xmx6g
NODE_OPTIONS: --max-old-space-size=4096
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "frontend/deps.edn"}}-{{ checksum "frontend/yarn.lock" }}
- run:
name: Install dependencies and build
working_directory: "./library"
command: |
yarn install
- run:
name: Build and Test
working_directory: "./library"
command: |
./scripts/build
yarn run test
test-components:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: medium+
environment:
JAVA_OPTS: -Xmx6g -Xms2g
NODE_OPTIONS: --max-old-space-size=4096
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "frontend/deps.edn"}}-{{ checksum "frontend/yarn.lock" }}
- run:
name: Install dependencies
working_directory: "./frontend"
command: |
yarn install
yarn run playwright install chromium
- run:
name: Build Storybook
working_directory: "./frontend"
command: yarn run build:storybook
- run:
name: Serve Storybook and run tests
working_directory: "./frontend"
command: |
npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx http-server storybook-static --port 6006 --silent" \
"npx wait-on tcp:6006 && yarn test:storybook"
test-integration:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: large
environment:
JAVA_OPTS: -Xmx6g -Xms2g
NODE_OPTIONS: --max-old-space-size=4096
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "frontend/deps.edn"}}-{{ checksum "frontend/yarn.lock" }}
# Build frontend
- run:
name: "frontend build"
working_directory: "./frontend"
command: |
yarn install
yarn run build:app:assets
yarn run build:app
yarn run build:app:libs
# Build the wasm bundle
- run:
name: "wasm build"
working_directory: "./render-wasm"
command: |
EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh
./build release
# Run integration tests
- run:
name: "integration tests"
working_directory: "./frontend"
command: |
yarn run playwright install chromium
yarn run test:e2e -x --workers=4
test-backend:
docker:
- image: penpotapp/devenv:latest
- image: cimg/postgres:14.5
environment:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
- image: cimg/redis:7.0.5
working_directory: ~/repo
resource_class: medium+
environment:
JAVA_OPTS: -Xmx4g -Xms100m -XX:+UseSerialGC
NODE_OPTIONS: --max-old-space-size=4096
steps:
- checkout
- restore_cache:
keys:
- v1-dependencies-{{ checksum "backend/deps.edn" }}
- run:
name: "tests"
working_directory: "./backend"
command: |
clojure -M:dev:test --reporter kaocha.report/documentation
environment:
PENPOT_TEST_DATABASE_URI: "postgresql://localhost/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://localhost/1"
- save_cache:
paths:
- ~/.m2
- ~/.gitlibs
key: v1-dependencies-{{ checksum "backend/deps.edn" }}
test-render-wasm:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: medium+
environment:
steps:
- checkout
- run:
name: "fmt check"
working_directory: "./render-wasm"
command: |
cargo fmt --check
- run:
name: "lint"
working_directory: "./render-wasm"
command: |
./lint
- run:
name: "cargo tests"
working_directory: "./render-wasm"
command: |
./test
workflows:
penpot:
jobs:
- test-frontend:
requires:
- lint: success
- test-library:
requires:
- lint: success
- test-components:
requires:
- lint: success
- test-backend:
requires:
- lint: success
- test-common:
requires:
- lint: success
- lint
- test-integration
- test-render-wasm

View File

@@ -34,18 +34,26 @@ jobs:
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
- name: Download Penpot Bundles
id: bundles
env:
FILE_NAME: penpot-${{ steps.vars.outputs.gh_ref }}.zip
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
tmp=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "$FILE_NAME" \
--query 'Metadata."bundle-version"' \
--output text)
echo "bundle_version=$tmp" >> $GITHUB_OUTPUT
pushd docker/images
aws s3 cp s3://${{ secrets.S3_BUCKET }}/$FILE_NAME .
unzip $FILE_NAME > /dev/null
mv penpot/backend bundle-backend
mv penpot/frontend bundle-frontend
mv penpot/exporter bundle-exporter
mv penpot/storybook bundle-storybook
popd
- name: Set up Docker Buildx
@@ -58,6 +66,18 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images:
frontend
backend
exporter
storybook
labels: |
bundle_version=${{ steps.bundles.outputs.bundle_version }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v6
env:
@@ -69,6 +89,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
@@ -83,6 +104,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
@@ -97,6 +119,22 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Build and push Storybook Docker image
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'storybook'
BUNDLE_PATH: './bundle-storybook'
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.storybook
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
@@ -109,5 +147,6 @@ jobs:
TEXT: |
❌ 🐳 *[PENPOT] Error building penpot docker images.*
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -0,0 +1,21 @@
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

@@ -11,7 +11,7 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
build_wasm: "no"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
@@ -21,6 +21,22 @@ jobs:
with:
gh_ref: ${{ github.ref_name }}
notify:
name: Notifications
runs-on: ubuntu-24.04
needs: build-docker
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🐳 *[PENPOT] Docker image available.*
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra
publish-final-tag:
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
needs: build-docker

View File

@@ -26,7 +26,7 @@ jobs:
- name: Check Commit Type
uses: gsactions/commit-message-checker@v2
with:
pattern: '^(Merge|Revert|:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s["A-Z].*[^.]$'
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
flags: 'gm'
error: 'Commit should match CONTRIBUTING.md guideline'
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request

View File

@@ -37,36 +37,43 @@ jobs:
ref: ${{ steps.vars.outputs.gh_ref }}
# --- Publicly release the docker images ---
- name: Login to private registry
uses: docker/login-action@v3
- name: Configure ECR credentials
uses: aws-actions/configure-aws-credentials@v4
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
aws-access-key-id: ${{ secrets.DOCKER_USERNAME }}
aws-secret-access-key: ${{ secrets.DOCKER_PASSWORD }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Publish docker images to DockerHub
env:
TAG: ${{ steps.vars.outputs.gh_ref }}
REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
HUB: ${{ secrets.PUB_DOCKER_HUB }}
- name: Install Skopeo
run: |
IMAGES=("frontend" "backend" "exporter")
EXTRA_TAGS=("main" "latest")
sudo apt-get update -y
sudo apt-get install -y skopeo
- name: Copy images from AWS ECR to Docker Hub
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
TAG: ${{ steps.vars.outputs.gh_ref }}
run: |
aws ecr get-login-password --region $AWS_REGION | \
skopeo login --username AWS --password-stdin \
$DOCKER_REGISTRY
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
IMAGES=("frontend" "backend" "exporter" "storybook")
for image in "${IMAGES[@]}"; do
docker pull "$REGISTRY/$image:$TAG"
docker tag "$REGISTRY/$image:$TAG" "penpotapp/$image:$TAG"
docker push "penpotapp/$image:$TAG"
skopeo copy --all \
docker://$DOCKER_REGISTRY/$image:$TAG \
docker://docker.io/penpotapp/$image:$TAG
for tag in "${EXTRA_TAGS[@]}"; do
docker tag "$REGISTRY/$image:$TAG" "penpotapp/$image:$tag"
docker push "penpotapp/$image:$tag"
for alias in main latest; do
skopeo copy --all \
docker://$DOCKER_REGISTRY/$image:$TAG \
docker://docker.io/penpotapp/$image:$alias
done
done

289
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,289 @@
name: "CI"
defaults:
run:
shell: bash
on:
pull_request:
types:
- opened
- synchronize
push:
branches:
- develop
- staging
concurrency:
group: ${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: "Linter"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check clojure code format
run: |
./scripts/lint
test-common:
name: "Common Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests on JVM
working-directory: ./common
run: |
clojure -M:dev:test
- name: Run tests on NODE
working-directory: ./common
run: |
./scripts/test
test-frontend:
name: "Frontend Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
run: |
./scripts/test-components
test-render-wasm:
name: "Render WASM Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test
test-backend:
name: "Backend Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
clojure -M:dev:test --reporter kaocha.report/documentation
test-library:
name: "Library Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run tests
working-directory: ./library
run: |
./scripts/test
build-integration:
name: "Build Integration Bundle"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build 0.0.0
- name: Store Bundle Cache
uses: actions/cache@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
name: "Integration Tests 1/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="1/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-1
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-2:
name: "Integration Tests 2/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="2/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-2
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-3:
name: "Integration Tests 3/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="3/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-3
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-4:
name: "Integration Tests 4/4"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache/restore@v4
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="4/4";
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-tests-result-4
path: frontend/test-results/
overwrite: true
retention-days: 3

1
.gitignore vendored
View File

@@ -80,3 +80,4 @@ node_modules
/playwright/.cache/
/render-wasm/target/
/**/.yarn/*
/.pnpm-store

View File

@@ -1,6 +1,6 @@
# CHANGELOG
## 2.12.0 (Unreleased)
## 2.13.0 (Unreleased)
### :boom: Breaking changes & Deprecations
@@ -8,19 +8,124 @@
### :heart: Community contributions (Thank you!)
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)
### :sparkles: New features & Enhancements
- Add new Box Shadow Tokens [Taiga #10201](https://tree.taiga.io/project/penpot/us/10201)
- Make i18n translation files load on-demand [Taiga #11474](https://tree.taiga.io/project/penpot/us/11474)
- Add deleted files to dashboard [Taiga #8149](https://tree.taiga.io/project/penpot/us/8149)
### :bug: Bugs fixed
- Fix problem when drag+duplicate a full grid [Taiga #12565](https://tree.taiga.io/project/penpot/issue/12565)
- Fix problem when pasting elements in reverse flex layout [Taiga #12460](https://tree.taiga.io/project/penpot/issue/12460)
- Fix wrong board size presets in Android [Taiga #12339](https://tree.taiga.io/project/penpot/issue/12339)
## 2.12.0 (Unreleased)
### :boom: Breaking changes & Deprecations
#### Backend RPC API changes
The backend RPC API URLS are changed from `/api/rpc/command/<name>` to
`/api/main/methods/<name>`. The previous PATH is preserved for backward
compatibility; however, if you are a user of this API, it is strongly
recommended that you adapt your code to use the new PATH.
#### Updated SSO Callback URL
The OAuth / Single Sign-On (SSO) callback endpoint has changed to
align with the new OpenID Connect (OIDC) implementation.
Old callback URL:
```
https://<your_domain>/api/auth/oauth/<oauth_provider>/callback
```
New callback URL:
```
https://<your_domain>/api/auth/oidc/callback
```
**Action required:**
If you have SSO/Social-Auth configured on your on-premise instance,
the following actions are required before update:
Update your OAuth or SSO provider configuration (e.g., Okta, Google,
Azure AD, etc.) to use the new callback URL. Failure to update may
result in authentication failures after upgrading.
**Reason for change:**
This update standardizes all authentication flows under the single URL
and makis it more modular, enabling the ability to configure SSO auth
provider dinamically.
#### Changes on default docker compose
We have updated the `docker/images/docker-compose.yaml` with a small
change related to the `PENPOT_SECRET_KEY`. Since this version, this
environment variable is also required on exporter. So if you are using
penpot on-premise you will need to apply the same changes on your own
`docker-compose.yaml` file.
We have removed the Minio server from the `docker/images/docker-compose.yml`
example. It's still usable as before, we just removed the example.
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
- Ensure consistent snap behavior across all zoom levels [Github #7774](https://github.com/penpot/penpot/pull/7774) by [@Tokytome](https://github.com/Tokytome)
- Fix crash in token grid view due to tooltip validation (by @dfelinto) [Github #7887](https://github.com/penpot/penpot/pull/7887)
- Enable Hindi translations on the application
### :sparkles: New features & Enhancements
- Add the ability to select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320)
- Add toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341)
- Make the file export process more reliable [Taiga #12555](https://tree.taiga.io/project/penpot/us/12555)
- Add auth flow changes [Taiga #12333](https://tree.taiga.io/project/penpot/us/12333)
- Add new shape validation mechanism for shapes [Github #7696](https://github.com/penpot/penpot/pull/7696)
- Apply color tokens from sidebar [Taiga #11353](https://tree.taiga.io/project/penpot/us/11353)
- Display tokens in the inspect tab [Taiga #9313](https://tree.taiga.io/project/penpot/us/9313)
- Refactor clipboard behavior to assess some minor inconsistencies and make pasting binary data faster. [Taiga #12571](https://tree.taiga.io/project/penpot/task/12571)
### :bug: Bugs fixed
- Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252)
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985)
- Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371)
- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437)
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
- Fix shortcut conflict in text editor (increase/decrease font size vs word selection)
- Fix problem with plugins generating code for pages different than current one [Taiga #12312](https://tree.taiga.io/project/penpot/issue/12312)
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
- Fix copy/pasting application/transit+json [Taiga #12721](https://tree.taiga.io/project/penpot/issue/12721)
- Fix problem with plugins content attribute [Plugins #209](https://github.com/penpot/penpot-plugins/issues/209)
- Fix U and E icon displayed in project list [Taiga #12806](https://tree.taiga.io/project/penpot/issue/12806)
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
- Fix unicode handling on email template abbreviation filter [Github #7966](https://github.com/penpot/penpot/pull/7966)
## 2.11.1
## 2.11.0 (Unreleased)
- Fix WEBP shape export on docker images [Taiga #3838](https://tree.taiga.io/project/penpot/issue/3838)
## 2.11.0
### :boom: Breaking changes & Deprecations
@@ -48,10 +153,6 @@
services which use netty internally (redis connection, S3 SDK client). This
configuration is not very commonly used so don't expected real impact on any user.
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
- New composite token: Typography [Taiga #10200](https://tree.taiga.io/project/penpot/us/10200)
@@ -81,6 +182,24 @@
- Fix internal Error when selecting a set by name in the token theme editor [Taiga #12310](https://tree.taiga.io/project/penpot/issue/12310)
- Fix drag & drop functionality is swapping instead or reordering [Taiga #12254](https://tree.taiga.io/project/penpot/issue/12254)
- Fix variants not syncronizing tokens on switch [Taiga #12290](https://tree.taiga.io/project/penpot/issue/12290)
- Fix incorrect behavior of Alt + Drag for variants [Taiga #12309](https://tree.taiga.io/project/penpot/issue/12309)
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385)
- Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367)
- Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397)
- Fix remove flex button doesnt work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314)
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
- Fix problem with certain text input in some editable labels (pages, components, tokens...) being in conflict with the drag/drop functionality [Taiga #12316](https://tree.taiga.io/project/penpot/issue/12316)
- Fix not controlled theme renaming [Taiga #12411](https://tree.taiga.io/project/penpot/issue/12411)
- Fix paste without selection sends the new element in the back [Taiga #12382](https://tree.taiga.io/project/penpot/issue/12382)
- Fix options button does not work for comments created in the lower part of the screen [Taiga #12422](https://tree.taiga.io/project/penpot/issue/12422)
- Fix problem when checking usage with removed teams [Taiga #12442](https://tree.taiga.io/project/penpot/issue/12442)
- Fix focus mode persisting across page/file navigation [Taiga #12469](https://tree.taiga.io/project/penpot/issue/12469)
- Fix shadow color validation [Github #7705](https://github.com/penpot/penpot/pull/7705)
- Fix exception on selection blend-mode using keyboard [Github #7710](https://github.com/penpot/penpot/pull/7710)
- Fix crash when using decimal (floating-point) values for X/Y or width/height [Taiga #12543](https://tree.taiga.io/project/penpot/issue/12543)
## 2.10.1

View File

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

View File

@@ -27,6 +27,7 @@
[app.common.transit :as t]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.main :as main]

View File

@@ -1,7 +1,8 @@
From: {{profile.fullname}} <{{profile.email}}> / {{profile.id}}
Subject: {{feedback-subject}}
Type: {{feedback-type}}
{%- if feedback-error-href %}
{% if feedback-error-href %}
HREF: {{feedback-error-href}}
{% endif -%}

View File

@@ -240,4 +240,4 @@
</div>
</body>
</html>
</html>

View File

@@ -3,7 +3,7 @@
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
{:id "penpot-design-system"
:name "Penpot Design System | Pencil"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/penpot-app.penpot"}
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
{:id "wireframing-kit"
:name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Builtin API Documentation - Penpot</title>
<title>{{label|upper}} API Documentation</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -19,7 +19,7 @@
<body>
<main>
<header>
<h1>Penpot API Documentation (v{{version}})</h1>
<h1>{{label|upper}}: API Documentation (v{{version}})</h1>
<small class="menu">
[
<nav>
@@ -31,9 +31,10 @@
</header>
<section class="doc-content">
<h2>INTRODUCTION</h2>
<p>This documentation is intended to be a general overview of the penpot RPC API.
If you prefer, you can use <a href="/api/openapi.json">OpenAPI</a>
and/or <a href="/api/openapi">SwaggerUI</a> as alternative.</p>
<p>This documentation is intended to be a general overview of
the {{label}} API. If you prefer, you can
use <a href="{{openapi}}">Swagger/OpenAPI</a> as
alternative.</p>
<h2>GENERAL NOTES</h2>
@@ -43,7 +44,7 @@
that starts with <b>get-</b> in the name, can use GET HTTP
method which in many cases benefits from the HTTP cache.</p>
{% block auth-section %}
<h3>Authentication</h3>
<p>The penpot backend right now offers two way for authenticate the request:
<b>cookies</b> (the same mechanism that we use ourselves on accessing the API from the
@@ -56,9 +57,10 @@
<p>The access token can be obtained on the appropriate section on profile settings
and it should be provided using <b>`Authorization`</b> header with <b>`Token
&lt;token-string&gt;`</b> value.</p>
{% endblock %}
<h3>Content Negotiation</h3>
<p>The penpot API by default operates indistinctly with: <b>`application/json`</b>
<p>This API operates indistinctly with: <b>`application/json`</b>
and <b>`application/transit+json`</b> content types. You should specify the
desired content-type on the <b>`Accept`</b> header, the transit encoding is used
by default.</p>
@@ -75,13 +77,16 @@
standard <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">Fetch
API</a></p>
{% block limits-section %}
<h3>Limits</h3>
<p>The rate limit work per user basis (this means that different api keys share
the same rate limit). For now the limits are not documented because we are
studying and analyzing the data. As a general rule, it should not be abused, if an
abusive use is detected, we will proceed to block the user's access to the
API.</p>
{% endblock %}
{% block webhooks-section %}
<h3>Webhooks</h3>
<p>All methods that emit webhook events are marked with flag <b>WEBHOOK</b>, the
data structure defined on each method represents the <i>payload</i> of the
@@ -97,9 +102,11 @@
"profileId": "db601c95-045f-808b-8002-361312e63531"
}
</pre>
{% endblock %}
</section>
<section class="rpc-doc-content">
<h2>RPC METHODS REFERENCE:</h2>
<h2>METHODS REFERENCE:</h2>
<ul class="rpc-items">
{% for item in methods %}
{% include "app/templates/api-doc-entry.tmpl" with item=item %}

View File

@@ -0,0 +1 @@
{% extends "app/templates/api-doc.tmpl" %}

View File

@@ -0,0 +1,10 @@
{% extends "app/templates/api-doc.tmpl" %}
{% block auth-section %}
{% endblock %}
{% block limits-section %}
{% endblock %}
{% block webhooks-section %}
{% endblock %}

View File

@@ -7,7 +7,7 @@
name="description"
content="SwaggerUI"
/>
<title>PENPOT Swagger UI</title>
<title>{{label|upper}} API</title>
<style>{{swagger-css|safe}}</style>
</head>
<body>
@@ -16,7 +16,7 @@
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '{{public-uri}}/api/openapi.json',
url: '{{uri}}',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,

View File

@@ -25,8 +25,7 @@
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="trace" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.http.sse" level="info" />
<Logger name="app.http" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />

View File

@@ -25,8 +25,7 @@
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="trace" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.http.sse" level="info" />
<Logger name="app.http" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />

View File

@@ -1,18 +1,18 @@
#!/usr/bin/env bash
export PENPOT_MANAGEMENT_API_SHARED_KEY=super-secret-management-api-key
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key
export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-ldap \
enable-login-with-password
enable-login-with-oidc \
enable-login-with-google \
enable-login-with-github \
enable-login-with-gitlab \
disable-login-with-ldap \
disable-login-with-oidc \
disable-login-with-google \
disable-login-with-github \
disable-login-with-gitlab \
enable-backend-worker \
enable-backend-asserts \
disable-feature-fdata-pointer-map \

View File

File diff suppressed because it is too large Load Diff

View File

@@ -331,6 +331,81 @@
(set/difference cfeat/backend-only-features))
#{}))))
(defn check-file-exists
[cfg id & {:keys [include-deleted?]
:or {include-deleted? false}
:as options}]
(db/get-with-sql cfg [sql:get-minimal-file id]
{:db/remove-deleted (not include-deleted?)}))
(def ^:private sql:file-permissions
"select fpr.is_owner,
fpr.is_admin,
fpr.can_edit
from file_profile_rel as fpr
inner join file as f on (f.id = fpr.file_id)
where fpr.file_id = ?
and fpr.profile_id = ?
union all
select tpr.is_owner,
tpr.is_admin,
tpr.can_edit
from team_profile_rel as tpr
inner join project as p on (p.team_id = tpr.team_id)
inner join file as f on (p.id = f.project_id)
where f.id = ?
and tpr.profile_id = ?
union all
select ppr.is_owner,
ppr.is_admin,
ppr.can_edit
from project_profile_rel as ppr
inner join file as f on (f.project_id = ppr.project_id)
where f.id = ?
and ppr.profile_id = ?")
(defn- get-file-permissions*
[conn profile-id file-id]
(when (and profile-id file-id)
(db/exec! conn [sql:file-permissions
file-id profile-id
file-id profile-id
file-id profile-id])))
(defn get-file-permissions
([conn profile-id file-id]
(let [rows (get-file-permissions* conn profile-id file-id)
is-owner (boolean (some :is-owner rows))
is-admin (boolean (some :is-admin rows))
can-edit (boolean (some :can-edit rows))]
(when (seq rows)
{:type :membership
:is-owner is-owner
:is-admin (or is-owner is-admin)
:can-edit (or is-owner is-admin can-edit)
:can-read true
:is-logged (some? profile-id)})))
([conn profile-id file-id share-id]
(let [perms (get-file-permissions conn profile-id file-id)
ldata (some-> (db/get* conn :share-link {:id share-id :file-id file-id})
(dissoc :flags)
(update :pages db/decode-pgarray #{}))]
;; NOTE: in a future when share-link becomes more powerful and
;; will allow us specify which parts of the app is available, we
;; will probably need to tweak this function in order to expose
;; this flags to the frontend.
(cond
(some? perms) perms
(some? ldata) {:type :share-link
:can-read true
:pages (:pages ldata)
:is-logged (some? profile-id)
:who-comment (:who-comment ldata)
:who-inspect (:who-inspect ldata)}))))
(defn get-project
[cfg project-id]
(db/get cfg :project {:id project-id}))
@@ -550,7 +625,7 @@
[cfg data file-id]
(let [library-ids (get-libraries cfg [file-id])]
(reduce (fn [data library-id]
(if-let [library (get-file cfg library-id)]
(if-let [library (get-file cfg library-id :include-deleted? true)]
(ctf/absorb-assets data (:data library))
data))
data
@@ -749,7 +824,7 @@
l.version
FROM libs AS l
INNER JOIN project AS p ON (p.id = l.project_id)
WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
WHERE l.deleted_at IS NULL;")
(defn get-file-libraries
[conn file-id]

View File

@@ -228,6 +228,7 @@
(db/tx-run! cfg (fn [cfg]
(cond-> (bfc/get-file cfg file-id
{:realize? true
:include-deleted? true
:lock-for-update? true})
detach?
(-> (ctf/detach-external-references file-id)
@@ -254,6 +255,8 @@
(write-entry! output path params)
(events/tap :progress {:section :storage-object :id id})
(with-open [input (sto/get-object-data storage sobject)]
(.putNextEntry ^ZipOutputStream output (ZipEntry. (str "objects/" id ext)))
(io/copy input output :size (:size sobject))
@@ -278,6 +281,8 @@
thumbnails (bfc/get-file-object-thumbnails cfg file-id)]
(events/tap :progress {:section :file :id file-id})
(vswap! bfc/*state* update :files assoc file-id
{:id file-id
:name (:name file)
@@ -285,14 +290,12 @@
(let [file (cond-> (select-keys file bfc/file-attrs)
(:options data)
(assoc :options (:options data))
(assoc :options (:options data)))
:always
(dissoc :data))
file (cond-> file
:always
(encode-file))
file (-> file
(dissoc :data)
(dissoc :deleted-at)
(encode-file))
path (str "files/" file-id ".json")]
(write-entry! output path file))
@@ -818,9 +821,10 @@
entries (keep (match-storage-entry-fn) entries)]
(doseq [{:keys [id entry]} entries]
(let [object (->> (read-entry input entry)
(decode-storage-object)
(validate-storage-object))
(let [object (-> (read-entry input entry)
(decode-storage-object)
(update :bucket d/nilv sto/default-bucket)
(validate-storage-object))
ext (cmedia/mtype->extension (:content-type object))
path (str "objects/" id ext)

View File

@@ -5,7 +5,6 @@
;; Copyright (c) KALEIDOS INC
(ns app.config
"A configuration management."
(:refer-clojure :exclude [get])
(:require
[app.common.data :as d]
@@ -47,6 +46,7 @@
:auto-file-snapshot-timeout "3h"
:public-uri "http://localhost:3449"
:host "localhost"
:tenant "default"
@@ -57,6 +57,8 @@
:objects-storage-backend "fs"
:objects-storage-fs-directory "assets"
:auth-token-cookie-name "auth-token"
:assets-path "/internal/assets/"
:smtp-default-reply-to "Penpot <no-reply@example.com>"
:smtp-default-from "Penpot <no-reply@example.com>"
@@ -90,7 +92,7 @@
[:secret-key {:optional true} :string]
[:tenant {:optional false} :string]
[:public-uri {:optional false} :string]
[:public-uri {:optional false} ::sm/uri]
[:host {:optional false} :string]
[:http-server-port {:optional true} ::sm/int]
@@ -100,7 +102,7 @@
[:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int]
[:management-api-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string]
[:telemetry-with-taiga {:optional true} ::sm/boolean] ;; DELETE
@@ -165,7 +167,7 @@
[:google-client-id {:optional true} :string]
[:google-client-secret {:optional true} :string]
[:oidc-client-id {:optional true} :string]
[:oidc-user-info-source {:optional true} :keyword]
[:oidc-user-info-source {:optional true} [:enum "auto" "userinfo" "token"]]
[:oidc-client-secret {:optional true} :string]
[:oidc-base-uri {:optional true} :string]
[:oidc-token-uri {:optional true} :string]
@@ -319,5 +321,9 @@
([key default]
(c/get config key default)))
(defn logging-context
[]
{:version/backend (:full version)})
;; Set value for all new threads bindings.
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))

View File

@@ -704,6 +704,12 @@
(and (sql-exception? cause)
(= "40001" (.getSQLState ^java.sql.SQLException cause))))
(defn duplicate-key-error?
[cause]
(and (sql-exception? cause)
(= "23505" (.getSQLState ^java.sql.SQLException cause))))
(extend-protocol jdbc.prepare/SettableParameter
clojure.lang.Keyword
(set-parameter [^clojure.lang.Keyword v ^PreparedStatement s ^long i]

View File

@@ -106,17 +106,17 @@
(let [content-part (MimeBodyPart.)
alternative-mpart (MimeMultipart. "alternative")]
(when-let [content (get body "text/plain")]
(let [text-part (MimeBodyPart.)]
(.setText text-part ^String content ^String charset)
(.addBodyPart alternative-mpart text-part)))
(when-let [content (get body "text/html")]
(let [html-part (MimeBodyPart.)]
(.setContent html-part ^String content
(str "text/html; charset=" charset))
(.addBodyPart alternative-mpart html-part)))
(when-let [content (get body "text/plain")]
(let [text-part (MimeBodyPart.)]
(.setText text-part ^String content ^String charset)
(.addBodyPart alternative-mpart text-part)))
(.setContent content-part alternative-mpart)
(.addBodyPart mixed-mpart content-part))

View File

@@ -25,7 +25,6 @@
[app.main :as-alias main]
[app.metrics :as mtx]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias rpc.doc]
[app.setup :as-alias setup]
[integrant.core :as ig]
[reitit.core :as r]
@@ -149,7 +148,6 @@
[:map
[::ws/routes schema:routes]
[::rpc/routes schema:routes]
[::rpc.doc/routes schema:routes]
[::oidc/routes schema:routes]
[::assets/routes schema:routes]
[::debug/routes schema:routes]
@@ -171,8 +169,9 @@
[sec/sec-fetch-metadata]
[mw/params]
[mw/format-response]
[session/soft-auth cfg]
[actoken/soft-auth cfg]
[mw/auth {:bearer (partial session/decode-token cfg)
:cookie (partial session/decode-token cfg)
:token (partial actoken/decode-token cfg)}]
[mw/parse-request]
[mw/errors errors/handle]
[mw/restrict-methods]]}
@@ -188,9 +187,5 @@
(::mgmt/routes cfg)]
(::ws/routes cfg)
["/api" {:middleware [[mw/cors]
[sec/client-header-check]]}
(::oidc/routes cfg)
(::rpc.doc/routes cfg)
(::rpc/routes cfg)]]]))
(::oidc/routes cfg)
(::rpc/routes cfg)]]))

View File

@@ -9,23 +9,19 @@
[app.common.logging :as l]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[yetti.request :as yreq]))
[app.tokens :as tokens]))
(def header-re #"(?i)^Token\s+(.*)")
(defn get-token
[request]
(some->> (yreq/get-header request "authorization")
(re-matches header-re)
(second)))
(defn- decode-token
(defn decode-token
[cfg token]
(when token
(tokens/verify cfg {:token token :iss "access-token"})))
(try
(tokens/verify cfg {:token token :iss "access-token"})
(catch Throwable cause
(l/trc :hint "exception on decoding token"
:token token
:cause cause))))
(def sql:get-token-data
"SELECT perms, profile_id, expires_at
@@ -35,47 +31,28 @@
OR (expires_at > now()));")
(defn- get-token-data
[pool token-id]
[pool claims]
(when-not (db/read-only? pool)
(some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{}))))
(defn- wrap-soft-auth
"Soft Authentication, will be executed synchronously on the undertow
worker thread."
[handler cfg]
(letfn [(handle-request [request]
(try
(let [token (get-token request)
claims (decode-token cfg token)]
(cond-> request
(map? claims)
(assoc ::id (:tid claims))))
(catch Throwable cause
(l/trace :hint "exception on decoding malformed token" :cause cause)
request)))]
(fn [request]
(handler (handle-request request)))))
(when-let [token-id (get claims :tid)]
(some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{})))))
(defn- wrap-authz
"Authorization middleware, will be executed synchronously on vthread."
[handler {:keys [::db/pool]}]
(fn [request]
(let [{:keys [perms profile-id expires-at]} (some->> (::id request) (get-token-data pool))]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))))
(let [{:keys [type claims]} (get request ::http/auth-data)]
(if (= :token type)
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
;; FIXME: revisit this, this data looks unused
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))
(def soft-auth
{:name ::soft-auth
:compile (fn [& _]
(when (contains? cf/flags :access-tokens)
wrap-soft-auth))})
(handler request)))))
(def authz
{:name ::authz

View File

@@ -30,7 +30,7 @@
(defn- get-file-media-object
[pool id]
(db/get pool :file-media-object {:id id}))
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage] :as cfg} obj]

View File

@@ -9,8 +9,7 @@
(:require
[app.common.schema :as sm]
[integrant.core :as ig]
[java-http-clj.core :as http]
[promesa.core :as p])
[java-http-clj.core :as http])
(:import
java.net.http.HttpClient))
@@ -29,14 +28,9 @@
(defn send!
([client req] (send! client req {}))
([client req {:keys [response-type sync?] :or {response-type :string sync? false}}]
([client req {:keys [response-type] :or {response-type :string}}]
(assert (client? client) "expected valid http client")
(if sync?
(http/send req {:client client :as response-type})
(try
(http/send-async req {:client client :as response-type})
(catch Throwable cause
(p/rejected cause))))))
(http/send req {:client client :as response-type})))
(defn- resolve-client
[params]
@@ -56,8 +50,8 @@
([cfg-or-client request]
(let [client (resolve-client cfg-or-client)
request (update request :uri str)]
(send! client request {:sync? true})))
(send! client request {})))
([cfg-or-client request options]
(let [client (resolve-client cfg-or-client)
request (update request :uri str)]
(send! client request (merge {:sync? true} options)))))
(send! client request options))))

View File

@@ -13,6 +13,7 @@
[app.config :as cf]
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.auth :as-alias auth]
[app.http.session :as-alias session]
[app.util.inet :as inet]
[clojure.spec.alpha :as s]
@@ -22,18 +23,16 @@
(defn request->context
"Extracts error report relevant context data from request."
[request]
(let [claims (-> {}
(into (::session/token-claims request))
(into (::actoken/token-claims request)))]
{:request/path (:path request)
:request/method (:method request)
:request/params (:params request)
:request/user-agent (yreq/get-header request "user-agent")
:request/ip-addr (inet/parse-request request)
:request/profile-id (:uid claims)
:version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")
:version/backend (:full cf/version)}))
(let [{:keys [claims] :as auth} (get request ::http/auth-data)]
(-> (cf/logging-context)
(assoc :request/path (:path request))
(assoc :request/method (:method request))
(assoc :request/params (:params request))
(assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth)
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error
(fn [cause _ _]
@@ -61,7 +60,6 @@
::yres/body data}
(binding [l/*context* (request->context request)]
(l/wrn :hint "restriction error" :cause err)
{::yres/status 400
::yres/body data}))))

View File

@@ -13,7 +13,7 @@
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.http.access-token :refer [get-token]]
[app.http.middleware :as mw]
[app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup]
@@ -32,20 +32,6 @@
[_ params]
(assert (db/pool? (::db/pool params)) "expect valid database pool"))
(def ^:private auth
{:name ::auth
:compile
(fn [_ _]
(fn [handler shared-key]
(if shared-key
(fn [request]
(let [token (get-token request)]
(if (= token shared-key)
(handler request)
{::yres/status 403})))
(fn [_ _]
{::yres/status 403}))))})
(def ^:private default-system
{:name ::default-system
:compile
@@ -64,23 +50,27 @@
(db/tx-run! cfg handler request)))))})
(defmethod ig/init-key ::routes
[_ cfg]
["" {:middleware [[auth (cf/get :management-api-shared-key)]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
[_ {:keys [::setup/props] :as cfg}]
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))]
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]])
["" {:middleware [[mw/shared-key-auth management-key]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
;; ---- HELPERS

View File

@@ -12,8 +12,11 @@
[app.common.schema :as-alias sm]
[app.common.transit :as t]
[app.config :as cf]
[app.http :as-alias http]
[app.http.errors :as errors]
[app.tokens :as tokens]
[app.util.pointer-map :as pmap]
[buddy.core.codecs :as bc]
[cuerdas.core :as str]
[yetti.adapter :as yt]
[yetti.middleware :as ymw]
@@ -240,3 +243,77 @@
(if (contains? allowed method)
(handler request)
{::yres/status 405}))))))})
(defn- wrap-auth
[handler decoders]
(let [token-re
#"(?i)^(Token|Bearer)\s+(.*)"
get-token-from-authorization
(fn [request]
(when-let [[_ token-type token] (some->> (yreq/get-header request "authorization")
(re-matches token-re))]
(if (= "token" (str/lower token-type))
{:type :token
:token token}
{:type :bearer
:token token})))
get-token-from-cookie
(fn [request]
(let [cname (cf/get :auth-token-cookie-name)
token (some-> (yreq/get-cookie request cname) :value)]
(when-not (str/empty? token)
{:type :cookie
:token token})))
get-token
(some-fn get-token-from-cookie get-token-from-authorization)
process-request
(fn [request]
(if-let [{:keys [type token] :as auth} (get-token request)]
(let [decode-fn (get decoders type)]
(if (or (= type :cookie) (= type :bearer))
(let [metadata (tokens/decode-header token)]
;; NOTE: we only proceed to decode claims on new
;; cookie tokens. The old cookies dont need to be
;; decoded because they use the token string as ID
(if (and (= (:kid metadata) 1)
(= (:ver metadata) 1)
(some? decode-fn))
(assoc request ::http/auth-data (assoc auth
:claims (decode-fn token)
:metadata metadata))
(assoc request ::http/auth-data (assoc auth :metadata {:ver 0}))))
(if decode-fn
(assoc request ::http/auth-data (assoc auth :claims (decode-fn token)))
(assoc request ::http/auth-data auth))))
request))]
(fn [request]
(-> request process-request handler))))
(def auth
{:name ::auth
:compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth
[handler shared-key]
(if shared-key
(let [shared-key (if (string? shared-key)
shared-key
(bc/bytes->b64-str shared-key true))]
(fn [request]
(let [key (yreq/get-header request "x-shared-key")]
(if (= key shared-key)
(handler (assoc request ::http/auth-with-shared-key true))
{::yres/status 403}))))
(fn [_ _]
{::yres/status 403})))
(def shared-key-auth
{:name ::shared-key-auth
:compile (constantly wrap-shared-key-auth)})

View File

@@ -11,28 +11,24 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.http :as-alias http]
[app.http.auth :as-alias http.auth]
[app.http.session.tasks :as-alias tasks]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[cuerdas.core :as str]
[integrant.core :as ig]
[yetti.request :as yreq]))
[yetti.request :as yreq]
[yetti.response :as yres]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; A default cookie name for storing the session.
(def default-auth-token-cookie-name "auth-token")
;; A cookie that we can use to check from other sites of the same
;; domain if a user is authenticated.
(def default-auth-data-cookie-name "auth-data")
;; Default value for cookie max-age
(def default-cookie-max-age (ct/duration {:days 7}))
@@ -44,10 +40,10 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defprotocol ISessionManager
(read [_ key])
(write! [_ key data])
(update! [_ data])
(delete! [_ key]))
(read-session [_ id])
(create-session [_ params])
(update-session [_ session])
(delete-session [_ id]))
(defn manager?
[o]
@@ -62,71 +58,82 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:params
[:map {:title "session-params"}
[:user-agent ::sm/text]
[:map {:title "SessionParams" :closed true}
[:profile-id ::sm/uuid]
[:created-at ::ct/inst]])
[:user-agent {:optional true} ::sm/text]
[:sso-provider-id {:optional true} ::sm/uuid]
[:sso-session-id {:optional true} :string]])
(def ^:private valid-params?
(sm/validator schema:params))
(defn- prepare-session-params
[params key]
(assert (string? key) "expected key to be a string")
(assert (not (str/blank? key)) "expected key to be not empty")
(assert (valid-params? params) "expected valid params")
{:user-agent (:user-agent params)
:profile-id (:profile-id params)
:created-at (:created-at params)
:updated-at (:created-at params)
:id key})
(defn- database-manager
[pool]
(reify ISessionManager
(read [_ token]
(db/exec-one! pool (sql/select :http-session {:id token})))
(read-session [_ id]
(if (string? id)
;; Backward compatibility
(let [session (db/exec-one! pool (sql/select :http-session {:id id}))]
(-> session
(assoc :modified-at (:updated-at session))
(dissoc :updated-at)))
(db/exec-one! pool (sql/select :http-session-v2 {:id id}))))
(write! [_ key params]
(let [params (-> params
(assoc :created-at (ct/now))
(prepare-session-params key))]
(db/insert! pool :http-session params)
params))
(create-session [_ params]
(assert (valid-params? params) "expect valid session params")
(update! [_ params]
(let [updated-at (ct/now)]
(db/update! pool :http-session
{:updated-at updated-at}
{:id (:id params)})
(assoc params :updated-at updated-at)))
(let [now (ct/now)
params (-> params
(assoc :id (uuid/next))
(assoc :created-at now)
(assoc :modified-at now))]
(db/insert! pool :http-session-v2 params
{::db/return-keys true})))
(delete! [_ token]
(db/delete! pool :http-session {:id token})
(update-session [_ session]
(let [modified-at (ct/now)]
(if (string? (:id session))
(db/insert! pool :http-session-v2
(-> session
(assoc :id (uuid/next))
(assoc :created-at modified-at)
(assoc :modified-at modified-at)))
(db/update! pool :http-session-v2
{:modified-at modified-at}
{:id (:id session)}
{::db/return-keys true}))))
(delete-session [_ id]
(if (string? id)
(db/delete! pool :http-session {:id id} {::db/return-keys false})
(db/delete! pool :http-session-v2 {:id id} {::db/return-keys false}))
nil)))
(defn inmemory-manager
[]
(let [cache (atom {})]
(reify ISessionManager
(read [_ token]
(get @cache token))
(read-session [_ id]
(get @cache id))
(write! [_ key params]
(let [params (-> params
(assoc :created-at (ct/now))
(prepare-session-params key))]
(swap! cache assoc key params)
params))
(create-session [_ params]
(assert (valid-params? params) "expect valid session params")
(update! [_ params]
(let [updated-at (ct/now)]
(swap! cache update (:id params) assoc :updated-at updated-at)
(assoc params :updated-at updated-at)))
(let [now (ct/now)
session (-> params
(assoc :id (uuid/next))
(assoc :created-at now)
(assoc :modified-at now))]
(swap! cache assoc (:id session) session)
session))
(delete! [_ token]
(swap! cache dissoc token)
(update-session [_ session]
(let [modified-at (ct/now)]
(swap! cache update (:id session) assoc :modified-at modified-at)
(assoc session :modified-at modified-at)))
(delete-session [_ id]
(swap! cache dissoc id)
nil))))
(defmethod ig/assert-key ::manager
@@ -146,103 +153,116 @@
;; MANAGER IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private assign-auth-token-cookie)
(declare ^:private clear-auth-token-cookie)
(declare ^:private gen-token)
(declare ^:private assign-session-cookie)
(declare ^:private clear-session-cookie)
(defn- assign-token
[cfg session]
(let [claims {:iss "authentication"
:aud "penpot"
:sid (:id session)
:iat (:modified-at session)
:uid (:profile-id session)
:sso-provider-id (:sso-provider-id session)
:sso-session-id (:sso-session-id session)}
header {:kid 1 :ver 1}
token (tokens/generate cfg claims header)]
(assoc session :token token)))
(defn create-fn
[{:keys [::manager] :as cfg} profile-id]
[{:keys [::manager] :as cfg} {profile-id :id :as profile}
& {:keys [sso-provider-id sso-session-id]}]
(assert (manager? manager) "expected valid session manager")
(assert (uuid? profile-id) "expected valid uuid for profile-id")
(fn [request response]
(let [uagent (yreq/get-header request "user-agent")
params {:profile-id profile-id
:user-agent uagent}
token (gen-token cfg params)
session (write! manager token params)]
(l/trc :hint "create" :profile-id (str profile-id))
(-> response
(assign-auth-token-cookie session)))))
session (->> {:user-agent uagent
:profile-id profile-id
:sso-provider-id sso-provider-id
:sso-session-id sso-session-id}
(d/without-nils)
(create-session manager)
(assign-token cfg))]
(l/trc :hint "create" :id (str (:id session)) :profile-id (str profile-id))
(assign-session-cookie response session))))
(defn delete-fn
[{:keys [::manager]}]
(assert (manager? manager) "expected valid session manager")
(fn [request response]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (yreq/get-cookie request cname)]
(l/trc :hint "delete" :profile-id (:profile-id request))
(some->> (:value cookie) (delete! manager))
(-> response
(assoc :status 204)
(assoc :body nil)
(clear-auth-token-cookie)))))
(some->> (get request ::id) (delete-session manager))
(clear-session-cookie response)))
(defn- gen-token
[cfg {:keys [profile-id created-at]}]
(tokens/generate cfg {:iss "authentication"
:iat created-at
:uid profile-id}))
(defn- decode-token
(defn decode-token
[cfg token]
(when token
(tokens/verify cfg {:token token :iss "authentication"})))
(try
(tokens/verify cfg {:token token :iss "authentication"})
(catch Throwable cause
(l/trc :hint "exception on decoding token"
:token token
:cause cause))))
(defn- get-token
(defn get-session
[request]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (some-> (yreq/get-cookie request cname) :value)]
(when-not (str/empty? cookie)
cookie)))
(get request ::session))
(defn- get-session
[manager token]
(some->> token (read manager)))
(defn invalidate-others
[cfg session]
(let [sql "delete from http_session_v2 where profile_id = ? and id != ?"]
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count))))
(defn- renew-session?
[{:keys [updated-at] :as session}]
(and (ct/inst? updated-at)
(let [elapsed (ct/diff updated-at (ct/now))]
(neg? (compare default-renewal-max-age elapsed)))))
(defn- wrap-soft-auth
[handler {:keys [::manager] :as cfg}]
(assert (manager? manager) "expected valid session manager")
(letfn [(handle-request [request]
(try
(let [token (get-token request)
claims (decode-token cfg token)]
(cond-> request
(map? claims)
(-> (assoc ::token-claims claims)
(assoc ::token token))))
(catch Throwable cause
(l/trc :hint "exception on decoding malformed token" :cause cause)
request)))]
(fn [request]
(handler (handle-request request)))))
[{:keys [id modified-at] :as session}]
(or (string? id)
(and (ct/inst? modified-at)
(let [elapsed (ct/diff modified-at (ct/now))]
(neg? (compare default-renewal-max-age elapsed))))))
(defn- wrap-authz
[handler {:keys [::manager]}]
[handler {:keys [::manager] :as cfg}]
(assert (manager? manager) "expected valid session manager")
(fn [request]
(let [session (get-session manager (::token request))
request (cond-> request
(some? session)
(assoc ::profile-id (:profile-id session)
::id (:id session)))
response (handler request)]
(let [{:keys [type token claims metadata]} (get request ::http/auth-data)]
(cond
(= type :cookie)
(let [session (case (:ver metadata)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
0 (read-session manager token)
1 (some->> (:sid claims) (read-session manager))
nil)
(if (renew-session? session)
(let [session (update! manager session)]
(-> response
(assign-auth-token-cookie session)))
response))))
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::session session)))
(def soft-auth
{:name ::soft-auth
:compile (constantly wrap-soft-auth)})
response (handler request)]
(if (and session (renew-session? session))
(let [session (->> session
(update-session manager)
(assign-token cfg))]
(assign-session-cookie response session))
response))
(= type :bearer)
(let [session (case (:ver metadata)
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
0 (read-session manager token)
1 (some->> (:sid claims) (read-session manager))
nil)
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::session session)))]
(handler request))
:else
(handler request)))))
(def authz
{:name ::authz
@@ -250,16 +270,16 @@
;; --- IMPL
(defn- assign-auth-token-cookie
[response {token :id updated-at :updated-at}]
(defn- assign-session-cookie
[response {token :token modified-at :modified-at}]
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
created-at updated-at
created-at modified-at
renewal (ct/plus created-at default-renewal-max-age)
expires (ct/plus created-at max-age)
secure? (contains? cf/flags :secure-session-cookies)
strict? (contains? cf/flags :strict-session-cookies)
cors? (contains? cf/flags :cors)
name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
name (cf/get :auth-token-cookie-name)
comment (str "Renewal at: " (ct/format-inst renewal :rfc1123))
cookie {:path "/"
:http-only true
@@ -268,12 +288,12 @@
:comment comment
:same-site (if cors? :none (if strict? :strict :lax))
:secure secure?}]
(update response :cookies assoc name cookie)))
(update response ::yres/cookies assoc name cookie)))
(defn- clear-auth-token-cookie
(defn- clear-session-cookie
[response]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(update response :cookies assoc cname {:path "/" :value "" :max-age 0})))
(let [cname (cf/get :auth-token-cookie-name)]
(update response ::yres/cookies assoc cname {:path "/" :value "" :max-age 0})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK: SESSION GC

View File

@@ -25,7 +25,8 @@
[app.util.inet :as inet]
[app.util.services :as-alias sv]
[app.worker :as wrk]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[yetti.request :as yreq]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
@@ -78,17 +79,32 @@
(remove #(contains? reserved-props (key %))))
props))
(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 {:external-session-id (::rpc/external-session-id params)
:external-event-origin (::rpc/external-event-origin params)
:triggered-by (::rpc/handler-name params)}]
{::type "action"
::profile-id (::rpc/profile-id params)
::ip-addr (::rpc/ip-addr params)
::context (d/without-nils context)}))
(defn get-external-session-id
[request]
(when-let [session-id (yreq/get-header request "x-external-session-id")]
(when-not (or (> (count session-id) 256)
(= session-id "null")
(str/blank? session-id))
session-id)))
(defn- get-client-event-origin
[request]
(when-let [origin (yreq/get-header request "x-event-origin")]
(when-not (or (= origin "null")
(str/blank? origin))
(str/prune origin 200))))
(defn get-client-user-agent
[request]
(when-let [user-agent (yreq/get-header request "user-agent")]
(str/prune user-agent 500)))
(defn- get-client-version
[request]
(when-let [origin (yreq/get-header request "x-frontend-version")]
(when-not (or (= origin "null")
(str/blank? origin))
(str/prune origin 100))))
;; --- SPECS
@@ -117,6 +133,33 @@
(def ^:private check-event
(sm/check-fn schema:event))
(defn- prepare-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)
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
[cfg mdata params result]
(let [resultm (meta result)
@@ -126,23 +169,15 @@
(::rpc/profile-id params)
uuid/zero)
session-id (get params ::rpc/external-session-id)
event-origin (get params ::rpc/external-event-origin)
props (-> (or (::replace-props resultm)
(-> params
(merge (::props resultm))
(dissoc :profile-id)
(dissoc :type)))
(clean-props))
token-id (::actoken/id request)
context (-> (::context resultm)
(assoc :external-session-id session-id)
(assoc :external-event-origin event-origin)
(assoc :access-token-id (some-> token-id str))
(d/without-nils))
context (merge (::context resultm)
(prepare-context-from-request request))
ip-addr (inet/parse-request request)]
{::type (or (::type resultm)

View File

@@ -57,7 +57,7 @@
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (cf/get :public-uri)
"origin" (str (cf/get :public-uri))
"cookie" (u/map->query-string {:auth-token token})}
params {:uri uri
:timeout 12000

View File

@@ -49,7 +49,7 @@
ctx (-> context
(assoc :tenant (cf/get :tenant))
(assoc :host (cf/get :host))
(assoc :public-uri (cf/get :public-uri))
(assoc :public-uri (str (cf/get :public-uri)))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]

View File

@@ -21,7 +21,7 @@
[app.http.client :as-alias http.client]
[app.http.debug :as-alias http.debug]
[app.http.management :as mgmt]
[app.http.session :as-alias session]
[app.http.session :as session]
[app.http.session.tasks :as-alias session.tasks]
[app.http.websocket :as http.ws]
[app.loggers.webhooks :as-alias webhooks]
@@ -31,7 +31,6 @@
[app.redis :as-alias rds]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias rpc.doc]
[app.setup :as-alias setup]
[app.srepl :as-alias srepl]
[app.storage :as-alias sto]
@@ -260,14 +259,17 @@
::oidc.providers/generic
{::http.client/client (ig/ref ::http.client/client)}
::oidc/providers
[(ig/ref ::oidc.providers/google)
(ig/ref ::oidc.providers/github)
(ig/ref ::oidc.providers/gitlab)
(ig/ref ::oidc.providers/generic)]
::oidc/routes
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::setup/props (ig/ref ::setup/props)
::oidc/providers {:google (ig/ref ::oidc.providers/google)
:github (ig/ref ::oidc.providers/github)
:gitlab (ig/ref ::oidc.providers/gitlab)
:oidc (ig/ref ::oidc.providers/generic)}
::oidc/providers (ig/ref ::oidc/providers)
::session/manager (ig/ref ::session/manager)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
@@ -280,7 +282,6 @@
{::session/manager (ig/ref ::session/manager)
::db/pool (ig/ref ::db/pool)
::rpc/routes (ig/ref ::rpc/routes)
::rpc.doc/routes (ig/ref ::rpc.doc/routes)
::setup/props (ig/ref ::setup/props)
::mtx/routes (ig/ref ::mtx/routes)
::oidc/routes (ig/ref ::oidc/routes)
@@ -300,6 +301,7 @@
{::db/pool (ig/ref ::db/pool)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::setup/props (ig/ref ::setup/props)
::session/manager (ig/ref ::session/manager)}
:app.http.assets/routes
@@ -337,14 +339,26 @@
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
:app.rpc.doc/routes
{:app.rpc/methods (ig/ref :app.rpc/methods)}
:app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}
::rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods)
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
{::rpc/methods (ig/ref :app.rpc/methods)
::rpc/management-methods (ig/ref :app.rpc/management-methods)
;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics)

View File

@@ -17,6 +17,7 @@
[app.common.time :as ct]
[app.config :as cf]
[app.db :as-alias db]
[app.http.client :as http]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[buddy.core.bytes :as bb]
@@ -37,6 +38,9 @@
org.im4java.core.IMOperation
org.im4java.core.Info))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
(def schema:upload
[:map {:title "Upload"}
[:filename :string]
@@ -241,7 +245,7 @@
(ex/raise :type :validation
:code :invalid-svg-file
:hint "uploaded svg does not provides dimensions"))
(merge input info {:ts (ct/now)}))
(merge input info {:ts (ct/now) :size (fs/size path)}))
(let [instance (Info. (str path))
mtype' (.getProperty instance "Mime type")]
@@ -261,6 +265,7 @@
(assoc input
:width width
:height height
:size (fs/size path)
:ts (ct/now)))))))
(defmethod process-error org.im4java.core.InfoException
@@ -270,6 +275,54 @@
:hint "invalid image"
:cause error))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn download-image
"Download an image from the provided URI and return the media input object"
[{:keys [::http/client]} uri]
(letfn [(parse-and-validate [{:keys [headers] :as response}]
(let [size (some-> (get headers "content-length") d/parse-integer)
mtype (get headers "content-type")
format (cm/mtype->format mtype)
max-size (cf/get :media-max-file-size default-max-file-size)]
(when-not size
(ex/raise :type :validation
:code :unknown-size
:hint "seems like the url points to resource with unknown size"))
(when (> size max-size)
(ex/raise :type :validation
:code :file-too-large
:hint (str/ffmt "the file size % is greater than the maximum %"
size
default-max-file-size)))
(when (nil? format)
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "seems like the url points to an invalid media object"))
{:size size :mtype mtype :format format}))]
(let [{:keys [body] :as response} (http/req! client
{:method :get :uri uri}
{:response-type :input-stream})
{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
(when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
{;; :size size
:path path
:mtype mtype})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FONTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -450,7 +450,13 @@
:fn (mg/resource "app/migrations/sql/0141-add-idx-to-file-library-rel.sql")}
{:name "0141-add-file-data-table.sql"
:fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")}])
:fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")}
{:name "0142-add-sso-provider-table"
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
{:name "0143-http-session-v2-table"
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@@ -0,0 +1,33 @@
CREATE TABLE sso_provider (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
modified_at timestamptz NOT NULL DEFAULT now(),
is_enabled boolean NOT NULL DEFAULT true,
type text NOT NULL CHECK (type IN ('oidc')),
domain text NOT NULL,
client_id text NOT NULL,
client_secret text NOT NULL,
base_uri text NOT NULL,
token_uri text NULL,
auth_uri text NULL,
user_uri text NULL,
jwks_uri text NULL,
logout_uri text NULL,
roles_attr text NULL,
email_attr text NULL,
name_attr text NULL,
user_info_source text NOT NULL DEFAULT 'token'
CHECK (user_info_source IN ('token', 'userinfo', 'auto')),
scopes text[] NULL,
roles text[] NULL
);
CREATE UNIQUE INDEX sso_provider__domain__idx
ON sso_provider(domain);

View File

@@ -0,0 +1,23 @@
CREATE TABLE http_session_v2 (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
modified_at timestamptz NOT NULL DEFAULT now(),
profile_id uuid REFERENCES profile(id) ON DELETE CASCADE,
user_agent text NULL,
sso_provider_id uuid NULL REFERENCES sso_provider(id) ON DELETE CASCADE,
sso_session_id text NULL
);
CREATE INDEX http_session_v2__profile_id__idx
ON http_session_v2(profile_id);
CREATE INDEX http_session_v2__sso_provider_id__idx
ON http_session_v2(sso_provider_id)
WHERE sso_provider_id IS NOT NULL;
CREATE INDEX http_session_v2__sso_session_id__idx
ON http_session_v2(sso_session_id)
WHERE sso_session_id IS NOT NULL;

View File

@@ -13,11 +13,15 @@
[app.common.schema :as sm]
[app.common.spec :as us]
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token :as actoken]
[app.http.client :as-alias http.client]
[app.http.middleware :as mw]
[app.http.security :as sec]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as-alias main]
@@ -26,6 +30,7 @@
[app.redis :as rds]
[app.rpc.climit :as climit]
[app.rpc.cond :as cond]
[app.rpc.doc :as doc]
[app.rpc.helpers :as rph]
[app.rpc.retry :as retry]
[app.rpc.rlimit :as rlimit]
@@ -36,7 +41,6 @@
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[promesa.core :as p]
[yetti.request :as yreq]
[yetti.response :as yres]))
@@ -44,7 +48,7 @@
(defn- default-handler
[_]
(p/rejected (ex/error :type :not-found)))
(ex/raise :type :not-found))
(defn- handle-response-transformation
[response request mdata]
@@ -64,67 +68,62 @@
(let [mdata (meta result)
response (if (fn? result)
(result request)
(let [result (rph/unwrap result)]
{::yres/status (::http/status mdata 200)
::yres/headers (::http/headers mdata {})
(let [result (rph/unwrap result)
status (or (::http/status mdata)
(if (nil? result)
204
200))
headers (cond-> (::http/headers mdata {})
(yres/stream-body? result)
(assoc "content-type" "application/octet-stream"))]
{::yres/status status
::yres/headers headers
::yres/body result}))]
(-> response
(handle-response-transformation request mdata)
(handle-before-comple-hook mdata))))
(defn get-external-session-id
[request]
(when-let [session-id (yreq/get-header request "x-external-session-id")]
(when-not (or (> (count session-id) 256)
(= session-id "null")
(str/blank? session-id))
session-id)))
(defn- get-external-event-origin
[request]
(when-let [origin (yreq/get-header request "x-event-origin")]
(when-not (or (> (count origin) 256)
(= origin "null")
(str/blank? origin))
origin)))
(defn- rpc-handler
(defn- make-rpc-handler
"Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow."
[methods {:keys [params path-params method] :as request}]
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
[methods]
(let [methods (update-vals methods peek)]
(fn [{:keys [params path-params method] :as request}]
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request)
(if (::http/auth-with-shared-key request)
uuid/zero
nil))
ip-addr (inet/parse-request request)
session-id (get-external-session-id request)
event-origin (get-external-event-origin request)
ip-addr (inet/parse-request request)
data (-> params
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::external-session-id session-id)
(assoc ::external-event-origin event-origin)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (-> params
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (vary-meta data assoc ::http/request request)
handler-fn (get methods (keyword handler-name) default-handler)]
data (with-meta data
{::http/request request})
(when (and (or (= method :get)
(= method :head))
(not (str/starts-with? handler-name "get-")))
(ex/raise :type :restriction
:code :method-not-allowed
:hint "method not allowed for this request"))
handler-fn (get methods (keyword handler-name) default-handler)]
(binding [cond/*enabled* true]
(let [response (handler-fn data)]
(handle-response request response)))))
(when (and (or (= method :get)
(= method :head))
(not (str/starts-with? handler-name "get-")))
(ex/raise :type :restriction
:code :method-not-allowed
:hint "method not allowed for this request"))
;; FIXME: why we have this cond enabled here, we need to move it outside this handler
(binding [cond/*enabled* true]
(let [response (handler-fn data)]
(handle-response request response)))))))
(defn- wrap-metrics
"Wrap service method with metrics measurement."
@@ -201,7 +200,7 @@
::sm/explain (explain params)))))))
f))
(defn- wrap-all
(defn- wrap
[cfg f mdata]
(as-> f $
(wrap-db-transaction cfg $ mdata)
@@ -215,17 +214,30 @@
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(defn- wrap
(defn- wrap-management
[cfg f mdata]
(l/trc :hint "register method" :name (::sv/name mdata))
(let [f (wrap-all cfg f mdata)]
(partial f cfg)))
(as-> f $
(wrap-db-transaction cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata)
(wrap-metrics cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(defn- process-method
[cfg [vfn mdata]]
[(keyword (::sv/name mdata)) [mdata (wrap cfg vfn mdata)]])
[cfg module wrap-fn [f mdata]]
(l/trc :hint "add method" :module module :name (::sv/name mdata))
(let [f (wrap-fn cfg f mdata)
k (keyword (::sv/name mdata))]
[k [mdata (partial f cfg)]]))
(defn- resolve-command-methods
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API METHODS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- resolve-methods
[cfg]
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
(->> (sv/scan-ns
@@ -254,7 +266,7 @@
'app.rpc.commands.verify-token
'app.rpc.commands.viewer
'app.rpc.commands.webhooks)
(map (partial process-method cfg))
(map (partial process-method cfg "rpc" wrap))
(into {}))))
(def ^:private schema:methods-params
@@ -278,7 +290,50 @@
(defmethod ig/init-key ::methods
[_ cfg]
(let [cfg (d/without-nils cfg)]
(resolve-command-methods cfg)))
(resolve-methods cfg)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MANAGEMENT METHODS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- resolve-management-methods
[cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription
'app.rpc.management.exporter)
(map (partial process-method cfg "management" wrap-management))
(into {}))))
(def ^:private schema:management-methods-params
[:map {:title "management-methods-params"}
::session/manager
::http.client/client
::db/pool
::rds/pool
::mbus/msgbus
::sto/storage
::mtx/metrics
::setup/props])
(defmethod ig/assert-key ::management-methods
[_ params]
(assert (sm/check schema:management-methods-params params)))
(defmethod ig/init-key ::management-methods
[_ cfg]
(let [cfg (d/without-nils cfg)]
(resolve-management-methods cfg)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ROUTES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- redirect
[href]
(fn [_]
{::yres/status 308
::yres/headers {"location" (str href)}}))
(def ^:private schema:methods
[:map-of :keyword [:tuple :map ::sm/fn]])
@@ -293,11 +348,50 @@
(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 (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"))
(defmethod ig/init-key ::routes
[_ {:keys [::methods] :as cfg}]
(let [methods (update-vals methods peek)]
[["/rpc" {:middleware [[session/authz cfg]
[actoken/authz cfg]]}
["/command/:type" {:handler (partial rpc-handler methods)}]]]))
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}]
(let [public-uri (cf/get :public-uri)
management-key (or (cf/get :management-api-key)
(get props :management-key))]
["/api"
["/management"
["/methods/:type"
{:middleware [[mw/shared-key-auth management-key]
[session/authz cfg]]
:handler (make-rpc-handler management-methods)}]
(doc/routes :methods management-methods
:label "management"
:base-uri (u/join public-uri "/api/management")
:description "MANAGEMENT API")]
["/main"
["/methods/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
:handler (make-rpc-handler methods)}]
(doc/routes :methods methods
:label "main"
:base-uri (u/join public-uri "/api/main")
:description "MAIN API")]
;; BACKWARD COMPATIBILITY
["/_doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
["/doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
["/rpc/command/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
:handler (make-rpc-handler methods)}]]))

View File

@@ -28,6 +28,7 @@
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
token (tokens/generate cfg {:iss "access-token"
:uid profile-id
:iat created-at
:tid token-id})

View File

@@ -7,21 +7,24 @@
(ns app.rpc.commands.auth
(:require
[app.auth :as auth]
[app.auth.oidc :as oidc]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.email.blacklist :as email.blacklist]
[app.email.whitelist :as email.whitelist]
[app.http :as-alias http]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
@@ -30,6 +33,7 @@
[app.rpc.helpers :as rph]
[app.setup :as-alias setup]
[app.setup.welcome-file :refer [create-welcome-file]]
[app.storage :as sto]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.worker :as wrk]
@@ -109,7 +113,7 @@
(assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))]
(-> response
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))]
@@ -145,7 +149,24 @@
[cfg params]
(if (= (:profile-id params)
(::rpc/profile-id params))
(rph/with-transform {} (session/delete-fn cfg))
(let [{:keys [claims]}
(rph/get-auth-data params)
provider
(some->> (get claims :sso-provider-id)
(oidc/get-provider cfg))
response
(if (and provider (:logout-uri provider))
(let [params {"logout_hint" (get claims :sso-session-id)
"client_id" (get provider :client-id)
"post_logout_redirect_uri" (str (cf/get :public-uri))}
uri (-> (u/uri (:logout-uri provider))
(assoc :query (u/map->query-string params)))]
{:redirect-uri uri})
{})]
(rph/with-transform response (session/delete-fn cfg)))
{}))
;; ---- COMMAND: Recover Profile
@@ -271,11 +292,30 @@
;; ---- COMMAND: Register Profile
(defn create-profile!
(defn import-profile-picture
[cfg uri]
(try
(let [storage (sto/resolve cfg)
input (media/download-image cfg uri)
input (media/run {:cmd :info :input input})
hash (sto/calculate-hash (:path input))
content (-> (sto/content (:path input) (:size input))
(sto/wrap-with-hash hash))
sobject (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
:bucket "profile"
:content-type (:mtype input)})]
(:id sobject))
(catch Throwable cause
(l/wrn :hint "unable to import profile picture"
:uri uri
:cause cause)
nil)))
(defn create-profile
"Create the profile entry on the database with limited set of input
attrs (all the other attrs are filled with default values)."
[conn {:keys [email] :as params}]
(dm/assert! ::sm/email email)
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
(let [id (or (:id params) (uuid/next))
props (-> (audit/extract-utm-params params)
(merge (:props params))
@@ -283,8 +323,7 @@
:viewed-walkthrough? false
:nudge {:big 10 :small 1}
:v2-info-shown true
:release-notes-viewed (:main cf/version)})
(db/tjson))
:release-notes-viewed (:main cf/version)}))
password (or (:password params) "!")
@@ -299,6 +338,12 @@
theme (:theme params nil)
email (str/lower email)
photo-id (some->> (or (:oidc/picture props)
(:google/picture props)
(:github/picture props)
(:gitlab/picture props))
(import-profile-picture cfg))
params {:id id
:fullname (:fullname params)
:email email
@@ -306,27 +351,26 @@
:lang locale
:password password
:deleted-at (:deleted-at params)
:props props
:props (db/tjson props)
:theme theme
:photo-id photo-id
:is-active is-active
:is-muted is-muted
:is-demo is-demo}]
(try
(-> (db/insert! conn :profile params)
(profile/decode-row))
(catch org.postgresql.util.PSQLException cause
(let [state (.getSQLState cause)]
(if (not= state "23505")
(throw cause)
(if (db/duplicate-key-error? cause)
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"
:cause cause)
(throw cause))))))
(do
(l/error :hint "not an error" :cause cause)
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"
:cause cause))))))))
(defn create-profile-rels!
(defn create-profile-rels
[conn {:keys [id] :as profile}]
(let [features (cfeat/get-enabled-features cf/flags)
team (teams/create-team conn
@@ -376,12 +420,13 @@
;; to detect if the profile is already registered
(or (profile/get-profile-by-email conn (:email claims))
(let [is-active (or (boolean (:is-active claims))
(boolean (:email-verified claims))
(not (contains? cf/flags :email-verification)))
params (-> params
(assoc :is-active is-active)
(update :password auth/derive-password))
profile (->> (create-profile! conn params)
(create-profile-rels! conn))]
profile (->> (create-profile cfg params)
(create-profile-rels conn))]
(vary-meta profile assoc :created true))))
created? (-> profile meta :created true?)
@@ -419,10 +464,10 @@
(and (some? invitation)
(= (:email profile)
(:member-email invitation)))
(let [claims (assoc invitation :member-id (:id profile))
token (tokens/generate cfg claims)]
(let [invitation (assoc invitation :member-id (:id profile))
token (tokens/generate cfg invitation)]
(-> {:invitation-token token}
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-transform (session/create-fn cfg profile claims))
(rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"}
::audit/profile-id (:id profile)})))
@@ -433,7 +478,7 @@
created?
(if (:is-active profile)
(-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-transform (session/create-fn cfg profile claims))
(rph/with-defer create-welcome-file-when-needed)
(rph/with-meta
{::audit/replace-props props
@@ -562,4 +607,32 @@
[cfg params]
(db/tx-run! cfg request-profile-recovery params))
;; --- COMMAND: get-sso-config
(defn- extract-domain
"Extract the domain part from email"
[email]
(let [at (str/last-index-of email "@")]
(when (and (>= at 0)
(< at (dec (count email))))
(-> (subs email (inc at))
(str/trim)
(str/lower)))))
(def ^:private schema:get-sso-provider
[:map {:title "get-sso-config"}
[:email ::sm/email]])
(def ^:private schema:get-sso-provider-result
[:map {:title "SSOProvider"}
[:id ::sm/uuid]])
(sv/defmethod ::get-sso-provider
{::rpc/auth false
::doc/added "2.12"
::sm/params schema:get-sso-provider
::sm/result schema:get-sso-provider-result}
[cfg {:keys [email]}]
(when-let [domain (extract-domain email)]
(when-let [config (db/get* cfg :sso-provider {:domain domain})]
(select-keys config [:id]))))

View File

@@ -11,9 +11,9 @@
[app.binfile.v1 :as bf.v1]
[app.binfile.v3 :as bf.v3]
[app.common.features :as cfeat]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.http.sse :as sse]
@@ -25,10 +25,12 @@
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.tasks.file-gc]
[app.util.services :as sv]
[app.worker :as-alias wrk]
[yetti.response :as yres]))
[datoteka.fs :as fs]))
(set! *warn-on-reflection* true)
@@ -38,57 +40,42 @@
schema:export-binfile
[:map {:title "export-binfile"}
[:file-id ::sm/uuid]
[:version {:optional true} ::sm/int]
[:include-libraries ::sm/boolean]
[:embed-assets ::sm/boolean]])
(defn stream-export-v1
[cfg {:keys [file-id include-libraries embed-assets] :as params}]
(yres/stream-body
(fn [_ output-stream]
(try
(-> cfg
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets embed-assets)
(assoc ::bfc/include-libraries include-libraries)
(bf.v1/export-files! output-stream))
(catch Throwable cause
(l/err :hint "exception on exporting file"
:file-id (str file-id)
:cause cause))))))
(defn- export-binfile
[{:keys [::sto/storage] :as cfg} {:keys [file-id include-libraries embed-assets]}]
(let [output (tmp/tempfile*)]
(try
(-> cfg
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets embed-assets)
(assoc ::bfc/include-libraries include-libraries)
(bf.v3/export-files! output))
(defn stream-export-v3
[cfg {:keys [file-id include-libraries embed-assets] :as params}]
(yres/stream-body
(fn [_ output-stream]
(try
(-> cfg
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets embed-assets)
(assoc ::bfc/include-libraries include-libraries)
(bf.v3/export-files! output-stream))
(catch Throwable cause
(l/err :hint "exception on exporting file"
:file-id (str file-id)
:cause cause))))))
(let [data (sto/content output)
object (sto/put-object! storage
{::sto/content data
::sto/touched-at (ct/in-future {:minutes 60})
:content-type "application/zip"
:bucket "tempfile"})]
(-> (cf/get :public-uri)
(u/join "/assets/by-id/")
(u/join (str (:id object)))))
(finally
(fs/delete output)))))
(sv/defmethod ::export-binfile
"Export a penpot file in a binary format."
{::doc/added "1.15"
::doc/changes [["2.12" "Remove version parameter, only one version is supported"]]
::webhooks/event? true
::sm/params schema:export-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id version file-id] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(files/check-read-permissions! pool profile-id file-id)
(fn [_]
(let [version (or version 1)
body (case (int version)
1 (stream-export-v1 cfg params)
2 (throw (ex-info "not-implemented" {}))
3 (stream-export-v3 cfg params))]
{::yres/status 200
::yres/headers {"content-type" "application/octet-stream"}
::yres/body body})))
(sse/response (partial export-binfile cfg params)))
;; --- Command: import-binfile

View File

@@ -291,7 +291,7 @@
(defn- get-unread-comment-threads
[cfg profile-id team-id]
(let [profile (-> (db/get cfg :profile {:id profile-id})
(let [profile (-> (db/get cfg :profile {:id profile-id} ::db/remove-deleted false)
(profile/decode-row))
notify (or (-> profile :props :notifications :dashboard-comments) :all)
result (case notify

View File

@@ -39,18 +39,19 @@
fullname (str "Demo User " sem)
password (-> (bn/random-bytes 16)
(bc/bytes->b64u)
(bc/bytes->b64 true)
(bc/bytes->str))
params {:email email
:fullname fullname
:is-active true
:is-demo true
:deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password)
:props {}}
profile (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(->> (auth/create-profile! conn params)
(auth/create-profile-rels! conn))))]
profile (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels conn))))]
(with-meta {:email email
:password password}
{::audit/profile-id (:id profile)})))

View File

@@ -26,6 +26,7 @@
[app.db.sql :as-alias sql]
[app.features.fdata :as feat.fdata]
[app.features.logical-deletion :as ldel]
[app.http.sse :as sse]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.msgbus :as mbus]
@@ -38,6 +39,7 @@
[app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.util.blob :as blob]
[app.util.events :as events]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.worker :as wrk]
@@ -77,85 +79,14 @@
;; --- FILE PERMISSIONS
(def ^:private sql:file-permissions
"select fpr.is_owner,
fpr.is_admin,
fpr.can_edit
from file_profile_rel as fpr
inner join file as f on (f.id = fpr.file_id)
where fpr.file_id = ?
and fpr.profile_id = ?
and f.deleted_at is null
union all
select tpr.is_owner,
tpr.is_admin,
tpr.can_edit
from team_profile_rel as tpr
inner join project as p on (p.team_id = tpr.team_id)
inner join file as f on (p.id = f.project_id)
where f.id = ?
and tpr.profile_id = ?
and f.deleted_at is null
union all
select ppr.is_owner,
ppr.is_admin,
ppr.can_edit
from project_profile_rel as ppr
inner join file as f on (f.project_id = ppr.project_id)
where f.id = ?
and ppr.profile_id = ?
and f.deleted_at is null")
(defn get-file-permissions
[conn profile-id file-id]
(when (and profile-id file-id)
(db/exec! conn [sql:file-permissions
file-id profile-id
file-id profile-id
file-id profile-id])))
(defn get-permissions
([conn profile-id file-id]
(let [rows (get-file-permissions conn profile-id file-id)
is-owner (boolean (some :is-owner rows))
is-admin (boolean (some :is-admin rows))
can-edit (boolean (some :can-edit rows))]
(when (seq rows)
{:type :membership
:is-owner is-owner
:is-admin (or is-owner is-admin)
:can-edit (or is-owner is-admin can-edit)
:can-read true
:is-logged (some? profile-id)})))
([conn profile-id file-id share-id]
(let [perms (get-permissions conn profile-id file-id)
ldata (some-> (db/get* conn :share-link {:id share-id :file-id file-id})
(dissoc :flags)
(update :pages db/decode-pgarray #{}))]
;; NOTE: in a future when share-link becomes more powerful and
;; will allow us specify which parts of the app is available, we
;; will probably need to tweak this function in order to expose
;; this flags to the frontend.
(cond
(some? perms) perms
(some? ldata) {:type :share-link
:can-read true
:pages (:pages ldata)
:is-logged (some? profile-id)
:who-comment (:who-comment ldata)
:who-inspect (:who-inspect ldata)}))))
(def has-edit-permissions?
(perms/make-edition-predicate-fn get-permissions))
(perms/make-edition-predicate-fn bfc/get-file-permissions))
(def has-read-permissions?
(perms/make-read-predicate-fn get-permissions))
(perms/make-read-predicate-fn bfc/get-file-permissions))
(def has-comment-permissions?
(perms/make-comment-predicate-fn get-permissions))
(perms/make-comment-predicate-fn bfc/get-file-permissions))
(def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?))
@@ -168,7 +99,7 @@
(defn check-comment-permissions!
[conn profile-id file-id share-id]
(let [perms (get-permissions conn profile-id file-id share-id)
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
can-read (has-read-permissions? perms)
can-comment (has-comment-permissions? perms)]
(when-not (or can-read can-comment)
@@ -220,7 +151,7 @@
(defn- get-minimal-file-with-perms
[cfg {:keys [:id ::rpc/profile-id]}]
(let [mfile (get-minimal-file cfg id)
perms (get-permissions cfg profile-id id)]
perms (bfc/get-file-permissions cfg profile-id id)]
(assoc mfile :permissions perms)))
(defn get-file-etag
@@ -246,7 +177,7 @@
;; will be already prefetched and we just reuse them instead
;; of making an additional database queries.
(let [perms (or (:permissions (::cond/object params))
(get-permissions conn profile-id id))]
(bfc/get-file-permissions conn profile-id id))]
(check-read-permissions! perms)
(let [team (teams/get-team conn
@@ -309,7 +240,7 @@
::sm/result schema:file-fragment}
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
(db/run! cfg (fn [cfg]
(let [perms (get-permissions cfg profile-id file-id share-id)]
(let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)]
(check-read-permissions! perms)
(-> (get-file-fragment cfg file-id fragment-id)
(rph/with-http-cache long-cache-duration))))))
@@ -353,9 +284,8 @@
::sm/params schema:get-project-files
::sm/result schema:files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}]
(dm/with-open [conn (db/open pool)]
(projects/check-read-permissions! conn profile-id project-id)
(get-project-files conn project-id)))
(projects/check-read-permissions! pool profile-id project-id)
(get-project-files pool project-id))
;; --- COMMAND QUERY: has-file-libraries
@@ -424,7 +354,6 @@
;; --- QUERY COMMAND: get-page
(defn- prune-objects
"Given the page data and the object-id returns the page data with all
other not needed objects removed from the `:objects` data
@@ -456,8 +385,7 @@
:code :params-validation
:hint "page-id is required when object-id is provided"))
(let [perms (get-permissions conn profile-id file-id share-id)
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
file (bfc/get-file cfg file-id :read-only? true)
proj (db/get conn :project {:id (:project-id file)})
@@ -688,11 +616,10 @@
"Get libraries used by the specified file."
{::doc/added "1.17"
::sm/params schema:get-file-libraries}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id file-id)
(bfc/get-file-libraries conn file-id)))
[cfg {:keys [::rpc/profile-id file-id]}]
(bfc/check-file-exists cfg file-id)
(check-read-permissions! cfg profile-id file-id)
(bfc/get-file-libraries cfg file-id))
;; --- COMMAND QUERY: Files that use this File library
@@ -765,6 +692,53 @@
(teams/check-read-permissions! conn profile-id team-id)
(get-team-recent-files conn team-id)))
;; --- COMMAND QUERY: get-team-deleted-files
(def sql:team-deleted-files
"WITH deleted_files AS (
SELECT f.id,
f.revn,
f.vern,
f.project_id,
f.created_at,
f.modified_at,
f.name,
f.is_shared,
f.deleted_at AS will_be_deleted_at,
ft.media_id AS thumbnail_id,
row_number() OVER w AS row_num,
p.team_id
FROM file AS f
INNER JOIN project AS p ON (p.id = f.project_id)
LEFT JOIN file_thumbnail AS ft on (ft.file_id = f.id
AND ft.revn = f.revn)
WHERE p.team_id = ?
AND (p.deleted_at > ?::timestamptz OR
f.deleted_at > ?::timestamptz)
WINDOW w AS (PARTITION BY f.project_id
ORDER BY f.modified_at DESC)
ORDER BY f.modified_at DESC
)
SELECT * FROM deleted_files")
(defn get-team-deleted-files
[conn team-id]
(let [now (ct/now)]
(db/exec! conn [sql:team-deleted-files team-id now now])))
(def ^:private schema:get-team-deleted-files
[:map {:title "get-team-deleted-files"}
[:team-id ::sm/uuid]])
(sv/defmethod ::get-team-deleted-files
{::doc/added "2.12"
::sm/params schema:get-team-deleted-files}
[cfg {:keys [::rpc/profile-id team-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(teams/check-read-permissions! conn profile-id team-id)
(get-team-deleted-files conn team-id))))
;; --- COMMAND QUERY: get-file-info
@@ -1113,3 +1087,138 @@
(check-edition-permissions! conn profile-id file-id)
(-> (ignore-sync conn params)
(update :features db/decode-pgarray #{})))
;; --- MUTATION COMMAND: delete-files-immediatelly
(def ^:private sql:delete-team-files
"UPDATE file AS uf SET deleted_at = ?::timestamptz
FROM (
SELECT f.id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
JOIN team AS t ON (t.id = p.team_id)
WHERE t.deleted_at IS NULL
AND t.id = ?
AND f.id = ANY(?::uuid[])
) AS subquery
WHERE uf.id = subquery.id
RETURNING uf.id, uf.deleted_at;")
(def ^:private schema:permanently-delete-team-files
[:map {:title "permanently-delete-team-files"}
[:team-id ::sm/uuid]
[:ids [::sm/set ::sm/uuid]]])
(sv/defmethod ::permanently-delete-team-files
"Mark the specified files to be deleted immediatelly on the
specified team. The team-id on params will be used to filter and
check writable permissons on team."
{::doc/added "2.12"
::sm/params schema:permanently-delete-team-files
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at team-id ids]}]
(teams/check-edition-permissions! conn profile-id team-id)
(reduce (fn [acc {:keys [id deleted-at]}]
(wrk/submit! {::db/conn conn
::wrk/task :delete-object
::wrk/params {:object :file
:deleted-at deleted-at
:id id}})
(conj acc id))
#{}
(db/plan conn [sql:delete-team-files request-at team-id
(db/create-array conn "uuid" ids)])))
;; --- MUTATION COMMAND: restore-files-immediatelly
(def ^:private sql:resolve-editable-files
"SELECT f.id, f.project_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
JOIN team AS t ON (t.id = p.team_id)
WHERE t.deleted_at IS NULL
AND t.id = ?
AND f.id = ANY(?::uuid[])")
(defn- restore-file
[conn file-id]
(db/update! conn :file
{:deleted-at nil
:has-media-trimmed false}
{:id file-id}
{::db/return-keys false})
(db/update! conn :file-media-object
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-change
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-data
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-tagged-object-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false}))
(def ^:private sql:restore-projects
"UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])")
(defn- restore-projects
[conn project-ids]
(let [project-ids (db/create-array conn "uuid" project-ids)]
(->> (db/exec-one! conn [sql:restore-projects project-ids])
(db/get-update-count))))
(defn- restore-deleted-team-files
[{:keys [::db/conn]} {:keys [::rpc/profile-id team-id ids]}]
(teams/check-edition-permissions! conn profile-id team-id)
(let [total-files
(count ids)
{:keys [files projects]}
(reduce (fn [result {:keys [id project-id]}]
(let [index (-> result :files count)]
(events/tap :progress {:file-id id :index index :total total-files})
(restore-file conn id)
(-> result
(update :files conj id)
(update :projects conj project-id))))
{:files #{} :projectes #{}}
(db/plan conn [sql:resolve-editable-files team-id
(db/create-array conn "uuid" ids)]))]
(restore-projects conn projects)
files))
(def ^:private schema:restore-deleted-team-files
[:map {:title "restore-deleted-team-files"}
[:team-id ::sm/uuid]
[:ids [::sm/set ::sm/uuid]]])
(sv/defmethod ::restore-deleted-team-files
"Removes the deletion mark from the specified files (and respective
projects) on the specified team."
{::doc/added "2.12"
::sse/stream? true
::sm/params schema:restore-deleted-team-files}
[cfg params]
(sse/response #(db/tx-run! cfg restore-deleted-team-files params)))

View File

@@ -96,7 +96,7 @@
;; loading all pages into memory for find the frame set for thumbnail.
(defn get-file-data-for-thumbnail
[{:keys [::db/conn] :as cfg} {:keys [data id] :as file}]
[{:keys [::db/conn] :as cfg} {:keys [data id] :as file} strip-frames-with-thumbnails]
(letfn [;; function responsible on finding the frame marked to be
;; used as thumbnail; the returned frame always have
;; the :page-id set to the page that it belongs.
@@ -173,7 +173,7 @@
;; Assoc the available thumbnails and prune not visible shapes
;; for avoid transfer unnecessary data.
:always
strip-frames-with-thumbnails
(update :objects assoc-thumbnails page-id thumbs)))))
(def ^:private
@@ -186,7 +186,8 @@
[:map {:title "PartialFile"}
[:id ::sm/uuid]
[:revn {:min 0} ::sm/int]
[:page [:map-of :keyword ::sm/any]]])
[:page [:map-of :keyword ::sm/any]]
[:strip-frames-with-thumbnails {:optional true} ::sm/boolean]])
(sv/defmethod ::get-file-data-for-thumbnail
"Retrieves the data for generate the thumbnail of the file. Used
@@ -195,24 +196,26 @@
::doc/module :files
::sm/params schema:get-file-data-for-thumbnail
::sm/result schema:partial-file}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! conn profile-id file-id)
(let [team (teams/get-team conn
:profile-id profile-id
:file-id file-id)
file (bfc/get-file cfg file-id
:include-deleted? true
:realize? true
:read-only? true)]
:read-only? true)
strip-frames-with-thumbnails
(or (nil? strip-frames-with-thumbnails) ;; if not present, default to true
(true? strip-frames-with-thumbnails))]
(-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-file-features! (:features file)))
{:file-id file-id
:revn (:revn file)
:page (get-file-data-for-thumbnail cfg file)}))))
:page (get-file-data-for-thumbnail cfg file strip-frames-with-thumbnails)}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MUTATION COMMANDS
@@ -328,12 +331,16 @@
;; --- MUTATION COMMAND: create-file-thumbnail
(defn- create-file-thumbnail!
[{:keys [::db/conn ::sto/storage]} {:keys [file-id revn props media] :as params}]
(defn- create-file-thumbnail
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}]
(media/validate-media-type! media)
(media/validate-media-size! media)
(let [props (db/tjson (or props {}))
(let [file (bfc/get-file cfg file-id
:include-deleted? true
:load-data? false)
props (db/tjson (or props {}))
path (:path media)
mtype (:mtype media)
hash (sto/calculate-hash path)
@@ -362,7 +369,7 @@
(db/update! conn :file-thumbnail
{:media-id (:id media)
:deleted-at nil
:deleted-at (:deleted-at file)
:updated-at tnow
:props props}
{:file-id file-id
@@ -373,6 +380,7 @@
:revn revn
:created-at tnow
:updated-at tnow
:deleted-at (:deleted-at file)
:props props
:media-id (:id media)}))
@@ -397,6 +405,8 @@
::rtry/when rtry/conflict-exception?
::sm/params schema:create-file-thumbnail}
;; FIXME: do not run the thumbnail upload inside a transaction
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; TODO For now we check read permissions instead of write,
@@ -404,6 +414,6 @@
;; review this approach on the future.
(files/check-read-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [media (create-file-thumbnail! cfg params)]
(let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media))
:id (:id media)})))))

View File

@@ -6,6 +6,7 @@
(ns app.rpc.commands.fonts
(:require
[app.binfile.common :as bfc]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -66,7 +67,7 @@
(uuid? file-id)
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]})
project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]})
perms (files/get-permissions conn profile-id file-id share-id)]
perms (bfc/get-file-permissions conn profile-id file-id share-id)]
(files/check-read-permissions! perms)
(db/query conn :team-font-variant
{:team-id (:team-id project)

View File

@@ -66,12 +66,12 @@
:member-email (:email profile))
token (tokens/generate cfg claims)]
(-> {:invitation-token token}
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/props (:props profile)
::audit/profile-id (:id profile)})))
(-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/props (:props profile)
::audit/profile-id (:id profile)}))))))
@@ -83,6 +83,6 @@
(profile/clean-email)
(profile/get-profile-by-email conn))
(->> (assoc info :is-active true :is-demo false)
(auth/create-profile! conn)
(auth/create-profile-rels! conn)
(auth/create-profile cfg)
(auth/create-profile-rels conn)
(profile/strip-private-attrs))))))

View File

@@ -7,14 +7,10 @@
(ns app.rpc.commands.media
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.client :as http]
[app.loggers.audit :as-alias audit]
[app.media :as media]
[app.rpc :as-alias rpc]
@@ -22,13 +18,7 @@
[app.rpc.commands.files :as files]
[app.rpc.doc :as-alias doc]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[cuerdas.core :as str]
[datoteka.io :as io]))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
[app.util.services :as sv]))
(def thumbnail-options
{:width 100
@@ -197,56 +187,12 @@
mobj))
(defn download-image
[{:keys [::http/client]} uri]
(letfn [(parse-and-validate [{:keys [headers] :as response}]
(let [size (some-> (get headers "content-length") d/parse-integer)
mtype (get headers "content-type")
format (cm/mtype->format mtype)
max-size (cf/get :media-max-file-size default-max-file-size)]
(when-not size
(ex/raise :type :validation
:code :unknown-size
:hint "seems like the url points to resource with unknown size"))
(when (> size max-size)
(ex/raise :type :validation
:code :file-too-large
:hint (str/ffmt "the file size % is greater than the maximum %"
size
default-max-file-size)))
(when (nil? format)
(ex/raise :type :validation
:code :media-type-not-allowed
:hint "seems like the url points to an invalid media object"))
{:size size :mtype mtype :format format}))]
(let [{:keys [body] :as response} (http/req! client
{:method :get :uri uri}
{:response-type :input-stream :sync? true})
{:keys [size mtype]} (parse-and-validate response)
path (tmp/tempfile :prefix "penpot.media.download.")
written (io/write* path body :size size)]
(when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
{:filename "tempfile"
:size size
:path path
:mtype mtype})))
(defn- create-file-media-object-from-url
[cfg {:keys [url name] :as params}]
(let [content (download-image cfg url)
(let [content (media/download-image cfg url)
params (-> params
(assoc :content content)
(assoc :name (or name (:filename content))))]
(assoc :name (d/nilv name "unknown")))]
;; NOTE: we use the climit here in a dynamic invocation because we
;; don't want saturate the process-image limit with IO (download

View File

@@ -107,7 +107,9 @@
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
[conn id & {:as opts}]
(-> (db/get-by-id conn :profile id opts)
;; NOTE: We need to set ::db/remove-deleted to false because demo profiles
;; are created with a set deleted-at value
(-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false))
(decode-row)))
;; --- MUTATION: Update Profile (own)
@@ -152,7 +154,6 @@
(declare validate-password!)
(declare update-profile-password!)
(declare invalidate-profile-session!)
(def ^:private
schema:update-profile-password
@@ -167,8 +168,7 @@
::climit/id :auth/global
::db/transaction true}
[cfg {:keys [::rpc/profile-id password] :as params}]
(let [profile (validate-password! cfg (assoc params :profile-id profile-id))
session-id (::session/id params)]
(let [profile (validate-password! cfg (assoc params :profile-id profile-id))]
(when (= (:email profile) (str/lower (:password params)))
(ex/raise :type :validation
@@ -176,14 +176,12 @@
:hint "you can't use your email as password"))
(update-profile-password! cfg (assoc profile :password password))
(invalidate-profile-session! cfg profile-id session-id)
nil))
(defn- invalidate-profile-session!
"Removes all sessions except the current one."
[{:keys [::db/conn]} profile-id session-id]
(let [sql "delete from http_session where profile_id = ? and id != ?"]
(:next.jdbc/update-count (db/exec-one! conn [sql profile-id session-id]))))
(->> (rph/get-request params)
(session/get-session)
(session/invalidate-others cfg))
nil))
(defn- validate-password!
[{:keys [::db/conn] :as cfg} {:keys [profile-id old-password] :as params}]
@@ -282,9 +280,9 @@
:file-path (str (:path file))
:file-mtype (:mtype file)}}))))
(defn- generate-thumbnail!
[_ file]
(let [input (media/run {:cmd :info :input file})
(defn- generate-thumbnail
[_ input]
(let [input (media/run {:cmd :info :input input})
thumb (media/run {:cmd :profile-thumbnail
:format :jpeg
:quality 85
@@ -305,7 +303,7 @@
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
[:process-image/global]])
(assoc ::climit/label "upload-photo")
(climit/invoke! generate-thumbnail! file))]
(climit/invoke! generate-thumbnail file))]
(sto/put-object! storage params)))
;; --- MUTATION: Request Email Change
@@ -473,13 +471,16 @@
p.fullname AS name,
p.email AS email
FROM team_profile_rel AS tpr1
JOIN team as t
ON tpr1.team_id = t.id
JOIN team_profile_rel AS tpr2
ON (tpr1.team_id = tpr2.team_id)
JOIN profile AS p
ON (tpr2.profile_id = p.id)
WHERE tpr1.profile_id = ?
AND tpr1.is_owner IS true
AND tpr2.can_edit IS true")
AND tpr2.can_edit IS true
AND t.deleted_at IS NULL")
(sv/defmethod ::get-subscription-usage
{::doc/added "2.9"}

View File

@@ -70,7 +70,27 @@
;; --- QUERY: Get projects
(declare get-projects)
(def ^:private sql:projects
"SELECT p.*,
coalesce(tpp.is_pinned, false) as is_pinned,
(SELECT count(*) FROM file AS f
WHERE f.project_id = p.id
AND f.deleted_at is null) AS count,
(SELECT count(*) FROM file AS f
WHERE f.project_id = p.id) AS total_count
FROM project AS p
INNER JOIN team AS t ON (t.id = p.team_id)
LEFT JOIN team_project_profile_rel AS tpp
ON (tpp.project_id = p.id AND
tpp.team_id = p.team_id AND
tpp.profile_id = ?)
WHERE p.team_id = ?
AND t.deleted_at is null
ORDER BY p.modified_at DESC")
(defn get-projects
[conn profile-id team-id]
(db/exec! conn [sql:projects profile-id team-id]))
(def ^:private schema:get-projects
[:map {:title "get-projects"}
@@ -78,32 +98,11 @@
(sv/defmethod ::get-projects
{::doc/added "1.18"
::doc/changes [["2.12" "This endpoint now return deleted but recoverable projects"]]
::sm/params schema:get-projects}
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(teams/check-read-permissions! conn profile-id team-id)
(get-projects conn profile-id team-id)))
(def sql:projects
"select p.*,
coalesce(tpp.is_pinned, false) as is_pinned,
(select count(*) from file as f
where f.project_id = p.id
and deleted_at is null) as count
from project as p
inner join team as t on (t.id = p.team_id)
left join team_project_profile_rel as tpp
on (tpp.project_id = p.id and
tpp.team_id = p.team_id and
tpp.profile_id = ?)
where p.team_id = ?
and p.deleted_at is null
and t.deleted_at is null
order by p.modified_at desc")
(defn get-projects
[conn profile-id team-id]
(db/exec! conn [sql:projects profile-id team-id]))
[cfg {:keys [::rpc/profile-id team-id]}]
(teams/check-read-permissions! cfg profile-id team-id)
(get-projects cfg profile-id team-id))
;; --- QUERY: Get all projects
@@ -170,12 +169,19 @@
;; --- MUTATION: Create Project
(defn- create-project
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
(let [project (teams/create-project conn params)]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/request-at profile-id team-id] :as params}]
(assert (ct/inst? request-at) "expect request-at assigned")
(let [params (-> params
(assoc :created-at request-at)
(assoc :modified-at request-at))
project (teams/create-project conn params)
timestamp (::rpc/request-at params)]
(teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel
{:project-id (:id project)
:profile-id profile-id
:created-at timestamp
:modified-at timestamp
:team-id team-id
:is-pinned false})
(assoc project :is-pinned false)))

View File

@@ -37,14 +37,14 @@
;; --- Helpers & Specs
(def ^:private sql:team-permissions
"select tpr.is_owner,
"SELECT tpr.is_owner,
tpr.is_admin,
tpr.can_edit
from team_profile_rel as tpr
join team as t on (t.id = tpr.team_id)
where tpr.profile_id = ?
and tpr.team_id = ?
and t.deleted_at is null")
FROM team_profile_rel AS tpr
JOIN team AS t ON (t.id = tpr.team_id)
WHERE tpr.profile_id = ?
AND tpr.team_id = ?
AND t.deleted_at IS NULL")
(defn get-permissions
[conn profile-id team-id]

View File

@@ -73,7 +73,7 @@
{:id (:id profile)}))
(-> claims
(rph/with-transform (session/create-fn cfg profile-id))
(rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/name "verify-profile-email"
::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))

View File

@@ -13,7 +13,6 @@
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
[app.rpc.doc :as-alias doc]
@@ -121,7 +120,7 @@
[system {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! system
(fn [{:keys [::db/conn] :as system}]
(let [perms (files/get-permissions conn profile-id file-id share-id)
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
params (-> params
(assoc ::perms perms)
(assoc :profile-id profile-id))]

View File

@@ -39,9 +39,8 @@
(defn- encode
[s]
(-> s
bh/blake2b-256
bc/bytes->b64u
bc/bytes->str))
(bh/blake2b-256)
(bc/bytes->b64-str true)))
(defn- fmt-key
[s]

View File

@@ -16,6 +16,7 @@
[app.common.schema.desc-native :as smdn]
[app.common.schema.openapi :as oapi]
[app.common.schema.registry :as sr]
[app.common.uri :as u]
[app.config :as cf]
[app.http.sse :as-alias sse]
[app.loggers.webhooks :as-alias webhooks]
@@ -25,7 +26,6 @@
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[pretty-spec.core :as ps]
[yetti.response :as-alias yres]))
@@ -33,8 +33,8 @@
;; DOC (human readable)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- prepare-doc-context
[methods]
(defn- context
[{:keys [methods entrypoint label openapi]}]
(letfn [(fmt-spec [mdata]
(when-let [spec (ex/ignoring (s/spec (::sv/spec mdata)))]
(with-out-str
@@ -62,8 +62,10 @@
:added (::added mdata)
:changes (some->> (::changes mdata) (partition-all 2) (map vec))
:spec (fmt-spec mdata)
:entrypoint (str (cf/get :public-uri) "/api/rpc/command/" (::sv/name mdata))
:entrypoint (-> entrypoint
(u/ensure-path-slash)
(u/join (::sv/name mdata))
(str))
:params-schema-js (fmt-schema :js mdata ::sm/params)
:result-schema-js (fmt-schema :js mdata ::sm/result)
:webhook-schema-js (fmt-schema :js mdata ::sm/webhook)
@@ -72,6 +74,9 @@
:webhook-schema-clj (fmt-schema :clj mdata ::sm/webhook)})]
{:version (:main cf/version)
:label label
:entrypoint (str entrypoint)
:openapi (str openapi)
:methods
(->> methods
(map val)
@@ -80,17 +85,19 @@
(map get-context)
(sort-by (juxt :module :name)))}))
(defn- doc-handler
[context]
(defn- handler
[& {:keys [template] :as options}]
(if (contains? cf/flags :backend-api-doc)
(fn [request]
(let [params (:query-params request)
pstyle (:type params "js")
context (assoc @context :param-style pstyle)]
(let [context (delay (context options))
template (or template "app/templates/api-doc.tmpl")]
(fn [request]
(let [params (:query-params request)
pstyle (:type params "js")
context (assoc @context :param-style pstyle)]
{::yres/status 200
::yres/body (-> (io/resource "app/templates/api-doc.tmpl")
(tmpl/render context))}))
{::yres/status 200
::yres/body (-> (io/resource template)
(tmpl/render context))})))
(fn [_]
{::yres/status 404})))
@@ -98,8 +105,8 @@
;; OPENAPI / SWAGGER (v3.1)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn prepare-openapi-context
[methods]
(defn- openapi-context
[{:keys [methods entrypoint description]}]
(let [definitions (atom {})
options {:registry sr/default-registry
::oapi/definitions-path "#/components/schemas/"
@@ -112,7 +119,9 @@
(fn [tsx schema]
(let [schema (sm/schema schema)
example (sm/generate schema)
example (sm/encode schema example output-transformer)]
example (sm/encode schema example output-transformer)
example (json/encode example :key-fn json/write-camel-key)]
{:default
{:description "A default response"
:content
@@ -123,7 +132,9 @@
gen-params-doc
(fn [tsx schema]
(let [example (sm/generate schema)
example (sm/encode schema example output-transformer)]
example (sm/encode schema example output-transformer)
example (json/encode example :key-fn json/write-camel-key)]
{:required true
:content
{"application/json"
@@ -158,34 +169,35 @@
(map gen-method-doc)
(sort-by (juxt :module :name))
(map (fn [doc]
[(str/ffmt "/command/%" (:name doc)) (:repr doc)]))
[(:name doc) (:repr doc)]))
(into {})))]
{:openapi "3.0.0"
:info {:version (:main cf/version)}
:servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri))
;; :description "penpot backend"
}]
:servers [{:url (str entrypoint)
:description (or description "")}]
:paths paths
:components {:schemas @definitions}}))
(defn openapi-json-handler
[context]
(defn- openapi-json-handler
[& {:as options}]
(if (contains? cf/flags :backend-openapi-doc)
(fn [_]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode @context)})
(let [context (delay (openapi-context options))]
(fn [_]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode @context)}))
(fn [_]
{::yres/status 404})))
(defn openapi-handler
[]
(defn- openapi-handler
[& {:keys [uri label]}]
(if (contains? cf/flags :backend-openapi-doc)
(fn [_]
(let [swagger-js (slurp (io/resource "app/assets/swagger-ui-4.18.3.js"))
swagger-cs (slurp (io/resource "app/assets/swagger-ui-4.18.3.css"))
context {:public-uri (cf/get :public-uri)
context {:uri (str uri)
:label label
:swagger-js swagger-js
:swagger-css swagger-cs}]
{::yres/status 200
@@ -196,27 +208,43 @@
{::yres/status 404})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MODULE INIT
;; ROUTES HELPER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/assert-key ::routes
[_ params]
(assert (sm/valid? ::rpc/methods (::rpc/methods params)) "expected valid methods"))
(defn routes
[& {:keys [label base-uri description methods]}]
(let [entrypoint
(-> base-uri
(u/ensure-path-slash)
(u/join "methods"))
(defmethod ig/init-key ::routes
[_ {:keys [::rpc/methods] :as cfg}]
[(let [context (delay (prepare-doc-context methods))]
[["/_doc"
{:handler (doc-handler context)
:allowed-methods #{:get}}]
["/doc"
{:handler (doc-handler context)
:allowed-methods #{:get}}]])
openapi
(-> base-uri
(u/ensure-path-slash)
(u/join "doc/openapi"))
(let [context (delay (prepare-openapi-context methods))]
[["/openapi"
{:handler (openapi-handler)
:allowed-methods #{:get}}]
["/openapi.json"
{:handler (openapi-json-handler context)
:allowed-methods #{:get}}]])])
template
(case label
"management" "app/templates/management-api-doc.tmpl"
"main" "app/templates/main-api-doc.tmpl")]
["/doc"
["" {:handler (handler :methods methods
:label label
:entrypoint entrypoint
:openapi openapi
:template template)
:allowed-methods #{:get}}]
["/openapi"
{:handler (openapi-handler
:uri (u/join openapi "openapi.json")
:label label)
:allowed-methods #{:get}}]
["/openapi.json"
{:handler (openapi-json-handler {:entrypoint entrypoint
:description description
:methods methods})
:allowed-methods #{:get}}]]))

View File

@@ -11,7 +11,7 @@
[app.common.data.macros :as dm]
[app.http :as-alias http]
[app.rpc :as-alias rpc]
[yetti.response :as-alias yres]))
[yetti.response :as yres]))
;; A utilty wrapper object for wrap service responses that does not
;; implements the IObj interface that make possible attach metadata to
@@ -78,3 +78,21 @@
(let [exp (if (integer? max-age) max-age (inst-ms max-age))
val (dm/fmt "max-age=%" (int (/ exp 1000.0)))]
(update response ::yres/headers assoc "cache-control" val)))))
(defn stream
"A convenience allias for yetti.response/stream-body"
[f]
(yres/stream-body f))
(defn get-request
"Get http request from RPC params"
[params]
(assert (contains? params ::rpc/request-at) "rpc params required")
(-> (meta params)
(get ::http/request)))
(defn get-auth-data
"Get http auth-data from RPC params"
[params]
(-> (get-request params)
(get ::http/auth-data)))

View File

@@ -0,0 +1,49 @@
;; 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.exporter
(:require
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.media :refer [schema:upload]]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.storage :as sto]
[app.util.services :as sv]))
;; ---- RPC METHOD: UPLOAD-TEMPFILE
(def ^:private
schema:upload-tempfile-params
[:map {:title "upload-templfile-params"}
[:content schema:upload]])
(def ^:private
schema:upload-tempfile-result
[:map {:title "upload-templfile-result"}])
(sv/defmethod ::upload-tempfile
{::doc/added "2.12"
::sm/params schema:upload-tempfile-params
::sm/result schema:upload-tempfile-result}
[cfg {:keys [::rpc/profile-id content]}]
(let [storage (sto/resolve cfg)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
content {::sto/content data
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 10})
:profile-id profile-id
:content-type (:mtype content)
:bucket "tempfile"}
object (sto/put-object! storage content)]
{:id (:id object)
:uri (-> (cf/get :public-uri)
(u/join "/assets/by-id/")
(u/join (str (:id object))))}))

View File

@@ -0,0 +1,183 @@
;; 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

@@ -102,8 +102,7 @@
::wrk/label "quotes-notification"
::wrk/params {:to (vec admins)
:subject subject
:body [{:type "text/plain"
:content content}]}}))))
:body content}}))))
(defn- generic-check!
[{:keys [::db/conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}]

View File

@@ -104,28 +104,29 @@
(def ^:private schema:limit
[:and
[:map
[::name :any]
[::name :keyword]
[::strategy schema:strategy]
[::key :string]
[::opts :string]]
[:or
[:map
[::capacity ::sm/int]
[::rate ::sm/int]
[::internal ::ct/duration]
[::params [::sm/vec :any]]]
[:map
[::nreq ::sm/int]
[::unit [:enum :days :hours :minutes :seconds :weeks]]]]])
[::opts :string]
[::capacity {:optional true} ::sm/int]
[::rate {:optional true} ::sm/int]
[::interval {:optional true} ::ct/duration]
[::params {:optional true} [::sm/vec :any]]
[::permits {:optional true} ::sm/int]
[::unit {:optional true} [:enum :days :hours :minutes :seconds :weeks]]]
[:fn (fn [attrs]
(let [contains-fn (partial contains? attrs)]
(or (every? contains-fn [::capacity ::rate ::interval])
(every? contains-fn [::permits ::unit]))))]])
(def ^:private schema:limits
[:map-of :keyword [::sm/vec schema:limit]])
(def ^:private valid-limit-tuple?
(sm/lazy-validator schema:limit-tuple))
(sm/validator schema:limit-tuple))
(def ^:private valid-rlimit-instance?
(sm/lazy-validator ::rpc/rlimit))
(sm/validator ::rpc/rlimit))
(defmethod parse-limit :window
[[name strategy opts :as vlimit]]
@@ -134,16 +135,16 @@
(merge
{::name name
::strategy strategy}
(if-let [[_ nreq unit] (re-find window-opts-re opts)]
(let [nreq (parse-long nreq)]
{::nreq nreq
(if-let [[_ permits unit] (re-find window-opts-re opts)]
(let [permits (parse-long permits)]
{::permits permits
::unit (case unit
"d" :days
"h" :hours
"m" :minutes
"s" :seconds
"w" :weeks)
::key (str "ratelimit.window." (d/name name))
::key (str "penpot.rlimit." (cf/get :tenant) ".window." (d/name name))
::opts opts})
(ex/raise :type :validation
:code :invalid-window-limit-opts
@@ -164,15 +165,15 @@
::interval interval
::opts opts
::params [(->seconds interval) rate capacity]
::key (str "ratelimit.bucket." (d/name name))})
::key (str "penpot.rlimit." (cf/get :tenant) ".bucket." (d/name name))})
(ex/raise :type :validation
:code :invalid-bucket-limit-opts
:hint (str/ffmt "looks like '%' does not have a valid format" opts))))
(defmethod process-limit :bucket
[rconn user-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
[rconn profile-id now {:keys [::key ::params ::service ::capacity ::interval ::rate] :as limit}]
(let [script (-> bucket-rate-limit-script
(assoc ::rscript/keys [(str key "." service "." user-id)])
(assoc ::rscript/keys [(str key "." service "." profile-id)])
(assoc ::rscript/vals (conj params (->seconds now))))
result (rds/eval rconn script)
allowed? (boolean (nth result 0))
@@ -192,18 +193,18 @@
(assoc ::lresult/remaining remaining))))
(defmethod process-limit :window
[rconn user-id now {:keys [::nreq ::unit ::key ::service] :as limit}]
[rconn profile-id now {:keys [::permits ::unit ::key ::service] :as limit}]
(let [ts (ct/truncate now unit)
ttl (ct/diff now (ct/plus ts {unit 1}))
script (-> window-rate-limit-script
(assoc ::rscript/keys [(str key "." service "." user-id "." (ct/format-inst ts))])
(assoc ::rscript/vals [nreq (->seconds ttl)]))
(assoc ::rscript/keys [(str key "." service "." profile-id "." (ct/format-inst ts))])
(assoc ::rscript/vals [permits (->seconds ttl)]))
result (rds/eval rconn script)
allowed? (boolean (nth result 0))
remaining (nth result 1)]
(l/trace :hint "limit processed"
:service service
:limit (name (::name limit))
:name (name (::name limit))
:strategy (name (::strategy limit))
:opts (::opts limit)
:allowed allowed?
@@ -214,8 +215,8 @@
(assoc ::lresult/reset (ct/plus ts {unit 1})))))
(defn- process-limits
[rconn user-id limits now]
(let [results (into [] (map (partial process-limit rconn user-id now)) limits)
[rconn profile-id limits now]
(let [results (into [] (map (partial process-limit rconn profile-id now)) limits)
remaining (->> results
(d/index-by ::name ::lresult/remaining)
(uri/map->query-string))
@@ -227,7 +228,7 @@
(when rejected
(l/warn :hint "rejected rate limit"
:user-id (str user-id)
:profile-id (str profile-id)
:limit-service (-> rejected ::service name)
:limit-name (-> rejected ::name name)
:limit-strategy (-> rejected ::strategy name)))
@@ -371,12 +372,9 @@
(defn- on-refresh-error
[_ cause]
(when-not (instance? java.util.concurrent.RejectedExecutionException cause)
(if-let [explain (-> cause ex-data ex/explain)]
(l/warn ::l/raw (str "unable to refresh config, invalid format:\n" explain)
::l/sync? true)
(l/warn :hint "unexpected exception on loading config"
:cause cause
::l/sync? true))))
(l/warn :hint "unexpected exception on loading config"
:cause cause
::l/sync? true)))
(defn- get-config-path
[]

View File

@@ -25,9 +25,9 @@ local allowed = filled >= requested
local newTokens = filled
if allowed then
newTokens = filled - requested
redis.call("hset", tokensKey, "tokens", newTokens, "timestamp", timestamp)
end
redis.call("hset", tokensKey, "tokens", newTokens, "timestamp", timestamp)
redis.call("expire", tokensKey, ttl)
return { allowed, newTokens }

View File

@@ -22,8 +22,7 @@
(defn- generate-random-key
[]
(-> (bn/random-bytes 64)
(bc/bytes->b64u)
(bc/bytes->str)))
(bc/bytes->b64-str true)))
(defn- get-all-props
[conn]
@@ -85,12 +84,11 @@
(l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate "
"all sessions on each restart, it is highly recommended setting up the "
"PENPOT_SECRET_KEY environment variable")))
(let [secret (or key (generate-random-key))]
(-> (get-all-props conn)
(assoc :secret-key secret)
(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)))))))
;; FIXME
(sm/register! ::props :any)
(sm/register! ::props [:map-of :keyword ::sm/any])

View File

@@ -14,7 +14,9 @@
[integrant.core :as ig])
(:import
java.time.Clock
java.time.Duration))
java.time.Duration
java.time.Instant
java.time.ZoneId))
(defonce current
(atom {:clock (Clock/systemDefaultZone)
@@ -36,6 +38,12 @@
[_ _]
(remove-watch current ::common))
(defn fixed
"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)))

View File

@@ -8,13 +8,13 @@
"Keys derivation service."
(:refer-clojure :exclude [derive])
(:require
[app.common.spec :as us]
[buddy.core.kdf :as bk]))
(defn derive
"Derive a key from secret-key"
[secret-key & {:keys [salt size] :or {size 32}}]
(us/assert! ::us/not-empty-string secret-key)
(assert (string? secret-key) "expect string")
(assert (seq secret-key) "expect string")
(let [engine (bk/engine {:key secret-key
:salt salt
:alg :hkdf

View File

@@ -61,8 +61,8 @@
:is-active is-active
:password password
:props {}}]
(->> (cmd.auth/create-profile! conn params)
(cmd.auth/create-profile-rels! conn)))))))
(->> (cmd.auth/create-profile system params)
(cmd.auth/create-profile-rels conn)))))))
(defmethod exec-command "update-profile"
[{:keys [fullname email password is-active]}]

View File

@@ -25,6 +25,7 @@
[app.db.sql :as-alias sql]
[app.features.fdata :as fdata]
[app.features.file-snapshots :as fsnap]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as main]
[app.msgbus :as mbus]
@@ -567,48 +568,12 @@
:id file-id})))
:deleted))
(defn- restore-file*
[{:keys [::db/conn]} file-id]
(db/update! conn :file
{:deleted-at nil
:has-media-trimmed false}
{:id file-id}
{::db/return-keys false})
(db/update! conn :file-media-object
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-change
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-data
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
;; Mark thumbnails to be deleted
(db/update! conn :file-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-tagged-object-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
:restored)
(defn restore-file!
"Mark a file and all related objects as not deleted"
[file-id]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! main/system
(fn [system]
(fn [{:keys [::db/conn] :as system}]
(when-let [file (db/get* system :file
{:id file-id}
{::db/remove-deleted false
@@ -622,7 +587,9 @@
:cause "explicit call to restore-file!"}
::audit/tracked-at (ct/now)})
(restore-file* system file-id))))))
(#'files/restore-file conn file-id))
:restored))))
(defn delete-project!
"Mark a project for deletion"
@@ -655,7 +622,7 @@
(doseq [{:keys [id]} (db/query conn :file
{:project-id project-id}
{::sql/columns [:id]})]
(restore-file* cfg id))
(#'files/restore-file conn id))
:restored)
@@ -877,10 +844,33 @@
:deleted-at deleted-at
:id id})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SSO
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn add-sso-config
[& {:keys [base-uri client-id client-secret domain]}]
(assert (and (string? base-uri) (str/starts-with? base-uri "http")) "expected a valid base-uri")
(assert (string? client-id) "expected a valid client-id")
(assert (string? client-secret) "expected a valid client-secret")
(assert (string? domain) "expected a valid domain")
(db/insert! main/system :sso-provider
{:id (uuid/next)
:type "oidc"
:client-id client-id
:client-secret client-secret
:domain domain
:base-uri base-uri}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MISC
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn decode-session-token
[token]
(session/decode-token main/system token))
(defn instrument-var
[var]
(alter-var-root var (fn [f]

View File

@@ -35,12 +35,16 @@
:assets-s3 :s3
nil)))
(def default-bucket
"file-media-object")
(def valid-buckets
#{"file-media-object"
"team-font-variant"
"file-object-thumbnail"
"file-thumbnail"
"profile"
"tempfile"
"file-data"
"file-data-fragment"
"file-change"})
@@ -163,9 +167,6 @@
backend
(:metadata result))))
(def ^:private sql:retrieve-storage-object
"select * from storage_object where id = ? and (deleted_at is null or deleted_at > now())")
(defn row->storage-object [res]
(let [mdata (or (some-> (:metadata res) (db/decode-transit-pgobject)) {})]
(impl/storage-object
@@ -177,9 +178,15 @@
(keyword (:backend res))
mdata)))
(defn- retrieve-database-object
(def ^:private sql:get-storage-object
"SELECT *
FROM storage_object
WHERE id = ?
AND (deleted_at IS NULL)")
(defn- get-database-object
[conn id]
(some-> (db/exec-one! conn [sql:retrieve-storage-object id])
(some-> (db/exec-one! conn [sql:get-storage-object id])
(row->storage-object)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -202,7 +209,7 @@
(defn get-object
[{:keys [::db/connectable] :as storage} id]
(assert (valid-storage? storage))
(retrieve-database-object connectable id))
(get-database-object connectable id))
(defn put-object!
"Creates a new object with the provided content."

View File

@@ -37,7 +37,6 @@
(into #{} (map :id))
(not-empty))))
(def ^:private sql:delete-sobjects
"DELETE FROM storage_object
WHERE id = ANY(?::uuid[])")
@@ -77,47 +76,37 @@
(d/group-by (comp keyword :backend) :id #{} items))
(def ^:private sql:get-deleted-sobjects
"SELECT s.* FROM storage_object AS s
"SELECT s.*
FROM storage_object AS s
WHERE s.deleted_at IS NOT NULL
AND s.deleted_at < now() - ?::interval
AND s.deleted_at <= ?
ORDER BY s.deleted_at ASC")
(defn- get-buckets
[conn min-age]
(let [age (db/interval min-age)]
[conn]
(let [now (ct/now)]
(sequence
(comp (partition-all 25)
(mapcat group-by-backend))
(db/cursor conn [sql:get-deleted-sobjects age]))))
(db/cursor conn [sql:get-deleted-sobjects now]))))
(defn- clean-deleted!
[{:keys [::db/conn ::min-age] :as cfg}]
[{:keys [::db/conn] :as cfg}]
(reduce (fn [total [backend-id ids]]
(let [deleted (delete-in-bulk! cfg backend-id ids)]
(+ total (or deleted 0))))
0
(get-buckets conn min-age)))
(get-buckets conn)))
(defmethod ig/assert-key ::handler
[_ params]
(assert (sto/valid-storage? (::sto/storage params)) "expect valid storage")
(assert (db/pool? (::db/pool params)) "expect valid storage"))
(defmethod ig/expand-key ::handler
[k v]
{k (assoc v ::min-age (ct/duration {:hours 2}))})
(defmethod ig/init-key ::handler
[_ {:keys [::min-age] :as cfg}]
(fn [{:keys [props] :as task}]
(let [min-age (ct/duration (or (:min-age props) min-age))]
(db/tx-run! cfg (fn [cfg]
(let [cfg (assoc cfg ::min-age min-age)
total (clean-deleted! cfg)]
(l/inf :hint "task finished"
:min-age (ct/format-duration min-age)
:total total)
{:deleted total}))))))
[_ cfg]
(fn [_]
(db/tx-run! cfg (fn [cfg]
(let [total (clean-deleted! cfg)]
(l/inf :hint "task finished" :total total)
{:deleted total})))))

View File

@@ -22,8 +22,10 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.storage :as-alias sto]
[app.storage :as sto]
[app.storage.impl :as impl]
[integrant.core :as ig]))
@@ -101,14 +103,15 @@
(def ^:private sql:mark-delete-in-bulk
"UPDATE storage_object
SET deleted_at = now(),
SET deleted_at = ?,
touched_at = NULL
WHERE id = ANY(?::uuid[])")
(defn- mark-delete-in-bulk!
[conn ids]
(let [ids (db/create-array conn "uuid" ids)]
(db/exec-one! conn [sql:mark-delete-in-bulk ids])))
[conn deletion-delay ids]
(let [ids (db/create-array conn "uuid" ids)
now (ct/plus (ct/now) deletion-delay)]
(db/exec-one! conn [sql:mark-delete-in-bulk now ids])))
;; NOTE: A getter that retrieves the key which will be used for group
;; ids; previously we have no value, then we introduced the
@@ -127,7 +130,7 @@
[{:keys [metadata]}]
(or (some-> metadata :bucket)
(some-> metadata :reference d/name)
"file-media-object"))
sto/default-bucket))
(defn- process-objects!
[conn has-refs? bucket objects]
@@ -137,18 +140,20 @@
(if-let [{:keys [id] :as object} (first objects)]
(if (has-refs? conn object)
(do
(l/debug :id (str id)
:status "freeze"
:bucket bucket)
(l/dbg :id (str id)
:status "freeze"
:bucket bucket)
(recur (conj to-freeze id) to-delete (rest objects)))
(do
(l/debug :id (str id)
:status "delete"
:bucket bucket)
(l/dbg :id (str id)
:status "delete"
:bucket bucket)
(recur to-freeze (conj to-delete id) (rest objects))))
(do
(let [deletion-delay (if (= bucket "tempfile")
(ct/duration {:hours 2})
(cf/get-deletion-delay))]
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
(some->> (seq to-delete) (mark-delete-in-bulk! conn))
(some->> (seq to-delete) (mark-delete-in-bulk! conn deletion-delay))
[(count to-freeze) (count to-delete)]))))
(defn- process-bucket!
@@ -160,6 +165,7 @@
"file-thumbnail" (process-objects! conn has-file-thumbnails-refs? bucket objects)
"profile" (process-objects! conn has-profile-refs? bucket objects)
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
"tempfile" (process-objects! conn (constantly false) bucket objects)
(ex/raise :type :internal
:code :unexpected-unknown-reference
:hint (dm/fmt "unknown reference '%'" bucket))))
@@ -173,27 +179,27 @@
[0 0]
(d/group-by lookup-bucket identity #{} chunk)))
(def ^:private
sql:get-touched-storage-objects
(def ^:private sql:get-touched-storage-objects
"SELECT so.*
FROM storage_object AS so
WHERE so.touched_at IS NOT NULL
AND so.touched_at <= ?
ORDER BY touched_at ASC
FOR UPDATE
SKIP LOCKED
LIMIT 10")
(defn get-chunk
[conn]
(->> (db/exec! conn [sql:get-touched-storage-objects])
[conn timestamp]
(->> (db/exec! conn [sql:get-touched-storage-objects timestamp])
(map impl/decode-row)
(not-empty)))
(defn- process-touched!
[{:keys [::db/pool] :as cfg}]
[{:keys [::db/pool ::timestamp] :as cfg}]
(loop [freezed 0
deleted 0]
(if-let [chunk (get-chunk pool)]
(if-let [chunk (get-chunk pool timestamp)]
(let [[nfo ndo] (db/tx-run! cfg process-chunk! chunk)]
(recur (long (+ freezed nfo))
(long (+ deleted ndo))))
@@ -209,5 +215,6 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_] (process-touched! cfg)))
(fn [_]
(process-touched! (assoc cfg ::timestamp (ct/now)))))

View File

@@ -79,14 +79,17 @@
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn tempfile
[& {:keys [suffix prefix min-age]
(defn tempfile*
[& {:keys [suffix prefix]
:or {prefix "penpot."
suffix ".tmp"}}]
(let [attrs (fs/make-permissions "rw-r--r--")
path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))
path (Files/createFile path attrs)]
(fs/delete-on-exit! path)
path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))]
(Files/createFile path attrs)))
(defn tempfile
[& {:keys [min-age] :as opts}]
(let [path (tempfile* opts)]
(sp/offer! queue [path (some-> min-age ct/duration)])
path))

View File

@@ -218,6 +218,9 @@
(when (or (nil? revn) (= revn (:revn file)))
file)))
;; FIXME: we should skip files that does not match the revn on the
;; props and add proper schema for this task props
(defn- process-file!
[cfg {:keys [file-id] :as props}]
(if-let [file (get-file cfg props)]

View File

@@ -8,6 +8,7 @@
"A maintenance task that is responsible of properly scheduling the
file-gc task for all files that matches the eligibility threshold."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
@@ -21,25 +22,24 @@
f.modified_at
FROM file AS f
WHERE f.has_media_trimmed IS false
AND f.modified_at < now() - ?::interval
AND f.modified_at < ?
AND f.deleted_at IS NULL
ORDER BY f.modified_at DESC
FOR UPDATE OF f
SKIP LOCKED")
(defn- get-candidates
[{:keys [::db/conn ::min-age] :as cfg}]
(let [min-age (db/interval min-age)]
(db/plan conn [sql:get-candidates min-age] {:fetch-size 10})))
(defn- schedule!
[cfg]
[{:keys [::db/conn] :as cfg} threshold]
(let [total (reduce (fn [total {:keys [id modified-at revn]}]
(let [params {:file-id id :modified-at modified-at :revn revn}]
(let [params {:file-id id :revn revn}]
(l/trc :hint "schedule"
:file-id (str id)
:revn revn
:modified-at (ct/format-inst modified-at))
(wrk/submit! (assoc cfg ::wrk/params params))
(inc total)))
0
(get-candidates cfg))]
(db/plan conn [sql:get-candidates threshold] {:fetch-size 10}))]
{:processed total}))
(defmethod ig/assert-key ::handler
@@ -53,12 +53,12 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props] :as task}]
(let [min-age (ct/duration (or (:min-age props) (::min-age cfg)))]
(let [threshold (-> (ct/duration (or (:min-age props) (::min-age cfg)))
(ct/in-past))]
(-> cfg
(assoc ::db/rollback (:rollback? props))
(assoc ::min-age min-age)
(assoc ::wrk/task :file-gc)
(assoc ::wrk/priority 10)
(assoc ::wrk/mark-retries 0)
(assoc ::wrk/delay 1000)
(db/tx-run! schedule!)))))
(assoc ::wrk/delay 10000)
(db/tx-run! schedule! threshold)))))

View File

@@ -18,15 +18,15 @@
(def ^:private sql:get-profiles
"SELECT id, photo_id FROM profile
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-profiles!
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-profiles deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-profiles timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id photo-id]}]
(l/trc :obj "profile" :id (str id))
@@ -41,15 +41,15 @@
(def ^:private sql:get-teams
"SELECT deleted_at, id, photo_id FROM team
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-teams!
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-teams deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-teams timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id photo-id deleted-at]}]
(l/trc :obj "team"
:id (str id)
@@ -68,15 +68,15 @@
"SELECT id, team_id, deleted_at, woff1_file_id, woff2_file_id, otf_file_id, ttf_file_id
FROM team_font_variant
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-fonts!
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-fonts deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-fonts timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id team-id deleted-at] :as font}]
(l/trc :obj "font-variant"
:id (str id)
@@ -98,15 +98,15 @@
"SELECT id, deleted_at, team_id
FROM project
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-projects!
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-projects deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-projects timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id team-id deleted-at]}]
(l/trc :obj "project"
:id (str id)
@@ -124,15 +124,15 @@
f.project_id
FROM file AS f
WHERE f.deleted_at IS NOT NULL
AND f.deleted_at < now() + ?::interval
AND f.deleted_at <= ?
ORDER BY f.deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-files!
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-files deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-files timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id deleted-at project-id] :as file}]
(l/trc :obj "file"
:id (str id)
@@ -148,15 +148,15 @@
"SELECT file_id, revn, media_id, deleted_at
FROM file_thumbnail
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn delete-file-thumbnails!
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-thumbnails deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-thumbnails timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [file-id revn media-id deleted-at]}]
(l/trc :obj "file-thumbnail"
:file-id (str file-id)
@@ -175,15 +175,15 @@
"SELECT file_id, object_id, media_id, deleted_at
FROM file_tagged_object_thumbnail
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn delete-file-object-thumbnails!
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-object-thumbnails deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-object-thumbnails timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [file-id object-id media-id deleted-at]}]
(l/trc :obj "file-object-thumbnail"
:file-id (str file-id)
@@ -203,15 +203,15 @@
"SELECT id, file_id, media_id, thumbnail_id, deleted_at
FROM file_media_object
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-file-media-objects!
[{:keys [::db/conn ::deletion-threshold ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-media-objects deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(->> (db/plan conn [sql:get-file-media-objects timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id file-id deleted-at] :as fmo}]
(l/trc :obj "file-media-object"
:id (str id)
@@ -231,16 +231,15 @@
"SELECT file_id, id, type, deleted_at, metadata, backend
FROM file_data
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-file-data!
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-data deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-data timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [file-id id type deleted-at metadata backend]}]
(some->> metadata
@@ -266,15 +265,15 @@
"SELECT id, file_id, deleted_at
FROM file_change
WHERE deleted_at IS NOT NULL
AND deleted_at < now() + ?::interval
AND deleted_at <= ?
ORDER BY deleted_at ASC
LIMIT ?
FOR UPDATE
SKIP LOCKED")
(defn- delete-file-changes!
[{:keys [::db/conn ::deletion-threshold ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-change deletion-threshold chunk-size] {:fetch-size 5})
[{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}]
(->> (db/plan conn [sql:get-file-change timestamp chunk-size] {:fetch-size 5})
(reduce (fn [total {:keys [id file-id deleted-at] :as xlog}]
(l/trc :obj "file-change"
:id (str id)
@@ -322,9 +321,8 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props] :as task}]
(let [threshold (ct/duration (get props :deletion-threshold 0))
cfg (assoc cfg ::deletion-threshold (db/interval threshold))]
(fn [_]
(let [cfg (assoc cfg ::timestamp (ct/now))]
(loop [procs (map deref deletion-proc-vars)
total 0]
(if-let [proc-fn (first procs)]

View File

@@ -15,19 +15,25 @@
[buddy.sign.jwe :as jwe]))
(defn generate
[{:keys [::setup/props] :as cfg} claims]
(assert (contains? cfg ::setup/props))
([cfg claims] (generate cfg claims nil))
([{:keys [::setup/props] :as cfg} claims header]
(assert (contains? props :tokens-key) "expect props to have tokens-key")
(let [tokens-key
(get props :tokens-key)
(let [tokens-key
(get props :tokens-key)
payload
(-> claims
(update :iat (fn [v] (or v (ct/now))))
(d/without-nils)
(t/encode))]
payload
(-> claims
(update :iat (fn [v] (or v (ct/now))))
(d/without-nils)
(t/encode))]
(jwe/encrypt payload tokens-key {:alg :a256kw :enc :a256gcm})))
(jwe/encrypt payload tokens-key {:alg :a256kw :enc :a256gcm :header header}))))
(defn decode-header
[token]
(ex/ignoring
(jwe/decode-header token)))
(defn decode
[{:keys [::setup/props] :as cfg} token]

View File

@@ -27,7 +27,7 @@
(throw (IllegalArgumentException. "Missing arguments on `defmethod` macro.")))
(let [mdata (assoc mdata
::docstring (some-> docs str/<<-)
::docstring (some-> docs str/unindent)
::spec sname
::name (name sname))

View File

@@ -7,10 +7,18 @@
(ns app.util.template
(:require
[app.common.exceptions :as ex]
[cuerdas.core :as str]
[selmer.filters :as sf]
[selmer.parser :as sp]))
;; (sp/cache-off!)
(sf/add-filter! :abbreviate
(fn [s n]
(let [n (parse-long n)]
(str/abbreviate s n))))
(defn render
[path context]
(try

View File

@@ -77,8 +77,8 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:insert-new-task
"insert into task (id, name, props, queue, label, priority, max_retries, scheduled_at)
values (?, ?, ?, ?, ?, ?, ?, now() + ?)
"insert into task (id, name, props, queue, label, priority, max_retries, created_at, modified_at, scheduled_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
returning id")
(def ^:private
@@ -88,7 +88,7 @@
AND queue=?
AND label=?
AND status = 'new'
AND scheduled_at > now()")
AND scheduled_at > ?")
(def ^:private schema:options
[:map {:title "submit-options"}
@@ -111,17 +111,19 @@
(check-options! options)
(let [duration (ct/duration delay)
interval (db/interval duration)
props (db/tjson params)
id (uuid/next)
tenant (cf/get :tenant)
task (d/name task)
queue (str/ffmt "%:%" tenant (d/name queue))
conn (db/get-connectable options)
deleted (when dedupe
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label])
:next.jdbc/update-count))]
(let [delay (ct/duration delay)
now (ct/now)
scheduled-at (-> (ct/plus now delay)
(ct/truncate :millisecond))
props (db/tjson params)
id (uuid/next)
tenant (cf/get :tenant)
task (d/name task)
queue (str/ffmt "%:%" tenant (d/name queue))
conn (db/get-connectable options)
deleted (when dedupe
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label now])
(db/get-update-count)))]
(l/trc :hint "submit task"
:name task
@@ -129,11 +131,13 @@
:queue queue
:label label
:dedupe (boolean dedupe)
:delay (ct/format-duration duration)
:delay (ct/format-duration delay)
:replace (or deleted 0))
(db/exec-one! conn [sql:insert-new-task id task props queue
label priority max-retries interval])
label priority max-retries
now now scheduled-at])
id))
(defn invoke!

View File

@@ -137,33 +137,34 @@ RETURNING task.id, task.queue")
::wait)))
(run-batch []
(let [rconn (rds/connect cfg)]
(try
(-> cfg
(assoc ::rds/conn rconn)
(db/tx-run! run-batch'))
(try
(let [rconn (rds/connect cfg)]
(try
(-> cfg
(assoc ::rds/conn rconn)
(db/tx-run! run-batch'))
(finally
(.close ^AutoCloseable rconn))))
(catch InterruptedException cause
(throw cause))
(catch Exception cause
(cond
(rds/exception? cause)
(do
(l/wrn :hint "redis exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
(catch InterruptedException cause
(throw cause))
(db/sql-exception? cause)
(do
(l/wrn :hint "database exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
(catch Exception cause
(cond
(rds/exception? cause)
(do
(l/wrn :hint "redis exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
:else
(do
(l/err :hint "unhandled exception (will retry in an instant)" :cause cause)
(px/sleep timeout))))
(db/sql-exception? cause)
(do
(l/wrn :hint "database exception (will retry in an instant)" :cause cause)
(px/sleep timeout))
(finally
(.close ^AutoCloseable rconn)))))
:else
(do
(l/err :hint "unhandled exception (will retry in an instant)" :cause cause)
(px/sleep timeout))))))
(dispatcher []
(l/inf :hint "started")
@@ -176,7 +177,7 @@ RETURNING task.id, task.queue")
(catch InterruptedException _
(l/trc :hint "interrupted"))
(catch Throwable cause
(l/err :hint " unexpected exception" :cause cause))
(l/err :hint "unexpected exception" :cause cause))
(finally
(l/inf :hint "terminated"))))]

View File

@@ -13,6 +13,7 @@
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.transit :as t]
[app.config :as cf]
[app.db :as db]
[app.metrics :as mtx]
[app.redis :as rds]
@@ -60,7 +61,8 @@
(defn get-error-context
[_ item]
{:params item})
(-> (cf/logging-context)
(assoc :params item)))
(defn- get-task
[{:keys [::db/pool]} task-id]
@@ -131,6 +133,11 @@
[{:keys [::id ::timeout] :as cfg} task-id scheduled-at]
(loop [task (get-task cfg task-id)]
(cond
(nil? task)
(l/wrn :hint "no task found on the database"
:runner-id id
:task-id task-id)
(ex/exception? task)
(if (or (db/connection-error? task)
(db/serialization-error? task))
@@ -151,12 +158,9 @@
(inst-ms (:scheduled-at task)))
(l/wrn :hint "skiping task, rescheduled"
:task-id task-id
:runner-id id)
(nil? task)
(l/wrn :hint "no task found on the database"
:runner-id id
:task-id task-id)
:scheduled-at (ct/format-inst (:scheduled-at task))
:expected-scheduled-at (ct/format-inst scheduled-at))
:else
(let [result (run-task cfg task)]
@@ -177,7 +181,8 @@
{:error explain
:status "retry"
:modified-at now
:scheduled-at (ct/plus now delay)
:scheduled-at (-> (ct/plus now delay)
(ct/truncate :millisecond))
:retry-num nretry}
{:id (:id task)})
nil))
@@ -213,6 +218,7 @@
:payload payload)))
(catch Throwable cause
(l/err :hint "unable to decode payload"
::l/context (cf/logging-context)
:payload payload
:length (alength ^String/1 payload)
:cause cause))))
@@ -224,11 +230,11 @@
"failed" (handle-task-failure result)
"completed" (handle-task-completion result)
(throw (IllegalArgumentException.
(str "invalid status received: " status))))))
(str "invalid status received: '" status "'"))))))
(run-task-loop [[task-id scheduled-at]]
(loop [result (run-task! cfg task-id scheduled-at)]
(when-let [cause (process-result result)]
(when-let [cause (some-> result process-result)]
(if (or (db/connection-error? cause)
(db/serialization-error? cause))
(do
@@ -236,9 +242,9 @@
:cause cause)
(px/sleep timeout)
(recur result))
(do
(l/err :hint "unhandled exception on processing task result"
:cause cause))))))]
(l/err :hint "unhandled exception on processing task result"
::l/context (cf/logging-context)
:cause cause)))))]
(try
(let [key (str/ffmt "penpot.worker.queue:%" queue)
@@ -254,11 +260,14 @@
(if (rds/timeout-exception? cause)
(do
(l/err :hint "redis pop operation timeout, consider increasing redis timeout (will retry in some instants)"
::l/context (cf/logging-context)
:timeout timeout
:cause cause)
(px/sleep timeout))
(l/err :hint "unhandled exception" :cause cause))))))
(l/err :hint "unhandled exception"
::l/context (cf/logging-context)
:cause cause))))))
(defn- start-thread!
[{:keys [::id ::queue ::wrk/tenant] :as cfg}]
@@ -284,6 +293,7 @@
:queue queue))
(catch Throwable cause
(l/err :hint "unexpected exception"
::l/context (cf/logging-context)
:id id
:queue queue
:cause cause))

View File

@@ -30,6 +30,7 @@
[app.rpc.commands.files :as files]
[app.rpc.commands.files-create :as files.create]
[app.rpc.commands.files-update :as files.update]
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.helpers :as rph]
[app.util.blob :as blob]
@@ -104,13 +105,8 @@
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
(dissoc :app.srepl/server
:app.http/server
:app.http/router
:app.auth.oidc.providers/google
:app.auth.oidc.providers/gitlab
:app.auth.oidc.providers/github
:app.auth.oidc.providers/generic
:app.http/route
:app.setup/templates
:app.auth.oidc/routes
:app.http.oauth/handler
:app.notifications/handler
:app.loggers.mattermost/reporter
@@ -182,23 +178,25 @@
:is-demo false}
params)]
(db/run! system
(fn [{:keys [::db/conn]}]
(fn [{:keys [::db/conn] :as cfg}]
(->> params
(cmd.auth/create-profile! conn)
(cmd.auth/create-profile-rels! conn)))))))
(cmd.auth/create-profile cfg)
(cmd.auth/create-profile-rels conn)))))))
(defn create-project*
([i params] (create-project* *system* i params))
([system i {:keys [profile-id team-id] :as params}]
(us/assert uuid? profile-id)
(us/assert uuid? team-id)
(db/run! system
(fn [{:keys [::db/conn]}]
(->> (merge {:id (mk-uuid "project" i)
:name (str "project" i)}
params)
(#'teams/create-project conn))))))
(assert (uuid? profile-id))
(assert (uuid? team-id))
(let [timestamp (ct/now)]
(db/run! system
(fn [cfg]
(->> (merge {:id (mk-uuid "project" i)
:name (str "project" i)}
params
{::rpc/request-at timestamp})
(#'projects/create-project cfg)))))))
(defn create-file*
([i params]
@@ -549,6 +547,44 @@
(io/copy r sw)
(.toString sw))))
(defn parse-sse
[content]
(let [state
(reduce (fn [{:keys [events data event id] :as state} line]
(cond
;; empty line → dispatch event if we have data
(str/blank? line)
(if (seq data)
(-> state
(update :events conj {:event (or event "message")
:data (-> (str/join "\n" data))})
(assoc :data [] :event nil))
state)
;; comment line (starts with :)
(str/starts-with? line ":")
state
:else
(let [[field raw-value] (str/split line #":" 2)
value (some-> raw-value (str/replace #"^ " ""))]
(case field
"data" (update state :data conj (or value ""))
"event" (assoc state :event value)
;; ignore retry and unknown fields
state))))
{:events [] :data [] :event nil}
(str/split content #"\r?\n"))
;; handle unterminated last event (no trailing blank line)
state (if (seq (:data state))
(update state :events conj
{:event (or (:event state) "message")
:data (str/join "\n" (:data state))})
state)]
(:events state)))
(defn consume-sse
[callback]
(let [{:keys [::yres/status ::yres/body ::yres/headers] :as response} (callback {})
@@ -558,12 +594,9 @@
(try
(px/exec! :virtual #(rcp/write-body-to-stream body nil output))
(into []
(map (fn [event]
(let [[item1 item2] (re-seq #"(.*): (.*)\n?" event)]
[(keyword (nth item1 2))
(tr/decode-str (nth item2 2))])))
(-> (slurp' input)
(str/split "\n\n")))
(map (fn [{:keys [event data]}]
[(keyword event)
(tr/decode-str data)]))
(parse-sse (slurp' input)))
(finally
(.close input)))))

View File

@@ -22,17 +22,6 @@
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest authenticate-method
(let [profile (th/create-profile* 1)
token (#'sess/gen-token th/*system* {:profile-id (:id profile)})
request {:params {:token token}}
response (#'mgmt/authenticate th/*system* request)]
(t/is (= 200 (::yres/status response)))
(t/is (= "authentication" (-> response ::yres/body :iss)))
(t/is (= (:id profile) (-> response ::yres/body :uid)))))
(t/deftest get-customer-method
(let [profile (th/create-profile* 1)
request {:params {:id (:id profile)}}
@@ -89,7 +78,3 @@
(let [subs' (-> response ::yres/body :subscription)]
(t/is (= subs' subs))))))

View File

@@ -1,57 +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 backend-tests.http-middleware-access-token-test
(:require
[app.db :as db]
[app.http.access-token]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest soft-auth-middleware
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! nil)
handler (#'app.http.access-token/wrap-soft-auth
(fn [req] (vreset! request req))
th/*system*)]
(with-mocks [m1 {:target 'app.http.access-token/get-token
:return nil}]
(handler {})
(t/is (= {} @request)))
(with-mocks [m1 {:target 'app.http.access-token/get-token
:return (:token token)}]
(handler {})
(let [token-id (get @request :app.http.access-token/id)]
(t/is (= token-id (:id token)))))))
(t/deftest authz-middleware
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! {})
handler (#'app.http.access-token/wrap-authz
(fn [req] (vreset! request req))
th/*system*)]
(handler nil)
(t/is (nil? @request))
(handler {:app.http.access-token/id (:id token)})
(t/is (= #{} (:app.http.access-token/perms @request)))
(t/is (= (:id profile) (:app.http.access-token/profile-id @request)))))

View File

@@ -0,0 +1,135 @@
;; 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 backend-tests.http-middleware-test
(:require
[app.common.time :as ct]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token]
[app.http.middleware :as mw]
[app.http.session :as session]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]
[yetti.request :as yreq]
[yetti.response :as yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defrecord DummyRequest [headers cookies]
yreq/IRequestCookies
(get-cookie [_ name]
{:value (get cookies name)})
yreq/IRequest
(get-header [_ name]
(get headers name)))
(t/deftest auth-middleware-1
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :token token-type))
(t/is (= "aaaa" token))
(t/is (nil? claims)))))
(t/deftest auth-middleware-2
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :bearer token-type))
(t/is (= "aaaa" token))
(t/is (nil? claims)))))
(t/deftest auth-middleware-3
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :cookie token-type))
(t/is (= "foobar" token))
(t/is (nil? claims)))))
(t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200})
"secret-key")]
(let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
(let [response (handler nil)]
(t/is (nil? response)))
(let [response (handler {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}})]
(t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
(t/deftest session-authz
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
handler (-> (fn [req] req)
(#'session/wrap-authz {::session/manager manager})
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
:cookie (partial session/decode-token cfg)}))
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "user agent"})
(#'session/assign-token cfg))
response (handler (->DummyRequest {} {"auth-token" (:token session)}))
{:keys [token claims] token-type :type}
(get response ::http/auth-data)]
(t/is (= :cookie token-type))
(t/is (= (:token session) token))
(t/is (= "authentication" (:iss claims)))
(t/is (= "penpot" (:aud claims)))
(t/is (= (:id session) (:sid claims)))
(t/is (= (:id profile) (:uid claims)))))

View File

@@ -23,7 +23,7 @@
(smt/check!
(smt/for [context (->> sg/int
(sg/fmap (fn [_]
(rpc.doc/prepare-openapi-context (::rpc/methods th/*system*)))))]
(#'rpc.doc/openapi-context (::rpc/methods th/*system*)))))]
(try
(json/encode context)
true

View File

@@ -9,6 +9,7 @@
[app.common.features :as cfeat]
[app.common.pprint :as pp]
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.config :as cf]
@@ -16,6 +17,7 @@
[app.db.sql :as sql]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -132,9 +134,10 @@
;; this will run pending task triggered by deleting user snapshot
(th/run-pending-tasks!)
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
;; delete 2 snapshots and 2 file data entries
(t/is (= 4 (:processed res))))))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; delete 2 snapshots and 2 file data entries
(t/is (= 4 (:processed res)))))))))
(t/deftest snapshots-locking
(let [profile-1 (th/create-profile* 1 {:is-active true})

View File

@@ -19,6 +19,7 @@
[app.http :as http]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -142,126 +143,112 @@
(t/is (= 0 (count result))))))))
(t/deftest file-gc-with-fragments
(letfn [(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
(let [params {::th/type :update-file
::rpc/profile-id profile-id
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(:result out)))]
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (uuid/random)
shape-id (uuid/random)]
page-id (uuid/random)
shape-id (uuid/random)]
;; Preventive file-gc
(t/is (true? (th/run-task! :file-gc {:file-id (:id file) :revn (:revn file)})))
;; Preventive file-gc
(t/is (true? (th/run-task! :file-gc {:file-id (:id file) :revn (:revn file)})))
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; Add page
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test"
:id page-id}])
;; Add page
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test"
:id page-id}])
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 5 (count rows)))
(t/is (= 3 (count (filterv :deleted-at rows)))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 5 (count rows)))
(t/is (= 3 (count (filterv :deleted-at rows)))))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; Add shape to page that should add a new fragment
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id shape-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id shape-id
:name "image"
:frame-id uuid/zero
:parent-id uuid/zero
:type :rect})}])
;; Add shape to page that should add a new fragment
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id shape-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id shape-id
:name "image"
:frame-id uuid/zero
:parent-id uuid/zero
:type :rect})}])
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file)
:type "fragment"
:deleted-at nil})]
(t/is (= 2 (count rows))))
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file)
:type "fragment"
:deleted-at nil})]
(t/is (= 2 (count rows))))
;; Lets proceed to delete all changes
(th/db-delete! :file-change {:file-id (:id file)})
(th/db-delete! :file-data {:file-id (:id file) :type "snapshot"})
;; Lets proceed to delete all changes
(th/db-delete! :file-change {:file-id (:id file)})
(th/db-delete! :file-data {:file-id (:id file) :type "snapshot"})
(th/db-update! :file
{:has-media-trimmed false}
{:id (:id file)})
(th/db-update! :file
{:has-media-trimmed false}
{:id (:id file)})
;; The file-gc should remove fragments related to changes
;; snapshots previously deleted.
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; The file-gc should remove fragments related to changes
;; snapshots previously deleted.
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
;; (pp/pprint rows)
(t/is (= 4 (count rows)))
(t/is (= 2 (count (remove :deleted-at rows)))))
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
;; (pp/pprint rows)
(t/is (= 4 (count rows)))
(t/is (= 2 (count (remove :deleted-at rows)))))
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows)))))))
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))))
(t/deftest file-gc-with-thumbnails
(letfn [(add-file-media-object [& {:keys [profile-id file-id]}]
@@ -279,20 +266,6 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(:result out)))
(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
(let [params {::th/type :update-file
::rpc/profile-id profile-id
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(:result out)))]
(let [storage (:app.storage/storage th/*system*)
@@ -340,7 +313,7 @@
;; freeze because of the deduplication (we have uploaded 2 times
;; the same files).
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -399,14 +372,14 @@
(th/db-exec! ["update file_change set deleted_at = now() where file_id = ? and label is not null" (:id file)])
(th/db-exec! ["update file set has_media_trimmed = false where id = ?" (:id file)])
(let [res (th/run-task! :objects-gc {:deletion-threshold 0})]
(let [res (th/run-task! :objects-gc {})]
;; this will remove the file change and file data entries for two snapshots
(t/is (= 4 (:processed res))))
;; Rerun the file-gc and objects-gc
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(let [res (th/run-task! :objects-gc {:deletion-threshold 0})]
(let [res (th/run-task! :objects-gc {})]
;; this will remove the file media objects marked as deleted
;; on prev file-gc
(t/is (= 2 (:processed res))))
@@ -414,7 +387,7 @@
;; Now that file-gc have deleted the file-media-object usage,
;; lets execute the touched-gc task, we should see that two of
;; them are marked to be deleted
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@@ -599,7 +572,7 @@
;; Now that file-gc have deleted the file-media-object usage,
;; lets execute the touched-gc task, we should see that two of
;; them are marked to be deleted.
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@@ -692,7 +665,7 @@
;; because of the deduplication (we have uploaded 2 times the
;; same files).
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 1 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -742,7 +715,7 @@
;; Now that objects-gc have deleted the object thumbnail lets
;; execute the touched-gc task
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(let [res (th/run-task! "storage-gc-touched" {})]
(t/is (= 1 (:freeze res))))
;; check file media objects
@@ -777,7 +750,7 @@
;; Now that file-gc have deleted the object thumbnail lets
;; execute the touched-gc task
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 1 (:delete res))))
;; check file media objects
@@ -949,8 +922,9 @@
(t/is (= 0 (:processed result))))
;; run permanent deletion
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 3 (:processed result))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed result)))))
;; query the list of file libraries of a after hard deletion
(let [data {::th/type :get-file-libraries
@@ -1161,7 +1135,7 @@
(th/sleep 300)
;; run the task
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; check that object thumbnails are still here
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
@@ -1190,7 +1164,7 @@
(t/is (= 2 (count rows))))
;; run the task again
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; check that we have all object thumbnails
(let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})]
@@ -1253,7 +1227,7 @@
(t/is (= 2 (count rows)))))
(t/testing "gc task"
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(let [rows (th/db-query :file-thumbnail {:file-id (:id file)})]
(t/is (= 2 (count rows)))
@@ -1300,7 +1274,7 @@
;; The FileGC task will schedule an inner taskq
(th/run-pending-tasks!)
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -1394,7 +1368,7 @@
;; we ensure that once object-gc is passed and marked two storage
;; objects to delete
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
@@ -1516,7 +1490,7 @@
(t/is (some? (not-empty (:objects component))))))
;; Re-run the file-gc task
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(let [row (th/db-get :file {:id (:id file)})]
(t/is (true? (:has-media-trimmed row))))
@@ -1546,7 +1520,7 @@
;; Now, we have deleted the usage of component if we pass file-gc,
;; that component should be deleted
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; Check that component is properly removed
(let [data {::th/type :get-file
@@ -1637,8 +1611,8 @@
:component-id c-id})}])
;; Run the file-gc on file and library
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-2)})))
;; Check that component exists
(let [data {::th/type :get-file
@@ -1711,7 +1685,7 @@
;; Now, we have deleted the usage of component if we pass file-gc,
;; that component should be deleted
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-1)})))
;; Check that component is properly removed
(let [data {::th/type :get-file
@@ -1860,8 +1834,8 @@
(t/is (not= (:id fill) (:id fmedia)))))
;; Run the file-gc on file and library
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file-2)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-1)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file-2)})))
;; Now proceed to delete file and absorb it
(let [data {::th/type :delete-file
@@ -1893,3 +1867,200 @@
(t/is (= (:id file-2) (:file-id (get rows 0))))
(t/is (nil? (:deleted-at (get rows 0)))))))
(t/deftest deleted-files-permanently-delete
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)]
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "foobar"
:is-shared false
:components-v2 true}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:name data) (:name result)))
(t/is (= proj-id (:project-id result)))))
(let [data {::th/type :delete-file
:id file-id
::rpc/profile-id (:id prof)}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; get deleted files
(let [data {::th/type :get-team-deleted-files
::rpc/profile-id (:id prof)
:team-id team-id}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[row1 :as result] (:result out)]
(t/is (= 1 (count result)))
(t/is (= (:will-be-deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z"))))
(let [data {::th/type :permanently-delete-team-files
::rpc/profile-id (:id prof)
:team-id team-id
:ids #{file-id}}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:ids data) result)))
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (= (:deleted-at row) now)))))))
(t/deftest restore-deleted-files
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)]
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "foobar"
:is-shared false
:components-v2 true}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:name data) (:name result)))
(t/is (= proj-id (:project-id result)))))
(let [data {::th/type :delete-file
:id file-id
::rpc/profile-id (:id prof)}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; get deleted files
(let [data {::th/type :get-team-deleted-files
::rpc/profile-id (:id prof)
:team-id team-id}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[row1 :as result] (:result out)]
(t/is (= 1 (count result)))
(t/is (= (:will-be-deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z"))))
(let [data {::th/type :restore-deleted-team-files
::rpc/profile-id (:id prof)
:team-id team-id
:ids #{file-id}}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (fn? result))
(let [events (th/consume-sse result)]
;; (pp/pprint events)
(t/is (= 2 (count events)))
(t/is (= :end (first (last events))))
(t/is (= (:ids data) (last (last events)))))))
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (nil? (:deleted-at row)))))))
(t/deftest restore-deleted-files-and-projets
(let [profile (th/create-profile* 1 {:is-active true})
team-id (:default-team-id profile)
now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)]
(let [project (th/create-project* 1 {:profile-id (:id profile)
:team-id team-id})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
data {::th/type :delete-project
:id (:id project)
::rpc/profile-id (:id profile)}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(th/run-pending-tasks!)
;; get deleted files
(let [data {::th/type :get-team-deleted-files
::rpc/profile-id (:id profile)
:team-id team-id}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[row1 :as result] (:result out)]
(t/is (= 1 (count result)))
(t/is (= (:will-be-deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z"))))
;; Check if project is deleted
(let [[row1 :as rows] (th/db-query :project {:id (:id project)})]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z")))
;; Restore files
(let [data {::th/type :restore-deleted-team-files
::rpc/profile-id (:id profile)
:team-id team-id
:ids #{(:id file)}}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (fn? result))
(let [events (th/consume-sse result)]
;; (pp/pprint events)
(t/is (= 2 (count events)))
(t/is (= :end (first (last events))))
(t/is (= (:ids data) (last (last events)))))))
(let [[row1 :as rows] (th/db-query :file {:project-id (:id project)})]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (nil? (:deleted-at row1))))
;; Check if project is restored
(let [[row1 :as rows] (th/db-query :project {:id (:id project)})]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (nil? (:deleted-at row1))))))))

View File

@@ -8,12 +8,14 @@
(:require
[app.common.pprint :as pp]
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.auth :as cauth]
[app.setup.clock :as clock]
[app.storage :as sto]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
@@ -83,7 +85,8 @@
(t/is (map? (:result out))))
;; run the task again
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))]
(th/run-task! "storage-gc-touched" {}))]
(t/is (= 2 (:freeze res))))
(let [[row1 row2 :as rows] (th/db-query :file-tagged-object-thumbnail
@@ -114,9 +117,9 @@
;; Run the File GC task that should remove unused file object
;; thumbnails
(th/run-task! :file-gc {:min-age 0 :file-id (:id file)})
(th/run-task! :file-gc {:file-id (:id file)})
(let [result (th/run-task! :objects-gc {:min-age 0})]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed result))))
;; check if row2 related thumbnail row still exists
@@ -133,7 +136,8 @@
(t/is (some? (sto/get-object storage (:media-id row2))))
;; run the task again
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 31}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 1 (:delete res)))
(t/is (= 0 (:freeze res))))
@@ -143,8 +147,9 @@
;; Run the storage gc deleted task, it should permanently delete
;; all storage objects related to the deleted thumbnails
(let [result (th/run-task! :storage-gc-deleted {:min-age 0})]
(t/is (= 1 (:deleted result))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res)))))
(t/is (nil? (sto/get-object storage (:media-id row1))))
(t/is (some? (sto/get-object storage (:media-id row2))))
@@ -216,9 +221,9 @@
;; Run the File GC task that should remove unused file object
;; thumbnails
(t/is (true? (th/run-task! :file-gc {:min-age 0 :file-id (:id file)})))
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(let [result (th/run-task! :objects-gc {:min-age 0})]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed result))))
;; check if row1 related thumbnail row still exists
@@ -230,7 +235,7 @@
(t/is (= (:object-id data1) (:object-id row)))
(t/is (uuid? (:media-id row1))))
(let [result (th/run-task! :storage-gc-touched {:min-age 0})]
(let [result (th/run-task! :storage-gc-touched {})]
(t/is (= 1 (:delete result))))
;; Check if storage objects still exists after file-gc
@@ -242,8 +247,9 @@
;; Run the storage gc deleted task, it should permanently delete
;; all storage objects related to the deleted thumbnails
(let [result (th/run-task! :storage-gc-deleted {:min-age 0})]
(t/is (= 1 (:deleted result))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted result)))))
(t/is (some? (sto/get-object storage (:media-id row2)))))))

View File

@@ -6,11 +6,13 @@
(ns backend-tests.rpc-font-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -129,7 +131,7 @@
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
@@ -141,16 +143,17 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 2 (:processed res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 6 (:delete res))))))
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 6 (:delete res)))))))
(t/deftest font-deletion-2
(let [prof (th/create-profile* 1 {:is-active true})
@@ -189,7 +192,7 @@
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
@@ -201,16 +204,17 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 1 (:processed res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res))))))
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))
(t/deftest font-deletion-3
(let [prof (th/create-profile* 1 {:is-active true})
@@ -248,7 +252,7 @@
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font-variant
@@ -260,13 +264,14 @@
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(let [res (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 1 (:processed res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(let [res (th/run-task! :storage-gc-touched {:min-age 0})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res))))))
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 3 (:delete res)))))))

View File

@@ -6,11 +6,13 @@
(ns backend-tests.rpc-project-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[backend-tests.helpers :as th]
[clojure.test :as t]))
@@ -104,7 +106,8 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 1 (count result)))))))
(t/is (= 1 (count (remove :deleted-at result))))
(t/is (= 2 (count result)))))))
(t/deftest permissions-checks-create-project
(let [profile1 (th/create-profile* 1)
@@ -207,7 +210,8 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 1 (count result)))))
(t/is (= 2 (count result)))
(t/is (= 1 (count (remove :deleted-at result))))))
;; run permanent deletion (should be noop)
(let [result (th/run-task! :objects-gc {})]
@@ -224,8 +228,9 @@
(t/is (= 0 (count result)))))
;; run permanent deletion
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 1 (:processed result))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed result)))))
;; query the list of files of a after hard deletion
(let [data {::th/type :get-project-files

View File

@@ -13,6 +13,7 @@
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
@@ -525,8 +526,9 @@
(t/is (= :not-found (:type edata)))))
;; run permanent deletion
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 2 (:processed result))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed result)))))
;; query the list of projects of a after hard deletion
(let [data {::th/type :get-projects
@@ -581,8 +583,9 @@
(t/is (= 1 (count rows)))
(t/is (ct/inst? (:deleted-at (first rows)))))
(let [result (th/run-task! :objects-gc {:deletion-threshold (cf/get-deletion-delay)})]
(t/is (= 7 (:processed result))))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:days 8}))]
(let [result (th/run-task! :objects-gc {})]
(t/is (= 7 (:processed result)))))))
(t/deftest create-team-access-request
(with-mocks [mock {:target 'app.email/send! :return nil}]

View File

@@ -11,6 +11,7 @@
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -53,19 +54,13 @@
(configure-storage-backend))
content (sto/content "content")
object (sto/put-object! storage {::sto/content content
::sto/expired-at (ct/in-future {:seconds 1})
::sto/expired-at (ct/in-future {:hours 1})
:content-type "text/plain"})]
(t/is (sto/object? object))
(t/is (ct/inst? (:expired-at object)))
(t/is (ct/is-after? (:expired-at object) (ct/now)))
(t/is (= object (sto/get-object storage (:id object))))
(th/sleep 1000)
(t/is (nil? (sto/get-object storage (:id object))))
(t/is (nil? (sto/get-object-data storage object)))
(t/is (nil? (sto/get-object-url storage object)))
(t/is (nil? (sto/get-object-path storage object)))))
(t/is (nil? (sto/get-object storage (:id object))))))
(t/deftest put-and-delete-object
(let [storage (-> (:app.storage/storage th/*system*)
@@ -98,20 +93,25 @@
::sto/expired-at (ct/now)
:content-type "text/plain"})
object2 (sto/put-object! storage {::sto/content content2
::sto/expired-at (ct/in-past {:hours 2})
::sto/expired-at (ct/in-future {:hours 2})
:content-type "text/plain"})
object3 (sto/put-object! storage {::sto/content content3
::sto/expired-at (ct/in-past {:hours 1})
::sto/expired-at (ct/in-future {:hours 1})
:content-type "text/plain"})]
(th/sleep 200)
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res))))
(binding [ct/*clock* (clock/fixed (ct/in-future {:minutes 0}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res)))))
(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}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 1 (:deleted res)))))
(let [res (th/db-exec-one! ["select count(*) from storage_object;"])]
(t/is (= 1 (:count res))))))
(t/deftest touched-gc-task-1
(let [storage (-> (:app.storage/storage th/*system*)
@@ -158,7 +158,7 @@
{:id (:id result-1)})
;; run the objects gc task for permanent deletion
(let [res (th/run-task! :objects-gc {:min-age 0})]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
;; check that we still have all the storage objects
@@ -182,7 +182,6 @@
(let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
(t/is (= 0 (:count res)))))))
(t/deftest touched-gc-task-2
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
@@ -243,11 +242,12 @@
{:id (:id result-2)})
;; run the objects gc task for permanent deletion
(let [res (th/run-task! :objects-gc {:min-age 0})]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
;; revert touched state to all storage objects
(th/db-exec-one! ["update storage_object set touched_at=now()"])
(th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)])
;; Run the task again
(let [res (th/run-task! :storage-gc-touched {})]
@@ -293,10 +293,10 @@
result-2 (:result out2)]
;; now we proceed to manually mark all storage objects touched
(th/db-exec! ["update storage_object set touched_at=now()"])
(th/db-exec! ["update storage_object set touched_at=?" (ct/now)])
;; run the touched gc task
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 2 (:freeze res)))
(t/is (= 0 (:delete res))))
@@ -305,16 +305,48 @@
(t/is (= 2 (count rows)))))
;; now we proceed to manually delete all file_media_object
(th/db-exec! ["update file_media_object set deleted_at = now()"])
(th/db-exec! ["update file_media_object set deleted_at = ?" (ct/now)])
(let [res (th/run-task! "objects-gc" {:min-age 0})]
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
;; run the touched gc task
(let [res (th/run-task! "storage-gc-touched" {:min-age 0})]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))))
;; check that we have all no objects
(let [rows (th/db-exec! ["select * from storage_object where deleted_at is null"])]
(t/is (= 0 (count rows))))))
(t/deftest tempfile-bucket-test
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content1 (sto/content "content1")
now (ct/now)
object1 (sto/put-object! storage {::sto/content content1
::sto/touched-at (ct/plus now {:minutes 1})
:bucket "tempfile"
:content-type "text/plain"})]
(binding [ct/*clock* (clock/fixed now)]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:minutes 1}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 1 (:delete res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 1}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 2}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res)))))))

View File

@@ -17,7 +17,7 @@
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
selmer/selmer {:mvn/version "1.12.62"}
selmer/selmer {:mvn/version "1.12.69"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "0.3.13"}
@@ -29,8 +29,7 @@
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
integrant/integrant {:mvn/version "1.0.0"}
funcool/tubax {:mvn/version "2021.05.20-0"}
funcool/cuerdas {:mvn/version "2025.06.16-414"}
funcool/cuerdas {:mvn/version "2026.415"}
funcool/promesa
{:git/sha "46048fc0d4bf5466a2a4121f5d52aefa6337f2e8"
:git/url "https://github.com/funcool/promesa"}
@@ -48,12 +47,8 @@
com.sun.mail/jakarta.mail {:mvn/version "2.0.2"}
org.la4j/la4j {:mvn/version "0.6.0"}
;; exception printing
fipp/fipp {:mvn/version "0.6.29"}
me.flowthing/pp {:mvn/version "2024-11-13.77"}
io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}}
:paths ["src" "vendor" "target/classes"]

7
common/scripts/test Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -ex
corepack enable;
corepack install;
yarn install;
yarn run test;

View File

@@ -1048,6 +1048,12 @@
(into [elem])
(into (subvec without-elem insert-pos)))))))
(defn invert-map
"Returns a map with keys and values swapped.
If the input map has duplicate values, later entries overwrite earlier ones."
[m]
(into {} (map (fn [[k v]] [v k]) m)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; String Functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

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