Compare commits

..

11 Commits

Author SHA1 Message Date
Aitor
1299074bd9 wip 2024-01-02 09:11:50 +01:00
Aitor
6b50c17781 wip 2023-12-20 13:29:29 +01:00
Aitor
e08e2bf64a wip 2023-12-19 14:35:57 +01:00
Alejandro Alonso
77f07f9751 Basic multiple fill support 2023-12-18 13:37:56 +01:00
Aitor
189e6b31d0 wip 2023-12-18 13:18:32 +01:00
Alejandro Alonso
4690945f59 WIP 2023-12-18 12:17:15 +01:00
Aitor
f0d0529f58 wip 2023-12-18 12:02:46 +01:00
Aitor
8525508c11 wip 2023-12-18 11:11:46 +01:00
Alejandro Alonso
eaa9aec8bb Read default shader from file 2023-12-18 11:01:50 +01:00
Aitor
23adf483ff wip 2023-12-18 10:38:26 +01:00
Aitor
bcf01abdbe wip 2023-12-18 10:08:42 +01:00
1663 changed files with 83866 additions and 116260 deletions

View File

@@ -31,43 +31,20 @@ jobs:
- run: cat .cljfmt.edn
- run: clj-kondo --version
- run:
name: "backend fmt check"
working_directory: "./backend"
command: |
yarn install
yarn run fmt:clj:check
# - run:
# name: "fmt check [clj]"
# command: |
# yarn run fmt:clj:check
- run:
name: "exporter fmt check"
working_directory: "./exporter"
command: |
yarn install
yarn run fmt:clj:check
- run:
name: "common fmt check"
working_directory: "./common"
command: |
yarn install
yarn run fmt:clj:check
- run:
name: "frontend fmt check"
working_directory: "./frontend"
command: |
yarn install
yarn run fmt:clj:check
- run:
name: "common linter check"
name: common lint
working_directory: "./common"
command: |
yarn install
yarn run lint:clj
- run:
name: "frontend linter check"
name: frontend lint
working_directory: "./frontend"
command: |
yarn install
@@ -75,14 +52,14 @@ jobs:
yarn run lint:clj
- run:
name: "backend linter check"
name: backend lint
working_directory: "./backend"
command: |
yarn install
yarn run lint:clj
- run:
name: "exporter linter check"
name: exporter lint
working_directory: "./exporter"
command: |
yarn install
@@ -93,7 +70,7 @@ jobs:
working_directory: "./common"
command: |
yarn test
clojure -M:dev:test
clojure -X:dev:test :patterns '["common-tests.*-test"]'
- run:
name: "frontend tests"
@@ -102,21 +79,11 @@ jobs:
yarn install
yarn test
- run:
name: "frontend integration tests"
working_directory: "./frontend"
command: |
yarn install
yarn run compile
clojure -M:dev:shadow-cljs release main
yarn playwright install --with-deps chromium
yarn e2e:test
- run:
name: "backend tests"
working_directory: "./backend"
command: |
clojure -M:dev:test
clojure -X:dev:test :patterns '["backend-tests.*-test"]'
environment:
PENPOT_TEST_DATABASE_URI: "postgresql://localhost/penpot_test"

View File

@@ -4,8 +4,8 @@
promesa.core/-> clojure.core/->
promesa.exec.csp/go-loop clojure.core/loop
rumext.v2/defc clojure.core/defn
rumext.v2/fnc clojure.core/fn
promesa.util/with-open clojure.core/with-open
app.common.schema.generators/let clojure.core/let
app.common.data/export clojure.core/def
app.common.data.macros/get-in clojure.core/get-in
app.common.data.macros/with-open clojure.core/with-open
@@ -15,13 +15,11 @@
:hooks
{:analyze-call
{app.common.data.macros/export hooks.export/export
potok.core/reify hooks.export/potok-reify
app.util.services/defmethod hooks.export/service-defmethod
app.common.record/defrecord hooks.export/penpot-defrecord
app.db/with-atomic hooks.export/penpot-with-atomic
potok.v2.core/reify hooks.export/potok-reify
rumext.v2/fnc hooks.export/rumext-fnc
rumext.v2/lazy-component hooks.export/rumext-lazycomponent
shadow.lazy/loadable hooks.export/rumext-lazycomponent
}}
:output

View File

@@ -12,7 +12,6 @@
(def registry (atom {}))
(defn potok-reify
[{:keys [:node :filename] :as params}]
(let [[rnode rtype & other] (:children node)
@@ -38,9 +37,6 @@
(api/token-node rsym)
(api/vector-node [])]
other))]
;; (prn (api/sexpr result))
{:node result})))
(defn penpot-with-atomic
@@ -75,17 +71,6 @@
{:node result})))
(defn rumext-lazycomponent
[{:keys [node]}]
(let [[cname mdata params & body] (rest (:children node))
[params body] (if (api/vector-node? mdata)
[mdata (cons params body)]
[params body])]
(let [result (api/list-node [(api/token-node 'constantly) nil])]
;; (prn (api/sexpr result))
{:node result})))
(defn penpot-defrecord
[{:keys [:node]}]
(let [[rnode rtype rparams & other] (:children node)

View File

@@ -3,7 +3,6 @@
:remove-surrounding-whitespace? true
:remove-consecutive-blank-lines? false
:extra-indents {rumext.v2/fnc [[:inner 0]]
cljs.test/async [[:inner 0]]
promesa.exec/thread [[:inner 0]]
specify! [[:inner 0] [:inner 1]]}
}

6
.gitignore vendored
View File

@@ -23,7 +23,6 @@
/*.jpg
/*.md
/*.png
/*.svg
/*.sql
/*.txt
/*.yml
@@ -57,7 +56,6 @@
/frontend/package-lock.json
/frontend/resources/fonts/experiments
/frontend/resources/public/*
/frontend/storybook-static/
/frontend/target/
/other/
/scripts/
@@ -69,7 +67,3 @@
clj-profiler/
node_modules
frontend/.storybook/preview-body.html
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -6,6 +6,4 @@ enableImmutableInstalls: false
enableTelemetry: false
httpTimeout: 600000
nodeLinker: node-modules

View File

@@ -1,244 +1,22 @@
# CHANGELOG
## 2.1.2
### :bug: Bugs fixed
- User switch language to "zh_hant" will get 400 [Github #4884](https://github.com/penpot/penpot/issues/4884)
- Smtp config ignoring port if ssl is set [Github #4872](https://github.com/penpot/penpot/issues/4872)
- Ability to let users to authenticate with a private oidc provider only [Github #4963](https://github.com/penpot/penpot/issues/4963)
## 2.1.1
### :sparkles: New features
- Consolidate templates new order and naming [Taiga #8392](https://tree.taiga.io/project/penpot/task/8392)
### :bug: Bugs fixed
- Fix the “search” label in translations [Taiga #8402](https://tree.taiga.io/project/penpot/issue/8402)
- Fix pencil loader [Taiga #8348](https://tree.taiga.io/project/penpot/issue/8348)
- Fix several issues on the OIDC.
- Fix regression on the `email-verification` flag [Taiga #8398](https://tree.taiga.io/project/penpot/issue/8398)
## 2.1.0 - Things can only get better!
### :rocket: Epics and highlights
## 1.20.0
### :boom: Breaking changes & Deprecations
### :heart: Community contributions (Thank you!)
### :sparkles: New features
- Improve auth process [Taiga #7094](https://tree.taiga.io/project/penpot/us/7094)
- Add locking degrees increment (hold shift) on path edition [Taiga #7761](https://tree.taiga.io/project/penpot/issue/7761)
- Persistence & Concurrent Edition Enhancements [Taiga #5657](https://tree.taiga.io/project/penpot/us/5657)
- Allow library colors as recent colors [Taiga #7640](https://tree.taiga.io/project/penpot/issue/7640)
- Missing scroll in viewmode comments [Taiga #7427](https://tree.taiga.io/project/penpot/issue/7427)
- Comments in View mode should mimic the positioning behavior of the Workspace [Taiga #7346](https://tree.taiga.io/project/penpot/issue/7346)
- Misaligned input on comments [Taiga #7461](https://tree.taiga.io/project/penpot/issue/7461)
### :bug: Bugs fixed
- Fix selection rectangle appears on scroll [Taiga #7525](https://tree.taiga.io/project/penpot/issue/7525)
- Fix layer tree not expanding to the bottom edge [Taiga #7466](https://tree.taiga.io/project/penpot/issue/7466)
- Fix guides move when board is moved by inputs [Taiga #8010](https://tree.taiga.io/project/penpot/issue/8010)
- Fix clickable area of Penptot logo in the viewer [Taiga #7988](https://tree.taiga.io/project/penpot/issue/7988)
- Fix constraints dropdown when selecting multiple shapes [Taiga #7686](https://tree.taiga.io/project/penpot/issue/7686)
- Layout and scrollign fixes for the bottom palette [Taiga #7559](https://tree.taiga.io/project/penpot/issue/7559)
- Fix expand libraries when search results are present [Taiga #7876](https://tree.taiga.io/project/penpot/issue/7876)
- Fix color palette default library [Taiga #8029](https://tree.taiga.io/project/penpot/issue/8029)
- Component Library is lost after exporting/importing in .zip format [Github #4672](https://github.com/penpot/penpot/issues/4672)
- Fix problem with moving+selection not working properly [Taiga #7943](https://tree.taiga.io/project/penpot/issue/7943)
- Fix problem with flex layout fit to content not positioning correctly children [Taiga #7537](https://tree.taiga.io/project/penpot/issue/7537)
- Fix black line is displaying after show main [Taiga #7653](https://tree.taiga.io/project/penpot/issue/7653)
- Fix "Share prototypes" modal remains open [Taiga #7442](https://tree.taiga.io/project/penpot/issue/7442)
- Fix "Components visibility and opacity" [#4694](https://github.com/penpot/penpot/issues/4694)
- Fix "Attribute overrides in copies are not exported in zip file" [Taiga #8072](https://tree.taiga.io/project/penpot/issue/8072)
- Fix group not automatically selected in the Layers panel after creation [Taiga #8078](https://tree.taiga.io/project/penpot/issue/8078)
- Fix export boards loses opacity [Taiga #7592](https://tree.taiga.io/project/penpot/issue/7592)
- Fix change color on imported svg also changes the stroke alignment[Taiga #7673](https://github.com/penpot/penpot/pull/7673)
- Fix show in view mode and interactions workflow [Taiga #4711](https://github.com/penpot/penpot/pull/4711)
- Fix internal error when I set up a stroke for some objects without and with stroke [Taiga #7558](https://tree.taiga.io/project/penpot/issue/7558)
- Toolbar keeps toggling on and off on spacebar press [Taiga #7654](https://github.com/penpot/penpot/pull/7654)
- Fix toolbar keeps hiding when click outside workspace [Taiga #7776](https://tree.taiga.io/project/penpot/issue/7776)
- Fix open overlay relative to a frame [Taiga #7563](https://tree.taiga.io/project/penpot/issue/7563)
- Workspace-palette items stay hidden when opening with keyboard-shortcut [Taiga #7489](https://tree.taiga.io/project/penpot/issue/7489)
- Fix SVG attrs are not handled correctly when exporting/importing in .zip [Taiga #7920](https://tree.taiga.io/project/penpot/issue/7920)
- Fix validation error when detaching with two nested copies and a swap [Taiga #8095](https://tree.taiga.io/project/penpot/issue/8095)
- Export shapes that are rotated act a bit strange when reimported [Taiga #7585](https://tree.taiga.io/project/penpot/issue/7585)
- Penpot crashes when a new colorpicker is created while uploading an image to another instance [Taiga #8119](https://tree.taiga.io/project/penpot/issue/8119)
- Removing Underline and Strikethrough Affects the Previous Text Object [Taiga #8103](https://tree.taiga.io/project/penpot/issue/8103)
- Color library loses association with shapes when exporting/importing the document [Taiga #8132](https://tree.taiga.io/project/penpot/issue/8132)
- Fix can't collapse groups when searching in the assets tab [Taiga #8125](https://tree.taiga.io/project/penpot/issue/8125)
- Fix 'Detach instance' shortcut is not working [Taiga #8102](https://tree.taiga.io/project/penpot/issue/8102)
- Fix import file message does not detect 0 as error [Taiga #6824](https://tree.taiga.io/project/penpot/issue/6824)
- Image Color Library is not persisted when exporting/importing in .zip [Taiga #8131](https://tree.taiga.io/project/penpot/issue/8131)
- Fix export files including libraries [Taiga #8266](https://tree.taiga.io/project/penpot/issue/8266)
## 2.0.3
### :bug: Bugs fixed
- Fix chrome scrollbar styling [Taiga #7852](https://tree.taiga.io/project/penpot/issue/7852)
- Fix incorrect password encoding on create-profile manage scritp [Github #3651](https://github.com/penpot/penpot/issues/3651)
## 2.0.2
### :sparkles: Enhancements
- Fix locking contention on cron subsystem (causes backend start blocking)
- Fix locking contention on file object thumbails backend RPC calls
### :bug: Bugs fixed
- Fix color palette sorting [Taiga #7458](https://tree.taiga.io/project/penpot/issue/7458)
- Fix style scoping problem with imported SVG [Taiga #7671](https://tree.taiga.io/project/penpot/issue/7671)
## 2.0.1
### :bug: Bugs fixed
- Fix different issues related to components v2 migrations including [Github #4443](https://github.com/penpot/penpot/issues/4443)
## 2.0.0 - I Just Can't Get Enough
### :rocket: Epics and highlights
- Grid CSS layout [Taiga #4915](https://tree.taiga.io/project/penpot/epic/4915)
- UI redesign [Taiga #4958](https://tree.taiga.io/project/penpot/epic/4958)
- New components System [Taiga #2662](https://tree.taiga.io/project/penpot/epic/2662)
- Swap components [Taiga #1331](https://tree.taiga.io/project/penpot/us/1331)
- Images as fill [Taiga #2983](https://tree.taiga.io/project/penpot/us/2983)
- HTML code generation [Taiga #5277](https://tree.taiga.io/project/penpot/us/5277)
- Light and dark themes [Taiga #2287](https://tree.taiga.io/project/penpot/us/2287)
### :boom: Breaking changes & Deprecations
- New strokes default to inside border [Taiga #6847](https://tree.taiga.io/project/penpot/issue/6847)
- Change default z ordering on layers in flex layout. The previous behavior was inconsistent with how HTML works and we changed it to be more consistent. Previous layers that overlapped could be hidden, the fastest way to fix this is changing the z-index property but a better way is to change the order of your layers.
### :heart: Community contributions (Thank you!)
- New Hausa, Yoruba and Igbo translations and update translation files (by All For Tech Empowerment Foundation) [Taiga #6950](https://tree.taiga.io/project/penpot/us/6950), [Taiga #6534](https://tree.taiga.io/project/penpot/us/6534)
- Hide bounding-box when editing shape (by @VasilevsVV) [#3930](https://github.com/penpot/penpot/pull/3930)
- CTRL + "+" to zoom into canvas instead of browser (by @audriu) [#3848](https://github.com/penpot/penpot/pull/3848)
- Add dev deps.edn in the project root (by @PEZ) [#3794](https://github.com/penpot/penpot/pull/3794)
- Allow passing overrides to frontend nginx config (by @m90) [#3602](https://github.com/penpot/penpot/pull/3602)
- Update index.njk to remove typo (by @fdvmoreira) [#155](https://github.com/penpot/penpot-docs/pull/155)
- Typo (by StephanEggermont) [#157](https://github.com/penpot/penpot-docs/pull/157)
### :sparkles: New features
- Send comments with Ctrl+Enter / Cmd + Enter [Taiga #6085](https://tree.taiga.io/project/penpot/issue/6085)
- Select through stroke only rectangle [Taiga #5484](https://tree.taiga.io/project/penpot/issue/5484)
- Stroke default position [Taiga #6847](https://tree.taiga.io/project/penpot/issue/6847)
- Override browser Ctrl+ and Ctrl- zoom with Penpot Zoom [Taiga #3200](https://tree.taiga.io/project/penpot/us/3200)
- Improve the way handlers work on flex layouts [Taiga #6598](https://tree.taiga.io/project/penpot/us/6598)
- Add menu entry for toggle between light/dark theme [Taiga #6829](https://tree.taiga.io/project/penpot/issue/6829)
- Switch themes shortcut [Taiga #6644](https://tree.taiga.io/project/penpot/us/6644)
- Constraints section at design tab new position [Taiga #6830](https://tree.taiga.io/project/penpot/issue/6830)
- [PICKER] File library colors order [Taiga #5399](https://tree.taiga.io/project/penpot/us/5399)
- Onboarding invitations improvements [Taiga #5974](https://tree.taiga.io/project/penpot/us/5974)
- [PERFORMANCE] Workspace thumbnails refactor [Taiga #5828](https://tree.taiga.io/project/penpot/us/5828)
- [PERFORMANCE] Add performance optimizations to shape rendering [Taiga #5835](https://tree.taiga.io/project/penpot/us/5835)
- [PERFORMANCE] Optimize SVG output [Taiga #4134](https://tree.taiga.io/project/penpot/us/4134)
- [PERFORMANCE] Optimize svg on importation [Taiga #5879](https://tree.taiga.io/project/penpot/us/5879)
- [PERFORMANCE] Optimization tasks related to design tab file [Taiga #5760](https://tree.taiga.io/project/penpot/us/5760)
- [INSTALL] Ability to setup features by team [Taiga #6108](https://tree.taiga.io/project/penpot/us/6108)
- [IMAGES] Keep aspect ratio option [Taiga #6933](https://tree.taiga.io/project/penpot/us/6933)
- [INSPECT] UI review [Taiga #5687](https://tree.taiga.io/project/penpot/us/5687)
- [GRID LAYOUT] Phase 1 [Taiga #4303](https://tree.taiga.io/project/penpot/us/4303)
- [GRID LAYOUT] Inspect code for Grid [Taiga #5277](https://tree.taiga.io/project/penpot/us/5277)
- [GRID LAYOUT] Phase 1 polishing [Taiga #5612](https://tree.taiga.io/project/penpot/us/5612)
- [GRID LAYOUT] Improvements & Feedback [Taiga #6047](https://tree.taiga.io/project/penpot/us/6047)
- [COMPONENTS] Naming of the main component [Taiga #5291](https://tree.taiga.io/project/penpot/us/5291)
- [COMPONENTS] Rework inside of components - Library page [Taiga #2918](https://tree.taiga.io/project/penpot/us/2918)
- [COMPONENTS] Update component when updating main instance [Taiga #3794](https://tree.taiga.io/project/penpot/us/3794)
- [COMPONENTS] Main component new behavior [Taiga #3796](https://tree.taiga.io/project/penpot/us/3796)
- [COMPONENTS] Main component look & feel [Taiga #5290](https://tree.taiga.io/project/penpot/us/5290)
- [COMPONENTS] Library view [Taiga #2880](https://tree.taiga.io/project/penpot/us/2880)
- [COMPONENTS] Positioning inside a component should relative, as in boards [Taiga #2826](https://tree.taiga.io/project/penpot/us/2826)
- [COMPONENTS] Update message should show only if affecting at components that are being used at a file [Taiga #1397](https://tree.taiga.io/project/penpot/us/1397)
- [COMPONENTS] Annotations [Taiga #4957](https://tree.taiga.io/project/penpot/us/4957)
- [COMPONENTS] Synchronization order for nested components [Taiga #5439](https://tree.taiga.io/project/penpot/us/5439)
- [COMPONENTS] Libraries modal zero case [Taiga #5294](https://tree.taiga.io/project/penpot/us/5294)
- [COMPONENTS] Contextual menu casuistics [Taiga #5292](https://tree.taiga.io/project/penpot/us/5292)
- [COMPONENTS] Libraries publishing flow review [Taiga #5293](https://tree.taiga.io/project/penpot/us/5293)
- [COMPONENTS] Add loading text to Libraries modal [Taiga #6702](https://tree.taiga.io/project/penpot/us/6702)
- [COMPONENTS] Components rename and organization in bulk [Taiga #2877](https://tree.taiga.io/project/penpot/us/2877)
- [COMPONENTS] Info overlay about components V2 [Taiga #6276](https://tree.taiga.io/project/penpot/us/6276)
- [REDESIGN] New styles basics [Taiga #4967](https://tree.taiga.io/project/penpot/us/4967)
- [REDESIGN] Layers tab redesign [Taiga #4966](https://tree.taiga.io/project/penpot/us/4966)
- [REDESIGN] Design tab phase 1 [Taiga #4982](https://tree.taiga.io/project/penpot/us/4966)
- [REDESIGN] Assets tab redesign [Taiga #4984](https://tree.taiga.io/project/penpot/us/4984)
- [REDESIGN] Palette panels (colors, typographies...) [Taiga #4983](https://tree.taiga.io/project/penpot/us/4983)
- [REDESIGN] Workspace structure [Taiga #4988](https://tree.taiga.io/project/penpot/us/4988)
- [REDESIGN] Shortcut tab [Taiga #4989](https://tree.taiga.io/project/penpot/us/4989)
- [REDESIGN] Toolbar [Taiga #5500](https://tree.taiga.io/project/penpot/us/5500)
- [REDESIGN] History tab [Taiga #5481](https://tree.taiga.io/project/penpot/us/5481)
- [REDESIGN] Path options/toolbar [Taiga #5815](https://tree.taiga.io/project/penpot/us/5815)
- [REDESIGN] Design tab phase 2 [Taiga #5814](https://tree.taiga.io/project/penpot/us/5814)
- [REDESIGN] Design tab phase 3 and dashboard details [Taiga #5920](https://tree.taiga.io/project/penpot/us/5920)
- [REDESIGN] Dashboard [Taiga #5164](https://tree.taiga.io/project/penpot/us/5164)
- [REDESIGN] New Dashboard UI [Taiga #5869](https://tree.taiga.io/project/penpot/us/5869)
- [REDESIGN] Prototype tab [Taiga #4985](https://tree.taiga.io/project/penpot/us/4985)
- [REDESIGN] Code tab [Taiga #4986](https://tree.taiga.io/project/penpot/us/4986)
- [REDESIGN] Modals and alert messages [Taiga #5915](https://tree.taiga.io/project/penpot/us/5915)
- [REDESIGN] Comments page [Taiga #5917](https://tree.taiga.io/project/penpot/us/5917)
- [REDESIGN] View Mode [Taiga #5163](https://tree.taiga.io/project/penpot/us/5163)
- [REDESIGN] Miscellaneous tasks [Taiga #6050](https://tree.taiga.io/project/penpot/us/6050)
- [REDESIGN] Swap components [Taiga #6739](https://tree.taiga.io/project/penpot/us/6739)
- [REDESIGN] Font selector [Taiga #6677](https://tree.taiga.io/project/penpot/us/6677)
- [REDESIGN] Colour system of alerts and notifications [Taiga #6746](https://tree.taiga.io/project/penpot/us/6746)
- [REDESIGN] Review text in paragraphs for accessibility [Taiga #6703](https://tree.taiga.io/project/penpot/us/6703)
- [REDESIGN] Interaction icons [Taiga #6880](https://tree.taiga.io/project/penpot/us/6880)
- [REDESIGN] Panels visual separations [Taiga #6692](https://tree.taiga.io/project/penpot/us/6692)
- [REDESIGN] Onboarding slides [Taiga #6678](https://tree.taiga.io/project/penpot/us/6678)
### :bug: Bugs fixed
- Fix pixelated thumbnails [Github #3681](https://github.com/penpot/penpot/issues/3681), [Github #3661](https://github.com/penpot/penpot/issues/3661)
- Fix problem with not applying colors to boards [Github #3941](https://github.com/penpot/penpot/issues/3941)
- Fix problem with path editor undoing changes [Github #3998](https://github.com/penpot/penpot/issues/3998)
- [View mode] Open overlay places frame in the wrong position when paired with a fixed element [Taiga #6385](https://tree.taiga.io/project/penpot/issue/6385)
- Flex Layout: Fit-content not recalculated after deleting an element [Taiga #5968](https://tree.taiga.io/project/penpot/issue/5968)
- Selecting from Color Palette does not work for board when there is no existing fill [Taiga #6464](https://tree.taiga.io/project/penpot/issue/6464)
- Color thumbnails are consistently rounded in the inspect code mode [Taiga #5886](https://tree.taiga.io/project/penpot/issue/5886)
- Adding vector path points before first point of existing open path not working [Taiga #6593](https://tree.taiga.io/project/penpot/issue/6593)
- Some image formats include the extension when importing [Taiga #5485](https://tree.taiga.io/project/penpot/issue/5485)
- Gradient color tool doesn't work properly with flipped items [Taiga #6485](https://tree.taiga.io/project/penpot/issue/6485)
- [TEXT] Align options are not shown when several text are selected [Taiga #5948](https://tree.taiga.io/project/penpot/issue/5948)
- [VIEW MODE] Comments not working properly on multiple pages [Taiga #6281](https://tree.taiga.io/project/penpot/issue/6281)
- [PERFORMANCE] Alignments are slow [Taiga #5865](https://tree.taiga.io/project/penpot/issue/5865)
- [EXPORT] Exporting an element with a non-visible drop shadow displays the shadow either way [Taiga #6768](https://tree.taiga.io/project/penpot/issue/6768)
- [SAFARI] Color picker cursor is not pointing correctly [Taiga #6733](https://tree.taiga.io/project/penpot/issue/6733)
- [Import Files] When user has imported .penpot file with new file name of previously downloaded library file the default library file name is set for it [Taiga #5596](https://tree.taiga.io/project/penpot/issue/5596)
- Issue when resizing a duotone FA icon [Taiga #5935](https://tree.taiga.io/project/penpot/issue/5935)
- "Hide grid" keyboard shortcut broken [Taiga #5102](https://tree.taiga.io/project/penpot/issue/5102)
- Picking a gradient color in recent colors for a new color in the assets tab crashes Penpot [Taiga #5601](https://tree.taiga.io/project/penpot/issue/5601)
- Thumbnails not loading [Taiga #6012](https://tree.taiga.io/project/penpot/issue/6012)
- Don't show signup link/form when registration is disabled. [Taiga #1196](https://tree.taiga.io/project/penpot/issue/1196)
- Registration Page UI UX issue with small resolutions [Taiga #1693](https://tree.taiga.io/project/penpot/issue/1693)
- [LOGIN] "E-Mail-Adress" input field is set to type 'text' instead of 'eMail [Taiga #1921](https://tree.taiga.io/project/penpot/issue/1921)
- Handling correctly slashes "/" in emails [Taiga #4906](https://tree.taiga.io/project/penpot/issue/4906)
- Tab character in texts crashes the app [Taiga #4418](https://tree.taiga.io/project/penpot/issue/4418)
- Text does not match export [Taiga #4129](https://tree.taiga.io/project/penpot/issue/4129)
- Scrollbars cover the layers carets [Taiga #4431](https://tree.taiga.io/project/penpot/issue/4431)
- Horizontal ruler disappear when overlapping a board [Taiga #4138](https://tree.taiga.io/project/penpot/issue/4138)
- Resize shape + Alt key is not working [Taiga #3447](https://tree.taiga.io/project/penpot/issue/3447)
- Libraries images broken on premise [Taiga #4573](https://tree.taiga.io/project/penpot/issue/4573)
- [VIEWER] Cannot scroll down in code </> mode [Taiga #4655](https://tree.taiga.io/project/penpot/issue/4655)
- Strange cursor behavior after clicking viewport with text tool [Taiga #4363](https://tree.taiga.io/project/penpot/issue/4363)
- Selected color affects all of them [Taiga #5285](https://tree.taiga.io/project/penpot/issue/5285)
- Fix problem with shadow negative spread [Github #3421](https://github.com/penpot/penpot/issues/3421)
- Fix problem with linked colors to strokes [Github #3522](https://github.com/penpot/penpot/issues/3522)
- Fix problem with hand tool stuck [Github #3318](https://github.com/penpot/penpot/issues/3318)
- Fix problem with fix scrolling on nested elements [Github #3508](https://github.com/penpot/penpot/issues/3508)
- Fix problem when changing typography assets [Github #3683](https://github.com/penpot/penpot/issues/3683)
- Internal error when you copy and paste some main components between files [Taiga #7397](https://tree.taiga.io/project/penpot/issue/7397)
- Fix toolbar disappearing [Taiga #7411](https://tree.taiga.io/project/penpot/issue/7411)
- Fix long text on tab breaks UI [Taiga #7421](https://tree.taiga.io/project/penpot/issue/7421)
- Fix pixelated thumbnails [Github
#3681](https://github.com/penpot/penpot/issues/3681) [Github #3661](https://github.com/penpot/penpot/issues/3661)
### :arrow_up: Deps updates
### :heart: Community contributions by (Thank you!)
## 1.19.5

123
README.md
View File

@@ -2,11 +2,10 @@
[uri_license]: https://www.mozilla.org/en-US/MPL/2.0
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://penpot.app/images/readme/github-dark-mode.png">
<source media="(prefers-color-scheme: light)" srcset="https://penpot.app/images/readme/github-light-mode.png">
<img alt="penpot header image" src="https://penpot.app/images/readme/github-light-mode.png">
</picture>
<h1 align="center">
<br>
<img src="https://penpot.app/images/readme/git-readme-header.png" 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>
<a href="https://gitter.im/penpot/community" rel="nofollow"><img src="https://camo.githubusercontent.com/5b0aecb33434f82a7b158eab7247544235ada0cf7eeb9ce8e52562dd67f614b7/68747470733a2f2f6261646765732e6769747465722e696d2f736572656e6f2d78797a2f636f6d6d756e6974792e737667" alt="Gitter" data-canonical-src="https://badges.gitter.im/sereno-xyz/community.svg" style="max-width:100%;"></a>
@@ -14,36 +13,22 @@
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img src="https://camo.githubusercontent.com/daadb4894128d1e19b72d80236f5959f1f2b47f9fe081373f3246131f0189f6c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f476974706f642d72656164792d2d746f2d2d636f64652d626c75653f6c6f676f3d676974706f64" alt="Gitpod ready-to-code" data-canonical-src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a></p>
<p align="center">
<a href="https://penpot.app/"><b>Website</b></a>
<a href="https://help.penpot.app/technical-guide/getting-started/"><b>Getting Started</b></a>
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a>
<a href="https://help.penpot.app/user-guide/introduction/info/"><b>Tutorials & Info</b></a>
<a href="https://community.penpot.app/"><b>Community</b></a>
</p>
<p align="center">
<a href="https://www.youtube.com/@Penpot"><b>Youtube</b></a>
<a href="https://peertube.kaleidos.net/a/penpot_app/video-channels"><b>Peertube</b></a>
<a href="https://www.linkedin.com/company/penpot/"><b>Linkedin</b></a> •
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a> •
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a> •
<a href="https://twitter.com/penpotapp"><b>X</b></a>
<a href="https://penpot.app/"><b>Website</b></a>
<a href="https://help.penpot.app/technical-guide/getting-started/"><b>Getting Started</b></a>
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a>
<a href="https://help.penpot.app/user-guide/introduction/info/"><b>Tutorials & Info</b></a>
<a href="https://community.penpot.app/"><b>Community</b></a>
<a href="https://twitter.com/penpotapp"><b>Twitter</b></a> •
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a> •
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a>
<a href="https://www.youtube.com/channel/UCAqS8G72uv9P5HG1IfgnQ9g"><b>Youtube</b></a>
</p>
<br />
![feature-readme](https://user-images.githubusercontent.com/1045247/189871786-0b44f7cf-3a0a-4445-a87b-9919ec398bf7.gif)
[Penpot video](https://github.com/penpot/penpot/assets/5446186/b8ad0764-585e-4ddc-b098-9b4090d337cc)
🎇 **Penpot Fest exceeded all expectations - it was a complete success!** 🎇 Penpot Fest is our first Design event that brought designers and developers from the Open Source communities and beyond. Watch the replay of the talks on our [Youtube channel](https://www.youtube.com/playlist?list=PLgcCPfOv5v56-fghJo2dHNBqL9zlDTslh) or [Peertube channel](https://peertube.kaleidos.net/w/p/1tWgyJTt8sKbWwCEcBimZW)
<br />
Penpot is the first **open-source** design tool for design and code collaboration. Designers can create stunning designs, interactive prototypes, design systems at scale, while developers enjoy ready-to-use code and make their workflow easy and fast. And all of this with no handoff drama.
Penpot is available on browser and [self host](https://penpot.app/self-host). Its web-based and works with open standards (SVG, CSS and HTML). And last but not least, its free!
Penpots latest [huge release 2.0](https://penpot.app/dev-diaries), takes the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more. Plus, it's faster and more accessible.
🎇 **Penpot Fest** is our design, code & Open Source event. Check out the highlights from [Penpot Fest 2023 edition](https://www.youtube.com/watch?v=sOpLZaK5mDc)!
Penpot is the first **Open Source** design and prototyping platform meant for cross-domain teams. Non dependent on operating systems, Penpot is web based and works with open standards (SVG). Penpot invites designers all over the world to fall in love with open source while getting developers excited about the design process in return.
## Table of contents ##
@@ -55,47 +40,48 @@ Penpots latest [huge release 2.0](https://penpot.app/dev-diaries), takes the
## Why Penpot ##
Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration.
Penpot makes design and prototyping accessible to every team in the world.
### Designed for developers ###
Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo".
### For cross-domain teams ###
We have a clear focus on design and code teams and our capabilities reflect exactly that. The less hand-off mindset, the more fun for everyone.
### Inspect mode ###
Work with ready-to-use code and make your workflow easy and fast. The inspect tab gives instant access to SVG, CSS and HTML code.
### Multiplatform ###
Being web based, Penpot is not dependent on operating systems or local installations, you will only need to run a modern browser.
### Self host your own instance ###
Provide your team or organization with a completely owned collaborative design tool. Use Penpot's cloud service or deploy your own Penpot server.
### Integrations ###
Penpot offers integration into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens.
### Whats great for design ###
With Penpot you can design libraries to share and reuse; turn design elements into components and tokens to allow reusability and scalability; and build realistic user flows and interactions.
<br />
### Open Standards ###
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://img.plasmic.app/img-optimizer/v1/img?src=https%3A%2F%2Fimg.plasmic.app%2Fimg-optimizer%2Fv1%2Fimg%2F9dd677c36afb477e9666ccd1d3f009ad.png" alt="Open Source" style="width: 65%;">
<img src="https://penpot.app/images/readme/git-open.png" alt="Open Source" style="width: 65%;">
</p>
<br />
## Getting started ##
### Install with Elestio ###
Penpot is the only design & prototype platform that is deployment agnostic. You can use it or deploy it anywhere.
[Elestio](https://elest.io/) offers a fully managed service for on-premise instances of a selection of open-source software! This means you can deploy a dedicated instance of Penpot in just 3 minutes with no technical knowledge needed.
Learn how to install it with Elestio and Docker, or other options on [our website](https://penpot.app/self-host).
<br />
You dont need to worry about DNS configuration, SMTP, backups, SSL certificates, OS & Penpot upgrades, and much more.
[Get started with Elestio.](https://help.penpot.app/technical-guide/getting-started/#install-with-elestio)
### Install with Docker ###
You can also get started with Penpot locally or self-host it with **docker** and **docker-compose**.
Heres a step-by-step guide on [getting started with Docker.](https://help.penpot.app/technical-guide/getting-started/#install-with-docker)
### Penpot cloud app ###
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://site-assets.plasmic.app/2168cf524dd543caeff32384eb9ea0a1.svg" alt="Open Source" style="width: 65%;">
<img src="https://penpot.app/images/readme/git-self-host.png" alt="Getting started" style="width: 65%;">
</p>
<br />
## Community ##
We love the Open Source software community. Contributing is our passion and if its yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
We love the open source software community. Contributing is our passion and if its yours too, [participate](https://community.penpot.app/) and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your ideas and code are welcome!
If you need help or have any questions; if youd like to share your experience using Penpot or get inspired; if youd rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)!
@@ -107,41 +93,30 @@ You will find the following categories:
- [Events and Announcements](https://community.penpot.app/c/announcements/5)
- [Inside Penpot](https://community.penpot.app/c/inside-penpot/21)
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
- [Design and Code Essentials](https://community.penpot.app/c/design-and-code-essentials/22)
<br />
<p align="center">
<img src="https://github.com/penpot/penpot/assets/5446186/6ac62220-a16c-46c9-ab21-d24ae357ed03" alt="Community" style="width: 65%;">
<img src="https://penpot.app/images/readme/git-collaborate.png" alt="Communnity" style="width: 65%;">
</p>
<br />
## Contributing ##
Any contribution will make a difference to improve Penpot. How can you get involved?
Every sort of contribution will be very helpful to enhance Penpot. How youll participate? All your ideas, designs and code are welcome:
Choose your way:
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community
- Invite your [team to join](https://design.penpot.app/#/auth/register)
- Star this repo and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app) and [X](https://twitter.com/penpotapp).
- Participate in the [Community](https://community.penpot.app/) space by asking and answering questions; reacting to others articles; opening your own conversations and following along on decisions affecting the project.
- Star this repo and follow us on Social Media: [Twitter](https://twitter.com/penpotapp), [Instagram](https://instagram.com/penpot.app), [Youtube](https://www.youtube.com/c/Penpot) or [Mastodon](https://fosstodon.org/@penpot/).
- Participate in the [Community](https://community.penpot.app/) asking and answering questions, reacting to others articles or opening your own conversations.
- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues)
- Create and [share Libraries & templates](https://penpot.app/libraries-templates.html) that will be helpful for the community
- Become a [translator](https://help.penpot.app/contributing-guide/translations)
- Give feedback: [Email us](mailto:support@penpot.app)
- Give feedback: [Mail us](mailto:support@penpot.app)
- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpots repository and make changes in both front and back end
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/).
<br />
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://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;">
<img src="https://penpot.app/images/readme/git-community.png" alt="Contributing" style="width: 65%;">
</p>
<br />
## Resources ##
You can ask and answer questions, have open-ended conversations, and follow along on decisions affecting the project.
@@ -166,4 +141,4 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC
```
Penpot is a Kaleidos [open source project](https://kaleidos.net/)
Penpot is a Kaleidos [open source project](https://kaleidos.net/products)

View File

@@ -2,19 +2,12 @@
We want to thank to the amazing people that help us! Thank you! You're the best!
Feel free you make a PR updating this file if you miss you in the
list.
## Security
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
* Vaibhav Shukla
* Hassan Ahmed (Alias Xen Lee)
* Michal Biesiada (@mbiesiad)
## Internationalization
* [00ff88](https://hosted.weblate.org/user/00ff88)
* [AhmadHB](https://hosted.weblate.org/user/AhmadHB)
* [Aimee](https://hosted.weblate.org/user/Aimee)
@@ -96,7 +89,6 @@ list.
* [zcraber](https://hosted.weblate.org/user/zcraber)
## Libraries & templates
* systxema
* plumilla
* victor crespo

View File

@@ -3,10 +3,10 @@
:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.0-alpha12"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
org.clojure/clojure {:mvn/version "1.12.0-alpha5"}
org.clojure/tools.namespace {:mvn/version "1.4.4"}
com.github.luben/zstd-jni {:mvn/version "1.5.6-3"}
com.github.luben/zstd-jni {:mvn/version "1.5.5-10"}
io.prometheus/simpleclient {:mvn/version "0.16.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
@@ -17,7 +17,7 @@
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
io.lettuce/lettuce-core {:mvn/version "6.3.2.RELEASE"}
io.lettuce/lettuce-core {:mvn/version "6.2.6.RELEASE"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
funcool/yetti
@@ -26,13 +26,12 @@
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.939"}
metosin/reitit-core {:mvn/version "0.7.0"}
nrepl/nrepl {:mvn/version "1.1.2"}
cider/cider-nrepl {:mvn/version "0.48.0"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"}
metosin/reitit-core {:mvn/version "0.6.0"}
nrepl/nrepl {:mvn/version "1.1.0"}
cider/cider-nrepl {:mvn/version "0.43.1"}
org.postgresql/postgresql {:mvn/version "42.7.3"}
org.xerial/sqlite-jdbc {:mvn/version "3.46.0.0"}
org.postgresql/postgresql {:mvn/version "42.6.0"}
com.zaxxer/HikariCP {:mvn/version "5.1.0"}
@@ -43,7 +42,7 @@
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.8"}
org.jsoup/jsoup {:mvn/version "1.17.2"}
org.jsoup/jsoup {:mvn/version "1.16.2"}
org.im4java/im4java
{:git/tag "1.4.0-penpot-2"
:git/sha "e2b3e16"
@@ -54,11 +53,11 @@
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
dawran6/emoji {:mvn/version "0.1.5"}
markdown-clj/markdown-clj {:mvn/version "1.12.1"}
markdown-clj/markdown-clj {:mvn/version "1.11.7"}
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.25.63"}
software.amazon.awssdk/s3 {:mvn/version "2.20.138"}
}
:paths ["src" "resources" "target/classes"]
@@ -74,13 +73,16 @@
:build
{:extra-deps
{io.github.clojure/tools.build {:git/tag "v0.10.3" :git/sha "15ead66"}}
{io.github.clojure/tools.build {:git/tag "v0.9.5" :git/sha "24f2894"}}
:ns-default build}
:test
{:main-opts ["-m" "kaocha.runner"]
:jvm-opts ["-Dlog4j2.configurationFile=log4j2-devenv-repl.xml"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}}
{:extra-paths ["test"]
:extra-deps
{io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}
:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test}
:outdated
{:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}}
@@ -88,8 +90,8 @@
:jmx-remote
{:jvm-opts ["-Dcom.sun.management.jmxremote"
"-Dcom.sun.management.jmxremote.port=9091"
"-Dcom.sun.management.jmxremote.rmi.port=9091"
"-Dcom.sun.management.jmxremote.port=9090"
"-Dcom.sun.management.jmxremote.rmi.port=9090"
"-Dcom.sun.management.jmxremote.local.only=false"
"-Dcom.sun.management.jmxremote.authenticate=false"
"-Dcom.sun.management.jmxremote.ssl=false"

View File

@@ -7,9 +7,7 @@
(ns user
(:require
[app.common.data :as d]
[app.common.debug :as debug]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.fressian :as fres]
[app.common.geom.matrix :as gmt]
[app.common.logging :as l]
@@ -44,7 +42,7 @@
[clojure.walk :refer [macroexpand-all]]
[criterium.core :as crit]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.core]
[integrant.core :as ig]
[malli.core :as m]
[malli.dev.pretty :as mdp]
@@ -56,12 +54,8 @@
[promesa.exec :as px]))
(repl/disable-reload! (find-ns 'integrant.core))
(repl/disable-reload! (find-ns 'app.common.debug))
(set! *warn-on-reflection* true)
(add-tap #'debug/tap-handler)
;; --- Benchmarking Tools
(defmacro run-quick-bench
@@ -137,11 +131,8 @@
;; :v6 v6
;; }])))
(defn calculate-frames
[{:keys [data]}]
(->> (vals (:pages-index data))
(mapcat (comp vals :objects))
(filter cfh/is-direct-child-of-root?)
(filter cfh/frame-shape?)
(count)))
(defonce debug-tap
(do
(add-tap #(locking debug-tap
(prn "tap debug:" %)))
1))

View File

@@ -4,19 +4,19 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "yarn@4.2.2",
"packageManager": "yarn@4.0.2",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"dependencies": {
"luxon": "^3.4.4",
"sax": "^1.4.1"
"luxon": "^3.4.2",
"sax": "^1.2.4"
},
"devDependencies": {
"nodemon": "^3.1.2",
"nodemon": "^3.0.1",
"source-map-support": "^0.5.21",
"ws": "^8.17.0"
"ws": "^8.13.0"
},
"scripts": {
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",

View File

@@ -168,7 +168,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name}}!</div>
</td>
</tr>
<tr>
@@ -475,4 +475,4 @@
</div>
</body>
</html>
</html>

View File

@@ -1,4 +1,4 @@
Hello {{name|abbreviate:25}}!
Hello {{name}}!
We received a request to change your current email to {{ pending-email }}.

View File

@@ -11,7 +11,7 @@
{% if profile %}
<span>
<span>Name: </span>
<span><code>{{profile.fullname|abbreviate:25}}</code></span>
<span><code>{{profile.fullname}}</code></span>
</span>
<br />
@@ -34,7 +34,7 @@
</p>
<p>
<strong>Subject:</strong><br />
<span>{{subject|abbreviate:300}}</span>
<span>{{subject}}</span>
</p>
<p>

View File

@@ -173,7 +173,7 @@
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">{{invited-by}} has invited you to join the team “{{ team }}”.</div>
</td>
</tr>
<tr>
@@ -465,4 +465,4 @@
</div>
</body>
</html>
</html>

View File

@@ -1,6 +1,6 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.
{{invited-by}} has invited you to join the team “{{ team }}”.
Accept invitation using this link:

View File

@@ -168,7 +168,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name}}!</div>
</td>
</tr>
<tr>
@@ -470,4 +470,4 @@
</div>
</body>
</html>
</html>

View File

@@ -1,4 +1,4 @@
Hello {{name|abbreviate:25}}!
Hello {{name}}!
We received a request to reset your password. Click the link below to choose a
new one:

View File

@@ -168,7 +168,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name|abbreviate:25}}!</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">Hello {{name}}!</div>
</td>
</tr>
<tr>

View File

@@ -1,4 +1,4 @@
Hello {{name|abbreviate:25}}!
Hello {{name}}!
Thanks for signing up for your Penpot account! Please verify your email using the
link below and get started building mockups and prototypes today!

View File

@@ -1,39 +1,30 @@
[{:id "wireframing-kit"
:name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/wireframing-kit.penpot"}
{:id "prototype-examples"
:name "Prototipe template"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/prototype-examples.penpot"}
{:id "plants-app"
:name "UI mockup example"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Plants-app.penpot"}
{:id "penpot-design-system"
:name "Design system example"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Penpot-Design-system.penpot"}
[{:id "material-design-3"
:name "Material Design 3"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Material%20Design%203.penpot"}
{:id "tutorial-for-beginners"
:name "Tutorial for beginners"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/tutorial-for-beginners.penpot"}
{:id "lucide-icons"
:name "Lucide Icons"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Lucide-icons.penpot"}
{:id "font-awesome"
:name "Font Awesome"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Font-Awesome.penpot"}
{:id "black-white-mobile-templates"
:name "Black & White Mobile Templates"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Black-White-Mobile-Templates.penpot"}
{:id "avataaars"
:name "Avataaars"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Avataaars-by-Pablo-Stanley.penpot"}
{:id "ux-notes"
:name "UX Notes"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/UX-Notes.penpot"}
{:id "whiteboarding-kit"
:name "Whiteboarding Kit"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"}
{:id "open-color-scheme"
:name "Open Color Scheme"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Open-Color-Scheme.penpot"}
{:id "penpot-design-system"
:name "Penpot Design System"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Penpot-Design-system.penpot"}
{:id "flex-layout-playground"
:name "Flex Layout Playground"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Flex-Layout-Playground.penpot"}]
:file-uri "https://github.com/penpot/penpot-files/raw/main/Flex%20Layout%20Playground.penpot"}
{:id "wireframing-kit"
:name "Wireframing Kit"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/wireframing-kit.penpot"}
{:id "ant-design"
:name "Ant Design UI Kit (lite)"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Ant-Design-UI-Kit-Lite.penpot"}
{:id "cocomaterial"
:name "Cocomaterial"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Cocomaterial.penpot"}
{:id "circum-icons"
:name "Circum Icons pack"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/CircumIcons.penpot"}
{:id "coreui"
:name "CoreUI"
:file-uri "https://github.com/penpot/penpot-files/raw/main/CoreUI%20DesignSystem%20(DEMO).penpot"}
{:id "whiteboarding-kit"
:name "Whiteboarding Kit"
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"}]

View File

@@ -37,13 +37,6 @@
<h2>GENERAL NOTES</h2>
<h3>HTTP Transport & Methods</h3>
<p>The HTTP is the transport method for accesing this API; all
functions can be called using POST HTTP method; the functions
that starts with <b>get-</b> in the name, can use GET HTTP
method which in many cases benefits from the HTTP cache.</p>
<h3>Authentication</h3>
<p>The penpot backend right now offers two way for authenticate the request:
<b>cookies</b> (the same mechanism that we use ourselves on accessing the API from the

View File

@@ -145,6 +145,17 @@ Debug Main Page
</small>
</div>
<div class="row">
<label>Ignore index errors?</label>
<input type="checkbox" name="ignore-index-errors" checked/>
<br />
<small>
Do not break on index lookup errors (remap operation).
Useful when importing a broken file that has broken
relations or missing pieces.
</small>
</div>
<div class="row">
<input type="submit" name="upload" value="Upload" />
</div>
@@ -157,7 +168,7 @@ Debug Main Page
<legend>Reset file version</legend>
<desc>Allows reset file data version to a specific number/</desc>
<form method="post" action="/dbg/actions/reset-file-version">
<form method="post" action="/dbg/actions/reset-file-data-version">
<div class="row">
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
</div>

View File

@@ -3,26 +3,15 @@
;; Optional: queue, ommited means Integer/MAX_VALUE
;; Optional: timeout, ommited means no timeout
;; Note: queue and timeout are excluding
{:update-file/global {:permits 20}
:update-file/by-profile
{:update-file/by-profile
{:permits 1 :queue 5}
:process-font/global {:permits 4}
:process-font/by-profile {:permits 1}
:update-file/global {:permits 20}
:derive-password/global {:permits 8}
:process-font/global {:permits 4}
:process-image/global {:permits 8}
:process-image/by-profile {:permits 1}
:auth/global {:permits 8}
:root/global
{:permits 40}
:root/by-profile
{:permits 10}
:file-thumbnail-ops/global
{:permits 20}
:file-thumbnail-ops/by-profile
{:permits 2}

View File

@@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<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="true" />
</Console>
<RollingFile name="main" fileName="logs/main-latest.log" filePattern="logs/main-%i.log">
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
alwaysWriteExceptions="true" />
<Policies>
<SizeBasedTriggeringPolicy size="50M"/>
</Policies>
<DefaultRolloverStrategy max="20"/>
</RollingFile>
</Appenders>
<Loggers>
<Logger name="io.lettuce" level="error" />
<Logger name="com.zaxxer.hikari" level="error"/>
<Logger name="org.postgresql" level="error" />
<Logger name="app.binfile" level="debug" />
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="trace" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.http.sse" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />
<Logger name="app.rpc.climit" level="debug" />
<Logger name="app.common.files.migrations" level="debug" />
<Logger name="app.loggers" level="debug" additivity="false">
<AppenderRef ref="main" level="debug" />
</Logger>
<Logger name="app" level="all" additivity="false">
<AppenderRef ref="main" level="trace" />
</Logger>
<Logger name="user" level="trace" additivity="false">
<AppenderRef ref="main" level="trace" />
</Logger>
<Root level="info">
<AppenderRef ref="main" />
</Root>
</Loggers>
</Configuration>

View File

@@ -6,13 +6,13 @@
alwaysWriteExceptions="true" />
</Console>
<RollingFile name="main" fileName="logs/main-latest.log" filePattern="logs/main-%i.log">
<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="true" />
<Policies>
<SizeBasedTriggeringPolicy size="50M"/>
</Policies>
<DefaultRolloverStrategy max="20"/>
<DefaultRolloverStrategy max="9"/>
</RollingFile>
</Appenders>
@@ -21,36 +21,32 @@
<Logger name="com.zaxxer.hikari" level="error"/>
<Logger name="org.postgresql" level="error" />
<Logger name="app.binfile" level="debug" />
<Logger name="app.rpc.commands.binfile" level="debug" />
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="trace" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.http.sse" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />
<Logger name="app.rpc.climit" level="debug" />
<Logger name="app.common.files.migrations" level="debug" />
<Logger name="app.rpc.climit" level="info" />
<Logger name="app.rpc.mutations.files" level="info" />
<Logger name="app.common.files.migrations" level="info" />
<Logger name="app.loggers" level="debug" additivity="false">
<AppenderRef ref="console" level="info" />
<AppenderRef ref="main" level="debug" />
</Logger>
<Logger name="app" level="all" additivity="false">
<AppenderRef ref="main" level="trace" />
<AppenderRef ref="console" level="debug" />
</Logger>
<Logger name="user" level="trace" additivity="false">
<AppenderRef ref="main" level="trace" />
<AppenderRef ref="console" level="info" />
</Logger>
<Root level="info">
<AppenderRef ref="main" />
<AppenderRef ref="console" level="info" />
</Root>
</Loggers>
</Configuration>

View File

@@ -1,65 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<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="true" />
</Console>
<RollingFile name="main" fileName="logs/main-latest.log" filePattern="logs/main-%i.log">
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
alwaysWriteExceptions="true" />
<Policies>
<SizeBasedTriggeringPolicy size="50M"/>
</Policies>
<DefaultRolloverStrategy max="9"/>
</RollingFile>
<RollingFile name="reports" fileName="logs/reports-latest.log" filePattern="logs/reports-%i.log">
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
alwaysWriteExceptions="true" />
<Policies>
<SizeBasedTriggeringPolicy size="100M"/>
</Policies>
<DefaultRolloverStrategy max="9"/>
</RollingFile>
</Appenders>
<Loggers>
<Logger name="io.lettuce" level="error" />
<Logger name="com.zaxxer.hikari" level="error"/>
<Logger name="org.postgresql" level="error" />
<Logger name="app.rpc.commands.binfile" level="debug" />
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="trace" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.http.sse" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.redis" level="info" />
<Logger name="app.rpc.rlimit" level="info" />
<Logger name="app.rpc.climit" level="debug" />
<Logger name="app.common.files.migrations" level="info" />
<Logger name="app.loggers" level="debug" additivity="false">
<AppenderRef ref="main" level="debug" />
</Logger>
<Logger name="app.features" level="all" additivity="true">
<AppenderRef ref="reports" level="warn" />
</Logger>
<Logger name="app" level="all" additivity="false">
<AppenderRef ref="main" level="trace" />
</Logger>
<Logger name="user" level="trace" additivity="false">
<AppenderRef ref="main" level="trace" />
</Logger>
<Root level="info">
<AppenderRef ref="main" />
</Root>
</Loggers>
</Configuration>

View File

@@ -11,9 +11,16 @@
<Logger name="io.lettuce" level="error" />
<Logger name="com.zaxxer.hikari" level="error" />
<Logger name="org.postgresql" level="error" />
<Logger name="app.util" level="info" />
<Logger name="app.loggers" level="debug" />
<Logger name="app" level="info" additivity="false">
<AppenderRef ref="console" level="info" />
<AppenderRef ref="console" />
</Logger>
<Root level="info">
<AppenderRef ref="console" />
</Root>

View File

@@ -160,6 +160,7 @@ available_commands = (
"delete-profile",
"search-profile",
"derive-password",
"migrate-components-v2",
)
parser = argparse.ArgumentParser(
@@ -232,4 +233,7 @@ elif args.action == "search-profile":
search_profile(email)
elif args.action == "migrate-components-v2":
migrate_components_v2()

View File

@@ -4,7 +4,7 @@ export PENPOT_HOST=devenv
export PENPOT_TENANT=dev
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-ldap \
enable-registration
enable-login-with-password
enable-login-with-oidc \
enable-login-with-google \
@@ -26,17 +26,11 @@ export PENPOT_FLAGS="\
enable-soft-rpc-rlimit \
enable-webhooks \
enable-access-tokens \
disable-feature-components-v2 \
enable-file-validation \
enable-file-schema-validation";
# Default deletion delay for devenv
export PENPOT_DELETION_DELAY="24h"
# Setup default upload media file size to 100MiB
export PENPOT_MEDIA_MAX_FILE_SIZE=104857600
# Setup default multipart upload size to 300MiB
export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
enable-file-schema-validation \
disable-soft-file-schema-validation \
disable-soft-file-validation";
# export PENPOT_DATABASE_URI="postgresql://172.17.0.1:5432/penpot"
# export PENPOT_DATABASE_USERNAME="penpot"
@@ -70,7 +64,7 @@ export OPTIONS="
-J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-J-Djdk.attach.allowAttachSelf \
-J-Dpolyglot.engine.WarnInterpreterOnly=false \
-J-Dlog4j2.configurationFile=log4j2-devenv-repl.xml \
-J-Dlog4j2.configurationFile=log4j2-devenv.xml \
-J-XX:+EnableDynamicAgentLoading \
-J-XX:-OmitStackTraceInFastThrow \
-J-XX:+UnlockDiagnosticVMOptions \

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env bash
source /home/penpot/environ
export PENPOT_FLAGS="$PENPOT_FLAGS disable-backend-worker"
export OPTIONS="
-A:jmx-remote -A:dev \
-J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-J-Djdk.attach.allowAttachSelf \
-J-Dlog4j2.configurationFile=log4j2-experiments.xml \
-J-XX:-OmitStackTraceInFastThrow \
-J-XX:+UnlockDiagnosticVMOptions \
-J-XX:+DebugNonSafepoints \
-J-Djdk.tracePinnedThreads=full \
-J-XX:+UseTransparentHugePages \
-J-XX:ReservedCodeCacheSize=1g \
-J-Dpolyglot.engine.WarnInterpreterOnly=false \
-J--enable-preview";
# Setup HEAP
export OPTIONS="$OPTIONS -J-Xms320g -J-Xmx320g -J-XX:+AlwaysPreTouch"
export PENPOT_HTTP_SERVER_IO_THREADS=2
export PENPOT_HTTP_SERVER_WORKER_THREADS=2
# Increase virtual thread pool size
# export OPTIONS="$OPTIONS -J-Djdk.virtualThreadScheduler.parallelism=16"
# Disable C2 Compiler
# export OPTIONS="$OPTIONS -J-XX:TieredStopAtLevel=1"
# Disable all compilers
# export OPTIONS="$OPTIONS -J-Xint"
# Setup GC
export OPTIONS="$OPTIONS -J-XX:+UseG1GC -J-Xlog:gc:logs/gc.log"
# Setup GC
#export OPTIONS="$OPTIONS -J-XX:+UseZGC -J-XX:+ZGenerational -J-Xlog:gc:logs/gc.log"
# Enable ImageMagick v7.x support
# export OPTIONS="-J-Dim4java.useV7=true $OPTIONS";
export OPTIONS_EVAL="nil"
# export OPTIONS_EVAL="(set! *warn-on-reflection* true)"
set -ex
exec clojure $OPTIONS -M -e "$OPTIONS_EVAL" -m rebel-readline.main

View File

@@ -18,9 +18,7 @@ if [ -f ./environ ]; then
source ./environ
fi
export JVM_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow -Dpolyglot.engine.WarnInterpreterOnly=false --enable-preview $JVM_OPTS"
export JVM_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --enable-preview $JVM_OPTS"
ENTRYPOINT=${1:-app.main};
set -ex
exec $JAVA_CMD $JVM_OPTS -jar penpot.jar -m $ENTRYPOINT
set -x
exec $JAVA_CMD $JVM_OPTS "$@" -jar penpot.jar -m app.main

View File

@@ -15,35 +15,48 @@ export PENPOT_FLAGS="\
enable-feature-fdata-pointer-map \
enable-feature-fdata-objects-map \
disable-secure-session-cookies \
enable-rpc-climit \
enable-smtp \
enable-access-tokens \
disable-feature-components-v2 \
enable-file-validation \
enable-file-schema-validation";
enable-file-schema-validation \
disable-soft-file-schema-validation \
disable-soft-file-validation";
export OPTIONS="
-A:jmx-remote -A:dev \
-J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-J-Djdk.attach.allowAttachSelf \
-J-Dpolyglot.engine.WarnInterpreterOnly=false \
-J-Dlog4j2.configurationFile=log4j2-devenv.xml \
-J-Dlog4j2.configurationFile=log4j2.xml \
-J-XX:+EnableDynamicAgentLoading \
-J-XX:-OmitStackTraceInFastThrow \
-J-XX:+UnlockDiagnosticVMOptions \
-J-XX:+DebugNonSafepoints"
# Default deletion delay for devenv
export PENPOT_DELETION_DELAY="24h"
# Setup HEAP
# export OPTIONS="$OPTIONS -J-Xms50m -J-Xmx1024m"
# export OPTIONS="$OPTIONS -J-Xms1100m -J-Xmx1100m -J-XX:+AlwaysPreTouch"
# Setup default upload media file size to 100MiB
export PENPOT_MEDIA_MAX_FILE_SIZE=104857600
# Increase virtual thread pool size
# export OPTIONS="$OPTIONS -J-Djdk.virtualThreadScheduler.parallelism=16"
# Setup default multipart upload size to 300MiB
export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
# Disable C2 Compiler
# export OPTIONS="$OPTIONS -J-XX:TieredStopAtLevel=1"
# Disable all compilers
# export OPTIONS="$OPTIONS -J-Xint"
# Setup GC
# export OPTIONS="$OPTIONS -J-XX:+UseG1GC"
# Setup GC
# export OPTIONS="$OPTIONS -J-XX:+UseZGC"
# Enable ImageMagick v7.x support
# export OPTIONS="-J-Dim4java.useV7=true $OPTIONS";
# Initialize MINIO config
mc alias set penpot-s3/ http://minio:9000 minioadmin minioadmin -q
mc admin user add penpot-s3 penpot-devenv penpot-devenv -q
@@ -59,8 +72,24 @@ export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3
export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000
export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
entrypoint=${1:-app.main};
if [ "$1" = "--watch" ]; then
trap "exit" INT TERM ERR
trap "kill 0" EXIT
set -ex
echo "Start Watch..."
clojure $OPTIONS -A:dev -M -m $entrypoint;
clojure $OPTIONS -A:dev -M -m app.main &
npx nodemon \
--watch src \
--watch ../common \
--ext "clj" \
--signal SIGKILL \
--exec 'echo "(app.main/stop)\n\r(repl/refresh)\n\r(app.main/start)\n" | nc -N localhost 6062'
wait;
else
set -x
clojure $OPTIONS -A:dev -M -m app.main;
fi

View File

@@ -6,7 +6,9 @@
(ns app.auth
(:require
[buddy.hashers :as hashers]))
[app.config :as cf]
[buddy.hashers :as hashers]
[cuerdas.core :as str]))
(def default-params
{:alg :argon2id
@@ -25,3 +27,17 @@
(catch Throwable _
{:update false
:valid false})))
(defn email-domain-in-whitelist?
"Returns true if email's domain is in the given whitelist or if
given whitelist is an empty string."
([email]
(let [domains (cf/get :registration-domain-whitelist)]
(email-domain-in-whitelist? domains email)))
([domains email]
(if (or (nil? domains) (empty? domains))
true
(let [[_ candidate] (-> (str/lower email)
(str/split #"@" 2))]
(contains? domains candidate)))))

View File

@@ -7,6 +7,7 @@
(ns app.auth.oidc
"OIDC client implementation."
(:require
[app.auth :as auth]
[app.auth.oidc.providers :as-alias providers]
[app.common.data :as d]
[app.common.data.macros :as dm]
@@ -16,17 +17,12 @@
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.email.blacklist :as email.blacklist]
[app.email.whitelist :as email.whitelist]
[app.http.client :as http]
[app.http.errors :as errors]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.rpc :as rpc]
[app.main :as-alias main]
[app.rpc.commands.profile :as profile]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.inet :as inet]
[app.util.json :as json]
[app.util.time :as dt]
[buddy.sign.jwk :as jwk]
@@ -35,14 +31,13 @@
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[ring.request :as rreq]
[ring.response :as-alias rres]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn obfuscate-string
(defn- obfuscate-string
[s]
(if (< (count s) 10)
(apply str (take (count s) (repeat "*")))
@@ -133,8 +128,8 @@
(-> body json/decode :keys process-oidc-jwks)
(do
(l/warn :hint "unable to retrieve JWKs (unexpected response status code)"
:response-status status
:response-body body)
:http-status status
:http-body body)
nil)))
(catch Throwable cause
(l/warn :hint "unable to retrieve JWKs (unexpected exception)"
@@ -148,18 +143,18 @@
(when (contains? cf/flags :login-with-oidc)
(if-let [opts (prepare-oidc-opts cfg)]
(let [jwks (fetch-oidc-jwks cfg opts)]
(l/inf :hint "provider initialized"
: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)
:roles-attr (:roles-attr opts)
:roles (:roles opts)
:keys (str/join "," (map str (keys jwks))))
(l/info :hint "provider initialized"
: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)
:roles-attr (:roles-attr opts)
:roles (:roles opts)
:keys (str/join "," (map str (keys jwks))))
(assoc opts :jwks jwks))
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "oidc")
@@ -183,10 +178,10 @@
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/inf :hint "provider initialized"
:provider "google"
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
(l/info :hint "provider initialized"
:provider "google"
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
@@ -211,9 +206,8 @@
(ex/raise :type :internal
:code :unable-to-retrieve-github-emails
:hint "unable to retrieve github emails"
:request-uri (:uri params)
:response-status status
:response-body body))
:http-status status
:http-body body))
(->> body json/decode (filter :primary) first :email))))
@@ -238,10 +232,10 @@
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/inf :hint "provider initialized"
:provider "github"
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
(l/info :hint "provider initialized"
:provider "github"
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
@@ -253,7 +247,7 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::providers/gitlab
[_ cfg]
[_ _]
(let [base (cf/get :gitlab-base-uri "https://gitlab.com")
opts {:base-uri base
:client-id (cf/get :gitlab-client-id)
@@ -262,18 +256,17 @@
:auth-uri (str base "/oauth/authorize")
:token-uri (str base "/oauth/token")
:user-uri (str base "/oauth/userinfo")
:jwks-uri (str base "/oauth/discovery/keys")
:name "gitlab"}]
(when (contains? cf/flags :login-with-gitlab)
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(let [jwks (fetch-oidc-jwks cfg opts)]
(l/inf :hint "provider initialized"
:provider "gitlab"
:base-uri base
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
(assoc opts :jwks jwks))
(do
(l/info :hint "provider initialized"
: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")
@@ -289,12 +282,12 @@
(into [(keyword (:name provider) fitem)] (map keyword) items)))
(defn- build-redirect-uri
[{:keys [::provider] :as cfg}]
[{:keys [provider] :as cfg}]
(let [public (u/uri (cf/get :public-uri))]
(str (assoc public :path (str "/api/auth/oauth/" (:name provider) "/callback")))))
(defn- build-auth-uri
[{:keys [::provider] :as cfg} state]
[{:keys [provider] :as cfg} state]
(let [params {:client_id (:client-id provider)
:redirect_uri (build-redirect-uri cfg)
:response_type "code"
@@ -305,19 +298,15 @@
(assoc :query query)
(str))))
(defn- qualify-prop-key
[provider k]
(keyword (:name provider) (name k)))
(defn- qualify-props
[provider props]
(reduce-kv (fn [result k v]
(assoc result (qualify-prop-key provider k) v))
(assoc result (keyword (:name provider) (name k)) v))
{}
props))
(defn- fetch-access-token
[{:keys [::provider] :as cfg} code]
(defn fetch-access-token
[{:keys [provider] :as cfg} code]
(let [params {:client_id (:client-id provider)
:client_secret (:client-secret provider)
:code code
@@ -329,31 +318,26 @@
:uri (:token-uri provider)
:body (u/map->query-string params)}]
(l/trc :hint "fetch 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))
(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))
(let [{:keys [status body]} (http/req! cfg req {:sync? true})]
(l/trc :hint "access token fetched" :status status :body body)
(l/trace :hint "access token response" :status status :body body)
(if (= status 200)
(let [data (json/decode body)
data {:token/access (get data :access_token)
:token/id (get data :id_token)
:token/type (get data :token_type)}]
(l/trc :hint "access token fetched"
:token-id (:token/id data)
:token-type (:token/type data)
:token (:token/access data))
data)
(let [data (json/decode body)]
{:token/access (get data :access_token)
:token/id (get data :id_token)
:token/type (get data :token_type)})
(ex/raise :type :internal
:code :unable-to-fetch-access-token
:hint "unable to fetch access token"
:request-uri (:uri req)
:response-status status
:response-body body)))))
:code :unable-to-retrieve-token
:hint "unable to retrieve token"
:http-status status
:http-body body)))))
(defn- process-user-info
[provider tdata info]
@@ -379,10 +363,10 @@
:props props})))
(defn- fetch-user-info
[{:keys [::provider] :as cfg} tdata]
(l/trc :hint "fetch user info"
:uri (:user-uri provider)
:token (obfuscate-string (:token/access tdata)))
[{:keys [provider] :as cfg} tdata]
(l/trace :hint "fetch user info"
:uri (:user-uri provider)
:token (obfuscate-string (:token/access tdata)))
(let [params {:uri (:user-uri provider)
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
@@ -390,9 +374,9 @@
:method :get}
response (http/req! cfg params {:sync? true})]
(l/trc :hint "user info response"
:status (:status response)
:body (:body 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
@@ -404,7 +388,7 @@
(-> response :body json/decode)))
(defn- get-user-info
[{:keys [::provider]} tdata]
[{:keys [provider]} tdata]
(try
(when (:token/id tdata)
(let [{:keys [kid alg] :as theader} (jwt/decode-header (:token/id tdata))]
@@ -428,8 +412,14 @@
::fullname
::props]))
(defn- get-info
[{:keys [::provider ::setup/props] :as cfg} {:keys [params] :as request}]
(defn get-info
[{:keys [provider ::main/props] :as cfg} {:keys [params] :as request}]
(when-let [error (get params :error)]
(ex/raise :type :internal
:code :error-on-retrieving-code
:error-id error
:error-desc (get params :error_description)))
(let [state (get params :state)
code (get params :code)
state (tokens/verify props {:token state :iss :oauth})
@@ -442,7 +432,7 @@
info (process-user-info provider tdata info)]
(l/trc :hint "user info" :info info)
(l/trace :hint "user info" :info info)
(when-not (s/valid? ::info info)
(l/warn :hint "received incomplete profile info object (please set correct scopes)" :info info)
@@ -475,173 +465,109 @@
(some? (:invitation-token state))
(assoc :invitation-token (:invitation-token state))
(some? (:external-session-id state))
(assoc :external-session-id (:external-session-id state))
;; If state token comes with props, merge them. The state token
;; props can contain pm_ and utm_ prefixed query params.
(map? (:props state))
(update :props merge (:props state)))))
(defn- get-profile
[cfg info]
(db/run! cfg (fn [{:keys [::db/conn]}]
(some->> (:email info)
(profile/clean-email)
(profile/get-profile-by-email conn)))))
[{:keys [::db/pool] :as cfg} info]
(dm/with-open [conn (db/open pool)]
(some->> (:email info)
(profile/get-profile-by-email conn))))
(defn- redirect-response
[uri]
{::rres/status 302
::rres/headers {"location" (str uri)}})
(defn- redirect-with-error
([error] (redirect-with-error error nil))
([error hint]
(let [params {:error error :hint hint}
params (d/without-nils params)
uri (-> (u/uri (cf/get :public-uri))
(assoc :path "/#/auth/login")
(assoc :query (u/map->query-string params)))]
(redirect-response uri))))
(defn- generate-error-redirect
[_ cause]
(let [data (if (ex/error? cause) (ex-data cause) nil)
code (or (:code data) :unexpected)
type (or (:type data) :internal)
hint (or (:hint data)
(if (ex/exception? cause)
(ex-message cause)
(str cause)))
(defn- redirect-to-register
[cfg info request]
(let [info (assoc info
:iss :prepared-register
:exp (dt/in-future {:hours 48}))
params {:error "unable-to-auth"
:hint hint
:type type
:code code}
params {:token (tokens/generate (::setup/props cfg) info)
:provider (:provider (:path-params request))
:fullname (:fullname info)}
params (d/without-nils params)]
(redirect-response
(-> (u/uri (cf/get :public-uri))
(assoc :path "/#/auth/register/validate")
(assoc :query (u/map->query-string params))))))
(defn- redirect-to-verify-token
[token]
(let [params {:token token}
uri (-> (u/uri (cf/get :public-uri))
(assoc :path "/#/auth/verify-token")
(assoc :path "/#/auth/login")
(assoc :query (u/map->query-string params)))]
(redirect-response uri)))
(defn- provider-has-email-verified?
[{:keys [::provider] :as cfg} {:keys [props] :as info}]
(let [prop (qualify-prop-key provider :email_verified)]
(true? (get props prop))))
(defn- profile-has-provider-props?
[{:keys [::provider] :as cfg} profile]
(let [prop (qualify-prop-key provider :email)]
(contains? (:props profile) prop)))
(defn- provider-matches-profile?
[{:keys [::provider] :as cfg} profile info]
(or (= (:auth-backend profile) (:name provider))
(profile-has-provider-props? cfg profile)
(provider-has-email-verified? cfg info)))
(defn- process-callback
(defn- generate-redirect
[cfg request info profile]
(cond
(some? profile)
(cond
(:is-blocked profile)
(redirect-with-error "profile-blocked")
(if profile
(let [sxf (session/create-fn cfg (:id profile))
token (or (:invitation-token info)
(tokens/generate (::main/props cfg)
{:iss :auth
:exp (dt/in-future "15m")
:profile-id (:id profile)}))
params {:token token}
uri (-> (u/uri (cf/get :public-uri))
(assoc :path "/#/auth/verify-token")
(assoc :query (u/map->query-string params)))]
(not (provider-matches-profile? cfg profile info))
(redirect-with-error "auth-provider-not-allowed")
(when (:is-blocked profile)
(ex/raise :type :restriction
:code :profile-blocked))
(not (:is-active profile))
(let [info (assoc info :profile-id (:id profile))]
(redirect-to-register cfg info request))
(audit/submit! cfg {::audit/type "command"
::audit/name "login-with-oidc"
::audit/profile-id (:id profile)
::audit/ip-addr (audit/parse-client-ip request)
::audit/props (audit/profile->props profile)})
:else
(let [sxf (session/create-fn cfg (:id profile))
token (or (:invitation-token info)
(tokens/generate (::setup/props cfg)
{:iss :auth
:exp (dt/in-future "15m")
:props (:props info)
:profile-id (:id profile)}))
props (audit/profile->props profile)
context (d/without-nils {:external-session-id (:external-session-id info)})]
(->> (redirect-response uri)
(sxf request)))
(audit/submit! cfg {::audit/type "action"
::audit/name "login-with-oidc"
::audit/profile-id (:id profile)
::audit/ip-addr (inet/parse-request request)
::audit/props props
::audit/context context})
(->> (redirect-to-verify-token token)
(sxf request))))
(if (auth/email-domain-in-whitelist? (:email info))
(let [info (assoc info
:iss :prepared-register
:is-active true
:exp (dt/in-future {:hours 48}))
token (tokens/generate (::main/props cfg) info)
params (d/without-nils
{:token token
:fullname (:fullname info)})
uri (-> (u/uri (cf/get :public-uri))
(assoc :path "/#/auth/register/validate")
(assoc :query (u/map->query-string params)))]
(and (email.blacklist/enabled? cfg)
(email.blacklist/contains? cfg (:email info)))
(redirect-with-error "email-domain-not-allowed")
(redirect-response uri))
(generate-error-redirect cfg "email-domain-not-allowed"))))
(and (email.whitelist/enabled? cfg)
(not (email.whitelist/contains? cfg (:email info))))
(redirect-with-error "email-domain-not-allowed")
:else
(let [info (assoc info :is-active (provider-has-email-verified? cfg info))]
(if (or (contains? cf/flags :registration)
(contains? cf/flags :oidc-registration))
(redirect-to-register cfg info request)
(redirect-with-error "registration-disabled")))))
(defn- get-external-session-id
[request]
(let [session-id (rreq/get-header request "x-external-session-id")]
(when (string? session-id)
(if (or (> (count session-id) 256)
(= session-id "null")
(str/blank? session-id))
nil
session-id))))
(defn- auth-handler
[cfg {:keys [params] :as request}]
(let [props (audit/extract-utm-params params)
esid (rpc/get-external-session-id request)
params {:iss :oauth
:invitation-token (:invitation-token params)
:external-session-id esid
:props props
:exp (dt/in-future "4h")}
state (tokens/generate (::setup/props cfg)
(d/without-nils params))
uri (build-auth-uri cfg state)]
(let [props (audit/extract-utm-params params)
state (tokens/generate (::main/props cfg)
{:iss :oauth
:invitation-token (:invitation-token params)
:props props
:exp (dt/in-future "4h")})
uri (build-auth-uri cfg state)]
{::rres/status 200
::rres/body {:redirect-uri uri}}))
(defn- callback-handler
[{:keys [::provider] :as cfg} request]
[cfg request]
(try
(if-let [error (dm/get-in request [:params :error])]
(redirect-with-error "unable-to-auth" error)
(let [info (get-info cfg request)
profile (get-profile cfg info)]
(process-callback cfg request info profile)))
(let [info (get-info cfg request)
profile (get-profile cfg info)]
(generate-redirect cfg request info profile))
(catch Throwable cause
(binding [l/*context* (-> (errors/request->context request)
(assoc :auth/provider (:name provider)))]
(let [edata (ex-data cause)]
(cond
(= :validation (:type edata))
(l/wrn :hint "invalid token received" :cause cause)
:else
(l/err :hint "error on oauth process" :cause cause))))
(redirect-with-error "unable-to-auth" (ex-message cause)))))
(l/warn :hint "error on oauth process" :cause cause)
(generate-error-redirect cfg cause))))
(def provider-lookup
{:compile
@@ -650,12 +576,13 @@
(fn [request]
(let [provider (some-> request :path-params :provider keyword)]
(if-let [provider (get providers provider)]
(handler (assoc cfg ::provider provider) request)
(handler (assoc cfg :provider provider) request)
(ex/raise :type :restriction
:code :provider-not-configured
: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)
@@ -668,6 +595,7 @@
(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]
@@ -689,7 +617,7 @@
[_]
(s/keys :req [::session/manager
::http/client
::setup/props
::main/props
::db/pool
::providers]))

View File

@@ -1,451 +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.binfile.common
"A binfile related file processing common code, used for different
binfile format implementations and management rpc methods."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.files.migrations :as fmg]
[app.common.files.validate :as fval]
[app.common.logging :as l]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.features.components-v2 :as feat.compv2]
[app.features.fdata :as feat.fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.util.blob :as blob]
[app.util.pointer-map :as pmap]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.set :as set]
[clojure.walk :as walk]
[cuerdas.core :as str]))
(set! *warn-on-reflection* true)
(def ^:dynamic *state* nil)
(def ^:dynamic *options* nil)
(def xf-map-id
(map :id))
(def xf-map-media-id
(comp
(mapcat (juxt :media-id
:thumbnail-id
:woff1-file-id
:woff2-file-id
:ttf-file-id
:otf-file-id))
(filter uuid?)))
(def into-vec
(fnil into []))
(def conj-vec
(fnil conj []))
(defn collect-storage-objects
[state items]
(update state :storage-objects into xf-map-media-id items))
(defn collect-summary
[state key items]
(update state key into xf-map-media-id items))
(defn lookup-index
[id]
(when id
(let [val (get-in @*state* [:index id])]
(l/trc :fn "lookup-index" :id (str id) :result (some-> val str) ::l/sync? true)
(or val id))))
(defn remap-id
[item key]
(cond-> item
(contains? item key)
(update key lookup-index)))
(defn- index-object
[index obj & attrs]
(reduce (fn [index attr-fn]
(let [old-id (attr-fn obj)
new-id (if (::overwrite *options*) old-id (uuid/next))]
(assoc index old-id new-id)))
index
attrs))
(defn update-index
([index coll]
(update-index index coll identity))
([index coll attr]
(reduce #(index-object %1 %2 attr) index coll)))
(defn decode-row
"A generic decode row helper"
[{:keys [data features] :as row}]
(cond-> row
features (assoc :features (db/decode-pgarray features #{}))
data (assoc :data (blob/decode data))))
(defn get-file
[cfg file-id]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(when-let [file (db/get* conn :file {:id file-id}
{::db/remove-deleted false})]
(-> file
(decode-row)
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))))))))
(defn get-project
[cfg project-id]
(db/get cfg :project {:id project-id}))
(defn get-team
[cfg team-id]
(-> (db/get cfg :team {:id team-id})
(decode-row)))
(defn get-fonts
[cfg team-id]
(db/query cfg :team-font-variant
{:team-id team-id
:deleted-at nil}))
(defn get-files-rels
"Given a set of file-id's, return all matching relations with the libraries"
[cfg ids]
(dm/assert!
"expected a set of uuids"
(and (set? ids)
(every? uuid? ids)))
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids (db/create-array conn "uuid" ids)
sql (str "SELECT flr.* FROM file_library_rel AS flr "
" JOIN file AS l ON (flr.library_file_id = l.id) "
" WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")]
(db/exec! conn [sql ids])))))
(def ^:private sql:get-libraries
"WITH RECURSIVE libs AS (
SELECT fl.id
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
WHERE flr.file_id = ANY(?)
UNION
SELECT fl.id
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
JOIN libs AS l ON (flr.file_id = l.id)
)
SELECT DISTINCT l.id
FROM libs AS l")
(defn get-libraries
"Get all libraries ids related to provided file ids"
[cfg ids]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids' (db/create-array conn "uuid" ids)]
(->> (db/exec! conn [sql:get-libraries ids'])
(into #{} xf-map-id))))))
(defn get-file-object-thumbnails
"Return all file object thumbnails for a given file."
[cfg file-id]
(db/query cfg :file-tagged-object-thumbnail
{:file-id file-id
:deleted-at nil}))
(defn get-file-thumbnail
"Return the thumbnail for the specified file-id"
[cfg {:keys [id revn]}]
(db/get* cfg :file-thumbnail
{:file-id id
:revn revn
:data nil}
{::sql/columns [:media-id :file-id :revn]}))
(def ^:private
xform:collect-media-id
(comp
(map :objects)
(mapcat vals)
(mapcat (fn [obj]
;; NOTE: because of some bug, we ended with
;; many shape types having the ability to
;; have fill-image attribute (which initially
;; designed for :path shapes).
(sequence
(keep :id)
(concat [(:fill-image obj)
(:metadata obj)]
(map :fill-image (:fills obj))
(map :stroke-image (:strokes obj))
(->> (:content obj)
(tree-seq map? :children)
(mapcat :fills)
(map :fill-image))))))))
(defn collect-used-media
"Given a fdata (file data), returns all media references."
[data]
(-> #{}
(into xform:collect-media-id (vals (:pages-index data)))
(into xform:collect-media-id (vals (:components data)))
(into (keys (:media data)))))
(defn get-file-media
[cfg {:keys [data id] :as file}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids (collect-used-media data)
ids (db/create-array conn "uuid" ids)
sql (str "SELECT * FROM file_media_object WHERE id = ANY(?)")]
;; We assoc the file-id again to the file-media-object row
;; because there are cases that used objects refer to other
;; files and we need to ensure in the exportation process that
;; all ids matches
(->> (db/exec! conn [sql ids])
(mapv #(assoc % :file-id id)))))))
(def ^:private sql:get-team-files
"SELECT f.id FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?")
(defn get-team-files
"Get a set of file ids for the specified team-id"
[{:keys [::db/conn]} team-id]
(->> (db/exec! conn [sql:get-team-files team-id])
(into #{} xf-map-id)))
(def ^:private sql:get-team-projects
"SELECT p.id FROM project AS p
WHERE p.team_id = ?
AND p.deleted_at IS NULL")
(defn get-team-projects
"Get a set of project ids for the team"
[{:keys [::db/conn]} team-id]
(->> (db/exec! conn [sql:get-team-projects team-id])
(into #{} xf-map-id)))
(def ^:private sql:get-project-files
"SELECT f.id FROM file AS f
WHERE f.project_id = ?
AND f.deleted_at IS NULL")
(defn get-project-files
"Get a set of file ids for the project"
[{:keys [::db/conn]} project-id]
(->> (db/exec! conn [sql:get-project-files project-id])
(into #{} xf-map-id)))
(defn- relink-shapes
"A function responsible to analyze all file data and
replace the old :component-file reference with the new
ones, using the provided file-index."
[data]
(letfn [(process-map-form [form]
(cond-> form
;; Relink image shapes
(and (map? (:metadata form))
(= :image (:type form)))
(update-in [:metadata :id] lookup-index)
;; Relink paths with fill image
(map? (:fill-image form))
(update-in [:fill-image :id] lookup-index)
;; This covers old shapes and the new :fills.
(uuid? (:fill-color-ref-file form))
(update :fill-color-ref-file lookup-index)
;; This covers the old shapes and the new :strokes
(uuid? (:stroke-color-ref-file form))
(update :stroke-color-ref-file lookup-index)
;; This covers all text shapes that have typography referenced
(uuid? (:typography-ref-file form))
(update :typography-ref-file lookup-index)
;; This covers the component instance links
(uuid? (:component-file form))
(update :component-file lookup-index)
;; This covers the shadows and grids (they have directly
;; the :file-id prop)
(uuid? (:file-id form))
(update :file-id lookup-index)))
(process-form [form]
(if (map? form)
(try
(process-map-form form)
(catch Throwable cause
(l/warn :hint "failed form" :form (pr-str form) ::l/sync? true)
(throw cause)))
form))]
(walk/postwalk process-form data)))
(defn- relink-media
"A function responsible of process the :media attr of file data and
remap the old ids with the new ones."
[media]
(reduce-kv (fn [res k v]
(let [id (lookup-index k)]
(if (uuid? id)
(-> res
(assoc id (assoc v :id id))
(dissoc k))
res)))
media
media))
(defn- relink-colors
"A function responsible of process the :colors attr of file data and
remap the old ids with the new ones."
[colors]
(reduce-kv (fn [res k v]
(if (:image v)
(update-in res [k :image :id] lookup-index)
res))
colors
colors))
(defn embed-assets
[cfg data file-id]
(let [library-ids (get-libraries cfg [file-id])]
(reduce (fn [data library-id]
(let [library (get-file cfg library-id)]
(ctf/absorb-assets data (:data library))))
data
library-ids)))
(defn- fix-version
[file]
(let [file (fmg/fix-version file)]
;; FIXME: We're temporarily activating all migrations because a
;; problem in the environments messed up with the version numbers
;; When this problem is fixed delete the following line
(if (> (:version file) 22)
(assoc file :version 22)
file)))
(defn process-file
[{:keys [id] :as file}]
(-> file
(fix-version)
(update :data (fn [fdata]
(-> fdata
(assoc :id id)
(dissoc :recent-colors))))
(fmg/migrate-file)
(update :data (fn [fdata]
(-> fdata
(update :pages-index relink-shapes)
(update :components relink-shapes)
(update :media relink-media)
(update :colors relink-colors)
(d/without-nils))))))
(defn- upsert-file!
[conn file]
(let [sql (str "INSERT INTO file (id, project_id, name, revn, version, is_shared, data, created_at, modified_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT (id) DO UPDATE SET data=?, version=?")]
(db/exec-one! conn [sql
(:id file)
(:project-id file)
(:name file)
(:revn file)
(:version file)
(:is-shared file)
(:data file)
(:created-at file)
(:modified-at file)
(:data file)
(:version file)])))
(defn persist-file!
"Applies all the final validations and perist the file."
[{:keys [::db/conn ::timestamp] :as cfg} {:keys [id] :as file}]
(dm/assert!
"expected valid timestamp"
(dt/instant? timestamp))
(let [file (-> file
(assoc :created-at timestamp)
(assoc :modified-at timestamp)
(assoc :ignore-sync-until (dt/plus timestamp (dt/duration {:seconds 5})))
(update :features
(fn [features]
(let [features (cfeat/check-supported-features! features)]
(-> (::features cfg #{})
(set/difference cfeat/frontend-only-features)
(set/union features))))))
_ (when (contains? cf/flags :file-schema-validation)
(fval/validate-file-schema! file))
_ (when (contains? cf/flags :soft-file-schema-validation)
(let [result (ex/try! (fval/validate-file-schema! file))]
(when (ex/exception? result)
(l/error :hint "file schema validation error" :cause result))))
file (if (contains? (:features file) "fdata/objects-map")
(feat.fdata/enable-objects-map file)
file)
file (if (contains? (:features file) "fdata/pointer-map")
(binding [pmap/*tracked* (pmap/create-tracked)]
(let [file (feat.fdata/enable-pointer-map file)]
(feat.fdata/persist-pointers! cfg id)
file))
file)
params (-> file
(update :features db/encode-pgarray conn "text")
(update :data blob/encode))]
(if (::overwrite cfg)
(upsert-file! conn params)
(db/insert! conn :file params ::db/return-keys false))
file))
(defn apply-pending-migrations!
"Apply alredy registered pending migrations to files"
[cfg]
(doseq [[feature file-id] (-> *state* deref :pending-to-migrate)]
(case feature
"components/v2"
(feat.compv2/migrate-file! cfg file-id
:validate? (::validate cfg true)
:skip-on-graphic-error? true)
"fdata/shape-data-type"
nil
(ex/raise :type :internal
:code :no-migration-defined
:hint (str/ffmt "no migation for feature '%' on file importation" feature)
:feature feature))))

View File

@@ -1,778 +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.binfile.v1
"A custom, perfromance and efficiency focused binfile format impl"
(:refer-clojure :exclude [assert])
(:require
[app.binfile.common :as bfc]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.fressian :as fres]
[app.common.logging :as l]
[app.common.spec :as us]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.tasks.file-gc]
[app.util.events :as events]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.java.io :as jio]
[clojure.set :as set]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[datoteka.io :as io]
[promesa.util :as pu]
[yetti.adapter :as yt])
(:import
com.github.luben.zstd.ZstdIOException
com.github.luben.zstd.ZstdInputStream
com.github.luben.zstd.ZstdOutputStream
java.io.DataInputStream
java.io.DataOutputStream
java.io.InputStream
java.io.OutputStream))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Threshold in MiB when we pass from using
;; in-memory byte-array's to use temporal files.
(def temp-file-threshold
(* 1024 1024 2))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; LOW LEVEL STREAM IO API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:const buffer-size (:xnio/buffer-size yt/defaults))
(def ^:const penpot-magic-number 800099563638710213)
;; A maximum (storage) object size allowed: 100MiB
(def ^:const max-object-size
(* 1024 1024 100))
(def ^:dynamic *position* nil)
(defn get-mark
[id]
(case id
:header 1
:stream 2
:uuid 3
:label 4
:obj 5
(ex/raise :type :validation
:code :invalid-mark-id
:hint (format "invalid mark id %s" id))))
(defmacro assert
[expr hint]
`(when-not ~expr
(ex/raise :type :validation
:code :unexpected-condition
:hint ~hint)))
(defmacro assert-mark
[v type]
`(let [expected# (get-mark ~type)
val# (long ~v)]
(when (not= val# expected#)
(ex/raise :type :validation
:code :unexpected-mark
:hint (format "received mark %s, expected %s" val# expected#)))))
(defmacro assert-label
[expr label]
`(let [v# ~expr]
(when (not= v# ~label)
(ex/raise :type :assertion
:code :unexpected-label
:hint (format "received label %s, expected %s" v# ~label)))))
;; --- PRIMITIVE IO
(defn write-byte!
[^DataOutputStream output data]
(l/trace :fn "write-byte!" :data data :position @*position* ::l/sync? true)
(.writeByte output (byte data))
(swap! *position* inc))
(defn read-byte!
[^DataInputStream input]
(let [v (.readByte input)]
(l/trace :fn "read-byte!" :val v :position @*position* ::l/sync? true)
(swap! *position* inc)
v))
(defn write-long!
[^DataOutputStream output data]
(l/trace :fn "write-long!" :data data :position @*position* ::l/sync? true)
(.writeLong output (long data))
(swap! *position* + 8))
(defn read-long!
[^DataInputStream input]
(let [v (.readLong input)]
(l/trace :fn "read-long!" :val v :position @*position* ::l/sync? true)
(swap! *position* + 8)
v))
(defn write-bytes!
[^DataOutputStream output ^bytes data]
(let [size (alength data)]
(l/trace :fn "write-bytes!" :size size :position @*position* ::l/sync? true)
(.write output data 0 size)
(swap! *position* + size)))
(defn read-bytes!
[^InputStream input ^bytes buff]
(let [size (alength buff)
readed (.readNBytes input buff 0 size)]
(l/trace :fn "read-bytes!" :expected (alength buff) :readed readed :position @*position* ::l/sync? true)
(swap! *position* + readed)
readed))
;; --- COMPOSITE IO
(defn write-uuid!
[^DataOutputStream output id]
(l/trace :fn "write-uuid!" :position @*position* :WRITTEN? (.size output) ::l/sync? true)
(doto output
(write-byte! (get-mark :uuid))
(write-long! (uuid/get-word-high id))
(write-long! (uuid/get-word-low id))))
(defn read-uuid!
[^DataInputStream input]
(l/trace :fn "read-uuid!" :position @*position* ::l/sync? true)
(let [m (read-byte! input)]
(assert-mark m :uuid)
(let [a (read-long! input)
b (read-long! input)]
(uuid/custom a b))))
(defn write-obj!
[^DataOutputStream output data]
(l/trace :fn "write-obj!" :position @*position* ::l/sync? true)
(let [^bytes data (fres/encode data)]
(doto output
(write-byte! (get-mark :obj))
(write-long! (alength data))
(write-bytes! data))))
(defn read-obj!
[^DataInputStream input]
(l/trace :fn "read-obj!" :position @*position* ::l/sync? true)
(let [m (read-byte! input)]
(assert-mark m :obj)
(let [size (read-long! input)]
(assert (pos? size) "incorrect header size found on reading header")
(let [buff (byte-array size)]
(read-bytes! input buff)
(fres/decode buff)))))
(defn write-label!
[^DataOutputStream output label]
(l/trace :fn "write-label!" :label label :position @*position* ::l/sync? true)
(doto output
(write-byte! (get-mark :label))
(write-obj! label)))
(defn read-label!
[^DataInputStream input]
(l/trace :fn "read-label!" :position @*position* ::l/sync? true)
(let [m (read-byte! input)]
(assert-mark m :label)
(read-obj! input)))
(defn write-header!
[^OutputStream output version]
(l/trace :fn "write-header!"
:version version
:position @*position*
::l/sync? true)
(let [vers (-> version name (subs 1) parse-long)
output (io/data-output-stream output)]
(doto output
(write-byte! (get-mark :header))
(write-long! penpot-magic-number)
(write-long! vers))))
(defn read-header!
[^InputStream input]
(l/trace :fn "read-header!" :position @*position* ::l/sync? true)
(let [input (io/data-input-stream input)
mark (read-byte! input)
mnum (read-long! input)
vers (read-long! input)]
(when (or (not= mark (get-mark :header))
(not= mnum penpot-magic-number))
(ex/raise :type :validation
:code :invalid-penpot-file
:hint "invalid penpot file"))
(keyword (str "v" vers))))
(defn copy-stream!
[^OutputStream output ^InputStream input ^long size]
(let [written (io/copy! input output :size size)]
(l/trace :fn "copy-stream!" :position @*position* :size size :written written ::l/sync? true)
(swap! *position* + written)
written))
(defn write-stream!
[^DataOutputStream output stream size]
(l/trace :fn "write-stream!" :position @*position* ::l/sync? true :size size)
(doto output
(write-byte! (get-mark :stream))
(write-long! size))
(copy-stream! output stream size))
(defn read-stream!
[^DataInputStream input]
(l/trace :fn "read-stream!" :position @*position* ::l/sync? true)
(let [m (read-byte! input)
s (read-long! input)
p (tmp/tempfile :prefix "penpot.binfile.")]
(assert-mark m :stream)
(when (> s max-object-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str/ffmt "unable to import storage object with size % bytes" s)))
(if (> s temp-file-threshold)
(with-open [^OutputStream output (io/output-stream p)]
(let [readed (io/copy! input output :offset 0 :size s)]
(l/trace :fn "read-stream*!" :expected s :readed readed :position @*position* ::l/sync? true)
(swap! *position* + readed)
[s p]))
[s (io/read-as-bytes input :size s)])))
(defmacro assert-read-label!
[input expected-label]
`(let [readed# (read-label! ~input)
expected# ~expected-label]
(when (not= readed# expected#)
(ex/raise :type :validation
:code :unexpected-label
:hint (format "unexpected label found: %s, expected: %s" readed# expected#)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- HELPERS
(defn zstd-input-stream
^InputStream
[input]
(ZstdInputStream. ^InputStream input))
(defn zstd-output-stream
^OutputStream
[output & {:keys [level] :or {level 0}}]
(ZstdOutputStream. ^OutputStream output (int level)))
(defn- get-files
[cfg ids]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [sql (str "SELECT id FROM file "
" WHERE id = ANY(?) ")
ids (db/create-array conn "uuid" ids)]
(->> (db/exec! conn [sql ids])
(into [] (map :id))
(not-empty))))))
;; --- EXPORT WRITER
(defmulti write-export ::version)
(defmulti write-section ::section)
(defn write-export!
[{:keys [::include-libraries ::embed-assets] :as cfg}]
(when (and include-libraries embed-assets)
(throw (IllegalArgumentException.
"the `include-libraries` and `embed-assets` are mutally excluding options")))
(write-export cfg))
(defmethod write-export :default
[{:keys [::output] :as options}]
(write-header! output :v1)
(pu/with-open [output (zstd-output-stream output :level 12)
output (io/data-output-stream output)]
(binding [bfc/*state* (volatile! {})]
(run! (fn [section]
(l/dbg :hint "write section" :section section ::l/sync? true)
(write-label! output section)
(let [options (-> options
(assoc ::output output)
(assoc ::section section))]
(binding [bfc/*options* options]
(write-section options))))
[:v1/metadata :v1/files :v1/rels :v1/sobjects]))))
(defmethod write-section :v1/metadata
[{:keys [::output ::ids ::include-libraries] :as cfg}]
(if-let [fids (get-files cfg ids)]
(let [lids (when include-libraries
(bfc/get-libraries cfg ids))
ids (into fids lids)]
(write-obj! output {:version cf/version :files ids})
(vswap! bfc/*state* assoc :files ids))
(ex/raise :type :not-found
:code :files-not-found
:hint "unable to retrieve files for export")))
(defmethod write-section :v1/files
[{:keys [::output ::embed-assets ::include-libraries] :as cfg}]
;; Initialize SIDS with empty vector
(vswap! bfc/*state* assoc :sids [])
(doseq [file-id (-> bfc/*state* deref :files)]
(let [detach? (and (not embed-assets) (not include-libraries))
thumbnails (->> (bfc/get-file-object-thumbnails cfg file-id)
(mapv #(dissoc % :file-id)))
file (cond-> (bfc/get-file cfg file-id)
detach?
(-> (ctf/detach-external-references file-id)
(dissoc :libraries))
embed-assets
(update :data #(bfc/embed-assets cfg % file-id))
:always
(assoc :thumbnails thumbnails))
media (bfc/get-file-media cfg file)]
(l/dbg :hint "write penpot file"
:id (str file-id)
:name (:name file)
:thumbnails (count thumbnails)
:features (:features file)
:media (count media)
::l/sync? true)
(doseq [item media]
(l/dbg :hint "write penpot file media object" :id (:id item) ::l/sync? true))
(doseq [item thumbnails]
(l/dbg :hint "write penpot file object thumbnail" :media-id (str (:media-id item)) ::l/sync? true))
(doto output
(write-obj! file)
(write-obj! media))
(vswap! bfc/*state* update :sids into bfc/xf-map-media-id media)
(vswap! bfc/*state* update :sids into bfc/xf-map-media-id thumbnails))))
(defmethod write-section :v1/rels
[{:keys [::output ::include-libraries] :as cfg}]
(let [ids (-> bfc/*state* deref :files set)
rels (when include-libraries
(bfc/get-files-rels cfg ids))]
(l/dbg :hint "found rels" :total (count rels) ::l/sync? true)
(write-obj! output rels)))
(defmethod write-section :v1/sobjects
[{:keys [::sto/storage ::output]}]
(let [sids (-> bfc/*state* deref :sids)
storage (media/configure-assets-storage storage)]
(l/dbg :hint "found sobjects"
:items (count sids)
::l/sync? true)
;; Write all collected storage objects
(write-obj! output sids)
(doseq [id sids]
(let [{:keys [size] :as obj} (sto/get-object storage id)]
(l/dbg :hint "write sobject" :id (str id) ::l/sync? true)
(doto output
(write-uuid! id)
(write-obj! (meta obj)))
(pu/with-open [stream (sto/get-object-data storage obj)]
(let [written (write-stream! output stream size)]
(when (not= written size)
(ex/raise :type :validation
:code :mismatch-readed-size
:hint (str/ffmt "found unexpected object size; size=% written=%" size written)))))))))
;; --- EXPORT READER
(defmulti read-import ::version)
(defmulti read-section ::section)
(s/def ::profile-id ::us/uuid)
(s/def ::project-id ::us/uuid)
(s/def ::input io/input-stream?)
(s/def ::overwrite? (s/nilable ::us/boolean))
(s/def ::ignore-index-errors? (s/nilable ::us/boolean))
;; FIXME: replace with schema
(s/def ::read-import-options
(s/keys :req [::db/pool ::sto/storage ::project-id ::profile-id ::input]
:opt [::overwrite? ::ignore-index-errors?]))
(defn read-import!
"Do the importation of the specified resource in penpot custom binary
format. There are some options for customize the importation
behavior:
`::bfc/overwrite`: if true, instead of creating new files and remapping id references,
it reuses all ids and updates existing objects; defaults to `false`."
[{:keys [::input ::bfc/timestamp] :or {timestamp (dt/now)} :as options}]
(dm/assert!
"expected input stream"
(io/input-stream? input))
(dm/assert!
"expected valid instant"
(dt/instant? timestamp))
(let [version (read-header! input)]
(read-import (assoc options ::version version ::bfc/timestamp timestamp))))
(defn- read-import-v1
[{:keys [::db/conn ::project-id ::profile-id ::input] :as cfg}]
(db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"])
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
(pu/with-open [input (zstd-input-stream input)
input (io/data-input-stream input)]
(binding [bfc/*state* (volatile! {:media [] :index {}})]
(let [team (teams/get-team conn
:profile-id profile-id
:project-id project-id)
features (cfeat/get-team-enabled-features cf/flags team)]
;; Process all sections
(run! (fn [section]
(l/dbg :hint "reading section" :section section ::l/sync? true)
(assert-read-label! input section)
(let [options (-> cfg
(assoc ::bfc/features features)
(assoc ::section section)
(assoc ::input input))]
(binding [bfc/*options* options]
(events/tap :progress {:op :import :section section})
(read-section options))))
[:v1/metadata :v1/files :v1/rels :v1/sobjects])
(bfc/apply-pending-migrations! cfg)
;; Knowing that the ids of the created files are in index,
;; just lookup them and return it as a set
(let [files (-> bfc/*state* deref :files)]
(into #{} (keep #(get-in @bfc/*state* [:index %])) files))))))
(defmethod read-import :v1
[options]
(db/tx-run! options read-import-v1))
(defmethod read-section :v1/metadata
[{:keys [::input]}]
(let [{:keys [version files]} (read-obj! input)]
(l/dbg :hint "metadata readed"
:version (:full version)
:files (mapv str files)
::l/sync? true)
(vswap! bfc/*state* update :index bfc/update-index files)
(vswap! bfc/*state* assoc :version version :files files)))
(defn- remap-thumbnails
[thumbnails file-id]
(mapv (fn [thumbnail]
(-> thumbnail
(assoc :file-id file-id)
(update :object-id #(str/replace-first % #"^(.*?)/" (str file-id "/")))))
thumbnails))
(defn- clean-features
[file]
(update file :features (fn [features]
(if (set? features)
(-> features
(cfeat/migrate-legacy-features)
(set/difference cfeat/backend-only-features))
#{}))))
(defmethod read-section :v1/files
[{:keys [::db/conn ::input ::project-id ::bfc/overwrite ::name] :as system}]
(doseq [[idx expected-file-id] (d/enumerate (-> bfc/*state* deref :files))]
(let [file (read-obj! input)
media (read-obj! input)
file-id (:id file)
file-id' (bfc/lookup-index file-id)
file (clean-features file)
thumbnails (:thumbnails file)]
(when (not= file-id expected-file-id)
(ex/raise :type :validation
:code :inconsistent-penpot-file
:found-id file-id
:expected-id expected-file-id
:hint "the penpot file seems corrupt, found unexpected uuid (file-id)"))
(l/dbg :hint "processing file"
:id (str file-id)
:features (:features file)
:version (-> file :data :version)
:media (count media)
:thumbnails (count thumbnails)
::l/sync? true)
(when (seq thumbnails)
(let [thumbnails (remap-thumbnails thumbnails file-id')]
(l/dbg :hint "updated index with thumbnails" :total (count thumbnails) ::l/sync? true)
(vswap! bfc/*state* update :thumbnails bfc/into-vec thumbnails)))
(when (seq media)
;; Update index with media
(l/dbg :hint "update index with media" :total (count media) ::l/sync? true)
(vswap! bfc/*state* update :index bfc/update-index (map :id media))
;; Store file media for later insertion
(l/dbg :hint "update media references" ::l/sync? true)
(vswap! bfc/*state* update :media into (map #(update % :id bfc/lookup-index)) media))
(let [file (-> file
(assoc :id file-id')
(cond-> (and (= idx 0) (some? name))
(assoc :name name))
(assoc :project-id project-id)
(dissoc :thumbnails)
(bfc/process-file))]
;; All features that are enabled and requires explicit migration are
;; added to the state for a posterior migration step.
(doseq [feature (-> (::bfc/features system)
(set/difference cfeat/no-migration-features)
(set/difference (:features file)))]
(vswap! bfc/*state* update :pending-to-migrate (fnil conj []) [feature file-id']))
(l/dbg :hint "create file" :id (str file-id') ::l/sync? true)
(bfc/persist-file! system file)
(when overwrite
(db/delete! conn :file-thumbnail {:file-id file-id'}))
file-id'))))
(defmethod read-section :v1/rels
[{:keys [::db/conn ::input ::bfc/timestamp]}]
(let [rels (read-obj! input)
ids (into #{} (-> bfc/*state* deref :files))]
;; Insert all file relations
(doseq [{:keys [library-file-id] :as rel} rels]
(let [rel (-> rel
(assoc :synced-at timestamp)
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index))]
(if (contains? ids library-file-id)
(do
(l/dbg :hint "create file library link"
:file-id (:file-id rel)
:lib-id (:library-file-id rel)
::l/sync? true)
(db/insert! conn :file-library-rel rel))
(l/warn :hint "ignoring file library link"
:file-id (:file-id rel)
:lib-id (:library-file-id rel)
::l/sync? true))))))
(defmethod read-section :v1/sobjects
[{:keys [::sto/storage ::db/conn ::input ::bfc/overwrite ::bfc/timestamp]}]
(let [storage (media/configure-assets-storage storage)
ids (read-obj! input)
thumb? (into #{} (map :media-id) (:thumbnails @bfc/*state*))]
(doseq [expected-storage-id ids]
(let [id (read-uuid! input)
mdata (read-obj! input)]
(when (not= id expected-storage-id)
(ex/raise :type :validation
:code :inconsistent-penpot-file
:hint "the penpot file seems corrupt, found unexpected uuid (storage-object-id)"))
(l/dbg :hint "readed storage object" :id (str id) ::l/sync? true)
(let [[size resource] (read-stream! input)
hash (sto/calculate-hash resource)
content (-> (sto/content resource size)
(sto/wrap-with-hash hash))
params (-> mdata
(assoc ::sto/content content)
(assoc ::sto/deduplicate? true)
(assoc ::sto/touched-at timestamp))
params (if (thumb? id)
(assoc params :bucket "file-object-thumbnail")
(assoc params :bucket "file-media-object"))
sobject (sto/put-object! storage params)]
(l/dbg :hint "persisted storage object"
:old-id (str id)
:new-id (str (:id sobject))
:is-thumbnail (boolean (thumb? id))
::l/sync? true)
(vswap! bfc/*state* update :index assoc id (:id sobject)))))
(doseq [item (:media @bfc/*state*)]
(l/dbg :hint "inserting file media object"
:id (str (:id item))
:file-id (str (:file-id item))
::l/sync? true)
(let [file-id (bfc/lookup-index (:file-id item))]
(if (= file-id (:file-id item))
(l/warn :hint "ignoring file media object" :file-id (str file-id) ::l/sync? true)
(db/insert! conn :file-media-object
(-> item
(assoc :file-id file-id)
(d/update-when :media-id bfc/lookup-index)
(d/update-when :thumbnail-id bfc/lookup-index))
{::db/on-conflict-do-nothing? overwrite}))))
(doseq [item (:thumbnails @bfc/*state*)]
(let [item (update item :media-id bfc/lookup-index)]
(l/dbg :hint "inserting file object thumbnail"
:file-id (str (:file-id item))
:media-id (str (:media-id item))
:object-id (:object-id item)
::l/sync? true)
(db/insert! conn :file-tagged-object-thumbnail item
{::db/on-conflict-do-nothing? overwrite})))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HIGH LEVEL API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn export-files!
"Do the exportation of a specified file in custom penpot binary
format. There are some options available for customize the output:
`::include-libraries`: additionally to the specified file, all the
linked libraries also will be included (including transitive
dependencies).
`::embed-assets`: instead of including the libraries, embed in the
same file library all assets used from external libraries."
[{:keys [::ids] :as cfg} output]
(dm/assert!
"expected a set of uuid's for `::ids` parameter"
(and (set? ids)
(every? uuid? ids)))
(dm/assert!
"expected instance of jio/IOFactory for `input`"
(satisfies? jio/IOFactory output))
(let [id (uuid/next)
tp (dt/tpoint)
ab (volatile! false)
cs (volatile! nil)]
(try
(l/info :hint "start exportation" :export-id (str id))
(pu/with-open [output (io/output-stream output)]
(binding [*position* (atom 0)]
(write-export! (assoc cfg ::output output))))
(catch java.io.IOException _cause
;; Do nothing, EOF means client closes connection abruptly
(vreset! ab true)
nil)
(catch Throwable cause
(vreset! cs cause)
(vreset! ab true)
(throw cause))
(finally
(l/info :hint "exportation finished" :export-id (str id)
:elapsed (str (inst-ms (tp)) "ms")
:aborted @ab
:cause @cs)))))
(defn import-files!
[cfg input]
(dm/assert!
"expected valid profile-id and project-id on `cfg`"
(and (uuid? (::profile-id cfg))
(uuid? (::project-id cfg))))
(dm/assert!
"expected instance of jio/IOFactory for `input`"
(satisfies? jio/IOFactory input))
(let [id (uuid/next)
tp (dt/tpoint)
cs (volatile! nil)]
(l/info :hint "import: started" :id (str id))
(try
(binding [*position* (atom 0)]
(pu/with-open [input (io/input-stream input)]
(read-import! (assoc cfg ::input input))))
(catch ZstdIOException cause
(ex/raise :type :validation
:code :invalid-penpot-file
:hint "invalid penpot file received: probably truncated"
:cause cause))
(catch Throwable cause
(vreset! cs cause)
(throw cause))
(finally
(l/info :hint "import: terminated"
:id (str id)
:elapsed (dt/format-duration (tp))
:error? (some? @cs))))))

View File

@@ -1,442 +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.binfile.v2
"A sqlite3 based binary file exportation with support for exportation
of entire team (or multiple teams) at once."
(:refer-clojure :exclude [read])
(:require
[app.binfile.common :as bfc]
[app.common.data :as d]
[app.common.features :as cfeat]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media :as media]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.events :as events]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.set :as set]
[cuerdas.core :as str]
[datoteka.io :as io]
[promesa.util :as pu])
(:import
java.sql.DriverManager))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; LOW LEVEL API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- create-database
([cfg]
(let [path (tmp/tempfile :prefix "penpot.binfile." :suffix ".sqlite")]
(create-database cfg path)))
([cfg path]
(let [db (DriverManager/getConnection (str "jdbc:sqlite:" path))]
(assoc cfg ::db db ::path path))))
(def ^:private
sql:create-kvdata-table
"CREATE TABLE kvdata (
tag text NOT NULL,
key text NOT NULL,
val text NOT NULL,
dat blob NULL
)")
(def ^:private
sql:create-kvdata-index
"CREATE INDEX kvdata__tag_key__idx
ON kvdata (tag, key)")
(defn- setup-schema!
[{:keys [::db]}]
(db/exec-one! db [sql:create-kvdata-table])
(db/exec-one! db [sql:create-kvdata-index]))
(defn- write!
[{:keys [::db]} tag k v & [data]]
(db/insert! db :kvdata
{:tag (d/name tag)
:key (str k)
:val (t/encode-str v {:type :json-verbose})
:dat data}
{::db/return-keys false}))
(defn- read-blob
[{:keys [::db]} tag k]
(let [obj (db/get db :kvdata
{:tag (d/name tag)
:key (str k)}
{::sql/columns [:dat]})]
(:dat obj)))
(defn- read-seq
([{:keys [::db]} tag]
(->> (db/query db :kvdata
{:tag (d/name tag)}
{::sql/columns [::val]})
(map :val)
(map t/decode-str)))
([{:keys [::db]} tag k]
(->> (db/query db :kvdata
{:tag (d/name tag)
:key (str k)}
{::sql/columns [::val]})
(map :val)
(map t/decode-str))))
(defn- read-obj
[{:keys [::db]} tag k]
(let [obj (db/get db :kvdata
{:tag (d/name tag)
:key (str k)}
{::sql/columns [:val]})]
(-> obj :val t/decode-str)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMPORT/EXPORT IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private write-project!)
(declare ^:private write-file!)
(defn- write-team!
[cfg team-id]
(let [team (bfc/get-team cfg team-id)
fonts (bfc/get-fonts cfg team-id)]
(events/tap :progress
{:op :export
:section :write-team
:id team-id
:name (:name team)})
(l/trc :hint "write" :obj "team"
:id (str team-id)
:fonts (count fonts))
(when-let [photo-id (:photo-id team)]
(vswap! bfc/*state* update :storage-objects conj photo-id))
(vswap! bfc/*state* update :teams conj team-id)
(vswap! bfc/*state* bfc/collect-storage-objects fonts)
(write! cfg :team team-id team)
(doseq [{:keys [id] :as font} fonts]
(vswap! bfc/*state* update :team-font-variants conj id)
(write! cfg :team-font-variant id font))))
(defn- write-project!
[cfg project-id]
(let [project (bfc/get-project cfg project-id)]
(events/tap :progress
{:op :export
:section :write-project
:id project-id
:name (:name project)})
(l/trc :hint "write" :obj "project" :id (str project-id))
(write! cfg :project (str project-id) project)
(vswap! bfc/*state* update :projects conj project-id)))
(defn- write-file!
[cfg file-id]
(let [file (bfc/get-file cfg file-id)
thumbs (bfc/get-file-object-thumbnails cfg file-id)
media (bfc/get-file-media cfg file)
rels (bfc/get-files-rels cfg #{file-id})]
(events/tap :progress
{:op :export
:section :write-file
:id file-id
:name (:name file)})
(vswap! bfc/*state* (fn [state]
(-> state
(update :files conj file-id)
(update :file-media-objects into bfc/xf-map-id media)
(bfc/collect-storage-objects thumbs)
(bfc/collect-storage-objects media))))
(write! cfg :file file-id file)
(write! cfg :file-rels file-id rels)
(run! (partial write! cfg :file-media-object file-id) media)
(run! (partial write! cfg :file-object-thumbnail file-id) thumbs)
(when-let [thumb (bfc/get-file-thumbnail cfg file)]
(vswap! bfc/*state* bfc/collect-storage-objects [thumb])
(write! cfg :file-thumbnail file-id thumb))
(l/trc :hint "write" :obj "file"
:thumbnails (count thumbs)
:rels (count rels)
:media (count media))))
(defn- write-storage-object!
[{:keys [::sto/storage] :as cfg} id]
(let [sobj (sto/get-object storage id)
data (with-open [input (sto/get-object-data storage sobj)]
(io/read-as-bytes input))]
(l/trc :hint "write" :obj "storage-object" :id (str id) :size (:size sobj))
(write! cfg :storage-object id (meta sobj) data)))
(defn- read-storage-object!
[{:keys [::sto/storage ::bfc/timestamp] :as cfg} id]
(let [mdata (read-obj cfg :storage-object id)
data (read-blob cfg :storage-object id)
hash (sto/calculate-hash data)
content (-> (sto/content data)
(sto/wrap-with-hash hash))
params (-> mdata
(assoc ::sto/content content)
(assoc ::sto/deduplicate? true)
(assoc ::sto/touched-at timestamp))
sobject (sto/put-object! storage params)]
(vswap! bfc/*state* update :index assoc id (:id sobject))
(l/trc :hint "read" :obj "storage-object"
:id (str id)
:new-id (str (:id sobject))
:size (:size sobject))))
(defn read-team!
[{:keys [::db/conn ::bfc/timestamp] :as cfg} team-id]
(l/trc :hint "read" :obj "team" :id (str team-id))
(let [team (read-obj cfg :team team-id)
team (-> team
(update :id bfc/lookup-index)
(update :photo-id bfc/lookup-index)
(assoc :created-at timestamp)
(assoc :modified-at timestamp))]
(events/tap :progress
{:op :import
:section :read-team
:id team-id
:name (:name team)})
(db/insert! conn :team
(update team :features db/encode-pgarray conn "text")
::db/return-keys false)
(doseq [font (->> (read-seq cfg :team-font-variant)
(filter #(= team-id (:team-id %))))]
(let [font (-> font
(update :id bfc/lookup-index)
(update :team-id bfc/lookup-index)
(update :woff1-file-id bfc/lookup-index)
(update :woff2-file-id bfc/lookup-index)
(update :ttf-file-id bfc/lookup-index)
(update :otf-file-id bfc/lookup-index)
(assoc :created-at timestamp)
(assoc :modified-at timestamp))]
(db/insert! conn :team-font-variant font
::db/return-keys false)))
team))
(defn read-project!
[{:keys [::db/conn ::bfc/timestamp] :as cfg} project-id]
(l/trc :hint "read" :obj "project" :id (str project-id))
(let [project (read-obj cfg :project project-id)
project (-> project
(update :id bfc/lookup-index)
(update :team-id bfc/lookup-index)
(assoc :created-at timestamp)
(assoc :modified-at timestamp))]
(events/tap :progress
{:op :import
:section :read-project
:id project-id
:name (:name project)})
(db/insert! conn :project project
::db/return-keys false)))
(defn read-file!
[{:keys [::db/conn ::bfc/timestamp] :as cfg} file-id]
(l/trc :hint "read" :obj "file" :id (str file-id))
(let [file (-> (read-obj cfg :file file-id)
(update :id bfc/lookup-index)
(update :project-id bfc/lookup-index)
(bfc/process-file))]
(events/tap :progress
{:op :import
:section :read-file
:id file-id
:name (:name file)})
;; All features that are enabled and requires explicit migration are
;; added to the state for a posterior migration step.
(doseq [feature (-> (::bfc/features cfg)
(set/difference cfeat/no-migration-features)
(set/difference (:features file)))]
(vswap! bfc/*state* update :pending-to-migrate (fnil conj []) [feature (:id file)]))
(bfc/persist-file! cfg file))
(doseq [thumbnail (read-seq cfg :file-object-thumbnail file-id)]
(let [thumbnail (-> thumbnail
(update :file-id bfc/lookup-index)
(update :media-id bfc/lookup-index))
file-id (:file-id thumbnail)
thumbnail (update thumbnail :object-id
#(str/replace-first % #"^(.*?)/" (str file-id "/")))]
(db/insert! conn :file-tagged-object-thumbnail thumbnail
{::db/return-keys false})))
(doseq [rel (read-obj cfg :file-rels file-id)]
(let [rel (-> rel
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index)
(assoc :synced-at timestamp))]
(db/insert! conn :file-library-rel rel
::db/return-keys false)))
(doseq [media (read-seq cfg :file-media-object file-id)]
(let [media (-> media
(update :id bfc/lookup-index)
(update :file-id bfc/lookup-index)
(update :media-id bfc/lookup-index)
(update :thumbnail-id bfc/lookup-index))]
(db/insert! conn :file-media-object media
::db/return-keys false
::sql/on-conflict-do-nothing true))))
(def ^:private empty-summary
{:teams #{}
:files #{}
:projects #{}
:file-media-objects #{}
:team-font-variants #{}
:storage-objects #{}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn export-team!
[cfg team-id]
(let [id (uuid/next)
tp (dt/tpoint)
cfg (-> (create-database cfg)
(update ::sto/storage media/configure-assets-storage))]
(l/inf :hint "start"
:operation "export"
:id (str id)
:path (str (::path cfg)))
(try
(db/tx-run! cfg (fn [cfg]
(setup-schema! cfg)
(binding [bfc/*state* (volatile! empty-summary)]
(write-team! cfg team-id)
(run! (partial write-project! cfg)
(bfc/get-team-projects cfg team-id))
(run! (partial write-file! cfg)
(bfc/get-team-files cfg team-id))
(run! (partial write-storage-object! cfg)
(-> bfc/*state* deref :storage-objects))
(write! cfg :manifest "team-id" team-id)
(write! cfg :manifest "objects" (deref bfc/*state*))
(::path cfg))))
(finally
(pu/close! (::db cfg))
(let [elapsed (tp)]
(l/inf :hint "end"
:operation "export"
:id (str id)
:elapsed (dt/format-duration elapsed)))))))
(defn import-team!
[cfg path]
(let [id (uuid/next)
tp (dt/tpoint)
cfg (-> (create-database cfg path)
(update ::sto/storage media/configure-assets-storage)
(assoc ::bfc/timestamp (dt/now)))]
(l/inf :hint "start"
:operation "import"
:id (str id)
:path (str (::path cfg)))
(try
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(db/exec-one! conn ["SET idle_in_transaction_session_timeout = 0"])
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
(binding [bfc/*state* (volatile! {:index {}})]
(let [objects (read-obj cfg :manifest "objects")]
;; We first process all storage objects, they have
;; deduplication so we can't rely on simple reindex. This
;; operation populates the index for all storage objects.
(run! (partial read-storage-object! cfg) (:storage-objects objects))
;; Populate index with all the incoming objects
(vswap! bfc/*state* update :index
(fn [index]
(-> index
(bfc/update-index (:teams objects))
(bfc/update-index (:projects objects))
(bfc/update-index (:files objects))
(bfc/update-index (:file-media-objects objects))
(bfc/update-index (:team-font-variants objects)))))
(let [team-id (read-obj cfg :manifest "team-id")
team (read-team! cfg team-id)
features (cfeat/get-team-enabled-features cf/flags team)
cfg (assoc cfg ::bfc/features features)]
(run! (partial read-project! cfg) (:projects objects))
(run! (partial read-file! cfg) (:files objects))
;; (run-pending-migrations! cfg)
team)))))
(finally
(pu/close! (::db cfg))
(let [elapsed (tp)]
(l/inf :hint "end"
:operation "import"
:id (str id)
:elapsed (dt/format-duration elapsed)))))))

View File

@@ -79,18 +79,13 @@
:telemetry-uri "https://telemetry.penpot.app/"
:media-max-file-size (* 1024 1024 30) ; 30MiB
:ldap-user-query "(|(uid=:username)(mail=:username))"
:ldap-attrs-username "uid"
:ldap-attrs-email "mail"
:ldap-attrs-fullname "cn"
;; a server prop key where initial project is stored.
:initial-project-skey "initial-project"
;; time to avoid email sending after profile modification
:email-verify-threshold "15m"})
:initial-project-skey "initial-project"})
(s/def ::default-rpc-rlimit ::us/vector-of-strings)
(s/def ::rpc-rlimit-config ::fs/path)
@@ -104,11 +99,6 @@
(s/def ::audit-log-archive-uri ::us/string)
(s/def ::audit-log-http-handler-concurrency ::us/integer)
(s/def ::email-domain-blacklist ::fs/path)
(s/def ::email-domain-whitelist ::fs/path)
(s/def ::deletion-delay ::dt/duration)
(s/def ::admins ::us/set-of-valid-emails)
(s/def ::file-change-snapshot-every ::us/integer)
(s/def ::file-change-snapshot-timeout ::dt/duration)
@@ -119,7 +109,8 @@
(s/def ::worker-default-parallelism ::us/integer)
(s/def ::worker-webhook-parallelism ::us/integer)
(s/def ::auth-data-cookie-domain ::us/string)
(s/def ::authenticated-cookie-domain ::us/string)
(s/def ::authenticated-cookie-name ::us/string)
(s/def ::auth-token-cookie-name ::us/string)
(s/def ::auth-token-cookie-max-age ::dt/duration)
@@ -216,18 +207,17 @@
(s/def ::telemetry-uri ::us/string)
(s/def ::telemetry-with-taiga ::us/boolean)
(s/def ::tenant ::us/string)
(s/def ::email-verify-threshold ::dt/duration)
(s/def ::config
(s/keys :opt-un [::secret-key
::flags
::admins
::deletion-delay
::allow-demo-users
::audit-log-archive-uri
::audit-log-http-handler-concurrency
::auth-token-cookie-name
::auth-token-cookie-max-age
::authenticated-cookie-name
::authenticated-cookie-domain
::database-password
::database-uri
@@ -237,8 +227,6 @@
::database-max-pool-size
::default-blob-version
::default-rpc-rlimit
::email-domain-blacklist
::email-domain-whitelist
::error-report-webhook
::default-executor-parallelism
::scheduled-executor-parallelism
@@ -338,16 +326,14 @@
::telemetry-uri
::telemetry-referer
::telemetry-with-taiga
::tenant
::email-verify-threshold]))
::tenant]))
(def default-flags
[:enable-backend-api-doc
:enable-backend-openapi-doc
:enable-backend-worker
:enable-secure-session-cookies
:enable-email-verification
:enable-v2-migration])
:enable-email-verification])
(defn- parse-flags
[config]
@@ -392,8 +378,7 @@
(defonce ^:dynamic flags (parse-flags config))
(def deletion-delay
(or (c/get config :deletion-delay)
(dt/duration {:days 7})))
(dt/duration {:days 7}))
(defn get
"A configuration getter. Helps code be more testable."

View File

@@ -19,7 +19,6 @@
[app.util.json :as json]
[app.util.time :as dt]
[clojure.java.io :as io]
[clojure.set :as set]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[next.jdbc :as jdbc]
@@ -237,11 +236,8 @@
(jdbc/get-connection system-or-pool)
(if (map? system-or-pool)
(open (::pool system-or-pool))
(throw (IllegalArgumentException. "unable to resolve connection pool")))))
(defn get-update-count
[result]
(:next.jdbc/update-count result))
(ex/raise :type :internal
:code :unable-resolve-pool))))
(defn get-connection
[cfg-or-conn]
@@ -249,7 +245,9 @@
cfg-or-conn
(if (map? cfg-or-conn)
(get-connection (::conn cfg-or-conn))
(throw (IllegalArgumentException. "unable to resolve connection")))))
(ex/raise :type :internal
:code :unable-resolve-connection
:hint "expected conn or system map"))))
(defn connection-map?
"Check if the provided value is a map like data structure that
@@ -257,130 +255,58 @@
[o]
(and (map? o) (connection? (::conn o))))
(defn get-connectable
"Resolve to a connection or connection pool instance; if it is not
possible, raises an exception"
(defn- get-connectable
[o]
(cond
(connection? o) o
(pool? o) o
(map? o) (get-connectable (or (::conn o) (::pool o)))
:else (throw (IllegalArgumentException. "unable to resolve connectable"))))
(def ^:private params-mapping
{::return-keys? :return-keys
::return-keys :return-keys})
(defn rename-opts
[opts]
(set/rename-keys opts params-mapping))
(def ^:private default-insert-opts
{:builder-fn sql/as-kebab-maps
:return-keys true})
:else (ex/raise :type :internal
:code :unable-resolve-connectable
:hint "expected conn, pool or system")))
(def ^:private default-opts
{:builder-fn sql/as-kebab-maps})
(defn exec!
([ds sv] (exec! ds sv nil))
([ds sv]
(-> (get-connectable ds)
(jdbc/execute! sv default-opts)))
([ds sv opts]
(let [conn (get-connectable ds)
opts (if (empty? opts)
default-opts
(into default-opts (rename-opts opts)))]
(jdbc/execute! conn sv opts))))
(-> (get-connectable ds)
(jdbc/execute! sv (into default-opts (sql/adapt-opts opts))))))
(defn exec-one!
([ds sv] (exec-one! ds sv nil))
([ds sv]
(-> (get-connectable ds)
(jdbc/execute-one! sv default-opts)))
([ds sv opts]
(let [conn (get-connectable ds)
opts (if (empty? opts)
default-opts
(into default-opts (rename-opts opts)))]
(jdbc/execute-one! conn sv opts))))
(-> (get-connectable ds)
(jdbc/execute-one! sv (into default-opts (sql/adapt-opts opts))))))
(defn insert!
"A helper that builds an insert sql statement and executes it. By
default returns the inserted row with all the field; you can delimit
the returned columns with the `::columns` option."
[ds table params & {:as opts}]
(let [conn (get-connectable ds)
sql (sql/insert table params opts)
opts (if (empty? opts)
default-insert-opts
(into default-insert-opts (rename-opts opts)))]
(jdbc/execute-one! conn sql opts)))
[ds table params & {:as opts :keys [::return-keys?] :or {return-keys? true}}]
(-> (get-connectable ds)
(exec-one! (sql/insert table params opts)
(assoc opts ::return-keys? return-keys?))))
(defn insert-many!
"An optimized version of `insert!` that perform insertion of multiple
values at once.
This expands to a single SQL statement with placeholders for every
value being inserted. For large data sets, this may exceed the limit
of sql string size and/or number of parameters."
[ds table cols rows & {:as opts}]
(let [conn (get-connectable ds)
sql (sql/insert-many table cols rows opts)
opts (if (empty? opts)
default-insert-opts
(into default-insert-opts (rename-opts opts)))
opts (update opts :return-keys boolean)]
(jdbc/execute! conn sql opts)))
(defn insert-multi!
[ds table cols rows & {:as opts :keys [::return-keys?] :or {return-keys? true}}]
(-> (get-connectable ds)
(exec! (sql/insert-multi table cols rows opts)
(assoc opts ::return-keys? return-keys?))))
(defn update!
"A helper that build an UPDATE SQL statement and executes it.
Given a connectable object, a table name, a hash map of columns and
values to set, and either a hash map of columns and values to search
on or a vector of a SQL where clause and parameters, perform an
update on the table.
By default returns an object with the number of affected rows; a
complete row can be returned if you pass `::return-keys` with `true`
or with a vector of columns.
Also it can be combined with the `::many` option if you perform an
update to multiple rows and you want all the affected rows to be
returned."
[ds table params where & {:as opts}]
(let [conn (get-connectable ds)
sql (sql/update table params where opts)
opts (if (empty? opts)
default-opts
(into default-opts (rename-opts opts)))
opts (update opts :return-keys boolean)]
(if (::many opts)
(jdbc/execute! conn sql opts)
(jdbc/execute-one! conn sql opts))))
[ds table params where & {:as opts :keys [::return-keys?] :or {return-keys? true}}]
(-> (get-connectable ds)
(exec-one! (sql/update table params where opts)
(assoc opts ::return-keys? return-keys?))))
(defn delete!
"A helper that builds an DELETE SQL statement and executes it.
Given a connectable object, a table name, and either a hash map of columns
and values to search on or a vector of a SQL where clause and parameters,
perform a delete on the table.
By default returns an object with the number of affected rows; a
complete row can be returned if you pass `::return-keys` with `true`
or with a vector of columns.
Also it can be combined with the `::many` option if you perform an
update to multiple rows and you want all the affected rows to be
returned."
[ds table params & {:as opts}]
(let [conn (get-connectable ds)
sql (sql/delete table params opts)
opts (if (empty? opts)
default-opts
(into default-opts (rename-opts opts)))]
(if (::many opts)
(jdbc/execute! conn sql opts)
(jdbc/execute-one! conn sql opts))))
(defn query
[ds table params & {:as opts}]
(exec! ds (sql/select table params opts) opts))
[ds table params & {:as opts :keys [::return-keys?] :or {return-keys? true}}]
(-> (get-connectable ds)
(exec-one! (sql/delete table params opts)
(assoc opts ::return-keys? return-keys?))))
(defn is-row-deleted?
[{:keys [deleted-at]}]
@@ -394,7 +320,7 @@
[ds table params & {:as opts}]
(let [rows (exec! ds (sql/select table params opts))
rows (cond->> rows
(::remove-deleted opts true)
(::remove-deleted? opts true)
(remove is-row-deleted?))]
(first rows)))
@@ -403,7 +329,7 @@
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))
(when (and (not row) (::check-deleted? opts true))
(ex/raise :type :not-found
:code :object-not-found
:table table
@@ -415,29 +341,14 @@
(-> (get-connectable ds)
(jdbc/plan sql sql/default-opts)))
(defn cursor
"Return a lazy seq of rows using server side cursors"
[conn query & {:keys [chunk-size] :or {chunk-size 25}}]
(let [cname (str (gensym "cursor_"))
fquery [(str "FETCH " chunk-size " FROM " cname)]]
;; declare cursor
(exec-one! conn
(if (vector? query)
(into [(str "DECLARE " cname " CURSOR FOR " (nth query 0))]
(rest query))
[(str "DECLARE " cname " CURSOR FOR " query)]))
;; return a lazy seq
((fn fetch-more []
(lazy-seq
(when-let [chunk (seq (exec! conn fquery))]
(concat chunk (fetch-more))))))))
(defn get-by-id
[ds table id & {:as opts}]
(get ds table {:id id} opts))
(defn query
[ds table params & {:as opts}]
(exec! ds (sql/select table params opts)))
(defn pgobject?
([v]
(instance? PGobject v))
@@ -490,10 +401,6 @@
(.createArrayOf conn ^String type (into-array Object objects))
(.createArrayOf conn ^String type objects))))
(defn encode-pgarray
[data conn type]
(create-array conn type data))
(defn decode-pgpoint
[^PGpoint v]
(gpt/point (.-x v) (.-y v)))
@@ -514,14 +421,12 @@
(defn rollback!
([conn]
(if (and (map? conn) (::savepoint conn))
(rollback! conn (::savepoint conn))
(let [^Connection conn (get-connection conn)]
(l/trc :hint "explicit rollback requested")
(.rollback conn))))
(let [^Connection conn (get-connection conn)]
(l/trc :hint "explicit rollback requested")
(.rollback conn)))
([conn ^Savepoint sp]
(let [^Connection conn (get-connection conn)]
(l/trc :hint "explicit rollback requested (savepoint)")
(l/trc :hint "explicit rollback requested")
(.rollback conn sp))))
(defn tx-run!
@@ -537,30 +442,23 @@
(let [conn (::conn system)
sp (savepoint conn)]
(try
(let [system' (-> system
(assoc ::savepoint sp)
(dissoc ::rollback))
result (apply f system' params)]
(if (::rollback system)
(rollback! conn sp)
(release! conn sp))
(let [result (apply f system params)]
(release! conn sp)
result)
(catch Throwable cause
(.rollback ^Connection conn ^Savepoint sp)
(rollback! conn sp)
(throw cause))))
(::pool system)
(with-atomic [conn (::pool system)]
(let [system' (-> system
(assoc ::conn conn)
(dissoc ::rollback))
result (apply f system' params)]
(let [system (assoc system ::conn conn)
result (apply f system params)]
(when (::rollback system)
(rollback! conn))
result))
:else
(throw (IllegalArgumentException. "invalid system/cfg provided"))))
(throw (IllegalArgumentException. "invalid arguments"))))
(defn run!
[system f & params]
@@ -650,6 +548,11 @@
(.setType "jsonb")
(.setValue (json/encode-str data)))))
(defn get-update-count
[result]
(:next.jdbc/update-count result))
;; --- Locks
(def ^:private siphash-state

View File

@@ -8,6 +8,7 @@
(:refer-clojure :exclude [update])
(:require
[app.db :as-alias db]
[clojure.set :as set]
[clojure.string :as str]
[next.jdbc.optional :as jdbc-opt]
[next.jdbc.sql.builder :as sql]))
@@ -19,6 +20,14 @@
{:table-fn snake-case
:column-fn snake-case})
(def params-mapping
{::db/return-keys? :return-keys
::db/columns :columns})
(defn adapt-opts
[opts]
(set/rename-keys opts params-mapping))
(defn as-kebab-maps
[rs opts]
(jdbc-opt/as-unqualified-modified-maps rs (assoc opts :label-fn kebab-case)))
@@ -30,13 +39,10 @@
(let [opts (merge default-opts opts)
opts (cond-> opts
(::db/on-conflict-do-nothing? opts)
(assoc :suffix "ON CONFLICT DO NOTHING")
(::on-conflict-do-nothing opts)
(assoc :suffix "ON CONFLICT DO NOTHING"))]
(sql/for-insert table key-map opts))))
(defn insert-many
(defn insert-multi
[table cols rows opts]
(let [opts (merge default-opts opts)]
(sql/for-insert-multi table cols rows opts)))
@@ -47,10 +53,11 @@
([table where-params opts]
(let [opts (merge default-opts opts)
opts (cond-> opts
(::order-by opts) (assoc :order-by (::order-by opts))
(::columns opts) (assoc :columns (::columns opts))
(::for-update opts) (assoc :suffix "FOR UPDATE")
(::for-share opts) (assoc :suffix "FOR SHARE"))]
(::db/columns opts) (assoc :columns (::db/columns 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"))]
(sql/for-query table where-params opts))))
(defn update
@@ -58,9 +65,11 @@
(update table key-map where-params nil))
([table key-map where-params opts]
(let [opts (into default-opts opts)
keys (::db/return-keys opts)
opts (if (vector? keys)
(assoc opts :suffix (str "RETURNING " (sql/as-cols keys opts)))
opts (if-let [columns (::db/columns opts)]
(let [columns (if (seq columns)
(sql/as-cols columns opts)
"*")]
(assoc opts :suffix (str "RETURNING " columns)))
opts)]
(sql/for-update table key-map where-params opts))))
@@ -68,9 +77,5 @@
([table where-params]
(delete table where-params nil))
([table where-params opts]
(let [opts (merge default-opts opts)
keys (::db/return-keys opts)
opts (if (vector? keys)
(assoc opts :suffix (str "RETURNING " (sql/as-cols keys opts)))
opts)]
(let [opts (merge default-opts opts)]
(sql/for-delete table where-params opts))))

View File

@@ -262,12 +262,13 @@
(let [email (if factory
(factory context)
(dissoc context ::conn))]
(wrk/submit! {::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
::wrk/priority 200
::db/conn conn
::wrk/params email})))
(wrk/submit! (merge
{::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
::wrk/priority 200
::wrk/conn conn}
email))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SENDMAIL FN / TASK HANDLER
@@ -306,8 +307,6 @@
(let [session (create-smtp-session cfg)]
(with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))]
(.connect ^Transport transport
^String (::host cfg)
^String (::port cfg)
^String (::username cfg)
^String (::password cfg))
@@ -450,11 +449,3 @@
{:email email :type "bounce"}
{:limit 10}))]
(>= (count reports) threshold))))
(defn has-reports?
([conn email] (has-reports? conn email nil))
([conn email {:keys [threshold] :or {threshold 1}}]
(let [reports (db/exec! conn (sql/select :global-complaint-report
{:email email}
{:limit 10}))]
(>= (count reports) threshold))))

View File

@@ -1,47 +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.email.blacklist
"Email blacklist provider"
(:refer-clojure :exclude [contains?])
(:require
[app.common.logging :as l]
[app.config :as cf]
[app.email :as-alias email]
[clojure.core :as c]
[clojure.java.io :as io]
[cuerdas.core :as str]
[integrant.core :as ig]))
(defmethod ig/init-key ::email/blacklist
[_ _]
(when (c/contains? cf/flags :email-blacklist)
(try
(let [path (cf/get :email-domain-blacklist)
result (with-open [reader (io/reader path)]
(reduce (fn [result line]
(if (str/starts-with? line "#")
result
(conj result (-> line str/trim str/lower))))
#{}
(line-seq reader)))]
(l/inf :hint "initializing email blacklist" :domains (count result))
(not-empty result))
(catch Throwable cause
(l/wrn :hint "unexpected exception on initializing email blacklist"
:cause cause)))))
(defn contains?
"Check if email is in the blacklist."
[{:keys [::email/blacklist]} email]
(let [[_ domain] (str/split email "@" 2)]
(c/contains? blacklist (str/lower domain))))
(defn enabled?
"Check if the blacklist is enabled"
[{:keys [::email/blacklist]}]
(some? blacklist))

View File

@@ -1,59 +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.email.whitelist
"Email whitelist provider"
(:refer-clojure :exclude [contains?])
(:require
[app.common.logging :as l]
[app.config :as cf]
[app.email :as-alias email]
[clojure.core :as c]
[clojure.java.io :as io]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[integrant.core :as ig]))
(defn- read-whitelist
[path]
(when (and path (fs/exists? path))
(try
(with-open [reader (io/reader path)]
(reduce (fn [result line]
(if (str/starts-with? line "#")
result
(conj result (-> line str/trim str/lower))))
#{}
(line-seq reader)))
(catch Throwable cause
(l/wrn :hint "unexpected exception on reading email whitelist"
:cause cause)))))
(defmethod ig/init-key ::email/whitelist
[_ _]
(let [whitelist (or (cf/get :registration-domain-whitelist) #{})
whitelist (if (c/contains? cf/flags :email-whitelist)
(into whitelist (read-whitelist (cf/get :email-domain-whitelist)))
whitelist)
whitelist (not-empty whitelist)]
(when whitelist
(l/inf :hint "initializing email whitelist" :domains (count whitelist)))
whitelist))
(defn contains?
"Check if email is in the whitelist."
[{:keys [::email/whitelist]} email]
(let [[_ domain] (str/split email "@" 2)]
(c/contains? whitelist (str/lower domain))))
(defn enabled?
"Check if the whitelist is enabled"
[{:keys [::email/whitelist]}]
(some? whitelist))

View File

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.util.blob :as blob]
[app.util.objects-map :as omap]
[app.util.pointer-map :as pmap]))
@@ -22,35 +21,14 @@
(defn enable-objects-map
[file]
(let [update-page
(fn [page]
(if (and (pmap/pointer-map? page)
(not (pmap/loaded? page)))
page
(update page :objects omap/wrap)))
update-data
(fn [fdata]
(update fdata :pages-index d/update-vals update-page))]
(let [update-fn #(d/update-when % :objects omap/wrap)]
(-> file
(update :data update-data)
(update :data (fn [fdata]
(-> fdata
(update :pages-index update-vals update-fn)
(update :components update-vals update-fn))))
(update :features conj "fdata/objects-map"))))
(defn process-objects
"Apply a function to all objects-map on the file. Usualy used for convert
the objects-map instances to plain maps"
[fdata update-fn]
(if (contains? fdata :pages-index)
(update fdata :pages-index d/update-vals
(fn [page]
(update page :objects
(fn [objects]
(if (omap/objects-map? objects)
(update-fn objects)
objects)))))
fdata))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; POINTER-MAP
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -60,14 +38,8 @@
[system file-id id]
(let [{:keys [content]} (db/get system :file-data-fragment
{:id id :file-id file-id}
{::sql/columns [:content]
::db/check-deleted false})]
(l/trc :hint "load pointer"
:file-id (str file-id)
:id (str id)
:found (some? content))
{::db/columns [:content]
::db/check-deleted? false})]
(when-not content
(ex/raise :type :internal
:code :fragment-not-found
@@ -81,27 +53,28 @@
"Given a database connection and the final file-id, persist all
pointers to the underlying storage (the database)."
[system file-id]
(let [conn (db/get-connection system)]
(doseq [[id item] @pmap/*tracked*]
(when (pmap/modified? item)
(l/trc :hint "persist pointer" :file-id (str file-id) :id (str id))
(let [content (-> item deref blob/encode)]
(db/insert! conn :file-data-fragment
{:id id
:file-id file-id
:content content}))))))
(doseq [[id item] @pmap/*tracked*]
(when (pmap/modified? item)
(l/trc :hint "persist pointer" :file-id (str file-id) :id (str id))
(let [content (-> item deref blob/encode)]
(db/insert! system :file-data-fragment
{:id id
:file-id file-id
:content content})))))
(defn process-pointers
"Apply a function to all pointers on the file. Usuly used for
dereference the pointer to a plain value before some processing."
[fdata update-fn]
(let [update-fn' (fn [val]
(if (pmap/pointer-map? val)
(update-fn val)
val))]
(-> fdata
(d/update-vals update-fn')
(update :pages-index d/update-vals update-fn'))))
(cond-> fdata
(contains? fdata :pages-index)
(update :pages-index process-pointers update-fn)
:always
(update-vals (fn [val]
(if (pmap/pointer-map? val)
(update-fn val)
val)))))
(defn get-used-pointer-ids
"Given a file, return all pointer ids used in the data."
@@ -117,6 +90,7 @@
(-> file
(update :data (fn [fdata]
(-> fdata
(update :pages-index d/update-vals pmap/wrap)
(d/update-when :components pmap/wrap))))
(update :pages-index update-vals pmap/wrap)
(update :components pmap/wrap))))
(update :features conj "fdata/pointer-map")))

View File

@@ -23,7 +23,6 @@
[app.metrics :as mtx]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias rpc.doc]
[app.setup :as-alias setup]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.exec :as px]
@@ -53,8 +52,8 @@
[_ cfg]
(merge {::port 6060
::host "0.0.0.0"
::max-body-size (* 1024 1024 30) ; default 30 MiB
::max-multipart-body-size (* 1024 1024 120)} ; default 120 MiB
::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 [_]
@@ -114,7 +113,7 @@
(partial not-found-handler request)))
(on-error [cause request]
(let [{:keys [::rres/body] :as response} (errors/handle cause request)]
(let [{:keys [body] :as response} (errors/handle cause request)]
(cond-> response
(map? body)
(-> (update ::rres/headers assoc "content-type" "application/transit+json")
@@ -137,7 +136,7 @@
::rpc/routes
::rpc.doc/routes
::oidc/routes
::setup/props
::main/props
::assets/routes
::debug/routes
::db/pool
@@ -151,9 +150,9 @@
[mw/params]
[mw/format-response]
[mw/parse-request]
[mw/errors errors/handle]
[session/soft-auth cfg]
[actoken/soft-auth cfg]
[mw/errors errors/handle]
[mw/restrict-methods]]}
(::mtx/routes cfg)

View File

@@ -10,7 +10,6 @@
[app.config :as cf]
[app.db :as db]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[ring.request :as rreq]))
@@ -43,7 +42,7 @@
(defn- wrap-soft-auth
"Soft Authentication, will be executed synchronously on the undertow
worker thread."
[handler {:keys [::setup/props]}]
[handler {:keys [::main/props]}]
(letfn [(handle-request [request]
(try
(let [token (get-token request)

View File

@@ -9,18 +9,16 @@
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.db :as db]
[app.db.sql :as sql]
[app.http.client :as http]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.worker :as-alias wrk]
[clojure.data.json :as j]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[jsonista.core :as j]
[promesa.exec :as px]
[ring.request :as rreq]
[ring.response :as-alias rres]))
@@ -32,7 +30,7 @@
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::http/client
::setup/props
::main/props
::db/pool]))
(defmethod ig/init-key ::routes
@@ -108,7 +106,7 @@
[cfg headers]
(let [tdata (get headers "x-penpot-data")]
(when-not (str/empty? tdata)
(let [result (tokens/verify (::setup/props cfg) {:token tdata :iss :profile-identity})]
(let [result (tokens/verify (::main/props cfg) {:token tdata :iss :profile-identity})]
(:profile-id result)))))
(defn- parse-notification
@@ -137,110 +135,83 @@
(defn- parse-json
[v]
(try
(j/read-str v)
(catch Throwable cause
(l/wrn :hint "unable to decode request body"
:cause cause))))
(ex/ignoring
(j/read-value v)))
(defn- register-bounce-for-profile
[{:keys [::db/pool]} {:keys [type kind profile-id] :as report}]
(when (= kind "permanent")
(try
(db/insert! pool :profile-complaint-report
(db/with-atomic [conn pool]
(db/insert! conn :profile-complaint-report
{:profile-id profile-id
:type (name type)
:content (db/tjson report)})
(catch Throwable cause
(l/warn :hint "unable to persist profile complaint"
:cause cause)))
;; TODO: maybe also try to find profiles by mail and if exists
;; register profile reports for them?
(doseq [recipient (:recipients report)]
(db/insert! conn :global-complaint-report
{:email (:email recipient)
:type (name type)
:content (db/tjson report)}))
(doseq [recipient (:recipients report)]
(db/insert! pool :global-complaint-report
{:email (:email recipient)
:type (name type)
:content (db/tjson report)}))
(let [profile (db/exec-one! pool (sql/select :profile {:id profile-id}))]
(when (some #(= (:email profile) (:email %)) (:recipients report))
;; If the report matches the profile email, this means that
;; the report is for itself, can be caused when a user
;; registers with an invalid email or the user email is
;; permanently rejecting receiving the email. In this case we
;; have no option to mark the user as muted (and in this case
;; the profile will be also inactive.
(l/inf :hint "mark profile: muted"
:profile-id (str (:id profile))
:email (:email profile)
:reason "bounce report"
:report-id (:feedback-id report))
(db/update! pool :profile
{:is-muted true}
{:id profile-id}
{::db/return-keys false})))))
(let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))]
(when (some #(= (:email profile) (:email %)) (:recipients report))
;; If the report matches the profile email, this means that
;; the report is for itself, can be caused when a user
;; registers with an invalid email or the user email is
;; permanently rejecting receiving the email. In this case we
;; have no option to mark the user as muted (and in this case
;; the profile will be also inactive.
(db/update! conn :profile
{:is-muted true}
{:id profile-id}))))))
(defn- register-complaint-for-profile
[{:keys [::db/pool]} {:keys [type profile-id] :as report}]
(try
(db/insert! pool :profile-complaint-report
(db/with-atomic [conn pool]
(db/insert! conn :profile-complaint-report
{:profile-id profile-id
:type (name type)
:content (db/tjson report)})
(catch Throwable cause
(l/warn :hint "unable to persist profile complaint"
:cause cause)))
;; TODO: maybe also try to find profiles by email and if exists
;; register profile reports for them?
(doseq [email (:recipients report)]
(db/insert! pool :global-complaint-report
{:email email
:type (name type)
:content (db/tjson report)}))
;; TODO: maybe also try to find profiles by email and if exists
;; register profile reports for them?
(doseq [email (:recipients report)]
(db/insert! conn :global-complaint-report
{:email email
:type (name type)
:content (db/tjson report)}))
(let [profile (db/exec-one! pool (sql/select :profile {:id profile-id}))]
(when (some #(= % (:email profile)) (:recipients report))
;; If the report matches the profile email, this means that
;; the report is for itself, rare case but can happen; In this
;; case just mark profile as muted (very rare case).
(l/inf :hint "mark profile: muted"
:profile-id (str (:id profile))
:email (:email profile)
:reason "complaint report"
:report-id (:feedback-id report))
(db/update! pool :profile
{:is-muted true}
{:id profile-id}
{::db/return-keys false}))))
(let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))]
(when (some #(= % (:email profile)) (:recipients report))
;; If the report matches the profile email, this means that
;; the report is for itself, rare case but can happen; In this
;; case just mark profile as muted (very rare case).
(db/update! conn :profile
{:is-muted true}
{:id profile-id})))))
(defn- process-report
[cfg {:keys [type profile-id] :as report}]
(l/trace :action "processing report" :report (pr-str report))
(cond
;; In this case we receive a bounce/complaint notification without
;; confirmed identity, we just emit a warning but do nothing about
;; it because this is not a normal case. All notifications should
;; come with profile identity.
(nil? profile-id)
(l/wrn :hint "not-identified report"
::l/body (pp/pprint-str report {:length 40 :level 6}))
(l/warn :msg "a notification without identity received from AWS"
:report (pr-str report))
(= "bounce" type)
(do
(l/trc :hint "bounce report"
::l/body (pp/pprint-str report {:length 40 :level 6}))
(register-bounce-for-profile cfg report))
(register-bounce-for-profile cfg report)
(= "complaint" type)
(do
(l/trc :hint "complaint report"
::l/body (pp/pprint-str report {:length 40 :level 6}))
(register-complaint-for-profile cfg report))
(register-complaint-for-profile cfg report)
:else
(l/wrn :hint "unrecognized report"
::l/body (pp/pprint-str report {:length 20 :level 4}))))
(l/warn :msg "unrecognized report received from AWS"
:report (pr-str report))))

View File

@@ -55,8 +55,8 @@
convention."
([cfg-or-client request]
(let [client (resolve-client cfg-or-client)]
(send! client request {:sync? true})))
(send! client request {})))
([cfg-or-client request options]
(let [client (resolve-client cfg-or-client)]
(send! client request (merge {:sync? true} options)))))
(send! client request options))))

View File

@@ -7,7 +7,6 @@
(ns app.http.debug
(:refer-clojure :exclude [error-handler])
(:require
[app.binfile.v1 :as bf.v1]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
@@ -16,13 +15,13 @@
[app.config :as cf]
[app.db :as db]
[app.http.session :as session]
[app.main :as-alias main]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.binfile :as binf]
[app.rpc.commands.files-create :refer [create-file]]
[app.rpc.commands.profile :as profile]
[app.setup :as-alias setup]
[app.srepl.helpers :as srepl]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[app.util.blob :as blob]
[app.util.template :as tmpl]
[app.util.time :as dt]
@@ -100,11 +99,11 @@
(let [profile (profile/get-profile pool profile-id)
project-id (:default-project-id profile)]
(db/run! pool (fn [{:keys [::db/conn] :as cfg}]
(create-file cfg {:id file-id
:name (str "Cloned file: " filename)
:project-id project-id
:profile-id profile-id})
(db/run! pool (fn [{:keys [::db/conn]}]
(create-file conn {:id file-id
:name (str "Cloned file: " filename)
:project-id project-id
:profile-id profile-id})
(db/update! conn :file
{:data data}
{:id file-id})
@@ -141,11 +140,11 @@
{::rres/status 200
::rres/body "OK UPDATED"})
(db/run! pool (fn [{:keys [::db/conn] :as cfg}]
(create-file cfg {:id file-id
:name fname
:project-id project-id
:profile-id profile-id})
(db/run! pool (fn [{:keys [::db/conn]}]
(create-file conn {:id file-id
:name fname
:project-id project-id
:profile-id profile-id})
(db/update! conn :file
{:data data}
{:id file-id})
@@ -269,10 +268,9 @@
(defn export-handler
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
(let [file-ids (into #{}
(comp (remove empty?)
(map parse-uuid))
(:file-ids params))
(let [file-ids (->> (:file-ids params)
(remove empty?)
(mapv parse-uuid))
libs? (contains? params :includelibs)
clone? (contains? params :clone)
embed? (contains? params :embedassets)]
@@ -281,22 +279,22 @@
(ex/raise :type :validation
:code :missing-arguments))
(let [path (tmp/tempfile :prefix "penpot.export.")]
(with-open [output (io/output-stream path)]
(-> cfg
(assoc ::bf.v1/ids file-ids)
(assoc ::bf.v1/embed-assets embed?)
(assoc ::bf.v1/include-libraries libs?)
(bf.v1/export-files! output)))
(let [path (-> cfg
(assoc ::binf/file-ids file-ids)
(assoc ::binf/embed-assets? embed?)
(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)
cfg (assoc cfg
::bf.v1/overwrite false
::bf.v1/profile-id profile-id
::bf.v1/project-id project-id)]
(bf.v1/import-files! cfg path)
project-id (:default-project-id profile)]
(binf/import!
(assoc cfg
::binf/input path
::binf/overwrite? false
::binf/ignore-index-errors? true
::binf/profile-id profile-id
::binf/project-id project-id))
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body "OK CLONED"})
@@ -307,6 +305,7 @@
"content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}}))))
(defn import-handler
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
(when-not (contains? params :file)
@@ -317,83 +316,83 @@
(let [profile (profile/get-profile pool profile-id)
project-id (:default-project-id profile)
overwrite? (contains? params :overwrite)
migrate? (contains? params :migrate)]
migrate? (contains? params :migrate)
ignore-index-errors? (contains? params :ignore-index-errors)]
(when-not project-id
(ex/raise :type :validation
:code :missing-project
:hint "project not found"))
(let [path (-> params :file :path)
cfg (assoc cfg
::bf.v1/overwrite overwrite?
::bf.v1/migrate migrate?
::bf.v1/profile-id profile-id
::bf.v1/project-id project-id)]
(bf.v1/import-files! cfg path)
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body "OK"})))
(binf/import!
(assoc cfg
::binf/input (-> params :file :path)
::binf/overwrite? overwrite?
::binf/migrate? migrate?
::binf/ignore-index-errors? ignore-index-errors?
::binf/profile-id profile-id
::binf/project-id project-id))
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body "OK"}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ACTIONS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- resend-email-notification
[cfg {:keys [params] :as request}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(when-not (contains? params :force)
(ex/raise :type :validation
:code :missing-force
:hint "missing force checkbox"))
[{:keys [::db/pool ::main/props] :as cfg} {:keys [params] :as request}]
(let [profile (some->> params
:email
(profile/clean-email)
(profile/get-profile-by-email conn))]
(when-not (contains? params :force)
(ex/raise :type :validation
:code :missing-force
:hint "missing force checkbox"))
(when-not profile
(ex/raise :type :validation
:code :missing-profile
:hint "unable to find profile by email"))
(let [profile (some->> params :email (profile/get-profile-by-email pool))]
(cond
(contains? params :block)
(do
(db/update! conn :profile {:is-blocked true} {:id (:id profile)})
(db/delete! conn :http-session {:profile-id (:id profile)})
(when-not profile
(ex/raise :type :validation
:code :missing-profile
:hint "unable to find profile by email"))
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "PROFILE '%' BLOCKED" (:email profile))})
(cond
(contains? params :block)
(do
(db/update! pool :profile {:is-blocked true} {:id (:id profile)})
(db/delete! pool :http-session {:profile-id (:id profile)})
(contains? params :unblock)
(do
(db/update! conn :profile {:is-blocked false} {:id (:id profile)})
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "PROFILE '%' UNBLOCKED" (:email profile))})
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "PROFILE '%' BLOCKED" (:email profile))})
(contains? params :resend)
(if (:is-blocked profile)
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body "PROFILE ALREADY BLOCKED"}
(do
(#'auth/send-email-verification! cfg profile)
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "RESENDED FOR '%'" (:email profile))}))
(contains? params :unblock)
(do
(db/update! pool :profile {:is-blocked false} {:id (:id profile)})
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "PROFILE '%' UNBLOCKED" (:email profile))})
:else
(do
(db/update! conn :profile {:is-active true} {:id (:id profile)})
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "PROFILE '%' ACTIVATED" (:email profile))}))))))
(contains? params :resend)
(if (:is-blocked profile)
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body "PROFILE ALREADY BLOCKED"}
(do
(auth/send-email-verification! pool props profile)
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "RESENDED FOR '%'" (:email profile))}))
:else
(do
(db/update! pool :profile {:is-active true} {:id (:id profile)})
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body (str/ffmt "PROFILE '%' ACTIVATED" (:email profile))}))))
(defn- reset-file-version
(defn- reset-file-data-version
[cfg {:keys [params] :as request}]
(let [file-id (some-> params :file-id d/parse-uuid)
version (some-> params :version d/parse-integer)]
@@ -413,8 +412,13 @@
:code :invalid-version
:hint "provided invalid version"))
(db/tx-run! cfg srepl/process-file! file-id #(assoc % :version version))
(srepl/update-file! cfg
:id file-id
:update-fn (fn [file]
(update file :data assoc :version version))
:migrate? false
:inc-revn? false
:save? true)
{::rres/status 200
::rres/headers {"content-type" "text/plain"}
::rres/body "OK"}))
@@ -486,8 +490,8 @@
["/error" {:handler (partial error-list-handler cfg)}]
["/actions/resend-email-verification"
{:handler (partial resend-email-notification cfg)}]
["/actions/reset-file-version"
{:handler (partial reset-file-version cfg)}]
["/actions/reset-file-data-version"
{:handler (partial reset-file-data-version cfg)}]
["/file/export" {:handler (partial export-handler cfg)}]
["/file/import" {:handler (partial import-handler cfg)}]
["/file/data" {:handler (partial file-data-handler cfg)}]

View File

@@ -14,28 +14,32 @@
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.session :as-alias session]
[app.util.inet :as inet]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[ring.request :as rreq]
[ring.response :as rres]))
(defn- parse-client-ip
[request]
(or (some-> (rreq/get-header request "x-forwarded-for") (str/split ",") first)
(rreq/get-header request "x-real-ip")
(rreq/remote-addr request)))
(defn request->context
"Extracts error report relevant context data from request."
[request]
(let [claims (-> {}
(into (::session/token-claims request))
(into (::actoken/token-claims request)))]
{:request/path (:path request)
:request/method (:method request)
:request/params (:params request)
:request/user-agent (rreq/get-header request "user-agent")
:request/ip-addr (inet/parse-request request)
:request/ip-addr (parse-client-ip request)
:request/profile-id (:uid claims)
:version/frontend (or (rreq/get-header request "x-frontend-version") "unknown")
:version/backend (:full cf/version)}))
(defmulti handle-error
(fn [cause _ _]
(-> cause ex-data :type)))
@@ -56,12 +60,8 @@
(defmethod handle-error :restriction
[err _ _]
(let [{:keys [code] :as data} (ex-data err)]
(if (= code :method-not-allowed)
{::rres/status 405
::rres/body data}
{::rres/status 400
::rres/body data})))
{::rres/status 400
::rres/body (ex-data err)})
(defmethod handle-error :rate-limit
[err _ _]
@@ -81,7 +81,6 @@
(cond
(or (= code :spec-validation)
(= code :params-validation)
(= code :schema-validation)
(= code :data-validation))
(let [explain (ex/explain data)]
{::rres/status 400
@@ -95,7 +94,7 @@
(= code :invalid-image)
(binding [l/*context* (request->context request)]
(let [cause (or parent-cause err)]
(l/warn :hint "unexpected error on processing image" :cause cause)
(l/error :hint "unexpected error on processing image" :cause cause)
{::rres/status 400 ::rres/body data}))
:else
@@ -214,14 +213,6 @@
:hint (ex-message error)
:data edata}}))))
(defmethod handle-exception java.io.IOException
[cause _ _]
(l/wrn :hint "io exception" :cause cause)
{::rres/status 500
::rres/body {:type :server-error
:code :io-exception
:hint (ex-message cause)}})
(defmethod handle-exception java.util.concurrent.CompletionException
[cause request _]
(let [cause' (ex-cause cause)]

View File

@@ -10,14 +10,16 @@
[app.common.logging :as l]
[app.common.transit :as t]
[app.config :as cf]
[app.http.errors :as errors]
[clojure.data.json :as json]
[app.util.json :as json]
[cuerdas.core :as str]
[ring.request :as rreq]
[ring.response :as rres]
[yetti.adapter :as yt]
[yetti.middleware :as ymw])
(:import
com.fasterxml.jackson.core.JsonParseException
com.fasterxml.jackson.core.io.JsonEOFException
com.fasterxml.jackson.databind.exc.MismatchedInputException
io.undertow.server.RequestTooBigException
java.io.InputStream
java.io.OutputStream))
@@ -32,22 +34,11 @@
{:name ::params
:compile (constantly ymw/wrap-params)})
(defn- get-reader
^java.io.BufferedReader
[request]
(let [^InputStream body (rreq/body request)]
(java.io.BufferedReader.
(java.io.InputStreamReader. body))))
(defn- read-json-key
[k]
(-> k str/kebab keyword))
(defn- write-json-key
[k]
(if (or (keyword? k) (symbol? k))
(str/camel k)
(str k)))
(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]
@@ -62,8 +53,8 @@
(update :params merge params))))
(str/starts-with? header "application/json")
(with-open [reader (get-reader request)]
(let [params (json/read reader :key-fn read-json-key)]
(with-open [^InputStream is (rreq/body request)]
(let [params (json/decode is json-mapper)]
(-> request
(assoc :body-params params)
(update :params merge params))))
@@ -71,33 +62,35 @@
:else
request)))
(handle-error [cause request]
(handle-error [cause]
(cond
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
(handle-error cause)
(throw cause))
(instance? RequestTooBigException cause)
(ex/raise :type :validation
:code :request-body-too-large
:hint (ex-message cause))
(instance? java.io.EOFException cause)
(or (instance? JsonEOFException cause)
(instance? JsonParseException cause)
(instance? MismatchedInputException cause))
(ex/raise :type :validation
:code :malformed-json
:hint (ex-message cause)
:cause cause)
:else
(errors/handle cause request)))]
(throw cause)))]
(fn [request]
(if (= (rreq/method request) :post)
(try
(-> request process-request handler)
(catch Throwable cause
(handle-error cause request)))
(let [request (ex/try! (process-request request))]
(if (ex/exception? request)
(handle-error request)
(handler request)))
(handler request)))))
(def parse-request
@@ -135,8 +128,7 @@
(-write-body-to-stream [_ _ output-stream]
(try
(with-open [^OutputStream bos (buffered-output-stream output-stream buffer-size)]
(with-open [^java.io.OutputStreamWriter writer (java.io.OutputStreamWriter. bos)]
(json/write data writer :key-fn write-json-key)))
(json/write! bos data json-mapper))
(catch java.io.IOException _)
(catch Throwable cause

View File

@@ -10,13 +10,11 @@
[app.common.data :as d]
[app.common.logging :as l]
[app.common.spec :as us]
[app.common.uri :as u]
[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.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.time :as dt]
[clojure.spec.alpha :as s]
@@ -34,7 +32,7 @@
;; A cookie that we can use to check from other sites of the same
;; domain if a user is authenticated.
(def default-auth-data-cookie-name "auth-data")
(def default-authenticated-cookie-name "authenticated")
;; Default value for cookie max-age
(def default-cookie-max-age (dt/duration {:days 7}))
@@ -134,13 +132,13 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private assign-auth-token-cookie)
(declare ^:private assign-auth-data-cookie)
(declare ^:private assign-authenticated-cookie)
(declare ^:private clear-auth-token-cookie)
(declare ^:private clear-auth-data-cookie)
(declare ^:private clear-authenticated-cookie)
(declare ^:private gen-token)
(defn create-fn
[{:keys [::manager ::setup/props]} profile-id]
[{:keys [::manager ::main/props]} profile-id]
(us/assert! ::manager manager)
(us/assert! ::us/uuid profile-id)
@@ -154,7 +152,7 @@
(l/trace :hint "create" :profile-id (str profile-id))
(-> response
(assign-auth-token-cookie session)
(assign-auth-data-cookie session)))))
(assign-authenticated-cookie session)))))
(defn delete-fn
[{:keys [::manager]}]
@@ -168,7 +166,7 @@
(assoc :status 204)
(assoc :body nil)
(clear-auth-token-cookie)
(clear-auth-data-cookie)))))
(clear-authenticated-cookie)))))
(defn- gen-token
[props {:keys [profile-id created-at]}]
@@ -198,7 +196,7 @@
(neg? (compare default-renewal-max-age elapsed)))))
(defn- wrap-soft-auth
[handler {:keys [::manager ::setup/props]}]
[handler {:keys [::manager ::main/props]}]
(us/assert! ::manager manager)
(letfn [(handle-request [request]
(try
@@ -230,7 +228,7 @@
(let [session (update! manager session)]
(-> response
(assign-auth-token-cookie session)
(assign-auth-data-cookie session)))
(assign-authenticated-cookie session)))
response))))
(def soft-auth
@@ -250,7 +248,6 @@
renewal (dt/plus created-at default-renewal-max-age)
expires (dt/plus created-at max-age)
secure? (contains? cf/flags :secure-session-cookies)
strict? (contains? cf/flags :strict-session-cookies)
cors? (contains? cf/flags :cors)
name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
comment (str "Renewal at: " (dt/format-instant renewal :rfc1123))
@@ -259,15 +256,15 @@
:expires expires
:value token
:comment comment
:same-site (if cors? :none (if strict? :strict :lax))
:same-site (if cors? :none :lax)
:secure secure?}]
(update response :cookies assoc name cookie)))
(defn- assign-auth-data-cookie
[response {profile-id :profile-id updated-at :updated-at}]
(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 :auth-data-cookie-domain)
cname default-auth-data-cookie-name
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)
@@ -275,17 +272,14 @@
comment (str "Renewal at: " (dt/format-instant renewal :rfc1123))
secure? (contains? cf/flags :secure-session-cookies)
strict? (contains? cf/flags :strict-session-cookies)
cors? (contains? cf/flags :cors)
cookie {:domain domain
:expires expires
:path "/"
:comment comment
:value (u/map->query-string {:profile-id profile-id})
:same-site (if cors? :none (if strict? :strict :lax))
:value true
:same-site :strict
:secure secure?}]
(cond-> response
(string? domain)
(update :cookies assoc cname cookie))))
@@ -295,10 +289,10 @@
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(update response :cookies assoc cname {:path "/" :value "" :max-age 0})))
(defn- clear-auth-data-cookie
(defn- clear-authenticated-cookie
[response]
(let [cname default-auth-data-cookie-name
domain (cf/get :auth-data-cookie-domain)]
(let [cname (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}))))

View File

@@ -9,10 +9,11 @@
(:refer-clojure :exclude [tap])
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.transit :as t]
[app.http.errors :as errors]
[app.util.events :as events]
[promesa.core :as p]
[promesa.exec :as px]
[promesa.exec.csp :as sp]
[promesa.util :as pu]
@@ -20,12 +21,26 @@
(:import
java.io.OutputStream))
(def ^:dynamic *channel* nil)
(defn- write!
[^OutputStream output ^bytes data]
[^OutputStream output ^bytes data]
(l/trc :hint "writting data" :data data :length (alength data))
(.write output data)
(.flush output))
(defn- create-writer-loop
[^OutputStream output]
(try
(loop []
(when-let [event (sp/take! *channel*)]
(let [result (ex/try! (write! output event))]
(if (ex/exception? result)
(l/wrn :hint "unexpected exception on sse writer" :cause result)
(recur)))))
(finally
(pu/close! output))))
(defn- encode
[[name data]]
(try
@@ -46,6 +61,13 @@
"Cache-Control" "no-cache, no-store, max-age=0, must-revalidate"
"Pragma" "no-cache"})
(defn tap
([data] (tap "event" data))
([name data]
(when-let [channel *channel*]
(sp/put! channel [name data])
nil)))
(defn response
[handler & {:keys [buf] :or {buf 32} :as opts}]
(fn [request]
@@ -53,17 +75,12 @@
::rres/status 200
::rres/body (reify rres/StreamableResponseBody
(-write-body-to-stream [_ _ output]
(binding [events/*channel* (sp/chan :buf buf :xf (keep encode))]
(let [listener (events/start-listener
(partial write! output)
(partial pu/close! output))]
(binding [*channel* (sp/chan :buf buf :xf (keep encode))]
(let [writer (px/run! :virtual (partial create-writer-loop output))]
(try
(let [result (handler)]
(events/tap :end result))
(tap "end" (handler))
(catch Throwable cause
(l/err :hint "unexpected error on processing sse response"
:cause cause)
(events/tap :error (errors/handle' cause request)))
(tap "error" (errors/handle' cause request)))
(finally
(sp/close! events/*channel*)
(px/await! listener)))))))}))
(sp/close! *channel*)
(p/await! writer)))))))}))

View File

@@ -9,30 +9,42 @@
(: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]
[app.common.transit :as t]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.client :as http.client]
[app.loggers.audit.tasks :as-alias tasks]
[app.loggers.webhooks :as-alias webhooks]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.retry :as rtry]
[app.setup :as-alias setup]
[app.util.inet :as inet]
[app.tokens :as tokens]
[app.util.services :as-alias sv]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]))
[integrant.core :as ig]
[lambdaisland.uri :as u]
[promesa.exec :as px]
[ring.request :as rreq]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn parse-client-ip
[request]
(or (some-> (rreq/get-header request "x-forwarded-for") (str/split ",") first)
(rreq/get-header request "x-real-ip")
(some-> (rreq/remote-addr request) str)))
(defn extract-utm-params
"Extracts additional data from params and namespace them under
`penpot` ns."
@@ -47,7 +59,8 @@
(assoc (->> sk str/kebab (keyword "penpot")) v))))]
(reduce-kv process-param {} params)))
(def profile-props
(def ^:private
profile-props
[:id
:is-active
:is-muted
@@ -80,20 +93,9 @@
(remove #(contains? reserved-props (key %))))
props))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context {:external-session-id (::rpc/external-session-id params)
:external-event-origin (::rpc/external-event-origin params)
:triggered-by (::rpc/handler-name params)}]
{::type "action"
::profile-id (::rpc/profile-id params)
::ip-addr (::rpc/ip-addr params)
::context (d/without-nils context)}))
;; --- SPECS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; COLLECTOR
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -131,7 +133,7 @@
[_ {:keys [::db/pool] :as cfg}]
(cond
(db/read-only? pool)
(l/warn :hint "audit disabled (db is read-only)")
(l/warn :hint "audit: disabled (db is read-only)")
:else
cfg))
@@ -145,31 +147,24 @@
(::rpc/profile-id params)
uuid/zero)
session-id (get params ::rpc/external-session-id)
event-origin (get params ::rpc/external-event-origin)
props (-> (or (::replace-props resultm)
(-> params
(merge (::props resultm))
(dissoc :profile-id)
(dissoc :type)))
props (-> (or (::replace-props resultm)
(-> params
(merge (::props resultm))
(dissoc :profile-id)
(dissoc :type)))
(clean-props))
(clean-props))
token-id (::actoken/id request)
context (-> (::context resultm)
(assoc :external-session-id session-id)
(assoc :external-event-origin event-origin)
(assoc :access-token-id (some-> token-id str))
(d/without-nils))
ip-addr (inet/parse-request request)]
context (d/without-nils
{:access-token-id (some-> token-id str)})]
{::type (or (::type resultm)
(::rpc/type cfg))
::name (or (::name resultm)
(::sv/name mdata))
::profile-id profile-id
::ip-addr ip-addr
::ip-addr (some-> request parse-client-ip)
::props props
::context context
@@ -191,58 +186,34 @@
(::webhooks/event? resultm)
false)}))
(defn- event->params
[event]
(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)
:context (::context event {})
:props (::props event {})
:source "backend"}
tnow (::tracked-at event)]
(cond-> params
(some? tnow)
(assoc :tracked-at tnow))))
(defn- append-audit-entry!
[cfg params]
(let [params (-> params
(update :props db/tjson)
(update :context db/tjson)
(update :ip-addr db/inet))]
(db/insert! cfg :audit-log params)))
(defn- handle-event!
[cfg event]
(let [params (event->params event)
tnow (dt/now)]
:context (::context event)
:props (::props event)}]
(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.
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(append-audit-entry! cfg params)))
(when (and (or (contains? cf/flags :telemetry)
(cf/get :telemetry-enabled))
(not (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.
;;
;; NOTE: this is only executed when general audit log is disabled
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow))
(assoc :props {})
(assoc :context {}))]
(append-audit-entry! cfg params)))
(rtry/with-retry {::rtry/when rtry/conflict-exception?
::rtry/max-retries 6
::rtry/label "persist-audit-log"
::db/conn (dm/check db/connection? conn-or-pool)}
(let [now (dt/now)]
(db/insert! conn-or-pool :audit-log
(-> params
(update :props db/tjson)
(update :context db/tjson)
(update :ip-addr db/inet)
(assoc :created-at now)
(assoc :tracked-at now)
(assoc :source "backend"))))))
(when (and (contains? cf/flags :webhooks)
(::webhooks/event? event))
@@ -255,43 +226,161 @@
:else label)
dedupe? (boolean (and batch-key batch-timeout))]
(wrk/submit! (-> cfg
(assoc ::wrk/task :process-webhook-event)
(assoc ::wrk/queue :webhooks)
(assoc ::wrk/max-retries 0)
(assoc ::wrk/delay (or batch-timeout 0))
(assoc ::wrk/dedupe dedupe?)
(assoc ::wrk/label label)
(assoc ::wrk/params (-> params
(dissoc :ip-addr)
(dissoc :type)))))))
(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."
[cfg event]
(try
(let [event (d/without-nils event)
cfg (-> cfg
(assoc ::rtry/when rtry/conflict-exception?)
(assoc ::rtry/max-retries 6)
(assoc ::rtry/label "persist-audit-log"))]
(us/verify! ::event event)
(rtry/invoke! cfg db/tx-run! handle-event! event))
(catch Throwable cause
(l/error :hint "unexpected error processing event" :cause cause))))
[cfg params]
(let [conn (or (::db/conn cfg) (::db/pool cfg))]
(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 insert!
"Submit audit event to the collector, intended to be used only from
command line helpers because this skips all webhooks and telemetry
logic."
[cfg event]
(when (contains? cf/flags :audit-log)
(let [event (d/without-nils event)]
(us/verify! ::event event)
(db/run! cfg (fn [cfg]
(let [tnow (dt/now)
params (-> (event->params event)
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(append-audit-entry! cfg params)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK: ARCHIVE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; This is a task responsible to send the accumulated events to
;; external service for archival.
(declare archive-events)
(s/def ::tasks/uri ::us/string)
(defmethod ig/pre-init-spec ::tasks/archive-task [_]
(s/keys :req [::db/pool ::main/props ::http.client/client]))
(defmethod ig/init-key ::tasks/archive
[_ cfg]
(fn [params]
;; 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)]
(when (and enabled (not uri))
(ex/raise :type :internal
:code :task-not-configured
:hint "archive task not configured, missing uri"))
(when enabled
(loop [total 0]
(let [n (archive-events cfg)]
(if n
(do
(px/sleep 100)
(recur (+ total ^long n)))
(when (pos? total)
(l/debug :hint "events archived" :total total)))))))))
(def ^:private sql:retrieve-batch-of-audit-log
"select *
from audit_log
where archived_at is null
order by created_at asc
limit 128
for update skip locked;")
(defn archive-events
[{:keys [::db/pool ::uri] :as cfg}]
(letfn [(decode-row [{:keys [props ip-addr context] :as row}]
(cond-> row
(db/pgobject? props)
(assoc :props (db/decode-transit-pgobject props))
(db/pgobject? context)
(assoc :context (db/decode-transit-pgobject context))
(db/pgobject? ip-addr "inet")
(assoc :ip-addr (db/decode-inet ip-addr))))
(row->event [row]
(select-keys row [:type
:name
:source
:created-at
:tracked-at
:profile-id
:ip-addr
:props
:context]))
(send [events]
(let [token (tokens/generate (::main/props cfg)
{:iss "authentication"
:iat (dt/now)
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (cf/get :public-uri)
"cookie" (u/map->query-string {:auth-token token})}
params {:uri uri
:timeout 6000
:method :post
:headers headers
:body body}
resp (http.client/req! cfg params {:sync? true})]
(if (= (:status resp) 204)
true
(do
(l/error :hint "unable to archive events"
:resp-status (:status resp)
:resp-body (:body resp))
false))))
(mark-as-archived [conn rows]
(db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)"
(->> (map :id rows)
(into-array java.util.UUID)
(db/create-array conn "uuid"))]))]
(db/with-atomic [conn pool]
(let [rows (db/exec! conn [sql:retrieve-batch-of-audit-log])
xform (comp (map decode-row)
(map row->event))
events (into [] xform rows)]
(when-not (empty? events)
(l/trace :hint "archive events chunk" :uri uri :events (count events))
(when (send events)
(mark-as-archived conn rows)
(count events)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GC Task
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:clean-archived
"delete from audit_log
where archived_at is not null")
(defn- clean-archived
[{:keys [::db/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/init-key ::tasks/gc
[_ cfg]
(fn [_]
(clean-archived cfg)))

View File

@@ -1,140 +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.audit.archive-task
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.client :as http]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.time :as dt]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[lambdaisland.uri :as u]
[promesa.exec :as px]))
;; This is a task responsible to send the accumulated events to
;; external service for archival.
(defn- decode-row
[{:keys [props ip-addr context] :as row}]
(cond-> row
(db/pgobject? props)
(assoc :props (db/decode-transit-pgobject props))
(db/pgobject? context)
(assoc :context (db/decode-transit-pgobject context))
(db/pgobject? ip-addr "inet")
(assoc :ip-addr (db/decode-inet ip-addr))))
(def ^:private event-keys
[:type
:name
:source
:created-at
:tracked-at
:profile-id
:ip-addr
:props
:context])
(defn- row->event
[row]
(select-keys row event-keys))
(defn- send!
[{:keys [::uri] :as cfg} events]
(let [token (tokens/generate (::setup/props cfg)
{:iss "authentication"
:iat (dt/now)
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (cf/get :public-uri)
"cookie" (u/map->query-string {:auth-token token})}
params {:uri uri
:timeout 12000
:method :post
:headers headers
:body body}
resp (http/req! cfg params)]
(if (= (:status resp) 204)
true
(do
(l/error :hint "unable to archive events"
:resp-status (:status resp)
:resp-body (:body resp))
false))))
(defn- mark-archived!
[{:keys [::db/conn]} rows]
(let [ids (db/create-array conn "uuid" (map :id rows))]
(db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)" ids])))
(def ^:private xf:create-event
(comp (map decode-row)
(map row->event)))
(def ^:private sql:get-audit-log-chunk
"SELECT *
FROM audit_log
WHERE archived_at is null
ORDER BY created_at ASC
LIMIT 128
FOR UPDATE
SKIP LOCKED")
(defn- get-event-rows
[{:keys [::db/conn] :as cfg}]
(->> (db/exec! conn [sql:get-audit-log-chunk])
(not-empty)))
(defn- archive-events!
[{:keys [::uri] :as cfg}]
(db/tx-run! cfg (fn [cfg]
(when-let [rows (get-event-rows cfg)]
(let [events (into [] xf:create-event rows)]
(l/trc :hint "archive events chunk" :uri uri :events (count events))
(when (send! cfg events)
(mark-archived! cfg rows)
(count events)))))))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req [::db/pool ::setup/props ::http/client]))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [params]
;; 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)]
(when (and enabled (not uri))
(ex/raise :type :internal
:code :task-not-configured
:hint "archive task not configured, missing uri"))
(when enabled
(loop [total 0]
(if-let [n (archive-events! cfg)]
(do
(px/sleep 100)
(recur (+ total ^long n)))
(when (pos? total)
(l/dbg :hint "events archived" :total total))))))))

View File

@@ -1,31 +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.audit.gc-task
(:require
[app.common.logging :as l]
[app.db :as db]
[clojure.spec.alpha :as s]
[integrant.core :as ig]))
(def ^:private sql:clean-archived
"DELETE FROM audit_log
WHERE archived_at IS NOT NULL")
(defn- clean-archived!
[{:keys [::db/pool]}]
(let [result (db/exec-one! pool [sql:clean-archived])
result (db/get-update-count result)]
(l/debug :hint "delete archived audit log entries" :deleted result)
result))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req [::db/pool]))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(clean-archived! cfg)))

View File

@@ -23,20 +23,17 @@
(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"
"- host: #" (:host report) "\n"
"- tenant: #" (:tenant report) "\n"
"- logger: #" (:logger report) "\n"
"```\n"
"- host: `" (:host report) "`\n"
"- tenant: `" (:tenant report) "`\n"
"- request-path: `" (:request-path report) "`\n"
"- frontend-version: `" (:frontend-version report) "`\n"
"- backend-version: `" (:backend-version report) "`\n"
"\n"
"```\n"
"Trace:\n"
(:trace report)
"```")
@@ -63,7 +60,6 @@
:frontend-version (:version/frontend context)
:profile-id (:request/profile-id context)
:request-path (:request/path context)
:logger (::l/logger record)
:trace (ex/format-throwable cause :detail? false :header? false)})
(defn handle-event

View File

@@ -15,9 +15,9 @@
[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.data.json :as json]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]))
@@ -64,31 +64,35 @@
(s/keys :req [::db/pool]))
(defmethod ig/init-key ::process-event-handler
[_ cfg]
[_ {:keys [::db/pool] :as cfg}]
(fn [{:keys [props] :as task}]
(let [event (:event props)]
(l/dbg :hint "process webhook event" :name (:name event))
(let [event (::event props)]
(l/debug :hint "process webhook event"
:name (:name event))
(when-let [items (lookup-webhooks cfg event)]
(l/trc :hint "webhooks found for event" :total (count items))
(l/trace :hint "webhooks found for event" :total (count items))
(db/tx-run! cfg (fn [cfg]
(doseq [item items]
(wrk/submit! (-> cfg
(assoc ::wrk/task :run-webhook)
(assoc ::wrk/queue :webhooks)
(assoc ::wrk/max-retries 3)
(assoc ::wrk/params {:event event
:config item}))))))))))
(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 json-write-opts
{:key-fn str/camel
:indent true})
(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]))
@@ -107,11 +111,9 @@
" where id=?")
err
(:id whook)]
res (db/exec-one! pool sql {::db/return-keys true})]
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 {:is-active false} {:id (:id whook)})))
(db/update! pool :webhook
{:updated-at (dt/now)
@@ -128,19 +130,19 @@
:rsp-data (db/tjson rsp)}))]
(fn [{:keys [props] :as task}]
(let [event (:event props)
whook (:config props)
(let [event (::event props)
whook (::config props)
body (case (:mtype whook)
"application/json" (json/write-str event json-write-opts)
"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/dbg :hint "run webhook"
:event-name (:name event)
:webhook-id (:id whook)
:webhook-uri (:uri whook)
:webhook-mtype (:mtype whook))
(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)
@@ -158,8 +160,8 @@
(report-delivery! whook req nil err)
(update-webhook! whook err)
(when (= err "unknown")
(l/err :hint "unknown error on webhook request"
:cause cause))))))))))
(l/error :hint "unknown error on webhook request"
:cause cause))))))))))
(defn interpret-response
[{:keys [status] :as response}]

View File

@@ -10,6 +10,7 @@
[app.auth.oidc :as-alias oidc]
[app.auth.oidc.providers :as-alias oidc.providers]
[app.common.logging :as l]
[app.common.svg :as csvg]
[app.config :as cf]
[app.db :as-alias db]
[app.email :as-alias email]
@@ -21,10 +22,10 @@
[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.migrations.v2 :as migrations.v2]
[app.msgbus :as-alias mbus]
[app.redis :as-alias rds]
[app.rpc :as-alias rpc]
@@ -33,10 +34,7 @@
[app.srepl :as-alias srepl]
[app.storage :as-alias sto]
[app.storage.fs :as-alias sto.fs]
[app.storage.gc-deleted :as-alias sto.gc-deleted]
[app.storage.gc-touched :as-alias sto.gc-touched]
[app.storage.s3 :as-alias sto.s3]
[app.svgo :as-alias svgo]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[cider.nrepl :refer [cider-nrepl-handler]]
@@ -102,13 +100,13 @@
{::mdef/name "penpot_tasks_timing"
::mdef/help "Background tasks timing (milliseconds)."
::mdef/labels ["name"]
::mdef/type :histogram}
::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 :histogram}
::mdef/type :summary}
:rpc-climit-queue
{::mdef/name "penpot_rpc_climit_queue"
@@ -126,7 +124,7 @@
{::mdef/name "penpot_rpc_climit_timing"
::mdef/help "Summary of the time between queuing and executing on the CLIMIT"
::mdef/labels ["name"]
::mdef/type :histogram}
::mdef/type :summary}
:audit-http-handler-queue-size
{::mdef/name "penpot_audit_http_handler_queue_size"
@@ -144,7 +142,7 @@
{::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 :histogram}
::mdef/type :summary}
:executors-active-threads
{::mdef/name "penpot_executors_active_threads"
@@ -204,11 +202,11 @@
:app.storage.tmp/cleaner
{::wrk/executor (ig/ref ::wrk/executor)}
::sto.gc-deleted/handler
::sto/gc-deleted-task
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
::sto.gc-touched/handler
::sto/gc-touched-task
{::db/pool (ig/ref ::db/pool)}
::http.client/client
@@ -221,7 +219,7 @@
{::db/pool (ig/ref ::db/pool)}
::http.awsns/routes
{::setup/props (ig/ref ::setup/props)
{::props (ig/ref ::setup/props)
::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)}
@@ -254,7 +252,7 @@
{::http.client/client (ig/ref ::http.client/client)}
::oidc.providers/gitlab
{::http.client/client (ig/ref ::http.client/client)}
{}
::oidc.providers/generic
{::http.client/client (ig/ref ::http.client/client)}
@@ -262,21 +260,19 @@
::oidc/routes
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::setup/props (ig/ref ::setup/props)
::props (ig/ref ::setup/props)
::oidc/providers {:google (ig/ref ::oidc.providers/google)
:github (ig/ref ::oidc.providers/github)
:gitlab (ig/ref ::oidc.providers/gitlab)
:oidc (ig/ref ::oidc.providers/generic)}
::session/manager (ig/ref ::session/manager)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
::session/manager (ig/ref ::session/manager)}
:app.http/router
{::session/manager (ig/ref ::session/manager)
::db/pool (ig/ref ::db/pool)
::rpc/routes (ig/ref ::rpc/routes)
::rpc.doc/routes (ig/ref ::rpc.doc/routes)
::setup/props (ig/ref ::setup/props)
::props (ig/ref ::setup/props)
::mtx/routes (ig/ref ::mtx/routes)
::oidc/routes (ig/ref ::oidc/routes)
::http.debug/routes (ig/ref ::http.debug/routes)
@@ -288,7 +284,7 @@
{::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::sto/storage (ig/ref ::sto/storage)
::setup/props (ig/ref ::setup/props)}
::props (ig/ref ::setup/props)}
::http.ws/routes
{::db/pool (ig/ref ::db/pool)
@@ -303,8 +299,7 @@
::sto/storage (ig/ref ::sto/storage)}
:app.rpc/climit
{::mtx/metrics (ig/ref ::mtx/metrics)
::wrk/executor (ig/ref ::wrk/executor)}
{::mtx/metrics (ig/ref ::mtx/metrics)}
:app.rpc/rlimit
{::wrk/executor (ig/ref ::wrk/executor)}
@@ -319,15 +314,14 @@
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::rds/redis (ig/ref ::rds/redis)
::svgo/optimizer (ig/ref ::svgo/optimizer)
::csvg/optimizer (ig/ref ::csvg/optimizer)
::rpc/climit (ig/ref ::rpc/climit)
::rpc/rlimit (ig/ref ::rpc/rlimit)
::setup/templates (ig/ref ::setup/templates)
::setup/props (ig/ref ::setup/props)
::props (ig/ref ::setup/props)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
:pool (ig/ref ::db/pool)}
:app.rpc.doc/routes
{:methods (ig/ref :app.rpc/methods)}
@@ -336,7 +330,7 @@
{::rpc/methods (ig/ref :app.rpc/methods)
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
::props (ig/ref ::setup/props)}
::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics)
@@ -345,27 +339,19 @@
: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)
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
:telemetry (ig/ref :app.tasks.telemetry/handler)
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
:session-gc (ig/ref ::session.tasks/gc)
:audit-log-archive (ig/ref :app.loggers.audit.archive-task/handler)
:audit-log-gc (ig/ref :app.loggers.audit.gc-task/handler)
:audit-log-archive (ig/ref ::audit.tasks/archive)
:audit-log-gc (ig/ref ::audit.tasks/gc)
:delete-object
(ig/ref :app.tasks.delete-object/handler)
:process-webhook-event
(ig/ref ::webhooks/process-event-handler)
:run-webhook
(ig/ref ::webhooks/run-webhook-handler)}}
::email/blacklist
{}
::email/whitelist
{}
::email/sendmail
{::email/host (cf/get :smtp-host)
::email/port (cf/get :smtp-port)
@@ -387,9 +373,6 @@
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
:app.tasks.delete-object/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.file-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
@@ -400,7 +383,7 @@
:app.tasks.telemetry/handler
{::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)
::setup/props (ig/ref ::setup/props)}
::props (ig/ref ::setup/props)}
[::srepl/urepl ::srepl/server]
{::srepl/port (cf/get :urepl-port 6062)
@@ -414,21 +397,21 @@
::setup/props
{::db/pool (ig/ref ::db/pool)
::setup/key (cf/get :secret-key)
::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)}
::svgo/optimizer
::csvg/optimizer
{}
:app.loggers.audit.archive-task/handler
{::setup/props (ig/ref ::setup/props)
::audit.tasks/archive
{::props (ig/ref ::setup/props)
::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)}
:app.loggers.audit.gc-task/handler
::audit.tasks/gc
{::db/pool (ig/ref ::db/pool)}
::webhooks/process-event-handler
@@ -503,7 +486,7 @@
::mtx/metrics (ig/ref ::mtx/metrics)
::db/pool (ig/ref ::db/pool)}
[::default ::wrk/runner]
[::default ::wrk/worker]
{::wrk/parallelism (cf/get ::worker-default-parallelism 1)
::wrk/queue :default
::rds/redis (ig/ref ::rds/redis)
@@ -511,7 +494,7 @@
::mtx/metrics (ig/ref ::mtx/metrics)
::db/pool (ig/ref ::db/pool)}
[::webhook ::wrk/runner]
[::webhook ::wrk/worker]
{::wrk/parallelism (cf/get ::worker-webhook-parallelism 1)
::wrk/queue :webhooks
::rds/redis (ig/ref ::rds/redis)
@@ -537,15 +520,6 @@
:worker? (contains? cf/flags :backend-worker)
:version (:full cf/version)))
(defn start-custom
[config]
(ig/load-namespaces config)
(alter-var-root #'system (fn [sys]
(when sys (ig/halt! sys))
(-> config
(ig/prep)
(ig/init)))))
(defn stop
[]
(alter-var-root #'system (fn [sys]
@@ -592,11 +566,6 @@
(nrepl/start-server :bind "0.0.0.0" :port 6064 :handler cider-nrepl-handler))
(start)
(when (contains? cf/flags :v2-migration)
(px/sleep 5000)
(migrations.v2/migrate app.main/system))
(deref p))
(catch Throwable cause
(binding [*out* *err*]

View File

@@ -32,6 +32,9 @@
org.im4java.core.IMOperation
org.im4java.core.Info))
(def default-max-file-size
(* 1024 1024 30)) ; 30 MiB
(s/def ::path fs/path?)
(s/def ::filename string?)
(s/def ::size integer?)
@@ -80,14 +83,13 @@
(defn validate-media-size!
[upload]
(let [max-size (cf/get :media-max-file-size)]
(when (> (:size upload) max-size)
(ex/raise :type :restriction
:code :media-max-file-size-reached
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
(:size upload)
max-size)))
upload))
(when (> (:size upload) (cf/get :media-max-file-size default-max-file-size))
(ex/raise :type :restriction
:code :media-max-file-size-reached
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
(:size upload)
default-max-file-size)))
upload)
(defmulti process :cmd)
(defmulti process-error class)

View File

@@ -337,49 +337,7 @@
:fn (mg/resource "app/migrations/sql/0106-mod-team-table.sql")}
{:name "0107-mod-file-tagged-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0107-mod-file-tagged-object-thumbnail-table.sql")}
{:name "0107-add-deletion-protection-trigger-function"
:fn (mg/resource "app/migrations/sql/0107-add-deletion-protection-trigger-function.sql")}
{:name "0108-mod-file-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0108-mod-file-thumbnail-table.sql")}
{:name "0109-mod-file-tagged-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0109-mod-file-tagged-object-thumbnail-table.sql")}
{:name "0110-mod-file-media-object-table"
:fn (mg/resource "app/migrations/sql/0110-mod-file-media-object-table.sql")}
{:name "0111-mod-file-data-fragment-table"
:fn (mg/resource "app/migrations/sql/0111-mod-file-data-fragment-table.sql")}
{:name "0112-mod-profile-table"
:fn (mg/resource "app/migrations/sql/0112-mod-profile-table.sql")}
{:name "0113-mod-team-font-variant-table"
:fn (mg/resource "app/migrations/sql/0113-mod-team-font-variant-table.sql")}
{:name "0114-mod-team-table"
:fn (mg/resource "app/migrations/sql/0114-mod-team-table.sql")}
{:name "0115-mod-project-table"
:fn (mg/resource "app/migrations/sql/0115-mod-project-table.sql")}
{:name "0116-mod-file-table"
:fn (mg/resource "app/migrations/sql/0116-mod-file-table.sql")}
{:name "0117-mod-file-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0117-mod-file-object-thumbnail-table.sql")}
{:name "0118-mod-task-table"
:fn (mg/resource "app/migrations/sql/0118-mod-task-table.sql")}
{:name "0119-mod-file-table"
:fn (mg/resource "app/migrations/sql/0119-mod-file-table.sql")}
{:name "0120-mod-audit-log-table"
:fn (mg/resource "app/migrations/sql/0120-mod-audit-log-table.sql")}])
:fn (mg/resource "app/migrations/sql/0107-mod-file-tagged-object-thumbnail-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@@ -1,8 +0,0 @@
CREATE OR REPLACE FUNCTION raise_deletion_protection()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'unable to proceed to delete row on "%"', TG_TABLE_NAME
USING HINT = 'disable deletion protection with "SET rules.deletion_protection TO off"';
RETURN NULL;
END;
$$ LANGUAGE plpgsql;

View File

@@ -1,25 +0,0 @@
--- Add missing index for deleted_at column, we include all related
--- columns because we expect the index to be small and expect use
--- index-only scans.
CREATE INDEX IF NOT EXISTS file_thumbnail__deleted_at__idx
ON file_thumbnail (deleted_at, file_id, revn, media_id)
WHERE deleted_at IS NOT NULL;
--- Add missing for media_id column, used mainly for refs checking
CREATE INDEX IF NOT EXISTS file_thumbnail__media_id__idx ON file_thumbnail (media_id);
--- Remove CASCADE from media_id and file_id foreign constraint
ALTER TABLE file_thumbnail
DROP CONSTRAINT file_thumbnail_file_id_fkey,
ADD FOREIGN KEY (file_id) REFERENCES file(id) DEFERRABLE;
ALTER TABLE file_thumbnail
DROP CONSTRAINT file_thumbnail_media_id_fkey,
ADD FOREIGN KEY (media_id) REFERENCES storage_object(id) DEFERRABLE;
--- Add deletion protection
CREATE OR REPLACE TRIGGER deletion_protection__tgr
BEFORE DELETE ON file_thumbnail FOR EACH STATEMENT
WHEN ((current_setting('rules.deletion_protection', true) IN ('on', '')) OR
(current_setting('rules.deletion_protection', true) IS NULL))
EXECUTE PROCEDURE raise_deletion_protection();

View File

@@ -1,26 +0,0 @@
ALTER TABLE file_tagged_object_thumbnail
ADD COLUMN updated_at timestamptz NULL,
ADD COLUMN deleted_at timestamptz NULL;
--- Add index for deleted_at column, we include all related columns
--- because we expect the index to be small and expect use index-only
--- scans.
CREATE INDEX IF NOT EXISTS file_tagged_object_thumbnail__deleted_at__idx
ON file_tagged_object_thumbnail (deleted_at, file_id, object_id, media_id)
WHERE deleted_at IS NOT NULL;
--- Remove CASCADE from media_id and file_id foreign constraint
ALTER TABLE file_tagged_object_thumbnail
DROP CONSTRAINT file_tagged_object_thumbnail_media_id_fkey,
ADD FOREIGN KEY (media_id) REFERENCES storage_object(id) DEFERRABLE;
ALTER TABLE file_tagged_object_thumbnail
DROP CONSTRAINT file_tagged_object_thumbnail_file_id_fkey,
ADD FOREIGN KEY (file_id) REFERENCES file(id) DEFERRABLE;
--- Add deletion protection
CREATE OR REPLACE TRIGGER deletion_protection__tgr
BEFORE DELETE ON file_tagged_object_thumbnail FOR EACH STATEMENT
WHEN ((current_setting('rules.deletion_protection', true) IN ('on', '')) OR
(current_setting('rules.deletion_protection', true) IS NULL))
EXECUTE PROCEDURE raise_deletion_protection();

View File

@@ -1,27 +0,0 @@
--- Fix legacy naming
ALTER INDEX media_object_pkey RENAME TO file_media_object_pkey;
ALTER INDEX media_object__file_id__idx RENAME TO file_media_object__file_id__idx;
--- Create index for the deleted_at column
CREATE INDEX IF NOT EXISTS file_media_object__deleted_at__idx
ON file_media_object (deleted_at, id, media_id)
WHERE deleted_at IS NOT NULL;
--- Drop now unnecesary trigger because this will be handled by the
--- application code
DROP TRIGGER file_media_object__on_delete__tgr ON file_media_object;
DROP FUNCTION on_delete_file_media_object ( ) CASCADE;
DROP TRIGGER file_media_object__on_insert__tgr ON file_media_object;
DROP FUNCTION on_media_object_insert () CASCADE;
--- Remove CASCADE from file FOREIGN KEY
ALTER TABLE file_media_object
DROP CONSTRAINT file_media_object_file_id_fkey,
ADD FOREIGN KEY (file_id) REFERENCES file(id) DEFERRABLE;
--- Add deletion protection
CREATE OR REPLACE TRIGGER deletion_protection__tgr
BEFORE DELETE ON file_media_object FOR EACH STATEMENT
WHEN ((current_setting('rules.deletion_protection', true) IN ('on', '')) OR
(current_setting('rules.deletion_protection', true) IS NULL))
EXECUTE PROCEDURE raise_deletion_protection();

View File

@@ -1,9 +0,0 @@
ALTER TABLE file_data_fragment
ADD COLUMN deleted_at timestamptz NULL;
--- Add index for deleted_at column, we include all related columns
--- because we expect the index to be small and expect use index-only
--- scans.
CREATE INDEX IF NOT EXISTS file_data_fragment__deleted_at__idx
ON file_data_fragment (deleted_at, file_id, id)
WHERE deleted_at IS NOT NULL;

View File

@@ -1,15 +0,0 @@
ALTER TABLE profile
DROP CONSTRAINT profile_photo_id_fkey,
ADD FOREIGN KEY (photo_id) REFERENCES storage_object(id) DEFERRABLE,
DROP CONSTRAINT profile_default_project_id_fkey,
ADD FOREIGN KEY (default_project_id) REFERENCES project(id) DEFERRABLE,
DROP CONSTRAINT profile_default_team_id_fkey,
ADD FOREIGN KEY (default_team_id) REFERENCES team(id) DEFERRABLE;
--- Add deletion protection
CREATE OR REPLACE TRIGGER deletion_protection__tgr
BEFORE DELETE ON profile FOR EACH STATEMENT
WHEN ((current_setting('rules.deletion_protection', true) IN ('on', '')) OR
(current_setting('rules.deletion_protection', true) IS NULL))
EXECUTE PROCEDURE raise_deletion_protection();

View File

@@ -1,20 +0,0 @@
--- Remove ON DELETE SET NULL from foreign constraint on
--- storage_object table
ALTER TABLE team_font_variant
DROP CONSTRAINT team_font_variant_otf_file_id_fkey,
ADD FOREIGN KEY (otf_file_id) REFERENCES storage_object(id) DEFERRABLE,
DROP CONSTRAINT team_font_variant_ttf_file_id_fkey,
ADD FOREIGN KEY (ttf_file_id) REFERENCES storage_object(id) DEFERRABLE,
DROP CONSTRAINT team_font_variant_woff1_file_id_fkey,
ADD FOREIGN KEY (woff1_file_id) REFERENCES storage_object(id) DEFERRABLE,
DROP CONSTRAINT team_font_variant_woff2_file_id_fkey,
ADD FOREIGN KEY (woff2_file_id) REFERENCES storage_object(id) DEFERRABLE,
DROP CONSTRAINT team_font_variant_team_id_fkey,
ADD FOREIGN KEY (team_id) REFERENCES team(id) DEFERRABLE;
--- Add deletion protection
CREATE OR REPLACE TRIGGER deletion_protection__tgr
BEFORE DELETE ON team_font_variant FOR EACH STATEMENT
WHEN ((current_setting('rules.deletion_protection', true) IN ('on', '')) OR
(current_setting('rules.deletion_protection', true) IS NULL))
EXECUTE PROCEDURE raise_deletion_protection();

View File

@@ -1,10 +0,0 @@
--- Add deletion protection
CREATE OR REPLACE TRIGGER deletion_protection__tgr
BEFORE DELETE ON team FOR EACH STATEMENT
WHEN ((current_setting('rules.deletion_protection', true) IN ('on', '')) OR
(current_setting('rules.deletion_protection', true) IS NULL))
EXECUTE PROCEDURE raise_deletion_protection();
ALTER TABLE team
DROP CONSTRAINT team_photo_id_fkey,
ADD FOREIGN KEY (photo_id) REFERENCES storage_object(id) DEFERRABLE;

View File

@@ -1,3 +0,0 @@
ALTER TABLE project
DROP CONSTRAINT project_team_id_fkey,
ADD FOREIGN KEY (team_id) REFERENCES team(id) DEFERRABLE;

View File

@@ -1,3 +0,0 @@
ALTER TABLE file
DROP CONSTRAINT file_project_id_fkey,
ADD FOREIGN KEY (project_id) REFERENCES project(id) DEFERRABLE;

View File

@@ -1,12 +0,0 @@
ALTER TABLE file_object_thumbnail
DROP CONSTRAINT file_object_thumbnail_file_id_fkey,
ADD FOREIGN KEY (file_id) REFERENCES file(id) DEFERRABLE,
DROP CONSTRAINT file_object_thumbnail_media_id_fkey,
ADD FOREIGN KEY (media_id) REFERENCES storage_object(id) DEFERRABLE;
--- Mark all related storage_object row as touched
-- UPDATE storage_object SET touched_at = now()
-- WHERE id IN (SELECT DISTINCT media_id
-- FROM file_object_thumbnail
-- WHERE media_id IS NOT NULL)
-- AND touched_at IS NULL;

View File

@@ -1,12 +0,0 @@
-- Removes the partitioning.
CREATE TABLE new_task (LIKE task INCLUDING ALL);
INSERT INTO new_task SELECT * FROM task;
ALTER TABLE task RENAME TO old_task;
ALTER TABLE new_task RENAME TO task;
DROP TABLE old_task;
ALTER INDEX new_task_label_name_queue_idx RENAME TO task__label_name_queue__idx;
ALTER INDEX new_task_scheduled_at_queue_idx RENAME TO task__scheduled_at_queue__idx;
ALTER TABLE task DROP CONSTRAINT new_task_pkey;
ALTER TABLE task ADD PRIMARY KEY (id);
ALTER TABLE task ALTER COLUMN created_at SET DEFAULT now();
ALTER TABLE task ALTER COLUMN modified_at SET DEFAULT now();

View File

@@ -1,2 +0,0 @@
ALTER TABLE file
ADD COLUMN version integer NULL;

View File

@@ -1,11 +0,0 @@
CREATE TABLE new_audit_log (LIKE audit_log INCLUDING ALL);
INSERT INTO new_audit_log SELECT * FROM audit_log;
ALTER TABLE audit_log RENAME TO old_audit_log;
ALTER TABLE new_audit_log RENAME TO audit_log;
DROP TABLE old_audit_log;
DROP INDEX new_audit_log_id_archived_at_idx;
ALTER TABLE audit_log DROP CONSTRAINT new_audit_log_pkey;
ALTER TABLE audit_log ADD PRIMARY KEY (id);
ALTER TABLE audit_log ALTER COLUMN created_at SET DEFAULT now();
ALTER TABLE audit_log ALTER COLUMN tracked_at SET DEFAULT now();

View File

@@ -1,103 +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.migrations.v2
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.db :as db]
[app.features.components-v2 :as feat]
[app.setup :as setup]
[app.util.time :as dt]))
(def ^:private sql:get-teams
"SELECT id, features,
row_number() OVER (ORDER BY created_at DESC) AS rown
FROM team
WHERE deleted_at IS NULL
AND (not (features @> '{components/v2}') OR features IS NULL)
ORDER BY created_at DESC")
(defn- get-teams
[conn]
(->> (db/cursor conn [sql:get-teams] {:chunk-size 1})
(map feat/decode-row)))
(defn- migrate-teams
[{:keys [::db/conn] :as system}]
;; Allow long running transaction for this connection
(db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"])
;; Do not allow other migration running in the same time
(db/xact-lock! conn 0)
;; Run teams migration
(run! (fn [{:keys [id rown]}]
(try
(-> (assoc system ::db/rollback false)
(feat/migrate-team! id
:rown rown
:label "v2-migration"
:validate? false
:skip-on-graphics-error? true))
(catch Throwable _
(swap! feat/*stats* update :errors (fnil inc 0))
(l/wrn :hint "error on migrating team (skiping)"))))
(get-teams conn))
(setup/set-prop! system :v2-migrated true))
(defn migrate
[system]
(let [tpoint (dt/tpoint)
stats (atom {})
migrated? (setup/get-prop system :v2-migrated false)]
(when-not migrated?
(l/inf :hint "v2 migration started")
(try
(binding [feat/*stats* stats]
(db/tx-run! system migrate-teams))
(let [stats (deref stats)
elapsed (dt/format-duration (tpoint))]
(l/inf :hint "v2 migration finished"
:files (:processed-files stats)
:teams (:processed-teams stats)
:errors (:errors stats)
:elapsed elapsed))
(catch Throwable cause
(l/err :hint "error on aplying v2 migration" :cause cause))))))
(def ^:private required-services
[[:app.main/assets :app.storage.s3/backend]
[:app.main/assets :app.storage.fs/backend]
:app.storage/storage
:app.db/pool
:app.setup/props
:app.svgo/optimizer
:app.metrics/metrics
:app.migrations/migrations
:app.http.client/client])
(defn -main
[& _args]
(try
(let [config-var (requiring-resolve 'app.main/system-config)
start-var (requiring-resolve 'app.main/start-custom)
stop-var (requiring-resolve 'app.main/stop)
system-var (requiring-resolve 'app.main/system)
config (select-keys @config-var required-services)]
(start-var config)
(migrate @system-var)
(stop-var)
(System/exit 0))
(catch Throwable cause
(ex/print-throwable cause)
(flush)
(System/exit -1))))

View File

@@ -91,7 +91,7 @@
(s/def ::connect? ::us/boolean)
(s/def ::io-threads ::us/integer)
(s/def ::worker-threads ::us/integer)
(s/def ::cache cache/cache?)
(s/def ::cache some?)
(s/def ::redis
(s/keys :req [::resources
@@ -168,7 +168,7 @@
(defn- shutdown-resources
[{:keys [::resources ::cache ::timer]}]
(cache/invalidate! cache)
(cache/invalidate-all! cache)
(when resources
(.shutdown ^ClientResources resources))
@@ -211,8 +211,7 @@
(defn get-or-connect
[{:keys [::cache] :as state} key options]
(us/assert! ::redis state)
(let [create (fn [_] (connect* state options))
connection (cache/get cache key create)]
(let [connection (cache/get cache key (fn [_] (connect* state options)))]
(-> state
(dissoc ::cache)
(assoc ::connection connection))))

View File

@@ -27,13 +27,10 @@
[app.rpc.helpers :as rph]
[app.rpc.retry :as retry]
[app.rpc.rlimit :as rlimit]
[app.setup :as-alias setup]
[app.storage :as-alias sto]
[app.util.inet :as inet]
[app.util.services :as sv]
[app.util.time :as dt]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[promesa.core :as p]
[ring.request :as rreq]
@@ -71,58 +68,27 @@
(handle-response-transformation request mdata)
(handle-before-comple-hook mdata))))
(defn get-external-session-id
[request]
(when-let [session-id (rreq/get-header request "x-external-session-id")]
(when-not (or (> (count session-id) 256)
(= session-id "null")
(str/blank? session-id))
session-id)))
(defn- get-external-event-origin
[request]
(when-let [origin (rreq/get-header request "x-event-origin")]
(when-not (or (> (count origin) 256)
(= origin "null")
(str/blank? origin))
origin)))
(defn- rpc-handler
"Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow."
[methods {:keys [params path-params method] :as request}]
(let [handler-name (:type path-params)
etag (rreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
[methods {:keys [params path-params] :as request}]
(let [type (keyword (:type path-params))
etag (rreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
ip-addr (inet/parse-request request)
session-id (get-external-session-id request)
event-origin (get-external-event-origin request)
data (-> params
(assoc ::request-at (dt/now))
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (-> params
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (dt/now))
(assoc ::external-session-id session-id)
(assoc ::external-event-origin event-origin)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (vary-meta data assoc ::http/request request)
handler-fn (get methods (keyword handler-name) default-handler)]
(when (and (or (= method :get)
(= method :head))
(not (str/starts-with? handler-name "get-")))
(ex/raise :type :restriction
:code :method-not-allowed
:hint "method not allowed for this request"))
data (vary-meta data assoc ::http/request request)
method (get methods type default-handler)]
(binding [cond/*enabled* true]
(let [response (handler-fn data)]
(let [response (method data)]
(handle-response request response)))))
(defn- wrap-metrics
@@ -173,21 +139,24 @@
(f cfg (us/conform spec params)))
f)))
;; TODO: integrate with sm/define
(defn- wrap-params-validation
[_ f mdata]
(if-let [schema (::sm/params mdata)]
(let [validate (sm/validator schema)
(let [schema (if (sm/lazy-schema? schema)
schema
(sm/define schema))
validate (sm/validator schema)
explain (sm/explainer schema)
decode (sm/decoder schema)]
(fn [cfg params]
(let [params (decode params)]
(if (validate params)
(f cfg params)
(let [params (d/without-qualified params)]
(ex/raise :type :validation
:code :params-validation
::sm/explain (explain params)))))))
(ex/raise :type :validation
:code :params-validation
::sm/explain (explain params))))))
f))
(defn- wrap-output-validation
@@ -213,10 +182,10 @@
(defn- wrap-all
[cfg f mdata]
(as-> f $
(wrap-metrics cfg $ mdata)
(cond/wrap cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata)
(wrap-metrics cfg $ mdata)
(rlimit/wrap cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)
@@ -226,7 +195,7 @@
(defn- wrap
[cfg f mdata]
(l/trc :hint "register method" :name (::sv/name mdata))
(l/debug :hint "register method" :name (::sv/name mdata))
(let [f (wrap-all cfg f mdata)]
(partial f cfg)))
@@ -274,9 +243,10 @@
::ldap/provider
::sto/storage
::mtx/metrics
::setup/props]
::main/props]
:opt [::climit
::rlimit]))
::rlimit]
:req-un [::db/pool]))
(defmethod ig/init-key ::methods
[_ cfg]
@@ -291,7 +261,7 @@
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req [::methods
::db/pool
::setup/props
::main/props
::session/manager]))
(defmethod ig/init-key ::routes

View File

@@ -20,35 +20,40 @@
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.edn :as edn]
[clojure.set :as set]
[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 pbh])
(:import
clojure.lang.ExceptionInfo
java.util.concurrent.atomic.AtomicLong))
clojure.lang.ExceptionInfo))
(set! *warn-on-reflection* true)
(defn- id->str
([id]
(-> (str id)
(subs 1)))
([id key]
(if key
(str (-> (str id) (subs 1)) "/" key)
(id->str id))))
[id]
(-> (str id)
(subs 1)))
(defn- create-cache
[{:keys [::wrk/executor]}]
(letfn [(on-remove [key _ cause]
(defn- create-bulkhead-cache
[config]
(letfn [(load-fn [[id skey]]
(when-let [config (get config id)]
(l/trc :hint "insert into cache" :id (id->str id) :key skey)
(pbh/create :permits (or (:permits config) (:concurrency config))
:queue (or (:queue config) (:queue-size config))
:timeout (:timeout config)
:type :semaphore)))
(on-remove [key _ cause]
(let [[id skey] key]
(l/trc :hint "disposed" :id (id->str id skey) :reason (str cause))))]
(cache/create :executor executor
(l/trc :hint "evict from cache" :id (id->str id) :key skey :reason (str cause))))]
(cache/create :executor :same-thread
:on-remove on-remove
:keepalive "5m")))
:keepalive "5m"
:load-fn load-fn)))
(s/def ::config/permits ::us/integer)
(s/def ::config/queue ::us/integer)
@@ -65,7 +70,7 @@
(s/def ::path ::fs/path)
(defmethod ig/pre-init-spec ::rpc/climit [_]
(s/keys :req [::mtx/metrics ::wrk/executor ::path]))
(s/keys :req [::mtx/metrics ::path]))
(defmethod ig/init-key ::rpc/climit
[_ {:keys [::path ::mtx/metrics] :as cfg}]
@@ -73,7 +78,7 @@
(when-let [params (some->> path slurp edn/read-string)]
(l/inf :hint "initializing concurrency limit" :config (str path))
(us/verify! ::config params)
{::cache (create-cache cfg)
{::cache (create-bulkhead-cache params)
::config params
::mtx/metrics metrics})))
@@ -84,191 +89,119 @@
(s/def ::rpc/climit
(s/nilable ::instance))
(defn- create-limiter
[config [id skey]]
(l/trc :hint "created" :id (id->str id skey))
(pbh/create :permits (or (:permits config) (:concurrency config))
:queue (or (:queue config) (:queue-size config))
:timeout (:timeout config)
:type :semaphore))
(defn measure!
[metrics mlabels stats elapsed]
(let [mpermits (:max-permits stats)
permits (:permits stats)
queue (:queue stats)
queue (- queue mpermits)
queue (if (neg? queue) 0 queue)]
(mtx/run! metrics
:id :rpc-climit-queue
:val queue
:labels mlabels)
(mtx/run! metrics
:id :rpc-climit-permits
:val permits
:labels mlabels)
(when elapsed
(mtx/run! metrics
:id :rpc-climit-timing
:val (inst-ms elapsed)
:labels mlabels))))
(defn log!
[action req-id stats limit-id limit-label params elapsed]
(let [mpermits (:max-permits stats)
queue (:queue stats)
queue (- queue mpermits)
queue (if (neg? queue) 0 queue)
level (if (pos? queue) :warn :trace)]
(l/log level
:hint action
:req req-id
:id limit-id
:label limit-label
:queue queue
:elapsed (some-> elapsed dt/format-duration)
:params (-> (select-keys params [::rpc/profile-id :file-id :profile-id])
(set/rename-keys {::rpc/profile-id :profile-id})
(update-vals str)))))
(def ^:private idseq (AtomicLong. 0))
(defn- invoke
[limiter metrics limit-id limit-key limit-label handler params]
(let [tpoint (dt/tpoint)
mlabels (into-array String [(id->str limit-id)])
limit-id (id->str limit-id limit-key)
stats (pbh/get-stats limiter)
req-id (.incrementAndGet ^AtomicLong idseq)]
(try
(measure! metrics mlabels stats nil)
(log! "enqueued" req-id stats limit-id limit-label params nil)
(px/invoke! limiter (fn []
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(measure! metrics mlabels stats elapsed)
(log! "acquired" req-id stats limit-id limit-label params elapsed)
(handler params))))
(catch ExceptionInfo cause
(let [{:keys [type code]} (ex-data cause)]
(if (= :bulkhead-error type)
(let [elapsed (tpoint)]
(log! "rejected" req-id stats limit-id limit-label params elapsed)
(ex/raise :type :concurrency-limit
:code code
:hint "concurrency limit reached"
:cause cause))
(throw cause))))
(finally
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(measure! metrics mlabels stats nil)
(log! "finished" req-id stats limit-id limit-label params elapsed))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MIDDLEWARE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private noop-fn (constantly nil))
(def ^:private global-limits
[[:root/global noop-fn]
[:root/by-profile ::rpc/profile-id]])
(defn- get-limits
[cfg]
(when-let [ref (get cfg ::id)]
(cond
(keyword? ref)
[[ref]]
(and (vector? ref)
(keyword (first ref)))
[ref]
(and (vector? ref)
(vector? (first ref)))
(rseq ref)
:else
(throw (IllegalArgumentException. "unable to normalize limit")))))
(defn wrap
[{:keys [::rpc/climit ::mtx/metrics]} handler mdata]
(let [cache (::cache climit)
config (::config climit)
label (::sv/name mdata)]
(if climit
(reduce (fn [handler [limit-id key-fn]]
(if-let [config (get config limit-id)]
(let [key-fn (or key-fn noop-fn)]
(l/trc :hint "instrumenting method"
:method label
:limit (id->str limit-id)
:timeout (:timeout config)
:permits (:permits config)
:queue (:queue config)
:keyed (not= key-fn noop-fn))
(if (and (= key-fn ::rpc/profile-id)
(false? (::rpc/auth mdata true)))
;; We don't enforce by-profile limit on methods that does
;; not require authentication
handler
(fn [cfg params]
(let [limit-key (key-fn params)
cache-key [limit-id limit-key]
limiter (cache/get cache cache-key (partial create-limiter config))
handler (partial handler cfg)]
(invoke limiter metrics limit-id limit-key label handler params)))))
(do
(l/wrn :hint "no config found for specified queue" :id (id->str limit-id))
handler)))
handler
(concat global-limits (get-limits mdata)))
handler)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- build-exec-chain
[{:keys [::label ::rpc/climit ::mtx/metrics] :as cfg} f]
(let [config (get climit ::config)
cache (get climit ::cache)]
(reduce (fn [handler [limit-id limit-key :as ckey]]
(if-let [config (get config limit-id)]
(fn [cfg params]
(let [limiter (cache/get cache ckey (partial create-limiter config))
handler (partial handler cfg)]
(invoke limiter metrics limit-id limit-key label handler params)))
(do
(l/wrn :hint "config not found" :label label :id limit-id)
f)))
f
(get-limits cfg))))
(defn invoke!
[cache metrics id key f]
(if-let [limiter (cache/get cache [id key])]
(let [tpoint (dt/tpoint)
labels (into-array String [(id->str id)])
wrapped (fn []
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(l/trc :hint "acquired"
:id (id->str id)
:key key
:permits (:permits stats)
:queue (:queue stats)
:max-permits (:max-permits stats)
:max-queue (:max-queue stats)
:elapsed (dt/format-duration elapsed))
(mtx/run! metrics
:id :rpc-climit-timing
:val (inst-ms elapsed)
:labels labels)
(try
(f)
(finally
(let [elapsed (tpoint)]
(l/trc :hint "finished"
:id (id->str id)
:key key
:permits (:permits stats)
:queue (:queue stats)
:max-permits (:max-permits stats)
:max-queue (:max-queue stats)
:elapsed (dt/format-duration elapsed)))))))
measure!
(fn [stats]
(mtx/run! metrics
:id :rpc-climit-queue
:val (:queue stats)
:labels labels)
(mtx/run! metrics
:id :rpc-climit-permits
:val (:permits stats)
:labels labels))]
(try
(let [stats (pbh/get-stats limiter)]
(measure! stats)
(l/trc :hint "enqueued"
:id (id->str id)
:key key
:permits (:permits stats)
:queue (:queue stats)
:max-permits (:max-permits stats)
:max-queue (:max-queue stats))
(pbh/invoke! limiter wrapped))
(catch ExceptionInfo cause
(let [{:keys [type code]} (ex-data cause)]
(if (= :bulkhead-error type)
(ex/raise :type :concurrency-limit
:code code
:hint "concurrency limit reached")
(throw cause))))
(finally
(measure! (pbh/get-stats limiter)))))
(do
(l/wrn :hint "unable to load limiter" :id (id->str id))
(f))))
(defn configure
[{:keys [::rpc/climit]} id]
(us/assert! ::rpc/climit climit)
(assoc climit ::id id))
(defn run!
"Run a function in context of climit.
Intended to be used in virtual threads."
[{:keys [::executor] :as cfg} f params]
(let [f (if (some? executor)
(fn [cfg params] (px/await! (px/submit! executor (fn [] (f cfg params)))))
f)
f (build-exec-chain cfg f)]
(f cfg params)))
([{:keys [::id ::cache ::mtx/metrics]} f]
(if (and cache id)
(invoke! cache metrics id nil f)
(f)))
([{:keys [::id ::cache ::mtx/metrics]} f executor]
(let [f #(p/await! (px/submit! executor f))]
(if (and cache id)
(invoke! cache metrics id nil f)
(f)))))
(def noop-fn (constantly nil))
(defn wrap
[{:keys [::rpc/climit ::mtx/metrics]} f {:keys [::id ::key-fn] :or {key-fn noop-fn} :as mdata}]
(if (and (some? climit) (some? id))
(if-let [config (get-in climit [::config id])]
(let [cache (::cache climit)]
(l/dbg :hint "instrumenting method"
:limit (id->str id)
:service-name (::sv/name mdata)
:timeout (:timeout config)
:permits (:permits config)
:queue (:queue config)
:keyed? (not= key-fn noop-fn))
(fn [cfg params]
(invoke! cache metrics id (key-fn params) (partial f cfg params))))
(do
(l/wrn :hint "no config found for specified queue" :id (id->str id))
f))
f))

View File

@@ -13,7 +13,6 @@
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias doc]
[app.rpc.quotes :as quotes]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
@@ -24,7 +23,7 @@
(dissoc row :perms))
(defn create-access-token
[{:keys [::db/conn ::setup/props]} profile-id name expiration]
[{:keys [::db/conn ::main/props]} profile-id name expiration]
(let [created-at (dt/now)
token-id (uuid/next)
token (tokens/generate props {:iss "access-token"
@@ -48,7 +47,7 @@
[{:keys [::db/pool] :as system} profile-id name expiration]
(db/with-atomic [conn pool]
(let [props (:app.setup/props system)]
(create-access-token {::db/conn conn ::setup/props props}
(create-access-token {::db/conn conn ::main/props props}
profile-id
name
expiration))))

View File

@@ -14,26 +14,12 @@
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.loggers.audit :as-alias audit]
[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.inet :as inet]
[app.util.services :as sv]
[app.util.time :as dt]))
(def ^:private event-columns
[:id
:name
:source
:type
:tracked-at
:created-at
:profile-id
:ip-addr
:props
:context])
[app.util.services :as sv]))
(defn- event->row [event]
[(uuid/next)
@@ -41,56 +27,33 @@
(:source event)
(:type event)
(:timestamp event)
(:created-at event)
(:profile-id event)
(db/inet (:ip-addr event))
(db/tjson (:props event))
(db/tjson (d/without-nils (:context event)))])
(defn- adjust-timestamp
[{:keys [timestamp created-at] :as event}]
(let [margin (inst-ms (dt/diff timestamp created-at))]
(if (or (neg? margin)
(> margin 3600000))
;; If event is in future or lags more than 1 hour, we reasign
;; timestamp to the server creation date
(-> event
(assoc :timestamp created-at)
(update :context assoc :original-timestamp timestamp))
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] :as params}]
(let [request (-> params meta ::http/request)
ip-addr (inet/parse-request request)
tnow (dt/now)
ip-addr (audit/parse-client-ip request)
xform (comp
(map (fn [event]
(-> event
(assoc :created-at tnow)
(assoc :profile-id profile-id)
(assoc :ip-addr ip-addr)
(assoc :source "frontend"))))
(map #(assoc % :profile-id profile-id))
(map #(assoc % :ip-addr ip-addr))
(map #(assoc % :source "frontend"))
(filter :profile-id)
(map adjust-timestamp)
(map event->row))
events (sequence xform events)]
(when (seq events)
(db/insert-many! pool :audit-log event-columns events))))
(def valid-event-types
#{"action" "identify"})
(db/insert-multi! pool :audit-log event-columns events))))
(def schema:event
[:map {:title "Event"}
[:name
[:and {:gen/elements ["update-file", "get-profile"]}
[:string {:max 250}]
[:re #"[\d\w-]{1,50}"]]]
[:type
[:and {:gen/elements valid-event-types}
[:string {:max 250}]
[::sm/one-of {:format "string"} valid-event-types]]]
[:name [:string {:max 250}]]
[:type [:string {:max 250}]]
[:props
[:map-of :keyword :any]]
[:context {:optional true}

View File

@@ -6,6 +6,7 @@
(ns app.rpc.commands.auth
(:require
[app.auth :as auth]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
@@ -16,17 +17,14 @@
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.email.blacklist :as email.blacklist]
[app.email.whitelist :as email.whitelist]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
@@ -38,16 +36,10 @@
(def schema:token
[::sm/word-string {:max 6000}])
(defn- elapsed-verify-threshold?
[profile]
(let [elapsed (dt/diff (:modified-at profile) (dt/now))
verify-threshold (cf/get :email-verify-threshold)]
(pos? (compare elapsed verify-threshold))))
;; ---- COMMAND: login with password
(defn login-with-password
[cfg {:keys [email password] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [email password] :as params}]
(when-not (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password))
@@ -55,20 +47,18 @@
:code :login-disabled
:hint "login is disabled in this instance"))
(letfn [(check-password [cfg profile password]
(letfn [(check-password [conn profile password]
(if (= (:password profile) "!")
(ex/raise :type :validation
:code :account-without-password
:hint "the current account does not have password")
(let [result (profile/verify-password cfg password (:password profile))]
(when (:update result)
(l/trc :hint "updating profile password"
:id (str (:id profile))
:email (:email profile))
(profile/update-profile-password! cfg (assoc profile :password password)))
(l/trace :hint "updating profile password" :id (:id profile) :email (:email profile))
(profile/update-profile-password! conn (assoc profile :password password)))
(:valid result))))
(validate-profile [cfg profile]
(validate-profile [conn profile]
(when-not profile
(ex/raise :type :validation
:code :wrong-credentials))
@@ -78,7 +68,7 @@
(when (:is-blocked profile)
(ex/raise :type :restriction
:code :profile-blocked))
(when-not (check-password cfg profile password)
(when-not (check-password conn profile password)
(ex/raise :type :validation
:code :wrong-credentials))
(when-let [deleted-at (:deleted-at profile)]
@@ -86,30 +76,27 @@
(ex/raise :type :validation
:code :wrong-credentials)))
profile)
profile)]
(login [{:keys [::db/conn] :as cfg}]
(let [profile (->> (profile/clean-email email)
(profile/get-profile-by-email conn)
(validate-profile cfg)
(profile/strip-private-attrs))
(db/with-atomic [conn pool]
(let [profile (->> (profile/get-profile-by-email conn email)
(validate-profile conn)
(profile/strip-private-attrs))
invitation (when-let [token (:invitation-token params)]
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
invitation (when-let [token (:invitation-token params)]
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
;; invitation because invitations matches exactly; and user can't login with other email and
;; accept invitation with other email
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
{:invitation-token (:invitation-token params)}
(assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))]
(-> response
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))]
(db/tx-run! cfg login)))
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
;; invitation because invitations matches exactly; and user can't login with other email and
;; accept invitation with other email
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
{:invitation-token (:invitation-token params)}
(assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))]
(-> response
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))))
(def schema:login-with-password
[:map {:title "login-with-password"}
@@ -121,41 +108,30 @@
"Performs authentication using penpot password."
{::rpc/auth false
::doc/added "1.15"
::climit/id :auth/global
::sm/params schema:login-with-password}
[cfg params]
(login-with-password cfg params))
;; ---- COMMAND: Logout
(def ^:private schema:logout
[:map {:title "logoug"}
[:profile-id {:optional true} ::sm/uuid]])
(sv/defmethod ::logout
"Clears the authentication cookie and logout the current session."
{::rpc/auth false
::doc/changes [["2.1" "Now requires profile-id passed in the body"]]
::doc/added "1.0"
::sm/params schema:logout}
[cfg params]
(if (= (:profile-id params)
(::rpc/profile-id params))
(rph/with-transform {} (session/delete-fn cfg))
{}))
::doc/added "1.15"}
[cfg _]
(rph/with-transform {} (session/delete-fn cfg)))
;; ---- COMMAND: Recover Profile
(defn recover-profile
[{:keys [::db/pool] :as cfg} {:keys [token password]}]
(letfn [(validate-token [token]
(let [tdata (tokens/verify (::setup/props cfg) {:token token :iss :password-recovery})]
(let [tdata (tokens/verify (::main/props cfg) {:token token :iss :password-recovery})]
(:profile-id tdata)))
(update-password [conn profile-id]
(let [pwd (profile/derive-password cfg password)]
(db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
nil))]
(db/update! conn :profile {:password pwd} {:id profile-id})))]
(db/with-atomic [conn pool]
(->> (validate-token token)
@@ -170,15 +146,14 @@
(sv/defmethod ::recover-profile
{::rpc/auth false
::doc/added "1.15"
::sm/params schema:recover-profile
::climit/id :auth/global}
::sm/params schema:recover-profile}
[cfg params]
(recover-profile cfg params))
;; ---- COMMAND: Prepare Register
(defn- validate-register-attempt!
[cfg params]
(defn validate-register-attempt!
[{:keys [::db/pool] :as cfg} params]
(when-not (contains? cf/flags :registration)
(when-not (contains? params :invitation-token)
@@ -186,51 +161,59 @@
:code :registration-disabled)))
(when (contains? params :invitation-token)
(let [invitation (tokens/verify (::setup/props cfg)
{:token (:invitation-token params)
:iss :team-invitation})]
(let [invitation (tokens/verify (::main/props cfg) {:token (:invitation-token params) :iss :team-invitation})]
(when-not (= (:email params) (:member-email invitation))
(ex/raise :type :restriction
:code :email-does-not-match-invitation
:hint "email should match the invitation"))))
(when (and (email.blacklist/enabled? cfg)
(email.blacklist/contains? cfg (:email params)))
(ex/raise :type :restriction
(when-not (auth/email-domain-in-whitelist? (:email params))
(ex/raise :type :validation
:code :email-domain-is-not-allowed))
(when (and (email.whitelist/enabled? cfg)
(not (email.whitelist/contains? cfg (:email params))))
(ex/raise :type :restriction
:code :email-domain-is-not-allowed))
;; Don't allow proceed in preparing registration if the profile is
;; already reported as spammer.
(when (eml/has-bounce-reports? pool (:email params))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email has one or many bounces reported"))
;; Perform a basic validation of email & password
(when (= (str/lower (:email params))
(str/lower (:password params)))
(ex/raise :type :validation
:code :email-as-password
:hint "you can't use your email as password"))
:hint "you can't use your email as password")))
(when (eml/has-bounce-reports? cfg (:email params))
(ex/raise :type :restriction
:code :email-has-permanent-bounces
:email (:email params)
:hint "looks like the email has bounce reports"))
(def register-retry-threshold
(dt/duration "15m"))
(when (eml/has-complaint-reports? cfg (:email params))
(ex/raise :type :restriction
:code :email-has-complaints
:email (:email params)
:hint "looks like the email has complaint reports")))
(defn- elapsed-register-retry-threshold?
[profile]
(let [elapsed (dt/diff (:modified-at profile) (dt/now))]
(pos? (compare elapsed register-retry-threshold))))
(defn prepare-register
[{:keys [::db/pool] :as cfg} {:keys [email] :as params}]
[{:keys [::db/pool] :as cfg} params]
(validate-register-attempt! cfg params)
(let [email (profile/clean-email email)
profile (profile/get-profile-by-email pool email)
params {:email email
(let [profile (when-let [profile (profile/get-profile-by-email pool (:email params))]
(cond
(:is-blocked profile)
(ex/raise :type :restriction
:code :profile-blocked)
(and (not (:is-active profile))
(elapsed-register-retry-threshold? profile))
profile
:else
(ex/raise :type :validation
:code :email-already-exists
:hint "profile already exists")))
params {:email (:email params)
:password (:password params)
:invitation-token (:invitation-token params)
:backend "penpot"
@@ -239,7 +222,8 @@
:exp (dt/in-future {:days 7})}
params (d/without-nils params)
token (tokens/generate (::setup/props cfg) params)]
token (tokens/generate (::main/props cfg) params)]
(with-meta {:token token}
{::audit/profile-id uuid/zero})))
@@ -268,8 +252,7 @@
(merge (:props params))
(merge {:viewed-tutorial? false
:viewed-walkthrough? false
:nudge {:big 10 :small 1}
:v2-info-shown true})
:nudge {:big 10 :small 1}})
(db/tjson))
password (or (:password params) "!")
@@ -298,17 +281,14 @@
(try
(-> (db/insert! conn :profile params)
(profile/decode-row))
(catch org.postgresql.util.PSQLException cause
(let [state (.getSQLState cause)]
(catch org.postgresql.util.PSQLException e
(let [state (.getSQLState e)]
(if (not= state "23505")
(throw cause)
(do
(l/error :hint "not an error" :cause cause)
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"
:cause cause))))))))
(throw e)
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"
:cause e)))))))
(defn create-profile-rels!
[conn {:keys [id] :as profile}]
@@ -321,20 +301,20 @@
(-> (db/update! conn :profile
{:default-team-id (:id team)
:default-project-id (:default-project-id team)}
{:id id}
{::db/return-keys true})
{:id id})
(profile/decode-row))))
(defn send-email-verification!
[{:keys [::db/conn] :as cfg} profile]
(let [vtoken (tokens/generate (::setup/props cfg)
[conn props profile]
(let [vtoken (tokens/generate props
{:iss :verify-email
:exp (dt/in-future "72h")
:profile-id (:id profile)
:email (:email profile)})
;; NOTE: this token is mainly used for possible complains
;; identification on the sns webhook
ptoken (tokens/generate (::setup/props cfg)
ptoken (tokens/generate props
{:iss :profile-identity
:profile-id (:id profile)
:exp (dt/in-future {:days 30})})]
@@ -348,104 +328,74 @@
(defn register-profile
[{:keys [::db/conn] :as cfg} {:keys [token fullname] :as params}]
(let [claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register})
params (-> claims
(into params)
(assoc :fullname fullname))
(let [claims (tokens/verify (::main/props cfg) {:token token :iss :prepared-register})
params (assoc claims :fullname fullname)
is-active (or (:is-active params)
(not (contains? cf/flags :email-verification)))
profile (if-let [profile-id (:profile-id claims)]
(profile/get-profile conn profile-id)
(let [is-active (or (boolean (:is-active claims))
(not (contains? cf/flags :email-verification)))
params (-> params
(assoc :is-active is-active)
(update :password #(profile/derive-password cfg %)))]
(let [params (-> params
(assoc :is-active is-active)
(update :password #(profile/derive-password cfg %)))]
(->> (create-profile! conn params)
(create-profile-rels! conn))))
;; When no profile-id comes on claims means a new register
created? (not (:profile-id claims))
invitation (when-let [token (:invitation-token params)]
(tokens/verify (::setup/props cfg) {:token token :iss :team-invitation}))
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))]
props (audit/profile->props profile)]
;; If profile is filled in claims, means it tries to register
;; again, so we proceed to update the modified-at attr
;; accordingly.
(when-let [id (:profile-id claims)]
(db/update! conn :profile {:modified-at (dt/now)} {:id id})
(audit/submit! cfg
{::audit/type "fact"
::audit/name "register-profile-retry"
::audit/profile-id id}))
(cond
;; When profile is blocked, we just ignore it and return plain data
(:is-blocked profile)
(do
(l/wrn :hint "register attempt for already blocked profile"
:profile-id (str (:id profile))
:profile-email (:email profile))
(rph/with-meta {:email (:email profile)}
{::audit/replace-props props
::audit/context {:action "ignore-because-blocked"}
::audit/profile-id (:id profile)
::audit/name "register-profile-retry"}))
;; If invitation token comes in params, this is because the user
;; comes from team-invitation process; in this case, regenerate
;; token and send back to the user a new invitation token (and
;; mark current session as logged). This happens only if the
;; invitation email matches with the register email.
(and (some? invitation)
(= (:email profile)
(:member-email invitation)))
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged). This happens
;; only if the invitation email matches with the register
;; email.
(and (some? invitation) (= (:email profile) (:member-email invitation)))
(let [claims (assoc invitation :member-id (:id profile))
token (tokens/generate (::setup/props cfg) claims)]
(-> {:invitation-token token}
token (tokens/generate (::main/props cfg) claims)
resp {:invitation-token token}]
(-> resp
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"}
(rph/with-meta {::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)})))
;; When a new user is created and it is already activated by
;; configuration or specified by OIDC, we just mark the profile
;; as logged-in
created?
(if (:is-active profile)
(-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta
{::audit/replace-props props
::audit/context {:action "login"}
::audit/profile-id (:id profile)}))
;; If auth backend is different from "penpot" means user is
;; registering using third party auth mechanism; in this case
;; we need to mark this session as logged.
(not= "penpot" (:auth-backend profile))
(-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)}))
(do
(when-not (eml/has-reports? conn (:email profile))
(send-email-verification! cfg profile))
(rph/with-meta {:email (:email profile)}
{::audit/replace-props props
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)})))
;; If the `:enable-insecure-register` flag is set, we proceed
;; to sign in the user directly, without email verification.
(true? is-active)
(-> (profile/strip-private-attrs profile)
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)}))
;; In all other cases, send a verification email.
:else
(let [elapsed? (elapsed-verify-threshold? profile)
complaints? (eml/has-reports? conn (:email profile))
action (if complaints?
"ignore-because-complaints"
(if elapsed?
"resend-email-verification"
"ignore"))]
(l/wrn :hint "repeated registry detected"
:profile-id (str (:id profile))
:profile-email (:email profile)
:context-action action)
(when (= action "resend-email-verification")
(db/update! conn :profile
{:modified-at (dt/now)}
{:id (:id profile)})
(send-email-verification! cfg profile))
(rph/with-meta {:email (:email profile)}
(do
(send-email-verification! conn (::main/props cfg) profile)
(rph/with-meta profile
{::audit/replace-props (audit/profile->props profile)
::audit/context {:action action}
::audit/profile-id (:id profile)
::audit/name "register-profile-retry"})))))
::audit/profile-id (:id profile)})))))
(def schema:register-profile
[:map {:title "register-profile"}
@@ -455,24 +405,25 @@
(sv/defmethod ::register-profile
{::rpc/auth false
::doc/added "1.15"
::sm/params schema:register-profile
::climit/id :auth/global}
[cfg params]
(db/tx-run! cfg register-profile params))
::sm/params schema:register-profile}
[{:keys [::db/pool] :as cfg} params]
(db/with-atomic [conn pool]
(-> (assoc cfg ::db/conn conn)
(register-profile params))))
;; ---- COMMAND: Request Profile Recovery
(defn- request-profile-recovery
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
(defn request-profile-recovery
[{:keys [::db/pool] :as cfg} {:keys [email] :as params}]
(letfn [(create-recovery-token [{:keys [id] :as profile}]
(let [token (tokens/generate (::setup/props cfg)
(let [token (tokens/generate (::main/props cfg)
{:iss :password-recovery
:exp (dt/in-future "15m")
:profile-id id})]
(assoc profile :token token)))
(send-email-notification [conn profile]
(let [ptoken (tokens/generate (::setup/props cfg)
(let [ptoken (tokens/generate (::main/props cfg)
{:iss :profile-identity
:profile-id (:id profile)
:exp (dt/in-future {:days 30})})]
@@ -485,42 +436,26 @@
:extra-data ptoken})
nil))]
(let [profile (->> (profile/clean-email email)
(profile/get-profile-by-email conn))]
(db/with-atomic [conn pool]
(when-let [profile (profile/get-profile-by-email conn email)]
(when-not (eml/allow-send-emails? conn profile)
(ex/raise :type :validation
:code :profile-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
(cond
(not profile)
(l/wrn :hint "attempt of profile recovery: no profile found"
:profile-email email)
(when-not (:is-active profile)
(ex/raise :type :validation
:code :profile-not-verified
:hint "the user need to validate profile before recover password"))
(not (eml/allow-send-emails? conn profile))
(l/wrn :hint "attempt of profile recovery: profile is muted"
:profile-id (str (:id profile))
:profile-email (:email profile))
(when (eml/has-bounce-reports? conn (:email profile))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
(eml/has-bounce-reports? conn (:email profile))
(l/wrn :hint "attempt of profile recovery: email has bounces"
:profile-id (str (:id profile))
:profile-email (:email profile))
(eml/has-complaint-reports? conn (:email profile))
(l/wrn :hint "attempt of profile recovery: email has complaints"
:profile-id (str (:id profile))
:profile-email (:email profile))
(not (elapsed-verify-threshold? profile))
(l/wrn :hint "attempt of profile recovery: retry attempt threshold not elapsed"
:profile-id (str (:id profile))
:profile-email (:email profile))
:else
(do
(db/update! conn :profile
{:modified-at (dt/now)}
{:id (:id profile)})
(->> profile
(create-recovery-token)
(send-email-notification conn)))))))
(->> profile
(create-recovery-token)
(send-email-notification conn))))))
(def schema:request-profile-recovery
@@ -532,6 +467,6 @@
::doc/added "1.15"
::sm/params schema:request-profile-recovery}
[cfg params]
(db/tx-run! cfg request-profile-recovery params))
(request-profile-recovery cfg params))

View File

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,9 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.db :as db]
[app.db.sql :as sql]
[app.features.fdata :as feat.fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
@@ -24,21 +23,18 @@
[app.rpc.retry :as rtry]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.util.time :as dt]))
[app.util.time :as dt]
[clojure.spec.alpha :as s]))
;; --- GENERAL PURPOSE INTERNAL HELPERS
(defn- decode-row
(defn decode-row
[{:keys [participants position] :as row}]
(cond-> row
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
(def xf-decode-row
(map decode-row))
(def ^:privateqpage-name
sql:get-file
(def sql:get-file
"select f.id, f.modified_at, f.revn, f.features,
f.project_id, p.team_id, f.data
from file as f
@@ -48,19 +44,17 @@
(defn- get-file
"A specialized version of get-file for comments module."
[cfg file-id page-id]
(let [file (db/exec-one! cfg [sql:get-file file-id])]
(when-not file
(ex/raise :type :not-found
:code :object-not-found
:hint "file not found"))
[{:keys [::db/conn] :as cfg} file-id page-id]
(if-let [{:keys [data] :as file} (some-> (db/exec-one! conn [sql:get-file file-id])
(files/decode-row))]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(let [{:keys [data] :as file} (files/decode-row file)]
(-> file
(assoc :page-name (dm/get-in data [:pages-index page-id :name]))
(assoc :page-id page-id)
(dissoc :data))))))
(-> file
(assoc :page-name (dm/get-in data [:pages-index page-id :name]))
(assoc :page-id page-id)))
(ex/raise :type :not-found
:code :object-not-found
:hint "file not found")))
(defn- get-comment-thread
[conn thread-id & {:as opts}]
@@ -68,8 +62,8 @@
(decode-row)))
(defn- get-comment
[conn comment-id & {:as opts}]
(db/get-by-id conn :comment comment-id opts))
[conn comment-id & {:keys [for-update?]}]
(db/get-by-id conn :comment comment-id {:for-update for-update?}))
(defn- get-next-seqn
[conn file-id]
@@ -98,25 +92,23 @@
(declare ^:private get-comment-threads)
(def ^:private
schema:get-comment-threads
[:and
[:map {:title "get-comment-threads"}
[:file-id {:optional true} ::sm/uuid]
[:team-id {:optional true} ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]]
[::sm/contains-any #{:file-id :team-id}]])
(s/def ::team-id ::us/uuid)
(s/def ::file-id ::us/uuid)
(s/def ::share-id (s/nilable ::us/uuid))
(s/def ::get-comment-threads
(s/and (s/keys :req [::rpc/profile-id]
:opt-un [::file-id ::share-id ::team-id])
#(or (:file-id %) (:team-id %))))
(sv/defmethod ::get-comment-threads
{::doc/added "1.15"
::sm/params schema:get-comment-threads}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}]
(dm/with-open [conn (db/open pool)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comment-threads conn profile-id file-id)))
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comment-threads conn profile-id file-id))))
(def ^:private sql:comment-threads
(def sql:comment-threads
"select distinct on (ct.id)
ct.*,
f.name as file_name,
@@ -141,24 +133,23 @@
(defn- get-comment-threads
[conn profile-id file-id]
(->> (db/exec! conn [sql:comment-threads profile-id file-id])
(into [] xf-decode-row)))
(into [] (map decode-row))))
;; --- COMMAND: Get Unread Comment Threads
(declare ^:private get-unread-comment-threads)
(def ^:private
schema:get-unread-comment-threads
[:map {:title "get-unread-comment-threads"}
[:team-id ::sm/uuid]])
(s/def ::team-id ::us/uuid)
(s/def ::get-unread-comment-threads
(s/keys :req [::rpc/profile-id]
:req-un [::team-id]))
(sv/defmethod ::get-unread-comment-threads
{::doc/added "1.15"
::sm/params schema:get-unread-comment-threads}
[cfg {:keys [::rpc/profile-id team-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(teams/check-read-permissions! conn profile-id team-id)
(get-unread-comment-threads conn profile-id team-id))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
(dm/with-open [conn (db/open pool)]
(teams/check-read-permissions! conn profile-id team-id)
(get-unread-comment-threads conn profile-id team-id)))
(def sql:comment-threads-by-team
"select distinct on (ct.id)
@@ -190,60 +181,62 @@
(defn- get-unread-comment-threads
[conn profile-id team-id]
(->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id])
(into [] xf-decode-row)))
(into [] (map decode-row))))
;; --- COMMAND: Get Single Comment Thread
(def ^:private
schema:get-comment-thread
[:map {:title "get-comment-thread"}
[:file-id ::sm/uuid]
[:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::get-comment-thread
(s/keys :req [::rpc/profile-id]
:req-un [::file-id ::us/id]
:opt-un [::share-id]))
(sv/defmethod ::get-comment-thread
{::doc/added "1.15"
::sm/params schema:get-comment-thread}
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(let [sql (str "with threads as (" sql:comment-threads ")"
"select * from threads where id = ?")]
(-> (db/exec-one! conn [sql profile-id file-id id])
(decode-row))))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id id share-id] :as params}]
(dm/with-open [conn (db/open pool)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(let [sql (str "with threads as (" sql:comment-threads ")"
"select * from threads where id = ?")]
(-> (db/exec-one! conn [sql profile-id file-id id])
(decode-row)))))
;; --- COMMAND: Retrieve Comments
(declare ^:private get-comments)
(def ^:private
schema:get-comments
[:map {:title "get-comments"}
[:thread-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::thread-id ::us/uuid)
(s/def ::get-comments
(s/keys :req [::rpc/profile-id]
:req-un [::thread-id]
:opt-un [::share-id]))
(sv/defmethod ::get-comments
{::doc/added "1.15"
::sm/params schema:get-comments}
[cfg {:keys [::rpc/profile-id thread-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comments conn thread-id)))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id thread-id share-id] :as params}]
(dm/with-open [conn (db/open pool)]
(let [{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comments conn thread-id))))
(def sql:comments
"select c.* from comment as c
where c.thread_id = ?
order by c.created_at asc")
(defn- get-comments
[conn thread-id]
(->> (db/query conn :comment
{:thread-id thread-id}
{:order-by [[:created-at :asc]]})
(into [] xf-decode-row)))
(into [] (map decode-row))))
;; --- COMMAND: Get file comments users
;; All the profiles that had comment the file, plus the current
;; profile.
(def ^:private sql:file-comment-users
(def sql:file-comment-users
"WITH available_profiles AS (
SELECT DISTINCT owner_id AS id
FROM comment
@@ -262,22 +255,20 @@
[conn file-id profile-id]
(db/exec! conn [sql:file-comment-users file-id profile-id]))
(def ^:private
schema:get-profiles-for-file-comments
[:map {:title "get-profiles-for-file-comments"}
[:file-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::get-profiles-for-file-comments
(s/keys :req [::rpc/profile-id]
:req-un [::file-id]
:opt-un [::share-id]))
(sv/defmethod ::get-profiles-for-file-comments
"Retrieves a list of profiles with limited set of properties of all
participants on comment threads of the file."
{::doc/added "1.15"
::doc/changes ["1.15" "Imported from queries and renamed."]
::sm/params schema:get-profiles-for-file-comments}
[cfg {:keys [::rpc/profile-id file-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-file-comments-users conn file-id profile-id))))
::doc/changes ["1.15" "Imported from queries and renamed."]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id share-id]}]
(dm/with-open [conn (db/open pool)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-file-comments-users conn file-id profile-id)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MUTATION COMMANDS
@@ -287,52 +278,54 @@
;; --- COMMAND: Create Comment Thread
(def ^:private
schema:create-comment-thread
[:map {:title "create-comment-thread"}
[:file-id ::sm/uuid]
[:position ::gpt/point]
[:content :string]
[:page-id ::sm/uuid]
[:frame-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::page-id ::us/uuid)
(s/def ::position ::gpt/point)
(s/def ::content ::us/string)
(s/def ::frame-id ::us/uuid)
(s/def ::create-comment-thread
(s/keys :req [::rpc/profile-id]
:req-un [::file-id ::position ::content ::page-id ::frame-id]
:opt-un [::share-id]))
(sv/defmethod ::create-comment-thread
{::doc/added "1.15"
::webhooks/event? true
::rtry/enabled true
::rtry/when rtry/conflict-exception?
::sm/params schema:create-comment-thread}
::webhooks/event? true}
[cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(let [{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [{:keys [team-id project-id page-name]} (get-file conn file-id page-id)]
(run! (partial quotes/check-quote! conn)
(list {::quotes/id ::quotes/comment-threads-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id}
{::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id}))
(run! (partial quotes/check-quote! cfg)
(list {::quotes/id ::quotes/comment-threads-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id}
{::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id}))
(rtry/with-retry {::rtry/when rtry/conflict-exception?
::rtry/max-retries 3
::rtry/label "create-comment-thread"
::db/conn conn}
(create-comment-thread conn
{:created-at request-at
:profile-id profile-id
:file-id file-id
:page-id page-id
:page-name page-name
:position position
:content content
:frame-id frame-id}))))))
(create-comment-thread conn {:created-at request-at
:profile-id profile-id
:file-id file-id
:page-id page-id
:page-name page-name
:position position
:content content
:frame-id frame-id})))))
(defn- create-comment-thread
[conn {:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
(let [;; NOTE: we take the next seq number from a separate query because the whole
;; operation can be retried on conflict, and in this case the new seq shold be
;; retrieved from the database.
@@ -372,72 +365,68 @@
;; --- COMMAND: Update Comment Thread Status
(def ^:private
schema:update-comment-thread-status
[:map {:title "update-comment-thread-status"}
[:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::id ::us/uuid)
(s/def ::share-id (s/nilable ::us/uuid))
(s/def ::update-comment-thread-status
(s/keys :req [::rpc/profile-id]
:req-un [::id]
:opt-un [::share-id]))
(sv/defmethod ::update-comment-thread-status
{::doc/added "1.15"
::sm/params schema:update-comment-thread-status}
[cfg {:keys [::rpc/profile-id id share-id]}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(upsert-comment-thread-status! conn profile-id id)))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
(db/with-atomic [conn pool]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(upsert-comment-thread-status! conn profile-id id))))
;; --- COMMAND: Update Comment Thread
(def ^:private
schema:update-comment-thread
[:map {:title "update-comment-thread"}
[:id ::sm/uuid]
[:is-resolved :boolean]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::is-resolved ::us/boolean)
(s/def ::update-comment-thread
(s/keys :req [::rpc/profile-id]
:req-un [::id ::is-resolved]
:opt-un [::share-id]))
(sv/defmethod ::update-comment-thread
{::doc/added "1.15"
::sm/params schema:update-comment-thread}
[cfg {:keys [::rpc/profile-id id is-resolved share-id]}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:is-resolved is-resolved}
{:id id})
nil))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id] :as params}]
(db/with-atomic [conn pool]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:is-resolved is-resolved}
{:id id})
nil)))
;; --- COMMAND: Add Comment
(declare ^:private get-comment-thread)
(def ^:private
schema:create-comment
[:map {:title "create-comment"}
[:thread-id ::sm/uuid]
[:content :string]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::create-comment
(s/keys :req [::rpc/profile-id]
:req-un [::thread-id ::content]
:opt-un [::share-id]))
(sv/defmethod ::create-comment
{::doc/added "1.15"
::webhooks/event? true
::sm/params schema:create-comment}
::webhooks/event? true}
[cfg {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::db/for-update? true)
{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(files/check-comment-permissions! conn profile-id (:id file) share-id)
(quotes/check-quote! conn
{::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id})
::quotes/file-id (:id file)})
;; Update the page-name cached attribute on comment thread table.
(when (not= page-name (:page-name thread))
@@ -473,21 +462,19 @@
;; --- COMMAND: Update Comment
(def ^:private
schema:update-comment
[:map {:title "update-comment"}
[:id ::sm/uuid]
[:content :string]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::update-comment
(s/keys :req [::rpc/profile-id]
:req-un [::id ::content]
:opt-un [::share-id]))
(sv/defmethod ::update-comment
{::doc/added "1.15"
::sm/params schema:update-comment}
{::doc/added "1.15"}
[cfg {:keys [::rpc/profile-id ::rpc/request-at id share-id content]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::sql/for-update true)
{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)]
(let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::db/for-update? true)
{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::db/for-update? true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
@@ -496,7 +483,7 @@
(ex/raise :type :validation
:code :not-allowed))
(let [{:keys [page-name]} (get-file cfg file-id page-id)]
(let [{:keys [page-name] :as file} (get-file cfg file-id page-id)]
(db/update! conn :comment
{:content content
:modified-at request-at}
@@ -510,90 +497,79 @@
;; --- COMMAND: Delete Comment Thread
(def ^:private
schema:delete-comment-thread
[:map {:title "delete-comment-thread"}
[:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::delete-comment-thread
(s/keys :req [::rpc/profile-id]
:req-un [::id]
:opt-un [::share-id]))
(sv/defmethod ::delete-comment-thread
{::doc/added "1.15"
::sm/params schema:delete-comment-thread}
[cfg {:keys [::rpc/profile-id id share-id]}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id]}]
(db/with-atomic [conn pool]
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
(db/delete! conn :comment-thread {:id id})
nil))))
(db/delete! conn :comment-thread {:id id})
nil)))
;; --- COMMAND: Delete comment
(def ^:private
schema:delete-comment
[:map {:title "delete-comment"}
[:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::delete-comment
(s/keys :req [::rpc/profile-id]
:req-un [::id]
:opt-un [::share-id]))
(sv/defmethod ::delete-comment
{::doc/added "1.15"
::sm/params schema:delete-comment}
[cfg {:keys [::rpc/profile-id id share-id]}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::sql/for-update true)
{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
(db/delete! conn :comment {:id id})
nil))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
(db/with-atomic [conn pool]
(let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::db/for-update? true)
{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(when-not (= owner-id profile-id)
(ex/raise :type :validation
:code :not-allowed))
(db/delete! conn :comment {:id id}))))
;; --- COMMAND: Update comment thread position
(def ^:private
schema:update-comment-thread-position
[:map {:title "update-comment-thread-position"}
[:id ::sm/uuid]
[:position ::gpt/point]
[:frame-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::update-comment-thread-position
(s/keys :req [::rpc/profile-id]
:req-un [::id ::position ::frame-id]
:opt-un [::share-id]))
(sv/defmethod ::update-comment-thread-position
{::doc/added "1.15"
::sm/params schema:update-comment-thread-position}
[cfg {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:position (db/pgpoint position)
:frame-id frame-id}
{:id (:id thread)})
nil))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
(db/with-atomic [conn pool]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:position (db/pgpoint position)
:frame-id frame-id}
{:id (:id thread)})
nil)))
;; --- COMMAND: Update comment frame
(def ^:private
schema:update-comment-thread-frame
[:map {:title "update-comment-thread-frame"}
[:id ::sm/uuid]
[:frame-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(s/def ::update-comment-thread-frame
(s/keys :req [::rpc/profile-id]
:req-un [::id ::frame-id]
:opt-un [::share-id]))
(sv/defmethod ::update-comment-thread-frame
{::doc/added "1.15"
::sm/params schema:update-comment-thread-frame}
[cfg {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:frame-id frame-id}
{:id id})
nil))))
{::doc/added "1.15"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
(db/with-atomic [conn pool]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(db/update! conn :comment-thread
{:modified-at request-at
:frame-id frame-id}
{:id id})
nil)))

View File

@@ -20,7 +20,6 @@
[app.common.types.file :as ctf]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.features.fdata :as feat.fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
@@ -35,7 +34,7 @@
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.set :as set]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
@@ -72,14 +71,19 @@
data (assoc :data (blob/decode data)))))
(defn check-version!
[file]
(let [version (:version file)]
[{:keys [data] :as file}]
(dm/assert!
"expect data to be decoded"
(map? data))
(let [version (:version data 0)]
(when (> version fmg/version)
(ex/raise :type :restriction
:code :file-version-not-supported
:hint "file version is greated that the maximum"
:file-version version
:max-version fmg/version))
file))
;; --- FILE PERMISSIONS
@@ -221,50 +225,33 @@
(defn- migrate-file
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
pmap/*tracked* (pmap/create-tracked)]
(let [;; For avoid unnecesary overhead of creating multiple pointers and
;; handly internally with objects map in their worst case (when
;; probably all shapes and all pointers will be readed in any
;; case), we just realize/resolve them before applying the
;; migration to the file
file (-> file
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))
;; When file is migrated, we break the rule of no perform
;; mutations on get operations and update the file with all
;; migrations applied
;;
;; WARN: he following code will not work on read-only mode,
;; it is a known issue; we keep is not implemented until we
;; really need this.
file (if (contains? (:features file) "fdata/objects-map")
(feat.fdata/enable-objects-map file)
file)
file (if (contains? (:features file) "fdata/pointer-map")
(feat.fdata/enable-pointer-map file)
file)]
(db/update! conn :file
{:data (blob/encode (:data file))
:version (:version file)
:features (db/create-array conn "text" (:features file))}
{:id id})
(when (contains? (:features file) "fdata/pointer-map")
(feat.fdata/persist-pointers! cfg id))
file)))
pmap/*tracked* (pmap/create-tracked)
cfeat/*new* (atom #{})]
(let [file (fmg/migrate-file file)]
;; NOTE: when file is migrated, we break the rule of no perform
;; mutations on get operations and update the file with all
;; migrations applied
;;
;; NOTE: the following code will not work on read-only mode, it
;; is a known issue; we keep is not implemented until we really
;; need this
(if (fmg/migrated? file)
(let [file (update file :features cfeat/migrate-legacy-features)
features (set/union (deref cfeat/*new*) (:features file))]
(db/update! conn :file
{:data (blob/encode (:data file))
:features (db/create-array conn "text" features)}
{:id id})
(feat.fdata/persist-pointers! cfg id)
(assoc file :features features))
file))))
(defn get-file
[{:keys [::db/conn] :as cfg} id & {:keys [project-id
migrate?
[{:keys [::db/conn] :as cfg} id & {:keys [project-id migrate?
include-deleted?
lock-for-update?]
:or {include-deleted? false
lock-for-update? false
migrate? true}}]
lock-for-update? false}}]
(dm/assert!
"expected cfg with valid connection"
(db/connection-map? cfg))
@@ -273,18 +260,17 @@
(when (some? project-id)
{:project-id project-id}))
file (-> (db/get conn :file params
{::db/check-deleted (not include-deleted?)
::db/remove-deleted (not include-deleted?)
::sql/for-update lock-for-update?})
{::db/check-deleted? (not include-deleted?)
::db/remove-deleted? (not include-deleted?)
::db/for-update? lock-for-update?})
(decode-row))]
(if (and migrate? (fmg/need-migration? file))
(if migrate?
(migrate-file cfg file)
file)))
(defn get-minimal-file
[cfg id & {:as opts}]
(let [opts (assoc opts ::sql/columns [:id :modified-at :revn])]
(db/get cfg :file {:id id} opts)))
[{:keys [::db/pool] :as cfg} id]
(db/get pool :file {:id id} {:columns [:id :modified-at :revn]}))
(defn get-file-etag
[{:keys [::rpc/profile-id]} {:keys [modified-at revn]}]
@@ -348,7 +334,6 @@
(sv/defmethod ::get-file-fragment
"Retrieve a file fragment by its ID. Only authenticated users."
{::doc/added "1.17"
::rpc/auth false
::sm/params schema:get-file-fragment
::sm/result schema:file-fragment}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id fragment-id share-id]}]
@@ -370,9 +355,7 @@
f.is_shared,
ft.media_id
from file as f
left join file_thumbnail as ft on (ft.file_id = f.id
and ft.revn = f.revn
and ft.deleted_at is null)
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
where f.project_id = ?
and f.deleted_at is null
order by f.modified_at desc")
@@ -531,7 +514,7 @@
ft.media_id
from file as f
inner join project as p on (p.id = f.project_id)
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn and ft.deleted_at is null)
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
where f.is_shared = true
and f.deleted_at is null
and p.deleted_at is null
@@ -671,13 +654,11 @@
f.modified_at,
f.name,
f.is_shared,
ft.media_id AS thumbnail_id,
ft.media_id,
row_number() over w as row_num
from file as f
inner join project as p on (p.id = f.project_id)
left join file_thumbnail as ft on (ft.file_id = f.id
and ft.revn = f.revn
and ft.deleted_at is null)
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
where p.team_id = ?
and p.deleted_at is null
and f.deleted_at is null
@@ -690,8 +671,10 @@
[conn team-id]
(->> (db/exec! conn [sql:team-recent-files team-id])
(mapv (fn [row]
(if-let [media-id (:thumbnail-id row)]
(assoc row :thumbnail-uri (resolve-public-uri media-id))
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(dissoc row :media-id))))))
(def ^:private schema:get-team-recent-files
@@ -723,12 +706,11 @@
(cfeat/check-client-features! (:features params))
(cfeat/check-file-features! (:features file) (:features params)))
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
{:name (:name file)
:components-count (count (ctkl/components-seq (:data file)))
:graphics-count (count (get-in file [:data :media] []))
:colors-count (count (get-in file [:data :colors] []))
:typography-count (count (get-in file [:data :typographies] []))})))
{:name (:name file)
:components-count (count (ctkl/components-seq (:data file)))
:graphics-count (count (get-in file [:data :media] []))
:colors-count (count (get-in file [:data :colors] []))
:typography-count (count (get-in file [:data :typographies] []))}))
(sv/defmethod ::get-file-summary
"Retrieve a file summary by its ID. Only authenticated users."
@@ -748,8 +730,7 @@
(db/update! conn :file
{:name name
:modified-at (dt/now)}
{:id id}
{::db/return-keys true}))
{:id id}))
(sv/defmethod ::rename-file
{::doc/added "1.17"
@@ -821,7 +802,7 @@
(feat.fdata/persist-pointers! cfg file-id))))
(defn- absorb-library
(defn- absorb-library!
"Find all files using a shared library, and absorb all library assets
into the file local libraries"
[cfg {:keys [id] :as library}]
@@ -839,26 +820,7 @@
:library-id (str id)
:files (str/join "," (map str ids)))
(run! (partial absorb-library-by-file! cfg ldata) ids)
library))
(defn absorb-library!
[{:keys [::db/conn] :as cfg} id]
(let [file (-> (get-file cfg id
:lock-for-update? true
:include-deleted? true)
(check-version!))
proj (db/get* conn :project {:id (:project-id file)}
{::db/remove-deleted false})
team (-> (db/get* conn :team {:id (:team-id proj)}
{::db/remove-deleted false})
(teams/decode-row))]
(-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-file-features! (:features file)))
(absorb-library cfg file)))
(run! (partial absorb-library-by-file! cfg ldata) ids)))
(defn- set-file-shared
[{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
@@ -871,21 +833,30 @@
;; file, we need to perform more complex operation,
;; so in this case we retrieve the complete file and
;; perform all required validations.
(let [file (-> (absorb-library! cfg id)
(assoc :is-shared false))]
(let [file (-> (get-file cfg id :lock-for-update? true)
(check-version!)
(assoc :is-shared false))
team (teams/get-team conn
:profile-id profile-id
:project-id (:project-id file))]
(-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params))
(cfeat/check-file-features! (:features file)))
(absorb-library! cfg file)
(db/delete! conn :file-library-rel {:library-file-id id})
(db/update! conn :file
{:is-shared false
:modified-at (dt/now)}
{:is-shared false}
{:id id})
(select-keys file [:id :name :is-shared]))
file)
(and (false? (:is-shared file))
(true? (:is-shared params)))
(let [file (assoc file :is-shared true)]
(db/update! conn :file
{:is-shared true
:modified-at (dt/now)}
{:is-shared false}
{:id id})
file)
@@ -918,19 +889,12 @@
;; --- MUTATION COMMAND: delete-file
(defn- mark-file-deleted
(defn- mark-file-deleted!
[conn file-id]
(let [file (db/update! conn :file
{:deleted-at (dt/now)}
{:id file-id}
{::db/return-keys [:id :name :is-shared :deleted-at
:project-id :created-at :modified-at]})]
(wrk/submit! {::db/conn conn
::wrk/task :delete-object
::wrk/params {:object :file
:deleted-at (:deleted-at file)
:id file-id}})
file))
(db/update! conn :file
{:deleted-at (dt/now)}
{:id file-id}
{::db/columns [:id :name :is-shared :project-id :created-at :modified-at]}))
(def ^:private
schema:delete-file
@@ -941,7 +905,29 @@
(defn- delete-file
[{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
(check-edition-permissions! conn profile-id id)
(let [file (mark-file-deleted conn id)]
(let [file (mark-file-deleted! conn id)]
;; NOTE: when a file is a shared library, then we proceed to load
;; the whole file, proceed with feature checking and properly execute
;; the absorb-library procedure
(when (:is-shared file)
(let [file (-> (get-file cfg id
:lock-for-update? true
:include-deleted? true)
(check-version!))
team (teams/get-team conn
:profile-id profile-id
:project-id (:project-id file))]
(-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params))
(cfeat/check-file-features! (:features file)))
(absorb-library! cfg file)))
(rph/with-meta (rph/wrap)
{::audit/props {:project-id (:project-id file)
:name (:name file)
@@ -1007,8 +993,8 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(db/with-atomic [conn pool]
(check-edition-permissions! conn profile-id file-id)
(unlink-file-from-library conn params)
nil))
(unlink-file-from-library conn params)))
;; --- MUTATION COMMAND: update-sync
@@ -1017,8 +1003,7 @@
(db/update! conn :file-library-rel
{:synced-at (dt/now)}
{:file-id file-id
:library-file-id library-id}
{::db/return-keys true}))
:library-file-id library-id}))
(def ^:private schema:update-file-library-sync-status
[:map {:title "update-file-library-sync-status"}
@@ -1040,10 +1025,8 @@
(defn ignore-sync
[conn {:keys [file-id date] :as params}]
(db/update! conn :file
{:ignore-sync-until date
:modified-at (dt/now)}
{:id file-id}
{::db/return-keys true}))
{:ignore-sync-until date}
{:id file-id}))
(s/def ::ignore-file-library-sync-status
(s/keys :req [::rpc/profile-id]

View File

@@ -6,22 +6,26 @@
(ns app.rpc.commands.files-create
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.features :as cfeat]
[app.common.schema :as sm]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.features.fdata :as feat.fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.util.blob :as blob]
[app.util.objects-map :as omap]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.util.time :as dt]
@@ -37,7 +41,7 @@
(defn create-file
[{:keys [::db/conn] :as cfg}
{:keys [id name project-id is-shared revn
modified-at deleted-at create-page page-id
modified-at deleted-at create-page
ignore-sync-until features]
:or {is-shared false revn 0 create-page true}
:as params}]
@@ -46,45 +50,47 @@
"expected a valid connection"
(db/connection? conn))
(binding [pmap/*tracked* (pmap/create-tracked)
cfeat/*current* features]
(let [file (ctf/make-file {:id id
:project-id project-id
:name name
:revn revn
:is-shared is-shared
:features features
:ignore-sync-until ignore-sync-until
:modified-at modified-at
:deleted-at deleted-at
:create-page create-page
:page-id page-id})
(let [id (or id (uuid/next))
file (if (contains? features "fdata/objects-map")
(feat.fdata/enable-objects-map file)
file)
pointers (pmap/create-tracked)
pmap? (contains? features "fdata/pointer-map")
omap? (contains? features "fdata/objects-map")
file (if (contains? features "fdata/pointer-map")
(feat.fdata/enable-pointer-map file)
file)]
data (binding [pmap/*tracked* pointers
cfeat/*current* features
cfeat/*wrap-with-objects-map-fn* (if omap? omap/wrap identity)
cfeat/*wrap-with-pointer-map-fn* (if pmap? pmap/wrap identity)]
(if create-page
(ctf/make-file-data id)
(ctf/make-file-data id nil)))
(db/insert! conn :file
(-> file
(update :data blob/encode)
(update :features db/encode-pgarray conn "text"))
{::db/return-keys false})
features (->> (set/difference features cfeat/frontend-only-features)
(db/create-array conn "text"))
(when (contains? features "fdata/pointer-map")
(feat.fdata/persist-pointers! cfg (:id file)))
file (db/insert! conn :file
(d/without-nils
{:id id
:project-id project-id
:name name
:revn revn
:is-shared is-shared
:data (blob/encode data)
:features features
:ignore-sync-until ignore-sync-until
:modified-at modified-at
:deleted-at deleted-at}))]
(->> (assoc params :file-id (:id file) :role :owner)
(create-file-role! conn))
(binding [pmap/*tracked* pointers]
(feat.fdata/persist-pointers! cfg id))
(db/update! conn :project
{:modified-at (dt/now)}
{:id project-id})
(->> (assoc params :file-id id :role :owner)
(create-file-role! conn))
file)))
(db/update! conn :project
{:modified-at (dt/now)}
{:id project-id})
(files/decode-row file)))
(def ^:private schema:create-file
[:map {:title "create-file"}

View File

@@ -12,17 +12,14 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.main :as-alias main]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.storage :as sto]
[app.util.services :as sv]
[app.util.time :as dt]
[cuerdas.core :as str]))
[app.util.time :as dt]))
(defn check-authorized!
[{:keys [::db/pool]} profile-id]
@@ -60,120 +57,70 @@
::sm/params schema:get-file-snapshots}
[cfg {:keys [::rpc/profile-id] :as params}]
(check-authorized! cfg profile-id)
(db/run! cfg get-file-snapshots params))
(db/run! cfg #(get-file-snapshots % params)))
(defn restore-file-snapshot!
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id id]}]
(let [storage (media/configure-assets-storage storage conn)
file (files/get-minimal-file conn file-id {::db/for-update true})
snapshot (db/get* conn :file-change
{:file-id file-id
:id id}
{::db/for-share true})]
params {:id id :file-id file-id}
options {:columns [:id :data :revn]}
snapshot (db/get* conn :file-change params options)]
(when-not snapshot
(ex/raise :type :not-found
:code :snapshot-not-found
:hint "unable to find snapshot with the provided label"
:id id
:file-id file-id))
(when (and (some? snapshot)
(some? (:data snapshot)))
(when-not (:data snapshot)
(ex/raise :type :precondition
:code :snapshot-without-data
:hint "snapshot has no data"
:label (:label snapshot)
:file-id file-id))
(l/debug :hint "snapshot found"
:snapshot-id (:id snapshot)
:file-id file-id)
(l/dbg :hint "restoring snapshot"
:file-id (str file-id)
:label (:label snapshot)
:snapshot-id (str (:id snapshot)))
(db/update! conn :file
{:data (:data snapshot)}
{:id file-id})
(db/update! conn :file
{:data (:data snapshot)
:revn (inc (:revn file))
:features (:features snapshot)}
{:id file-id})
;; clean object thumbnails
(let [sql (str "delete from file_object_thumbnail "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
;; clean object thumbnails
(let [sql (str "update file_tagged_object_thumbnail "
" set deleted_at = now() "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/del-object! storage media-id)))
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id)))
;; clean object thumbnails
(let [sql (str "delete from file_thumbnail "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/del-object! storage media-id)))
;; clean object thumbnails
(let [sql (str "update file_thumbnail "
" set deleted_at = now() "
" where file_id=? returning media_id")
res (db/exec! conn [sql file-id])]
(doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id)))
{:id (:id snapshot)})))
{:id (:id snapshot)
:label (:label snapshot)}))
(defn- resolve-snapshot-by-label
[conn file-id label]
(->> (db/query conn :file-change
{:file-id file-id
:label label}
{::sql/order-by [[:created-at :desc]]
::sql/columns [:file-id :id :label]})
(first)))
(def ^:private
schema:restore-file-snapshot
[:and
[:map
[:file-id ::sm/uuid]
[:id {:optional true} ::sm/uuid]
[:label {:optional true} :string]]
[::sm/contains-any #{:id :label}]])
(def ^:private schema:restore-file-snapshot
[:map
[:file-id ::sm/uuid]
[:id ::sm/uuid]])
(sv/defmethod ::restore-file-snapshot
{::doc/added "1.20"
::doc/skip true
::sm/params schema:restore-file-snapshot}
[cfg {:keys [::rpc/profile-id file-id id label] :as params}]
[cfg {:keys [::rpc/profile-id] :as params}]
(check-authorized! cfg profile-id)
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(let [params (cond-> params
(and (not id) (string? label))
(merge (resolve-snapshot-by-label conn file-id label)))]
(restore-file-snapshot! cfg params)))))
(db/tx-run! cfg #(restore-file-snapshot! % params)))
(defn take-file-snapshot!
[cfg {:keys [file-id label]}]
(let [conn (db/get-connection cfg)
file (db/get conn :file {:id file-id})
id (uuid/next)]
(l/debug :hint "creating file snapshot"
:file-id (str file-id)
:label label)
(db/insert! conn :file-change
{:id id
:revn (:revn file)
:data (:data file)
:features (:features file)
:file-id (:id file)
:label label}
{::db/return-keys false})
{:id id :label label}))
(defn generate-snapshot-label
[]
(let [ts (-> (dt/now)
(dt/format-instant)
(str/replace #"[T:\.]" "-")
(str/rtrim "Z"))]
(str "snapshot-" ts)))
[{:keys [::db/conn]} {:keys [file-id label]}]
(when-let [file (db/get* conn :file {:id file-id})]
(let [id (uuid/next)
label (or label (str "Snapshot at " (dt/format-instant (dt/now) :rfc1123)))]
(l/debug :hint "persisting file snapshot" :file-id file-id :label label)
(db/insert! conn :file-change
{:id id
:revn (:revn file)
:data (:data file)
:features (:features file)
:file-id (:id file)
:label label})
{:id id})))
(def ^:private schema:take-file-snapshot
[:map [:file-id ::sm/uuid]])
@@ -184,8 +131,5 @@
::sm/params schema:take-file-snapshot}
[cfg {:keys [::rpc/profile-id] :as params}]
(check-authorized! cfg profile-id)
(db/tx-run! cfg (fn [cfg]
(let [params (update params :label (fn [label]
(or label (generate-snapshot-label))))]
(take-file-snapshot! cfg params)))))
(db/tx-run! cfg #(take-file-snapshot! % params)))

View File

@@ -8,15 +8,11 @@
(:require
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.files.changes :as cpc]
[app.common.schema :as sm]
[app.common.files.changes :as fch]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.features.components-v2 :as feat.compv2]
[app.features.fdata :as fdata]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.files-create :as files.create]
@@ -24,51 +20,48 @@
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.util.blob :as blob]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.util.time :as dt]
[clojure.set :as set]))
[clojure.set :as set]
[clojure.spec.alpha :as s]))
;; --- MUTATION COMMAND: create-temp-file
(def ^:private schema:create-temp-file
[:map {:title "create-temp-file"}
[:name :string]
[:project-id ::sm/uuid]
[:id {:optional true} ::sm/uuid]
[:is-shared :boolean]
[:features ::cfeat/features]
[:create-page :boolean]])
(s/def ::create-page ::us/boolean)
(s/def ::create-temp-file
(s/keys :req [::rpc/profile-id]
:req-un [::files/name
::files/project-id]
:opt-un [::files/id
::files/is-shared
::files/features
::create-page]))
(sv/defmethod ::create-temp-file
{::doc/added "1.17"
::doc/module :files
::sm/params schema:create-temp-file}
::doc/module :files}
[cfg {:keys [::rpc/profile-id project-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(projects/check-edition-permissions! conn profile-id project-id)
(let [team (teams/get-team conn :profile-id profile-id :project-id project-id)
(let [team (teams/get-team conn
:profile-id profile-id
:project-id project-id)
;; When we create files, we only need to respect the team
;; features, because some features can be enabled
;; globally, but the team is still not migrated properly.
input-features (:features params #{})
;; If the imported project doesn't contain v2 we need to remove it
team-features
(cond-> (cfeat/get-team-enabled-features cf/flags team)
(not (contains? input-features "components/v2"))
(disj "components/v2"))
features (-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params)))
;; We also include all no migration features declared by
;; client; that enables the ability to enable a runtime
;; feature on frontend and make it permanent on file
features (-> input-features
features (-> (:features params #{})
(set/intersection cfeat/no-migration-features)
(set/union team-features))
(set/union features))
params (-> params
(assoc :profile-id profile-id)
@@ -79,18 +72,16 @@
;; --- MUTATION COMMAND: update-temp-file
(def ^:private schema:update-temp-file
[:map {:title "update-temp-file"}
[:changes [:vector ::cpc/change]]
[:revn {:min 0} :int]
[:session-id ::sm/uuid]
[:id ::sm/uuid]])
(s/def ::update-temp-file
(s/keys :req [::rpc/profile-id]
:req-un [::files.update/changes
::files.update/revn
::files.update/session-id
::files/id]))
(sv/defmethod ::update-temp-file
{::doc/added "1.17"
::doc/module :files
::sm/params schema:update-temp-file}
::doc/module :files}
[cfg {:keys [::rpc/profile-id session-id id revn changes] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :file-change
@@ -102,74 +93,42 @@
:revn revn
:data nil
:changes (blob/encode changes)})
(rph/with-meta (rph/wrap nil)
{::audit/replace-props {:file-id id
:revn revn}}))))
nil)))
;; --- MUTATION COMMAND: persist-temp-file
(defn persist-temp-file
[{:keys [::db/conn] :as cfg} {:keys [id ::rpc/profile-id] :as params}]
(let [file (files/get-file cfg id
:migrate? false
:lock-for-update? true)]
[conn {:keys [id] :as params}]
(let [file (db/get-by-id conn :file id)
revs (db/query conn :file-change
{:file-id id}
{:order-by [[:revn :asc]]})
revn (count revs)]
(when (nil? (:deleted-at file))
(ex/raise :type :validation
:code :cant-persist-already-persisted-file))
(let [changes (->> (db/cursor conn
(sql/select :file-change {:file-id id}
{:order-by [[:revn :asc]]})
{:chunk-size 10})
(sequence (mapcat (comp blob/decode :changes))))
file (update file :data cpc/process-changes changes)
file (if (contains? (:features file) "fdata/objects-map")
(fdata/enable-objects-map file)
file)
file (if (contains? (:features file) "fdata/pointer-map")
(binding [pmap/*tracked* (pmap/create-tracked)]
(let [file (fdata/enable-pointer-map file)]
(fdata/persist-pointers! cfg id)
file))
file)]
;; Delete changes from the changes history
(db/delete! conn :file-change {:file-id id})
(let [data
(->> revs
(mapcat #(->> % :changes blob/decode))
(fch/process-changes (blob/decode (:data file))))]
(db/update! conn :file
{:deleted-at nil
:revn 1
:data (blob/encode (:data file))}
{:id id})
:revn revn
:data (blob/encode data)}
{:id id}))
nil))
(let [team (teams/get-team conn :profile-id profile-id :project-id (:project-id file))
file-features (:features file)
team-features (cfeat/get-team-enabled-features cf/flags team)]
(when (and (contains? team-features "components/v2")
(not (contains? file-features "components/v2")))
;; Migrate components v2
(feat.compv2/migrate-file! cfg
(:id file)
:max-procs 2
:validate? true
:throw-on-validate? true)))
nil)))
(def ^:private schema:persist-temp-file
[:map {:title "persist-temp-file"}
[:id ::sm/uuid]])
(s/def ::persist-temp-file
(s/keys :req [::rpc/profile-id]
:req-un [::files/id]))
(sv/defmethod ::persist-temp-file
{::doc/added "1.17"
::doc/module :files
::sm/params schema:persist-temp-file}
::doc/module :files}
[cfg {:keys [::rpc/profile-id id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(files/check-edition-permissions! conn profile-id id)
(persist-temp-file cfg params))))
(persist-temp-file conn params))))

View File

@@ -10,14 +10,12 @@
[app.common.data.macros :as dm]
[app.common.features :as cfeat]
[app.common.files.helpers :as cfh]
[app.common.files.migrations :as fmg]
[app.common.geom.shapes :as gsh]
[app.common.schema :as sm]
[app.common.thumbnails :as thc]
[app.common.types.shape-tree :as ctt]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.features.fdata :as feat.fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
@@ -28,7 +26,6 @@
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
[app.rpc.doc :as-alias doc]
[app.rpc.retry :as rtry]
[app.storage :as sto]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
@@ -48,7 +45,7 @@
(let [sql (str/concat
"select object_id, media_id, tag "
" from file_tagged_object_thumbnail"
" where file_id=? and tag=? and deleted_at is null")
" where file_id=? and tag=?")
res (db/exec! conn [sql file-id tag])]
(->> res
(d/index-by :object-id (fn [row]
@@ -60,7 +57,7 @@
(let [sql (str/concat
"select object_id, media_id, tag "
" from file_tagged_object_thumbnail"
" where file_id=? and deleted_at is null")
" where file_id=?")
res (db/exec! conn [sql file-id])]
(->> res
(d/index-by :object-id (fn [row]
@@ -71,7 +68,7 @@
(let [sql (str/concat
"select object_id, media_id, tag "
" from file_tagged_object_thumbnail"
" where file_id=? and object_id = ANY(?) and deleted_at is null")
" where file_id=? and object_id = ANY(?)")
ids (db/create-array conn "text" (seq object-ids))
res (db/exec! conn [sql file-id ids])]
@@ -87,7 +84,10 @@
::sm/params [:map {:title "get-file-object-thumbnails"}
[:file-id ::sm/uuid]
[:tag {:optional true} :string]]
::sm/result [:map-of :string :string]}
::sm/result [:map-of :string :string]
::cond/get-object #(files/get-minimal-file %1 (:file-id %2))
::cond/reuse-key? true
::cond/key-fn files/get-file-etag}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}]
(dm/with-open [conn (db/open pool)]
(files/check-read-permissions! conn profile-id file-id)
@@ -105,12 +105,24 @@
(letfn [;; function responsible on finding the frame marked to be
;; used as thumbnail; the returned frame always have
;; the :page-id set to the page that it belongs.
(get-thumbnail-frame [{:keys [data]}]
(d/seek #(or (:use-for-thumbnail %)
(:use-for-thumbnail? %)) ; NOTE: backward comp (remove on v1.21)
(for [page (-> data :pages-index vals)
frame (-> page :objects ctt/get-frames)]
(assoc frame :page-id (:id page)))))
(get-thumbnail-frame [file]
;; NOTE: this is a hack for avoid perform blocking
;; operation inside the for loop, clojure lazy-seq uses
;; synchronized blocks that does not plays well with
;; virtual threads where all rpc methods calls are
;; dispatched, so we need to perform the load operation
;; first. This operation forces all pointer maps load into
;; the memory.
;;
;; FIXME: this is no longer true with clojure>=1.12
(let [{:keys [data]} (update file :data feat.fdata/process-pointers pmap/load!)]
;; Then proceed to find the frame set for thumbnail
(d/seek #(or (:use-for-thumbnail %)
(:use-for-thumbnail? %)) ; NOTE: backward comp (remove on v1.21)
(for [page (-> data :pages-index vals)
frame (-> page :objects ctt/get-frames)]
(assoc frame :page-id (:id page))))))
;; function responsible to filter objects data structure of
;; all unneeded shapes if a concrete frame is provided. If no
@@ -154,29 +166,30 @@
objects)))]
(let [frame (get-thumbnail-frame file)
frame-id (:id frame)
page-id (or (:page-id frame)
(-> data :pages first))
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
(let [frame (get-thumbnail-frame file)
frame-id (:id frame)
page-id (or (:page-id frame)
(-> data :pages first))
page (dm/get-in data [:pages-index page-id])
page (cond-> page (pmap/pointer-map? page) deref)
frame-ids (if (some? frame) (list frame-id) (map :id (ctt/get-frames (:objects page))))
page (dm/get-in data [:pages-index page-id])
page (cond-> page (pmap/pointer-map? page) deref)
frame-ids (if (some? frame) (list frame-id) (map :id (ctt/get-frames (:objects page))))
obj-ids (map #(thc/fmt-object-id (:id file) page-id % "frame") frame-ids)
thumbs (get-object-thumbnails conn id obj-ids)]
obj-ids (map #(thc/fmt-object-id (:id file) page-id % "frame") frame-ids)
thumbs (get-object-thumbnails conn id obj-ids)]
(cond-> page
;; If we have frame, we need to specify it on the page level
;; and remove the all other unrelated objects.
(some? frame-id)
(-> (assoc :thumbnail-frame-id frame-id)
(update :objects filter-objects frame-id))
(cond-> page
;; If we have frame, we need to specify it on the page level
;; and remove the all other unrelated objects.
(some? frame-id)
(-> (assoc :thumbnail-frame-id frame-id)
(update :objects filter-objects frame-id))
;; Assoc the available thumbnails and prune not visible shapes
;; for avoid transfer unnecessary data.
:always
(update :objects assoc-thumbnails page-id thumbs)))))
;; Assoc the available thumbnails and prune not visible shapes
;; for avoid transfer unnecessary data.
:always
(update :objects assoc-thumbnails page-id thumbs))))))
(def ^:private
schema:get-file-data-for-thumbnail
@@ -208,10 +221,7 @@
:profile-id profile-id
:file-id file-id)
file (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(-> (files/get-file cfg file-id :migrate? false)
(update :data feat.fdata/process-pointers deref)
(fmg/migrate-file)))]
file (files/get-file cfg file-id)]
(-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params))
@@ -225,55 +235,34 @@
;; MUTATION COMMANDS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def sql:get-file-object-thumbnail
"SELECT * FROM file_tagged_object_thumbnail
WHERE file_id = ? AND object_id = ? AND tag = ?
FOR UPDATE")
;; --- MUTATION COMMAND: create-file-object-thumbnail
(def sql:create-file-object-thumbnail
"INSERT INTO file_tagged_object_thumbnail (file_id, object_id, tag, media_id)
VALUES (?, ?, ?, ?)
ON CONFLICT (file_id, object_id, tag)
DO UPDATE SET updated_at=?, media_id=?, deleted_at=null
RETURNING *")
(def ^:private sql:create-object-thumbnail
"insert into file_tagged_object_thumbnail(file_id, object_id, media_id, tag)
values (?, ?, ?, ?)
on conflict(file_id, tag, object_id) do
update set media_id = ?
returning *;")
(defn- create-file-object-thumbnail!
[{:keys [::db/conn ::sto/storage]} file-id object-id media tag]
(defn- persist-thumbnail!
[storage media created-at]
(let [path (:path media)
mtype (:mtype media)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))]
(sto/wrap-with-hash hash))
media (sto/put-object! storage
{::sto/content data
::sto/deduplicate? true
::sto/touched-at (dt/now)
:content-type mtype
:bucket "file-object-thumbnail"})]
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? true
::sto/touched-at created-at
:content-type mtype
:bucket "file-object-thumbnail"})))
(db/exec-one! conn [sql:create-object-thumbnail file-id object-id
(:id media) tag (:id media)])))
(defn- create-file-object-thumbnail!
[{:keys [::sto/storage] :as cfg} file-id object-id media tag]
(let [tsnow (dt/now)
media (persist-thumbnail! storage media tsnow)
[th1 th2] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(let [th1 (db/exec-one! conn [sql:get-file-object-thumbnail file-id object-id tag])
th2 (db/exec-one! conn [sql:create-file-object-thumbnail
file-id object-id tag (:id media)
tsnow (:id media)])]
[th1 th2])))]
(when (and (some? th1)
(not= (:media-id th1)
(:media-id th2)))
(sto/touch-object! storage (:media-id th1)))
th2))
(def ^:private
schema:create-file-object-thumbnail
(def schema:create-file-object-thumbnail
[:map {:title "create-file-object-thumbnail"}
[:file-id ::sm/uuid]
[:object-id :string]
@@ -283,36 +272,37 @@
(sv/defmethod ::create-file-object-thumbnail
{::doc/added "1.19"
::doc/module :files
::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
[:file-thumbnail-ops/global]]
::rtry/enabled true
::rtry/when rtry/conflict-exception?
::climit/id :file-thumbnail-ops
::climit/key-fn ::rpc/profile-id
::audit/skip true
::sm/params schema:create-file-object-thumbnail}
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
(media/validate-media-type! media)
(media/validate-media-size! media)
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id object-id media tag]}]
(db/with-atomic [conn pool]
(files/check-edition-permissions! conn profile-id file-id)
(media/validate-media-type! media)
(media/validate-media-size! media)
(db/run! cfg files/check-edition-permissions! profile-id file-id)
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(create-file-object-thumbnail! cfg file-id object-id media (or tag "frame"))))
(when-not (db/read-only? conn)
(-> cfg
(update ::sto/storage media/configure-assets-storage)
(assoc ::db/conn conn)
(create-file-object-thumbnail! file-id object-id media (or tag "frame"))))))
;; --- MUTATION COMMAND: delete-file-object-thumbnail
(defn- delete-file-object-thumbnail!
[{:keys [::db/conn ::sto/storage]} file-id object-id]
(when-let [{:keys [media-id tag]} (db/get* conn :file-tagged-object-thumbnail
{:file-id file-id
:object-id object-id}
{::sql/for-update true})]
(when-let [{:keys [media-id]} (db/get* conn :file-tagged-object-thumbnail
{:file-id file-id
:object-id object-id}
{::db/for-update? true})]
(sto/touch-object! storage media-id)
(db/update! conn :file-tagged-object-thumbnail
{:deleted-at (dt/now)}
(db/delete! conn :file-tagged-object-thumbnail
{:file-id file-id
:object-id object-id
:tag tag})))
:object-id object-id})
nil))
(s/def ::delete-file-object-thumbnail
(s/keys :req [::rpc/profile-id]
@@ -321,17 +311,29 @@
(sv/defmethod ::delete-file-object-thumbnail
{::doc/added "1.19"
::doc/module :files
::climit/id :file-thumbnail-ops
::climit/key-fn ::rpc/profile-id
::audit/skip true}
[cfg {:keys [::rpc/profile-id file-id object-id]}]
(files/check-edition-permissions! cfg profile-id file-id)
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(-> cfg
(update ::sto/storage media/configure-assets-storage conn)
(delete-file-object-thumbnail! file-id object-id))
nil)))
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id object-id]}]
(db/with-atomic [conn pool]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(-> cfg
(update ::sto/storage media/configure-assets-storage)
(assoc ::db/conn conn)
(delete-file-object-thumbnail! file-id object-id))
nil)))
;; --- MUTATION COMMAND: create-file-thumbnail
(def ^:private sql:create-file-thumbnail
"insert into file_thumbnail (file_id, revn, media_id, props)
values (?, ?, ?, ?::jsonb)
on conflict(file_id, revn) do
update set media_id=?, props=?, updated_at=now();")
(defn- create-file-thumbnail!
[{:keys [::db/conn ::sto/storage]} {:keys [file-id revn props media] :as params}]
(media/validate-media-type! media)
@@ -343,68 +345,36 @@
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))
tnow (dt/now)
media (sto/put-object! storage
{::sto/content data
::sto/deduplicate? true
::sto/touched-at tnow
::sto/deduplicate? false
:content-type mtype
:bucket "file-thumbnail"})
thumb (db/get* conn :file-thumbnail
{:file-id file-id
:revn revn}
{::db/remove-deleted false
::sql/for-update true})]
(if (some? thumb)
(do
;; We mark the old media id as touched if it does not match
(when (not= (:id media) (:media-id thumb))
(sto/touch-object! storage (:media-id thumb)))
(db/update! conn :file-thumbnail
{:media-id (:id media)
:deleted-at nil
:updated-at tnow
:props props}
{:file-id file-id
:revn revn}))
(db/insert! conn :file-thumbnail
{:file-id file-id
:revn revn
:created-at tnow
:updated-at tnow
:props props
:media-id (:id media)}))
:bucket "file-thumbnail"})]
(db/exec-one! conn [sql:create-file-thumbnail file-id revn
(:id media) props
(:id media) props])
media))
(def ^:private
schema:create-file-thumbnail
[:map {:title "create-file-thumbnail"}
[:file-id ::sm/uuid]
[:revn :int]
[:media ::media/upload]])
(sv/defmethod ::create-file-thumbnail
"Creates or updates the file thumbnail. Mainly used for paint the
grid thumbnails."
{::doc/added "1.19"
::doc/module :files
::audit/skip true
::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
[:file-thumbnail-ops/global]]
::rtry/enabled true
::rtry/when rtry/conflict-exception?
::sm/params schema:create-file-thumbnail}
::climit/id :file-thumbnail-ops
::climit/key-fn ::rpc/profile-id
::sm/params [:map {:title "create-file-thumbnail"}
[:file-id ::sm/uuid]
[:revn :int]
[:media ::media/upload]]}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)
media (create-file-thumbnail! cfg params)]
{:uri (files/resolve-public-uri (:id media))
:id (:id media)})))))
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(db/with-atomic [conn pool]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [media (-> cfg
(update ::sto/storage media/configure-assets-storage)
(assoc ::db/conn conn)
(create-file-thumbnail! params))]
{:uri (files/resolve-public-uri (:id media))}))))

View File

@@ -30,39 +30,41 @@
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.util.blob :as blob]
[app.util.objects-map :as omap]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.set :as set]
[promesa.exec :as px]))
[clojure.set :as set]))
;; --- SCHEMA
(def ^:private
schema:update-file
[:map {:title "update-file"}
[:id ::sm/uuid]
[:session-id ::sm/uuid]
[:revn {:min 0} :int]
[:features {:optional true} ::cfeat/features]
[:changes {:optional true} [:vector ::cpc/change]]
[:changes-with-metadata {:optional true}
[:vector [:map
[:changes [:vector ::cpc/change]]
[:hint-origin {:optional true} :keyword]
[:hint-events {:optional true} [:vector :string]]]]]
[:skip-validate {:optional true} :boolean]])
(sm/define
[:map {:title "update-file"}
[:id ::sm/uuid]
[:session-id ::sm/uuid]
[:revn {:min 0} :int]
[:features {:optional true} ::cfeat/features]
[:changes {:optional true} [:vector ::cpc/change]]
[:changes-with-metadata {:optional true}
[:vector [:map
[:changes [:vector ::cpc/change]]
[:hint-origin {:optional true} :keyword]
[:hint-events {:optional true} [:vector :string]]]]]
[:skip-validate {:optional true} :boolean]]))
(def ^:private
schema:update-file-result
[:vector {:title "update-file-result"}
[:map
[:changes [:vector ::cpc/change]]
[:file-id ::sm/uuid]
[:id ::sm/uuid]
[:revn {:min 0} :int]
[:session-id ::sm/uuid]]])
(sm/define
[:vector {:title "update-file-result"}
[:map
[:changes [:vector ::cpc/change]]
[:file-id ::sm/uuid]
[:id ::sm/uuid]
[:revn {:min 0} :int]
[:session-id ::sm/uuid]]]))
;; --- HELPERS
@@ -70,26 +72,14 @@
;; to all clients using it.
(def ^:private library-change-types
#{:add-color
:mod-color
:del-color
:add-media
:mod-media
:del-media
:add-component
:mod-component
:del-component
:restore-component
:add-typography
:mod-typography
:del-typography})
#{:add-color :mod-color :del-color
:add-media :mod-media :del-media
:add-component :mod-component :del-component :restore-component
:add-typography :mod-typography :del-typography})
(def ^:private file-change-types
#{:add-obj
:mod-obj
:del-obj
:reg-objects
:mov-objects})
#{:add-obj :mod-obj :del-obj
:reg-objects :mov-objects})
(defn- library-change?
[{:keys [type] :as change}]
@@ -118,11 +108,18 @@
[f]
(fn [cfg {:keys [id] :as file}]
(binding [pmap/*tracked* (pmap/create-tracked)
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
cfeat/*wrap-with-pointer-map-fn* pmap/wrap]
(let [result (f cfg file)]
(feat.fdata/persist-pointers! cfg id)
result))))
(defn- wrap-with-objects-map-context
[f]
(fn [cfg file]
(binding [cfeat/*wrap-with-objects-map-fn* omap/wrap]
(f cfg file))))
(declare get-lagged-changes)
(declare send-notifications!)
(declare update-file)
@@ -135,8 +132,8 @@
;; database.
(sv/defmethod ::update-file
{::climit/id [[:update-file/by-profile ::rpc/profile-id]
[:update-file/global]]
{::climit/id :update-file/by-profile
::climit/key-fn ::rpc/profile-id
::webhooks/event? true
::webhooks/batch-timeout (dt/duration "2m")
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
@@ -183,32 +180,41 @@
(l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))))
(defn update-file
[{:keys [::mtx/metrics] :as cfg}
{:keys [file features changes changes-with-metadata] :as params}]
(let [features (-> features
(set/difference cfeat/frontend-only-features)
(set/union (:features file)))
[{:keys [::db/conn ::mtx/metrics] :as cfg}
{:keys [id file features changes changes-with-metadata] :as params}]
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(let [update-fn (cond-> update-file*
(contains? features "fdata/pointer-map")
(wrap-with-pointer-map-context)
update-fn (cond-> update-file*
(contains? features "fdata/pointer-map")
(wrap-with-pointer-map-context))
(contains? features "fdata/objects-map")
(wrap-with-objects-map-context))
changes (if changes-with-metadata
(->> changes-with-metadata (mapcat :changes) vec)
(vec changes))]
changes (if changes-with-metadata
(->> changes-with-metadata (mapcat :changes) vec)
(vec changes))
(when (> (:revn params)
(:revn file))
(ex/raise :type :validation
:code :revn-conflict
:hint "The incoming revision number is greater that stored version."
:context {:incoming-revn (:revn params)
:stored-revn (:revn file)}))
features (-> features
(set/difference cfeat/frontend-only-features)
(set/union (:features file)))]
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
(when (> (:revn params)
(:revn file))
(ex/raise :type :validation
:code :revn-conflict
:hint "The incoming revision number is greater that stored version."
:context {:incoming-revn (:revn params)
:stored-revn (:revn file)}))
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
(when (not= features (:features file))
(let [features (db/create-array conn "text" features)]
(db/update! conn :file
{:features features}
{:id id})))
(binding [cfeat/*current* features
cfeat/*previous* (:features file)]
(let [file (assoc file :features features)
params (-> params
(assoc :file file)
@@ -226,10 +232,13 @@
(defn- update-file*
[{:keys [::db/conn ::wrk/executor] :as cfg}
{:keys [profile-id file changes session-id ::created-at skip-validate] :as params}]
(let [;; Process the file data on separated thread for avoid to do
;; the CPU intensive operation on vthread.
file (px/invoke! executor (partial update-file-data cfg file changes skip-validate))
features (db/create-array conn "text" (:features file))]
(let [;; Process the file data in the CLIMIT context; scheduling it
;; to be executed on a separated executor for avoid to do the
;; CPU intensive operation on vthread.
update-fdata-fn (partial update-file-data cfg file changes skip-validate)
file (-> (climit/configure cfg :update-file/global)
(climit/run! update-fdata-fn executor))]
(db/insert! conn :file-change
{:id (uuid/next)
@@ -241,14 +250,11 @@
:features (db/create-array conn "text" (:features file))
:data (when (take-snapshot? file)
(:data file))
:changes (blob/encode changes)}
{::db/return-keys false})
:changes (blob/encode changes)})
(db/update! conn :file
{:revn (:revn file)
:data (:data file)
:version (:version file)
:features features
:data-backend nil
:modified-at created-at
:has-media-trimmed false}
@@ -262,15 +268,17 @@
;; Send asynchronous notifications
(send-notifications! cfg params)
{:revn (:revn file)
:lagged (get-lagged-changes conn params)})))
;; Retrieve and return lagged data
(get-lagged-changes conn params))))
(defn- soft-validate-file-schema!
[file]
(try
(val/validate-file-schema! file)
(catch Throwable cause
(l/error :hint "file schema validation error" :cause cause))))
(l/error :hint "file schema validation error" :cause cause)))
file)
(defn- soft-validate-file!
[file libs]
@@ -278,77 +286,60 @@
(val/validate-file! file libs)
(catch Throwable cause
(l/error :hint "file validation error"
:cause cause))))
:cause cause)))
file)
(defn- update-file-data
[{:keys [::db/conn] :as cfg} file changes skip-validate]
(let [file (update file :data (fn [data]
(-> data
(blob/decode)
(assoc :id (:id file)))))
;; For avoid unnecesary overhead of creating multiple pointers
;; and handly internally with objects map in their worst
;; case (when probably all shapes and all pointers will be
;; readed in any case), we just realize/resolve them before
;; applying the migration to the file
file (if (fmg/need-migration? file)
(-> file
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))
(fmg/migrate-file))
file)
(assoc :id (:id file))
(fmg/migrate-data)
(d/without-nils))))
;; WARNING: this ruins performance; maybe we need to find
;; some other way to do general validation
libs (when (and (or (contains? cf/flags :file-validation)
(contains? cf/flags :soft-file-validation))
libs (when (and (contains? cf/flags :file-validation)
(not skip-validate))
(->> (files/get-file-libraries conn (:id file))
(into [file] (map (fn [{:keys [id]}]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
pmap/*tracked* nil]
;; We do not resolve the objects maps here
;; because there is a lower probability that all
;; shapes needed to be loded into memory, so we
;; leeave it on lazy status
(-> (files/get-file cfg id :migrate? false)
(update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved
(update :data feat.fdata/process-objects (partial into {}))
(feat.fdata/process-pointers deref) ; ensure all pointers resolved
(fmg/migrate-file))))))
(d/index-by :id)))
(d/index-by :id)))]
(-> (files/check-version! file)
(update :revn inc)
(update :data cpc/process-changes changes)
file (-> (files/check-version! file)
(update :revn inc)
(update :data cpc/process-changes changes)
(update :data d/without-nils))]
;; If `libs` is defined, then full validation is performed
(cond-> (contains? cf/flags :soft-file-validation)
(soft-validate-file! libs))
(cond-> (contains? cf/flags :soft-file-schema-validation)
(soft-validate-file-schema!))
(binding [pmap/*tracked* nil]
(when (contains? cf/flags :soft-file-validation)
(soft-validate-file! file libs))
(cond-> (and (contains? cf/flags :file-validation)
(not skip-validate))
(val/validate-file! libs))
(when (contains? cf/flags :soft-file-schema-validation)
(soft-validate-file-schema! file))
(cond-> (and (contains? cf/flags :file-schema-validation)
(not skip-validate))
(val/validate-file-schema!))
(when (and (contains? cf/flags :file-validation)
(not skip-validate))
(val/validate-file! file libs))
(cond-> (and (contains? cfeat/*current* "fdata/objects-map")
(not (contains? cfeat/*previous* "fdata/objects-map")))
(feat.fdata/enable-objects-map))
(when (and (contains? cf/flags :file-schema-validation)
(not skip-validate))
(val/validate-file-schema! file)))
(cond-> (and (contains? cfeat/*current* "fdata/pointer-map")
(not (contains? cfeat/*previous* "fdata/pointer-map")))
(feat.fdata/enable-pointer-map))
(cond-> file
(contains? cfeat/*current* "fdata/objects-map")
(feat.fdata/enable-objects-map)
(update :data blob/encode))))
(contains? cfeat/*current* "fdata/pointer-map")
(feat.fdata/enable-pointer-map)
:always
(update :data blob/encode))))
(defn- take-snapshot?
"Defines the rule when file `data` snapshot should be saved."

View File

@@ -8,15 +8,14 @@
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.climit :as climit]
[app.rpc.commands.files :as files]
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
@@ -27,27 +26,38 @@
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[promesa.exec :as px]))
[clojure.spec.alpha :as s]))
(def valid-weight #{100 200 300 400 500 600 700 800 900 950})
(def valid-style #{"normal" "italic"})
(s/def ::data (s/map-of ::us/string any?))
(s/def ::file-id ::us/uuid)
(s/def ::font-id ::us/uuid)
(s/def ::id ::us/uuid)
(s/def ::name ::us/not-empty-string)
(s/def ::project-id ::us/uuid)
(s/def ::share-id ::us/uuid)
(s/def ::style valid-style)
(s/def ::team-id ::us/uuid)
(s/def ::weight valid-weight)
;; --- QUERY: Get font variants
(def ^:private
schema:get-font-variants
[:schema {:title "get-font-variants"}
[:and
[:map
[:team-id {:optional true} ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:project-id {:optional true} ::sm/uuid]
[:share-id {:optional true} ::sm/uuid]]
[::sm/contains-any #{:team-id :file-id :project-id}]]])
(s/def ::get-font-variants
(s/and
(s/keys :req [::rpc/profile-id]
:opt-un [::team-id
::file-id
::project-id
::share-id])
(fn [o]
(or (contains? o :team-id)
(contains? o :file-id)
(contains? o :project-id)))))
(sv/defmethod ::get-font-variants
{::doc/added "1.18"
::sm/params schema:get-font-variants}
{::doc/added "1.18"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id file-id project-id share-id] :as params}]
(dm/with-open [conn (db/open pool)]
(cond
@@ -77,33 +87,28 @@
(declare create-font-variant)
(def ^:private schema:create-font-variant
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:data [:map-of :string :any]]
[:font-id ::sm/uuid]
[:font-family :string]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]])
(s/def ::create-font-variant
(s/keys :req [::rpc/profile-id]
:req-un [::team-id
::data
::font-id
::font-family
::font-weight
::font-style]))
(sv/defmethod ::create-font-variant
{::doc/added "1.18"
::climit/id [[:process-font/by-profile ::rpc/profile-id]
[:process-font/global]]
::webhooks/event? true
::sm/params schema:create-font-variant}
[cfg {:keys [::rpc/profile-id team-id] :as params}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(teams/check-edition-permissions! conn profile-id team-id)
(quotes/check-quote! conn {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(create-font-variant cfg (assoc params :profile-id profile-id))))))
::webhooks/event? true}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(teams/check-edition-permissions! pool profile-id team-id)
(quotes/check-quote! pool {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(create-font-variant cfg (assoc params :profile-id profile-id))))
(defn create-font-variant
[{:keys [::sto/storage ::db/conn ::wrk/executor]} {:keys [data] :as params}]
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [data] :as params}]
(letfn [(generate-missing! [data]
(let [data (media/run {:cmd :generate-fonts :input data})]
(when (and (not (contains? data "font/otf"))
@@ -131,7 +136,6 @@
ttf-params (prepare-font data "font/ttf")
wf1-params (prepare-font data "font/woff")
wf2-params (prepare-font data "font/woff2")]
(cond-> {}
(some? otf-params)
(assoc :otf (sto/put-object! storage otf-params))
@@ -143,7 +147,7 @@
(assoc :woff2 (sto/put-object! storage wf2-params)))))
(insert-font-variant! [{:keys [woff1 woff2 otf ttf]}]
(db/insert! conn :team-font-variant
(db/insert! pool :team-font-variant
{:id (uuid/next)
:team-id (:team-id params)
:font-id (:font-id params)
@@ -155,112 +159,72 @@
:otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))]
(let [data (px/invoke! executor (partial generate-missing! data))
(let [data (-> (climit/configure cfg :process-font/global)
(climit/run! (partial generate-missing! data)
(::wrk/executor cfg)))
assets (persist-fonts-files! data)
result (insert-font-variant! assets)]
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))
;; --- UPDATE FONT FAMILY
(def ^:private
schema:update-font
[:map {:title "update-font"}
[:team-id ::sm/uuid]
[:id ::sm/uuid]
[:name :string]])
(s/def ::update-font
(s/keys :req [::rpc/profile-id]
:req-un [::team-id ::id ::name]))
(sv/defmethod ::update-font
{::doc/added "1.18"
::webhooks/event? true
::sm/params schema:update-font}
[cfg {:keys [::rpc/profile-id team-id id name]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(teams/check-edition-permissions! conn profile-id team-id)
(db/update! conn :team-font-variant
{:font-family name}
{:font-id id
:team-id team-id})
(rph/with-meta (rph/wrap nil)
{::audit/replace-props {:id id
:name name
:team-id team-id
:profile-id profile-id}}))))
::webhooks/event? true}
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id id name]}]
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(rph/with-meta
(db/update! conn :team-font-variant
{:font-family name}
{:font-id id
:team-id team-id})
{::audit/replace-props {:id id
:name name
:team-id team-id
:profile-id profile-id}})))
;; --- DELETE FONT
(def ^:private
schema:delete-font
[:map {:title "delete-font"}
[:team-id ::sm/uuid]
[:id ::sm/uuid]])
(s/def ::delete-font
(s/keys :req [::rpc/profile-id]
:req-un [::team-id ::id]))
(sv/defmethod ::delete-font
{::doc/added "1.18"
::webhooks/event? true
::sm/params schema:delete-font}
[cfg {:keys [::rpc/profile-id id team-id]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn ::sto/storage] :as cfg}]
(teams/check-edition-permissions! conn profile-id team-id)
(let [fonts (db/query conn :team-font-variant
{:team-id team-id
:font-id id
:deleted-at nil}
{::sql/for-update true})
storage (media/configure-assets-storage storage conn)
tnow (dt/now)]
(when-not (seq fonts)
(ex/raise :type :not-found
:code :object-not-found))
(doseq [font fonts]
(db/update! conn :team-font-variant
{:deleted-at tnow}
{:id (:id font)})
(some->> (:woff1-file-id font) (sto/touch-object! storage))
(some->> (:woff2-file-id font) (sto/touch-object! storage))
(some->> (:ttf-file-id font) (sto/touch-object! storage))
(some->> (:otf-file-id font) (sto/touch-object! storage)))
(rph/with-meta (rph/wrap)
{::audit/props {:id id
:team-id team-id
:name (:font-family (peek fonts))
:profile-id profile-id}})))))
::webhooks/event? true}
[{:keys [::db/pool]} {:keys [::rpc/profile-id id team-id]}]
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(let [font (db/update! conn :team-font-variant
{:deleted-at (dt/now)}
{:font-id id :team-id team-id})]
(rph/with-meta (rph/wrap)
{::audit/props {:id id
:team-id team-id
:name (:font-family font)
:profile-id profile-id}}))))
;; --- DELETE FONT VARIANT
(def ^:private schema:delete-font-variant
[:map {:title "delete-font-variant"}
[:team-id ::sm/uuid]
[:id ::sm/uuid]])
(s/def ::delete-font-variant
(s/keys :req [::rpc/profile-id]
:req-un [::team-id ::id]))
(sv/defmethod ::delete-font-variant
{::doc/added "1.18"
::webhooks/event? true
::sm/params schema:delete-font-variant}
[cfg {:keys [::rpc/profile-id id team-id]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn ::sto/storage] :as cfg}]
(teams/check-edition-permissions! conn profile-id team-id)
(let [variant (db/get conn :team-font-variant
{:id id :team-id team-id}
{::sql/for-update true})
storage (media/configure-assets-storage storage conn)]
(db/update! conn :team-font-variant
::webhooks/event? true}
[{:keys [::db/pool]} {:keys [::rpc/profile-id id team-id]}]
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(let [variant (db/update! conn :team-font-variant
{:deleted-at (dt/now)}
{:id (:id variant)})
{:id id :team-id team-id})]
(rph/with-meta (rph/wrap)
{::audit/props {:font-family (:font-family variant)
:font-id (:font-id variant)}}))))
(some->> (:woff1-file-id variant) (sto/touch-object! storage))
(some->> (:woff2-file-id variant) (sto/touch-object! storage))
(some->> (:ttf-file-id variant) (sto/touch-object! storage))
(some->> (:otf-file-id variant) (sto/touch-object! storage))
(rph/with-meta (rph/wrap)
{::audit/props {:font-family (:font-family variant)
:font-id (:font-id variant)}})))))

View File

@@ -12,12 +12,12 @@
[app.db :as db]
[app.http.session :as session]
[app.loggers.audit :as-alias audit]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.services :as sv]
[clojure.spec.alpha :as s]))
@@ -40,7 +40,7 @@
{::rpc/auth false
::doc/added "1.15"
::doc/module :auth}
[{:keys [::setup/props ::ldap/provider] :as cfg} params]
[{:keys [::main/props ::ldap/provider] :as cfg} params]
(when-not provider
(ex/raise :type :restriction
:code :ldap-not-initialized
@@ -72,7 +72,7 @@
(rph/with-meta {::audit/props (:props profile)
::audit/profile-id (:id profile)})))
(-> (profile/strip-private-attrs profile)
(-> profile
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (:props profile)
::audit/profile-id (:id profile)}))))))
@@ -82,8 +82,8 @@
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(or (some->> (:email info)
(profile/clean-email)
(profile/get-profile-by-email conn))
(profile/get-profile-by-email conn)
(profile/decode-row))
(->> (assoc info :is-active true :is-demo false)
(auth/create-profile! conn)
(auth/create-profile-rels! conn)

View File

@@ -7,84 +7,36 @@
(ns app.rpc.commands.management
"A collection of RPC methods for manage the files, projects and team organization."
(:require
[app.binfile.common :as bfc]
[app.binfile.v1 :as bf.v1]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.files.migrations :as pmg]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.features.fdata :as feat.fdata]
[app.http.sse :as sse]
[app.loggers.audit :as audit]
[app.loggers.webhooks :as-alias webhooks]
[app.rpc :as-alias rpc]
[app.rpc.commands.binfile :as binfile]
[app.rpc.commands.files :as files]
[app.rpc.commands.projects :as proj]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.setup :as-alias setup]
[app.setup.templates :as tmpl]
[app.util.blob :as blob]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.walk :as walk]
[promesa.core :as p]
[promesa.exec :as px]))
;; --- COMMAND: Duplicate File
(defn duplicate-file
[{:keys [::db/conn ::bfc/timestamp] :as cfg} {:keys [profile-id file-id name reset-shared-flag] :as params}]
(let [;; We don't touch the original file on duplication
file (bfc/get-file cfg file-id)
project-id (:project-id file)
file (-> file
(update :id bfc/lookup-index)
(update :project-id bfc/lookup-index)
(cond-> (string? name)
(assoc :name name))
(cond-> (true? reset-shared-flag)
(assoc :is-shared false)))
flibs (bfc/get-files-rels cfg #{file-id})
fmeds (bfc/get-file-media cfg file)]
(when (uuid? profile-id)
(proj/check-edition-permissions! conn profile-id project-id))
(vswap! bfc/*state* update :index bfc/update-index fmeds :id)
;; Process and persist file
(let [file (->> (bfc/process-file file)
(bfc/persist-file! cfg))]
;; The file profile creation is optional, so when no profile is
;; present (when this function is called from profile less
;; environment: SREPL) we just omit the creation of the relation
(when (uuid? profile-id)
(db/insert! conn :file-profile-rel
{:file-id (:id file)
:profile-id profile-id
:is-owner true
:is-admin true
:can-edit true}
{::db/return-keys? false}))
(doseq [params (sequence (comp
(map #(bfc/remap-id % :file-id))
(map #(bfc/remap-id % :library-file-id))
(map #(assoc % :synced-at timestamp))
(map #(assoc % :created-at timestamp)))
flibs)]
(db/insert! conn :file-library-rel params ::db/return-keys false))
(doseq [params (sequence (comp
(map #(bfc/remap-id % :id))
(map #(assoc % :created-at timestamp))
(map #(bfc/remap-id % :file-id)))
fmeds)]
(db/insert! conn :file-media-object params ::db/return-keys false))
file)))
(declare duplicate-file)
(def ^:private
schema:duplicate-file
@@ -98,55 +50,176 @@
{::doc/added "1.16"
::webhooks/event? true
::sm/params schema:duplicate-file}
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
[cfg {:keys [::rpc/profile-id] :as params}]
(db/tx-run! cfg duplicate-file (assoc params :profile-id profile-id)))
(binding [bfc/*state* (volatile! {:index {file-id (uuid/next)}})]
(duplicate-file (assoc cfg ::bfc/timestamp (dt/now))
(-> params
(assoc :profile-id profile-id)
(assoc :reset-shared-flag true)))))))
(defn- remap-id
[item index key]
(cond-> item
(contains? item key)
(assoc key (get index (get item key) (get item key)))))
(defn- process-file
[cfg index {:keys [id] :as file}]
(letfn [(process-form [form]
(cond-> form
;; Relink library items
(and (map? form)
(uuid? (:component-file form)))
(update :component-file #(get index % %))
(and (map? form)
(uuid? (:fill-color-ref-file form)))
(update :fill-color-ref-file #(get index % %))
(and (map? form)
(uuid? (:stroke-color-ref-file form)))
(update :stroke-color-ref-file #(get index % %))
(and (map? form)
(uuid? (:typography-ref-file form)))
(update :typography-ref-file #(get index % %))
;; Relink Image Shapes
(and (map? form)
(map? (:metadata form))
(= :image (:type form)))
(update-in [:metadata :id] #(get index % %))))
;; A function responsible to analyze all file data and
;; replace the old :component-file reference with the new
;; ones, using the provided file-index
(relink-shapes [data]
(walk/postwalk process-form data))
;; A function responsible of process the :media attr of file
;; data and remap the old ids with the new ones.
(relink-media [media]
(reduce-kv (fn [res k v]
(let [id (get index k)]
(if (uuid? id)
(-> res
(assoc id (assoc v :id id))
(dissoc k))
res)))
media
media))
(update-fdata [fdata new-id]
(-> fdata
(assoc :id new-id)
(pmg/migrate-data)
(update :pages-index relink-shapes)
(update :components relink-shapes)
(update :media relink-media)
(d/without-nils)
(feat.fdata/process-pointers pmap/clone)))]
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
pmap/*tracked* (pmap/create-tracked)
cfeat/*new* (atom #{})]
(let [new-id (get index id)
file (-> file
(assoc :id new-id)
(update :data update-fdata new-id)
(update :features into (deref cfeat/*new*))
(update :features cfeat/migrate-legacy-features))]
(feat.fdata/persist-pointers! cfg new-id)
file))))
(def sql:get-used-libraries
"select flr.*
from file_library_rel as flr
inner join file as l on (flr.library_file_id = l.id)
where flr.file_id = ?
and l.deleted_at is null")
(def sql:get-used-media-objects
"select fmo.*
from file_media_object as fmo
inner join storage_object as so on (fmo.media_id = so.id)
where fmo.file_id = ?
and so.deleted_at is null")
(defn duplicate-file*
[{:keys [::db/conn] :as cfg} {:keys [profile-id file index project-id name flibs fmeds]} {:keys [reset-shared-flag]}]
(let [flibs (or flibs (db/exec! conn [sql:get-used-libraries (:id file)]))
fmeds (or fmeds (db/exec! conn [sql:get-used-media-objects (:id file)]))
;; memo uniform creation/modification date
now (dt/now)
ignore (dt/plus now (dt/duration {:seconds 5}))
;; add to the index all file media objects.
index (reduce #(assoc %1 (:id %2) (uuid/next)) index fmeds)
flibs-xf (comp
(map #(remap-id % index :file-id))
(map #(remap-id % index :library-file-id))
(map #(assoc % :synced-at now))
(map #(assoc % :created-at now)))
;; remap all file-library-rel row
flibs (sequence flibs-xf flibs)
fmeds-xf (comp
(map #(assoc % :id (get index (:id %))))
(map #(assoc % :created-at now))
(map #(remap-id % index :file-id)))
;; remap all file-media-object rows
fmeds (sequence fmeds-xf fmeds)
file (cond-> file
(some? project-id)
(assoc :project-id project-id)
(some? name)
(assoc :name name)
(true? reset-shared-flag)
(assoc :is-shared false))
file (-> file
(assoc :created-at now)
(assoc :modified-at now)
(assoc :ignore-sync-until ignore))
file (process-file cfg index file)]
(db/insert! conn :file
(-> file
(update :features #(db/create-array conn "text" %))
(update :data blob/encode)))
(db/insert! conn :file-profile-rel
{:file-id (:id file)
:profile-id profile-id
:is-owner true
:is-admin true
:can-edit true})
(doseq [params flibs]
(db/insert! conn :file-library-rel params))
(doseq [params fmeds]
(db/insert! conn :file-media-object params))
file))
(defn duplicate-file
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id] :as params}]
(let [;; We don't touch the original file on duplication
file (files/get-file cfg file-id :migrate? false)
index {file-id (uuid/next)}
params (assoc params :index index :file file)]
(proj/check-edition-permissions! conn profile-id (:project-id file))
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
(duplicate-file* cfg params {:reset-shared-flag true})))
;; --- COMMAND: Duplicate Project
(defn duplicate-project
[{:keys [::db/conn ::bfc/timestamp] :as cfg} {:keys [profile-id project-id name] :as params}]
(binding [bfc/*state* (volatile! {:index {project-id (uuid/next)}})]
(let [project (-> (db/get-by-id conn :project project-id)
(assoc :created-at timestamp)
(assoc :modified-at timestamp)
(assoc :is-pinned false)
(update :id bfc/lookup-index)
(cond-> (string? name)
(assoc :name name)))
files (bfc/get-project-files cfg project-id)]
;; Update index with the project files and the project-id
(vswap! bfc/*state* update :index bfc/update-index files)
;; Check if the source team-id allow creating new project for current user
(teams/check-edition-permissions! conn profile-id (:team-id project))
;; create the duplicated project and assign the current profile as
;; a project owner
(let [project (teams/create-project conn project)]
;; The project profile creation is optional, so when no profile is
;; present (when this function is called from profile less
;; environment: SREPL) we just omit the creation of the relation
(when (uuid? profile-id)
(teams/create-project-role conn profile-id (:id project) :owner))
(doseq [file-id files]
(let [params (-> params
(dissoc :name)
(assoc :file-id file-id)
(assoc :reset-shared-flag false))]
(duplicate-file cfg params)))
project))))
(declare duplicate-project)
(def ^:private
schema:duplicate-project
@@ -161,99 +234,56 @@
::webhooks/event? true
::sm/params schema:duplicate-project}
[cfg {:keys [::rpc/profile-id] :as params}]
(db/tx-run! cfg (fn [cfg]
;; Defer all constraints
(db/exec-one! cfg ["SET CONSTRAINTS ALL DEFERRED"])
(-> (assoc cfg ::bfc/timestamp (dt/now))
(duplicate-project (assoc params :profile-id profile-id))))))
(db/tx-run! cfg duplicate-project (assoc params :profile-id profile-id)))
(defn duplicate-team
[{:keys [::db/conn ::bfc/timestamp] :as cfg} & {:keys [profile-id team-id name] :as params}]
(defn duplicate-project
[{:keys [::db/conn] :as cfg} {:keys [profile-id project-id name] :as params}]
;; Check if the source team-id allowed to be read by the user if
;; profile-id is present; it can be ommited if this function is
;; called from SREPL helpers where no profile is available
(when (uuid? profile-id)
(teams/check-read-permissions! conn profile-id team-id))
;; Defer all constraints
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
(binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})]
(let [projs (bfc/get-team-projects cfg team-id)
files (bfc/get-team-files cfg team-id)
frels (bfc/get-files-rels cfg files)
(let [project (-> (db/get-by-id conn :project project-id)
(assoc :is-pinned false))
team (-> (db/get-by-id conn :team team-id)
(assoc :created-at timestamp)
(assoc :modified-at timestamp)
(update :id bfc/lookup-index)
(cond-> (string? name)
(assoc :name name)))
files (db/query conn :file
{:project-id (:id project)
:deleted-at nil}
{:columns [:id]})
fonts (db/query conn :team-font-variant
{:team-id team-id})]
project (cond-> project
(string? name)
(assoc :name name)
(vswap! bfc/*state* update :index
(fn [index]
(-> index
(bfc/update-index projs)
(bfc/update-index files)
(bfc/update-index fonts :id))))
:always
(assoc :id (uuid/next)))]
;; FIXME: disallow clone default team
;; Create the new team in the database
(db/insert! conn :team team)
;; Check if the source team-id allow creating new project for current user
(teams/check-edition-permissions! conn profile-id (:team-id project))
;; Duplicate team <-> profile relations
(doseq [params frels]
(let [params (-> params
(assoc :id (uuid/next))
(update :team-id bfc/lookup-index)
(assoc :created-at timestamp)
(assoc :modified-at timestamp))]
(db/insert! conn :team-profile-rel params
{::db/return-keys false})))
;; create the duplicated project and assign the current profile as
;; a project owner
(teams/create-project conn project)
(teams/create-project-role conn profile-id (:id project) :owner)
;; Duplicate team fonts
(doseq [font fonts]
(let [params (-> font
(update :id bfc/lookup-index)
(update :team-id bfc/lookup-index)
(assoc :created-at timestamp)
(assoc :modified-at timestamp))]
(db/insert! conn :team-font-variant params
{::db/return-keys false})))
;; duplicate all files
(let [index (reduce #(assoc %1 (:id %2) (uuid/next)) {} files)
params (-> params
(dissoc :name)
(assoc :project-id (:id project))
(assoc :index index))]
(doseq [{:keys [id]} files]
(let [file (files/get-file cfg id :migrate? false)
params (assoc params :file file)
opts {:reset-shared-flag false}]
(duplicate-file* cfg params opts))))
;; Duplicate projects; We don't reuse the `duplicate-project`
;; here because we handle files duplication by whole team
;; instead of by project and we want to preserve some project
;; props which are reset on the `duplicate-project` impl
(doseq [project-id projs]
(let [project (db/get conn :project {:id project-id})
project (-> project
(assoc :created-at timestamp)
(assoc :modified-at timestamp)
(update :id bfc/lookup-index)
(update :team-id bfc/lookup-index))]
(teams/create-project conn project)
;; The project profile creation is optional, so when no profile is
;; present (when this function is called from profile less
;; environment: SREPL) we just omit the creation of the relation
(when (uuid? profile-id)
(teams/create-project-role conn profile-id (:id project) :owner))))
(doseq [file-id files]
(let [params (-> params
(dissoc :name)
(assoc :file-id file-id)
(assoc :reset-shared-flag false))]
(duplicate-file cfg params)))
team)))
;; return the created project
project))
;; --- COMMAND: Move file
(def sql:get-files
"select id, features, project_id from file where id = ANY(?)")
"select id, project_id from file where id = ANY(?)")
(def sql:move-files
"update file set project_id = ? where id = ANY(?)")
@@ -277,8 +307,7 @@
[{:keys [::db/conn] :as cfg} {:keys [profile-id ids project-id] :as params}]
(let [fids (db/create-array conn "uuid" ids)
files (->> (db/exec! conn [sql:get-files fids])
(map files/decode-row))
files (db/exec! conn [sql:get-files fids])
source (into #{} (map :project-id) files)
pids (->> (conj source project-id)
(db/create-array conn "uuid"))]
@@ -298,12 +327,7 @@
;; Check the team compatibility
(let [orig-team (teams/get-team conn :profile-id profile-id :project-id (first source))
dest-team (teams/get-team conn :profile-id profile-id :project-id project-id)]
(cfeat/check-teams-compatibility! orig-team dest-team)
;; Check if all pending to move files are compaib
(let [features (cfeat/get-team-enabled-features cf/flags dest-team)]
(doseq [file files]
(cfeat/check-file-features! features (:features file)))))
(cfeat/check-teams-compatibility! orig-team dest-team))
;; move all files to the project
(db/exec-one! conn [sql:move-files project-id fids])
@@ -360,15 +384,7 @@
;; Check the teams compatibility
(let [orig-team (teams/get-team conn :profile-id profile-id :team-id (:team-id project))
dest-team (teams/get-team conn :profile-id profile-id :team-id team-id)]
(cfeat/check-teams-compatibility! orig-team dest-team)
;; Check if all pending to move files are compaib
(let [features (cfeat/get-team-enabled-features cf/flags dest-team)]
(doseq [file (->> (db/query conn :file
{:project-id project-id}
{:columns [:features]})
(map files/decode-row))]
(cfeat/check-file-features! features (:features file)))))
(cfeat/check-teams-compatibility! orig-team dest-team))
;; move project to the destination team
(db/update! conn :project
@@ -397,32 +413,6 @@
;; --- COMMAND: Clone Template
(defn- clone-template
[cfg {:keys [project-id ::rpc/profile-id] :as params} template]
(db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}]
;; NOTE: the importation process performs some operations that
;; are not very friendly with virtual threads, and for avoid
;; unexpected blocking of other concurrent operations we
;; dispatch that operation to a dedicated executor.
(let [cfg (-> cfg
(assoc ::bf.v1/project-id project-id)
(assoc ::bf.v1/profile-id profile-id))
result (px/invoke! executor (partial bf.v1/import-files! cfg template))]
(db/update! conn :project
{:modified-at (dt/now)}
{:id project-id})
(let [props (audit/clean-props params)]
(doseq [file-id result]
(let [props (assoc props :id file-id)
event (-> (audit/event-from-rpc-params params)
(assoc ::audit/name "create-file")
(assoc ::audit/props props))]
(audit/submit! cfg event))))
result))))
(def ^:private
schema:clone-template
(sm/define
@@ -430,6 +420,8 @@
[:project-id ::sm/uuid]
[:template-id ::sm/word-string]]))
(declare ^:private clone-template)
(sv/defmethod ::clone-template
"Clone into the specified project the template by its id."
{::doc/added "1.16"
@@ -439,14 +431,35 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}]
(let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]})
_ (teams/check-edition-permissions! pool profile-id (:team-id project))
template (tmpl/get-template-stream cfg template-id)]
template (tmpl/get-template-stream cfg template-id)
params (-> cfg
(assoc ::binfile/input template)
(assoc ::binfile/project-id (:id project))
(assoc ::binfile/profile-id profile-id)
(assoc ::binfile/ignore-index-errors? true)
(assoc ::binfile/migrate? true))]
(when-not template
(ex/raise :type :not-found
:code :template-not-found
:hint "template not found"))
(sse/response #(clone-template cfg params template))))
(sse/response #(clone-template params))))
(defn- clone-template
[{:keys [::wrk/executor ::binfile/project-id] :as params}]
(db/tx-run! params
(fn [{:keys [::db/conn] :as params}]
;; NOTE: the importation process performs some operations that
;; are not very friendly with virtual threads, and for avoid
;; unexpected blocking of other concurrent operations we
;; dispatch that operation to a dedicated executor.
(let [result (p/thread-call executor (partial binfile/import! params))]
(db/update! conn :project
{:modified-at (dt/now)}
{:id project-id})
(deref result)))))
;; --- COMMAND: Get list of builtin templates

View File

@@ -23,12 +23,10 @@
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as-alias wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[datoteka.io :as io]
[promesa.exec :as px]))
[datoteka.io :as io]))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
@@ -57,25 +55,20 @@
:opt-un [::id]))
(sv/defmethod ::upload-file-media-object
{::doc/added "1.17"
::climit/id [[:process-image/by-profile ::rpc/profile-id]
[:process-image/global]]}
{::doc/added "1.17"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(files/check-edition-permissions! pool profile-id file-id)
(media/validate-media-type! content)
(media/validate-media-size! content)
(db/run! cfg (fn [cfg]
(let [object (create-file-media-object cfg params)
props {:name (:name params)
:file-id file-id
:is-local (:is-local params)
:size (:size content)
:mtype (:mtype content)}]
(with-meta object
{::audit/replace-props props}))))))
(let [object (db/run! cfg #(create-file-media-object % params))
props {:name (:name params)
:file-id file-id
:is-local (:is-local params)
:size (:size content)
:mtype (:mtype content)}]
(with-meta object
{::audit/replace-props props}))))
(defn- big-enough-for-thumbnail?
"Checks if the provided image info is big enough for
@@ -150,19 +143,16 @@
(assoc ::image (process-main-image info)))))
(defn create-file-media-object
[{:keys [::sto/storage ::db/conn ::wrk/executor]}
[{:keys [::sto/storage ::db/conn ::wrk/executor] :as cfg}
{:keys [id file-id is-local name content]}]
(let [result (px/invoke! executor (partial process-image content))
(let [result (-> (climit/configure cfg :process-image/global)
(climit/run! (partial process-image content) executor))
image (sto/put-object! storage (::image result))
thumb (when-let [params (::thumb result)]
(sto/put-object! storage params))]
(db/update! conn :file
{:modified-at (dt/now)
:has-media-trimmed false}
{:id file-id})
(db/exec-one! conn [sql:create-file-media-object
(or id (uuid/next))
file-id is-local name
@@ -187,7 +177,7 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(files/check-edition-permissions! pool profile-id file-id)
(create-file-media-object-from-url cfg (assoc params :profile-id profile-id))))
(db/run! cfg #(create-file-media-object-from-url % params))))
(defn download-image
[{:keys [::http/client]} uri]
@@ -239,17 +229,7 @@
params (-> params
(assoc :content content)
(assoc :name (or name (:filename content))))]
;; NOTE: we use the climit here in a dynamic invocation because we
;; don't want saturate the process-image limit with IO (download
;; of external image)
(-> cfg
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
[:process-image/global]])
(assoc ::climit/label "create-file-media-object-from-url")
(climit/invoke! #(db/run! %1 create-file-media-object %2) params))))
(create-file-media-object cfg params)))
;; --- Clone File Media object (Upload and create from url)

View File

@@ -13,7 +13,6 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.email :as eml]
[app.http.session :as session]
[app.loggers.audit :as audit]
@@ -23,14 +22,12 @@
[app.rpc.climit :as climit]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.setup :as-alias setup]
[app.storage :as sto]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[cuerdas.core :as str]
[promesa.exec :as px]))
[app.worker :as-alias wrk]
[cuerdas.core :as str]))
(declare check-profile-existence!)
(declare decode-row)
@@ -40,19 +37,6 @@
(declare strip-private-attrs)
(declare verify-password)
(defn clean-email
"Clean and normalizes email address string"
[email]
(let [email (str/lower email)
email (if (str/starts-with? email "mailto:")
(subs email 7)
email)
email (if (or (str/starts-with? email "<")
(str/ends-with? email ">"))
(str/trim email "<>")
email)]
email))
(def ^:private
schema:profile
(sm/define
@@ -91,8 +75,8 @@
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
[conn id & {:as opts}]
(-> (db/get-by-id conn :profile id opts)
[conn id & {:as attrs}]
(-> (db/get-by-id conn :profile id attrs)
(decode-row)))
;; --- MUTATION: Update Profile (own)
@@ -102,7 +86,7 @@
(sm/define
[:map {:title "update-profile"}
[:fullname [::sm/word-string {:max 250}]]
[:lang {:optional true} [:string {:max 8}]]
[:lang {:optional true} [:string {:max 5}]]
[:theme {:optional true} [:string {:max 250}]]]))
(sv/defmethod ::update-profile
@@ -110,11 +94,12 @@
::sm/params schema:update-profile
::sm/result schema:profile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}]
(db/with-atomic [conn pool]
;; NOTE: we need to retrieve the profile independently if we use
;; it or not for explicit locking and avoid concurrent updates of
;; the same row/object.
(let [profile (-> (db/get-by-id conn :profile profile-id ::sql/for-update true)
(let [profile (-> (db/get-by-id conn :profile profile-id ::db/for-update? true)
(decode-row))
;; Update the profile map with direct params
@@ -151,23 +136,25 @@
[:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]]))
(sv/defmethod ::update-profile-password
{::doc/added "1.0"
{:doc/added "1.0"
::sm/params schema:update-profile-password
::climit/id :auth/global}
[cfg {:keys [::rpc/profile-id password] :as params}]
::sm/result :nil}
(db/tx-run! cfg (fn [cfg]
(let [profile (validate-password! cfg (assoc params :profile-id profile-id))
session-id (::session/id params)]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id password] :as params}]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)
profile (validate-password! cfg (assoc params :profile-id profile-id))
session-id (::session/id params)]
(when (= (:email profile) (str/lower (:password params)))
(ex/raise :type :validation
:code :email-as-password
:hint "you can't use your email as password"))
(when (= (str/lower (:email profile))
(str/lower (:password params)))
(ex/raise :type :validation
:code :email-as-password
:hint "you can't use your email as password"))
(update-profile-password! cfg (assoc profile :password password))
(invalidate-profile-session! cfg profile-id session-id)
nil))))
(update-profile-password! conn (assoc profile :password password))
(invalidate-profile-session! cfg profile-id session-id)
nil)))
(defn- invalidate-profile-session!
"Removes all sessions except the current one."
@@ -177,7 +164,7 @@
(defn- validate-password!
[{:keys [::db/conn] :as cfg} {:keys [profile-id old-password] :as params}]
(let [profile (db/get-by-id conn :profile profile-id ::sql/for-update true)]
(let [profile (db/get-by-id conn :profile profile-id ::db/for-update? true)]
(when (and (not= (:password profile) "!")
(not (:valid (verify-password cfg old-password (:password profile)))))
(ex/raise :type :validation
@@ -185,12 +172,11 @@
profile))
(defn update-profile-password!
[{:keys [::db/conn] :as cfg} {:keys [id password] :as profile}]
[conn {:keys [id password] :as profile}]
(when-not (db/read-only? conn)
(db/update! conn :profile
{:password (derive-password cfg password)}
{:id id})
nil))
{:password (auth/derive-password password)}
{:id id})))
;; --- MUTATION: Update Photo
@@ -215,9 +201,8 @@
(defn update-profile-photo
[{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id file] :as params}]
(let [photo (upload-photo cfg params)
profile (db/get-by-id pool :profile profile-id ::sql/for-update true)]
profile (db/get-by-id pool :profile profile-id ::db/for-update? true)]
;; Schedule deletion of old photo
(when-let [id (:photo-id profile)]
@@ -236,7 +221,7 @@
:file-mtype (:mtype file)}}))))
(defn- generate-thumbnail!
[_ file]
[file]
(let [input (media/run {:cmd :info :input file})
thumb (media/run {:cmd :profile-thumbnail
:format :jpeg
@@ -253,15 +238,12 @@
:content-type (:mtype thumb)}))
(defn upload-photo
[{:keys [::sto/storage ::wrk/executor] :as cfg} {:keys [file] :as params}]
(let [params (-> cfg
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
[:process-image/global]])
(assoc ::climit/label "upload-photo")
(assoc ::climit/executor executor)
(climit/invoke! generate-thumbnail! file))]
[{:keys [::sto/storage ::wrk/executor] :as cfg} {:keys [file]}]
(let [params (-> (climit/configure cfg :process-image/global)
(climit/run! (partial generate-thumbnail! file) executor))]
(sto/put-object! storage params)))
;; --- MUTATION: Request Email Change
(declare ^:private request-email-change!)
@@ -276,19 +258,19 @@
(sv/defmethod ::request-email-change
{::doc/added "1.0"
::sm/params schema:request-email-change}
[cfg {:keys [::rpc/profile-id email] :as params}]
(db/tx-run! cfg
(fn [cfg]
(let [profile (db/get-by-id cfg :profile profile-id)
params (assoc params
:profile profile
:email (clean-email email))]
(if (contains? cf/flags :smtp)
(request-email-change! cfg params)
(change-email-immediately! cfg params))))))
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id email] :as params}]
(db/with-atomic [conn pool]
(let [profile (db/get-by-id conn :profile profile-id)
cfg (assoc cfg ::conn conn)
params (assoc params
:profile profile
:email (str/lower email))]
(if (contains? cf/flags :smtp)
(request-email-change! cfg params)
(change-email-immediately! cfg params)))))
(defn- change-email-immediately!
[{:keys [::db/conn]} {:keys [profile email] :as params}]
[{:keys [::conn]} {:keys [profile email] :as params}]
(when (not= email (:email profile))
(check-profile-existence! conn params))
@@ -299,13 +281,13 @@
{:changed true})
(defn- request-email-change!
[{:keys [::db/conn] :as cfg} {:keys [profile email] :as params}]
(let [token (tokens/generate (::setup/props cfg)
[{:keys [::conn] :as cfg} {:keys [profile email] :as params}]
(let [token (tokens/generate (::main/props cfg)
{:iss :change-email
:exp (dt/in-future "15m")
:profile-id (:id profile)
:email email})
ptoken (tokens/generate (::setup/props cfg)
ptoken (tokens/generate (::main/props cfg)
{:iss :profile-identity
:profile-id (:id profile)
:exp (dt/in-future {:days 30})})]
@@ -319,28 +301,9 @@
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
(when (eml/has-bounce-reports? conn email)
(ex/raise :type :restriction
(ex/raise :type :validation
:code :email-has-permanent-bounces
:email email
:hint "looks like the email has bounce reports"))
(when (eml/has-complaint-reports? conn email)
(ex/raise :type :restriction
:code :email-has-complaints
:email email
:hint "looks like the email has spam complaint reports"))
(when (eml/has-bounce-reports? conn (:email profile))
(ex/raise :type :restriction
:code :email-has-permanent-bounces
:email (:email profile)
:hint "looks like the email has bounce reports"))
(when (eml/has-complaint-reports? conn (:email profile))
(ex/raise :type :restriction
:code :email-has-complaints
:email (:email profile)
:hint "looks like the email has spam complaint reports"))
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
(eml/send! {::eml/conn conn
::eml/factory eml/change-email
@@ -366,7 +329,7 @@
::sm/params schema:update-profile-props}
[{:keys [::db/pool]} {:keys [::rpc/profile-id props]}]
(db/with-atomic [conn pool]
(let [profile (get-profile conn profile-id ::sql/for-update true)
(let [profile (get-profile conn profile-id ::db/for-update? true)
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
@@ -385,13 +348,13 @@
;; --- MUTATION: Delete Profile
(declare ^:private get-owned-teams)
(declare ^:private get-owned-teams-with-participants)
(sv/defmethod ::delete-profile
{::doc/added "1.0"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(db/with-atomic [conn pool]
(let [teams (get-owned-teams conn profile-id)
(let [teams (get-owned-teams-with-participants conn profile-id)
deleted-at (dt/now)]
;; If we found owned teams with participants, we don't allow
@@ -403,48 +366,47 @@
:hint "The user need to transfer ownership of owned teams."
:context {:teams (mapv :id teams)}))
;; Mark profile deleted immediatelly
(doseq [{:keys [id]} teams]
(db/update! conn :team
{:deleted-at deleted-at}
{:id id}))
(db/update! conn :profile
{:deleted-at deleted-at}
{:id profile-id})
;; Schedule cascade deletion to a worker
(wrk/submit! {::db/conn conn
::wrk/task :delete-object
::wrk/params {:object :profile
:deleted-at deleted-at
:id profile-id}})
(rph/with-transform {} (session/delete-fn cfg)))))
;; --- HELPERS
(def sql:owned-teams
"WITH owner_teams AS (
SELECT tpr.team_id AS id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS TRUE
AND tpr.profile_id = ?
"with owner_teams as (
select tpr.team_id as id
from team_profile_rel as tpr
where tpr.is_owner is true
and tpr.profile_id = ?
)
SELECT tpr.team_id AS id,
count(tpr.profile_id) - 1 AS participants
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id from owner_teams)
GROUP BY 1")
select tpr.team_id as id,
count(tpr.profile_id) - 1 as participants
from team_profile_rel as tpr
where tpr.team_id in (select id from owner_teams)
and tpr.profile_id != ?
group by 1")
(defn get-owned-teams
(defn- get-owned-teams-with-participants
[conn profile-id]
(db/exec! conn [sql:owned-teams profile-id]))
(db/exec! conn [sql:owned-teams profile-id profile-id]))
(def ^:private sql:profile-existence
"select exists (select * from profile
where email = ?
and deleted_at is null) as val")
(defn- check-profile-existence!
(defn check-profile-existence!
[conn {:keys [email] :as params}]
(let [result (db/exec-one! conn [sql:profile-existence email])]
(let [email (str/lower email)
result (db/exec-one! conn [sql:profile-existence email])]
(when (:val result)
(ex/raise :type :validation
:code :email-already-exists))
@@ -459,7 +421,7 @@
(defn get-profile-by-email
"Returns a profile looked up by email or `nil` if not match found."
[conn email]
(->> (db/exec! conn [sql:profile-by-email (clean-email email)])
(->> (db/exec! conn [sql:profile-by-email (str/lower email)])
(map decode-row)
(first)))
@@ -474,13 +436,17 @@
(into {} (filter (fn [[k _]] (simple-ident? k))) props))
(defn derive-password
[{:keys [::wrk/executor]} password]
[cfg password]
(when password
(px/invoke! executor (partial auth/derive-password password))))
(-> (climit/configure cfg :derive-password/global)
(climit/run! (partial auth/derive-password password)
(::wrk/executor cfg)))))
(defn verify-password
[{:keys [::wrk/executor]} password password-data]
(px/invoke! executor (partial auth/verify-password password password-data)))
[cfg password password-data]
(-> (climit/configure cfg :derive-password/global)
(climit/run! (partial auth/verify-password password password-data)
(::wrk/executor cfg))))
(defn decode-row
[{:keys [props] :as row}]

View File

@@ -7,10 +7,8 @@
(ns app.rpc.commands.projects
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as webhooks]
[app.rpc :as-alias rpc]
@@ -21,7 +19,6 @@
[app.rpc.quotes :as quotes]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]))
(s/def ::id ::us/uuid)
@@ -192,8 +189,8 @@
{:project-id (:id project)
:profile-id profile-id
:team-id team-id
:is-pinned false})
(assoc project :is-pinned false))))
:is-pinned true})
(assoc project :is-pinned true))))
;; --- MUTATION: Toggle Project Pin
@@ -236,7 +233,7 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id name] :as params}]
(db/with-atomic [conn pool]
(check-edition-permissions! conn profile-id id)
(let [project (db/get-by-id conn :project id ::sql/for-update true)]
(let [project (db/get-by-id conn :project id ::db/for-update? true)]
(db/update! conn :project
{:name name}
{:id id})
@@ -246,39 +243,27 @@
;; --- MUTATION: Delete Project
(defn- delete-project
[conn project-id]
(let [project (db/update! conn :project
{:deleted-at (dt/now)}
{:id project-id}
{::db/return-keys true})]
(when (:is-default project)
(ex/raise :type :validation
:code :non-deletable-project
:hint "impossible to delete default project"))
(wrk/submit! {::db/conn conn
::wrk/task :delete-object
::wrk/params {:object :project
:deleted-at (:deleted-at project)
:id project-id}})
project))
(s/def ::delete-project
(s/keys :req [::rpc/profile-id]
:req-un [::id]))
;; TODO: right now, we just don't allow delete default projects, in a
;; future we need to ensure raise a correct exception signaling that
;; this is not allowed.
(sv/defmethod ::delete-project
{::doc/added "1.18"
::webhooks/event? true}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
(db/with-atomic [conn pool]
(check-edition-permissions! conn profile-id id)
(let [project (delete-project conn id)]
(let [project (db/update! conn :project
{:deleted-at (dt/now)}
{:id id :is-default false})]
(rph/with-meta (rph/wrap)
{::audit/props {:team-id (:team-id project)
:name (:name project)
:created-at (:created-at project)
:modified-at (:modified-at project)}}))))

View File

@@ -9,7 +9,6 @@
[app.common.spec :as us]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :refer [resolve-public-uri]]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]
[clojure.spec.alpha :as s]))
@@ -38,15 +37,12 @@
)
select distinct
f.id,
f.revn,
f.project_id,
f.created_at,
f.modified_at,
f.name,
f.is_shared,
ft.media_id
f.is_shared
from file as f
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
inner join projects as pr on (f.project_id = pr.id)
where f.name ilike ('%' || ? || '%')
and (f.deleted_at is null or f.deleted_at > now())
@@ -54,16 +50,10 @@
(defn search-files
[conn profile-id team-id search-term]
(->> (db/exec! conn [sql:search-files
profile-id team-id
profile-id team-id
search-term])
(mapv (fn [row]
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(dissoc row :media-id))))))
(db/exec! conn [sql:search-files
profile-id team-id
profile-id team-id
search-term]))
(s/def ::team-id ::us/uuid)
(s/def ::search-files ::us/string)

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