Compare commits

..

10 Commits

Author SHA1 Message Date
Andrés Moya
8f4fcfe5eb wip cleanup 2022-11-29 15:49:24 +01:00
Andrés Moya
7ba15b9a3a wip estotienemejorpinta 2022-10-11 09:37:17 +02:00
Andrés Moya
1cba7346fd wip tampocosirve 2022-10-11 09:37:17 +02:00
Andrés Moya
f1bee5edf5 Revert "wip roto 2"
This reverts commit 1cb7391b35fefaa8898abb6dc482310291db3d68.
2022-10-11 09:37:17 +02:00
Andrés Moya
835ed342f8 wip roto 2 2022-10-11 09:37:17 +02:00
Andrés Moya
de4c7762ad wip roto 2022-10-11 09:37:17 +02:00
Andrés Moya
3112d04a99 wip 2022-10-11 09:37:17 +02:00
Andrés Moya
3b26d05fed 🎉 Show transient changes in component copies 2022-10-11 09:37:17 +02:00
Andrés Moya
0e88398dc2 🎉 Show transient changes in component copies (old algorithm) 2022-10-11 09:37:17 +02:00
Andrés Moya
5d3eadc6db 🐛 Avoid loops in main instance sync 2022-10-11 09:37:15 +02:00
816 changed files with 31879 additions and 71402 deletions

View File

@@ -3,19 +3,19 @@ jobs:
build:
docker:
- image: penpotapp/devenv:latest
- image: cimg/postgres:14.5
- image: cimg/postgres:13.5
environment:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
- image: cimg/redis:7.0.5
- image: cimg/redis:6.2.6
working_directory: ~/repo
resource_class: large
environment:
# Customize the JVM maximum heap limit
JVM_OPTS: -Xmx4g
JVM_OPTS: -Xmx1g
steps:
- checkout
@@ -29,13 +29,6 @@ jobs:
- run: cd .clj-kondo && cat config.edn
- run:
name: frontend styles prettier
working_directory: "./frontend"
command: |
yarn install
yarn run lint-scss
- run:
name: common lint
working_directory: "./common"
@@ -50,6 +43,13 @@ jobs:
clj-kondo --version
clj-kondo --parallel --lint src/
- run:
name: frontend styles prettier
working_directory: "./frontend"
command: |
yarn install
yarn run lint-scss
- run:
name: backend lint
working_directory: "./backend"
@@ -57,42 +57,47 @@ jobs:
clj-kondo --version
clj-kondo --parallel --lint src/
- run:
working_directory: "./common"
name: common tests
command: |
yarn install
yarn test
clojure -X:dev:test :patterns '["common-tests.*-test"]'
environment:
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin
JVM_OPTS: -Xmx4g
NODE_OPTIONS: --max-old-space-size=4096
# run backend test
- run:
name: backend test
working_directory: "./backend"
command: |
clojure -X:dev:test :patterns '["backend-tests.*-test"]'
command: "clojure -X:dev:test"
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"
JVM_OPTS: -Xmx4g
- run:
name: frontend tests
working_directory: "./frontend"
command: |
yarn install
yarn test
clojure -M:dev:shadow-cljs compile test
node target/tests.js
environment:
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin
- run:
working_directory: "./common"
name: common tests (cljs)
command: |
yarn install
yarn run compile-test
node target/test.js
environment:
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin
- run:
working_directory: "./common"
name: common tests (clj)
command: |
clojure -X:dev:test
environment:
PATH: /usr/local/nodejs/bin/:/usr/local/bin:/bin:/usr/bin
NODE_OPTIONS: --max-old-space-size=4096
- save_cache:
paths:

View File

@@ -7,7 +7,6 @@
app.common.data/export clojure.core/def
app.db/with-atomic clojure.core/with-open
app.common.data.macros/get-in clojure.core/get-in
app.common.data.macros/with-open clojure.core/with-open
app.common.data.macros/select-keys clojure.core/select-keys
app.common.logging/with-context clojure.core/do}
@@ -45,15 +44,6 @@
:redundant-do
{:level :off}
:earmuffed-var-not-dynamic
{:level :off}
:dynamic-var-not-earmuffed
{:level :off}
:used-underscored-binding
{:level :warning}
:unused-binding
{:exclude-destructured-as true
:exclude-destructured-keys-in-fn-args false

34
.gitignore vendored
View File

@@ -1,59 +1,55 @@
*-init.clj
*.jar
*.orig
*.penpot
*.orig
.calva
.clj-kondo
.cpcache
.lein-deps-sum
.lein-failures
.lein-plugins/
.lein-repl-history
.lsp
.nrepl-port
.nyc_output
.rebel_readline_history
.repl
.shadow-cljs
/*.jpg
/*.md
/*.png
/*.sql
/*.txt
/*.yml
/*.zip
/.clj-kondo/.cache
/_dump
/backend/*.md
/backend/*.sql
/backend/*.txt
/backend/-
/backend/assets/
/backend/builtin-templates
/backend/dist/
/backend/logs/
/backend/resources/public/assets
/backend/resources/public/media
/backend/target/
/backend/builtin-templates
/bundle*
/cd.md
/clj-profiler/
/common/.shadow-cljs
/common/coverage
/common/target
/deploy
/docker/images/bundle*
/exporter/.shadow-cljs
/exporter/target
/frontend/.shadow-cljs
/frontend/package-lock.json
/frontend/cypress/videos/*/
/frontend/cypress/fixtures/validuser.json
/frontend/cypress/videos/*/
/frontend/cypress/videos/*/
/frontend/dist/
/frontend/npm-debug.log
/frontend/out/
/frontend/package-lock.json
/frontend/resources/fonts/experiments
/frontend/resources/public/*
/frontend/target/
/other/
/scripts/
/frontend/cypress/videos/*/
/media
/telemetry/
/tmp/
/vendor/**/target
/vendor/svgclean/bundle*.js
/web
clj-profiler/
figwheel_server.log
node_modules

View File

@@ -1,209 +1,9 @@
# CHANGELOG
## 1.18.6 (Unreleased)
### :bug: Bugs fixed
- Fix comments navigation from workspace [Taiga #5504](https://tree.taiga.io/project/penpot/issue/5504)
### :sparkles: Enhancements
- Add the ability to overwrite internal resolver with `PENPOT_INTERNAL_RESOLVER` environment
variable [GH #3310](https://github.com/penpot/penpot/issues/3310)
## 1.18.5
### :bug: Bugs fixed
- Fix add flow option in contextual menu for frames
- Fix issues related with invitations
- Fix problem with undefined gaps
- Add deleted fonts auto match mechanism
## 1.18.4
### :bug: Bugs fixed
- Fix zooming while color picker breaks UI [GH #3214](https://github.com/penpot/penpot/issues/3214)
- Fix problem with layout not reflowing on shape deletion [Taiga #5289](https://tree.taiga.io/project/penpot/issue/5289)
- Fix extra long typography names on assets and palette [Taiga #5199](https://tree.taiga.io/project/penpot/issue/5199)
- Fix background-color property on inspect code [Taiga #5300](https://tree.taiga.io/project/penpot/issue/5300)
## 1.18.3
### :bug: Bugs fixed
- Fix problem with rulers not placing correctly [Taiga #5093](https://tree.taiga.io/project/penpot/issue/5093)
- Fix page context menu [Taiga #5145](https://tree.taiga.io/project/penpot/issue/5145)
- Fix project file count [Taiga #5148](https://tree.taiga.io/project/penpot/issue/5148)
- Fix OIDC roles checking mechanism [GH #3152](https://github.com/penpot/penpot/issues/3152)
- Import updated translation strings from weblate
### :arrow_up: Deps updates
## 1.18.2
### :bug: Bugs fixed
- Fix problem with frame title rotation
- Fix first level board "Show in view mode" is automatically unchecked [Taiga #5136](https://tree.taiga.io/project/penpot/issue/5136)
## 1.18.1
### :bug: Bugs fixed
- Fix problems with imported SVG shadows [Taiga #4922](https://tree.taiga.io/project/penpot/issue/4922)
- Fix problems with imported SVG embedded images and transforms [Taiga #4639](https://tree.taiga.io/project/penpot/issue/4639)
## 1.18.0
## :rocket: Next (1.17)
### :boom: Breaking changes & Deprecations
### :sparkles: New features
- Adds more accessibility improvements in dashboard [Taiga #4577](https://tree.taiga.io/project/penpot/us/4577)
- Adds paddings and gaps prediction on layout creation [Taiga #4838](https://tree.taiga.io/project/penpot/task/4838)
- Add visual feedback when proportionally scaling text elements with **K** [Taiga #3415](https://tree.taiga.io/project/penpot/us/3415)
- Add visualization and mouse control to paddings, margins and gaps in frames with layout [Taiga #4839](https://tree.taiga.io/project/penpot/task/4839)
- Allow for absolute positioned elements inside layout [Taiga #4834](https://tree.taiga.io/project/penpot/us/4834)
- Add z-index option for flex layout items [Taiga #2980](https://tree.taiga.io/project/penpot/us/2980)
- Scale content proportionally affects strokes, shadows, blurs and corners [Taiga #1951](https://tree.taiga.io/project/penpot/us/1951)
- Use tabulators to navigate layers [Taiga #5010](https://tree.taiga.io/project/penpot/issue/5010)
### :bug: Bugs fixed
- Fix problem with rules position on changing pages [Taiga #4847](https://tree.taiga.io/project/penpot/issue/4847)
- Fix error streen when uploading wrong SVG [#2995](https://github.com/penpot/penpot/issues/2995)
- Fix selecting children from hidden parent layers [Taiga #4934](https://tree.taiga.io/project/penpot/issue/4934)
- Fix problem when undoing multiple selected colors [Taiga #4920](https://tree.taiga.io/project/penpot/issue/4920)
- Allow selection of empty board by partial rect [Taiga #4806](https://tree.taiga.io/project/penpot/issue/4806)
- Improve behavior for undo on text edition [Taiga #4693](https://tree.taiga.io/project/penpot/issue/4693)
- Improve deeps selection of nested arboards [Taiga #4913](https://tree.taiga.io/project/penpot/issue/4913)
- Fix problem on selection numeric inputs on Firefox [#2991](https://github.com/penpot/penpot/issues/2991)
- Changed the text dominant-baseline to use ideographic [Taiga #4791](https://tree.taiga.io/project/penpot/issue/4791)
- Viewer wrong translations [Github #3035](https://github.com/penpot/penpot/issues/3035)
- Fix problem with text editor in Safari
- Fix unlink library color when blur color picker input [#3026](https://github.com/penpot/penpot/issues/3026)
- Fix snap pixel when moving path points on high zoom [#2930](https://github.com/penpot/penpot/issues/2930)
- Fix shortcuts for zoom now take into account the mouse position [#2924](https://github.com/penpot/penpot/issues/2924)
- Fix close colorpicker on Firefox when mouse-up is outside the picker [#2911](https://github.com/penpot/penpot/issues/2911)
- Fix problems with touch devices and Wacom tablets [#2216](https://github.com/penpot/penpot/issues/2216)
- Fix problem with board titles misplaced [Taiga #4738](https://tree.taiga.io/project/penpot/issue/4738)
- Fix problem with alt getting stuck when alt+tab [Taiga #5013](https://tree.taiga.io/project/penpot/issue/5013)
- Fix problem with z positioning of elements [Taiga #5014](https://tree.taiga.io/project/penpot/issue/5014)
- Fix problem in Firefox with scroll jumping when changin pages [#3052](https://github.com/penpot/penpot/issues/3052)
- Fix nested frame interaction created flow in wrong frame [Taiga #5043](https://tree.taiga.io/project/penpot/issue/5043)
- Font-Kerning does not work on Artboard Export to PNG/JPG/PDF [#3029](https://github.com/penpot/penpot/issues/3029)
- Fix manipulate duplicated project (delete, duplicate, rename, pin/unpin...) [Taiga #5027](https://tree.taiga.io/project/penpot/issue/5027)
- Fix deleted files appear in search results [Taiga #5002](https://tree.taiga.io/project/penpot/issue/5002)
- Fix problem with selected colors and texts [Taiga #5051](https://tree.taiga.io/project/penpot/issue/5051)
- Fix problem when assigning color from palette or assets [Taiga #5050](https://tree.taiga.io/project/penpot/issue/5050)
- Fix shortcuts for alignment [Taiga #5030](https://tree.taiga.io/project/penpot/issue/5030)
- Fix path options not showing when editing rects or ellipses [Taiga #5053](https://tree.taiga.io/project/penpot/issue/5053)
- Fix tooltips for some alignment options are truncated on design tab [Taiga #5040](https://tree.taiga.io/project/penpot/issue/5040)
- Fix horizontal margins drag don't always start from place [Taiga #5020](https://tree.taiga.io/project/penpot/issue/5020)
- Fix multiplayer username sometimes is not displayed correctly [Taiga #4400](https://tree.taiga.io/project/penpot/issue/4400)
- Show warning when trying to invite a user that is already in members [Taiga #4147](https://tree.taiga.io/project/penpot/issue/4147)
- Fix problem with text out of borders when changing from auto-width to fixed [Taiga #4308](https://tree.taiga.io/project/penpot/issue/4308)
- Fix header not showing when exiting fullscreen mode in viewer [Taiga #4244](https://tree.taiga.io/project/penpot/issue/4244)
- Fix visual problem in select options [Taiga #5028](https://tree.taiga.io/project/penpot/issue/5028)
- Forbid empty names for assets [Taiga #5056](https://tree.taiga.io/project/penpot/issue/5056)
- Select children after ungroup action [Taiga #4917](https://tree.taiga.io/project/penpot/issue/4917)
- Fix problem with guides not showing when moving over nested frames [Taiga #4905](https://tree.taiga.io/project/penpot/issue/4905)
- Fix change email and password for users signed in via social login [Taiga #4273](https://tree.taiga.io/project/penpot/issue/4273)
- Fix drag and drop files from browser or file explorer under circumstances [Taiga #5054](https://tree.taiga.io/project/penpot/issue/5054)
- Fix problem when copy/pasting shapes [Taiga #4931](https://tree.taiga.io/project/penpot/issue/4931)
- Fix problem with color picker not able to change hue [Taiga #5065](https://tree.taiga.io/project/penpot/issue/5065)
- Fix problem with outer stroke in texts [Taiga #5078](https://tree.taiga.io/project/penpot/issue/5078)
- Fix problem with text carring over next line when changing to fixed [Taiga #5067](https://tree.taiga.io/project/penpot/issue/5067)
- Fix don't show invite user hero to users with editor role [Taiga #5086](https://tree.taiga.io/project/penpot/issue/5086)
- Fix enter emails on onboarding new user creating team [Taiga #5089](https://tree.taiga.io/project/penpot/issue/5089)
- Fix invalid files amount after moving on dashboard [Taiga #5080](https://tree.taiga.io/project/penpot/issue/5080)
- Fix dashboard left sidebar, the [x] overlaps the field [Taiga #5064](https://tree.taiga.io/project/penpot/issue/5064)
- Fix expanded typography on assets sidebar is moving [Taiga #5063](https://tree.taiga.io/project/penpot/issue/5063)
- Fix spelling mistake in confirmation after importing only 1 file [Taiga #5095](https://tree.taiga.io/project/penpot/issue/5095)
- Fix problem with selection colors and texts [Taiga #5079](https://tree.taiga.io/project/penpot/issue/5079)
- Remove "show in view mode" flag when moving frame to frame [Taiga #5091](https://tree.taiga.io/project/penpot/issue/5091)
- Fix problem creating files in project page [Taiga #5060](https://tree.taiga.io/project/penpot/issue/5060)
- Disable empty names on rename files [Taiga #5088](https://tree.taiga.io/project/penpot/issue/5088)
- Fix problem with SVG and flex layout [Taiga #](https://tree.taiga.io/project/penpot/issue/5099)
- Fix unpublish and delete shared library warning messages [Taiga #5090](https://tree.taiga.io/project/penpot/issue/5090)
- Fix last update project timer update after creating new file [Taiga #5096](https://tree.taiga.io/project/penpot/issue/5096)
- Fix dashboard scrolling using 'Page Up' and 'Page Down' [Taiga #5081](https://tree.taiga.io/project/penpot/issue/5081)
- Fix view mode header buttons overlapping in small resolutions [Taiga #5058](https://tree.taiga.io/project/penpot/issue/5058)
- Fix precision for wrap in flex [Taiga #5072](https://tree.taiga.io/project/penpot/issue/5072)
- Fix relative position overlay positioning [Taiga #5092](https://tree.taiga.io/project/penpot/issue/5092)
- Fix hide grid keyboard shortcut [Github #3071](https://github.com/penpot/penpot/pull/3071)
- Fix problem with opacity in imported SVG's [Taiga #4923](https://tree.taiga.io/project/penpot/issue/4923)
### :heart: Community contributions by (Thank you!)
- To @ondrejkonec: for contributing to the code with:
- Refactor CSS variables [Github #2948](https://github.com/penpot/penpot/pull/2948)
## 1.17.3
### :bug: Bugs fixed
- Fix copy and paste very nested inside itself [Taiga #4848](https://tree.taiga.io/project/penpot/issue/4848)
- Fix custom fonts not rendered correctly [Taiga #4874](https://tree.taiga.io/project/penpot/issue/4874)
- Fix problem with shadows and blur on multiple selection
- Fix problem with redo shortcut
- Fix Component texts not displayed in assets panel [Taiga #4907](https://tree.taiga.io/project/penpot/issue/4907)
- Fix search field has implemented shared styles for "close icon" and "search icon" [Taiga #4927](https://tree.taiga.io/project/penpot/issue/4927)
- Fix Handling correctly slashes "/" in emails [Taiga #4906](https://tree.taiga.io/project/penpot/issue/4906)
- Fix Change text color from selected colors [Taiga #4933](https://tree.taiga.io/project/penpot/issue/4933)
### :sparkles: Enhancements
- Adds environment variables for specifying the export and backend URI for the frontend docker image, thanks to @Supernova3339 for the initial PR and suggestion [Github #2984](https://github.com/penpot/penpot/issues/2984)
## 1.17.2
### :bug: Bugs fixed
- Fix invite members button text [Taiga #4794](https://tree.taiga.io/project/penpot/issue/4794)
- Fix problem with opacity in frames [Taiga #4795](https://tree.taiga.io/project/penpot/issue/4795)
- Fix correct behaviour for space-around and added space-evenly option
- Fix duplicate with alt and undo only undo one step [Taiga #4746](https://tree.taiga.io/project/penpot/issue/4746)
- Fix problem creating frames inside layout [Taiga #4844](https://tree.taiga.io/project/penpot/issue/4844)
- Fix paste board inside itself [Taiga #4775](https://tree.taiga.io/project/penpot/issue/4775)
- Fix middle button panning can drag guides [Taiga #4266](https://tree.taiga.io/project/penpot/issue/4266)
### :heart: Community contributions by (Thank you!)
- To @ondrejkonec: for some code contributions on this release.
## 1.17.1
### :bug: Bugs fixed
- Fix components groups items show the component name in list mode [Taiga #4770](https://tree.taiga.io/project/penpot/issue/4770)
- Fix typing CMD+Z on MacOS turns the cursor into a Zoom cursor [Taiga #4778](https://tree.taiga.io/project/penpot/issue/4778)
- Fix white space on small screens [Taiga #4774](https://tree.taiga.io/project/penpot/issue/4774)
- Fix button spacing on delete acount modal [Taiga #4762](https://tree.taiga.io/project/penpot/issue/4762)
- Fix invitations input on team management and onboarding modal [Taiga #4760](https://tree.taiga.io/project/penpot/issue/4760)
- Fix weird numeration creating new elements in dashboard [Taiga #4755](https://tree.taiga.io/project/penpot/issue/4755)
- Fix can move shape with lens zoom active [Taiga #4787](https://tree.taiga.io/project/penpot/issue/4787)
- Fix social links broken [Taiga #4759](https://tree.taiga.io/project/penpot/issue/4759)
- Fix tooltips on left toolbar [Taiga #4793](https://tree.taiga.io/project/penpot/issue/4793)
## 1.17.0
### :sparkles: New features
- Adds layout flex functionality for boards
- Better overlays interactions on boards inside boards [Taiga #4386](https://tree.taiga.io/project/penpot/us/4386)
- Show board miniature in manual overlay setting [Taiga #4475](https://tree.taiga.io/project/penpot/issue/4475)
- Handoff visual improvements [Taiga #3124](https://tree.taiga.io/project/penpot/us/3124)
- Dynamic alignment only in sight [Github 1971](https://github.com/penpot/penpot/issues/1971)
- Add some accessibility to shortcut panel [Taiga #4713](https://tree.taiga.io/project/penpot/issue/4713)
- Add shortcuts for text editing [Taiga #2052](https://tree.taiga.io/project/penpot/us/2052)
- Second level boards treated as groups in terms of selection [Taiga #4269](https://tree.taiga.io/project/penpot/us/4269)
- Performance improvements both for backend and frontend
- Accessibility improvements for login area [Taiga #4353](https://tree.taiga.io/project/penpot/us/4353)
- Outbound webhooks [Taiga #4577](https://tree.taiga.io/project/penpot/us/4577)
- Add copy invitation link to the invitation options [Taiga #4213](https://tree.taiga.io/project/penpot/us/4213)
- Dynamic alignment only in sight [Taiga #3537](https://tree.taiga.io/project/penpot/us/3537)
- Improve naming of layers [Taiga #4036](https://tree.taiga.io/project/penpot/us/4036)
- Add zoom lense [Taiga #4691](https://tree.taiga.io/project/penpot/us/4691)
- Detect potential problems with custom font vertical metrics [Taiga #4697](https://tree.taiga.io/project/penpot/us/4697)
### :bug: Bugs fixed
- Add title to color bullets [Taiga #4218](https://tree.taiga.io/project/penpot/task/4218)
@@ -211,79 +11,13 @@
- Fix shortcut texts alignment [Taiga #4275](https://tree.taiga.io/project/penpot/issue/4275)
- Fix some texts and a typo [Taiga #4215](https://tree.taiga.io/project/penpot/issue/4215)
- Fix twitter support account link [Taiga #4279](https://tree.taiga.io/project/penpot/issue/4279)
- Fix lang autodetect issue [Taiga #4277](https://tree.taiga.io/project/penpot/issue/4277)
- Fix adding an extra page on import [Taiga #4543](https://tree.taiga.io/project/penpot/task/4543)
- Fix unable to select text at assets inputs in firefox [Taiga #4572](https://tree.taiga.io/project/penpot/issue/4572)
- Fix component sync when converting to path [Taiga #3642](https://tree.taiga.io/project/penpot/issue/3642)
- Fix style for team invite in deutsch [Taiga #4614](https://tree.taiga.io/project/penpot/issue/4614)
- Fix problem with text edition in Safari [Taiga #4046](https://tree.taiga.io/project/penpot/issue/4046)
- Fix show outline with rounded corners on rects [Taiga #4053](https://tree.taiga.io/project/penpot/issue/4053)
- Fix wrong interaction between comments and panning modes [Taiga #4297](https://tree.taiga.io/project/penpot/issue/4297)
- Fix bad element positioning on interaction with fixed scroll [Github #2660](https://github.com/penpot/penpot/issues/2660)
- Fix display type of component library not persistent [Taiga #4512](https://tree.taiga.io/project/penpot/issue/4512)
- Fix problem when moving texts with keyboard [#2690](https://github.com/penpot/penpot/issues/2690)
- Fix problem when drawing boxes won't detect mouse-up [Taiga #4618](https://tree.taiga.io/project/penpot/issue/4618)
- Fix missing loading icon on shared libraries [Taiga #4148](https://tree.taiga.io/project/penpot/issue/4148)
- Fix selection stroke missing in properties of multiple texts [Taiga #4048](https://tree.taiga.io/project/penpot/issue/4048)
- Fix missing create component menu for frames [Github #2670](https://github.com/penpot/penpot/issues/2670)
- Fix "currentColor" is not converted when importing SVG [Github 2276](https://github.com/penpot/penpot/issues/2276)
- Fix incorrect color in properties of multiple bool shapes [Taiga #4355](https://tree.taiga.io/project/penpot/issue/4355)
- Fix pressing the enter key gives you an internal error [Github 2675](https://github.com/penpot/penpot/issues/2675) [Github 2577](https://github.com/penpot/penpot/issues/2577)
- Fix confirm group name with enter doesn't work in assets modal [Taiga #4506](https://tree.taiga.io/project/penpot/issue/4506)
- Fix group/ungroup shapes inside a component [Taiga #4052](https://tree.taiga.io/project/penpot/issue/4052)
- Fix wrong update of text in components [Taiga #4646](https://tree.taiga.io/project/penpot/issue/4646)
- Fix problem with SVG imports with style [#2605](https://github.com/penpot/penpot/issues/2605)
- Fix ghost shapes after sync groups in components [Taiga #4649](https://tree.taiga.io/project/penpot/issue/4649)
- Fix layer orders messed up on move, group, reparent and undo [Github #2672](https://github.com/penpot/penpot/issues/2672)
- Fix max height in library dialog [Github #2335](https://github.com/penpot/penpot/issues/2335)
- Fix undo ungroup (shift+g) scrambles positions [Taiga #4674](https://tree.taiga.io/project/penpot/issue/4674)
- Fix justified text is stretched [Github #2539](https://github.com/penpot/penpot/issues/2539)
- Fix mousewheel on viewer inspector [Taiga #4221](https://tree.taiga.io/project/penpot/issue/4221)
- Fix path edition activated on boards [Taiga #4105](https://tree.taiga.io/project/penpot/issue/4105)
- Fix hidden layers inside groups become visible after the group visibility is changed[Taiga #4710](https://tree.taiga.io/project/penpot/issue/4710)
- Fix format of HSLA color on viewer [Taiga #4393](https://tree.taiga.io/project/penpot/issue/4393)
- Fix some typos [Taiga #4724](https://tree.taiga.io/project/penpot/issue/4724)
- Fix ctrl+c for inspect code [Taiga #4739](https://tree.taiga.io/project/penpot/issue/4739)
- Fix text in custom font is not at the expected position at export [Taiga #4394](https://tree.taiga.io/project/penpot/issue/4394)
- Fix unneeded popup when updating local components [Taiga #4430](https://tree.taiga.io/project/penpot/issue/4430)
- Fix multiuser - "Shadow" element is not updating immediately [Taiga #4709](https://tree.taiga.io/project/penpot/issue/4709)
- Fix paths not flagged as modified when resized [Taiga #4742](https://tree.taiga.io/project/penpot/issue/4742)
- Fix resend invitation doesn't reset the expiration date [Taiga #4741](https://tree.taiga.io/project/penpot/issue/4741)
- Fix incorrect state after undo page creation [Taiga #4690](https://tree.taiga.io/project/penpot/issue/4690)
- Fix copy paste texts with typography assets linked [Taiga #4750](https://tree.taiga.io/project/penpot/issue/4750)
### :arrow_up: Deps updates
### :heart: Community contributions by (Thank you!)
- To @iprithvitharun: let's make UX Writing contributions in Open Source a trend!
## 1.16.2-beta
### :bug: Bugs fixed
- Fix strage cursor behaviour after clicking viewport with text pool [Github #2447](https://github.com/penpot/penpot/issues/2447)
## 1.16.1-beta
### :bug: Bugs fixed
- Fix unexpected exception related to default nudge value
- Fix firefox changing layer color type is not applied [Taiga #4292](https://tree.taiga.io/project/penpot/issue/4292)
- Fix justify alignes text left [Taiga #4322](https://tree.taiga.io/project/penpot/issue/4322)
- Fix text out of borders with "auto width" and center align [Taiga #4308](https://tree.taiga.io/project/penpot/issue/4308)
- Fix wrong validation text after interaction with 2 and more files [Taiga #4276](https://tree.taiga.io/project/penpot/issue/4276)
- Fix auto-width for texts can make text appear stretched [Github #2482](https://github.com/penpot/penpot/issues/2482)
- Fix boards name do not disappear in focus mode [#4272](https://tree.taiga.io/project/penpot/issue/4272)
- Fix wrong email in the info message at change email [Taiga #4274](https://tree.taiga.io/project/penpot/issue/4274)
- Fix transform to path RMB menu item is not relevant if shape is already path [Taiga #4302](https://tree.taiga.io/project/penpot/issue/4302)
- Fix join nodes icon is active when 2 already joined nodes are selected [Taiga #4370](https://tree.taiga.io/project/penpot/issue/4370)
- Fix path nodes panel. "To curve" and "To corner" icons are active if node is already curved/cornered [Taiga #4371](https://tree.taiga.io/project/penpot/issue/4371)
- Fix displaying comments settings are not applied via "Comments" menu drop-down on the top navbar on view mode [Taiga #4389](https://tree.taiga.io/project/penpot/issue/4389)
- Fix bad behaviour on hovering and click nested artboards [Taiga #4018](https://tree.taiga.io/project/penpot/issue/4018) and [Taiga #4269](https://tree.taiga.io/project/penpot/us/4269)
- Fix lang autodetect issue [Taiga #4277](https://tree.taiga.io/project/penpot/issue/4277)
- Fix colorpicker does not close upon switching to Dashboard [Taiga #4408](https://tree.taiga.io/project/penpot/issue/4408)
- Fix problem with auto-width/auto-height + lock-proportions
## 1.16.0-beta
## :rocket: 1.16.0-beta
### :boom: Breaking changes & Deprecations
@@ -324,11 +58,8 @@
- Fix ungroup does not work for typographies [Taiga #4195](https://tree.taiga.io/project/penpot/issue/4195)
- Fix inviting to non existing users can fail [Taiga #4108](https://tree.taiga.io/project/penpot/issue/4108)
- Fix components marked as touched when moved [Taiga #4061](https://tree.taiga.io/project/penpot/task/4061)
- Fix boards grouped shouldn't show the title [Taiga #4251](https://tree.taiga.io/project/penpot/issue/4251)
- Fix gradient handlers are under resize handlers[Taiga #4298](https://tree.taiga.io/project/penpot/issue/4298)
- Fix grid not syncing immediately in multiuser [Taiga #4339](https://tree.taiga.io/project/penpot/issue/4339)
- Fix custom font upload fails silently for unsupported formats [Taiga #4279](https://tree.taiga.io/project/penpot/issue/4280)
### :arrow_up: Deps updates
### :heart: Community contributions by (Thank you!)
- To @andrewzhurov for many code contributions on this release.
@@ -340,17 +71,11 @@
### :bug: Bugs fixed
- Fix artboard border radius [Taiga #4291](https://tree.taiga.io/project/penpot/issue/4291)
- Fix copied & pasted layer is not visible [Taiga #4283](https://tree.taiga.io/project/penpot/issue/4283)
- Fix notification to newsletter is shown in all cases [Taiga #4367](https://tree.taiga.io/project/penpot/issue/4367)
- Fix comments section is not scrolling by mouse wheel [Taiga #4305](https://tree.taiga.io/project/penpot/issue/4305)
- Fix justify alignes text left [Taiga #4322](https://tree.taiga.io/project/penpot/issue/4322)
- Fix text out of borders with "auto width" and center align [Taiga #4308](https://tree.taiga.io/project/penpot/issue/4308)
## 1.15.4-beta
### :bug: Bugs fixed
- Fix social buttons in register form [Taiga #4320](https://tree.taiga.io/project/penpot/issue/4320)
- Remove gitter information from feedback page [Taiga #4157](https://tree.taiga.io/project/penpot/issue/4157)
- Fix overlay remains open on frame change [Taiga #4066](https://tree.taiga.io/project/penpot/issue/4066)
- Fix toggle overlay position [Taiga #4091](https://tree.taiga.io/project/penpot/issue/4091)

View File

@@ -101,14 +101,14 @@ Each commit should have:
Examples of good commit messages:
- `:bug: Fix unexpected error on launching modal`
- `:bug: Set proper error message on generic error`
- `:sparkles: Enable new modal for profile`
- `:zap: Improve performance of dashboard navigation`
- `:wrench: Update default backend configuration`
- `:books: Add more documentation for authentication process`
- `:ambulance: Fix critical bug on user registration process`
- `:tada: Add new approach for user registration`
- :bug: Fix unexpected error on launching modal
- :bug: Set proper error message on generic error
- :sparkles: Enable new modal for profile
- :zap: Improve performance of dashboard navigation
- :wrench: Update default backend configuration
- :books: Add more documentation for authentication process
- :ambulance: Fix critical bug on user registration process
- :tada: Add new approach for user registration
## Code of conduct ##

View File

@@ -4,7 +4,7 @@
<h1 align="center">
<br>
<img src="https://penpot.app/images/readme/git-readme-header.png" alt="PENPOT">
<img src="https://penpot.app/images/readme/readme-logo.jpg" alt="PENPOT">
</h1>
<p align="center"><a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img src="https://camo.githubusercontent.com/3fcf3d6b678ea15fde3cf7d6af0e242160366282d62a7c182d83a50bfee3f45e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4d504c2d322e302d626c75652e737667" alt="License: MPL-2.0" data-canonical-src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
@@ -50,7 +50,7 @@ Being web based, Penpot is not dependent on operating systems or local installat
Using SVG as no other design and prototyping tool does, Penpot files sport compatibility with most of the vectorial tools, are tech friendly and extremely easy to use on the web. We make sure you will always own your work.
<p align="center">
<img src="https://penpot.app/images/readme/git-open.png" alt="Open Source" style="width: 65%;">
<img src="https://penpot.app/images/open-source.png" alt="Open Source">
</p>
@@ -74,7 +74,7 @@ Heres a step-by-step guide on [getting started with Docker.](https://help.pen
If you prefer not to install Penpot in a local environment, [login or register on our Penpot cloud app](https://design.penpot.app). Create a team to work together on projects and share design assets or jump right away into Penpot and **start designing** on your own.
<p align="center">
<img src="https://penpot.app/images/readme/git-self-host.png" alt="Getting started" style="width: 65%;">
<img src="https://help.penpot.app/img/home-techguide.png" alt="Getting started">
</p>
## Community ##
@@ -93,7 +93,7 @@ You will find the following categories:
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
<p align="center">
<img src="https://penpot.app/images/readme/git-collaborate.png" alt="Communnity" style="width: 65%;">
<img src="https://penpot.app/images/cross-teams.webp" alt="Community">
</p>
## Contributing ##
@@ -111,7 +111,7 @@ Every sort of contribution will be very helpful to enhance Penpot. How youll
To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing-guide](https://help.penpot.app/contributing-guide/).
<p align="center">
<img src="https://penpot.app/images/readme/git-community.png" alt="Contributing" style="width: 65%;">
<img src="https://help.penpot.app/img/home-contributing.png" alt="Contributing">
</p>
## Resources ##

View File

@@ -16,11 +16,16 @@
{:src-dirs ["src" "resources"]
:target-dir class-dir})
(b/compile-clj
{:basis basis
:src-dirs ["src"]
:class-dir class-dir})
(b/uber
{:class-dir class-dir
:uber-file jar-file
:main 'clojure.main
:exclude [#".*Log4j2Plugins\.dat$"]
:exclude [#"goog.*" #"^javasist.*"]
:basis basis}))
(defn compile [_]

View File

@@ -1,9 +1,12 @@
{:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.11.1"}
org.clojure/core.async {:mvn/version "1.6.673"}
org.clojure/core.async {:mvn/version "1.5.648"}
com.github.luben/zstd-jni {:mvn/version "1.5.2-5"}
;; Logging
org.zeromq/jeromq {:mvn/version "0.5.2"}
com.github.luben/zstd-jni {:mvn/version "1.5.2-4"}
org.clojure/data.fressian {:mvn/version "1.0.0"}
io.prometheus/simpleclient {:mvn/version "0.16.0"}
@@ -13,20 +16,21 @@
:exclusions [org.eclipse.jetty/jetty-server
org.eclipse.jetty/jetty-servlet]}
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
io.lettuce/lettuce-core {:mvn/version "6.2.2.RELEASE"}
io.lettuce/lettuce-core {:mvn/version "6.2.0.RELEASE"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
funcool/yetti
{:git/tag "v9.12"
:git/sha "51646d8"
{:git/tag "v9.9"
:git/sha "f0a455d"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.847"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.828"}
metosin/reitit-core {:mvn/version "0.5.18"}
org.postgresql/postgresql {:mvn/version "42.5.2"}
org.postgresql/postgresql {:mvn/version "42.5.0"}
com.zaxxer/HikariCP {:mvn/version "5.0.1"}
io.whitfin/siphash {:mvn/version "2.0.0"}
@@ -34,9 +38,7 @@
buddy/buddy-hashers {:mvn/version "1.8.158"}
buddy/buddy-sign {:mvn/version "3.4.333"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.2"}
org.jsoup/jsoup {:mvn/version "1.15.3"}
org.jsoup/jsoup {:mvn/version "1.15.1"}
org.im4java/im4java
{:git/tag "1.4.0-penpot-2"
:git/sha "e2b3e16"
@@ -48,12 +50,11 @@
integrant/integrant {:mvn/version "0.8.0"}
dawran6/emoji {:mvn/version "0.1.5"}
markdown-clj/markdown-clj {:mvn/version "1.11.4"}
markdown-clj/markdown-clj {:mvn/version "1.11.3"}
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.19.29"}
}
software.amazon.awssdk/s3 {:mvn/version "2.17.278"}}
:paths ["src" "resources" "target/classes"]
:aliases
@@ -61,6 +62,7 @@
{:extra-deps
{com.bhauman/rebel-readline {:mvn/version "RELEASE"}
org.clojure/tools.namespace {:mvn/version "RELEASE"}
org.clojure/test.check {:mvn/version "RELEASE"}
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
org.clojure/data.csv {:mvn/version "RELEASE"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"}
@@ -68,8 +70,7 @@
:extra-paths ["test" "dev"]}
:build
{:extra-deps
{io.github.clojure/tools.build {:git/tag "v0.9.3" :git/sha "e537cd1"}}
{:extra-deps {io.github.clojure/tools.build {:git/tag "v0.8.3" :git/sha "0d20256"}}
:ns-default build}
:test

View File

@@ -12,12 +12,9 @@
[app.common.logging :as l]
[app.common.perf :as perf]
[app.common.pprint :as pp]
[app.common.spec :as us]
[app.common.transit :as t]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.main :as main]
[app.srepl.helpers]
[app.srepl.main :as srepl]
[app.util.blob :as blob]
[app.util.fressian :as fres]
@@ -29,13 +26,10 @@
[clojure.pprint :refer [pprint print-table]]
[clojure.repl :refer :all]
[clojure.spec.alpha :as s]
[clojure.stacktrace :as trace]
[clojure.spec.gen.alpha :as sgen]
[clojure.test :as test]
[clojure.test.check.generators :as gen]
[clojure.tools.namespace.repl :as repl]
[clojure.walk :refer [macroexpand-all]]
[criterium.core :as crit]
[cuerdas.core :as str]
[datoteka.core]
[integrant.core :as ig]))
@@ -48,24 +42,24 @@
(defmacro run-quick-bench
[& exprs]
`(crit/with-progress-reporting (crit/quick-bench (do ~@exprs) :verbose)))
`(with-progress-reporting (quick-bench (do ~@exprs) :verbose)))
(defmacro run-quick-bench'
[& exprs]
`(crit/quick-bench (do ~@exprs)))
`(quick-bench (do ~@exprs)))
(defmacro run-bench
[& exprs]
`(crit/with-progress-reporting (crit/bench (do ~@exprs) :verbose)))
`(with-progress-reporting (bench (do ~@exprs) :verbose)))
(defmacro run-bench'
[& exprs]
`(crit/bench (do ~@exprs)))
`(bench (do ~@exprs)))
;; --- Development Stuff
(defn- run-tests
([] (run-tests #"^backend-tests.*-test$"))
([] (run-tests #"^app.*-test$"))
([o]
(repl/refresh)
(cond
@@ -80,15 +74,12 @@
(defn- start
[]
(try
(alter-var-root #'system (fn [sys]
(when sys (ig/halt! sys))
(-> (merge main/system-config main/worker-config)
(ig/prep)
(ig/init))))
:started
(catch Throwable cause
(ex/print-throwable cause))))
(alter-var-root #'system (fn [sys]
(when sys (ig/halt! sys))
(-> (merge main/system-config main/worker-config)
(ig/prep)
(ig/init))))
:started)
(defn- stop
[]
@@ -109,20 +100,12 @@
(defn compression-bench
[data]
(let [humanize (fn [v] (hum/filesize v :binary true :format " %.4f "))
v1 (time (humanize (alength (blob/encode data {:version 1}))))
v3 (time (humanize (alength (blob/encode data {:version 3}))))
v4 (time (humanize (alength (blob/encode data {:version 4}))))
v5 (time (humanize (alength (blob/encode data {:version 5}))))
v6 (time (humanize (alength (blob/encode data {:version 6}))))
]
(let [humanize (fn [v] (hum/filesize v :binary true :format " %.4f "))]
(print-table
[{
:v1 v1
:v3 v3
:v4 v4
:v5 v5
:v6 v6
[{:v1 (humanize (alength (blob/encode data {:version 1})))
:v2 (humanize (alength (blob/encode data {:version 2})))
:v3 (humanize (alength (blob/encode data {:version 3})))
:v4 (humanize (alength (blob/encode data {:version 4})))
}])))
(defonce debug-tap

View File

@@ -0,0 +1,66 @@
<mjml>
<mj-head>
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
<mj-attributes>
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
</mj-attributes>
</mj-head>
<mj-body background-color="#E5E5E5">
<mj-section padding="0">
<mj-column>
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
width="97px" height="32px" align="left" padding="16px" />
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF">
<mj-column>
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text>
<mj-text>We received a request to change your current email to {{ pending-email }}.</mj-text>
<mj-text>Click to the link below to confirm the change:</mj-text>
<mj-button href="{{ public-uri }}/#/auth/verify-token?token={{token}}">
Confirm email change
</mj-button>
<mj-text>
If you received this email by mistake, please consider changing your password
for security reasons.
</mj-text>
<mj-text>Enjoy!</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0">
<mj-column>
<mj-social icon-size="24px" mode="horizontal">
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
</mj-social>
</mj-column>
</mj-section>
<mj-section padding="0 0 24px 0">
<mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text>
</mj-column>
</mj-section>
</mg-body>
</mjml>

View File

@@ -0,0 +1,59 @@
<mjml>
<mj-head>
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
<mj-attributes>
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
</mj-attributes>
</mj-head>
<mj-body background-color="#E5E5E5">
<mj-section padding="0">
<mj-column>
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
width="97px" height="32px" align="left" padding="16px" />
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF">
<mj-column>
<mj-text font-size="24px" font-weight="600">Hello!</mj-text>
<mj-text>
{{invited-by}} has invited you to join the team “{{ team }}”.
</mj-text>
<mj-button href="{{ public-uri }}/#/auth/verify-token?token={{token}}">
Accept invite
</mj-button>
<mj-text>Enjoy!</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0">
<mj-column>
<mj-social icon-size="24px" mode="horizontal">
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
</mj-social>
</mj-column>
</mj-section>
<mj-section padding="0 0 24px 0">
<mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text>
</mj-column>
</mj-section>
</mg-body>
</mjml>

View File

@@ -0,0 +1,68 @@
<mjml>
<mj-head>
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
<mj-attributes>
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
</mj-attributes>
</mj-head>
<mj-body background-color="#E5E5E5">
<mj-section padding="0">
<mj-column>
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
width="97px" height="32px" align="left" padding="16px" />
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF">
<mj-column>
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text>
<mj-text>
We have received a request to reset your password. Click the link
below to choose a new one:
</mj-text>
<mj-button href="{{ public-uri }}/#/auth/recovery?token={{token}}">
Reset password
</mj-button>
<mj-text>
If you received this email by mistake, you can safely ignore
it. Your password won't be changed.
</mj-text>
<mj-text>Enjoy!</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0">
<mj-column>
<mj-social icon-size="24px" mode="horizontal">
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
</mj-social>
</mj-column>
</mj-section>
<mj-section padding="0 0 24px 0">
<mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text>
</mj-column>
</mj-section>
</mg-body>
</mjml>

View File

@@ -0,0 +1,65 @@
<mjml>
<mj-head>
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
<mj-attributes>
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
</mj-attributes>
</mj-head>
<mj-body background-color="#E5E5E5">
<mj-section padding="0">
<mj-column>
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
width="97px" height="32px" align="left" padding="16px" />
</mj-column>
</mj-section>
<mj-section background-color="#FFFFFF">
<mj-column>
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text>
<mj-text>
Thanks for signing up for your Penpot account! Please verify your
email using the link below and get started building mockups and
prototypes today!
</mj-text>
<mj-button href="{{ public-uri }}/#/auth/verify-token?token={{token}}">
Verify email
</mj-button>
<mj-text>Enjoy!</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A">
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0">
<mj-column>
<mj-social icon-size="24px" mode="horizontal">
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://instagram.com/penpotapp/" padding="0 8px" />
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/uxbox" padding="0 8px" />
</mj-social>
</mj-column>
</mj-section>
<mj-section padding="0 0 24px 0">
<mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text>
</mj-column>
</mj-section>
</mg-body>
</mjml>

View File

@@ -103,9 +103,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -129,9 +129,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -143,7 +143,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -157,9 +157,9 @@
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -211,9 +211,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -225,7 +225,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -239,9 +239,9 @@
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
@@ -257,9 +257,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -271,7 +271,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -285,9 +285,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -301,7 +301,7 @@
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -321,7 +321,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -341,7 +341,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -361,7 +361,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -370,7 +370,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<a href="https://instagram.com/penpotapp/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -381,7 +381,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -390,7 +390,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -401,7 +401,7 @@
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
@@ -411,9 +411,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -425,7 +425,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -439,9 +439,9 @@
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -457,9 +457,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>

View File

@@ -103,9 +103,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -129,9 +129,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -143,7 +143,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -157,9 +157,9 @@
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -201,9 +201,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -215,7 +215,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -229,9 +229,9 @@
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
@@ -247,9 +247,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -261,7 +261,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -275,9 +275,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -291,7 +291,7 @@
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -311,7 +311,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -331,7 +331,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -351,7 +351,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -360,7 +360,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<a href="https://instagram.com/penpotapp/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -371,7 +371,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -380,7 +380,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -391,7 +391,7 @@
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
@@ -401,9 +401,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -415,7 +415,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -429,9 +429,9 @@
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -447,9 +447,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>

View File

@@ -103,9 +103,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -129,9 +129,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -143,7 +143,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -157,9 +157,9 @@
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -206,9 +206,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -220,7 +220,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -234,9 +234,9 @@
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
@@ -252,9 +252,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -266,7 +266,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -280,9 +280,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -296,7 +296,7 @@
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -316,7 +316,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -336,7 +336,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -356,7 +356,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -365,7 +365,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<a href="https://instagram.com/penpotapp/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -376,7 +376,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -385,7 +385,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -396,7 +396,7 @@
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
@@ -406,9 +406,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -420,7 +420,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -434,9 +434,9 @@
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -452,9 +452,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>

View File

@@ -103,9 +103,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -129,9 +129,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -143,7 +143,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -157,9 +157,9 @@
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -201,9 +201,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -215,7 +215,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -229,9 +229,9 @@
<td style="direction:ltr;font-size:0px;padding:24px 0 0 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:425px;"
>
@@ -247,9 +247,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -261,7 +261,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -275,9 +275,9 @@
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -291,7 +291,7 @@
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
>
<tr>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -311,7 +311,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -331,7 +331,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -351,7 +351,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -360,7 +360,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.instagram.com/penpot.app/" target="_blank">
<a href="https://instagram.com/penpotapp/" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-instagram.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -371,7 +371,7 @@
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
@@ -380,7 +380,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot" target="_blank">
<a href="https://tree.taiga.io/project/uxbox" target="_blank">
<img height="24" src="{{ public-uri }}/images/email/logo-taiga.png" style="border-radius:3px;display:block;" width="24" />
</a>
</td>
@@ -391,7 +391,7 @@
</table>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
@@ -401,9 +401,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
@@ -415,7 +415,7 @@
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
@@ -429,9 +429,9 @@
<td style="direction:ltr;font-size:0px;padding:0 0 24px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
@@ -447,9 +447,9 @@
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>

View File

@@ -1,8 +1,4 @@
[{:id "material-design-3"
:name "Material Design 3"
:thumbnail-uri "https://penpot.app/images/libraries/cover-md3.jpg"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Material%20Design%203.penpot"}
{:id "tutorial-for-beginners"
[{:id "tutorial-for-beginners"
:name "Tutorial for beginners"
:thumbnail-uri "https://penpot.app/images/libraries/tutorial-for-beginners.jpg"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/tutorial-for-beginners.penpot"}
@@ -26,11 +22,11 @@
:name "Circum Icons pack"
:thumbnail-uri "https://penpot.app/images/libraries/cover-circum.jpg"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/CircumIcons.penpot"}
{:id "coreui"
:name "CoreUI"
:thumbnail-uri "https://penpot.app/images/libraries/cover-coreui.jpg"
:file-uri "https://github.com/penpot/penpot-files/raw/main/CoreUI%20DesignSystem%20(DEMO).penpot"}
{:id "whiteboarding-kit"
:name "Whiteboarding Kit"
:thumbnail-uri "https://penpot.app/images/libraries/cover-whiteboards.jpg"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"}]
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"}
{:id "material-design-baseline"
:name "Material Design (baseline)"
:thumbnail-uri "https://penpot.app/images/libraries/cover-material.jpg"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Material-Design-Kit.penpot"}]

View File

@@ -6,21 +6,14 @@
<div class="tags">
{% if item.deprecated %}
<span class="tag">
<span>DEPRECATED</span>
</span>
{% endif %}
{% if item.auth %}
<span class="tag">
<span>AUTH</span>
</span>
{% endif %}
{% if item.webhook %}
<span class="tag">
<span>WEBHOOK</span>
<span>Deprecated:</span>
<span>since v{{item.deprecated}}</span>,
</span>
{% endif %}
<span class="tag">
<span>Auth:</span>
<span>{% if item.auth %}YES{% else %}NO{% endif %}</span>
</span>
</div>
</div>
<div class="rpc-row-detail hidden">

View File

@@ -11,8 +11,7 @@ penpot - error list
<main class="horizontal-list">
<ul>
{% for item in items %}
<li><a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
<span class="title">{{item.hint|abbreviate:150}}</span></li>
<li><a href="/dbg/error/{{item.id}}">{{item.created-at}}</a></li>
{% endfor %}
</ul>
</main>

View File

@@ -1,112 +0,0 @@
{% extends "app/templates/base.tmpl" %}
{% block title %}
penpot - error report v2 {{id}}
{% endblock %}
{% block content %}
<nav>
<div>[<a href="/dbg/error">⮜</a>]</div>
<div>[<a href="#message">message</a>]</div>
<div>[<a href="#props">props</a>]</div>
<div>[<a href="#context">context</a>]</div>
{% if params %}
<div>[<a href="#params">request params</a>]</div>
{% endif %}
{% if data %}
<div>[<a href="#edata">error data</a>]</div>
{% endif %}
{% if spec-explain %}
<div>[<a href="#spec-explain">spec explain</a>]</div>
{% endif %}
{% if spec-problems %}
<div>[<a href="#spec-problems">spec problems</a>]</div>
{% endif %}
{% if spec-value %}
<div>[<a href="#spec-value">spec value</a>]</div>
{% endif %}
{% if trace %}
<div>[<a href="#trace">error trace</a>]</div>
{% endif %}
</nav>
<main>
<div class="table">
<div class="table-row multiline">
<div id="message" class="table-key">MESSAGE: </div>
<div class="table-val">
<h1>{{hint}}</h1>
</div>
</div>
<div class="table-row multiline">
<div id="props" class="table-key">LOG PROPS: </div>
<div class="table-val">
<pre>{{props}}</pre>
</div>
</div>
<div class="table-row multiline">
<div id="context" class="table-key">CONTEXT: </div>
<div class="table-val">
<pre>{{context}}</pre>
</div>
</div>
{% if params %}
<div class="table-row multiline">
<div id="params" class="table-key">REQUEST PARAMS: </div>
<div class="table-val">
<pre>{{params}}</pre>
</div>
</div>
{% endif %}
{% if data %}
<div class="table-row multiline">
<div id="edata" class="table-key">ERROR DATA: </div>
<div class="table-val">
<pre>{{data}}</pre>
</div>
</div>
{% endif %}
{% if spec-explain %}
<div class="table-row multiline">
<div id="spec-explain" class="table-key">SPEC EXPLAIN: </div>
<div class="table-val">
<pre>{{spec-explain}}</pre>
</div>
</div>
{% endif %}
{% if spec-problems %}
<div class="table-row multiline">
<div id="spec-problems" class="table-key">SPEC PROBLEMS: </div>
<div class="table-val">
<pre>{{spec-problems}}</pre>
</div>
</div>
{% endif %}
{% if spec-value %}
<div class="table-row multiline">
<div id="spec-value" class="table-key">SPEC VALUE: </div>
<div class="table-val">
<pre>{{spec-value}}</pre>
</div>
</div>
{% endif %}
{% if trace %}
<div class="table-row multiline">
<div id="trace" class="table-key">TRACE:</div>
<div class="table-val">
<pre>{{trace}}</pre>
</div>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -23,10 +23,6 @@ input[type=text], input[type=submit] {
padding: 3px;
}
pre {
white-space: pre-wrap;
}
main {
margin: 20px;
}
@@ -141,6 +137,8 @@ nav > div:not(:last-child) {
margin: 0px;
padding: 0px;
flex-direction: column;
flex-wrap: wrap;
height: calc(100vh - 75px);
justify-content: flex-start;
}
@@ -153,31 +151,19 @@ nav > div:not(:last-child) {
margin: 0px 20px;
cursor: pointer;
display: flex;
justify-content: center;
border-radius: 3px;
}
.horizontal-list li:hover {
background-color: #e9e9e9;
}
.horizontal-list li > *:not(:last-child) {
margin-right: 10px;
}
.horizontal-list li > a {
text-decoration: none;
color: inherit;
}
.horizontal-list li > .date {
font-weight: 200;
color: #686868;
min-width: 210px;
}
form .row {
padding: 5px 0;
}

View File

@@ -1,8 +0,0 @@
;; Required: concurrency
;; Optional: queue-size, ommited means Integer/MAX_VALUE
{:update-file {:concurrency 1 :queue-size 3}
:auth {:concurrency 128}
:process-font {:concurrency 4 :queue-size 32}
:process-image {:concurrency 8 :queue-size 32}
:push-audit-events
{:concurrency 1 :queue-size 3}}

View File

@@ -2,18 +2,21 @@
<Configuration status="info" monitorInterval="30">
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
alwaysWriteExceptions="false" />
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"/>
</Console>
<RollingFile name="main" fileName="logs/main.log" filePattern="logs/main-%i.log">
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
alwaysWriteExceptions="false" />
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"/>
<Policies>
<SizeBasedTriggeringPolicy size="50M"/>
</Policies>
<DefaultRolloverStrategy max="9"/>
</RollingFile>
<JeroMQ name="zmq">
<Property name="endpoint">tcp://localhost:45556</Property>
<JsonLayout complete="false" compact="true" includeTimeMillis="true" stacktraceAsString="true" properties="true" />
</JeroMQ>
</Appenders>
<Loggers>
@@ -29,8 +32,10 @@
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />
<Logger name="app.rpc.climit" level="info" />
<Logger name="app.rpc.mutations.files" level="info" />
<Logger name="app.cli" level="debug" additivity="false">
<AppenderRef ref="console"/>
</Logger>
<Logger name="app.loggers" level="debug" additivity="false">
<AppenderRef ref="main" level="debug" />
@@ -38,6 +43,7 @@
<Logger name="app" level="all" additivity="false">
<AppenderRef ref="main" level="trace" />
<AppenderRef ref="zmq" level="debug" />
</Logger>
<Logger name="user" level="trace" additivity="false">

View File

@@ -2,8 +2,7 @@
<Configuration status="info" monitorInterval="60">
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
alwaysWriteExceptions="false" />
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"/>
</Console>
</Appenders>
@@ -12,7 +11,6 @@
<Logger name="com.zaxxer.hikari" level="error" />
<Logger name="org.postgresql" level="error" />
<Logger name="app.util" level="info" />
<Logger name="app" level="info" additivity="false">
<AppenderRef ref="console" />
</Logger>

View File

@@ -1,10 +1,6 @@
;; Example rlimit.edn file
^{:refresh "30s"}
{:default
[[:default :window "200000/h"]]
#{:command/get-teams}
[[:burst :bucket "5/1/5s"]]
#{:command/get-profile}
[[:burst :bucket "60/60/1m"]]}
#{:query/profile}
[[:burst :bucket "100/60/1m"]]}

View File

@@ -12,11 +12,10 @@ cp ../CHANGES.md target/classes/changelog.md;
clojure -T:build jar;
mv target/penpot.jar target/dist/penpot.jar
cp resources/log4j2.xml target/dist/log4j2.xml
cp scripts/run.template.sh target/dist/run.sh;
cp scripts/manage.py target/dist/manage.py
cp scripts/manage.template.sh target/dist/manage.sh;
chmod +x target/dist/run.sh;
chmod +x target/dist/manage.py
chmod +x target/dist/manage.sh;
# Prefetch
bb ./scripts/prefetch-templates.clj resources/app/onboarding.edn builtin-templates/

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -x
jcmd |grep "rebel" |sed -nE 's/^([0-9]+).*$/\1/p' | xargs kill -9

View File

@@ -1,167 +0,0 @@
#!/usr/bin/env python3
#
# 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
import argparse
import json
import socket
import sys
from getpass import getpass
from urllib.parse import urlparse
PREPL_URI = "tcp://localhost:6063"
def get_prepl_conninfo():
uri_data = urlparse(PREPL_URI)
if uri_data.scheme != "tcp":
raise RuntimeError(f"invalid PREPL_URI: {PREPL_URI}")
if not isinstance(uri_data.netloc, str):
raise RuntimeError(f"invalid PREPL_URI: {PREPL_URI}")
host, port = uri_data.netloc.split(":", 2)
if port is None:
port = 6063
if isinstance(port, str):
port = int(port)
return host, port
def send_eval(expr):
host, port = get_prepl_conninfo()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
s.send(expr.encode("utf-8"))
s.send(b":repl/quit\n\n")
with s.makefile() as f:
result = json.load(f)
tag = result.get("tag", None)
if tag != "ret":
raise RuntimeError("unexpected response from PREPL")
return result.get("val", None), result.get("exception", None)
def encode(val):
return json.dumps(json.dumps(val))
def print_error(res):
for error in res["via"]:
print("ERR:", error["message"])
break
def run_cmd(params):
expr = "(app.srepl.ext/run-json-cmd {})".format(encode(params))
res, failed = send_eval(expr)
if failed:
print_error(res)
sys.exit(-1)
return res
def create_profile(fullname, email, password):
params = {
"cmd": "create-profile",
"params": {
"fullname": fullname,
"email": email,
"password": password
}
}
res = run_cmd(params)
print(f"Created: {res['email']} / {res['id']}")
def update_profile(email, fullname, password, is_active):
params = {
"cmd": "update-profile",
"params": {
"email": email,
"fullname": fullname,
"password": password,
"is_active": is_active
}
}
res = run_cmd(params)
if res is True:
print(f"Updated")
else:
print(f"No profile found with email {email}")
def derive_password(password):
params = {
"cmd": "derive-password",
"params": {
"password": password,
}
}
res = run_cmd(params)
print(f"Derived password: \"{res}\"")
available_commands = [
"create-profile",
"update-profile",
"derive-password"
]
parser = argparse.ArgumentParser(
description=(
"Penpot Command Line Interface (CLI)"
)
)
parser.add_argument("-V", "--version", action="version", version="Penpot CLI %%develop%%")
parser.add_argument("action", action="store", choices=available_commands)
parser.add_argument("-n", "--fullname", help="Fullname", action="store")
parser.add_argument("-e", "--email", help="Email", action="store")
parser.add_argument("-p", "--password", help="Password", action="store")
parser.add_argument("-c", "--connect", help="Connect to PREPL", action="store", default="tcp://localhost:6063")
args = parser.parse_args()
PREPL_URI = args.connect
if args.action == "create-profile":
email = args.email
password = args.password
fullname = args.fullname
if email is None:
email = input("Email: ")
if fullname is None:
fullname = input("Fullname: ")
if password is None:
password = getpass("Password: ")
create_profile(fullname, email, password)
elif args.action == "update-profile":
email = args.email
password = args.password
if email is None:
email = input("Email: ")
if password is None:
password = getpass("Password: ")
update_profile(email, None, password, None)
elif args.action == "derive-password":
password = args.password
if password is None:
password = getpass("Password: ")
derive_password(password)

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set +e
JAVA_CMD=$(type -p java)
set -e
if [[ ! -n "$JAVA_CMD" ]]; then
if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
JAVA_CMD="$JAVA_HOME/bin/java"
else
>&2 echo "Couldn't find 'java'. Please set JAVA_HOME."
exit 1
fi
fi
if [ -f ./environ ]; then
source ./environ
fi
exec $JAVA_CMD $JVM_OPTS -jar penpot.jar -m app.cli.manage "$@"

View File

@@ -2,21 +2,7 @@
export PENPOT_HOST=devenv
export PENPOT_TENANT=dev
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-backend-asserts \
enable-audit-log \
enable-transit-readable-response \
enable-demo-users \
disable-secure-session-cookies \
enable-smtp \
enable-prepl-server \
enable-urepl-server \
enable-rpc-climit \
enable-rpc-rlimit \
enable-soft-rpc-rlimit \
enable-webhooks \
enable-access-tokens";
export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-rpc-rate-limit enable-warn-rpc-rate-limits enable-smtp"
# export PENPOT_DATABASE_URI="postgresql://172.17.0.1:5432/penpot"
# export PENPOT_DATABASE_USERNAME="penpot"
@@ -30,6 +16,8 @@ export PENPOT_FLAGS="\
# export PENPOT_LOGGERS_LOKI_URI="http://172.17.0.1:3100/loki/api/v1/push"
# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"
export PENPOT_DEFAULT_RATE_LIMIT="default,window,10000/h"
# Initialize MINIO config
mc alias set penpot-s3/ http://minio:9000 minioadmin minioadmin
mc admin user add penpot-s3 penpot-devenv penpot-devenv
@@ -43,14 +31,13 @@ export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000
export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
export OPTIONS="
-A:jmx-remote -A:dev \
-A:dev:jmx-remote \
-J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-J-Djdk.attach.allowAttachSelf \
-J-Dlog4j2.configurationFile=log4j2-devenv.xml \
-J-Xms50m \
-J-Xmx1024m \
-J-XX:+UseZGC \
-J-XX:+UseG1GC \
-J-XX:-OmitStackTraceInFastThrow \
-J-Xms50m -J-Xmx1024m \
-J-Djdk.attach.allowAttachSelf \
-J-XX:+UnlockDiagnosticVMOptions \
-J-XX:+DebugNonSafepoints";

View File

@@ -1,24 +1,20 @@
#!/usr/bin/env bash
set +e
JAVA_CMD=$(type -p java)
set -e
if [[ ! -n "$JAVA_CMD" ]]; then
if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
JAVA_CMD="$JAVA_HOME/bin/java"
else
set +e
JAVA_CMD=$(type -p java)
set -e
if [[ ! -n "$JAVA_CMD" ]]; then
>&2 echo "Couldn't find 'java'. Please set JAVA_HOME."
exit 1
fi
fi
if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
JAVA_CMD="$JAVA_HOME/bin/java"
else
>&2 echo "Couldn't find 'java'. Please set JAVA_HOME."
exit 1
fi
fi
if [ -f ./environ ]; then
source ./environ
source ./environ
fi
export JVM_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow $JVM_OPTS"
set -x
exec $JAVA_CMD $JVM_OPTS "$@" -jar penpot.jar -m app.main

View File

@@ -2,7 +2,7 @@
export PENPOT_HOST=devenv
export PENPOT_TENANT=dev
export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-smtp enable-webhooks"
export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-smtp"
set -ex

View File

@@ -1,26 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.auth
(:require
[buddy.hashers :as hashers]))
(defn derive-password
[password]
(hashers/derive password
{:alg :argon2id
:memory 16384
:iterations 20
:parallelism 2}))
(defn verify-password
[attempt password]
(try
(hashers/verify attempt password)
(catch Throwable _
{:update false
:valid false})))

View File

@@ -41,18 +41,15 @@
(reduce-kv clojure.string/replace s replacements))
(defn- search-user
[{:keys [::conn base-dn] :as cfg} email]
(let [query (replace-several (:query cfg) ":username" email)
attrs [(:attrs-username cfg)
(:attrs-email cfg)
(:attrs-fullname cfg)]
[{:keys [conn attrs base-dn] :as cfg} email]
(let [query (replace-several (:query cfg) ":username" email)
params {:filter query
:sizelimit 1
:attributes attrs}]
(first (ldap/search conn base-dn params))))
(defn- retrieve-user
[{:keys [::conn] :as cfg} {:keys [email password]}]
[{:keys [conn] :as cfg} {:keys [email password]}]
(when-let [{:keys [dn] :as user} (search-user cfg email)]
(when (ldap/bind? conn dn password)
{:fullname (get user (-> cfg :attrs-fullname keyword))
@@ -69,7 +66,7 @@
(defn authenticate
[cfg params]
(with-open [conn (connect cfg)]
(when-let [user (-> (assoc cfg ::conn conn)
(when-let [user (-> (assoc cfg :conn conn)
(retrieve-user params))]
(when-not (s/valid? ::info-data user)
(let [explain (s/explain-str ::info-data user)]
@@ -103,6 +100,17 @@
:host (:host cfg) :port (:port cfg) :cause cause)
nil))))
(defn- prepare-attributes
[cfg]
(assoc cfg :attrs [(:attrs-username cfg)
(:attrs-email cfg)
(:attrs-fullname cfg)]))
(defmethod ig/init-key ::provider
[_ cfg]
(when (:enabled? cfg)
(some-> cfg try-connectivity prepare-attributes)))
(s/def ::enabled? ::us/boolean)
(s/def ::host ::cf/ldap-host)
(s/def ::port ::cf/ldap-port)
@@ -116,7 +124,8 @@
(s/def ::attrs-fullname ::cf/ldap-attrs-fullname)
(s/def ::attrs-username ::cf/ldap-attrs-username)
(s/def ::provider-params
(defmethod ig/pre-init-spec ::provider
[_]
(s/keys :opt-un [::host ::port
::ssl ::tls
::enabled?
@@ -126,14 +135,3 @@
::attrs-email
::attrs-username
::attrs-fullname]))
(s/def ::provider
(s/nilable ::provider-params))
(defmethod ig/pre-init-spec ::provider
[_]
(s/spec ::provider))
(defmethod ig/init-key ::provider
[_ cfg]
(when (:enabled? cfg)
(try-connectivity cfg)))

View File

@@ -7,7 +7,6 @@
(ns app.auth.oidc
"OIDC client implementation."
(:require
[app.auth.oidc.providers :as-alias providers]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
@@ -18,10 +17,8 @@
[app.db :as db]
[app.http.client :as http]
[app.http.middleware :as hmw]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.rpc.commands.profile :as profile]
[app.rpc.queries.profile :as profile]
[app.tokens :as tokens]
[app.util.json :as json]
[app.util.time :as dt]
@@ -50,11 +47,9 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- discover-oidc-config
[cfg {:keys [base-uri] :as opts}]
[{:keys [http-client]} {:keys [base-uri] :as opts}]
(let [discovery-uri (u/join base-uri ".well-known/openid-configuration")
response (ex/try! (http/req! cfg
{:method :get :uri (str discovery-uri)}
{:sync? true}))]
response (ex/try (http/req! http-client {:method :get :uri (str discovery-uri)} {:sync? true}))]
(cond
(ex/exception? response)
(do
@@ -64,17 +59,10 @@
nil)
(= 200 (:status response))
(let [data (json/decode (:body response))
token-uri (get data :token_endpoint)
auth-uri (get data :authorization_endpoint)
user-uri (get data :userinfo_endpoint)]
(l/debug :hint "oidc uris discovered"
:token-uri token-uri
:auth-uri auth-uri
:user-uri user-uri)
{:token-uri token-uri
:auth-uri auth-uri
:user-uri user-uri})
(let [data (json/read (:body response))]
{:token-uri (get data :token_endpoint)
:auth-uri (get data :authorization_endpoint)
:user-uri (get data :userinfo_endpoint)})
:else
(do
@@ -85,15 +73,15 @@
(defn- prepare-oidc-opts
[cfg]
(let [opts {:base-uri (cf/get :oidc-base-uri)
:client-id (cf/get :oidc-client-id)
:client-secret (cf/get :oidc-client-secret)
:token-uri (cf/get :oidc-token-uri)
:auth-uri (cf/get :oidc-auth-uri)
:user-uri (cf/get :oidc-user-uri)
:scopes (cf/get :oidc-scopes #{"openid" "profile" "email"})
:roles-attr (cf/get :oidc-roles-attr)
:roles (cf/get :oidc-roles)
(let [opts {:base-uri (:base-uri cfg)
:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:token-uri (:token-uri cfg)
:auth-uri (:auth-uri cfg)
:user-uri (:user-uri cfg)
:scopes (:scopes cfg #{"openid" "profile" "email"})
:roles-attr (:roles-attr cfg)
:roles (:roles cfg)
:name "oidc"}
opts (d/without-nils opts)]
@@ -108,56 +96,61 @@
(some-> (discover-oidc-config cfg opts)
(merge opts {:discover? true}))))))
(defmethod ig/pre-init-spec ::providers/generic [_]
(s/keys :req [::http/client]))
(defmethod ig/init-key ::providers/generic
(defmethod ig/prep-key ::generic-provider
[_ cfg]
(when (contains? cf/flags :login-with-oidc)
(d/without-nils cfg))
(defmethod ig/init-key ::generic-provider
[_ cfg]
(when (:enabled? cfg)
(if-let [opts (prepare-oidc-opts cfg)]
(do
(l/info :hint "provider initialized"
:provider "oidc"
:provider :oidc
:method (if (:discover? opts) "discover" "manual")
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts))
:scopes (str/join "," (:scopes opts))
:auth-uri (:auth-uri opts)
:user-uri (:user-uri opts)
:token-uri (:token-uri opts)
:scopes (str/join "," (:scopes opts))
:auth-uri (:auth-uri opts)
:user-uri (:user-uri opts)
:token-uri (:token-uri opts)
:roles-attr (:roles-attr opts)
:roles (:roles opts))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "oidc")
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :oidc)
nil))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GOOGLE AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::providers/google
[_ _]
(let [opts {:client-id (cf/get :google-client-id)
:client-secret (cf/get :google-client-secret)
(defmethod ig/prep-key ::google-provider
[_ cfg]
(d/without-nils cfg))
(defmethod ig/init-key ::google-provider
[_ cfg]
(let [opts {:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:scopes #{"openid" "email" "profile"}
:auth-uri "https://accounts.google.com/o/oauth2/v2/auth"
:token-uri "https://oauth2.googleapis.com/token"
:user-uri "https://openidconnect.googleapis.com/v1/userinfo"
:name "google"}]
(when (contains? cf/flags :login-with-google)
(when (:enabled? cfg)
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :hint "provider initialized"
:provider "google"
:provider :google
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "google")
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :google)
nil)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -165,29 +158,29 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- retrieve-github-email
[cfg tdata props]
(or (some-> props :github/email p/resolved)
(->> (http/req! cfg
{:uri "https://api.github.com/user/emails"
:headers {"Authorization" (dm/str (:type tdata) " " (:token tdata))}
:timeout 6000
:method :get})
(p/map (fn [{:keys [status body] :as response}]
[{:keys [http-client]} tdata info]
(or (some-> info :email p/resolved)
(-> (http/req! http-client {:uri "https://api.github.com/user/emails"
:headers {"Authorization" (dm/str (:type tdata) " " (:token tdata))}
:timeout 6000
:method :get})
(p/then (fn [{:keys [status body] :as response}]
(when-not (s/int-in-range? 200 300 status)
(ex/raise :type :internal
:code :unable-to-retrieve-github-emails
:hint "unable to retrieve github emails"
:http-status status
:http-body body))
(->> response :body json/decode (filter :primary) first :email))))))
(->> response :body json/read (filter :primary) first :email))))))
(defmethod ig/pre-init-spec ::providers/github [_]
(s/keys :req [::http/client]))
(defmethod ig/init-key ::providers/github
(defmethod ig/prep-key ::github-provider
[_ cfg]
(let [opts {:client-id (cf/get :github-client-id)
:client-secret (cf/get :github-client-secret)
(d/without-nils cfg))
(defmethod ig/init-key ::github-provider
[_ cfg]
(let [opts {:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:scopes #{"read:user" "user:email"}
:auth-uri "https://github.com/login/oauth/authorize"
:token-uri "https://github.com/login/oauth/access_token"
@@ -196,64 +189,63 @@
;; Additional hooks for provider specific way of
;; retrieve emails.
:get-email-fn (partial retrieve-github-email cfg)}]
:get-email-fn (partial retrieve-github-email cfg)}]
(when (contains? cf/flags :login-with-github)
(when (:enabled? cfg)
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :hint "provider initialized"
:provider "github"
:provider :github
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "github")
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :github)
nil)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GITLAB AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::providers/gitlab
[_ _]
(let [base (cf/get :gitlab-base-uri "https://gitlab.com")
(defmethod ig/prep-key ::gitlab-provider
[_ cfg]
(d/without-nils cfg))
(defmethod ig/init-key ::gitlab-provider
[_ cfg]
(let [base (:base-uri cfg "https://gitlab.com")
opts {:base-uri base
:client-id (cf/get :gitlab-client-id)
:client-secret (cf/get :gitlab-client-secret)
:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:scopes #{"openid" "profile" "email"}
:auth-uri (str base "/oauth/authorize")
:token-uri (str base "/oauth/token")
:user-uri (str base "/oauth/userinfo")
:name "gitlab"}]
(when (contains? cf/flags :login-with-gitlab)
(when (:enabled? cfg)
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :hint "provider initialized"
:provider "gitlab"
:provider :gitlab
:base-uri base
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "gitlab")
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :gitlab)
nil)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HANDLERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- parse-attr-path
[provider path]
(let [[fitem & items] (str/split path "__")]
(into [(keyword (:name provider) fitem)] (map keyword) items)))
(defn- build-redirect-uri
[{:keys [provider] :as cfg}]
(let [public (u/uri (cf/get :public-uri))]
(let [public (u/uri (:public-uri cfg))]
(str (assoc public :path (str "/api/auth/oauth/" (:name provider) "/callback")))))
(defn- build-auth-uri
@@ -276,7 +268,7 @@
props))
(defn retrieve-access-token
[{:keys [provider] :as cfg} code]
[{:keys [provider http-client] :as cfg} code]
(let [params {:client_id (:client-id provider)
:client_secret (:client-secret provider)
:code code
@@ -287,45 +279,26 @@
"accept" "application/json"}
:uri (:token-uri provider)
:body (u/map->query-string params)}]
(l/trace :hint "request access token"
:provider (:name provider)
:client-id (:client-id provider)
:client-secret (obfuscate-string (:client-secret provider))
:grant-type (:grant_type params)
:redirect-uri (:redirect_uri params))
(->> (http/req! cfg req)
(p/map (fn [{:keys [status body] :as res}]
(l/trace :hint "access token response"
:status status
:body body)
(if (= status 200)
(let [data (json/decode body)]
{:token (get data :access_token)
:type (get data :token_type)})
(ex/raise :type :internal
:code :unable-to-retrieve-token
:http-status status
:http-body body)))))))
(p/then
(http/req! http-client req)
(fn [{:keys [status body] :as res}]
(if (= status 200)
(let [data (json/read body)]
{:token (get data :access_token)
:type (get data :token_type)})
(ex/raise :type :internal
:code :unable-to-retrieve-token
:http-status status
:http-body body))))))
(defn- retrieve-user-info
[{:keys [provider] :as cfg} tdata]
[{:keys [provider http-client] :as cfg} tdata]
(letfn [(retrieve []
(l/trace :hint "request user info"
:uri (:user-uri provider)
:token (obfuscate-string (:token tdata))
:token-type (:type tdata))
(http/req! cfg
{:uri (:user-uri provider)
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
:timeout 6000
:method :get}))
(http/req! http-client {:uri (:user-uri provider)
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
:timeout 6000
:method :get}))
(validate-response [response]
(l/trace :hint "user info response"
:status (:status response)
:body (:body response))
(when-not (s/int-in-range? 200 300 (:status response))
(ex/raise :type :internal
:code :unable-to-retrieve-user-info
@@ -334,33 +307,28 @@
:http-body (:body response)))
response)
(get-email [props]
(get-email [info]
;; Allow providers hook into this for custom email
;; retrieval method.
(if-let [get-email-fn (:get-email-fn provider)]
(get-email-fn tdata props)
(let [attr-kw (cf/get :oidc-email-attr "email")
attr-ph (parse-attr-path provider attr-kw)]
(p/resolved (get-in props attr-ph)))))
(get-email-fn tdata info)
(let [attr-kw (cf/get :oidc-email-attr :email)]
(get info attr-kw))))
(get-name [info]
(let [attr-kw (cf/get :oidc-name-attr "name")
attr-ph (parse-attr-path provider attr-kw)]
(get-in info attr-ph)))
(let [attr-kw (cf/get :oidc-name-attr :name)]
(get info attr-kw)))
(process-response [response]
(p/let [info (-> response :body json/decode)
props (qualify-props provider info)
email (get-email props)]
(p/let [info (-> response :body json/read)
email (get-email info)]
{:backend (:name provider)
:fullname (or (get-name props) email)
:email email
:props props}))
:fullname (or (get-name info) email)
:props (->> (dissoc info :name :email)
(qualify-props provider))}))
(validate-info [info]
(l/trace :hint "authentication info" :info info)
(when-not (s/valid? ::info info)
(l/warn :hint "received incomplete profile info object (please set correct scopes)"
:info (pr-str info))
@@ -370,10 +338,10 @@
:info info))
info)]
(->> (retrieve)
(p/fmap validate-response)
(p/mcat process-response)
(p/fmap validate-info))))
(-> (retrieve)
(p/then validate-response)
(p/then process-response)
(p/then validate-info))))
(s/def ::backend ::us/not-empty-string)
(s/def ::email ::us/not-empty-string)
@@ -385,24 +353,23 @@
::fullname
::props]))
(defn get-info
[{:keys [provider] :as cfg} {:keys [params] :as request}]
(letfn [(validate-oidc [{:keys [props] :as info}]
(defn retrieve-info
[{:keys [sprops provider] :as cfg} {:keys [params] :as request}]
(letfn [(validate-oidc [info]
;; If the provider is OIDC, we can proceed to check
;; roles if they are defined.
(when (and (= "oidc" (:name provider))
(seq (:roles provider)))
(let [expected-roles (into #{} (:roles provider))
current-roles (let [roles-kw (cf/get :oidc-roles-attr "roles")
roles-ph (parse-attr-path provider roles-kw)
roles (get-in props roles-ph)]
(let [provider-roles (into #{} (:roles provider))
profile-roles (let [attr (cf/get :oidc-roles-attr :roles)
roles (get info attr)]
(cond
(string? roles) (into #{} (str/words roles))
(vector? roles) (into #{} roles)
:else #{}))]
;; check if profile has a configured set of roles
(when-not (set/subset? expected-roles current-roles)
(when-not (set/subset? provider-roles profile-roles)
(ex/raise :type :internal
:code :unable-to-auth
:hint "not enough permissions"))))
@@ -426,42 +393,44 @@
(let [state (get params :state)
code (get params :code)
state (tokens/verify (::main/props cfg) {:token state :iss :oauth})]
state (tokens/verify sprops {:token state :iss :oauth})]
(-> (p/resolved code)
(p/then #(retrieve-access-token cfg %))
(p/then #(retrieve-user-info cfg %))
(p/then' validate-oidc)
(p/then' (partial post-process state))))))
(defn- get-profile
[{:keys [::db/pool ::wrk/executor] :as cfg} info]
(defn- retrieve-profile
[{:keys [pool executor] :as cfg} info]
(px/with-dispatch executor
(with-open [conn (db/open pool)]
(some->> (:email info)
(profile/get-profile-by-email conn)))))
(profile/retrieve-profile-data-by-email conn)
(profile/populate-additional-data conn)
(profile/decode-profile-row)))))
(defn- redirect-response
[uri]
(yrs/response :status 302 :headers {"location" (str uri)}))
(defn- generate-error-redirect
[_ error]
(let [uri (-> (u/uri (cf/get :public-uri))
[cfg error]
(let [uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/login")
(assoc :query (u/map->query-string {:error "unable-to-auth" :hint (ex-message error)})))]
(redirect-response uri)))
(defn- generate-redirect
[cfg request info profile]
[{:keys [sprops session audit] :as cfg} request info profile]
(if profile
(let [sxf (session/create-fn cfg (:id profile))
(let [sxf ((:create session) (:id profile))
token (or (:invitation-token info)
(tokens/generate (::main/props cfg)
{:iss :auth
:exp (dt/in-future "15m")
:profile-id (:id profile)}))
(tokens/generate sprops {:iss :auth
:exp (dt/in-future "15m")
:profile-id (:id profile)}))
params {:token token}
uri (-> (u/uri (cf/get :public-uri))
uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/verify-token")
(assoc :query (u/map->query-string params)))]
@@ -469,11 +438,13 @@
(ex/raise :type :restriction
:code :profile-blocked))
(audit/submit! cfg {:type "command"
:name "login-with-password"
:profile-id (:id profile)
:ip-addr (audit/parse-client-ip request)
:props (audit/profile->props profile)})
(when (fn? audit)
(audit :cmd :submit
:type "command"
:name "login"
:profile-id (:id profile)
:ip-addr (audit/parse-client-ip request)
:props (audit/profile->props profile)))
(->> (redirect-response uri)
(sxf request)))
@@ -482,19 +453,19 @@
:iss :prepared-register
:is-active true
:exp (dt/in-future {:hours 48}))
token (tokens/generate (::main/props cfg) info)
token (tokens/generate sprops info)
params (d/without-nils
{:token token
:fullname (:fullname info)})
uri (-> (u/uri (cf/get :public-uri))
uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/register/validate")
(assoc :query (u/map->query-string params)))]
(redirect-response uri))))
(defn- auth-handler
[cfg {:keys [params] :as request}]
[{:keys [sprops] :as cfg} {:keys [params] :as request}]
(let [props (audit/extract-utm-params params)
state (tokens/generate (::main/props cfg)
state (tokens/generate sprops
{:iss :oauth
:invitation-token (:invitation-token params)
:props props
@@ -505,8 +476,8 @@
(defn- callback-handler
[cfg request]
(letfn [(process-request []
(p/let [info (get-info cfg request)
profile (get-profile cfg info)]
(p/let [info (retrieve-info cfg request)
profile (retrieve-profile cfg info)]
(generate-redirect cfg request info profile)))
(handle-error [cause]
@@ -520,7 +491,7 @@
{:compile
(fn [& _]
(fn [handler]
(fn [{:keys [::providers] :as cfg} request]
(fn [{:keys [providers] :as cfg} request]
(let [provider (some-> request :path-params :provider keyword)]
(if-let [provider (get providers provider)]
(handler (assoc cfg :provider provider) request)
@@ -529,54 +500,41 @@
:provider provider
:hint "provider not configured"))))))})
(s/def ::client-id ::cf/oidc-client-id)
(s/def ::client-secret ::cf/oidc-client-secret)
(s/def ::base-uri ::cf/oidc-base-uri)
(s/def ::token-uri ::cf/oidc-token-uri)
(s/def ::auth-uri ::cf/oidc-auth-uri)
(s/def ::user-uri ::cf/oidc-user-uri)
(s/def ::scopes ::cf/oidc-scopes)
(s/def ::roles ::cf/oidc-roles)
(s/def ::roles-attr ::cf/oidc-roles-attr)
(s/def ::email-attr ::cf/oidc-email-attr)
(s/def ::name-attr ::cf/oidc-name-attr)
;; FIXME: migrate to qualified-keywords
(s/def ::provider
(s/keys :req-un [::client-id
::client-secret]
:opt-un [::base-uri
::token-uri
::auth-uri
::user-uri
::scopes
::roles
::roles-attr
::email-attr
::name-attr]))
(s/def ::providers (s/map-of ::us/keyword (s/nilable ::provider)))
(s/def ::routes vector?)
(s/def ::public-uri ::us/not-empty-string)
(s/def ::http-client ::http/client)
(s/def ::session map?)
(s/def ::sprops map?)
(s/def ::providers map?)
(defmethod ig/pre-init-spec ::routes
[_]
(s/keys :req [::session/manager
::http/client
::wrk/executor
::main/props
::db/pool
::providers]))
(s/keys :req-un [::public-uri
::session
::sprops
::http-client
::providers
::db/pool
::wrk/executor]))
(defmethod ig/init-key ::routes
[_ {:keys [::wrk/executor] :as cfg}]
[_ {:keys [executor session] :as cfg}]
(let [cfg (update cfg :provider d/without-nils)]
["" {:middleware [[session/authz cfg]
["" {:middleware [[(:middleware session)]
[hmw/with-dispatch executor]
[hmw/with-config cfg]
[provider-lookup]]}
[provider-lookup]
]}
;; We maintain the both URI prefixes for backward compatibility.
["/auth/oauth"
["/:provider"
{:handler auth-handler
:allowed-methods #{:post}}]
["/:provider/callback"
{:handler callback-handler
:allowed-methods #{:get}}]]
["/auth/oidc"
["/:provider"
{:handler auth-handler
:allowed-methods #{:post}}]

View File

@@ -10,8 +10,9 @@
[app.common.logging :as l]
[app.db :as db]
[app.main :as main]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.auth :as cmd.auth]
[app.rpc.mutations.profile :as profile]
[app.rpc.queries.profile :refer [retrieve-profile-data-by-email]]
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]
[integrant.core :as ig])
@@ -54,17 +55,16 @@
:type :password}))]
(try
(db/with-atomic [conn (:app.db/pool system)]
(->> (auth/create-profile! conn
{:fullname fullname
:email email
:password password
:is-active true
:is-demo false})
(auth/create-profile-rels! conn)))
(->> (cmd.auth/create-profile conn
{:fullname fullname
:email email
:password password
:is-active true
:is-demo false})
(cmd.auth/create-profile-relations conn)))
(when (pos? (:verbosity options))
(println "User created successfully."))
(System/exit 0)
(catch Exception _e
@@ -79,7 +79,7 @@
(db/with-atomic [conn (:app.db/pool system)]
(let [email (or (:email options)
(read-from-console {:label "Email:"}))
profile (profile/get-profile-by-email conn email)]
profile (retrieve-profile-data-by-email conn email)]
(when-not profile
(when (pos? (:verbosity options))
(println "Profile does not exists."))

View File

@@ -27,10 +27,6 @@
clojure.lang.IRecord
clojure.lang.IDeref)
(prefer-method print-method
clojure.lang.IPersistentMap
clojure.lang.IDeref)
(prefer-method pprint/simple-dispatch
clojure.lang.IPersistentMap
clojure.lang.IDeref)
@@ -50,19 +46,20 @@
:database-username "penpot"
:database-password "penpot"
:default-blob-version 5
:default-blob-version 4
:loggers-zmq-uri "tcp://localhost:45556"
:rpc-rlimit-config (fs/path "resources/rlimit.edn")
:rpc-climit-config (fs/path "resources/climit.edn")
:file-change-snapshot-every 5
:file-change-snapshot-timeout "3h"
:public-uri "http://localhost:3449"
:host "localhost"
:tenant "default"
:tenant "main"
:redis-uri "redis://redis/0"
:srepl-host "127.0.0.1"
:srepl-port 6062
:assets-storage-backend :assets-fs
:storage-assets-fs-directory "assets"
@@ -89,7 +86,6 @@
(s/def ::default-rpc-rlimit ::us/vector-of-strings)
(s/def ::rpc-rlimit-config ::fs/path)
(s/def ::rpc-climit-config ::fs/path)
(s/def ::media-max-file-size ::us/integer)
@@ -97,17 +93,13 @@
(s/def ::telemetry-enabled ::us/boolean)
(s/def ::audit-log-archive-uri ::us/string)
(s/def ::audit-log-http-handler-concurrency ::us/integer)
(s/def ::admins ::us/set-of-valid-emails)
(s/def ::admins ::us/set-of-strings)
(s/def ::file-change-snapshot-every ::us/integer)
(s/def ::file-change-snapshot-timeout ::dt/duration)
(s/def ::default-executor-parallelism ::us/integer)
(s/def ::scheduled-executor-parallelism ::us/integer)
(s/def ::worker-default-parallelism ::us/integer)
(s/def ::worker-webhook-parallelism ::us/integer)
(s/def ::worker-executor-parallelism ::us/integer)
(s/def ::authenticated-cookie-domain ::us/string)
(s/def ::authenticated-cookie-name ::us/string)
@@ -124,17 +116,6 @@
(s/def ::database-min-pool-size ::us/integer)
(s/def ::database-max-pool-size ::us/integer)
(s/def ::quotes-teams-per-profile ::us/integer)
(s/def ::quotes-access-tokens-per-profile ::us/integer)
(s/def ::quotes-projects-per-team ::us/integer)
(s/def ::quotes-invitations-per-team ::us/integer)
(s/def ::quotes-profiles-per-team ::us/integer)
(s/def ::quotes-files-per-project ::us/integer)
(s/def ::quotes-files-per-team ::us/integer)
(s/def ::quotes-font-variants-per-team ::us/integer)
(s/def ::quotes-comment-threads-per-file ::us/integer)
(s/def ::quotes-comments-per-file ::us/integer)
(s/def ::default-blob-version ::us/integer)
(s/def ::error-report-webhook ::us/string)
(s/def ::user-feedback-destination ::us/string)
@@ -153,9 +134,9 @@
(s/def ::oidc-user-uri ::us/string)
(s/def ::oidc-scopes ::us/set-of-strings)
(s/def ::oidc-roles ::us/set-of-strings)
(s/def ::oidc-roles-attr ::us/string)
(s/def ::oidc-email-attr ::us/string)
(s/def ::oidc-name-attr ::us/string)
(s/def ::oidc-roles-attr ::us/keyword)
(s/def ::oidc-email-attr ::us/keyword)
(s/def ::oidc-name-attr ::us/keyword)
(s/def ::host ::us/string)
(s/def ::http-server-port ::us/integer)
(s/def ::http-server-host ::us/string)
@@ -163,6 +144,7 @@
(s/def ::http-server-max-multipart-body-size ::us/integer)
(s/def ::http-server-io-threads ::us/integer)
(s/def ::http-server-worker-threads ::us/integer)
(s/def ::initial-project-skey ::us/string)
(s/def ::ldap-attrs-email ::us/string)
(s/def ::ldap-attrs-fullname ::us/string)
(s/def ::ldap-attrs-username ::us/string)
@@ -174,6 +156,8 @@
(s/def ::ldap-ssl ::us/boolean)
(s/def ::ldap-starttls ::us/boolean)
(s/def ::ldap-user-query ::us/string)
(s/def ::loggers-loki-uri ::us/string)
(s/def ::loggers-zmq-uri ::us/string)
(s/def ::media-directory ::us/string)
(s/def ::media-uri ::us/string)
(s/def ::profile-bounce-max-age ::dt/duration)
@@ -184,6 +168,11 @@
(s/def ::redis-uri ::us/string)
(s/def ::registration-domain-whitelist ::us/set-of-strings)
(s/def ::semaphore-process-font ::us/integer)
(s/def ::semaphore-process-image ::us/integer)
(s/def ::semaphore-update-file ::us/integer)
(s/def ::semaphore-auth ::us/integer)
(s/def ::smtp-default-from ::us/string)
(s/def ::smtp-default-reply-to ::us/string)
(s/def ::smtp-host ::us/string)
@@ -192,15 +181,18 @@
(s/def ::smtp-ssl ::us/boolean)
(s/def ::smtp-tls ::us/boolean)
(s/def ::smtp-username (s/nilable ::us/string))
(s/def ::urepl-host ::us/string)
(s/def ::urepl-port ::us/integer)
(s/def ::prepl-host ::us/string)
(s/def ::prepl-port ::us/integer)
(s/def ::srepl-host ::us/string)
(s/def ::srepl-port ::us/integer)
(s/def ::assets-storage-backend ::us/keyword)
(s/def ::fdata-storage-backend ::us/keyword)
(s/def ::storage-assets-fs-directory ::us/string)
(s/def ::storage-assets-s3-bucket ::us/string)
(s/def ::storage-assets-s3-region ::us/keyword)
(s/def ::storage-assets-s3-endpoint ::us/string)
(s/def ::storage-fdata-s3-bucket ::us/string)
(s/def ::storage-fdata-s3-region ::us/keyword)
(s/def ::storage-fdata-s3-prefix ::us/string)
(s/def ::storage-fdata-s3-endpoint ::us/string)
(s/def ::telemetry-uri ::us/string)
(s/def ::telemetry-with-taiga ::us/boolean)
(s/def ::tenant ::us/string)
@@ -211,7 +203,6 @@
::admins
::allow-demo-users
::audit-log-archive-uri
::audit-log-http-handler-concurrency
::auth-token-cookie-name
::auth-token-cookie-max-age
::authenticated-cookie-name
@@ -226,9 +217,7 @@
::default-rpc-rlimit
::error-report-webhook
::default-executor-parallelism
::scheduled-executor-parallelism
::worker-default-parallelism
::worker-webhook-parallelism
::worker-executor-parallelism
::file-change-snapshot-every
::file-change-snapshot-timeout
::user-feedback-destination
@@ -257,6 +246,7 @@
::http-server-max-multipart-body-size
::http-server-io-threads
::http-server-worker-threads
::initial-project-skey
::ldap-attrs-email
::ldap-attrs-fullname
::ldap-attrs-username
@@ -269,24 +259,14 @@
::ldap-starttls
::ldap-user-query
::local-assets-uri
::loggers-loki-uri
::loggers-zmq-uri
::media-max-file-size
::profile-bounce-max-age
::profile-bounce-threshold
::profile-complaint-max-age
::profile-complaint-threshold
::public-uri
::quotes-teams-per-profile
::quotes-access-tokens-per-profile
::quotes-projects-per-team
::quotes-invitations-per-team
::quotes-profiles-per-team
::quotes-files-per-project
::quotes-files-per-team
::quotes-font-variants-per-team
::quotes-comment-threads-per-file
::quotes-comments-per-file
::redis-uri
::registration-domain-whitelist
::rpc-rlimit-config
@@ -305,16 +285,19 @@
::smtp-tls
::smtp-username
::urepl-host
::urepl-port
::prepl-host
::prepl-port
::srepl-host
::srepl-port
::assets-storage-backend
::storage-assets-fs-directory
::storage-assets-s3-bucket
::storage-assets-s3-region
::storage-assets-s3-endpoint
::fdata-storage-backend
::storage-fdata-s3-bucket
::storage-fdata-s3-region
::storage-fdata-s3-prefix
::storage-fdata-s3-endpoint
::telemetry-enabled
::telemetry-uri
::telemetry-referer
@@ -352,11 +335,10 @@
(merge defaults)
(us/conform ::config))
(catch Throwable e
(when (ex/error? e)
(when (ex/ex-info? e)
(println ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;")
(println "Error on validating configuration:")
(println (some-> e ex-data ex/explain))
(println (ex/explain (ex-data e)))
(println (us/pretty-explain (ex-data e)))
(println ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"))
(throw e))))

View File

@@ -17,6 +17,7 @@
[app.db.sql :as sql]
[app.metrics :as mtx]
[app.util.json :as json]
[app.util.migrations :as mg]
[app.util.time :as dt]
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
@@ -31,6 +32,7 @@
io.whitfin.siphash.SipHasherContainer
java.io.InputStream
java.io.OutputStream
java.lang.AutoCloseable
java.sql.Connection
java.sql.Savepoint
org.postgresql.PGConnection
@@ -48,9 +50,12 @@
;; Initialization
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare apply-migrations!)
(s/def ::connection-timeout ::us/integer)
(s/def ::max-size ::us/integer)
(s/def ::min-size ::us/integer)
(s/def ::migrations map?)
(s/def ::name keyword?)
(s/def ::password ::us/string)
(s/def ::uri ::us/not-empty-string)
@@ -59,26 +64,26 @@
(s/def ::read-only? ::us/boolean)
(s/def ::pool-options
(s/keys :opt [::uri
::name
::min-size
::max-size
::connection-timeout
::validation-timeout
::username
::password
::mtx/metrics
::read-only?]))
(s/keys :opt-un [::uri ::name
::min-size
::max-size
::connection-timeout
::validation-timeout
::migrations
::username
::password
::mtx/metrics
::read-only?]))
(def defaults
{::name :main
::min-size 0
::max-size 60
::connection-timeout 10000
::validation-timeout 10000
::idle-timeout 120000 ; 2min
::max-lifetime 1800000 ; 30m
::read-only? false})
{:name :main
:min-size 0
:max-size 60
:connection-timeout 10000
:validation-timeout 10000
:idle-timeout 120000 ; 2min
:max-lifetime 1800000 ; 30m
:read-only? false})
(defmethod ig/prep-key ::pool
[_ cfg]
@@ -88,23 +93,39 @@
(defmethod ig/pre-init-spec ::pool [_] ::pool-options)
(defmethod ig/init-key ::pool
[_ {:keys [::uri ::read-only?] :as cfg}]
(when uri
(l/info :hint "initialize connection pool"
:name (d/name (::name cfg))
:uri uri
:read-only read-only?
:with-credentials (and (contains? cfg ::username)
(contains? cfg ::password))
:min-size (::min-size cfg)
:max-size (::max-size cfg))
(create-pool cfg)))
[_ {:keys [migrations read-only? uri] :as cfg}]
(if uri
(let [pool (create-pool cfg)]
(l/info :hint "initialize connection pool"
:name (d/name (:name cfg))
:uri uri
:read-only read-only?
:with-credentials (and (contains? cfg :username)
(contains? cfg :password))
:min-size (:min-size cfg)
:max-size (:max-size cfg))
(when-not read-only?
(some->> (seq migrations) (apply-migrations! pool)))
pool)
(do
(l/warn :hint "unable to initialize pool, missing url"
:name (d/name (:name cfg))
:read-only read-only?)
nil)))
(defmethod ig/halt-key! ::pool
[_ pool]
(when pool
(.close ^HikariDataSource pool)))
(defn- apply-migrations!
[pool migrations]
(with-open [conn ^AutoCloseable (open pool)]
(mg/setup! conn)
(doseq [[name steps] migrations]
(mg/migrate! conn {:name (d/name name) :steps steps}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API & Impl
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -114,19 +135,19 @@
"SET idle_in_transaction_session_timeout = 300000;"))
(defn- create-datasource-config
[{:keys [::mtx/metrics ::uri] :as cfg}]
[{:keys [metrics uri] :as cfg}]
(let [config (HikariConfig.)]
(doto config
(.setJdbcUrl (str "jdbc:" uri))
(.setPoolName (d/name (::name cfg)))
(.setPoolName (d/name (:name cfg)))
(.setAutoCommit true)
(.setReadOnly (::read-only? cfg))
(.setConnectionTimeout (::connection-timeout cfg))
(.setValidationTimeout (::validation-timeout cfg))
(.setIdleTimeout (::idle-timeout cfg))
(.setMaxLifetime (::max-lifetime cfg))
(.setMinimumIdle (::min-size cfg))
(.setMaximumPoolSize (::max-size cfg))
(.setReadOnly (:read-only? cfg))
(.setConnectionTimeout (:connection-timeout cfg))
(.setValidationTimeout (:validation-timeout cfg))
(.setIdleTimeout (:idle-timeout cfg))
(.setMaxLifetime (:max-lifetime cfg))
(.setMinimumIdle (:min-size cfg))
(.setMaximumPoolSize (:max-size cfg))
(.setConnectionInitSql initsql)
(.setInitializationFailTimeout -1))
@@ -136,8 +157,8 @@
(PrometheusMetricsTrackerFactory.)
(.setMetricsTrackerFactory config)))
(some->> ^String (::username cfg) (.setUsername config))
(some->> ^String (::password cfg) (.setPassword config))
(some->> ^String (:username cfg) (.setUsername config))
(some->> ^String (:password cfg) (.setPassword config))
config))
@@ -145,28 +166,15 @@
[v]
(instance? javax.sql.DataSource v))
(s/def ::conn some?)
(s/def ::nilable-pool (s/nilable ::pool))
(s/def ::pool pool?)
(s/def ::pool-or-conn some?)
(defn closed?
[pool]
(.isClosed ^HikariDataSource pool))
(defn read-only?
[pool-or-conn]
(cond
(instance? HikariDataSource pool-or-conn)
(.isReadOnly ^HikariDataSource pool-or-conn)
(instance? Connection pool-or-conn)
(.isReadOnly ^Connection pool-or-conn)
:else
(ex/raise :type :internal
:code :invalid-connection
:hint "invalid connection provided")))
[pool]
(.isReadOnly ^HikariDataSource pool))
(defn create-pool
[cfg]
@@ -224,46 +232,44 @@
[pool]
(jdbc/get-connection pool))
(def ^:private default-opts
{:builder-fn sql/as-kebab-maps})
(defn exec!
([ds sv]
(jdbc/execute! ds sv default-opts))
(exec! ds sv {}))
([ds sv opts]
(jdbc/execute! ds sv (merge default-opts opts))))
(jdbc/execute! ds sv (assoc opts :builder-fn sql/as-kebab-maps))))
(defn exec-one!
([ds sv]
(jdbc/execute-one! ds sv default-opts))
([ds sv] (exec-one! ds sv {}))
([ds sv opts]
(jdbc/execute-one! ds sv
(-> (merge default-opts opts)
(assoc :return-keys (::return-keys? opts false))))))
(jdbc/execute-one! ds sv (assoc opts :builder-fn sql/as-kebab-maps))))
(defn insert!
[ds table params & {:as opts}]
(exec-one! ds
(sql/insert table params opts)
(merge {::return-keys? true} opts)))
([ds table params] (insert! ds table params nil))
([ds table params opts]
(exec-one! ds
(sql/insert table params opts)
(merge {:return-keys true} opts))))
(defn insert-multi!
[ds table cols rows & {:as opts}]
(exec! ds
(sql/insert-multi table cols rows opts)
(merge {::return-keys? true} opts)))
([ds table cols rows] (insert-multi! ds table cols rows nil))
([ds table cols rows opts]
(exec! ds
(sql/insert-multi table cols rows opts)
(merge {:return-keys true} opts))))
(defn update!
[ds table params where & {:as opts}]
(exec-one! ds
(sql/update table params where opts)
(merge {::return-keys? true} opts)))
([ds table params where] (update! ds table params where nil))
([ds table params where opts]
(exec-one! ds
(sql/update table params where opts)
(merge {:return-keys true} opts))))
(defn delete!
[ds table params & {:as opts}]
(exec-one! ds
(sql/delete table params opts)
(merge {::return-keys? true} opts)))
([ds table params] (delete! ds table params nil))
([ds table params opts]
(exec-one! ds
(sql/delete table params opts)
(assoc opts :return-keys true))))
(defn is-row-deleted?
[{:keys [deleted-at]}]
@@ -272,34 +278,54 @@
(inst-ms (dt/now)))))
(defn get*
"Retrieve a single row from database that matches a simple filters. Do
not raises exceptions."
[ds table params & {:as opts}]
(let [rows (exec! ds (sql/select table params opts))
rows (cond->> rows
(::remove-deleted? opts true)
(remove is-row-deleted?))]
(first rows)))
"Internal function for retrieve a single row from database that
matches a simple filters."
([ds table params]
(get* ds table params nil))
([ds table params {:keys [check-deleted?] :or {check-deleted? true} :as opts}]
(let [rows (exec! ds (sql/select table params opts))
rows (cond->> rows
check-deleted?
(remove is-row-deleted?))]
(first rows))))
(defn get
"Retrieve a single row from database that matches a simple
filters. Raises :not-found exception if no object is found."
[ds table params & {:as opts}]
(let [row (get* ds table params opts)]
(when (and (not row) (::check-deleted? opts true))
(ex/raise :type :not-found
:code :object-not-found
:table table
:hint "database object not found"))
row))
([ds table params]
(get ds table params nil))
([ds table params {:keys [check-deleted?] :or {check-deleted? true} :as opts}]
(let [row (get* ds table params opts)]
(when (and (not row) check-deleted?)
(ex/raise :type :not-found
:table table
:hint "database object not found"))
row)))
(defn get-by-params
"DEPRECATED"
([ds table params]
(get-by-params ds table params nil))
([ds table params {:keys [check-not-found] :or {check-not-found true} :as opts}]
(let [row (get* ds table params (assoc opts :check-deleted? check-not-found))]
(when (and (not row) check-not-found)
(ex/raise :type :not-found
:table table
:hint "database object not found"))
row)))
(defn get-by-id
[ds table id & {:as opts}]
(get ds table {:id id} opts))
([ds table id]
(get ds table {:id id} nil))
([ds table id opts]
(let [opts (cond-> opts
(contains? opts :check-not-found)
(assoc :check-deleted? (:check-not-found opts)))]
(get ds table {:id id} opts))))
(defn query
[ds table params & {:as opts}]
(exec! ds (sql/select table params opts)))
([ds table params]
(query ds table params nil))
([ds table params opts]
(exec! ds (sql/select table params opts))))
(defn pgobject?
([v]
@@ -326,13 +352,10 @@
[v]
(and (pgarray? v) (= "uuid" (.getBaseTypeName ^PgArray v))))
;; TODO rename to decode-pgarray-into
(defn decode-pgarray
([v] (decode-pgarray v []))
([v in]
(into in (some-> ^PgArray v .getArray)))
([v in xf]
(into in xf (some-> ^PgArray v .getArray))))
([v] (some->> ^PgArray v .getArray vec))
([v in] (some->> ^PgArray v .getArray (into in)))
([v in xf] (some->> ^PgArray v .getArray (into in xf))))
(defn pgarray->set
[v]
@@ -394,58 +417,47 @@
(defn decode-json-pgobject
[^PGobject o]
(when o
(let [typ (.getType o)
val (.getValue o)]
(if (or (= typ "json")
(= typ "jsonb"))
(json/decode val)
val))))
(let [typ (.getType o)
val (.getValue o)]
(if (or (= typ "json")
(= typ "jsonb"))
(json/read val)
val)))
(defn decode-transit-pgobject
[^PGobject o]
(when o
(let [typ (.getType o)
val (.getValue o)]
(if (or (= typ "json")
(= typ "jsonb"))
(t/decode-str val)
val))))
(let [typ (.getType o)
val (.getValue o)]
(if (or (= typ "json")
(= typ "jsonb"))
(t/decode-str val)
val)))
(defn inet
[ip-addr]
(when ip-addr
(doto (org.postgresql.util.PGobject.)
(.setType "inet")
(.setValue (str ip-addr)))))
(doto (org.postgresql.util.PGobject.)
(.setType "inet")
(.setValue (str ip-addr))))
(defn decode-inet
[^PGobject o]
(when o
(if (= "inet" (.getType o))
(.getValue o)
nil)))
(if (= "inet" (.getType o))
(.getValue o)
nil))
(defn tjson
"Encode as transit json."
[data]
(when data
(doto (org.postgresql.util.PGobject.)
(.setType "jsonb")
(.setValue (t/encode-str data {:type :json-verbose})))))
(doto (org.postgresql.util.PGobject.)
(.setType "jsonb")
(.setValue (t/encode-str data {:type :json-verbose}))))
(defn json
"Encode as plain json."
[data]
(when data
(doto (org.postgresql.util.PGobject.)
(.setType "jsonb")
(.setValue (json/encode-str data)))))
(defn get-update-count
[result]
(:next.jdbc/update-count result))
(doto (org.postgresql.util.PGobject.)
(.setType "jsonb")
(.setValue (json/write-str data))))
;; --- Locks
@@ -476,18 +488,3 @@
(let [n (xact-check-param n)
row (exec-one! conn ["select pg_try_advisory_xact_lock(?::bigint) as lock" n])]
(:lock row)))
(defn sql-exception?
[cause]
(instance? java.sql.SQLException cause))
(defn connection-error?
[cause]
(and (sql-exception? cause)
(contains? #{"08003" "08006" "08001" "08004"}
(.getSQLState ^java.sql.SQLException cause))))
(defn serialization-error?
[cause]
(and (sql-exception? cause)
(= "40001" (.getSQLState ^java.sql.SQLException cause))))

View File

@@ -7,7 +7,6 @@
(ns app.db.sql
(:refer-clojure :exclude [update])
(:require
[app.db :as-alias db]
[clojure.string :as str]
[next.jdbc.optional :as jdbc-opt]
[next.jdbc.sql.builder :as sql]))
@@ -44,10 +43,8 @@
([table where-params opts]
(let [opts (merge default-opts opts)
opts (cond-> opts
(::db/for-update? opts) (assoc :suffix "FOR UPDATE")
(::db/for-share? opts) (assoc :suffix "FOR KEY SHARE")
(:for-update opts) (assoc :suffix "FOR UPDATE")
(:for-key-share opts) (assoc :suffix "FOR KEY SHARE"))]
(:for-update opts) (assoc :suffix "FOR UPDATE")
(:for-key-share opts) (assoc :suffix "FOR KEY SHARE"))]
(sql/for-query table where-params opts))))
(defn update

View File

@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC
(ns app.email
(ns app.emails
"Main api for send emails."
(:require
[app.common.exceptions :as ex]
@@ -14,7 +14,7 @@
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.email.invite-to-team :as-alias email.invite-to-team]
[app.emails.invite-to-team :as-alias emails.invite-to-team]
[app.metrics :as mtx]
[app.util.template :as tmpl]
[app.worker :as wrk]
@@ -64,14 +64,14 @@
(some? bcc) (assign-recipient :bcc bcc)))
(defn- assign-from
[mmsg {:keys [::default-from] :as cfg} {:keys [from] :as params}]
[mmsg {:keys [default-from]} {:keys [from] :as props}]
(let [from (or from default-from)]
(when from
(let [from (parse-address from)]
(.addFrom ^MimeMessage mmsg from)))))
(defn- assign-reply-to
[mmsg {:keys [::default-reply-to] :as cfg} {:keys [reply-to] :as params}]
[mmsg {:keys [default-reply-to] :as cfg} {:keys [reply-to] :as params}]
(let [reply-to (or reply-to default-reply-to)]
(when reply-to
(let [reply-to (parse-address reply-to)]
@@ -127,8 +127,9 @@
mmsg))
(defn- opts->props
[{:keys [::username ::tls ::host ::port ::timeout ::default-from]
:or {timeout 30000}}]
[{:keys [username tls host port timeout default-from]
:or {timeout 30000}
:as opts}]
(reduce-kv
(fn [^Properties props k v]
(if (nil? v)
@@ -149,8 +150,8 @@
"mail.smtp.connectiontimeout" timeout}))
(defn- create-smtp-session
[cfg]
(let [props (opts->props cfg)]
[opts]
(let [props (opts->props opts)]
(Session/getInstance props)))
(defn- create-smtp-message
@@ -170,7 +171,7 @@
;; TEMPLATE EMAIL IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private email-path "app/email/%(id)s/%(lang)s.%(type)s")
(def ^:private email-path "app/emails/%(id)s/%(lang)s.%(type)s")
(defn- render-email-template-part
[type id context]
@@ -256,17 +257,15 @@
"Schedule an already defined email to be sent using asynchronously
using worker task."
[{:keys [::conn ::factory] :as context}]
(us/verify fn? factory)
(us/verify some? conn)
(let [email (if factory
(factory context)
(dissoc context ::conn))]
(wrk/submit! (merge
{::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
::wrk/priority 200
::wrk/conn conn}
email))))
(let [email (factory context)]
(wrk/submit! (assoc email
::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
::wrk/priority 200
::wrk/conn conn))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SENDMAIL FN / TASK HANDLER
@@ -282,14 +281,14 @@
(s/def ::default-from ::cf/smtp-default-from)
(s/def ::smtp-config
(s/keys :opt [::username
::password
::tls
::ssl
::host
::port
::default-from
::default-reply-to]))
(s/keys :opt-un [::username
::password
::tls
::ssl
::host
::port
::default-from
::default-reply-to]))
(declare send-to-logger!)
@@ -305,8 +304,8 @@
(let [session (create-smtp-session cfg)]
(with-open [transport (.getTransport session (if (:ssl cfg) "smtps" "smtp"))]
(.connect ^Transport transport
^String (::username cfg)
^String (::password cfg))
^String (:username cfg)
^String (:password cfg))
(let [^MimeMessage message (create-smtp-message cfg session params)]
(.sendMessage ^Transport transport
@@ -318,10 +317,10 @@
(send-to-logger! cfg params))))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req [::sendmail ::mtx/metrics]))
(s/keys :req-un [::sendmail ::mtx/metrics]))
(defmethod ig/init-key ::handler
[_ {:keys [::sendmail]}]
[_ {:keys [sendmail]}]
(fn [{:keys [props] :as task}]
(sendmail props)))
@@ -379,14 +378,14 @@
"Password change confirmation email"
(template-factory ::change-email))
(s/def ::email.invite-to-team/invited-by ::us/string)
(s/def ::email.invite-to-team/team ::us/string)
(s/def ::email.invite-to-team/token ::us/string)
(s/def ::emails.invite-to-team/invited-by ::us/string)
(s/def ::emails.invite-to-team/team ::us/string)
(s/def ::emails.invite-to-team/token ::us/string)
(s/def ::invite-to-team
(s/keys :req-un [::email.invite-to-team/invited-by
::email.invite-to-team/token
::email.invite-to-team/team]))
(s/keys :req-un [::emails.invite-to-team/invited-by
::emails.invite-to-team/token
::emails.invite-to-team/team]))
(def invite-to-team
"Teams member invitation email."

View File

@@ -6,22 +6,12 @@
(ns app.http
(:require
[app.auth.oidc :as-alias oidc]
[app.common.data :as d]
[app.common.logging :as l]
[app.common.transit :as t]
[app.db :as-alias db]
[app.http.access-token :as actoken]
[app.http.assets :as-alias assets]
[app.http.awsns :as-alias awsns]
[app.http.debug :as-alias debug]
[app.http.errors :as errors]
[app.http.middleware :as mw]
[app.http.session :as session]
[app.http.websocket :as-alias ws]
[app.metrics :as mtx]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias rpc.doc]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
@@ -46,53 +36,47 @@
(s/def ::max-body-size integer?)
(s/def ::max-multipart-body-size integer?)
(s/def ::io-threads integer?)
(s/def ::worker-threads integer?)
(defmethod ig/prep-key ::server
[_ cfg]
(merge {::port 6060
::host "0.0.0.0"
::max-body-size (* 1024 1024 30) ; 30 MiB
::max-multipart-body-size (* 1024 1024 120)} ; 120 MiB
(merge {:name "http"
:port 6060
:host "0.0.0.0"
:max-body-size (* 1024 1024 30) ; 30 MiB
:max-multipart-body-size (* 1024 1024 120)} ; 120 MiB
(d/without-nils cfg)))
(defmethod ig/pre-init-spec ::server [_]
(s/keys :req [::port ::host]
:opt [::max-body-size
::max-multipart-body-size
::router
::handler
::io-threads
::wrk/executor]))
(s/and
(s/keys :req-un [::port ::host ::name ::max-body-size ::max-multipart-body-size]
:opt-un [::router ::handler ::io-threads ::worker-threads ::wrk/executor])
(fn [cfg]
(or (contains? cfg :router)
(contains? cfg :handler)))))
(defmethod ig/init-key ::server
[_ {:keys [::handler ::router ::host ::port] :as cfg}]
(l/info :hint "starting http server" :port port :host host)
[_ {:keys [handler router port name host] :as cfg}]
(l/info :hint "starting http server" :port port :host host :name name)
(let [options {:http/port port
:http/host host
:http/max-body-size (::max-body-size cfg)
:http/max-multipart-body-size (::max-multipart-body-size cfg)
:xnio/io-threads (::io-threads cfg)
:xnio/dispatch (::wrk/executor cfg)
:http/max-body-size (:max-body-size cfg)
:http/max-multipart-body-size (:max-multipart-body-size cfg)
:xnio/io-threads (:io-threads cfg)
:xnio/worker-threads (:worker-threads cfg)
:xnio/dispatch (:executor cfg)
:ring/async true}
handler (cond
(some? router)
handler (if (some? router)
(wrap-router router)
(some? handler)
handler
:else
(throw (UnsupportedOperationException. "handler or router are required")))
options (d/without-nils options)
server (yt/server handler options)]
(assoc cfg ::server (yt/start! server))))
handler)
server (yt/server handler (d/without-nils options))]
(assoc cfg :server (yt/start! server))))
(defmethod ig/halt-key! ::server
[_ {:keys [::server ::port] :as cfg}]
(l/info :msg "stopping http server" :port port)
[_ {:keys [server name port] :as cfg}]
(l/info :msg "stopping http server" :name name :port port)
(yt/stop! server))
(defn- not-found-handler
@@ -106,7 +90,9 @@
(let [params (:path-params match)
result (:result match)
handler (or (:handler result) not-found-handler)
request (assoc request :path-params params)]
request (-> request
(assoc :path-params params)
(update :params merge params))]
(handler request respond raise))
(not-found-handler request respond raise)))
@@ -128,41 +114,67 @@
;; HTTP ROUTER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::assets map?)
(s/def ::audit-handler fn?)
(s/def ::awsns-handler fn?)
(s/def ::debug-routes (s/nilable vector?))
(s/def ::doc-routes (s/nilable vector?))
(s/def ::feedback fn?)
(s/def ::oauth map?)
(s/def ::oidc-routes (s/nilable vector?))
(s/def ::rpc-routes (s/nilable vector?))
(s/def ::session map?)
(s/def ::storage map?)
(s/def ::ws fn?)
(defmethod ig/pre-init-spec ::router [_]
(s/keys :req [::session/manager
::actoken/manager
::ws/routes
::rpc/routes
::rpc.doc/routes
::oidc/routes
::assets/routes
::debug/routes
::db/pool
::mtx/routes
::awsns/routes]))
(s/keys :req-un [::mtx/metrics
::ws
::storage
::assets
::session
::feedback
::awsns-handler
::debug-routes
::oidc-routes
::audit-handler
::rpc-routes
::doc-routes]))
(defmethod ig/init-key ::router
[_ cfg]
[_ {:keys [ws session metrics assets feedback] :as cfg}]
(rr/router
[["" {:middleware [[mw/server-timing]
[mw/format-response]
[mw/params]
[mw/parse-request]
[session/soft-auth cfg]
[actoken/soft-auth cfg]
[mw/errors errors/handle]
[mw/restrict-methods]]}
(::mtx/routes cfg)
(::assets/routes cfg)
(::debug/routes cfg)
["/metrics" {:handler (::mtx/handler metrics)
:allowed-methods #{:get}}]
["/assets" {:middleware [(:middleware session)]}
["/by-id/:id" {:handler (:objects-handler assets)}]
["/by-file-media-id/:id" {:handler (:file-objects-handler assets)}]
["/by-file-media-id/:id/thumbnail" {:handler (:file-thumbnails-handler assets)}]]
(:debug-routes cfg)
["/webhooks"
(::awsns/routes cfg)]
["/sns" {:handler (:awsns-handler cfg)
:allowed-methods #{:post}}]]
(::ws/routes cfg)
["/ws/notifications" {:middleware [(:middleware session)]
:handler ws
:allowed-methods #{:get}}]
["/api" {:middleware [[mw/cors]]}
(::oidc/routes cfg)
(::rpc.doc/routes cfg)
(::rpc/routes cfg)]]]))
["/api" {:middleware [[mw/cors]
[(:middleware session)]]}
["/audit/events" {:handler (:audit-handler cfg)
:allowed-methods #{:post}}]
["/feedback" {:handler feedback
:allowed-methods #{:post}}]
(:doc-routes cfg)
(:oidc-routes cfg)
(:rpc-routes cfg)]]]))

View File

@@ -1,96 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.http.access-token
(:require
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.db :as db]
[app.main :as-alias main]
[app.tokens :as tokens]
[app.worker :as-alias wrk]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.request :as yrq]))
(s/def ::manager
(s/keys :req [::db/pool ::wrk/executor ::main/props]))
(defmethod ig/pre-init-spec ::manager [_] ::manager)
(defmethod ig/init-key ::manager [_ cfg] cfg)
(defmethod ig/halt-key! ::manager [_ _])
(def header-re #"^Token\s+(.*)")
(defn- get-token
[request]
(some->> (yrq/get-header request "authorization")
(re-matches header-re)
(second)))
(defn- decode-token
[props token]
(when token
(tokens/verify props {:token token :iss "access-token"})))
(defn- get-token-perms
[pool token-id]
(when-not (db/read-only? pool)
(when-let [token (db/get* pool :access-token {:id token-id} {:columns [:perms]})]
(some-> (:perms token)
(db/decode-pgarray #{})))))
(defn- wrap-soft-auth
[handler {:keys [::manager]}]
(us/assert! ::manager manager)
(let [{:keys [::wrk/executor ::main/props]} manager]
(fn [request respond raise]
(let [token (get-token request)]
(->> (px/submit! executor (partial decode-token props token))
(p/fnly (fn [claims cause]
(when cause
(l/trace :hint "exception on decoding malformed token" :cause cause))
(let [request (cond-> request
(map? claims)
(assoc ::id (:tid claims)))]
(handler request respond raise)))))))))
(defn- wrap-authz
[handler {:keys [::manager]}]
(us/assert! ::manager manager)
(let [{:keys [::wrk/executor ::db/pool]} manager]
(fn [request respond raise]
(if-let [token-id (::id request)]
(->> (px/submit! executor (partial get-token-perms pool token-id))
(p/fnly (fn [perms cause]
(cond
(some? cause)
(raise cause)
(nil? perms)
(handler request respond raise)
:else
(let [request (assoc request ::perms perms)]
(handler request respond raise))))))
(handler request respond raise)))))
(def soft-auth
{:name ::soft-auth
:compile (fn [& _]
(when (contains? cf/flags :access-tokens)
wrap-soft-auth))})
(def authz
{:name ::authz
:compile (fn [& _]
(when (contains? cf/flags :access-tokens)
wrap-authz))})

View File

@@ -7,17 +7,18 @@
(ns app.http.assets
"Assets related handlers."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.common.uri :as u]
[app.db :as db]
[app.metrics :as mtx]
[app.storage :as sto]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.response :as yrs]))
(def ^:private cache-max-age
@@ -26,100 +27,105 @@
(def ^:private signature-max-age
(dt/duration {:hours 24 :minutes 15}))
(defn get-id
[{:keys [path-params]}]
(if-let [id (some-> path-params :id d/parse-uuid)]
(p/resolved id)
(p/rejected (ex/error :type :not-found
:hunt "object not found"))))
(defn coerce-id
[id]
(let [res (parse-uuid id)]
(when-not (uuid? res)
(ex/raise :type :not-found
:hint "object not found"))
res))
(defn- get-file-media-object
[pool id]
(db/get pool :file-media-object {:id id}))
(defn- serve-object-from-s3
[{:keys [::sto/storage] :as cfg} obj]
(let [mdata (meta obj)]
(->> (sto/get-object-url storage obj {:max-age signature-max-age})
(p/fmap (fn [{:keys [host port] :as url}]
(let [headers {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
(yrs/response
:status 307
:headers headers)))))))
(defn- serve-object-from-fs
[{:keys [::path]} obj]
(let [purl (u/join (u/uri path)
(sto/object->relative-path obj))
mdata (meta obj)
headers {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
(p/resolved
(yrs/response :status 204 :headers headers))))
[{:keys [pool executor] :as storage} id]
(px/with-dispatch executor
(let [id (coerce-id id)
mobj (db/exec-one! pool ["select * from file_media_object where id=?" id])]
(when-not mobj
(ex/raise :type :not-found
:hint "object does not found"))
mobj)))
(defn- serve-object
"Helper function that returns the appropriate response depending on
the storage object backend type."
[{:keys [::sto/storage] :as cfg} {:keys [backend] :as obj}]
(let [backend (sto/resolve-backend storage backend)]
(case (::sto/type backend)
:s3 (serve-object-from-s3 cfg obj)
:fs (serve-object-from-fs cfg obj))))
[{:keys [storage] :as cfg} obj]
(let [mdata (meta obj)
backend (sto/resolve-backend storage (:backend obj))]
(case (:type backend)
:s3
(p/let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
(yrs/response :status 307
:headers {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cache-max-age))}))
:fs
(p/let [purl (u/uri (:assets-path cfg))
purl (u/join purl (sto/object->relative-path obj))]
(yrs/response :status 204
:headers {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cache-max-age))})))))
(defn objects-handler
"Handler that servers storage objects by id."
[{:keys [::sto/storage ::wrk/executor] :as cfg} request respond raise]
(->> (get-id request)
(p/mcat executor (fn [id] (sto/get-object storage id)))
(p/mcat executor (fn [obj]
(if (some? obj)
(serve-object cfg obj)
(p/resolved (yrs/response 404)))))
(p/fnly executor (fn [result cause]
(if cause (raise cause) (respond result))))))
[{:keys [storage executor] :as cfg} request respond raise]
(-> (px/with-dispatch executor
(p/let [id (get-in request [:path-params :id])
id (coerce-id id)
obj (sto/get-object storage id)]
(if obj
(serve-object cfg obj)
(yrs/response 404))))
(p/bind p/wrap)
(p/then' respond)
(p/catch raise)))
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
[{:keys [::sto/storage ::wrk/executor] :as cfg} request kf]
(let [pool (::db/pool storage)]
(->> (get-id request)
(p/fmap executor (fn [id] (get-file-media-object pool id)))
(p/mcat executor (fn [mobj] (sto/get-object storage (kf mobj))))
(p/mcat executor (fn [sobj]
(if sobj
(serve-object cfg sobj)
(p/resolved (yrs/response 404))))))))
[{:keys [storage] :as cfg} request kf]
(p/let [id (get-in request [:path-params :id])
mobj (get-file-media-object storage id)
obj (sto/get-object storage (kf mobj))]
(if obj
(serve-object cfg obj)
(yrs/response 404))))
(defn file-objects-handler
"Handler that serves storage objects by file media id."
[cfg request respond raise]
(->> (generic-handler cfg request :media-id)
(p/fnly (fn [result cause]
(if cause (raise cause) (respond result))))))
(-> (generic-handler cfg request :media-id)
(p/then respond)
(p/catch raise)))
(defn file-thumbnails-handler
"Handler that serves storage objects by thumbnail-id and quick
fallback to file-media-id if no thumbnail is available."
[cfg request respond raise]
(->> (generic-handler cfg request #(or (:thumbnail-id %) (:media-id %)))
(p/fnly (fn [result cause]
(if cause (raise cause) (respond result))))))
(-> (generic-handler cfg request #(or (:thumbnail-id %) (:media-id %)))
(p/then respond)
(p/catch raise)))
;; --- Initialization
(s/def ::path ::us/string)
(s/def ::routes vector?)
(s/def ::storage some?)
(s/def ::assets-path ::us/string)
(s/def ::cache-max-age ::dt/duration)
(s/def ::signature-max-age ::dt/duration)
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::sto/storage ::wrk/executor ::path]))
(defmethod ig/pre-init-spec ::handlers [_]
(s/keys :req-un [::storage
::wrk/executor
::mtx/metrics
::assets-path
::cache-max-age
::signature-max-age]))
(defmethod ig/init-key ::routes
(defmethod ig/init-key ::handlers
[_ cfg]
["/assets"
["/by-id/:id" {:handler (partial objects-handler cfg)}]
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
{:objects-handler (partial objects-handler cfg)
:file-objects-handler (partial file-objects-handler cfg)
:file-thumbnails-handler (partial file-thumbnails-handler cfg)})

View File

@@ -12,9 +12,7 @@
[app.db :as db]
[app.db.sql :as sql]
[app.http.client :as http]
[app.main :as-alias main]
[app.tokens :as tokens]
[app.worker :as-alias wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
@@ -28,23 +26,21 @@
(declare parse-notification)
(declare process-report)
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::http/client
::main/props
::db/pool
::wrk/executor]))
(s/def ::http-client ::http/client)
(s/def ::sprops map?)
(defmethod ig/init-key ::routes
[_ {:keys [::wrk/executor] :as cfg}]
(letfn [(handler [request respond _]
(let [data (-> request yrq/body slurp)]
(px/run! executor #(handle-request cfg data)))
(respond (yrs/response 200)))]
["/sns" {:handler handler
:allowed-methods #{:post}}]))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req-un [::db/pool ::http-client ::sprops]))
(defmethod ig/init-key ::handler
[_ {:keys [executor] :as cfg}]
(fn [request respond _]
(let [data (-> request yrq/body slurp)]
(px/run! executor #(handle-request cfg data)))
(respond (yrs/response 200))))
(defn handle-request
[cfg data]
[{:keys [http-client] :as cfg} data]
(try
(let [body (parse-json data)
mtype (get body "Type")]
@@ -53,7 +49,7 @@
(let [surl (get body "SubscribeURL")
stopic (get body "TopicArn")]
(l/info :action "subscription received" :topic stopic :url surl)
(http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
(http/req! http-client {:uri surl :method :post :timeout 10000} {:sync? true}))
(= mtype "Notification")
(when-let [message (parse-json (get body "Message"))]
@@ -104,10 +100,10 @@
(get mail "headers")))
(defn- extract-identity
[cfg headers]
[{:keys [sprops]} headers]
(let [tdata (get headers "x-penpot-data")]
(when-not (str/empty? tdata)
(let [result (tokens/verify (::main/props cfg) {:token tdata :iss :profile-identity})]
(let [result (tokens/verify sprops {:token tdata :iss :profile-identity})]
(:profile-id result)))))
(defn- parse-notification
@@ -140,7 +136,7 @@
(j/read-value v)))
(defn- register-bounce-for-profile
[{:keys [::db/pool]} {:keys [type kind profile-id] :as report}]
[{:keys [pool]} {:keys [type kind profile-id] :as report}]
(when (= kind "permanent")
(db/with-atomic [conn pool]
(db/insert! conn :profile-complaint-report
@@ -169,7 +165,7 @@
{:id profile-id}))))))
(defn- register-complaint-for-profile
[{:keys [::db/pool]} {:keys [type profile-id] :as report}]
[{:keys [pool]} {:keys [type profile-id] :as report}]
(db/with-atomic [conn pool]
(db/insert! conn :profile-complaint-report
{:profile-id profile-id

View File

@@ -7,45 +7,34 @@
(ns app.http.client
"Http client abstraction layer."
(:require
[app.common.spec :as us]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[java-http-clj.core :as http]
[promesa.core :as p])
(:import
java.net.http.HttpClient))
[java-http-clj.core :as http]))
(s/def ::client #(instance? HttpClient %))
(s/def ::client-holder
(s/keys :req [::client]))
(s/def ::client fn?)
(defmethod ig/pre-init-spec ::client [_]
(s/keys :req [::wrk/executor]))
(defmethod ig/pre-init-spec :app.http/client [_]
(s/keys :req-un [::wrk/executor]))
(defmethod ig/init-key ::client
[_ {:keys [::wrk/executor] :as cfg}]
(http/build-client {:executor executor
:connect-timeout 30000 ;; 10s
:follow-redirects :always}))
(defn send!
([client req] (send! client req {}))
([client req {:keys [response-type sync?] :or {response-type :string sync? false}}]
(us/assert! ::client 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))))))
(defmethod ig/init-key :app.http/client
[_ {:keys [executor] :as cfg}]
(let [client (http/build-client {:executor executor
:connect-timeout 30000 ;; 10s
:follow-redirects :always})]
(with-meta
(fn send
([req] (send req {}))
([req {:keys [response-type sync?] :or {response-type :string sync? false}}]
(if sync?
(http/send req {:client client :as response-type})
(http/send-async req {:client client :as response-type}))))
{::client client})))
(defn req!
"A convencience toplevel function for gradual migration to a new API
convention."
([{:keys [::client]} request]
(us/assert! ::client client)
(send! client request {}))
([{:keys [::client]} request options]
(us/assert! ::client client)
(send! client request options)))
([client request]
(client request))
([client request options]
(client request options)))

View File

@@ -14,11 +14,9 @@
[app.config :as cf]
[app.db :as db]
[app.http.middleware :as mw]
[app.http.session :as session]
[app.rpc.commands.binfile :as binf]
[app.rpc.commands.files-create :refer [create-file]]
[app.rpc.commands.profile :as profile]
[app.storage :as-alias sto]
[app.rpc.mutations.files :refer [create-file]]
[app.rpc.queries.profile :as profile]
[app.util.blob :as blob]
[app.util.template :as tmpl]
[app.util.time :as dt]
@@ -40,9 +38,9 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn authorized?
[pool {:keys [::session/profile-id]}]
[pool {:keys [profile-id]}]
(or (= "devenv" (cf/get :host))
(let [profile (ex/ignoring (profile/get-profile pool profile-id))
(let [profile (ex/ignoring (profile/retrieve-profile-data pool profile-id))
admins (or (cf/get :admins) #{})]
(contains? admins (:email profile)))))
@@ -62,7 +60,7 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn index-handler
[{:keys [::db/pool]} request]
[{:keys [pool]} request]
(when-not (authorized? pool request)
(ex/raise :type :authentication
:code :only-admins-allowed))
@@ -82,7 +80,7 @@
"select revn, changes, data from file_change where file_id=? and revn = ?")
(defn- retrieve-file-data
[{:keys [::db/pool]} {:keys [params ::session/profile-id] :as request}]
[{:keys [pool]} {:keys [params profile-id] :as request}]
(when-not (authorized? pool request)
(ex/raise :type :authentication
:code :only-admins-allowed))
@@ -108,9 +106,8 @@
(prepare-download-response data filename)
(contains? params :clone)
(let [profile (profile/get-profile pool profile-id)
project-id (:default-project-id profile)
data (blob/decode data)]
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
data (some-> data blob/decode)]
(create-file pool {:id (uuid/next)
:name (str "Cloned file: " filename)
:project-id project-id
@@ -119,7 +116,7 @@
(yrs/response 201 "OK CREATED"))
:else
(prepare-response (blob/decode data))))))
(prepare-response (some-> data blob/decode))))))
(defn- is-file-exists?
[pool id]
@@ -127,9 +124,8 @@
(-> (db/exec-one! pool [sql id]) :exists)))
(defn- upload-file-data
[{:keys [::db/pool]} {:keys [::session/profile-id params] :as request}]
(let [profile (profile/get-profile pool profile-id)
project-id (:default-project-id profile)
[{:keys [pool]} {:keys [profile-id params] :as request}]
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
data (some-> params :file :path io/read-as-bytes blob/decode)]
(if (and data project-id)
@@ -165,7 +161,7 @@
:code :method-not-found)))
(defn file-changes-handler
[{:keys [::db/pool]} {:keys [params] :as request}]
[{:keys [pool]} {:keys [params] :as request}]
(when-not (authorized? pool request)
(ex/raise :type :authentication
:code :only-admins-allowed))
@@ -205,63 +201,57 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn error-handler
[{:keys [::db/pool]} request]
(letfn [(get-report [{:keys [path-params]}]
(ex/ignoring
(let [report-id (some-> path-params :id parse-uuid)]
(some-> (db/get-by-id pool :server-error-report report-id)
(update :content db/decode-transit-pgobject)))))
[{:keys [pool]} request]
(letfn [(parse-id [request]
(let [id (get-in request [:path-params :id])
id (parse-uuid id)]
(when (uuid? id)
id)))
(render-template-v1 [{:keys [content]}]
(let [context (dissoc content
(retrieve-report [id]
(ex/ignoring
(some-> (db/get-by-id pool :server-error-report id) :content db/decode-transit-pgobject)))
(render-template [report]
(let [context (dissoc report
:trace :cause :params :data :spec-problems :message
:spec-explain :spec-value :error :explain :hint)
params {:context (pp/pprint-str context :width 200)
:hint (:hint content)
:spec-explain (:spec-explain content)
:spec-problems (:spec-problems content)
:spec-value (:spec-value content)
:data (:data content)
:trace (or (:trace content)
(some-> content :error :trace))
:params (:params content)}]
:hint (:hint report)
:spec-explain (:spec-explain report)
:spec-problems (:spec-problems report)
:spec-value (:spec-value report)
:data (:data report)
:trace (or (:trace report)
(some-> report :error :trace))
:params (:params report)}]
(-> (io/resource "app/templates/error-report.tmpl")
(tmpl/render params))))
(render-template-v2 [{report :content}]
(-> (io/resource "app/templates/error-report.v2.tmpl")
(tmpl/render report)))
]
(tmpl/render params))))]
(when-not (authorized? pool request)
(ex/raise :type :authentication
:code :only-admins-allowed))
(if-let [report (get-report request)]
(let [result (if (= 1 (:version report))
(render-template-v1 report)
(render-template-v2 report))]
(let [result (some-> (parse-id request)
(retrieve-report)
(render-template))]
(if result
(yrs/response :status 200
:body result
:headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}))
(yrs/response 404 "not found"))))
"x-robots-tag" "noindex"})
(yrs/response 404 "not found")))))
(def sql:error-reports
"SELECT id, created_at,
content->>'~:hint' AS hint
FROM server_error_report
ORDER BY created_at DESC
LIMIT 100")
"select id, created_at from server_error_report order by created_at desc limit 100")
(defn error-list-handler
[{:keys [::db/pool]} request]
[{:keys [pool]} request]
(when-not (authorized? pool request)
(ex/raise :type :authentication
:code :only-admins-allowed))
(let [items (->> (db/exec! pool [sql:error-reports])
(map #(update % :created-at dt/format-instant :rfc1123)))]
(let [items (db/exec! pool [sql:error-reports])
items (map #(update % :created-at dt/format-instant :rfc1123) items)]
(yrs/response :status 200
:body (-> (io/resource "app/templates/error-list.tmpl")
(tmpl/render {:items items}))
@@ -273,7 +263,7 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn export-handler
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
[{:keys [pool] :as cfg} {:keys [params profile-id] :as request}]
(let [file-ids (->> (:file-ids params)
(remove empty?)
@@ -292,8 +282,7 @@
(assoc ::binf/include-libraries? libs?)
(binf/export-to-tmpfile!))]
(if clone?
(let [profile (profile/get-profile pool profile-id)
project-id (:default-project-id profile)]
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)]
(binf/import!
(assoc cfg
::binf/input path
@@ -315,16 +304,15 @@
(defn import-handler
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
[{:keys [pool] :as cfg} {:keys [params profile-id] :as request}]
(when-not (contains? params :file)
(ex/raise :type :validation
:code :missing-upload-file
:hint "missing upload file"))
(let [profile (profile/get-profile pool profile-id)
project-id (:default-project-id profile)
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
overwrite? (contains? params :overwrite)
migrate? (contains? params :migrate)
migrate? (contains? params :migrate)
ignore-index-errors? (contains? params :ignore-index-errors)]
(when-not project-id
@@ -352,14 +340,15 @@
(defn health-handler
"Mainly a task that performs a health check."
[{:keys [::db/pool]} _]
(try
(db/exec-one! pool ["select count(*) as count from server_prop;"])
(yrs/response 200 "OK")
(catch Throwable cause
(l/warn :hint "unable to execute query on health handler"
:cause cause)
(yrs/response 503 "KO"))))
[{:keys [pool]} _]
(db/with-atomic [conn pool]
(try
(db/exec-one! conn ["select count(*) as count from server_prop;"])
(yrs/response 200 "OK")
(catch Throwable cause
(l/warn :hint "unable to execute query on health handler"
:cause cause)
(yrs/response 503 "KO")))))
(defn changelog-handler
[_ _]
@@ -387,18 +376,18 @@
(raise (ex/error :type :authentication
:code :only-admins-allowed))))))})
(s/def ::session map?)
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::db/pool
::wrk/executor
::sto/storage
::session/manager]))
(s/keys :req-un [::db/pool ::wrk/executor ::session]))
(defmethod ig/init-key ::routes
[_ {:keys [::db/pool ::wrk/executor] :as cfg}]
[_ {:keys [session pool executor] :as cfg}]
[["/readyz" {:middleware [[mw/with-dispatch executor]
[mw/with-config cfg]]
:handler health-handler}]
["/dbg" {:middleware [[session/authz cfg]
["/dbg" {:middleware [[(:middleware session)]
[with-authorization pool]
[mw/with-dispatch executor]
[mw/with-config cfg]]}

View File

@@ -9,34 +9,33 @@
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.session :as-alias session]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[yetti.request :as yrq]
[yetti.response :as yrs]))
(def ^:dynamic *context* {})
(defn- parse-client-ip
[request]
(or (some-> (yrq/get-header request "x-forwarded-for") (str/split ",") first)
(yrq/get-header request "x-real-ip")
(yrq/remote-addr request)))
(defn request->context
"Extracts error report relevant context data from request."
(defn get-context
[request]
(let [claims (-> {}
(into (::session/token-claims request))
(into (::actoken/token-claims request)))]
{:path (:path request)
:method (:method request)
:params (:params request)
:ip-addr (parse-client-ip request)
:user-agent (yrq/get-header request "user-agent")
:profile-id (:uid claims)
:version (or (yrq/get-header request "x-frontend-version")
"unknown")}))
(merge
*context*
{:path (:path request)
:method (:method request)
:params (:params request)
:ip-addr (parse-client-ip request)
:profile-id (:profile-id request)}
(let [headers (:headers request)]
{:user-agent (get headers "user-agent")
:frontend-version (get headers "x-frontend-version" "unknown")})))
(defmulti handle-exception
(fn [err & _rest]
@@ -48,10 +47,6 @@
[err _]
(yrs/response 401 (ex-data err)))
(defmethod handle-exception :authorization
[err _]
(yrs/response 403 (ex-data err)))
(defmethod handle-exception :restriction
[err _]
(yrs/response 400 (ex-data err)))
@@ -66,7 +61,7 @@
(let [{:keys [code] :as data} (ex-data err)]
(cond
(= code :spec-validation)
(let [explain (ex/explain data)]
(let [explain (us/pretty-explain data)]
(yrs/response :status 400
:body (-> data
(dissoc ::s/problems ::s/value)
@@ -80,57 +75,44 @@
(defmethod handle-exception :assertion
[error request]
(let [edata (ex-data error)
explain (ex/explain edata)]
(binding [l/*context* (request->context request)]
(l/error :hint "Assertion error" :message (ex-message error) :cause error)
(yrs/response :status 500
:body {:type :server-error
:code :assertion
:data (-> edata
(dissoc ::s/problems ::s/value ::s/spec)
(cond-> explain (assoc :explain explain)))}))))
(let [edata (ex-data error)
explain (us/pretty-explain edata)]
(l/error ::l/raw (str (ex-message error) "\n" explain)
::l/context (get-context request)
:cause error)
(yrs/response :status 500
:body {:type :server-error
:code :assertion
:data (-> edata
(dissoc ::s/problems ::s/value ::s/spec)
(cond-> explain (assoc :explain explain)))})))
(defmethod handle-exception :not-found
[err _]
(yrs/response 404 (ex-data err)))
(defmethod handle-exception :internal
[error request]
(let [{:keys [code] :as edata} (ex-data error)]
(cond
(= :concurrency-limit-reached code)
(yrs/response 429)
:else
(binding [l/*context* (request->context request)]
(l/error :hint "Internal error" :message (ex-message error) :cause error)
(yrs/response 500 {:type :server-error
:code :unhandled
:hint (ex-message error)
:data edata})))))
(defmethod handle-exception org.postgresql.util.PSQLException
[error request]
(let [state (.getSQLState ^java.sql.SQLException error)]
(binding [l/*context* (request->context request)]
(l/error :hint "PSQL error" :message (ex-message error) :cause error)
(cond
(= state "57014")
(yrs/response 504 {:type :server-error
:code :statement-timeout
:hint (ex-message error)})
(l/error ::l/raw (ex-message error)
::l/context (get-context request)
:cause error)
(cond
(= state "57014")
(yrs/response 504 {:type :server-error
:code :statement-timeout
:hint (ex-message error)})
(= state "25P03")
(yrs/response 504 {:type :server-error
:code :idle-in-transaction-timeout
:hint (ex-message error)})
(= state "25P03")
(yrs/response 504 {:type :server-error
:code :idle-in-transaction-timeout
:hint (ex-message error)})
:else
(yrs/response 500 {:type :server-error
:code :unexpected
:hint (ex-message error)
:state state})))))
:else
(yrs/response 500 {:type :server-error
:code :unexpected
:hint (ex-message error)
:state state}))))
(defmethod handle-exception :default
[error request]
@@ -138,8 +120,10 @@
(cond
;; This means that exception is not a controlled exception.
(nil? edata)
(binding [l/*context* (request->context request)]
(l/error :hint "Unexpected error" :message (ex-message error) :cause error)
(do
(l/error ::l/raw (ex-message error)
::l/context (get-context request)
:cause error)
(yrs/response 500 {:type :server-error
:code :unexpected
:hint (ex-message error)}))
@@ -154,8 +138,10 @@
(handle-exception (:handling edata) request)
:else
(binding [l/*context* (request->context request)]
(l/error :hint "Unhandled error" :message (ex-message error) :cause error)
(do
(l/error ::l/raw (ex-message error)
::l/context (get-context request)
:cause error)
(yrs/response 500 {:type :server-error
:code :unhandled
:hint (ex-message error)
@@ -163,7 +149,16 @@
(defn handle
[cause request]
(if (or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause))
(handle-exception (ex-cause cause) request)
(cond
(or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause))
(handle-exception (.getCause ^Throwable cause) request)
(ex/wrapped? cause)
(let [context (meta cause)
cause (deref cause)]
(binding [*context* context]
(handle-exception cause request)))
:else
(handle-exception cause request)))

View File

@@ -0,0 +1,80 @@
;; 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.http.feedback
"A general purpose feedback module."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.config :as cf]
[app.db :as db]
[app.emails :as eml]
[app.rpc.queries.profile :as profile]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.request :as yrq]
[yetti.response :as yrs]))
(declare ^:private send-feedback)
(declare ^:private handler)
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req-un [::db/pool ::wrk/executor]))
(defmethod ig/init-key ::handler
[_ {:keys [executor] :as cfg}]
(let [enabled? (contains? cf/flags :user-feedback)]
(if enabled?
(fn [request respond raise]
(-> (px/submit! executor #(handler cfg request))
(p/then' respond)
(p/catch raise)))
(fn [_ _ raise]
(raise (ex/error :type :validation
:code :feedback-disabled
:hint "feedback module is disabled"))))))
(defn- handler
[{:keys [pool] :as cfg} {:keys [profile-id] :as request}]
(let [ftoken (cf/get :feedback-token ::no-token)
token (yrq/get-header request "x-feedback-token")
params (d/merge (:params request)
(:body-params request))]
(cond
(uuid? profile-id)
(let [profile (profile/retrieve-profile-data pool profile-id)
params (assoc params :from (:email profile))]
(send-feedback pool profile params))
(= token ftoken)
(send-feedback cfg nil params))
(yrs/response 204)))
(s/def ::content ::us/string)
(s/def ::from ::us/email)
(s/def ::subject ::us/string)
(s/def ::feedback
(s/keys :req-un [::from ::subject ::content]))
(defn- send-feedback
[pool profile params]
(let [params (us/conform ::feedback params)
destination (cf/get :feedback-destination)]
(eml/send! {::eml/conn pool
::eml/factory eml/feedback
:from destination
:to destination
:profile profile
:reply-to (:from params)
:email (:from params)
:subject (:subject params)
:content (:content params)})
nil))

View File

@@ -19,7 +19,6 @@
[yetti.request :as yrq]
[yetti.response :as yrs])
(:import
com.fasterxml.jackson.core.JsonParseException
com.fasterxml.jackson.core.io.JsonEOFException
io.undertow.server.RequestTooBigException
java.io.OutputStream))
@@ -32,12 +31,6 @@
{:name ::params
:compile (constantly ymw/wrap-params)})
(def ^:private json-mapper
(json/mapper
{:encode-key-fn str/camel
:decode-key-fn (comp keyword str/kebab)
:pretty true}))
(defn wrap-parse-request
[handler]
(letfn [(process-request [request]
@@ -52,7 +45,7 @@
(str/starts-with? header "application/json")
(with-open [is (yrq/body request)]
(let [params (json/decode is json-mapper)]
(let [params (json/read is)]
(-> request
(assoc :body-params params)
(update :params merge params))))
@@ -67,23 +60,21 @@
:code :request-body-too-large
:hint (ex-message cause)))
(or (instance? JsonEOFException cause)
(instance? JsonParseException cause))
(instance? JsonEOFException cause)
(raise (ex/error :type :validation
:code :malformed-json
:hint (ex-message cause)
:cause cause))
:hint (ex-message cause)))
:else
(raise cause)))]
(fn [request respond raise]
(let [request (ex/try! (process-request request))]
(if (ex/exception? request)
(if (ex/runtime-exception? request)
(handle-error raise (or (ex-cause request) request))
(handle-error raise request))
(handler request respond raise))))))
(when-let [request (try
(process-request request)
(catch RuntimeException cause
(handle-error raise (or (.getCause cause) cause)))
(catch Throwable cause
(handle-error raise cause)))]
(handler request respond raise)))))
(def parse-request
{:name ::parse-request
@@ -122,32 +113,7 @@
(finally
(.close ^OutputStream output-stream))))))
(json-streamable-body [data]
(reify yrs/StreamableResponseBody
(-write-body-to-stream [_ _ output-stream]
(try
(with-open [bos (buffered-output-stream output-stream buffer-size)]
(json/write! bos data json-mapper))
(catch java.io.IOException _cause
;; Do nothing, EOF means client closes connection abruptly
nil)
(catch Throwable cause
(l/warn :hint "unexpected error on encoding response"
:cause cause))
(finally
(.close ^OutputStream output-stream))))))
(format-response-with-json [response _]
(let [body (yrs/body response)]
(if (or (boolean? body) (coll? body))
(-> response
(update :headers assoc "content-type" "application/json")
(assoc :body (json-streamable-body body)))
response)))
(format-response-with-transit [response request]
(format-response [response request]
(let [body (yrs/body response)]
(if (or (boolean? body) (coll? body))
(let [qs (yrq/query request)
@@ -160,20 +126,6 @@
(assoc :body (transit-streamable-body body opts))))
response)))
(format-response [response request]
(let [accept (yrq/get-header request "accept")]
(cond
(or (= accept "application/transit+json")
(str/includes? accept "application/transit+json"))
(format-response-with-transit response request)
(or (= accept "application/json")
(str/includes? accept "application/json"))
(format-response-with-json response request)
:else
(format-response-with-transit response request))))
(process-response [response request]
(cond-> response
(map? response) (format-response request)))]

View File

@@ -5,31 +5,21 @@
;; Copyright (c) KALEIDOS INC
(ns app.http.session
(:refer-clojure :exclude [read])
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.http.session.tasks :as-alias tasks]
[app.main :as-alias main]
[app.tokens :as tokens]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.request :as yrq]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; A default cookie name for storing the session.
(def default-auth-token-cookie-name "auth-token")
@@ -43,243 +33,119 @@
;; Default age for automatic session renewal
(def default-renewal-max-age (dt/duration {:hours 6}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PROTOCOLS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defprotocol ISessionStore
(read-session [store key])
(write-session [store key data])
(update-session [store data])
(delete-session [store key]))
(defprotocol ISessionManager
(read [_ key])
(write! [_ key data])
(update! [_ data])
(delete! [_ key]))
(s/def ::manager #(satisfies? ISessionManager %))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; STORAGE IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::session-params
(s/keys :req-un [::user-agent
::profile-id
::created-at]))
(defn- prepare-session-params
[key params]
(us/assert! ::us/not-empty-string key)
(us/assert! ::session-params 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
[{:keys [::db/pool ::wrk/executor ::main/props]}]
^{::wrk/executor executor
::db/pool pool
::main/props props}
(reify ISessionManager
(read [_ token]
(defn- make-database-store
[{:keys [pool sprops executor]}]
(reify ISessionStore
(read-session [_ token]
(px/with-dispatch executor
(db/exec-one! pool (sql/select :http-session {:id token}))))
(write! [_ key params]
(write-session [_ _ data]
(px/with-dispatch executor
(let [params (prepare-session-params key params)]
(db/insert! pool :http-session params)
params)))
(let [profile-id (:profile-id data)
user-agent (:user-agent data)
created-at (or (:created-at data) (dt/now))
token (tokens/generate sprops {:iss "authentication"
:iat created-at
:uid profile-id})
params {:user-agent user-agent
:profile-id profile-id
:created-at created-at
:updated-at created-at
:id token}]
(db/insert! pool :http-session params))))
(update! [_ params]
(update-session [_ data]
(let [updated-at (dt/now)]
(px/with-dispatch executor
(db/update! pool :http-session
{:updated-at updated-at}
{:id (:id params)})
(assoc params :updated-at updated-at))))
{:id (:id data)})
(assoc data :updated-at updated-at))))
(delete! [_ token]
(delete-session [_ token]
(px/with-dispatch executor
(db/delete! pool :http-session {:id token})
nil))))
(defn inmemory-manager
[{:keys [::db/pool ::wrk/executor ::main/props]}]
(defn make-inmemory-store
[{:keys [sprops]}]
(let [cache (atom {})]
^{::main/props props
::wrk/executor executor
::db/pool pool}
(reify ISessionManager
(read [_ token]
(reify ISessionStore
(read-session [_ token]
(p/do (get @cache token)))
(write! [_ key params]
(write-session [_ _ data]
(p/do
(let [params (prepare-session-params key params)]
(swap! cache assoc key params)
(let [profile-id (:profile-id data)
user-agent (:user-agent data)
created-at (or (:created-at data) (dt/now))
token (tokens/generate sprops {:iss "authentication"
:iat created-at
:uid profile-id})
params {:user-agent user-agent
:created-at created-at
:updated-at created-at
:profile-id profile-id
:id token}]
(swap! cache assoc token params)
params)))
(update! [_ params]
(p/do
(let [updated-at (dt/now)]
(swap! cache update (:id params) assoc :updated-at updated-at)
(assoc params :updated-at updated-at))))
(update-session [_ data]
(let [updated-at (dt/now)]
(swap! cache update (:id data) assoc :updated-at updated-at)
(assoc data :updated-at updated-at)))
(delete! [_ token]
(delete-session [_ token]
(p/do
(swap! cache dissoc token)
nil)))))
(defmethod ig/pre-init-spec ::manager [_]
(s/keys :req [::db/pool ::wrk/executor ::main/props]))
(s/def ::sprops map?)
(defmethod ig/pre-init-spec ::store [_]
(s/keys :req-un [::db/pool ::wrk/executor ::sprops]))
(defmethod ig/init-key ::manager
[_ {:keys [::db/pool] :as cfg}]
(defmethod ig/init-key ::store
[_ {:keys [pool] :as cfg}]
(if (db/read-only? pool)
(inmemory-manager cfg)
(database-manager cfg)))
(make-inmemory-store cfg)
(make-database-store cfg)))
(defmethod ig/halt-key! ::manager
(defmethod ig/halt-key! ::store
[_ _])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MANAGER IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private assign-auth-token-cookie)
(declare ^:private assign-authenticated-cookie)
(declare ^:private clear-auth-token-cookie)
(declare ^:private clear-authenticated-cookie)
(declare ^:private gen-token)
(defn create-fn
[{:keys [::manager]} profile-id]
(us/assert! ::manager manager)
(us/assert! ::us/uuid profile-id)
(let [props (-> manager meta ::main/props)]
(fn [request response]
(let [uagent (yrq/get-header request "user-agent")
params {:profile-id profile-id
:user-agent uagent
:created-at (dt/now)}
token (gen-token props params)]
(->> (write! manager token params)
(p/fmap (fn [session]
(l/trace :hint "create" :profile-id (str profile-id))
(-> response
(assign-auth-token-cookie session)
(assign-authenticated-cookie session)))))))))
(defn delete-fn
[{:keys [::manager]}]
(us/assert! ::manager manager)
(letfn [(delete [{:keys [profile-id] :as request}]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (yrq/get-cookie request cname)]
(l/trace :hint "delete" :profile-id profile-id)
(some->> (:value cookie) (delete! manager))))]
(fn [request response]
(p/do
(delete request)
(-> response
(assoc :status 204)
(assoc :body nil)
(clear-auth-token-cookie)
(clear-authenticated-cookie))))))
(defn- gen-token
[props {:keys [profile-id created-at]}]
(tokens/generate props {:iss "authentication"
:iat created-at
:uid profile-id}))
(defn- decode-token
[props token]
(when token
(tokens/verify props {:token token :iss "authentication"})))
(defn- get-token
[request]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (some-> (yrq/get-cookie request cname) :value)]
(when-not (str/empty? cookie)
cookie)))
(defn- get-session
[manager token]
(some->> token (read manager)))
(defn- renew-session?
[{:keys [updated-at] :as session}]
(and (dt/instant? updated-at)
(let [elapsed (dt/diff updated-at (dt/now))]
(neg? (compare default-renewal-max-age elapsed)))))
(defn- wrap-reneval
[respond manager session]
(fn [response]
(p/let [session (update! manager session)]
(-> response
(assign-auth-token-cookie session)
(assign-authenticated-cookie session)
(respond)))))
(defn- wrap-soft-auth
[handler {:keys [::manager]}]
(us/assert! ::manager manager)
(let [{:keys [::wrk/executor ::main/props]} (meta manager)]
(fn [request respond raise]
(let [token (ex/try! (get-token request))]
(if (ex/exception? token)
(raise token)
(->> (px/submit! executor (partial decode-token props token))
(p/fnly (fn [claims cause]
(when cause
(l/trace :hint "exception on decoding malformed token" :cause cause))
(let [request (cond-> request
(map? claims)
(-> (assoc ::token-claims claims)
(assoc ::token token)))]
(handler request respond raise))))))))))
(defn- wrap-authz
[handler {:keys [::manager]}]
(us/assert! ::manager manager)
(fn [request respond raise]
(if-let [token (::token request)]
(->> (get-session manager token)
(p/fnly (fn [session cause]
(cond
(some? cause)
(raise cause)
(nil? session)
(handler request respond raise)
:else
(let [request (-> request
(assoc ::profile-id (:profile-id session))
(assoc ::id (:id session)))
respond (cond-> respond
(renew-session? session)
(wrap-reneval manager session))]
(handler request respond raise))))))
(handler request respond raise))))
(def soft-auth
{:name ::soft-auth
:compile (constantly wrap-soft-auth)})
(def authz
{:name ::authz
:compile (constantly wrap-authz)})
;; --- IMPL
(defn- assign-auth-token-cookie
(defn- create-session!
[store profile-id user-agent]
(let [params {:user-agent user-agent
:profile-id profile-id}]
(write-session store nil params)))
(defn- update-session!
[store session]
(update-session store session))
(defn- delete-session!
[store {:keys [cookies] :as request}]
(let [name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(when-let [token (get-in cookies [name :value])]
(delete-session store token))))
(defn- retrieve-session
[store request]
(let [cookie-name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(when-let [cookie (yrq/get-cookie request cookie-name)]
(read-session store (:value cookie)))))
(defn assign-auth-token-cookie
[response {token :id updated-at :updated-at}]
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
created-at (or updated-at (dt/now))
@@ -298,19 +164,16 @@
:secure secure?}]
(update response :cookies assoc name cookie)))
(defn- assign-authenticated-cookie
(defn assign-authenticated-cookie
[response {updated-at :updated-at}]
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
domain (cf/get :authenticated-cookie-domain)
cname (cf/get :authenticated-cookie-name "authenticated")
created-at (or updated-at (dt/now))
renewal (dt/plus created-at default-renewal-max-age)
expires (dt/plus created-at max-age)
comment (str "Renewal at: " (dt/format-instant renewal :rfc1123))
secure? (contains? cf/flags :secure-session-cookies)
domain (cf/get :authenticated-cookie-domain)
name (cf/get :authenticated-cookie-name "authenticated")
cookie {:domain domain
:expires expires
:path "/"
@@ -320,46 +183,114 @@
:secure secure?}]
(cond-> response
(string? domain)
(update :cookies assoc cname cookie))))
(update :cookies assoc name cookie))))
(defn- clear-auth-token-cookie
(defn clear-auth-token-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 [name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(update response :cookies assoc name {:path "/" :value "" :max-age -1})))
(defn- clear-authenticated-cookie
[response]
(let [cname (cf/get :authenticated-cookie-name default-authenticated-cookie-name)
(let [name (cf/get :authenticated-cookie-name default-authenticated-cookie-name)
domain (cf/get :authenticated-cookie-domain)]
(cond-> response
(string? domain)
(update :cookies assoc cname {:domain domain :path "/" :value "" :max-age 0}))))
(update :cookies assoc name {:domain domain :path "/" :value "" :max-age -1}))))
(defn- make-middleware
[{:keys [store] :as cfg}]
(letfn [;; Check if time reached for automatic session renewal
(renew-session? [{:keys [updated-at] :as session}]
(and (dt/instant? updated-at)
(let [elapsed (dt/diff updated-at (dt/now))]
(neg? (compare default-renewal-max-age elapsed)))))
;; Wrap respond with session renewal code
(wrap-respond [respond session]
(fn [response]
(p/let [session (update-session! store session)]
(-> response
(assign-auth-token-cookie session)
(assign-authenticated-cookie session)
(respond)))))]
{:name :session
:compile (fn [& _]
(fn [handler]
(fn [request respond raise]
(try
(-> (retrieve-session store request)
(p/finally (fn [session cause]
(cond
(some? cause)
(raise cause)
(nil? session)
(handler request respond raise)
:else
(let [request (-> request
(assoc :profile-id (:profile-id session))
(assoc :session-id (:id session)))
respond (cond-> respond
(renew-session? session)
(wrap-respond session))]
(handler request respond raise))))))
(catch Throwable cause
(raise cause))))))}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK: SESSION GC
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- STATE INIT: SESSION
(s/def ::tasks/max-age ::dt/duration)
(s/def ::store #(satisfies? ISessionStore %))
(defmethod ig/pre-init-spec ::tasks/gc [_]
(s/keys :req [::db/pool]
:opt [::tasks/max-age]))
(defmethod ig/pre-init-spec :app.http/session [_]
(s/keys :req-un [::store]))
(defmethod ig/prep-key ::tasks/gc
(defmethod ig/prep-key :app.http/session
[_ cfg]
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)]
(merge {::tasks/max-age max-age} (d/without-nils cfg))))
(d/merge {:buffer-size 128}
(d/without-nils cfg)))
(def ^:private
sql:delete-expired
"delete from http_session
where updated_at < now() - ?::interval
or (updated_at is null and
created_at < now() - ?::interval)")
(defmethod ig/init-key :app.http/session
[_ {:keys [store] :as cfg}]
(-> cfg
(assoc :middleware (make-middleware cfg))
(assoc :create (fn [profile-id]
(fn [request response]
(p/let [uagent (yrq/get-header request "user-agent")
session (create-session! store profile-id uagent)]
(-> response
(assign-auth-token-cookie session)
(assign-authenticated-cookie session))))))
(assoc :delete (fn [request response]
(p/do
(delete-session! store request)
(-> response
(assoc :status 204)
(assoc :body nil)
(clear-auth-token-cookie)
(clear-authenticated-cookie)))))))
(defmethod ig/init-key ::tasks/gc
[_ {:keys [::db/pool ::tasks/max-age] :as cfg}]
;; --- STATE INIT: SESSION GC
(declare sql:delete-expired)
(s/def ::max-age ::dt/duration)
(defmethod ig/pre-init-spec ::gc-task [_]
(s/keys :req-un [::db/pool]
:opt-un [::max-age]))
(defmethod ig/prep-key ::gc-task
[_ cfg]
(merge {:max-age default-cookie-max-age}
(d/without-nils cfg)))
(defmethod ig/init-key ::gc-task
[_ {:keys [pool max-age] :as cfg}]
(l/debug :hint "initializing session gc task" :max-age max-age)
(fn [_]
(db/with-atomic [conn pool]
@@ -371,3 +302,9 @@
:deleted result)
result))))
(def ^:private
sql:delete-expired
"delete from http_session
where updated_at < now() - ?::interval
or (updated_at is null and
created_at < now() - ?::interval)")

View File

@@ -12,7 +12,6 @@
[app.common.pprint :as pp]
[app.common.spec :as us]
[app.db :as db]
[app.http.session :as session]
[app.metrics :as mtx]
[app.msgbus :as mbus]
[app.util.time :as dt]
@@ -35,7 +34,7 @@
(def state (atom {}))
(defn- on-connect
[{:keys [::mtx/metrics]} wsp]
[{:keys [metrics]} wsp]
(let [created-at (dt/now)]
(swap! state assoc (::ws/id @wsp) wsp)
(mtx/run! metrics
@@ -49,7 +48,7 @@
:val (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0)))))
(defn- on-rcv-message
[{:keys [::mtx/metrics]} _ message]
[{:keys [metrics]} _ message]
(mtx/run! metrics
:id :websocket-messages-total
:labels recv-labels
@@ -57,7 +56,7 @@
message)
(defn- on-snd-message
[{:keys [::mtx/metrics]} _ message]
[{:keys [metrics]} _ message]
(mtx/run! metrics
:id :websocket-messages-total
:labels send-labels
@@ -96,6 +95,7 @@
:user-agent (::ws/user-agent @wsp)
:ip-addr (::ws/remote-addr @wsp)
:last-activity-at (::ws/last-activity-at @wsp)
:http-session-id (::ws/http-session-id @wsp)
:subscribed-file (-> wsp deref ::file-subscription :file-id)
:subscribed-team (-> wsp deref ::team-subscription :team-id)}))
@@ -120,7 +120,7 @@
(defmethod handle-message :connect
[cfg wsp _]
(let [msgbus (::mbus/msgbus cfg)
(let [msgbus (:msgbus cfg)
conn-id (::ws/id @wsp)
profile-id (::profile-id @wsp)
session-id (::session-id @wsp)
@@ -139,7 +139,7 @@
(defmethod handle-message :disconnect
[cfg wsp _]
(let [msgbus (::mbus/msgbus cfg)
(let [msgbus (:msgbus cfg)
conn-id (::ws/id @wsp)
profile-id (::profile-id @wsp)
session-id (::session-id @wsp)
@@ -173,7 +173,7 @@
(defmethod handle-message :subscribe-team
[cfg wsp {:keys [team-id] :as params}]
(let [msgbus (::mbus/msgbus cfg)
(let [msgbus (:msgbus cfg)
conn-id (::ws/id @wsp)
session-id (::session-id @wsp)
output-ch (::ws/output-ch @wsp)
@@ -204,8 +204,8 @@
(a/<! (mbus/sub! msgbus :topic team-id :chan channel)))))
(defmethod handle-message :subscribe-file
[cfg wsp {:keys [file-id version] :as params}]
(let [msgbus (::mbus/msgbus cfg)
[cfg wsp {:keys [file-id] :as params}]
(let [msgbus (:msgbus cfg)
conn-id (::ws/id @wsp)
profile-id (::profile-id @wsp)
session-id (::session-id @wsp)
@@ -239,8 +239,7 @@
(let [message {:type :presence
:file-id file-id
:session-id session-id
:profile-id profile-id
:version version}]
:profile-id profile-id}]
(a/<! (mbus/pub! msgbus :topic file-id :message message))))
(a/>! output-ch message)
(recur))))
@@ -259,7 +258,7 @@
(defmethod handle-message :unsubscribe-file
[cfg wsp {:keys [file-id] :as params}]
(let [msgbus (::mbus/msgbus cfg)
(let [msgbus (:msgbus cfg)
conn-id (::ws/id @wsp)
session-id (::session-id @wsp)
profile-id (::profile-id @wsp)
@@ -289,7 +288,7 @@
(defmethod handle-message :pointer-update
[cfg wsp {:keys [file-id] :as message}]
(let [msgbus (::mbus/msgbus cfg)
(let [msgbus (:msgbus cfg)
profile-id (::profile-id @wsp)
session-id (::session-id @wsp)
subs (::file-subscription @wsp)
@@ -314,47 +313,39 @@
;; HTTP HANDLER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::msgbus ::mbus/msgbus)
(s/def ::session-id ::us/uuid)
(s/def ::handler-params
(s/keys :req-un [::session-id]))
(defn- http-handler
[cfg {:keys [params ::session/profile-id] :as request} respond raise]
(let [{:keys [session-id]} (us/conform ::handler-params params)]
(cond
(not profile-id)
(raise (ex/error :type :authentication
:hint "Authentication required."))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req-un [::msgbus ::db/pool ::mtx/metrics]))
(not (yws/upgrade-request? request))
(raise (ex/error :type :validation
:code :websocket-request-expected
:hint "this endpoint only accepts websocket connections"))
:else
(do
(l/trace :hint "websocket request" :profile-id profile-id :session-id session-id)
(->> (ws/handler
::ws/on-rcv-message (partial on-rcv-message cfg)
::ws/on-snd-message (partial on-snd-message cfg)
::ws/on-connect (partial on-connect cfg)
::ws/handler (partial handle-message cfg)
::profile-id profile-id
::session-id session-id)
(yws/upgrade request)
(respond))))))
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::mbus/msgbus
::mtx/metrics
::db/pool
::session/manager]))
(s/def ::routes vector?)
(defmethod ig/init-key ::routes
(defmethod ig/init-key ::handler
[_ cfg]
["/ws/notifications" {:middleware [[session/authz cfg]]
:handler (partial http-handler cfg)
:allowed-methods #{:get}}])
(fn [{:keys [profile-id params] :as req} respond raise]
(let [{:keys [session-id]} (us/conform ::handler-params params)]
(cond
(not profile-id)
(raise (ex/error :type :authentication
:hint "Authentication required."))
(not (yws/upgrade-request? req))
(raise (ex/error :type :validation
:code :websocket-request-expected
:hint "this endpoint only accepts websocket connections"))
:else
(do
(l/trace :hint "websocket request" :profile-id profile-id :session-id session-id)
(->> (ws/handler
::ws/on-rcv-message (partial on-rcv-message cfg)
::ws/on-snd-message (partial on-snd-message cfg)
::ws/on-connect (partial on-connect cfg)
::ws/handler (partial handle-message cfg)
::profile-id profile-id
::session-id session-id)
(yws/upgrade req)
(respond)))))))

View File

@@ -8,7 +8,6 @@
"Services related to the user activity (audit log)."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
@@ -16,25 +15,19 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.client :as http]
[app.loggers.audit.tasks :as-alias tasks]
[app.loggers.webhooks :as-alias webhooks]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.tokens :as tokens]
[app.util.retry :as rtry]
[app.util.async :as aa]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[lambdaisland.uri :as u]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.request :as yrq]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[yetti.request :as yrq]
[yetti.response :as yrs]))
(defn parse-client-ip
[request]
@@ -56,169 +49,210 @@
(assoc (->> sk str/kebab (keyword "penpot")) v))))]
(reduce-kv process-param {} params)))
(def ^:private
profile-props
[:id
:is-active
:is-muted
:auth-backend
:email
:default-team-id
:default-project-id
:fullname
:lang])
(defn profile->props
[profile]
(-> profile
(select-keys profile-props)
(select-keys [:id :is-active :is-muted :auth-backend :email :default-team-id :default-project-id :fullname :lang])
(merge (:props profile))
(d/without-nils)))
(def reserved-props
#{:session-id
:password
:old-password
:token})
(defn clean-props
[props]
(into {}
(comp
(d/without-nils)
(d/without-qualified)
(remove #(contains? reserved-props (key %))))
props))
[{:keys [profile-id] :as event}]
(let [invalid-keys #{:session-id
:password
:old-password
:token}
xform (comp
(remove (fn [kv]
(qualified-keyword? (first kv))))
(remove (fn [kv]
(contains? invalid-keys (first kv))))
(remove (fn [[k v]]
(and (= k :profile-id)
(= v profile-id))))
(filter (fn [[_ v]]
(or (string? v)
(keyword? v)
(uuid? v)
(boolean? v)
(number? v)))))]
;; --- SPECS
(update event :props #(into {} xform %))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HTTP Handler
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare persist-http-events)
(s/def ::profile-id ::us/uuid)
(s/def ::name ::us/string)
(s/def ::type ::us/string)
(s/def ::props (s/map-of ::us/keyword any?))
(s/def ::ip-addr ::us/string)
(s/def ::timestamp dt/instant?)
(s/def ::context (s/map-of ::us/keyword any?))
(s/def ::webhooks/event? ::us/boolean)
(s/def ::webhooks/batch-timeout ::dt/duration)
(s/def ::webhooks/batch-key
(s/or :fn fn? :str string? :kw keyword?))
(s/def ::frontend-event
(s/keys :req-un [::type ::name ::props ::timestamp ::profile-id]
:opt-un [::context]))
(s/def ::event
(s/keys :req-un [::type ::name ::profile-id]
:opt-un [::ip-addr ::props]
:opt [::webhooks/event?
::webhooks/batch-timeout
::webhooks/batch-key]))
(s/def ::frontend-events (s/every ::frontend-event))
(defmethod ig/init-key ::http-handler
[_ {:keys [executor pool] :as cfg}]
(if (or (db/read-only? pool) (not (contains? cf/flags :audit-log)))
(do
(l/warn :hint "audit log http handler disabled or db is read-only")
(fn [_ respond _]
(respond (yrs/response 204))))
(letfn [(handler [{:keys [profile-id] :as request}]
(let [events (->> (:events (:params request))
(remove #(not= profile-id (:profile-id %)))
(us/conform ::frontend-events))
ip-addr (parse-client-ip request)
cfg (-> cfg
(assoc :source "frontend")
(assoc :events events)
(assoc :ip-addr ip-addr))]
(persist-http-events cfg)))
(handle-error [cause]
(let [xdata (ex-data cause)]
(if (= :spec-validation (:code xdata))
(l/error ::l/raw (str "spec validation on persist-events:\n" (us/pretty-explain xdata)))
(l/error :hint "error on persist-events" :cause cause))))]
(fn [request respond _]
;; Fire and forget, log error in case of error
(-> (px/submit! executor #(handler request))
(p/catch handle-error))
(respond (yrs/response 204))))))
(defn- persist-http-events
[{:keys [pool events ip-addr source] :as cfg}]
(let [columns [:id :name :source :type :tracked-at :profile-id :ip-addr :props :context]
prepare-xf (map (fn [event]
[(uuid/next)
(:name event)
source
(:type event)
(:timestamp event)
(:profile-id event)
(db/inet ip-addr)
(db/tjson (:props event))
(db/tjson (d/without-nils (:context event)))]))]
(when (seq events)
(->> (into [] prepare-xf events)
(db/insert-multi! pool :audit-log columns)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; COLLECTOR
;; Collector
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Defines a service that collects the audit/activity log using
;; internal database. Later this audit log can be transferred to
;; an external storage and data cleared.
(s/def ::collector
(s/keys :req [::wrk/executor ::db/pool]))
(declare persist-events)
(defmethod ig/pre-init-spec ::collector [_]
(s/keys :req [::db/pool ::wrk/executor]))
(s/keys :req-un [::db/pool ::wrk/executor]))
(s/def ::ip-addr string?)
(s/def ::backend-event
(s/keys :req-un [::type ::name ::profile-id]
:opt-un [::ip-addr ::props]))
(def ^:private backend-event-xform
(comp
(filter #(us/valid? ::backend-event %))
(map clean-props)))
(defmethod ig/init-key ::collector
[_ {:keys [::db/pool] :as cfg}]
[_ {:keys [pool] :as cfg}]
(cond
(not (contains? cf/flags :audit-log))
(do
(l/info :hint "audit log collection disabled")
(constantly nil))
(db/read-only? pool)
(l/warn :hint "audit: disabled (db is read-only)")
(do
(l/warn :hint "audit log collection disabled, db is read-only")
(constantly nil))
:else
cfg))
(let [input (a/chan 512 backend-event-xform)
buffer (aa/batch input {:max-batch-size 100
:max-batch-age (* 10 1000) ; 10s
:init []})]
(l/info :hint "audit log collector initialized")
(a/go-loop []
(when-let [[_type events] (a/<! buffer)]
(let [res (a/<! (persist-events cfg events))]
(when (ex/exception? res)
(l/error :hint "error on persisting events" :cause res))
(recur))))
(defn- handle-event!
[conn-or-pool event]
(us/verify! ::event event)
(let [params {:id (uuid/next)
:name (:name event)
:type (:type event)
:profile-id (:profile-id event)
:ip-addr (:ip-addr event)
:props (:props event)}]
(fn [& {:keys [cmd] :as params}]
(case cmd
:stop
(a/close! input)
(when (contains? cf/flags :audit-log)
;; NOTE: this operation may cause primary key conflicts on inserts
;; because of the timestamp precission (two concurrent requests), in
;; this case we just retry the operation.
(rtry/with-retry {::rtry/when rtry/conflict-exception?
::rtry/max-retries 6
::rtry/label "persist-audit-log"}
(let [now (dt/now)]
(db/insert! conn-or-pool :audit-log
(-> params
(update :props db/tjson)
(update :ip-addr db/inet)
(assoc :created-at now)
(assoc :tracked-at now)
(assoc :source "backend"))))))
:submit
(let [params (-> params
(dissoc :cmd)
(assoc :tracked-at (dt/now)))]
(when-not (a/offer! input params)
(l/warn :hint "activity channel is full"))))))))
(when (and (contains? cf/flags :webhooks)
(::webhooks/event? event))
(let [batch-key (::webhooks/batch-key event)
batch-timeout (::webhooks/batch-timeout event)
label (dm/str "rpc:" (:name params))
label (cond
(ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event)))
(string? batch-key) (dm/str label ":" batch-key)
:else label)
dedupe? (boolean (and batch-key batch-timeout))]
(wrk/submit! ::wrk/conn conn-or-pool
::wrk/task :process-webhook-event
::wrk/queue :webhooks
::wrk/max-retries 0
::wrk/delay (or batch-timeout 0)
::wrk/dedupe dedupe?
::wrk/label label
::webhooks/event
(-> params
(dissoc :ip-addr)
(dissoc :type)))))
params))
(defn submit!
"Submit audit event to the collector."
[{:keys [::wrk/executor] :as cfg} params]
(let [conn (or (::db/conn cfg) (::db/pool cfg))]
(us/assert! ::wrk/executor executor)
(us/assert! ::db/pool-or-conn conn)
(try
(handle-event! conn (d/without-nils params))
(catch Throwable cause
(l/error :hint "audit: unexpected error processing event" :cause cause)))))
(defn- persist-events
[{:keys [pool executor] :as cfg} events]
(letfn [(event->row [event]
[(uuid/next)
(:name event)
(:type event)
(:profile-id event)
(:tracked-at event)
(some-> (:ip-addr event) db/inet)
(db/tjson (:props event))
"backend"])]
(aa/with-thread executor
(when (seq events)
(db/with-atomic [conn pool]
(db/insert-multi! conn :audit-log
[:id :name :type :profile-id :tracked-at :ip-addr :props :source]
(sequence (keep event->row) events)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK: ARCHIVE
;; Archive Task
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; This is a task responsible to send the accumulated events to
;; This is a task responsible to send the accumulated events to an
;; external service for archival.
(declare archive-events)
(s/def ::tasks/uri ::us/string)
(s/def ::http-client fn?)
(s/def ::uri ::us/string)
(s/def ::sprops map?)
(defmethod ig/pre-init-spec ::tasks/archive-task [_]
(s/keys :req [::db/pool ::main/props ::http/client]))
(defmethod ig/pre-init-spec ::archive-task [_]
(s/keys :req-un [::db/pool ::sprops ::http-client]
:opt-un [::uri]))
(defmethod ig/init-key ::tasks/archive
[_ cfg]
(fn [params]
(defmethod ig/init-key ::archive-task
[_ {:keys [uri] :as cfg}]
(fn [props]
;; NOTE: this let allows overwrite default configured values from
;; the repl, when manually invoking the task.
(let [enabled (or (contains? cf/flags :audit-log-archive)
(:enabled params false))
uri (cf/get :audit-log-archive-uri)
uri (or uri (:uri params))
cfg (assoc cfg ::uri uri)]
(:enabled props false))
uri (or uri (:uri props))
cfg (assoc cfg :uri uri)]
(when (and enabled (not uri))
(ex/raise :type :internal
@@ -230,21 +264,20 @@
(let [n (archive-events cfg)]
(if n
(do
(px/sleep 100)
(aa/thread-sleep 100)
(recur (+ total n)))
(when (pos? total)
(l/debug :hint "events archived" :total total)))))))))
(l/trace :hint "events chunk archived" :num total)))))))))
(def ^:private sql:retrieve-batch-of-audit-log
"select *
from audit_log
(def sql:retrieve-batch-of-audit-log
"select * from audit_log
where archived_at is null
order by created_at asc
limit 128
limit 256
for update skip locked;")
(defn archive-events
[{:keys [::db/pool ::uri] :as cfg}]
[{:keys [pool uri sprops http-client] :as cfg}]
(letfn [(decode-row [{:keys [props ip-addr context] :as row}]
(cond-> row
(db/pgobject? props)
@@ -268,10 +301,9 @@
:context]))
(send [events]
(let [token (tokens/generate (::main/props cfg)
{:iss "authentication"
:iat (dt/now)
:uid uuid/zero})
(let [token (tokens/generate sprops {:iss "authentication"
:iat (dt/now)
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (cf/get :public-uri)
@@ -281,7 +313,7 @@
:method :post
:headers headers
:body body}
resp (http/req! cfg params {:sync? true})]
resp (http-client params {:sync? true})]
(if (= (:status resp) 204)
true
(do
@@ -302,7 +334,7 @@
(map row->event))
events (into [] xform rows)]
(when-not (empty? events)
(l/trace :hint "archive events chunk" :uri uri :events (count events))
(l/debug :action "archive-events" :uri uri :events (count events))
(when (send events)
(mark-as-archived conn rows)
(count events)))))))
@@ -311,21 +343,20 @@
;; GC Task
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:clean-archived
(def sql:clean-archived
"delete from audit_log
where archived_at is not null")
(defn- clean-archived
[{:keys [::db/pool]}]
[{:keys [pool]}]
(let [result (db/exec-one! pool [sql:clean-archived])
result (:next.jdbc/update-count result)]
(l/debug :hint "delete archived audit log entries" :deleted result)
result))
(defmethod ig/pre-init-spec ::tasks/gc [_]
(s/keys :req [::db/pool]))
(defmethod ig/pre-init-spec ::gc-task [_]
(s/keys :req-un [::db/pool]))
(defmethod ig/init-key ::tasks/gc
(defmethod ig/init-key ::gc-task
[_ cfg]
(fn [_]
(clean-archived cfg)))
(partial clean-archived cfg))

View File

@@ -7,17 +7,16 @@
(ns app.loggers.database
"A specific logger impl that persists errors on the database."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.util.async :as aa]
[app.worker :as wrk]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.exec :as px]
[promesa.exec.csp :as sp]))
[cuerdas.core :as str]
[integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Error Listener
@@ -28,81 +27,67 @@
(defonce enabled (atom true))
(defn- persist-on-database!
[pool id report]
[{:keys [pool] :as cfg} {:keys [id] :as event}]
(when-not (db/read-only? pool)
(db/insert! pool :server-error-report
{:id id
:version 2
:content (db/tjson report)})))
(db/insert! pool :server-error-report {:id id :content (db/tjson event)})))
(defn record->report
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
(us/assert! ::l/record record)
(defn- parse-event-data
[event]
(reduce-kv
(fn [acc k v]
(cond
(= k :id) (assoc acc k (uuid/uuid v))
(= k :profile-id) (assoc acc k (uuid/uuid v))
(str/blank? v) acc
:else (assoc acc k v)))
{}
event))
(merge
{:context (-> context
(assoc :tenant (cf/get :tenant))
(assoc :host (cf/get :host))
(assoc :public-uri (cf/get :public-uri))
(assoc :version (:full cf/version))
(assoc :logger-name logger)
(assoc :logger-level level)
(dissoc :params)
(pp/pprint-str :width 200))
:params (some-> (:params context)
(pp/pprint-str :width 200))
:props (pp/pprint-str props :width 200)
:hint (or (ex-message cause) @message)
:trace (ex/format-throwable cause :data? false :explain? false :header? false :summary? false)}
(defn parse-event
[event]
(-> (parse-event-data event)
(assoc :hint (or (:hint event) (:message event)))
(assoc :tenant (cf/get :tenant))
(assoc :host (cf/get :host))
(assoc :public-uri (cf/get :public-uri))
(assoc :version (:full cf/version))
(update :id #(or % (uuid/next)))))
(when-let [data (ex-data cause)]
{:spec-value (some-> (::s/value data) (pp/pprint-str :width 200))
:spec-explain (ex/explain data)
:data (-> data
(dissoc ::s/problems ::s/value ::s/spec :hint)
(pp/pprint-str :width 200))})))
(defn handle-event
[{:keys [executor] :as cfg} event]
(aa/with-thread executor
(try
(let [event (parse-event event)
uri (cf/get :public-uri)]
(defn- handle-event
[{:keys [::db/pool]} {:keys [::l/id] :as record}]
(try
(let [uri (cf/get :public-uri)
report (-> record record->report d/without-nils)]
(l/debug :hint "registering error on database" :id id
:uri (str uri "/dbg/error/" id))
(l/debug :hint "registering error on database" :id (:id event)
:uri (str uri "/dbg/error/" (:id event)))
(persist-on-database! pool id report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(defn error-record?
[{:keys [::l/level ::l/cause]}]
(and (= :error level)
(ex/exception? cause)))
(persist-on-database! cfg event))
(catch Exception cause
(l/warn :hint "unexpected exception on database error logger" :cause cause)))))
(defmethod ig/pre-init-spec ::reporter [_]
(s/keys :req [::db/pool]))
(s/keys :req-un [::wrk/executor ::db/pool ::receiver]))
(defn error-event?
[event]
(= "error" (:logger/level event)))
(defmethod ig/init-key ::reporter
[_ cfg]
(let [input (sp/chan (sp/sliding-buffer 32) (filter error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(px/thread
{:name "penpot/database-reporter" :virtual true}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [record (sp/take! input)]
(handle-event cfg record)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated"))))))
[_ {:keys [receiver] :as cfg}]
(l/info :msg "initializing database error persistence")
(let [output (a/chan (a/sliding-buffer 5) (filter error-event?))]
(receiver :sub output)
(a/go-loop []
(let [msg (a/<! output)]
(if (nil? msg)
(l/info :msg "stopping error reporting loop")
(do
(a/<! (handle-event cfg msg))
(recur)))))
output))
(defmethod ig/halt-key! ::reporter
[_ thread]
(some-> thread px/interrupt!))
[_ output]
(a/close! output))

View File

@@ -0,0 +1,93 @@
;; 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.loggers.loki
"A Loki integration."
(:require
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cfg]
[app.util.json :as json]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[integrant.core :as ig]))
(declare ^:private handle-event)
(declare ^:private start-rcv-loop)
(s/def ::uri ::us/string)
(s/def ::receiver fn?)
(s/def ::http-client fn?)
(defmethod ig/pre-init-spec ::reporter [_]
(s/keys :req-un [ ::receiver ::http-client]
:opt-un [::uri]))
(defmethod ig/init-key ::reporter
[_ {:keys [receiver uri] :as cfg}]
(when uri
(l/info :msg "initializing loki reporter" :uri uri)
(let [input (a/chan (a/dropping-buffer 2048))]
(receiver :sub input)
(doto (Thread. #(start-rcv-loop cfg input))
(.setDaemon true)
(.setName "penpot/loki-sender")
(.start))
input)))
(defmethod ig/halt-key! ::reporter
[_ output]
(when output
(a/close! output)))
(defn- start-rcv-loop
[cfg input]
(loop []
(let [msg (a/<!! input)]
(when-not (nil? msg)
(handle-event cfg msg)
(recur))))
(l/info :msg "stopping error reporting loop"))
(defn- prepare-payload
[event]
(let [labels {:host (cfg/get :host)
:tenant (cfg/get :tenant)
:version (:full cfg/version)
:logger (:logger/name event)
:level (:logger/level event)}]
{:streams
[{:stream labels
:values [[(str (* (inst-ms (:created-at event)) 1000000))
(str (:message event)
(when-let [error (:trace event)]
(str "\n" error)))]]}]}))
(defn- make-request
[{:keys [http-client uri] :as cfg} payload]
(http-client {:uri uri
:timeout 3000
:method :post
:headers {"content-type" "application/json"}
:body (json/write payload)}
{:sync? true}))
(defn- handle-event
[cfg event]
(try
(let [payload (prepare-payload event)
response (make-request cfg payload)]
(when-not (= 204 (:status response))
(map? response)
(l/error :hint "error on sending log to loki (unexpected response)"
:response (pr-str response))))
(catch Throwable cause
(l/error :hint "error on sending log to loki (unexpected exception)"
:cause cause))))

View File

@@ -7,92 +7,69 @@
(ns app.loggers.mattermost
"A mattermost integration for error reporting."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.http.client :as http]
[app.loggers.database :as ldb]
[app.util.json :as json]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.exec :as px]
[promesa.exec.csp :as sp]))
[promesa.core :as p]))
(defonce enabled (atom true))
(defn- send-mattermost-notification!
[cfg {:keys [id public-uri] :as report}]
(let [text (str "Exception: " public-uri "/dbg/error/" id " "
(when-let [pid (:profile-id report)]
(str "(pid: #uuid-" pid ")"))
"\n"
"```\n"
"- host: `" (:host report) "`\n"
"- tenant: `" (:tenant report) "`\n"
"- version: `" (:version report) "`\n"
"\n"
"Trace:\n"
(:trace report)
"```")
resp (http/req! cfg
{:uri (cf/get :error-report-webhook)
:method :post
:headers {"content-type" "application/json"}
:body (json/encode-str {:text text})}
{:sync? true})]
(when (not= 200 (:status resp))
(l/warn :hint "error on sending data"
:response (pr-str resp)))))
(defn record->report
[{:keys [::l/context ::l/id ::l/cause] :as record}]
(us/assert! ::l/record record)
{:id id
:tenant (cf/get :tenant)
:host (cf/get :host)
:public-uri (cf/get :public-uri)
:version (:full cf/version)
:profile-id (:profile-id context)
:trace (ex/format-throwable cause :detail? false :header? false)})
[{:keys [http-client] :as cfg} {:keys [host id public-uri] :as event}]
(let [uri (:uri cfg)
text (str "Exception on (host: " host ", url: " public-uri "/dbg/error/" id ")\n"
(when-let [pid (:profile-id event)]
(str "- profile-id: #uuid-" pid "\n")))]
(p/then
(http-client {:uri uri
:method :post
:headers {"content-type" "application/json"}
:body (json/write-str {:text text})})
(fn [{:keys [status] :as rsp}]
(when (not= status 200)
(l/warn :hint "error on sending data to mattermost"
:response (pr-str rsp)))))))
(defn handle-event
[cfg record]
(when @enabled
(try
(let [report (record->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause)))))
[cfg event]
(let [ch (a/chan)]
(-> (p/let [event (ldb/parse-event event)]
(send-mattermost-notification! cfg event))
(p/finally (fn [_ cause]
(when cause
(l/warn :hint "unexpected exception on error reporter" :cause cause))
(a/close! ch))))
ch))
(s/def ::http-client fn?)
(s/def ::uri ::cf/error-report-webhook)
(defmethod ig/pre-init-spec ::reporter [_]
(s/keys :req [::http/client]))
(s/keys :req-un [::http-client ::receiver]
:opt-un [::uri]))
(defmethod ig/init-key ::reporter
[_ cfg]
(when-let [uri (cf/get :error-report-webhook)]
(px/thread
{:name "penpot/mattermost-reporter"
:virtual true}
(l/info :hint "initializing error reporter" :uri uri)
(let [input (sp/chan (sp/sliding-buffer 128) (filter ldb/error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(try
(loop []
(when-let [msg (sp/take! input)]
(handle-event cfg msg)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated")))))))
[_ {:keys [receiver uri] :as cfg}]
(when uri
(l/info :msg "initializing mattermost error reporter" :uri uri)
(let [output (a/chan (a/sliding-buffer 128)
(filter (fn [event]
(= (:logger/level event) "error"))))]
(receiver :sub output)
(a/go-loop []
(let [msg (a/<! output)]
(if (nil? msg)
(l/info :msg "stopping error reporting loop")
(do
(a/<! (handle-event cfg msg))
(recur)))))
output)))
(defmethod ig/halt-key! ::reporter
[_ thread]
(some-> thread px/interrupt!))
[_ output]
(when output
(a/close! output)))

View File

@@ -1,186 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.loggers.webhooks
"A mattermost integration for error reporting."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.uri :as uri]
[app.config :as cf]
[app.db :as db]
[app.http.client :as http]
[app.util.json :as json]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]))
;; --- HELPERS
(defn key-fn
[k & keys]
(fn [params]
(reduce #(dm/str %1 ":" (get params %2))
(dm/str (get params k))
keys)))
;; --- PROC
(defn- lookup-webhooks-by-team
[pool team-id]
(db/exec! pool ["select w.* from webhook as w where team_id=? and is_active=true" team-id]))
(defn- lookup-webhooks-by-project
[pool project-id]
(let [sql [(str "select w.* from webhook as w"
" join project as p on (p.team_id = w.team_id)"
" where p.id = ? and w.is_active = true")
project-id]]
(db/exec! pool sql)))
(defn- lookup-webhooks-by-file
[pool file-id]
(let [sql [(str "select w.* from webhook as w"
" join project as p on (p.team_id = w.team_id)"
" join file as f on (f.project_id = p.id)"
" where f.id = ? and w.is_active = true")
file-id]]
(db/exec! pool sql)))
(defn- lookup-webhooks
[{:keys [::db/pool]} {:keys [props] :as event}]
(or (some->> (:team-id props) (lookup-webhooks-by-team pool))
(some->> (:project-id props) (lookup-webhooks-by-project pool))
(some->> (:file-id props) (lookup-webhooks-by-file pool))))
(defmethod ig/pre-init-spec ::process-event-handler [_]
(s/keys :req [::db/pool]))
(defmethod ig/init-key ::process-event-handler
[_ {:keys [::db/pool] :as cfg}]
(fn [{:keys [props] :as task}]
(let [event (::event props)]
(l/debug :hint "process webhook event"
:name (:name event))
(when-let [items (lookup-webhooks cfg event)]
(l/trace :hint "webhooks found for event" :total (count items))
(db/with-atomic [conn pool]
(doseq [item items]
(wrk/submit! ::wrk/conn conn
::wrk/task :run-webhook
::wrk/queue :webhooks
::wrk/max-retries 3
::event event
::config item)))))))
;; --- RUN
(declare interpret-exception)
(declare interpret-response)
(def ^:private json-mapper
(json/mapper
{:encode-key-fn str/camel
:decode-key-fn (comp keyword str/kebab)
:pretty true}))
(defmethod ig/pre-init-spec ::run-webhook-handler [_]
(s/keys :req [::http/client ::db/pool]))
(defmethod ig/prep-key ::run-webhook-handler
[_ cfg]
(merge {::max-errors 3} (d/without-nils cfg)))
(defmethod ig/init-key ::run-webhook-handler
[_ {:keys [::db/pool ::max-errors] :as cfg}]
(letfn [(update-webhook! [whook err]
(if err
(let [sql [(str "update webhook "
" set error_code=?, "
" error_count=error_count+1 "
" where id=?")
err
(:id whook)]
res (db/exec-one! pool sql {::db/return-keys? true})]
(when (>= (:error-count res) max-errors)
(db/update! pool :webhook {:is-active false} {:id (:id whook)})))
(db/update! pool :webhook
{:updated-at (dt/now)
:error-code nil
:error-count 0}
{:id (:id whook)})))
(report-delivery! [whook req rsp err]
(db/insert! pool :webhook-delivery
{:webhook-id (:id whook)
:created-at (dt/now)
:error-code err
:req-data (db/tjson req)
:rsp-data (db/tjson rsp)}))]
(fn [{:keys [props] :as task}]
(let [event (::event props)
whook (::config props)
body (case (:mtype whook)
"application/json" (json/encode-str event json-mapper)
"application/transit+json" (t/encode-str event)
"application/x-www-form-urlencoded" (uri/map->query-string event))]
(l/debug :hint "run webhook"
:event-name (:name event)
:webhook-id (:id whook)
:webhook-uri (:uri whook)
:webhook-mtype (:mtype whook))
(let [req {:uri (:uri whook)
:headers {"content-type" (:mtype whook)
"user-agent" (str/ffmt "penpot/%" (:main cf/version))}
:timeout (dt/duration "4s")
:method :post
:body body}]
(try
(let [rsp (http/req! cfg req {:response-type :input-stream :sync? true})
err (interpret-response rsp)]
(report-delivery! whook req rsp err)
(update-webhook! whook err))
(catch Throwable cause
(let [err (interpret-exception cause)]
(report-delivery! whook req nil err)
(update-webhook! whook err)
(when (= err "unknown")
(l/error :hint "unknown error on webhook request"
:cause cause))))))))))
(defn interpret-response
[{:keys [status] :as response}]
(when-not (or (= 200 status)
(= 204 status))
(str/ffmt "unexpected-status:%" status)))
(defn interpret-exception
[cause]
(cond
(instance? javax.net.ssl.SSLHandshakeException cause)
"ssl-validation-error"
(instance? java.net.ConnectException cause)
"connection-error"
(instance? java.lang.IllegalArgumentException cause)
"invalid-uri"
(instance? java.net.http.HttpConnectTimeoutException cause)
"timeout"
))

View File

@@ -0,0 +1,110 @@
;; 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.loggers.zmq
"A generic ZMQ listener."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.util.json :as json]
[app.util.time :as dt]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig])
(:import
org.zeromq.SocketType
org.zeromq.ZMQ$Socket
org.zeromq.ZContext))
(declare prepare)
(declare start-rcv-loop)
(s/def ::endpoint ::us/string)
(defmethod ig/pre-init-spec ::receiver [_]
(s/keys :opt-un [::endpoint]))
(defmethod ig/init-key ::receiver
[_ {:keys [endpoint] :as cfg}]
(l/info :msg "initializing ZMQ receiver" :bind endpoint)
(let [buffer (a/chan 1)
output (a/chan 1 (comp (filter map?)
(keep prepare)))
mult (a/mult output)]
(when endpoint
(let [thread (Thread. #(start-rcv-loop {:out buffer :endpoint endpoint}))]
(.setDaemon thread false)
(.setName thread "penpot/zmq-logger-receiver")
(.start thread)))
(a/pipe buffer output)
(with-meta
(fn [cmd ch]
(case cmd
:sub (a/tap mult ch)
:unsub (a/untap mult ch))
ch)
{::output output
::buffer buffer
::mult mult})))
(defmethod ig/halt-key! ::receiver
[_ f]
(a/close! (::buffer (meta f))))
(def ^:private json-mapper
(json/mapper
{:encode-key-fn str/camel
:decode-key-fn (comp keyword str/kebab)}))
(defn- start-rcv-loop
([] (start-rcv-loop nil))
([{:keys [out endpoint] :or {endpoint "tcp://localhost:5556"}}]
(let [out (or out (a/chan 1))
zctx (ZContext. 1)
socket (.. zctx (createSocket SocketType/SUB))]
(.. socket (connect ^String endpoint))
(.. socket (subscribe ""))
(.. socket (setReceiveTimeOut 5000))
(loop []
(let [msg (.recv ^ZMQ$Socket socket)
msg (ex/ignoring (json/read msg json-mapper))
msg (if (nil? msg) :empty msg)]
(if (a/>!! out msg)
(recur)
(do
(.close ^java.lang.AutoCloseable socket)
(.destroy ^ZContext zctx))))))))
(s/def ::logger-name string?)
(s/def ::level string?)
(s/def ::thread string?)
(s/def ::time-millis integer?)
(s/def ::message string?)
(s/def ::context-map map?)
(s/def ::thrown map?)
(s/def ::log4j-event
(s/keys :req-un [::logger-name ::level ::thread ::time-millis ::message]
:opt-un [::context-map ::thrown]))
(defn- prepare
[event]
(if (s/valid? ::log4j-event event)
(merge {:message (:message event)
:created-at (dt/instant (:time-millis event))
:logger/name (:logger-name event)
:logger/level (str/lower (:level event))}
(when-let [trace (-> event :thrown :extended-stack-trace)]
{:trace trace})
(:context-map event))
(do
(l/warn :hint "invalid event" :event event)
nil)))

View File

@@ -6,246 +6,108 @@
(ns app.main
(:require
[app.auth.ldap :as-alias ldap]
[app.auth.oidc :as-alias oidc]
[app.auth.oidc.providers :as-alias oidc.providers]
[app.auth.oidc]
[app.common.logging :as l]
[app.config :as cf]
[app.db :as-alias db]
[app.email :as-alias email]
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.assets :as-alias http.assets]
[app.http.awsns :as http.awsns]
[app.http.client :as-alias http.client]
[app.http.debug :as-alias http.debug]
[app.http.session :as-alias session]
[app.http.session.tasks :as-alias session.tasks]
[app.http.websocket :as http.ws]
[app.loggers.audit.tasks :as-alias audit.tasks]
[app.loggers.webhooks :as-alias webhooks]
[app.metrics :as-alias mtx]
[app.metrics.definition :as-alias mdef]
[app.msgbus :as-alias mbus]
[app.redis :as-alias rds]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias rpc.doc]
[app.srepl :as-alias srepl]
[app.storage :as-alias sto]
[app.storage.fs :as-alias sto.fs]
[app.storage.s3 :as-alias sto.s3]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[cuerdas.core :as str]
[integrant.core :as ig])
(:gen-class))
(def default-metrics
{:update-file-changes
{::mdef/name "penpot_rpc_update_file_changes_total"
::mdef/help "A total number of changes submitted to update-file."
::mdef/type :counter}
:update-file-bytes-processed
{::mdef/name "penpot_rpc_update_file_bytes_processed_total"
::mdef/help "A total number of bytes processed by update-file."
::mdef/type :counter}
:rpc-mutation-timing
{::mdef/name "penpot_rpc_mutation_timing"
::mdef/help "RPC mutation method call timing."
::mdef/labels ["name"]
::mdef/type :histogram}
:rpc-command-timing
{::mdef/name "penpot_rpc_command_timing"
::mdef/help "RPC command method call timing."
::mdef/labels ["name"]
::mdef/type :histogram}
:rpc-query-timing
{::mdef/name "penpot_rpc_query_timing"
::mdef/help "RPC query method call timing."
::mdef/labels ["name"]
::mdef/type :histogram}
:websocket-active-connections
{::mdef/name "penpot_websocket_active_connections"
::mdef/help "Active websocket connections gauge"
::mdef/type :gauge}
:websocket-messages-total
{::mdef/name "penpot_websocket_message_total"
::mdef/help "Counter of processed messages."
::mdef/labels ["op"]
::mdef/type :counter}
:websocket-session-timing
{::mdef/name "penpot_websocket_session_timing"
::mdef/help "Websocket session timing (seconds)."
::mdef/type :summary}
:session-update-total
{::mdef/name "penpot_http_session_update_total"
::mdef/help "A counter of session update batch events."
::mdef/type :counter}
:tasks-timing
{::mdef/name "penpot_tasks_timing"
::mdef/help "Background tasks timing (milliseconds)."
::mdef/labels ["name"]
::mdef/type :summary}
:redis-eval-timing
{::mdef/name "penpot_redis_eval_timing"
::mdef/help "Redis EVAL commands execution timings (ms)"
::mdef/labels ["name"]
::mdef/type :summary}
:rpc-climit-queue-size
{::mdef/name "penpot_rpc_climit_queue_size"
::mdef/help "Current number of queued submissions on the CLIMIT."
::mdef/labels ["name"]
::mdef/type :gauge}
:rpc-climit-concurrency
{::mdef/name "penpot_rpc_climit_concurrency"
::mdef/help "Current number of used concurrency capacity on the CLIMIT"
::mdef/labels ["name"]
::mdef/type :gauge}
:rpc-climit-timing
{::mdef/name "penpot_rpc_climit_timing"
::mdef/help "Summary of the time between queuing and executing on the CLIMIT"
::mdef/labels ["name"]
::mdef/type :summary}
:audit-http-handler-queue-size
{::mdef/name "penpot_audit_http_handler_queue_size"
::mdef/help "Current number of queued submissions on the audit log http handler"
::mdef/labels []
::mdef/type :gauge}
:audit-http-handler-concurrency
{::mdef/name "penpot_audit_http_handler_concurrency"
::mdef/help "Current number of used concurrency capacity on the audit log http handler"
::mdef/labels []
::mdef/type :gauge}
:audit-http-handler-timing
{::mdef/name "penpot_audit_http_handler_timing"
::mdef/help "Summary of the time between queuing and executing on the audit log http handler"
::mdef/labels []
::mdef/type :summary}
:executors-active-threads
{::mdef/name "penpot_executors_active_threads"
::mdef/help "Current number of threads available in the executor service."
::mdef/labels ["name"]
::mdef/type :gauge}
:executors-completed-tasks
{::mdef/name "penpot_executors_completed_tasks_total"
::mdef/help "Approximate number of completed tasks by the executor."
::mdef/labels ["name"]
::mdef/type :counter}
:executors-running-threads
{::mdef/name "penpot_executors_running_threads"
::mdef/help "Current number of threads with state RUNNING."
::mdef/labels ["name"]
::mdef/type :gauge}
:executors-queued-submissions
{::mdef/name "penpot_executors_queued_submissions"
::mdef/help "Current number of queued submissions."
::mdef/labels ["name"]
::mdef/type :gauge}})
(def system-config
{::db/pool
{::db/uri (cf/get :database-uri)
::db/username (cf/get :database-username)
::db/password (cf/get :database-password)
::db/read-only? (cf/get :database-readonly false)
::db/min-size (cf/get :database-min-pool-size 0)
::db/max-size (cf/get :database-max-pool-size 60)
::mtx/metrics (ig/ref ::mtx/metrics)}
{:app.db/pool
{:uri (cf/get :database-uri)
:username (cf/get :database-username)
:password (cf/get :database-password)
:read-only (cf/get :database-readonly false)
:metrics (ig/ref :app.metrics/metrics)
:migrations (ig/ref :app.migrations/all)
:name :main
:min-size (cf/get :database-min-pool-size 0)
:max-size (cf/get :database-max-pool-size 60)}
;; Default thread pool for IO operations
::wrk/executor
{::wrk/parallelism (cf/get :default-executor-parallelism 100)}
[::default :app.worker/executor]
{:parallelism (cf/get :default-executor-parallelism 70)}
::wrk/scheduled-executor
{::wrk/parallelism (cf/get :scheduled-executor-parallelism 20)}
;; Dedicated thread pool for background tasks execution.
[::worker :app.worker/executor]
{:parallelism (cf/get :worker-executor-parallelism 20)}
::wrk/monitor
{::mtx/metrics (ig/ref ::mtx/metrics)
::wrk/name "default"
::wrk/executor (ig/ref ::wrk/executor)}
:app.worker/scheduler
{:parallelism 1
:prefix :scheduler}
:app.worker/executors
{:default (ig/ref [::default :app.worker/executor])
:worker (ig/ref [::worker :app.worker/executor])}
:app.worker/executor-monitor
{:metrics (ig/ref :app.metrics/metrics)
:executors (ig/ref :app.worker/executors)}
:app.migrations/migrations
{::db/pool (ig/ref ::db/pool)}
{}
::mtx/metrics
{:default default-metrics}
:app.metrics/metrics
{}
::mtx/routes
{::mtx/metrics (ig/ref ::mtx/metrics)}
:app.migrations/all
{:main (ig/ref :app.migrations/migrations)}
::rds/redis
{::rds/uri (cf/get :redis-uri)
::mtx/metrics (ig/ref ::mtx/metrics)}
:app.redis/redis
{:uri (cf/get :redis-uri)
:metrics (ig/ref :app.metrics/metrics)}
::mbus/msgbus
:app.msgbus/msgbus
{:backend (cf/get :msgbus-backend :redis)
:executor (ig/ref ::wrk/executor)
:redis (ig/ref ::rds/redis)}
:executor (ig/ref [::default :app.worker/executor])
:redis (ig/ref :app.redis/redis)}
:app.storage.tmp/cleaner
{::wrk/executor (ig/ref ::wrk/executor)
::wrk/scheduled-executor (ig/ref ::wrk/scheduled-executor)}
{:executor (ig/ref [::worker :app.worker/executor])
:scheduler (ig/ref :app.worker/scheduler)}
::sto/gc-deleted-task
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
:app.storage/gc-deleted-task
{:pool (ig/ref :app.db/pool)
:storage (ig/ref :app.storage/storage)
:executor (ig/ref [::worker :app.worker/executor])}
::sto/gc-touched-task
{::db/pool (ig/ref ::db/pool)}
:app.storage/gc-touched-task
{:pool (ig/ref :app.db/pool)}
::http.client/client
{::wrk/executor (ig/ref ::wrk/executor)}
:app.http/client
{:executor (ig/ref [::default :app.worker/executor])}
::session/manager
{::db/pool (ig/ref ::db/pool)
::wrk/executor (ig/ref ::wrk/executor)
::props (ig/ref :app.setup/props)}
:app.http/session
{:store (ig/ref :app.http.session/store)}
::actoken/manager
{::db/pool (ig/ref ::db/pool)
::wrk/executor (ig/ref ::wrk/executor)
::props (ig/ref :app.setup/props)}
:app.http.session/store
{:pool (ig/ref :app.db/pool)
:sprops (ig/ref :app.setup/props)
:executor (ig/ref [::default :app.worker/executor])}
::session.tasks/gc
{::db/pool (ig/ref ::db/pool)}
:app.http.session/gc-task
{:pool (ig/ref :app.db/pool)
:max-age (cf/get :auth-token-cookie-max-age)}
::http.awsns/routes
{::props (ig/ref :app.setup/props)
::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)
::wrk/executor (ig/ref ::wrk/executor)}
:app.http.awsns/handler
{:sprops (ig/ref :app.setup/props)
:pool (ig/ref :app.db/pool)
:http-client (ig/ref :app.http/client)
:executor (ig/ref [::worker :app.worker/executor])}
::http/server
{::http/port (cf/get :http-server-port)
::http/host (cf/get :http-server-host)
::http/router (ig/ref ::http/router)
::http/metrics (ig/ref ::mtx/metrics)
::http/executor (ig/ref ::wrk/executor)
::http/io-threads (cf/get :http-server-io-threads)
::http/max-body-size (cf/get :http-server-max-body-size)
::http/max-multipart-body-size (cf/get :http-server-max-multipart-body-size)}
:app.http/server
{:port (cf/get :http-server-port)
:host (cf/get :http-server-host)
:router (ig/ref :app.http/router)
:metrics (ig/ref :app.metrics/metrics)
:executor (ig/ref [::default :app.worker/executor])
:io-threads (cf/get :http-server-io-threads)
:max-body-size (cf/get :http-server-max-body-size)
:max-multipart-body-size (cf/get :http-server-max-multipart-body-size)}
::ldap/provider
:app.auth.ldap/provider
{:host (cf/get :ldap-host)
:port (cf/get :ldap-port)
:ssl (cf/get :ldap-ssl)
@@ -259,219 +121,252 @@
:bind-password (cf/get :ldap-bind-password)
:enabled? (contains? cf/flags :login-with-ldap)}
::oidc.providers/google
{}
:app.auth.oidc/google-provider
{:enabled? (contains? cf/flags :login-with-google)
:client-id (cf/get :google-client-id)
:client-secret (cf/get :google-client-secret)}
::oidc.providers/github
{::http.client/client (ig/ref ::http.client/client)}
:app.auth.oidc/github-provider
{:enabled? (contains? cf/flags :login-with-github)
:http-client (ig/ref :app.http/client)
:client-id (cf/get :github-client-id)
:client-secret (cf/get :github-client-secret)}
::oidc.providers/gitlab
{}
:app.auth.oidc/gitlab-provider
{:enabled? (contains? cf/flags :login-with-gitlab)
:base-uri (cf/get :gitlab-base-uri "https://gitlab.com")
:client-id (cf/get :gitlab-client-id)
:client-secret (cf/get :gitlab-client-secret)}
::oidc.providers/generic
{::http.client/client (ig/ref ::http.client/client)}
:app.auth.oidc/generic-provider
{:enabled? (contains? cf/flags :login-with-oidc)
:http-client (ig/ref :app.http/client)
::oidc/routes
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::props (ig/ref :app.setup/props)
::wrk/executor (ig/ref ::wrk/executor)
::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)}
::session/manager (ig/ref ::session/manager)}
:client-id (cf/get :oidc-client-id)
:client-secret (cf/get :oidc-client-secret)
:base-uri (cf/get :oidc-base-uri)
:token-uri (cf/get :oidc-token-uri)
:auth-uri (cf/get :oidc-auth-uri)
:user-uri (cf/get :oidc-user-uri)
:scopes (cf/get :oidc-scopes)
:roles-attr (cf/get :oidc-roles-attr)
:roles (cf/get :oidc-roles)}
:app.auth.oidc/routes
{:providers {:google (ig/ref :app.auth.oidc/google-provider)
:github (ig/ref :app.auth.oidc/github-provider)
:gitlab (ig/ref :app.auth.oidc/gitlab-provider)
:oidc (ig/ref :app.auth.oidc/generic-provider)}
:sprops (ig/ref :app.setup/props)
:http-client (ig/ref :app.http/client)
:pool (ig/ref :app.db/pool)
:session (ig/ref :app.http/session)
:public-uri (cf/get :public-uri)
:executor (ig/ref [::default :app.worker/executor])}
;; TODO: revisit the dependencies of this service, looks they are too much unused of them
:app.http/router
{::session/manager (ig/ref ::session/manager)
::actoken/manager (ig/ref ::actoken/manager)
::wrk/executor (ig/ref ::wrk/executor)
::db/pool (ig/ref ::db/pool)
::rpc/routes (ig/ref ::rpc/routes)
::rpc.doc/routes (ig/ref ::rpc.doc/routes)
::props (ig/ref :app.setup/props)
::mtx/routes (ig/ref ::mtx/routes)
::oidc/routes (ig/ref ::oidc/routes)
::http.debug/routes (ig/ref ::http.debug/routes)
::http.assets/routes (ig/ref ::http.assets/routes)
::http.ws/routes (ig/ref ::http.ws/routes)
::http.awsns/routes (ig/ref ::http.awsns/routes)}
{:assets (ig/ref :app.http.assets/handlers)
:feedback (ig/ref :app.http.feedback/handler)
:session (ig/ref :app.http/session)
:awsns-handler (ig/ref :app.http.awsns/handler)
:debug-routes (ig/ref :app.http.debug/routes)
:oidc-routes (ig/ref :app.auth.oidc/routes)
:ws (ig/ref :app.http.websocket/handler)
:metrics (ig/ref :app.metrics/metrics)
:public-uri (cf/get :public-uri)
:storage (ig/ref :app.storage/storage)
:audit-handler (ig/ref :app.loggers.audit/http-handler)
:rpc-routes (ig/ref :app.rpc/routes)
:doc-routes (ig/ref :app.rpc.doc/routes)
:executor (ig/ref [::default :app.worker/executor])}
:app.http.debug/routes
{::db/pool (ig/ref ::db/pool)
::wrk/executor (ig/ref ::wrk/executor)
::session/manager (ig/ref ::session/manager)
::sto/storage (ig/ref ::sto/storage)}
{:pool (ig/ref :app.db/pool)
:executor (ig/ref [::worker :app.worker/executor])
:storage (ig/ref :app.storage/storage)
:session (ig/ref :app.http/session)}
:app.http.websocket/routes
{::db/pool (ig/ref ::db/pool)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref :app.msgbus/msgbus)
::session/manager (ig/ref ::session/manager)}
:app.http.websocket/handler
{:pool (ig/ref :app.db/pool)
:metrics (ig/ref :app.metrics/metrics)
:msgbus (ig/ref :app.msgbus/msgbus)}
:app.http.assets/routes
{::http.assets/path (cf/get :assets-path)
::http.assets/cache-max-age (dt/duration {:hours 24})
::http.assets/cache-max-agesignature-max-age (dt/duration {:hours 24 :minutes 5})
::sto/storage (ig/ref ::sto/storage)
::wrk/executor (ig/ref ::wrk/executor)}
:app.http.assets/handlers
{:metrics (ig/ref :app.metrics/metrics)
:assets-path (cf/get :assets-path)
:storage (ig/ref :app.storage/storage)
:executor (ig/ref [::default :app.worker/executor])
:cache-max-age (dt/duration {:hours 24})
:signature-max-age (dt/duration {:hours 24 :minutes 5})}
:app.rpc/climit
{::mtx/metrics (ig/ref ::mtx/metrics)
::wrk/executor (ig/ref ::wrk/executor)}
:app.http.feedback/handler
{:pool (ig/ref :app.db/pool)
:executor (ig/ref [::default :app.worker/executor])}
:app.rpc/semaphores
{:metrics (ig/ref :app.metrics/metrics)
:executor (ig/ref [::default :app.worker/executor])}
:app.rpc/rlimit
{::wrk/executor (ig/ref ::wrk/executor)
::wrk/scheduled-executor (ig/ref ::wrk/scheduled-executor)}
{:executor (ig/ref [::worker :app.worker/executor])
:scheduler (ig/ref :app.worker/scheduler)}
:app.rpc/methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::wrk/executor (ig/ref ::wrk/executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::rds/redis (ig/ref ::rds/redis)
::rpc/climit (ig/ref ::rpc/climit)
::rpc/rlimit (ig/ref ::rpc/rlimit)
::props (ig/ref :app.setup/props)
:pool (ig/ref ::db/pool)
:templates (ig/ref :app.setup/builtin-templates)
{:pool (ig/ref :app.db/pool)
:session (ig/ref :app.http/session)
:sprops (ig/ref :app.setup/props)
:metrics (ig/ref :app.metrics/metrics)
:storage (ig/ref :app.storage/storage)
:msgbus (ig/ref :app.msgbus/msgbus)
:public-uri (cf/get :public-uri)
:redis (ig/ref :app.redis/redis)
:audit (ig/ref :app.loggers.audit/collector)
:ldap (ig/ref :app.auth.ldap/provider)
:http-client (ig/ref :app.http/client)
:rlimit (ig/ref :app.rpc/rlimit)
:executors (ig/ref :app.worker/executors)
:executor (ig/ref [::default :app.worker/executor])
:templates (ig/ref :app.setup/builtin-templates)
:semaphores (ig/ref :app.rpc/semaphores)
}
:app.rpc.doc/routes
{:methods (ig/ref :app.rpc/methods)}
:app.rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods)
::db/pool (ig/ref ::db/pool)
::wrk/executor (ig/ref ::wrk/executor)
::session/manager (ig/ref ::session/manager)
::actoken/manager (ig/ref ::actoken/manager)
::props (ig/ref :app.setup/props)}
{:methods (ig/ref :app.rpc/methods)}
::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics)
::wrk/tasks
{:sendmail (ig/ref ::email/handler)
:app.worker/registry
{:metrics (ig/ref :app.metrics/metrics)
:tasks
{:sendmail (ig/ref :app.emails/handler)
:objects-gc (ig/ref :app.tasks.objects-gc/handler)
:file-gc (ig/ref :app.tasks.file-gc/handler)
:file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler)
:storage-gc-deleted (ig/ref ::sto/gc-deleted-task)
:storage-gc-touched (ig/ref ::sto/gc-touched-task)
:storage-gc-deleted (ig/ref :app.storage/gc-deleted-task)
:storage-gc-touched (ig/ref :app.storage/gc-touched-task)
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
:telemetry (ig/ref :app.tasks.telemetry/handler)
:session-gc (ig/ref ::session.tasks/gc)
:audit-log-archive (ig/ref ::audit.tasks/archive)
:audit-log-gc (ig/ref ::audit.tasks/gc)
:session-gc (ig/ref :app.http.session/gc-task)
:audit-log-archive (ig/ref :app.loggers.audit/archive-task)
:audit-log-gc (ig/ref :app.loggers.audit/gc-task)}}
:process-webhook-event
(ig/ref ::webhooks/process-event-handler)
:run-webhook
(ig/ref ::webhooks/run-webhook-handler)}}
::email/sendmail
{::email/host (cf/get :smtp-host)
::email/port (cf/get :smtp-port)
::email/ssl (cf/get :smtp-ssl)
::email/tls (cf/get :smtp-tls)
::email/username (cf/get :smtp-username)
::email/password (cf/get :smtp-password)
::email/default-reply-to (cf/get :smtp-default-reply-to)
::email/default-from (cf/get :smtp-default-from)}
:app.emails/sendmail
{:host (cf/get :smtp-host)
:port (cf/get :smtp-port)
:ssl (cf/get :smtp-ssl)
:tls (cf/get :smtp-tls)
:username (cf/get :smtp-username)
:password (cf/get :smtp-password)
:default-reply-to (cf/get :smtp-default-reply-to)
:default-from (cf/get :smtp-default-from)}
::email/handler
{::email/sendmail (ig/ref ::email/sendmail)
::mtx/metrics (ig/ref ::mtx/metrics)}
:app.emails/handler
{:sendmail (ig/ref :app.emails/sendmail)
:metrics (ig/ref :app.metrics/metrics)}
:app.tasks.tasks-gc/handler
{::db/pool (ig/ref ::db/pool)}
{:pool (ig/ref :app.db/pool)
:max-age cf/deletion-delay}
:app.tasks.objects-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
{:pool (ig/ref :app.db/pool)
:storage (ig/ref :app.storage/storage)}
:app.tasks.file-gc/handler
{::db/pool (ig/ref ::db/pool)}
{:pool (ig/ref :app.db/pool)}
:app.tasks.file-xlog-gc/handler
{::db/pool (ig/ref ::db/pool)}
{:pool (ig/ref :app.db/pool)}
:app.tasks.telemetry/handler
{::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)
::props (ig/ref :app.setup/props)}
{:pool (ig/ref :app.db/pool)
:version (:full cf/version)
:uri (cf/get :telemetry-uri)
:sprops (ig/ref :app.setup/props)
:http-client (ig/ref :app.http/client)}
[::srepl/urepl ::srepl/server]
{::srepl/port (cf/get :urepl-port 6062)
::srepl/host (cf/get :urepl-host "localhost")}
[::srepl/prepl ::srepl/server]
{::srepl/port (cf/get :prepl-port 6063)
::srepl/host (cf/get :prepl-host "localhost")}
:app.srepl/server
{:port (cf/get :srepl-port)
:host (cf/get :srepl-host)}
:app.setup/builtin-templates
{::http.client/client (ig/ref ::http.client/client)}
{:http-client (ig/ref :app.http/client)}
:app.setup/props
{::db/pool (ig/ref ::db/pool)
::key (cf/get :secret-key)
{:pool (ig/ref :app.db/pool)
:key (cf/get :secret-key)}
;; NOTE: this dependency is only necessary for proper initialization ordering, props
;; module requires the migrations to run before initialize.
::migrations (ig/ref :app.migrations/migrations)}
:app.loggers.zmq/receiver
{:endpoint (cf/get :loggers-zmq-uri)}
::audit.tasks/archive
{::props (ig/ref :app.setup/props)
::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)}
:app.loggers.audit/http-handler
{:pool (ig/ref :app.db/pool)
:executor (ig/ref [::default :app.worker/executor])}
::audit.tasks/gc
{::db/pool (ig/ref ::db/pool)}
:app.loggers.audit/collector
{:pool (ig/ref :app.db/pool)
:executor (ig/ref [::worker :app.worker/executor])}
::webhooks/process-event-handler
{::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)}
:app.loggers.audit/archive-task
{:uri (cf/get :audit-log-archive-uri)
:sprops (ig/ref :app.setup/props)
:pool (ig/ref :app.db/pool)
:http-client (ig/ref :app.http/client)}
::webhooks/run-webhook-handler
{::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)}
:app.loggers.audit/gc-task
{:pool (ig/ref :app.db/pool)}
:app.loggers.loki/reporter
{:uri (cf/get :loggers-loki-uri)
:receiver (ig/ref :app.loggers.zmq/receiver)
:http-client (ig/ref :app.http/client)}
:app.loggers.mattermost/reporter
{::http.client/client (ig/ref ::http.client/client)}
{:uri (cf/get :error-report-webhook)
:receiver (ig/ref :app.loggers.zmq/receiver)
:http-client (ig/ref :app.http/client)}
:app.loggers.database/reporter
{::db/pool (ig/ref ::db/pool)}
{:receiver (ig/ref :app.loggers.zmq/receiver)
:pool (ig/ref :app.db/pool)
:executor (ig/ref [::worker :app.worker/executor])}
::sto/storage
{::db/pool (ig/ref ::db/pool)
::wrk/executor (ig/ref ::wrk/executor)
::sto/backends
:app.storage/storage
{:pool (ig/ref :app.db/pool)
:executor (ig/ref [::default :app.worker/executor])
:backends
{:assets-s3 (ig/ref [::assets :app.storage.s3/backend])
:assets-fs (ig/ref [::assets :app.storage.fs/backend])}}
:assets-fs (ig/ref [::assets :app.storage.fs/backend])
;; keep this for backward compatibility
:s3 (ig/ref [::assets :app.storage.s3/backend])
:fs (ig/ref [::assets :app.storage.fs/backend])}}
[::assets :app.storage.s3/backend]
{::sto.s3/region (cf/get :storage-assets-s3-region)
::sto.s3/endpoint (cf/get :storage-assets-s3-endpoint)
::sto.s3/bucket (cf/get :storage-assets-s3-bucket)
::wrk/executor (ig/ref ::wrk/executor)}
{:region (cf/get :storage-assets-s3-region)
:endpoint (cf/get :storage-assets-s3-endpoint)
:bucket (cf/get :storage-assets-s3-bucket)
:executor (ig/ref [::default :app.worker/executor])}
[::assets :app.storage.fs/backend]
{::sto.fs/directory (cf/get :storage-assets-fs-directory)}
{:directory (cf/get :storage-assets-fs-directory)}
})
(def worker-config
{::wrk/cron
{::wrk/scheduled-executor (ig/ref ::wrk/scheduled-executor)
::wrk/registry (ig/ref ::wrk/registry)
::db/pool (ig/ref ::db/pool)
::wrk/entries
{:app.worker/cron
{:executor (ig/ref [::worker :app.worker/executor])
:scheduler (ig/ref :app.worker/scheduler)
:tasks (ig/ref :app.worker/registry)
:pool (ig/ref :app.db/pool)
:entries
[{:cron #app/cron "0 0 * * * ?" ;; hourly
:task :file-xlog-gc}
@@ -504,27 +399,11 @@
{:cron #app/cron "30 */5 * * * ?" ;; every 5m
:task :audit-log-gc})]}
::wrk/dispatcher
{::rds/redis (ig/ref ::rds/redis)
::mtx/metrics (ig/ref ::mtx/metrics)
::db/pool (ig/ref ::db/pool)}
[::default ::wrk/worker]
{::wrk/parallelism (cf/get ::worker-default-parallelism 1)
::wrk/queue :default
::rds/redis (ig/ref ::rds/redis)
::wrk/registry (ig/ref ::wrk/registry)
::mtx/metrics (ig/ref ::mtx/metrics)
::db/pool (ig/ref ::db/pool)}
[::webhook ::wrk/worker]
{::wrk/parallelism (cf/get ::worker-webhook-parallelism 1)
::wrk/queue :webhooks
::rds/redis (ig/ref ::rds/redis)
::wrk/registry (ig/ref ::wrk/registry)
::mtx/metrics (ig/ref ::mtx/metrics)
::db/pool (ig/ref ::db/pool)}})
:app.worker/worker
{:executor (ig/ref [::worker :app.worker/executor])
:tasks (ig/ref :app.worker/registry)
:metrics (ig/ref :app.metrics/metrics)
:pool (ig/ref :app.db/pool)}})
(def system nil)
@@ -538,7 +417,7 @@
(merge worker-config))
(ig/prep)
(ig/init))))
(l/info :hint "welcome to penpot"
(l/info :msg "welcome to penpot"
:flags (str/join "," (map name cf/flags))
:worker? (contains? cf/flags :backend-worker)
:version (:full cf/version)))
@@ -551,9 +430,4 @@
(defn -main
[& _args]
(try
(start)
(catch Throwable cause
(l/error :hint (ex-message cause)
:cause cause)
(System/exit -1))))
(start))

View File

@@ -12,8 +12,6 @@
[app.common.media :as cm]
[app.common.spec :as us]
[app.config :as cf]
[app.db :as-alias db]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[app.util.svg :as svg]
[buddy.core.bytes :as bb]
@@ -299,7 +297,8 @@
"Given storage map, returns a storage configured with the appropriate
backend for assets and optional connection attached."
([storage]
(assoc storage ::sto/backend (cf/get :assets-storage-backend :assets-fs)))
([storage pool-or-conn]
(-> (configure-assets-storage storage)
(assoc ::db/pool-or-conn pool-or-conn))))
(assoc storage :backend (cf/get :assets-storage-backend :assets-fs)))
([storage conn]
(-> storage
(assoc :conn conn)
(assoc :backend (cf/get :assets-storage-backend :assets-fs)))))

View File

@@ -38,6 +38,110 @@
;; METRICS SERVICE PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def default-metrics
{:update-file-changes
{::mdef/name "penpot_rpc_update_file_changes_total"
::mdef/help "A total number of changes submitted to update-file."
::mdef/type :counter}
:update-file-bytes-processed
{::mdef/name "penpot_rpc_update_file_bytes_processed_total"
::mdef/help "A total number of bytes processed by update-file."
::mdef/type :counter}
:rpc-mutation-timing
{::mdef/name "penpot_rpc_mutation_timing"
::mdef/help "RPC mutation method call timing."
::mdef/labels ["name"]
::mdef/type :histogram}
:rpc-command-timing
{::mdef/name "penpot_rpc_command_timing"
::mdef/help "RPC command method call timing."
::mdef/labels ["name"]
::mdef/type :histogram}
:rpc-query-timing
{::mdef/name "penpot_rpc_query_timing"
::mdef/help "RPC query method call timing."
::mdef/labels ["name"]
::mdef/type :histogram}
:websocket-active-connections
{::mdef/name "penpot_websocket_active_connections"
::mdef/help "Active websocket connections gauge"
::mdef/type :gauge}
:websocket-messages-total
{::mdef/name "penpot_websocket_message_total"
::mdef/help "Counter of processed messages."
::mdef/labels ["op"]
::mdef/type :counter}
:websocket-session-timing
{::mdef/name "penpot_websocket_session_timing"
::mdef/help "Websocket session timing (seconds)."
::mdef/type :summary}
:session-update-total
{::mdef/name "penpot_http_session_update_total"
::mdef/help "A counter of session update batch events."
::mdef/type :counter}
:tasks-timing
{::mdef/name "penpot_tasks_timing"
::mdef/help "Background tasks timing (milliseconds)."
::mdef/labels ["name"]
::mdef/type :summary}
:redis-eval-timing
{::mdef/name "penpot_redis_eval_timing"
::mdef/help "Redis EVAL commands execution timings (ms)"
::mdef/labels ["name"]
::mdef/type :summary}
:semaphore-queued-submissions
{::mdef/name "penpot_semaphore_queued_submissions"
::mdef/help "Current number of queued submissions on SEMAPHORE."
::mdef/labels ["name"]
::mdef/type :gauge}
:semaphore-used-permits
{::mdef/name "penpot_semaphore_used_permits"
::mdef/help "Current number of used permits on SEMAPHORE."
::mdef/labels ["name"]
::mdef/type :gauge}
:semaphore-timing
{::mdef/name "penpot_semaphore_timing"
::mdef/help "Total timing of SEMAPHORE."
::mdef/labels ["name"]
::mdef/type :summary}
:executors-active-threads
{::mdef/name "penpot_executors_active_threads"
::mdef/help "Current number of threads available in the executor service."
::mdef/labels ["name"]
::mdef/type :gauge}
:executors-completed-tasks
{::mdef/name "penpot_executors_completed_tasks_total"
::mdef/help "Approximate number of completed tasks by the executor."
::mdef/labels ["name"]
::mdef/type :counter}
:executors-running-threads
{::mdef/name "penpot_executors_running_threads"
::mdef/help "Current number of threads with state RUNNING."
::mdef/labels ["name"]
::mdef/type :gauge}
:executors-queued-submissions
{::mdef/name "penpot_executors_queued_submissions"
::mdef/help "Current number of queued submissions."
::mdef/labels ["name"]
::mdef/type :gauge}})
(s/def ::mdef/name string?)
(s/def ::mdef/help string?)
(s/def ::mdef/labels (s/every string? :kind vector?))
@@ -65,13 +169,8 @@
::handler
::definitions]))
(s/def ::default ::definitions)
(defmethod ig/pre-init-spec ::metrics [_]
(s/keys :req-un [::default]))
(defmethod ig/init-key ::metrics
[_ cfg]
[_ _]
(l/info :action "initialize metrics")
(let [registry (create-registry)
definitions (reduce-kv (fn [res k v]
@@ -79,7 +178,7 @@
(create-collector)
(assoc res k)))
{}
(:default cfg))]
default-metrics)]
(us/verify! ::definitions definitions)
@@ -87,7 +186,6 @@
::definitions definitions
::registry registry}))
(defn- handler
[registry _ respond _]
(let [samples (.metricFamilySamples ^CollectorRegistry registry)
@@ -96,18 +194,6 @@
(respond {:headers {"content-type" TextFormat/CONTENT_TYPE_004}
:body (.toString writer)})))
(s/def ::routes vector?)
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::metrics]))
(defmethod ig/init-key ::routes
[_ {:keys [::metrics]}]
(let [registry (::registry metrics)]
["/metrics" {:handler (partial handler registry)
:allowed-methods #{:get}}]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Implementation
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -145,7 +231,7 @@
(defmethod run-collector! :counter
[{:keys [::mdef/instance]} {:keys [inc labels] :or {inc 1 labels default-empty-labels}}]
(let [instance (.labels ^Counter instance (if (is-array? labels) labels (into-array String labels)))]
(let [instance (.labels instance (if (is-array? labels) labels (into-array String labels)))]
(.inc ^Counter$Child instance (double inc))))
(defmethod run-collector! :gauge

View File

@@ -6,12 +6,8 @@
(ns app.migrations
(:require
[app.common.data.macros :as dm]
[app.common.logging :as l]
[app.db :as db]
[app.migrations.clj.migration-0023 :as mg0023]
[app.util.migrations :as mg]
[clojure.spec.alpha :as s]
[integrant.core :as ig]))
(def migrations
@@ -251,84 +247,7 @@
{:name "0079-mod-profile-table"
:fn (mg/resource "app/migrations/sql/0079-mod-profile-table.sql")}
])
{:name "0080-mod-index-names"
:fn (mg/resource "app/migrations/sql/0080-mod-index-names.sql")}
{:name "0081-add-deleted-at-index-to-file-table"
:fn (mg/resource "app/migrations/sql/0081-add-deleted-at-index-to-file-table.sql")}
{:name "0082-add-features-column-to-file-table"
:fn (mg/resource "app/migrations/sql/0082-add-features-column-to-file-table.sql")}
{:name "0083-add-file-data-fragment-table"
:fn (mg/resource "app/migrations/sql/0083-add-file-data-fragment-table.sql")}
{:name "0084-add-features-column-to-file-change-table"
:fn (mg/resource "app/migrations/sql/0084-add-features-column-to-file-change-table.sql")}
{:name "0085-add-webhook-table"
:fn (mg/resource "app/migrations/sql/0085-add-webhook-table.sql")}
{:name "0086-add-webhook-delivery-table"
:fn (mg/resource "app/migrations/sql/0086-add-webhook-delivery-table.sql")}
{:name "0087-mod-task-table"
:fn (mg/resource "app/migrations/sql/0087-mod-task-table.sql")}
{:name "0088-mod-team-profile-rel-table"
:fn (mg/resource "app/migrations/sql/0088-mod-team-profile-rel-table.sql")}
{:name "0089-mod-project-profile-rel-table"
:fn (mg/resource "app/migrations/sql/0089-mod-project-profile-rel-table.sql")}
{:name "0090-mod-http-session-table"
:fn (mg/resource "app/migrations/sql/0090-mod-http-session-table.sql")}
{:name "0091-mod-team-project-profile-rel-table"
:fn (mg/resource "app/migrations/sql/0091-mod-team-project-profile-rel-table.sql")}
{:name "0092-mod-team-invitation-table"
:fn (mg/resource "app/migrations/sql/0092-mod-team-invitation-table.sql")}
{:name "0093-del-file-share-tokens-table"
:fn (mg/resource "app/migrations/sql/0093-del-file-share-tokens-table.sql")}
{:name "0094-del-profile-attr-table"
:fn (mg/resource "app/migrations/sql/0094-del-profile-attr-table.sql")}
{:name "0095-del-storage-data-table"
:fn (mg/resource "app/migrations/sql/0095-del-storage-data-table.sql")}
{:name "0096-del-storage-pending-table"
:fn (mg/resource "app/migrations/sql/0096-del-storage-pending-table.sql")}
{:name "0098-add-quotes-table"
:fn (mg/resource "app/migrations/sql/0098-add-quotes-table.sql")}
{:name "0099-add-access-token-table"
:fn (mg/resource "app/migrations/sql/0099-add-access-token-table.sql")}
{:name "0100-mod-profile-indexes"
:fn (mg/resource "app/migrations/sql/0100-mod-profile-indexes.sql")}
{:name "0101-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0101-mod-server-error-report-table.sql")}
])
(defn apply-migrations!
[pool name migrations]
(dm/with-open [conn (db/open pool)]
(mg/setup! conn)
(mg/migrate! conn {:name name :steps migrations})))
(defmethod ig/pre-init-spec ::migrations
[_]
(s/keys :req [::db/pool]))
(defmethod ig/init-key ::migrations
[module {:keys [::db/pool]}]
(when-not (db/read-only? pool)
(l/info :hint "running migrations" :module module)
(some->> (seq migrations) (apply-migrations! pool "main"))))
(defmethod ig/init-key ::migrations [_ _] migrations)

View File

@@ -1,11 +0,0 @@
ALTER INDEX team_font_variant_deleted_at_idx
RENAME TO team_font_variant__deleted_at__idx;
ALTER INDEX team_deleted_at_idx
RENAME TO team__deleted_at__idx;
ALTER INDEX profile_deleted_at_idx
RENAME TO profile__deleted_at__idx;
ALTER INDEX project_deleted_at_idx
RENAME TO project__deleted_at__idx;

View File

@@ -1,3 +0,0 @@
CREATE INDEX file__deleted_at__idx
ON file (deleted_at, id)
WHERE deleted_at IS NOT NULL;

View File

@@ -1,2 +0,0 @@
ALTER TABLE file
ADD COLUMN features text[] DEFAULT NULL;

View File

@@ -1,15 +0,0 @@
CREATE TABLE file_data_fragment (
id uuid NOT NULL,
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
created_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NULL,
content bytea NOT NULL,
PRIMARY KEY (file_id, id)
);
ALTER TABLE file_data_fragment
ALTER COLUMN metadata SET STORAGE external,
ALTER COLUMN content SET STORAGE external;

View File

@@ -1,8 +0,0 @@
ALTER TABLE file_change
ADD COLUMN features text[] DEFAULT NULL;
ALTER TABLE file_change
ALTER COLUMN features SET STORAGE external;
ALTER TABLE file
ALTER COLUMN features SET STORAGE external;

View File

@@ -1,25 +0,0 @@
CREATE TABLE webhook (
id uuid PRIMARY KEY,
team_id uuid NOT NULL REFERENCES team(id) ON DELETE CASCADE DEFERRABLE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
uri text NOT NULL,
mtype text NOT NULL,
error_code text NULL,
error_count smallint DEFAULT 0,
is_active boolean DEFAULT true,
secret_key text NULL
);
ALTER TABLE webhook
ALTER COLUMN uri SET STORAGE external,
ALTER COLUMN mtype SET STORAGE external,
ALTER COLUMN error_code SET STORAGE external,
ALTER COLUMN secret_key SET STORAGE external;
CREATE INDEX webhook__team_id__idx ON webhook (team_id);

View File

@@ -1,16 +0,0 @@
CREATE TABLE webhook_delivery (
webhook_id uuid NOT NULL REFERENCES webhook(id) ON DELETE CASCADE DEFERRABLE,
created_at timestamptz NOT NULL DEFAULT now(),
error_code text NULL,
req_data jsonb NULL,
rsp_data jsonb NULL,
PRIMARY KEY (webhook_id, created_at)
);
ALTER TABLE webhook_delivery
ALTER COLUMN error_code SET STORAGE external,
ALTER COLUMN req_data SET STORAGE external,
ALTER COLUMN rsp_data SET STORAGE external;

View File

@@ -1,9 +0,0 @@
ALTER TABLE task
ADD COLUMN label text NULL;
ALTER TABLE task
ALTER COLUMN label SET STORAGE external;
CREATE INDEX task__label__idx
ON task (label, name, queue)
WHERE status = 'new';

View File

@@ -1,3 +0,0 @@
ALTER TABLE team_profile_rel DROP CONSTRAINT team_profile_rel_pkey;
ALTER TABLE team_profile_rel ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
ALTER TABLE team_profile_rel ADD CONSTRAINT team_profile_rel_unique UNIQUE (team_id, profile_id);

View File

@@ -1,3 +0,0 @@
ALTER TABLE project_profile_rel DROP CONSTRAINT project_profile_rel_pkey;
ALTER TABLE project_profile_rel ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
ALTER TABLE project_profile_rel ADD CONSTRAINT project_profile_rel_unique UNIQUE (project_id, profile_id);

View File

@@ -1,2 +0,0 @@
ALTER TABLE http_session DROP CONSTRAINT http_session_pkey;
ALTER TABLE http_session ADD CONSTRAINT http_session_pkey PRIMARY KEY (id);

View File

@@ -1,3 +0,0 @@
ALTER TABLE team_project_profile_rel DROP CONSTRAINT team_project_profile_rel_pkey;
ALTER TABLE team_project_profile_rel ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
ALTER TABLE team_project_profile_rel ADD CONSTRAINT team_project_profile_rel_unique UNIQUE (team_id, project_id, profile_id);

View File

@@ -1,3 +0,0 @@
ALTER TABLE team_invitation DROP CONSTRAINT team_invitation_pkey;
ALTER TABLE team_invitation ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
ALTER TABLE team_invitation ADD CONSTRAINT team_invitation_unique UNIQUE (team_id, email_to);

View File

@@ -1 +0,0 @@
DROP TABLE file_share_token;

View File

@@ -1 +0,0 @@
DROP TABLE profile_attr;

View File

@@ -1 +0,0 @@
DROP TABLE storage_data;

View File

@@ -1 +0,0 @@
DROP TABLE storage_pending;

View File

@@ -1,82 +0,0 @@
CREATE TABLE usage_quote (
id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY,
target text NOT NULL,
quote bigint NOT NULL,
profile_id uuid NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
project_id uuid NULL REFERENCES project(id) ON DELETE CASCADE DEFERRABLE,
team_id uuid NULL REFERENCES team(id) ON DELETE CASCADE DEFERRABLE,
file_id uuid NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE
);
ALTER TABLE usage_quote
ALTER COLUMN target SET STORAGE external;
CREATE INDEX usage_quote__profile_id__idx ON usage_quote(profile_id, target);
CREATE INDEX usage_quote__project_id__idx ON usage_quote(project_id, target);
CREATE INDEX usage_quote__team_id__idx ON usage_quote(team_id, target);
-- DROP TABLE IF EXISTS usage_quote_test;
-- CREATE TABLE usage_quote_test (
-- id bigserial NOT NULL PRIMARY KEY,
-- target text NOT NULL,
-- quote bigint NOT NULL,
-- profile_id bigint NULL,
-- team_id bigint NULL,
-- project_id bigint NULL,
-- file_id bigint NULL
-- );
-- ALTER TABLE usage_quote_test
-- ALTER COLUMN target SET STORAGE external;
-- CREATE INDEX usage_quote_test__profile_id__idx ON usage_quote_test(profile_id, target);
-- CREATE INDEX usage_quote_test__project_id__idx ON usage_quote_test(project_id, target);
-- CREATE INDEX usage_quote_test__team_id__idx ON usage_quote_test(team_id, target);
-- -- CREATE INDEX usage_quote_test__target__idx ON usage_quote_test(target);
-- DELETE FROM usage_quote_test;
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 50*RANDOM(), 2000*RANDOM(), null, null
-- FROM generate_series(1, 5000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 200*RANDOM(), 300*RANDOM(), 300*RANDOM(), null
-- FROM generate_series(1, 1000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), null, 300*RANDOM()
-- FROM generate_series(1, 1000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), 300*RANDOM(), 300*RANDOM()
-- FROM generate_series(1, 1000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 30*RANDOM(), null, 2000*RANDOM(), null
-- FROM generate_series(1, 5000);
-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
-- SELECT 'files-per-project', 10*RANDOM(), null, null, 2000*RANDOM()
-- FROM generate_series(1, 5000);
-- VACUUM ANALYZE usage_quote_test;
-- select * from usage_quote_test
-- where target = 'files-per-project'
-- and profile_id = 1
-- and team_id is null
-- and project_id is null;
-- select * from usage_quote_test
-- where target = 'files-per-project'
-- and ((team_id = 1 and (profile_id = 1 or profile_id is null)) or
-- (profile_id = 1 and team_id is null and project_id is null));
-- select * from usage_quote_test
-- where target = 'files-per-project'
-- and ((project_id = 1 and (profile_id = 1 or profile_id is null)) or
-- (team_id = 1 and (profile_id = 1 or profile_id is null)) or
-- (profile_id = 1 and team_id is null and project_id is null));

View File

@@ -1,19 +0,0 @@
DROP TABLE IF EXISTS access_token;
CREATE TABLE access_token (
id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY,
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
name text NOT NULL,
token text NOT NULL,
perms text[] NULL
);
ALTER TABLE access_token
ALTER COLUMN name SET STORAGE external,
ALTER COLUMN token SET STORAGE external,
ALTER COLUMN perms SET STORAGE external;
CREATE INDEX access_token__profile_id__idx ON access_token(profile_id);

View File

@@ -1,31 +0,0 @@
ALTER TABLE profile
ADD COLUMN default_project_id uuid NULL REFERENCES project(id) ON DELETE SET NULL DEFERRABLE,
ADD COLUMN default_team_id uuid NULL REFERENCES team(id) ON DELETE SET NULL DEFERRABLE;
CREATE INDEX profile__default_project__idx ON profile(default_project_id);
CREATE INDEX profile__default_team__idx ON profile(default_team_id);
with profiles as (
select p.id,
tpr.team_id as default_team_id,
ppr.project_id as default_project_id
from profile as p
join team_profile_rel as tpr
on (tpr.profile_id = p.id and
tpr.is_owner is true)
join project_profile_rel as ppr
on (ppr.profile_id = p.id and
ppr.is_owner is true)
join project as pj
on (pj.id = ppr.project_id)
join team as tm
on (tm.id = tpr.team_id)
where pj.is_default is true
and tm.is_default is true
and pj.team_id = tm.id
)
update profile
set default_team_id = p.default_team_id,
default_project_id = p.default_project_id
from profiles as p
where profile.id = p.id;

View File

@@ -1,2 +0,0 @@
ALTER TABLE server_error_report
ADD COLUMN version integer DEFAULT 1;

View File

@@ -20,8 +20,7 @@
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]))
[promesa.core :as p]))
(set! *warn-on-reflection* true)
@@ -53,8 +52,8 @@
(s/def ::rcv-ch ::aa/channel)
(s/def ::pub-ch ::aa/channel)
(s/def ::state ::us/agent)
(s/def ::pconn ::redis/connection-holder)
(s/def ::sconn ::redis/connection-holder)
(s/def ::pconn ::redis/connection)
(s/def ::sconn ::redis/connection)
(s/def ::msgbus
(s/keys :req [::cmd-ch ::rcv-ch ::pub-ch ::state ::pconn ::sconn ::wrk/executor]))
@@ -79,7 +78,7 @@
(us/verify! ::msgbus msgbus)
(set-error-handler! state #(l/error :cause % :hint "unexpected error on agent" ::l/sync? true))
(set-error-handler! state #(l/error :cause % :hint "unexpected error on agent" ::l/async false))
(set-error-mode! state :continue)
(start-io-loop! msgbus)
@@ -123,8 +122,8 @@
(defn- redis-disconnect
[{:keys [::pconn ::sconn] :as cfg}]
(d/close! pconn)
(d/close! sconn))
(redis/close! pconn)
(redis/close! sconn))
(defn- conj-subscription
"A low level function that is responsible to create on-demand
@@ -133,7 +132,7 @@
[nsubs cfg topic chan]
(let [nsubs (if (nil? nsubs) #{chan} (conj nsubs chan))]
(when (= 1 (count nsubs))
(l/trace :hint "open subscription" :topic topic ::l/sync? true)
(l/trace :hint "open subscription" :topic topic ::l/async false)
(redis-sub cfg topic))
nsubs))
@@ -144,7 +143,7 @@
[nsubs cfg topic chan]
(let [nsubs (disj nsubs chan)]
(when (empty? nsubs)
(l/trace :hint "close subscription" :topic topic ::l/sync? true)
(l/trace :hint "close subscription" :topic topic ::l/async false)
(redis-unsub cfg topic))
nsubs))
@@ -206,33 +205,31 @@
(when-let [closed (a/<! (send-to-topic topic message))]
(send-via executor state unsubscribe-channels cfg closed nil))))
]
(px/thread
{:name "penpot/msgbus-io-loop"}
(loop []
(let [[val port] (a/alts!! [pub-ch rcv-ch])]
(cond
(nil? val)
(do
(l/trace :hint "stopping io-loop, nil received")
(send-via executor state (fn [state]
(->> (vals state)
(mapcat identity)
(filter some?)
(run! a/close!))
nil)))
(= port rcv-ch)
(do
(a/<!! (process-incoming val))
(recur))
(a/go-loop []
(let [[val port] (a/alts! [pub-ch rcv-ch])]
(cond
(nil? val)
(do
(l/trace :hint "stopping io-loop, nil received")
(send-via executor state (fn [state]
(->> (vals state)
(mapcat identity)
(filter some?)
(run! a/close!))
nil)))
(= port pub-ch)
(let [result (a/<!! (redis-pub cfg val))]
(when (ex/exception? result)
(l/error :hint "unexpected error on publishing"
:message val
:cause result))
(recur))))))))
(= port rcv-ch)
(do
(a/<! (process-incoming val))
(recur))
(= port pub-ch)
(let [result (a/<! (redis-pub cfg val))]
(when (ex/exception? result)
(l/error :hint "unexpected error on publishing" :message val
:cause result))
(recur)))))))
(defn- redis-pub
"Publish a message to the redis server. Asynchronous operation,

View File

@@ -21,19 +21,13 @@
[promesa.core :as p])
(:import
clojure.lang.IDeref
clojure.lang.MapEntry
io.lettuce.core.KeyValue
io.lettuce.core.RedisClient
io.lettuce.core.RedisCommandInterruptedException
io.lettuce.core.RedisCommandTimeoutException
io.lettuce.core.RedisException
io.lettuce.core.RedisURI
io.lettuce.core.ScriptOutputType
io.lettuce.core.api.StatefulConnection
io.lettuce.core.api.StatefulRedisConnection
io.lettuce.core.api.async.RedisAsyncCommands
io.lettuce.core.api.async.RedisScriptingAsyncCommands
io.lettuce.core.api.sync.RedisCommands
io.lettuce.core.codec.ByteArrayCodec
io.lettuce.core.codec.RedisCodec
io.lettuce.core.codec.StringCodec
@@ -51,12 +45,13 @@
(declare initialize-resources)
(declare shutdown-resources)
(declare connect*)
(declare connect)
(declare close!)
(s/def ::timer
#(instance? Timer %))
(s/def ::default-connection
(s/def ::connection
#(or (instance? StatefulRedisConnection %)
(and (instance? IDeref %)
(instance? StatefulRedisConnection (deref %)))))
@@ -66,13 +61,6 @@
(and (instance? IDeref %)
(instance? StatefulRedisPubSubConnection (deref %)))))
(s/def ::connection
(s/or :default ::default-connection
:pubsub ::pubsub-connection))
(s/def ::connection-holder
(s/keys :req [::connection]))
(s/def ::redis-uri
#(instance? RedisURI %))
@@ -87,37 +75,32 @@
(s/def ::connect? ::us/boolean)
(s/def ::io-threads ::us/integer)
(s/def ::worker-threads ::us/integer)
(s/def ::cache #(instance? clojure.lang.Atom %))
(s/def ::redis
(s/keys :req [::resources
::redis-uri
::timer
::mtx/metrics]
:opt [::connection
::cache]))
(s/keys :req [::resources ::redis-uri ::timer ::mtx/metrics]
:opt [::connection]))
(defmethod ig/pre-init-spec ::redis [_]
(s/keys :req-un [::uri ::mtx/metrics]
:opt-un [::timeout
::connect?
::io-threads
::worker-threads]))
(defmethod ig/prep-key ::redis
[_ cfg]
(let [runtime (Runtime/getRuntime)
cpus (.availableProcessors ^Runtime runtime)]
(merge {::timeout (dt/duration "10s")
::io-threads (max 3 cpus)
::worker-threads (max 3 cpus)}
(d/without-nils cfg))))
(defmethod ig/pre-init-spec ::redis [_]
(s/keys :req [::uri ::mtx/metrics]
:opt [::timeout
::connect?
::io-threads
::worker-threads]))
(merge {:timeout (dt/duration 5000)
:io-threads (max 3 cpus)
:worker-threads (max 3 cpus)}
(d/without-nils cfg))))
(defmethod ig/init-key ::redis
[_ {:keys [::connect?] :as cfg}]
(let [state (initialize-resources cfg)]
(cond-> state
connect? (assoc ::connection (connect* cfg {})))))
[_ {:keys [connect?] :as cfg}]
(let [cfg (initialize-resources cfg)]
(cond-> cfg
connect? (assoc ::connection (connect cfg)))))
(defmethod ig/halt-key! ::redis
[_ state]
@@ -131,7 +114,7 @@
(defn- initialize-resources
"Initialize redis connection resources"
[{:keys [::uri ::io-threads ::worker-threads ::connect?] :as cfg}]
[{:keys [uri io-threads worker-threads connect? metrics] :as cfg}]
(l/info :hint "initialize redis resources"
:uri uri
:io-threads io-threads
@@ -148,32 +131,34 @@
redis-uri (RedisURI/create ^String uri)]
(-> cfg
(assoc ::resources resources)
(assoc ::timer timer)
(assoc ::mtx/metrics metrics)
(assoc ::cache (atom {}))
(assoc ::redis-uri redis-uri))))
(assoc ::timer timer)
(assoc ::redis-uri redis-uri)
(assoc ::resources resources))))
(defn- shutdown-resources
[{:keys [::resources ::cache ::timer]}]
(run! d/close! (vals @cache))
(run! close! (vals @cache))
(when resources
(.shutdown ^ClientResources resources))
(when timer
(.stop ^Timer timer)))
(defn connect*
[{:keys [::resources ::redis-uri] :as state}
{:keys [timeout codec type]
:or {codec default-codec type :default}}]
(defn connect
[{:keys [::resources ::redis-uri] :as cfg}
& {:keys [timeout codec type] :or {codec default-codec type :default}}]
(us/assert! ::resources resources)
(let [client (RedisClient/create ^ClientResources resources ^RedisURI redis-uri)
timeout (or timeout (::timeout state))
timeout (or timeout (:timeout cfg))
conn (case type
:default (.connect ^RedisClient client ^RedisCodec codec)
:pubsub (.connectPubSub ^RedisClient client ^RedisCodec codec))]
(.setTimeout ^StatefulConnection conn ^Duration timeout)
(reify
IDeref
(deref [_] conn)
@@ -183,109 +168,53 @@
(.close ^StatefulConnection conn)
(.shutdown ^RedisClient client)))))
(defn connect
[state & {:as opts}]
(let [connection (connect* state opts)]
(-> state
(assoc ::connection connection)
(dissoc ::cache)
(vary-meta assoc `d/close! (fn [_] (d/close! connection))))))
(defn get-or-connect
[{:keys [::cache] :as state} key options]
(us/assert! ::redis state)
(-> state
(assoc ::connection
(or (get @cache key)
(-> (swap! cache (fn [cache]
(when-let [prev (get cache key)]
(d/close! prev))
(assoc cache key (connect* state options))))
(get key))))
(dissoc ::cache)))
(assoc state ::connection
(or (get @cache key)
(-> (swap! cache (fn [cache]
(when-let [prev (get cache key)]
(close! prev))
(assoc cache key (connect state options))))
(get key)))))
(defn add-listener!
[{:keys [::connection] :as conn} listener]
(us/assert! ::pubsub-connection connection)
[conn listener]
(us/assert! ::pubsub-connection @conn)
(us/assert! ::pubsub-listener listener)
(.addListener ^StatefulRedisPubSubConnection @connection
(.addListener ^StatefulRedisPubSubConnection @conn
^RedisPubSubListener listener)
conn)
(defn publish!
[{:keys [::connection]} topic message]
[conn topic message]
(us/assert! ::us/string topic)
(us/assert! ::us/bytes message)
(us/assert! ::default-connection connection)
(us/assert! ::connection @conn)
(let [pcomm (.async ^StatefulRedisConnection @connection)]
(let [pcomm (.async ^StatefulRedisConnection @conn)]
(.publish ^RedisAsyncCommands pcomm ^String topic ^bytes message)))
(defn subscribe!
"Blocking operation, intended to be used on a thread/agent thread."
[{:keys [::connection]} & topics]
(us/assert! ::pubsub-connection connection)
(try
(let [topics (into-array String (map str topics))
cmd (.sync ^StatefulRedisPubSubConnection @connection)]
(.subscribe ^RedisPubSubCommands cmd topics))
(catch RedisCommandInterruptedException cause
(throw (InterruptedException. (ex-message cause))))))
"Blocking operation, intended to be used on a worker/agent thread."
[conn & topics]
(us/assert! ::pubsub-connection @conn)
(let [topics (into-array String (map str topics))
cmd (.sync ^StatefulRedisPubSubConnection @conn)]
(.subscribe ^RedisPubSubCommands cmd topics)))
(defn unsubscribe!
"Blocking operation, intended to be used on a thread/agent thread."
[{:keys [::connection]} & topics]
(us/assert! ::pubsub-connection connection)
(try
(let [topics (into-array String (map str topics))
cmd (.sync ^StatefulRedisPubSubConnection @connection)]
(.unsubscribe ^RedisPubSubCommands cmd topics))
(catch RedisCommandInterruptedException cause
(throw (InterruptedException. (ex-message cause))))))
(defn rpush!
[{:keys [::connection]} key payload]
(us/assert! ::default-connection connection)
(us/assert! (or (and (vector? payload)
(every? bytes? payload))
(bytes? payload)))
(try
(let [cmd (.sync ^StatefulRedisConnection @connection)
data (if (vector? payload) payload [payload])
vals (make-array (. Class (forName "[B")) (count data))]
(loop [i 0 xs (seq data)]
(when xs
(aset ^"[[B" vals i ^bytes (first xs))
(recur (inc i) (next xs))))
(.rpush ^RedisCommands cmd
^String key
^"[[B" vals))
(catch RedisCommandInterruptedException cause
(throw (InterruptedException. (ex-message cause))))))
(defn blpop!
[{:keys [::connection]} timeout & keys]
(us/assert! ::default-connection connection)
(try
(let [keys (into-array Object (map str keys))
cmd (.sync ^StatefulRedisConnection @connection)
timeout (/ (double (inst-ms timeout)) 1000.0)]
(when-let [res (.blpop ^RedisCommands cmd
^double timeout
^"[Ljava.lang.String;" keys)]
(MapEntry/create
(.getKey ^KeyValue res)
(.getValue ^KeyValue res))))
(catch RedisCommandInterruptedException cause
(throw (InterruptedException. (ex-message cause))))))
"Blocking operation, intended to be used on a worker/agent thread."
[conn & topics]
(us/assert! ::pubsub-connection @conn)
(let [topics (into-array String (map str topics))
cmd (.sync ^StatefulRedisPubSubConnection @conn)]
(.unsubscribe ^RedisPubSubCommands cmd topics)))
(defn open?
[{:keys [::connection]}]
(us/assert! ::pubsub-connection connection)
(.isOpen ^StatefulConnection @connection))
[conn]
(.isOpen ^StatefulConnection @conn))
(defn pubsub-listener
[& {:keys [on-message on-subscribe on-unsubscribe]}]
@@ -314,6 +243,10 @@
(when on-unsubscribe
(on-unsubscribe nil topic count)))))
(defn close!
[o]
(.close ^AutoCloseable o))
(def ^:private scripts-cache (atom {}))
(def noop-fn (constantly nil))
@@ -329,12 +262,12 @@
::rscript/vals]))
(defn eval!
[{:keys [::mtx/metrics ::connection] :as state} script]
(us/assert! ::redis state)
(us/assert! ::default-connection connection)
[{:keys [::mtx/metrics] :as state} script]
(us/assert! ::rscript/script script)
(us/assert! ::redis state)
(let [cmd (.async ^StatefulRedisConnection @connection)
(let [rconn (-> state ::connection deref)
cmd (.async ^StatefulRedisConnection rconn)
keys (into-array String (map str (::rscript/keys script)))
vals (into-array String (map str (::rscript/vals script)))
sname (::rscript/name script)]
@@ -343,52 +276,44 @@
(if (instance? io.lettuce.core.RedisNoScriptException cause)
(do
(l/error :hint "no script found" :name sname :cause cause)
(->> (load-script)
(p/mapcat eval-script)))
(-> (load-script)
(p/then eval-script)))
(if-let [on-error (::rscript/on-error script)]
(on-error cause)
(p/rejected cause))))
(eval-script [sha]
(let [tpoint (dt/tpoint)]
(->> (.evalsha ^RedisScriptingAsyncCommands cmd
^String sha
^ScriptOutputType ScriptOutputType/MULTI
^"[Ljava.lang.String;" keys
^"[Ljava.lang.String;" vals)
(p/fmap (fn [result]
(let [elapsed (tpoint)]
(mtx/run! metrics {:id :redis-eval-timing
:labels [(name sname)]
:val (inst-ms elapsed)})
(l/trace :hint "eval script"
:name (name sname)
:sha sha
:params (str/join "," (::rscript/vals script))
:elapsed (dt/format-duration elapsed))
result)))
(p/merr on-error))))
(-> (.evalsha ^RedisScriptingAsyncCommands cmd
^String sha
^ScriptOutputType ScriptOutputType/MULTI
^"[Ljava.lang.String;" keys
^"[Ljava.lang.String;" vals)
(p/then (fn [result]
(let [elapsed (tpoint)]
(mtx/run! metrics {:id :redis-eval-timing
:labels [(name sname)]
:val (inst-ms elapsed)})
(l/trace :hint "eval script"
:name (name sname)
:sha sha
:params (str/join "," (::rscript/vals script))
:elapsed (dt/format-duration elapsed))
result)))
(p/catch on-error))))
(read-script []
(-> script ::rscript/path io/resource slurp))
(load-script []
(l/trace :hint "load script" :name sname)
(->> (.scriptLoad ^RedisScriptingAsyncCommands cmd
(-> (.scriptLoad ^RedisScriptingAsyncCommands cmd
^String (read-script))
(p/map (fn [sha]
(p/then (fn [sha]
(swap! scripts-cache assoc sname sha)
sha))))]
(if-let [sha (get @scripts-cache sname)]
(eval-script sha)
(->> (load-script)
(p/mapcat eval-script))))))
(defn timeout-exception?
[cause]
(instance? RedisCommandTimeoutException cause))
(defn exception?
[cause]
(instance? RedisException cause))
(-> (load-script)
(p/then eval-script))))))

View File

@@ -6,289 +6,181 @@
(ns app.rpc
(:require
[app.auth.ldap :as-alias ldap]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[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.session :as session]
[app.loggers.audit :as audit]
[app.loggers.webhooks :as-alias webhooks]
[app.main :as-alias main]
[app.metrics :as mtx]
[app.msgbus :as-alias mbus]
[app.rpc.climit :as climit]
[app.rpc.cond :as cond]
[app.rpc.helpers :as rph]
[app.rpc.retry :as retry]
[app.rpc.rlimit :as rlimit]
[app.storage :as-alias sto]
[app.rpc.semaphore :as-alias rsem]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[app.util.time :as ts]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.request :as yrq]
[yetti.response :as yrs]))
(s/def ::profile-id ::us/uuid)
(defn- default-handler
[_]
(p/rejected (ex/error :type :not-found)))
(defn- handle-response-transformation
[response request mdata]
(let [transform-fn (reduce (fn [res-fn transform-fn]
(fn [request response]
(p/then (res-fn request response) #(transform-fn request %))))
(constantly response)
(::response-transform-fns mdata))]
(transform-fn request response)))
(let [response (if (sv/wrapped? response) @response response)]
(if-let [transform-fn (:transform-response mdata)]
(p/do (transform-fn request response))
(p/resolved response))))
(defn- handle-before-comple-hook
[response mdata]
(doseq [hook-fn (::before-complete-fns mdata)]
(when-let [hook-fn (:before-complete mdata)]
(ex/ignoring (hook-fn)))
response)
(defn- handle-response
[request result]
(if (fn? result)
(p/wrap (result request))
(let [mdata (meta result)]
(p/-> (yrs/response {:status (::http/status mdata 200)
:headers (::http/headers mdata {})
:body (rph/unwrap result)})
(handle-response-transformation request mdata)
(handle-before-comple-hook mdata)))))
(let [mdata (meta result)]
(p/-> (yrs/response 200 result (::http/headers mdata {}))
(handle-response-transformation request mdata)
(handle-before-comple-hook mdata))))
(defn- rpc-query-handler
"Ring handler that dispatches query requests and convert between
internal async flow into ring async flow."
[methods {:keys [params path-params] :as request} respond raise]
(let [type (keyword (:type path-params))
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
[methods {:keys [profile-id session-id params] :as request} respond raise]
(let [type (keyword (:type params))
data (into {::http/request request} params)
data (if profile-id
(assoc data :profile-id profile-id ::session-id session-id)
(dissoc data :profile-id))
method (get methods type default-handler)]
data (-> params
(assoc ::request-at (dt/now))
(assoc ::http/request request))
data (if profile-id
(-> data
(assoc :profile-id profile-id)
(assoc ::profile-id profile-id))
(dissoc data :profile-id ::profile-id))
method (get methods type default-handler)]
(->> (method data)
(p/mcat (partial handle-response request))
(p/fnly (fn [response cause]
(if cause
(raise cause)
(respond response)))))))
(-> (method data)
(p/then (partial handle-response request))
(p/then respond)
(p/catch (fn [cause]
(let [context {:profile-id profile-id}]
(raise (ex/wrap-with-context cause context))))))))
(defn- rpc-mutation-handler
"Ring handler that dispatches mutation requests and convert between
internal async flow into ring async flow."
[methods {:keys [params path-params] :as request} respond raise]
(let [type (keyword (:type path-params))
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
data (-> params
(assoc ::request-at (dt/now))
(assoc ::http/request request))
data (if profile-id
(-> data
(assoc :profile-id profile-id)
(assoc ::profile-id profile-id))
(dissoc data :profile-id))
method (get methods type default-handler)]
[methods {:keys [profile-id session-id params] :as request} respond raise]
(let [type (keyword (:type params))
data (into {::request request} params)
data (if profile-id
(assoc data :profile-id profile-id ::session-id session-id)
(dissoc data :profile-id))
(->> (method data)
(p/mcat (partial handle-response request))
(p/fnly (fn [response cause]
(if cause
(raise cause)
(respond response)))))))
method (get methods type default-handler)]
(-> (method data)
(p/then (partial handle-response request))
(p/then respond)
(p/catch (fn [cause]
(let [context {:profile-id profile-id}]
(raise (ex/wrap-with-context cause context))))))))
(defn- rpc-command-handler
"Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow."
[methods {:keys [params path-params] :as request} respond raise]
(let [type (keyword (:type path-params))
etag (yrq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
[methods {:keys [profile-id session-id params] :as request} respond raise]
(let [cmd (keyword (:command params))
data (into {::request request} params)
data (if profile-id
(assoc data :profile-id profile-id ::session-id session-id)
(dissoc data :profile-id))
data (-> params
(assoc ::request-at (dt/now))
(assoc ::session/id (::session/id request))
(assoc ::http/request request)
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
method (get methods type default-handler)]
(binding [cond/*enabled* true]
(->> (method data)
(p/mcat (partial handle-response request))
(p/fnly (fn [response cause]
(if cause
(raise cause)
(respond response))))))))
method (get methods cmd default-handler)]
(-> (method data)
(p/then (partial handle-response request))
(p/then respond)
(p/catch (fn [cause]
(let [context {:profile-id profile-id}]
(raise (ex/wrap-with-context cause context))))))))
(defn- wrap-metrics
"Wrap service method with metrics measurement."
[{:keys [::mtx/metrics ::metrics-id]} f mdata]
[{:keys [metrics ::metrics-id]} f mdata]
(let [labels (into-array String [(::sv/name mdata)])]
(fn [cfg params]
(let [tp (dt/tpoint)]
(->> (f cfg params)
(p/fnly (fn [_ _]
(mtx/run! metrics
:id metrics-id
:val (inst-ms (tp))
:labels labels))))))))
(defn- wrap-authentication
[_ f mdata]
(fn [cfg params]
(let [profile-id (::profile-id params)]
(if (and (::auth mdata true) (not (uuid? profile-id)))
(p/rejected
(ex/error :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint"))
(f cfg params)))))
(defn- wrap-access-token
"Wraps service method with access token validation."
[_ f {:keys [::sv/name] :as mdata}]
(if (contains? cf/flags :access-tokens)
(fn [cfg params]
(let [request (::http/request params)]
(if (contains? request ::actoken/id)
(let [perms (::actoken/perms request #{})]
(if (contains? perms name)
(f cfg params)
(p/rejected
(ex/error :type :authorization
:code :operation-not-allowed
:allowed perms))))
(f cfg params))))
f))
(let [tp (ts/tpoint)]
(p/finally
(f cfg params)
(fn [_ _]
(mtx/run! metrics
:id metrics-id
:val (inst-ms (tp))
:labels labels)))))))
(defn- wrap-dispatch
"Wraps service method into async flow, with the ability to dispatching
it to a preconfigured executor service."
[{:keys [::wrk/executor] :as cfg} f mdata]
[{:keys [executor] :as cfg} f mdata]
(with-meta
(fn [cfg params]
(->> (px/submit! executor (px/wrap-bindings #(f cfg params)))
(p/mapcat p/wrap)
(p/map rph/wrap)))
(-> (px/submit! executor #(f cfg params))
(p/bind p/wrap)))
mdata))
(defn- wrap-audit
[cfg f mdata]
(if (or (contains? cf/flags :webhooks)
(contains? cf/flags :audit-log))
(letfn [(handle-audit [params result]
(let [resultm (meta result)
request (::http/request params)
profile-id (or (::audit/profile-id resultm)
(:profile-id result)
(if (= (::type cfg) "command")
(::profile-id params)
(:profile-id params))
uuid/zero)
props (-> (or (::audit/replace-props resultm)
(-> params
(merge (::audit/props resultm))
(dissoc :profile-id)
(dissoc :type)))
(audit/clean-props))
event {:type (or (::audit/type resultm)
[{:keys [audit] :as cfg} f mdata]
(if audit
(with-meta
(fn [cfg {:keys [::request] :as params}]
(p/finally (f cfg params)
(fn [result _]
(when result
(let [resultm (meta result)
profile-id (or (::audit/profile-id resultm)
(:profile-id result)
(:profile-id params))
props (or (::audit/replace-props resultm)
(-> params
(merge (::audit/props resultm))
(dissoc :type)))]
(audit :cmd :submit
:type (or (::audit/type resultm)
(::type cfg))
:name (or (::audit/name resultm)
(::sv/name mdata))
:profile-id profile-id
:ip-addr (some-> request audit/parse-client-ip)
:props props
;; NOTE: for batch-key lookup we need the params as-is
;; because the rpc api does not need to know the
;; audit/webhook specific object layout.
::params (dissoc params ::http/request)
::webhooks/batch-key
(or (::webhooks/batch-key mdata)
(::webhooks/batch-key resultm))
::webhooks/batch-timeout
(or (::webhooks/batch-timeout mdata)
(::webhooks/batch-timeout resultm))
::webhooks/event?
(or (::webhooks/event? mdata)
(::webhooks/event? resultm)
false)}]
(audit/submit! cfg event)))
(handle-request [cfg params]
(->> (f cfg params)
(p/fnly (fn [result cause]
(when-not cause
(handle-audit params result))))))]
(if-not (::audit/skip mdata)
(with-meta handle-request mdata)
f))
:props (dissoc props ::request)))))))
mdata)
f))
(defn- wrap-spec-conform
[_ f mdata]
(let [spec (or (::sv/spec mdata) (s/spec any?))]
(fn [cfg params]
(let [params (ex/try! (us/conform spec params))]
(if (ex/exception? params)
(p/rejected params)
(f cfg params))))))
(defn- wrap-all
[cfg f mdata]
(as-> f $
(wrap-dispatch cfg $ mdata)
(wrap-metrics cfg $ mdata)
(cond/wrap cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata)
(rlimit/wrap cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)
(wrap-authentication cfg $ mdata)
(wrap-access-token cfg $ mdata)))
(defn- wrap
[cfg f mdata]
(l/debug :hint "register method" :name (::sv/name mdata))
(let [f (wrap-all cfg f mdata)]
(with-meta #(f cfg %) mdata)))
(let [f (as-> f $
(wrap-dispatch cfg $ mdata)
(wrap-metrics cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(rsem/wrap cfg $ mdata)
(rlimit/wrap cfg $ mdata)
(wrap-audit cfg $ mdata))
spec (or (::sv/spec mdata) (s/spec any?))
auth? (:auth mdata true)]
(l/debug :hint "register method" :name (::sv/name mdata))
(with-meta
(fn [{:keys [::request] :as params}]
;; Raise authentication error when rpc method requires auth but
;; no profile-id is found in the request.
(p/do!
(if (and auth? (not (uuid? (:profile-id params))))
(ex/raise :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint")
(let [params (us/conform spec (dissoc params ::request))]
(f cfg (assoc params ::request request))))))
mdata)))
(defn- process-method
[cfg vfn]
@@ -299,76 +191,74 @@
(defn- resolve-query-methods
[cfg]
(let [cfg (assoc cfg ::type "query" ::metrics-id :rpc-query-timing)]
(->> (sv/scan-ns
'app.rpc.queries.projects
'app.rpc.queries.profile
'app.rpc.queries.viewer
'app.rpc.queries.fonts)
(->> (sv/scan-ns 'app.rpc.queries.projects
'app.rpc.queries.files
'app.rpc.queries.teams
'app.rpc.queries.comments
'app.rpc.queries.profile
'app.rpc.queries.viewer
'app.rpc.queries.fonts)
(map (partial process-method cfg))
(into {}))))
(defn- resolve-mutation-methods
[cfg]
(let [cfg (assoc cfg ::type "mutation" ::metrics-id :rpc-mutation-timing)]
(->> (sv/scan-ns
'app.rpc.mutations.media
'app.rpc.mutations.profile
'app.rpc.mutations.projects
'app.rpc.mutations.fonts
'app.rpc.mutations.share-link)
(->> (sv/scan-ns 'app.rpc.mutations.media
'app.rpc.mutations.profile
'app.rpc.mutations.files
'app.rpc.mutations.comments
'app.rpc.mutations.projects
'app.rpc.mutations.teams
'app.rpc.mutations.management
'app.rpc.mutations.fonts
'app.rpc.mutations.share-link
'app.rpc.mutations.verify-token)
(map (partial process-method cfg))
(into {}))))
(defn- resolve-command-methods
[cfg]
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
(->> (sv/scan-ns
'app.rpc.commands.access-token
'app.rpc.commands.audit
'app.rpc.commands.auth
'app.rpc.commands.feedback
'app.rpc.commands.fonts
'app.rpc.commands.binfile
'app.rpc.commands.comments
'app.rpc.commands.demo
'app.rpc.commands.files
'app.rpc.commands.files-create
'app.rpc.commands.files-share
'app.rpc.commands.files-temp
'app.rpc.commands.files-update
'app.rpc.commands.ldap
'app.rpc.commands.management
'app.rpc.commands.media
'app.rpc.commands.profile
'app.rpc.commands.projects
'app.rpc.commands.search
'app.rpc.commands.teams
'app.rpc.commands.verify-token
'app.rpc.commands.viewer
'app.rpc.commands.webhooks)
(->> (sv/scan-ns 'app.rpc.commands.binfile
'app.rpc.commands.comments
'app.rpc.commands.management
'app.rpc.commands.verify-token
'app.rpc.commands.auth
'app.rpc.commands.ldap
'app.rpc.commands.demo
'app.rpc.commands.files)
(map (partial process-method cfg))
(into {}))))
(s/def ::audit (s/nilable fn?))
(s/def ::http-client fn?)
(s/def ::ldap (s/nilable map?))
(s/def ::msgbus ::mbus/msgbus)
(s/def ::public-uri ::us/not-empty-string)
(s/def ::session map?)
(s/def ::storage some?)
(s/def ::sprops map?)
(defmethod ig/pre-init-spec ::methods [_]
(s/keys :req [::session/manager
::http.client/client
::db/pool
::mbus/msgbus
::ldap/provider
::sto/storage
::mtx/metrics
::main/props
::wrk/executor]
:opt [::climit
::rlimit]
:req-un [::db/pool]))
(s/keys :req-un [::storage
::session
::sprops
::audit
::public-uri
::msgbus
::http-client
::rsem/semaphores
::rlimit/rlimit
::mtx/metrics
::db/pool
::ldap]))
(defmethod ig/init-key ::methods
[_ cfg]
(let [cfg (d/without-nils cfg)]
{:mutations (resolve-mutation-methods cfg)
:queries (resolve-query-methods cfg)
:commands (resolve-command-methods cfg)}))
{:mutations (resolve-mutation-methods cfg)
:queries (resolve-query-methods cfg)
:commands (resolve-command-methods cfg)})
(s/def ::mutations
(s/map-of keyword? fn?))
@@ -384,21 +274,13 @@
::queries
::commands]))
(s/def ::routes vector?)
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::methods
::db/pool
::main/props
::wrk/executor
::session/manager
::actoken/manager]))
(s/keys :req-un [::methods]))
(defmethod ig/init-key ::routes
[_ {:keys [::methods] :as cfg}]
[["/rpc" {:middleware [[session/authz cfg]
[actoken/authz cfg]]}
["/command/:type" {:handler (partial rpc-command-handler (:commands methods))}]
[_ {:keys [methods] :as cfg}]
[["/rpc"
["/command/:command" {:handler (partial rpc-command-handler (:commands methods))}]
["/query/:type" {:handler (partial rpc-query-handler (:queries methods))}]
["/mutation/:type" {:handler (partial rpc-mutation-handler (:mutations methods))
:allowed-methods #{:post}}]]])

View File

@@ -1,206 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.climit
"Concurrencly limiter for RPC."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.metrics :as mtx]
[app.rpc :as-alias rpc]
[app.util.services :as-alias sv]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.edn :as edn]
[clojure.spec.alpha :as s]
[datoteka.fs :as fs]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]
[promesa.exec.bulkhead :as pxb])
(:import
com.github.benmanes.caffeine.cache.Cache
com.github.benmanes.caffeine.cache.CacheLoader
com.github.benmanes.caffeine.cache.Caffeine
com.github.benmanes.caffeine.cache.RemovalListener))
(defn- capacity-exception?
[o]
(and (ex/error? o)
(let [data (ex-data o)]
(and (= :bulkhead-error (:type data))
(= :capacity-limit-reached (:code data))))))
(defn invoke!
[limiter f]
(->> (px/submit! limiter f)
(p/hcat (fn [result cause]
(cond
(capacity-exception? cause)
(p/rejected
(ex/error :type :internal
:code :concurrency-limit-reached
:queue (-> limiter meta ::bkey name)
:cause cause))
(some? cause)
(p/rejected cause)
:else
(p/resolved result))))))
(defn- create-limiter
[{:keys [::wrk/executor ::mtx/metrics ::bkey ::skey concurrency queue-size]}]
(let [labels (into-array String [(name bkey)])
on-queue (fn [instance]
(l/trace :hint "enqueued"
:key (name bkey)
:skey (str skey)
:queue-size (get instance ::pxb/current-queue-size)
:concurrency (get instance ::pxb/current-concurrency))
(mtx/run! metrics
:id :rpc-climit-queue-size
:val (get instance ::pxb/current-queue-size)
:labels labels)
(mtx/run! metrics
:id :rpc-climit-concurrency
:val (get instance ::pxb/current-concurrency)
:labels labels))
on-run (fn [instance task]
(let [elapsed (- (inst-ms (dt/now))
(inst-ms task))]
(l/trace :hint "execute"
:key (name bkey)
:skey (str skey)
:elapsed (str elapsed "ms"))
(mtx/run! metrics
:id :rpc-climit-timing
:val elapsed
:labels labels)
(mtx/run! metrics
:id :rpc-climit-queue-size
:val (get instance ::pxb/current-queue-size)
:labels labels)
(mtx/run! metrics
:id :rpc-climit-concurrency
:val (get instance ::pxb/current-concurrency)
:labels labels)))
options {:executor executor
:concurrency concurrency
:queue-size (or queue-size Integer/MAX_VALUE)
:on-queue on-queue
:on-run on-run}]
(-> (pxb/create options)
(vary-meta assoc ::bkey bkey ::skey skey))))
(defn- create-cache
[{:keys [::wrk/executor] :as params} config]
(let [listener (reify RemovalListener
(onRemoval [_ key _val cause]
(l/trace :hint "cache: remove" :key key :reason (str cause))))
loader (reify CacheLoader
(load [_ key]
(let [[bkey skey] key]
(when-let [config (get config bkey)]
(-> (merge params config)
(assoc ::bkey bkey)
(assoc ::skey skey)
(create-limiter))))))]
(.. (Caffeine/newBuilder)
(weakValues)
(executor executor)
(removalListener listener)
(build loader))))
(defprotocol IConcurrencyManager)
(s/def ::concurrency ::us/integer)
(s/def ::queue-size ::us/integer)
(s/def ::config
(s/map-of keyword?
(s/keys :req-un [::concurrency]
:opt-un [::queue-size])))
(defmethod ig/prep-key ::rpc/climit
[_ cfg]
(merge {::path (cf/get :rpc-climit-config)}
(d/without-nils cfg)))
(s/def ::path ::fs/path)
(defmethod ig/pre-init-spec ::rpc/climit [_]
(s/keys :req [::wrk/executor ::mtx/metrics ::path]))
(defmethod ig/init-key ::rpc/climit
[_ {:keys [::path] :as params}]
(when (contains? cf/flags :rpc-climit)
(if-let [config (some->> path slurp edn/read-string)]
(do
(l/info :hint "initializing concurrency limit" :config (str path))
(us/verify! ::config config)
(let [cache (create-cache params config)]
^{::cache cache}
(reify
IConcurrencyManager
clojure.lang.IDeref
(deref [_] config)
clojure.lang.ILookup
(valAt [_ key]
(let [key (if (vector? key) key [key])]
(.get ^Cache cache key))))))
(l/warn :hint "unable to load configuration" :config (str path)))))
(s/def ::rpc/climit
(s/nilable #(satisfies? IConcurrencyManager %)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmacro with-dispatch
[lim & body]
`(if ~lim
(invoke! ~lim (^:once fn [] (p/wrap (do ~@body))))
(p/wrap (do ~@body))))
(defn wrap
[{:keys [::rpc/climit]} f {:keys [::queue ::key-fn] :as mdata}]
(if (and (some? climit)
(some? queue))
(if-let [config (get @climit queue)]
(do
(l/debug :hint "wrap: instrumenting method"
:limit-name (name queue)
:service-name (::sv/name mdata)
:queue-size (or (:queue-size config) Integer/MAX_VALUE)
:concurrency (:concurrency config)
:keyed? (some? key-fn))
(if (some? key-fn)
(fn [cfg params]
(let [key [queue (key-fn params)]
lim (get climit key)]
(invoke! lim (partial f cfg params))))
(let [lim (get climit queue)]
(fn [cfg params]
(invoke! lim (partial f cfg params))))))
(do
(l/warn :hint "wrap: no config found"
:queue (name queue)
:service (::sv/name mdata))
f))
f))

View File

@@ -1,87 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.commands.access-token
(:require
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.db :as db]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias doc]
[app.rpc.quotes :as quotes]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
[clojure.spec.alpha :as s]))
(defn- decode-row
[{:keys [perms] :as row}]
(cond-> row
(db/pgarray? perms "text")
(assoc :perms (db/decode-pgarray perms #{}))))
(defn- create-access-token
[{:keys [::conn ::main/props]} profile-id name perms]
(let [created-at (dt/now)
token-id (uuid/next)
token (tokens/generate props {:iss "access-token"
:tid token-id
:iat created-at})]
(db/insert! conn :access-token
{:id token-id
:name name
:token token
:profile-id profile-id
:created-at created-at
:updated-at created-at
:perms (db/create-array conn "text" perms)})))
(defn repl-create-access-token
[{:keys [::db/pool] :as system} profile-id name perms]
(db/with-atomic [conn pool]
(let [props (:app.setup/props system)]
(create-access-token {::conn conn ::main/props props}
profile-id
name
perms))))
(s/def ::name ::us/not-empty-string)
(s/def ::perms ::us/set-of-strings)
(s/def ::create-access-token
(s/keys :req [::rpc/profile-id]
:req-un [::name ::perms]))
(sv/defmethod ::create-access-token
{::doc/added "1.18"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name perms]}]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::conn conn)]
(quotes/check-quote! conn
{::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id})
(-> (create-access-token cfg profile-id name perms)
(decode-row)))))
(s/def ::delete-access-token
(s/keys :req [::rpc/profile-id]
:req-un [::us/id]))
(sv/defmethod ::delete-access-token
{::doc/added "1.18"}
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
(db/delete! pool :access-token {:id id :profile-id profile-id})
nil)
(s/def ::get-access-tokens
(s/keys :req [::rpc/profile-id]))
(sv/defmethod ::get-access-tokens
{::doc/added "1.18"}
[{:keys [::db/pool]} {:keys [::rpc/profile-id]}]
(->> (db/query pool :access-token {:profile-id profile-id})
(mapv decode-row)))

View File

@@ -1,87 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.commands.audit
"Audit Log related RPC methods"
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[promesa.core :as p]
[promesa.exec :as px]))
(defn- event->row [event]
[(uuid/next)
(:name event)
(:source event)
(:type event)
(:timestamp event)
(:profile-id event)
(db/inet (:ip-addr event))
(db/tjson (:props event))
(db/tjson (d/without-nils (:context event)))])
(def ^:private event-columns
[:id :name :source :type :tracked-at
:profile-id :ip-addr :props :context])
(defn- handle-events
[{:keys [::db/pool]} {:keys [::rpc/profile-id events ::http/request]}]
(let [ip-addr (audit/parse-client-ip request)
xform (comp
(map #(assoc % :profile-id profile-id))
(map #(assoc % :ip-addr ip-addr))
(map #(assoc % :source "frontend"))
(filter :profile-id)
(map event->row))
events (sequence xform events)]
(when (seq events)
(db/insert-multi! pool :audit-log event-columns events))))
(s/def ::name ::us/string)
(s/def ::type ::us/string)
(s/def ::props (s/map-of ::us/keyword any?))
(s/def ::timestamp dt/instant?)
(s/def ::context (s/map-of ::us/keyword any?))
(s/def ::event
(s/keys :req-un [::type ::name ::props ::timestamp]
:opt-un [::context]))
(s/def ::events (s/every ::event))
(s/def ::push-audit-events
(s/keys :req [::rpc/profile-id]
:req-un [::events]))
(sv/defmethod ::push-audit-events
{::climit/queue :push-audit-events
::climit/key-fn ::rpc/profile-id
::audit/skip true
::doc/added "1.17"}
[{:keys [::db/pool ::wrk/executor] :as cfg} params]
(if (or (db/read-only? pool)
(not (contains? cf/flags :audit-log)))
(do
(l/warn :hint "audit: http handler disabled or db is read-only")
(rph/wrap nil))
(->> (px/submit! executor #(handle-events cfg params))
(p/fmap (constantly nil)))))

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