Merge branch 'staging'

This commit is contained in:
Andrey Antukh 2022-03-01 11:10:27 +01:00
commit fae79d67e6
332 changed files with 12392 additions and 6659 deletions

View file

@ -8,49 +8,48 @@ assignees: ''
--- ---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
4. See error
**Expected behavior** **Expected behavior**
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
**Actual behavior**
A clear and concise description of what happens instead; what the bug is.
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS (e.g. iOS):
- OS: (e.g. iOS) - Browser & version (e.g. Chrome 89.0):
- Browser (e.g. chrome, safari)
- Version (e.g. 22)
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device & model (e.g. iPhone 6):
- Device: (e.g. iPhone6) - OS & version (e.g. iOS 8.1):
- OS: (e.g. iOS8.1) - Browser & version (e.g. stock browser 22):
- Browser (e.g. stock browser, safari)
- Version (e.g. 22)
**Environment (please complete the following information):** **Environment (please complete the following information):**
Specify if using SAAS (https://design.penpot.app) or self-hosted instance. - Host (e.g. https://design.penpot.app, local instance):
If self-hosted instance, add OS and runtime information to help explain your problem. *If self-hosted:*
- OS Version (e.g. Ubuntu 16.04):
- Docker / Docker-compose version (e.g. Docker version 18.03.0-ce, build 0520e24):
- Image version (e.g. Alpine):
- OS Version: (e.g. Ubuntu 16.04) Docker commands or docker-compose file (if possible and if proceed.x):
```
Also provide Docker commands or docker-compose file if possible and if proceed.x ```
- Docker / Docker-compose Version: (e.g. Docker version 18.03.0-ce, build 0520e24)
- Image (e.g. alpine)
**Frontend Stack Trace (if self-hosted)**
Frontend Stack Trace:
<details> <details>
``` ```
@ -59,8 +58,7 @@ Also provide Docker commands or docker-compose file if possible and if proceed.x
</details> </details>
**Backend Stack Trace (if self-hosted)** Backend Stack Trace:
<details> <details>
``` ```
@ -69,5 +67,6 @@ Also provide Docker commands or docker-compose file if possible and if proceed.x
</details> </details>
**Additional context** **Additional context:**
Add any other context about the problem here.
Any other context about the problem.

4
.gitignore vendored
View file

@ -1,6 +1,7 @@
*-init.clj *-init.clj
*.jar *.jar
*.penpot *.penpot
*.orig
.calva .calva
.clj-kondo .clj-kondo
.cpcache .cpcache
@ -33,13 +34,16 @@
/exporter/.shadow-cljs /exporter/.shadow-cljs
/exporter/target /exporter/target
/frontend/.shadow-cljs /frontend/.shadow-cljs
/frontend/package-lock.json
/frontend/cypress/videos/*/ /frontend/cypress/videos/*/
/frontend/cypress/fixtures/validuser.json
/frontend/dist/ /frontend/dist/
/frontend/npm-debug.log /frontend/npm-debug.log
/frontend/out/ /frontend/out/
/frontend/resources/fonts/experiments /frontend/resources/fonts/experiments
/frontend/resources/public/* /frontend/resources/public/*
/frontend/target/ /frontend/target/
/frontend/cypress/videos/*/
/media /media
/telemetry/ /telemetry/
/vendor/**/target /vendor/**/target

View file

@ -1,5 +1,52 @@
# CHANGELOG # CHANGELOG
## 1.12.0-beta
### :boom: Breaking changes
### :sparkles: New features
- Open feedback in a new window [Taiga #2901](https://tree.taiga.io/project/penpot/us/2901)
- Improve usage of file menu [Taiga #2853](https://tree.taiga.io/project/penpot/us/2853)
- Rotation to snap to 15º intervals with shift [Taiga #2437](https://tree.taiga.io/project/penpot/issue/2437)
- Support border radius and stroke properties for images [Taiga #497](https://tree.taiga.io/project/penpot/us/497)
- Disallow using same password as user email [Taiga #2454](https://tree.taiga.io/project/penpot/us/2454)
- Add configurable nudge amount [Taiga #910](https://tree.taiga.io/project/penpot/us/910)
- Add stroke properties for image shapes [Taiga #497](https://tree.taiga.io/project/penpot/us/497)
- On user settings, hide the theme selector as long as we only have one theme [Taiga #2610](https://tree.taiga.io/project/penpot/us/2610)
- Automatically open comments from dashboard notifications [Taiga #2605](https://tree.taiga.io/project/penpot/us/2605)
- Enhance the behaviour of the artboards list on view mode [Taiga #2634](https://tree.taiga.io/project/penpot/us/2634)
- Add recent used fonts in font selection widget [Taiga #1381](https://tree.taiga.io/project/penpot/us/1381)
- Allow to align items relative to groups [Taiga #2533](https://tree.taiga.io/project/penpot/us/2533)
- Scroll bars [Taiga #2550](https://tree.taiga.io/project/penpot/task/2550)
- Add select layer option to context menu [Taiga #2474](https://tree.taiga.io/project/penpot/us/2474)
- Guides [Taiga #290](https://tree.taiga.io/project/penpot/us/290)
- Improve file menu by adding semantically groups [Github #1203](https://github.com/penpot/penpot/issues/1203)
- Add update components in bulk option in context menu [Taiga #1975](https://tree.taiga.io/project/penpot/us/1975)
- Create first E2E tests [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608), [Taiga #2608](https://tree.taiga.io/project/penpot/task/2608)
- Redesign of workspace toolbars [Taiga #2319](https://tree.taiga.io/project/penpot/us/2319)
- Graphic Tablet usability improvements [Taiga #1913](https://tree.taiga.io/project/penpot/us/1913)
- Improved mouse collision detection for groups and text shapes [Taiga #2452](https://tree.taiga.io/project/penpot/us/2452), [Taiga #2453](https://tree.taiga.io/project/penpot/us/2453)
- Add support for alternative S3 storage providers and all aws regions [#1267](https://github.com/penpot/penpot/issues/1267)
### :bug: Bugs fixed
- Fixed ungroup typography when editing it [Taiga #2391](https://tree.taiga.io/project/penpot/issue/2391)
- Fixed error when trying to post an empty comment [Taiga #2603](https://tree.taiga.io/project/penpot/issue/2603)
- Fixed missing translation strings [Taiga #2786](https://tree.taiga.io/project/penpot/issue/2786)
- Fixed color palette outside viewport [Taiga #2715](https://tree.taiga.io/project/penpot/issue/2715)
- Fixed missing translate string [Taiga #2780](https://tree.taiga.io/project/penpot/issue/2780)
- Fixed handoff shadow type text [Taiga #2717](https://tree.taiga.io/project/penpot/issue/2717)
- Fixed components get "dirty" marker when moved [Taiga #2764](https://tree.taiga.io/project/penpot/issue/2764)
- Fixed cannot align objects in a group that is not part of a frame [Taiga #2762](https://tree.taiga.io/project/penpot/issue/2762)
- Fix problem with double click on exit path editing [Taiga #2906](https://tree.taiga.io/project/penpot/issue/2906)
- Fixed alignment of layers with children [Taiga #2862](https://tree.taiga.io/project/penpot/issue/2862)
### :heart: Community contributions by (Thank you!)
- Cleanup unused static images (by @rhcarvalho) [#1561](https://github.com/penpot/penpot/pull/1561)
- Compress static images to save space (by @rhcarvalho) [#1562](https://github.com/penpot/penpot/pull/1562)
## 1.11.2-beta ## 1.11.2-beta
### :bug: Bugs fixed ### :bug: Bugs fixed
@ -18,7 +65,6 @@
- Increase default max connection pool size to 60 - Increase default max connection pool size to 60
- Reduce resource usage of the error reporter. - Reduce resource usage of the error reporter.
## 1.11.1-beta ## 1.11.1-beta
### :bug: Bugs fixed ### :bug: Bugs fixed
@ -30,11 +76,8 @@
- Update nodejs version to 16.13.1 on docker images. - Update nodejs version to 16.13.1 on docker images.
## 1.11.0-beta ## 1.11.0-beta
### :boom: Breaking changes
### :sparkles: New features ### :sparkles: New features
- Add an option to hide artboards names on the viewport [Taiga #2034](https://tree.taiga.io/project/penpot/issue/2034) - Add an option to hide artboards names on the viewport [Taiga #2034](https://tree.taiga.io/project/penpot/issue/2034)
@ -112,7 +155,7 @@
### :arrow_up: Deps updates ### :arrow_up: Deps updates
- Update devenv docker image dependencies. - Update devenv docker image dependencies
### :heart: Community contributions by (Thank you!) ### :heart: Community contributions by (Thank you!)
@ -124,13 +167,13 @@
### :sparkles: Enhacements ### :sparkles: Enhacements
- Allow parametrice file snapshoting interval. - Allow parametrice file snapshoting interval
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix issue on :mov-object change impl. - Fix issue on :mov-object change impl
- Minor fix on how file changes log is persisted. - Minor fix on how file changes log is persisted
- Fix many issues on error reporting. - Fix many issues on error reporting
## 1.10.3-beta ## 1.10.3-beta

View file

@ -6,7 +6,7 @@
org.zeromq/jeromq {:mvn/version "0.5.2"} org.zeromq/jeromq {:mvn/version "0.5.2"}
com.taoensso/nippy {:mvn/version "3.1.1"} com.taoensso/nippy {:mvn/version "3.1.1"}
com.github.luben/zstd-jni {:mvn/version "1.5.1-1"} com.github.luben/zstd-jni {:mvn/version "1.5.2-1"}
org.clojure/data.fressian {:mvn/version "1.0.0"} org.clojure/data.fressian {:mvn/version "1.0.0"}
io.prometheus/simpleclient {:mvn/version "0.14.1"} io.prometheus/simpleclient {:mvn/version "0.14.1"}
@ -25,7 +25,7 @@
com.github.seancorfield/next.jdbc {:mvn/version "1.2.761"} com.github.seancorfield/next.jdbc {:mvn/version "1.2.761"}
metosin/reitit-ring {:mvn/version "0.5.15"} metosin/reitit-ring {:mvn/version "0.5.15"}
org.postgresql/postgresql {:mvn/version "42.3.1"} org.postgresql/postgresql {:mvn/version "42.3.2"}
com.zaxxer/HikariCP {:mvn/version "5.0.1"} com.zaxxer/HikariCP {:mvn/version "5.0.1"}
funcool/datoteka {:mvn/version "2.0.0"} funcool/datoteka {:mvn/version "2.0.0"}
@ -39,11 +39,11 @@
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
integrant/integrant {:mvn/version "0.8.0"} integrant/integrant {:mvn/version "0.8.0"}
io.sentry/sentry {:mvn/version "5.5.2"} io.sentry/sentry {:mvn/version "5.6.1"}
;; Pretty Print specs ;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"} pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.17.111"}} software.amazon.awssdk/s3 {:mvn/version "2.17.122"}}
:paths ["src" "resources" "target/classes"] :paths ["src" "resources" "target/classes"]
:aliases :aliases
@ -59,13 +59,10 @@
:extra-paths ["test" "dev"]} :extra-paths ["test" "dev"]}
:build :build
{:extra-deps {io.github.clojure/tools.build {:git/tag "v0.7.4" :git/sha "ac442da"}} {:extra-deps
{io.github.clojure/tools.build {:git/tag "v0.7.5" :git/sha "34727f7"}}
:ns-default build} :ns-default build}
:kaocha
{:extra-deps {lambdaisland/kaocha {:mvn/version "RELEASE"}}
:main-opts ["-m" "kaocha.runner"]}
:test :test
{:extra-paths ["test"] {:extra-paths ["test"]
:extra-deps :extra-deps

View file

@ -6,6 +6,7 @@
(ns user (ns user
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.perf :as perf] [app.common.perf :as perf]

View file

@ -10,6 +10,18 @@
# export PENPOT_DATABASE_PASSWORD="penpot_pre" # export PENPOT_DATABASE_PASSWORD="penpot_pre"
# export PENPOT_FLAGS="enable-asserts enable-audit-log $PENPOT_FLAGS" # export PENPOT_FLAGS="enable-asserts enable-audit-log $PENPOT_FLAGS"
# Initialize MINIO config
# mc alias set penpot-s3/ http://minio:9000 minioadmin minioadmin
# mc admin user add penpot-s3 penpot-devenv penpot-devenv
# mc admin policy set penpot-s3 readwrite user=penpot-devenv
# mc mb penpot-s3/penpot -p
# export AWS_ACCESS_KEY_ID=penpot-devenv
# export AWS_SECRET_ACCESS_KEY=penpot-devenv
# export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3
# export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000
# export PENPOT_STORAGE_ASSETS_S3_REGION=eu-central-1
# export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
export OPTIONS=" export OPTIONS="
-A:dev \ -A:dev \
-J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \ -J-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \

View file

@ -41,9 +41,7 @@
data)) data))
(def defaults (def defaults
{:http-server-port 6060 {:host "devenv"
:http-server-host "0.0.0.0"
:host "devenv"
:tenant "dev" :tenant "dev"
:database-uri "postgresql://postgres/penpot" :database-uri "postgresql://postgres/penpot"
:database-username "penpot" :database-username "penpot"
@ -106,12 +104,21 @@
(s/def ::file-change-snapshot-every ::us/integer) (s/def ::file-change-snapshot-every ::us/integer)
(s/def ::file-change-snapshot-timeout ::dt/duration) (s/def ::file-change-snapshot-timeout ::dt/duration)
(s/def ::default-executor-parallelism ::us/integer)
(s/def ::blocking-executor-parallelism ::us/integer)
(s/def ::worker-executor-parallelism ::us/integer)
(s/def ::secret-key ::us/string) (s/def ::secret-key ::us/string)
(s/def ::allow-demo-users ::us/boolean) (s/def ::allow-demo-users ::us/boolean)
(s/def ::assets-path ::us/string) (s/def ::assets-path ::us/string)
(s/def ::authenticated-cookie-domain ::us/string)
(s/def ::database-password (s/nilable ::us/string)) (s/def ::database-password (s/nilable ::us/string))
(s/def ::database-uri ::us/string) (s/def ::database-uri ::us/string)
(s/def ::database-username (s/nilable ::us/string)) (s/def ::database-username (s/nilable ::us/string))
(s/def ::database-readonly ::us/boolean)
(s/def ::database-min-pool-size ::us/integer)
(s/def ::database-max-pool-size ::us/integer)
(s/def ::default-blob-version ::us/integer) (s/def ::default-blob-version ::us/integer)
(s/def ::error-report-webhook ::us/string) (s/def ::error-report-webhook ::us/string)
(s/def ::user-feedback-destination ::us/string) (s/def ::user-feedback-destination ::us/string)
@ -134,6 +141,8 @@
(s/def ::host ::us/string) (s/def ::host ::us/string)
(s/def ::http-server-port ::us/integer) (s/def ::http-server-port ::us/integer)
(s/def ::http-server-host ::us/string) (s/def ::http-server-host ::us/string)
(s/def ::http-server-min-threads ::us/integer)
(s/def ::http-server-max-threads ::us/integer)
(s/def ::http-session-idle-max-age ::dt/duration) (s/def ::http-session-idle-max-age ::dt/duration)
(s/def ::http-session-updater-batch-max-age ::dt/duration) (s/def ::http-session-updater-batch-max-age ::dt/duration)
(s/def ::http-session-updater-batch-max-size ::us/integer) (s/def ::http-session-updater-batch-max-size ::us/integer)
@ -179,9 +188,11 @@
(s/def ::storage-assets-fs-directory ::us/string) (s/def ::storage-assets-fs-directory ::us/string)
(s/def ::storage-assets-s3-bucket ::us/string) (s/def ::storage-assets-s3-bucket ::us/string)
(s/def ::storage-assets-s3-region ::us/keyword) (s/def ::storage-assets-s3-region ::us/keyword)
(s/def ::storage-assets-s3-endpoint ::us/string)
(s/def ::storage-fdata-s3-bucket ::us/string) (s/def ::storage-fdata-s3-bucket ::us/string)
(s/def ::storage-fdata-s3-region ::us/keyword) (s/def ::storage-fdata-s3-region ::us/keyword)
(s/def ::storage-fdata-s3-prefix ::us/string) (s/def ::storage-fdata-s3-prefix ::us/string)
(s/def ::storage-fdata-s3-endpoint ::us/string)
(s/def ::telemetry-uri ::us/string) (s/def ::telemetry-uri ::us/string)
(s/def ::telemetry-with-taiga ::us/boolean) (s/def ::telemetry-with-taiga ::us/boolean)
(s/def ::tenant ::us/string) (s/def ::tenant ::us/string)
@ -198,11 +209,18 @@
::allow-demo-users ::allow-demo-users
::audit-log-archive-uri ::audit-log-archive-uri
::audit-log-gc-max-age ::audit-log-gc-max-age
::authenticated-cookie-domain
::database-password ::database-password
::database-uri ::database-uri
::database-username ::database-username
::database-readonly
::database-min-pool-size
::database-max-pool-size
::default-blob-version ::default-blob-version
::error-report-webhook ::error-report-webhook
::default-executor-parallelism
::blocking-executor-parallelism
::worker-executor-parallelism
::file-change-snapshot-every ::file-change-snapshot-every
::file-change-snapshot-timeout ::file-change-snapshot-timeout
::user-feedback-destination ::user-feedback-destination
@ -225,6 +243,8 @@
::host ::host
::http-server-host ::http-server-host
::http-server-port ::http-server-port
::http-server-max-threads
::http-server-min-threads
::http-session-idle-max-age ::http-session-idle-max-age
::http-session-updater-batch-max-age ::http-session-updater-batch-max-age
::http-session-updater-batch-max-size ::http-session-updater-batch-max-size
@ -274,10 +294,12 @@
::storage-assets-fs-directory ::storage-assets-fs-directory
::storage-assets-s3-bucket ::storage-assets-s3-bucket
::storage-assets-s3-region ::storage-assets-s3-region
::storage-assets-s3-endpoint
::fdata-storage-backend ::fdata-storage-backend
::storage-fdata-s3-bucket ::storage-fdata-s3-bucket
::storage-fdata-s3-region ::storage-fdata-s3-region
::storage-fdata-s3-prefix ::storage-fdata-s3-prefix
::storage-fdata-s3-endpoint
::telemetry-enabled ::telemetry-enabled
::telemetry-uri ::telemetry-uri
::telemetry-referer ::telemetry-referer

View file

@ -47,13 +47,12 @@
;; Initialization ;; Initialization
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare instrument-jdbc!)
(declare apply-migrations!) (declare apply-migrations!)
(s/def ::connection-timeout ::us/integer) (s/def ::connection-timeout ::us/integer)
(s/def ::max-pool-size ::us/integer) (s/def ::max-size ::us/integer)
(s/def ::min-size ::us/integer)
(s/def ::migrations map?) (s/def ::migrations map?)
(s/def ::min-pool-size ::us/integer)
(s/def ::name keyword?) (s/def ::name keyword?)
(s/def ::password ::us/string) (s/def ::password ::us/string)
(s/def ::read-only ::us/boolean) (s/def ::read-only ::us/boolean)
@ -62,38 +61,49 @@
(s/def ::validation-timeout ::us/integer) (s/def ::validation-timeout ::us/integer)
(defmethod ig/pre-init-spec ::pool [_] (defmethod ig/pre-init-spec ::pool [_]
(s/keys :req-un [::uri ::name ::username ::password] (s/keys :req-un [::uri ::name
:opt-un [::min-pool-size ::min-size
::max-pool-size ::max-size
::connection-timeout ::connection-timeout
::validation-timeout ::validation-timeout]
::migrations :opt-un [::migrations
::username
::password
::mtx/metrics ::mtx/metrics
::read-only])) ::read-only]))
(defmethod ig/prep-key ::pool
[_ cfg]
(merge {:name :main
:min-size 0
:max-size 30
:connection-timeout 10000
:validation-timeout 10000
:idle-timeout 120000 ; 2min
:max-lifetime 1800000 ; 30m
:read-only false}
(d/without-nils cfg)))
(defmethod ig/init-key ::pool (defmethod ig/init-key ::pool
[_ {:keys [migrations metrics name] :as cfg}] [_ {:keys [migrations name read-only] :as cfg}]
(l/info :action "initialize connection pool" :name (d/name name) :uri (:uri cfg)) (l/info :hint "initialize connection pool"
(some-> metrics :registry instrument-jdbc!) :name (d/name name)
:uri (:uri cfg)
:read-only read-only
:with-credentials (and (contains? cfg :username)
(contains? cfg :password))
:min-size (:min-size cfg)
:max-size (:max-size cfg))
(let [pool (create-pool cfg)] (let [pool (create-pool cfg)]
(some->> (seq migrations) (apply-migrations! pool)) (when-not read-only
(some->> (seq migrations) (apply-migrations! pool)))
pool)) pool))
(defmethod ig/halt-key! ::pool (defmethod ig/halt-key! ::pool
[_ pool] [_ pool]
(.close ^HikariDataSource pool)) (.close ^HikariDataSource pool))
(defn- instrument-jdbc!
[registry]
(mtx/instrument-vars!
[#'next.jdbc/execute-one!
#'next.jdbc/execute!]
{:registry registry
:type :counter
:name "database_query_total"
:help "An absolute counter of database queries."}))
(defn- apply-migrations! (defn- apply-migrations!
[pool migrations] [pool migrations]
(with-open [conn ^AutoCloseable (open pool)] (with-open [conn ^AutoCloseable (open pool)]
@ -110,22 +120,19 @@
"SET idle_in_transaction_session_timeout = 300000;")) "SET idle_in_transaction_session_timeout = 300000;"))
(defn- create-datasource-config (defn- create-datasource-config
[{:keys [metrics read-only] :or {read-only false} :as cfg}] [{:keys [metrics uri] :as cfg}]
(let [dburi (:uri cfg) (let [config (HikariConfig.)]
username (:username cfg)
password (:password cfg)
config (HikariConfig.)]
(doto config (doto config
(.setJdbcUrl (str "jdbc:" dburi)) (.setJdbcUrl (str "jdbc:" uri))
(.setPoolName (d/name (:name cfg))) (.setPoolName (d/name (:name cfg)))
(.setAutoCommit true) (.setAutoCommit true)
(.setReadOnly read-only) (.setReadOnly (:read-only cfg))
(.setConnectionTimeout (:connection-timeout cfg 10000)) ;; 10seg (.setConnectionTimeout (:connection-timeout cfg))
(.setValidationTimeout (:validation-timeout cfg 10000)) ;; 10seg (.setValidationTimeout (:validation-timeout cfg))
(.setIdleTimeout 120000) ;; 2min (.setIdleTimeout (:idle-timeout cfg))
(.setMaxLifetime 1800000) ;; 30min (.setMaxLifetime (:max-lifetime cfg))
(.setMinimumIdle (:min-pool-size cfg 0)) (.setMinimumIdle (:min-size cfg))
(.setMaximumPoolSize (:max-pool-size cfg 50)) (.setMaximumPoolSize (:max-size cfg))
(.setConnectionInitSql initsql) (.setConnectionInitSql initsql)
(.setInitializationFailTimeout -1)) (.setInitializationFailTimeout -1))
@ -135,8 +142,8 @@
(PrometheusMetricsTrackerFactory.) (PrometheusMetricsTrackerFactory.)
(.setMetricsTrackerFactory config))) (.setMetricsTrackerFactory config)))
(when username (.setUsername config username)) (some->> ^String (:username cfg) (.setUsername config))
(when password (.setPassword config password)) (some->> ^String (:password cfg) (.setPassword config))
config)) config))
@ -146,10 +153,14 @@
(s/def ::pool pool?) (s/def ::pool pool?)
(defn pool-closed? (defn closed?
[pool] [pool]
(.isClosed ^HikariDataSource pool)) (.isClosed ^HikariDataSource pool))
(defn read-only?
[pool]
(.isReadOnly ^HikariDataSource pool))
(defn create-pool (defn create-pool
[cfg] [cfg]
(let [dsc (create-datasource-config cfg)] (let [dsc (create-datasource-config cfg)]

View file

@ -10,6 +10,7 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cf]
[app.http.doc :as doc] [app.http.doc :as doc]
[app.http.errors :as errors] [app.http.errors :as errors]
[app.http.middleware :as middleware] [app.http.middleware :as middleware]
@ -24,19 +25,30 @@
(declare wrap-router) (declare wrap-router)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HTTP SERVER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::handler fn?) (s/def ::handler fn?)
(s/def ::router some?) (s/def ::router some?)
(s/def ::port ::us/integer) (s/def ::port ::us/integer)
(s/def ::host ::us/string) (s/def ::host ::us/string)
(s/def ::name ::us/string) (s/def ::name ::us/string)
(s/def ::max-threads ::cf/http-server-max-threads)
(defmethod ig/pre-init-spec ::server [_] (s/def ::min-threads ::cf/http-server-min-threads)
(s/keys :req-un [::port]
:opt-un [::name ::mtx/metrics ::router ::handler ::host]))
(defmethod ig/prep-key ::server (defmethod ig/prep-key ::server
[_ cfg] [_ cfg]
(merge {:name "http"} (d/without-nils cfg))) (merge {:name "http"
:min-threads 4
:max-threads 60
:port 6060
:host "0.0.0.0"}
(d/without-nils cfg)))
(defmethod ig/pre-init-spec ::server [_]
(s/keys :req-un [::port ::host ::name ::min-threads ::max-threads]
:opt-un [::mtx/metrics ::router ::handler]))
(defn- instrument-metrics (defn- instrument-metrics
[^Server server metrics] [^Server server metrics]
@ -48,15 +60,22 @@
(defmethod ig/init-key ::server (defmethod ig/init-key ::server
[_ {:keys [handler router port name metrics host] :as opts}] [_ {:keys [handler router port name metrics host] :as opts}]
(l/info :msg "starting http server" :port port :host host :name name) (l/info :hint "starting http server"
(let [options {:http/port port :http/host host} :port port :host host :name name
:min-threads (:min-threads opts)
:max-threads (:max-threads opts))
(let [options {:http/port port
:http/host host
:thread-pool/max-threads (:max-threads opts)
:thread-pool/min-threads (:min-threads opts)
:ring/async true}
handler (cond handler (cond
(fn? handler) handler (fn? handler) handler
(some? router) (wrap-router router) (some? router) (wrap-router router)
:else (ex/raise :type :internal :else (ex/raise :type :internal
:code :invalid-argument :code :invalid-argument
:hint "Missing `handler` or `router` option.")) :hint "Missing `handler` or `router` option."))
server (-> (yt/server handler options) server (-> (yt/server handler (d/without-nils options))
(cond-> metrics (instrument-metrics metrics)))] (cond-> metrics (instrument-metrics metrics)))]
(assoc opts :server (yt/start! server)))) (assoc opts :server (yt/start! server))))
@ -70,20 +89,20 @@
(let [default (rr/routes (let [default (rr/routes
(rr/create-resource-handler {:path "/"}) (rr/create-resource-handler {:path "/"})
(rr/create-default-handler)) (rr/create-default-handler))
options {:middleware [middleware/server-timing]} options {:middleware [middleware/wrap-server-timing]
:inject-match? false
:inject-router? false}
handler (rr/ring-handler router default options)] handler (rr/ring-handler router default options)]
(fn [request] (fn [request respond _]
(try (handler request respond (fn [cause]
(handler request)
(catch Throwable e
(l/error :hint "unexpected error processing request" (l/error :hint "unexpected error processing request"
::l/context (errors/get-error-context request e) ::l/context (errors/get-error-context request cause)
:query-string (:query-string request) :query-string (:query-string request)
:cause e) :cause cause)
{:status 500 :body "internal server error"}))))) (respond {:status 500 :body "internal server error"}))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Http Router ;; HTTP ROUTER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::rpc map?) (s/def ::rpc map?)
@ -145,7 +164,6 @@
[middleware/multipart-params] [middleware/multipart-params]
[middleware/keyword-params] [middleware/keyword-params]
[middleware/format-response-body] [middleware/format-response-body]
[middleware/etag]
[middleware/parse-request-body] [middleware/parse-request-body]
[middleware/errors errors/handle] [middleware/errors errors/handle]
[middleware/cookies]]} [middleware/cookies]]}

View file

@ -13,9 +13,12 @@
[app.db :as db] [app.db :as db]
[app.metrics :as mtx] [app.metrics :as mtx]
[app.storage :as sto] [app.storage :as sto]
[app.util.async :as async]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig])) [integrant.core :as ig]
[promesa.core :as p]))
(def ^:private cache-max-age (def ^:private cache-max-age
(dt/duration {:hours 24})) (dt/duration {:hours 24}))
@ -52,10 +55,10 @@
:body (sto/get-object-bytes storage obj)} :body (sto/get-object-bytes storage obj)}
:s3 :s3
(let [url (sto/get-object-url storage obj {:max-age signature-max-age})] (let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
{:status 307 {:status 307
:headers {"location" (str url) :headers {"location" (str url)
"x-host" (:host url) "x-host" (cond-> host port (str ":" port))
"cache-control" (str "max-age=" (inst-ms cache-max-age))} "cache-control" (str "max-age=" (inst-ms cache-max-age))}
:body ""}) :body ""})
@ -69,29 +72,38 @@
:body ""})))) :body ""}))))
(defn- generic-handler (defn- generic-handler
[{:keys [storage] :as cfg} _request id] [{:keys [storage executor] :as cfg} request kf]
(let [obj (sto/get-object storage id)] (async/with-dispatch executor
(let [id (get-in request [:path-params :id])
mobj (get-file-media-object storage id)
obj (sto/get-object storage (kf mobj))]
(if obj
(serve-object cfg obj)
{:status 404 :body ""}))))
(defn objects-handler
[{:keys [storage executor] :as cfg} request respond raise]
(-> (async/with-dispatch executor
(let [id (get-in request [:path-params :id])
id (coerce-id id)
obj (sto/get-object storage id)]
(if obj (if obj
(serve-object cfg obj) (serve-object cfg obj)
{:status 404 :body ""}))) {:status 404 :body ""})))
(p/then respond)
(defn objects-handler (p/catch raise)))
[cfg request]
(let [id (get-in request [:path-params :id])]
(generic-handler cfg request (coerce-id id))))
(defn file-objects-handler (defn file-objects-handler
[{:keys [storage] :as cfg} request] [cfg request respond raise]
(let [id (get-in request [:path-params :id]) (-> (generic-handler cfg request :media-id)
mobj (get-file-media-object storage id)] (p/then respond)
(generic-handler cfg request (:media-id mobj)))) (p/catch raise)))
(defn file-thumbnails-handler (defn file-thumbnails-handler
[{:keys [storage] :as cfg} request] [cfg request respond raise]
(let [id (get-in request [:path-params :id]) (-> (generic-handler cfg request #(or (:thumbnail-id %) (:media-id %)))
mobj (get-file-media-object storage id)] (p/then respond)
(generic-handler cfg request (or (:thumbnail-id mobj) (:media-id mobj))))) (p/catch raise)))
;; --- Initialization ;; --- Initialization
@ -101,10 +113,16 @@
(s/def ::signature-max-age ::dt/duration) (s/def ::signature-max-age ::dt/duration)
(defmethod ig/pre-init-spec ::handlers [_] (defmethod ig/pre-init-spec ::handlers [_]
(s/keys :req-un [::storage ::mtx/metrics ::assets-path ::cache-max-age ::signature-max-age])) (s/keys :req-un [::storage
::wrk/executor
::mtx/metrics
::assets-path
::cache-max-age
::signature-max-age]))
(defmethod ig/init-key ::handlers (defmethod ig/init-key ::handlers
[_ cfg] [_ cfg]
{:objects-handler #(objects-handler cfg %) {:objects-handler (partial objects-handler cfg)
:file-objects-handler #(file-objects-handler cfg %) :file-objects-handler (partial file-objects-handler cfg)
:file-thumbnails-handler #(file-thumbnails-handler cfg %)}) :file-thumbnails-handler (partial file-thumbnails-handler cfg)})

View file

@ -26,7 +26,8 @@
(defmethod ig/init-key ::handler (defmethod ig/init-key ::handler
[_ cfg] [_ cfg]
(fn [request] (fn [request respond _]
(try
(let [body (parse-json (slurp (:body request))) (let [body (parse-json (slurp (:body request)))
mtype (get body "Type")] mtype (get body "Type")]
(cond (cond
@ -43,8 +44,12 @@
:else :else
(l/warn :hint "unexpected data received" (l/warn :hint "unexpected data received"
:report (pr-str body))) :report (pr-str body))))
{:status 200 :body ""}))) (catch Throwable cause
(l/error :hint "unexpected exception on awsns handler"
:cause cause)))
(respond {:status 200 :body ""})))
(defn- parse-bounce (defn- parse-bounce
[data] [data]

View file

@ -14,14 +14,18 @@
[app.db :as db] [app.db :as db]
[app.rpc.mutations.files :as m.files] [app.rpc.mutations.files :as m.files]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.util.async :as async]
[app.util.blob :as blob] [app.util.blob :as blob]
[app.util.template :as tmpl] [app.util.template :as tmpl]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[datoteka.core :as fs] [datoteka.core :as fs]
[fipp.edn :as fpp] [fipp.edn :as fpp]
[integrant.core :as ig])) [integrant.core :as ig]
[promesa.core :as p]))
;; (selmer.parser/cache-off!) ;; (selmer.parser/cache-off!)
@ -201,12 +205,23 @@
(db/exec-one! conn ["select count(*) as count from server_prop;"]) (db/exec-one! conn ["select count(*) as count from server_prop;"])
{:status 200 :body "Ok"})) {:status 200 :body "Ok"}))
(defn- wrap-async
[{:keys [executor] :as cfg} f]
(fn [request respond raise]
(-> (async/with-dispatch executor
(f cfg request))
(p/then respond)
(p/catch raise))))
(defmethod ig/pre-init-spec ::handlers [_]
(s/keys :req-un [::db/pool ::wrk/executor]))
(defmethod ig/init-key ::handlers (defmethod ig/init-key ::handlers
[_ cfg] [_ cfg]
{:index (partial index cfg) {:index (wrap-async cfg index)
:health-check (partial health-check cfg) :health-check (wrap-async cfg health-check)
:retrieve-file-data (partial retrieve-file-data cfg) :retrieve-file-data (wrap-async cfg retrieve-file-data)
:retrieve-file-changes (partial retrieve-file-changes cfg) :retrieve-file-changes (wrap-async cfg retrieve-file-changes)
:retrieve-error (partial retrieve-error cfg) :retrieve-error (wrap-async cfg retrieve-error)
:retrieve-error-list (partial retrieve-error-list cfg) :retrieve-error-list (wrap-async cfg retrieve-error-list)
:upload-file-data (partial upload-file-data cfg)}) :upload-file-data (wrap-async cfg upload-file-data)})

View file

@ -46,8 +46,9 @@
[rpc] [rpc]
(let [context (prepare-context rpc)] (let [context (prepare-context rpc)]
(if (contains? cf/flags :backend-api-doc) (if (contains? cf/flags :backend-api-doc)
(fn [_] (fn [_ respond _]
{:status 200 (respond {:status 200
:body (-> (io/resource "api-doc.tmpl") :body (-> (io/resource "api-doc.tmpl")
(tmpl/render context))}) (tmpl/render context))}))
(constantly {:status 404 :body ""})))) (fn [_ respond _]
(respond {:status 404 :body ""})))))

View file

@ -14,48 +14,55 @@
[app.db :as db] [app.db :as db]
[app.emails :as eml] [app.emails :as eml]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.worker :as wrk]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig])) [integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]))
(declare send-feedback) (declare ^:private send-feedback)
(declare ^:private handler)
(defmethod ig/pre-init-spec ::handler [_] (defmethod ig/pre-init-spec ::handler [_]
(s/keys :req-un [::db/pool])) (s/keys :req-un [::db/pool ::wrk/executor]))
(defmethod ig/init-key ::handler (defmethod ig/init-key ::handler
[_ {:keys [pool] :as scfg}] [_ {:keys [executor] :as cfg}]
(let [enabled? (contains? cf/flags :user-feedback)]
(if enabled?
(fn [request respond raise]
(-> (px/submit! executor #(handler cfg request))
(p/then' respond)
(p/catch raise)))
(fn [_ _ raise]
(raise (ex/error :type :validation
:code :feedback-disabled
:hint "feedback module is disabled"))))))
(defn- handler
[{:keys [pool] :as cfg} {:keys [profile-id] :as request}]
(let [ftoken (cf/get :feedback-token ::no-token) (let [ftoken (cf/get :feedback-token ::no-token)
enabled (contains? cf/flags :user-feedback)] token (get-in request [:headers "x-feedback-token"])
(fn [{:keys [profile-id] :as request}]
(let [token (get-in request [:headers "x-feedback-token"])
params (d/merge (:params request) params (d/merge (:params request)
(:body-params request))] (:body-params request))]
(when-not enabled
(ex/raise :type :validation
:code :feedback-disabled
:hint "feedback module is disabled"))
(cond (cond
(uuid? profile-id) (uuid? profile-id)
(let [profile (profile/retrieve-profile-data pool profile-id) (let [profile (profile/retrieve-profile-data pool profile-id)
params (assoc params :from (:email profile))] params (assoc params :from (:email profile))]
(when-not (:is-muted profile) (send-feedback pool profile params))
(send-feedback pool profile params)))
(= token ftoken) (= token ftoken)
(send-feedback scfg nil params)) (send-feedback cfg nil params))
{:status 204 :body ""})))) {:status 204 :body ""}))
(s/def ::content ::us/string) (s/def ::content ::us/string)
(s/def ::from ::us/email) (s/def ::from ::us/email)
(s/def ::subject ::us/string) (s/def ::subject ::us/string)
(s/def ::feedback (s/def ::feedback
(s/keys :req-un [::from ::subject ::content])) (s/keys :req-un [::from ::subject ::content]))
(defn send-feedback (defn- send-feedback
[pool profile params] [pool profile params]
(let [params (us/conform ::feedback params) (let [params (us/conform ::feedback params)
destination (cf/get :feedback-destination)] destination (cf/get :feedback-destination)]

View file

@ -10,8 +10,6 @@
[app.common.transit :as t] [app.common.transit :as t]
[app.config :as cf] [app.config :as cf]
[app.util.json :as json] [app.util.json :as json]
[buddy.core.codecs :as bc]
[buddy.core.hash :as bh]
[ring.core.protocols :as rp] [ring.core.protocols :as rp]
[ring.middleware.cookies :refer [wrap-cookies]] [ring.middleware.cookies :refer [wrap-cookies]]
[ring.middleware.keyword-params :refer [wrap-keyword-params]] [ring.middleware.keyword-params :refer [wrap-keyword-params]]
@ -21,13 +19,15 @@
(defn wrap-server-timing (defn wrap-server-timing
[handler] [handler]
(let [seconds-from #(float (/ (- (System/nanoTime) %) 1000000000))] (letfn [(get-age [start]
(fn [request] (float (/ (- (System/nanoTime) start) 1000000000)))
(let [start (System/nanoTime)
response (handler request)] (update-headers [headers start]
(update response :headers (assoc headers "Server-Timing" (str "total;dur=" (get-age start))))]
(fn [headers]
(assoc headers "Server-Timing" (str "total;dur=" (seconds-from start))))))))) (fn [request respond raise]
(let [start (System/nanoTime)]
(handler request #(respond (update % :headers update-headers start)) raise)))))
(defn wrap-parse-request-body (defn wrap-parse-request-body
[handler] [handler]
@ -36,11 +36,11 @@
(t/read! reader))) (t/read! reader)))
(parse-json [body] (parse-json [body]
(json/read body))] (json/read body))
(fn [{:keys [headers body] :as request}]
(try (handle-request [{:keys [headers body] :as request}]
(let [ctype (get headers "content-type")] (let [ctype (get headers "content-type")]
(handler (case ctype (case ctype
"application/transit+json" "application/transit+json"
(let [params (parse-transit body)] (let [params (parse-transit body)]
(-> request (-> request
@ -54,14 +54,22 @@
(update :params merge params))) (update :params merge params)))
request))) request)))
(catch Exception e
(handle-exception [cause]
(let [data {:type :validation (let [data {:type :validation
:code :unable-to-parse-request-body :code :unable-to-parse-request-body
:hint "malformed params"}] :hint "malformed params"}]
(l/error :hint (ex-message e) :cause e) (l/error :hint (ex-message cause) :cause cause)
{:status 400 {:status 400
:headers {"content-type" "application/transit+json"} :headers {"content-type" "application/transit+json"}
:body (t/encode-str data {:type :json-verbose})})))))) :body (t/encode-str data {:type :json-verbose})}))]
(fn [request respond raise]
(try
(let [request (handle-request request)]
(handler request respond raise))
(catch Exception cause
(respond (handle-exception cause)))))))
(def parse-request-body (def parse-request-body
{:name ::parse-request-body {:name ::parse-request-body
@ -81,8 +89,9 @@
(def ^:const buffer-size (:http/output-buffer-size yt/base-defaults)) (def ^:const buffer-size (:http/output-buffer-size yt/base-defaults))
(defn- transit-streamable-body (defn wrap-format-response-body
[data opts] [handler]
(letfn [(transit-streamable-body [data opts]
(reify rp/StreamableResponseBody (reify rp/StreamableResponseBody
(write-body-to-stream [_ _ output-stream] (write-body-to-stream [_ _ output-stream]
;; Use the same buffer as jetty output buffer size ;; Use the same buffer as jetty output buffer size
@ -97,11 +106,9 @@
(l/warn :hint "unexpected error on encoding response" (l/warn :hint "unexpected error on encoding response"
:cause cause)))))) :cause cause))))))
(defn- impl-format-response-body (impl-format-response-body [response {:keys [query-params] :as request}]
[response {:keys [query-params] :as request}]
(let [body (:body response) (let [body (:body response)
opts {:type (if (contains? query-params "transit_verbose") :json-verbose :json)}] opts {:type (if (contains? query-params "transit_verbose") :json-verbose :json)}]
(cond (cond
(:ws response) (:ws response)
response response
@ -117,12 +124,15 @@
:else :else
response))) response)))
(defn- wrap-format-response-body (handle-response [response request]
[handler]
(fn [request]
(let [response (handler request)]
(cond-> response (cond-> response
(map? response) (impl-format-response-body request))))) (map? response) (impl-format-response-body request)))]
(fn [request respond raise]
(handler request
(fn [response]
(respond (handle-response response request)))
raise))))
(def format-response-body (def format-response-body
{:name ::format-response-body {:name ::format-response-body
@ -130,11 +140,9 @@
(defn wrap-errors (defn wrap-errors
[handler on-error] [handler on-error]
(fn [request] (fn [request respond _]
(try (handler request respond (fn [cause]
(handler request) (-> cause (on-error request) respond)))))
(catch Throwable e
(on-error e request)))))
(def errors (def errors
{:name ::errors {:name ::errors
@ -160,41 +168,7 @@
{:name ::server-timing {:name ::server-timing
:compile (constantly wrap-server-timing)}) :compile (constantly wrap-server-timing)})
(defn wrap-etag (defn wrap-cors
[handler]
(letfn [(encode [data]
(when (string? data)
(str "W/\"" (-> data bh/blake2b-128 bc/bytes->hex) "\"")))]
(fn [{method :request-method headers :headers :as request}]
(cond-> (handler request)
(= :get method)
(as-> $ (if-let [etag (-> $ :body meta :etag encode)]
(cond-> (update $ :headers assoc "etag" etag)
(= etag (get headers "if-none-match"))
(-> (assoc :body "")
(assoc :status 304)))
$))))))
(def etag
{:name ::etag
:compile (constantly wrap-etag)})
(defn activity-logger
[handler]
(let [logger "penpot.profile-activity"]
(fn [{:keys [headers] :as request}]
(let [ip-addr (get headers "x-forwarded-for")
profile-id (:profile-id request)
qstring (:query-string request)]
(l/info ::l/async true
::l/logger logger
:ip-addr ip-addr
:profile-id profile-id
:uri (str (:uri request) (when qstring (str "?" qstring)))
:method (name (:request-method request)))
(handler request)))))
(defn- wrap-cors
[handler] [handler]
(if-not (contains? cf/flags :cors) (if-not (contains? cf/flags :cors)
handler handler
@ -209,12 +183,15 @@
(assoc "access-control-allow-credentials" "true") (assoc "access-control-allow-credentials" "true")
(assoc "access-control-expose-headers" "x-requested-with, content-type, cookie") (assoc "access-control-expose-headers" "x-requested-with, content-type, cookie")
(assoc "access-control-allow-headers" "x-frontend-version, content-type, accept, x-requested-width"))))))] (assoc "access-control-allow-headers" "x-frontend-version, content-type, accept, x-requested-width"))))))]
(fn [request] (fn [request respond raise]
(if (= (:request-method request) :options) (if (= (:request-method request) :options)
(-> {:status 200 :body ""} (-> {:status 200 :body ""}
(add-cors-headers request)) (add-cors-headers request)
(let [response (handler request)] (respond))
(add-cors-headers response request))))))) (handler request
(fn [response]
(respond (add-cors-headers response request)))
raise))))))
(def cors (def cors
{:name ::cors {:name ::cors

View file

@ -21,7 +21,10 @@
[clojure.set :as set] [clojure.set :as set]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[integrant.core :as ig])) [integrant.core :as ig]
[promesa.exec :as px]))
;; TODO: make it fully async (?)
(defn- build-redirect-uri (defn- build-redirect-uri
[{:keys [provider] :as cfg}] [{:keys [provider] :as cfg}]
@ -213,7 +216,10 @@
(redirect-response uri)))) (redirect-response uri))))
(defn- auth-handler (defn- auth-handler
[{:keys [tokens] :as cfg} {:keys [params] :as request}] [{:keys [tokens executor] :as cfg} {:keys [params] :as request} respond _]
(px/run!
executor
(fn []
(let [invitation (:invitation-token params) (let [invitation (:invitation-token params)
props (extract-utm-props params) props (extract-utm-props params)
state (tokens :generate state (tokens :generate
@ -222,19 +228,23 @@
:props props :props props
:exp (dt/in-future "15m")}) :exp (dt/in-future "15m")})
uri (build-auth-uri cfg state)] uri (build-auth-uri cfg state)]
(respond
{:status 200 {:status 200
:body {:redirect-uri uri}})) :body {:redirect-uri uri}})))))
(defn- callback-handler (defn- callback-handler
[cfg request] [{:keys [executor] :as cfg} request respond _]
(px/run!
executor
(fn []
(try (try
(let [info (retrieve-info cfg request) (let [info (retrieve-info cfg request)
profile (retrieve-profile cfg info)] profile (retrieve-profile cfg info)]
(generate-redirect cfg request info profile)) (respond (generate-redirect cfg request info profile)))
(catch Exception e (catch Exception cause
(l/warn :hint "error on oauth process" (l/warn :hint "error on oauth process" :cause cause)
:cause e) (respond (generate-error-redirect cfg cause)))))))
(generate-error-redirect cfg e))))
;; --- INIT ;; --- INIT
@ -250,15 +260,19 @@
(defn wrap-handler (defn wrap-handler
[cfg handler] [cfg handler]
(fn [request] (fn [request respond raise]
(let [provider (get-in request [:path-params :provider]) (let [provider (get-in request [:path-params :provider])
provider (get-in @cfg [:providers provider])] provider (get-in @cfg [:providers provider])]
(when-not provider (if provider
(ex/raise :type :not-found (handler (assoc @cfg :provider provider)
:context {:provider provider} request
:hint "provider not configured")) respond
(-> (assoc @cfg :provider provider) raise)
(handler request))))) (raise
(ex/error
:type :not-found
:provider provider
:hint "provider not configured"))))))
(defmethod ig/init-key ::handler (defmethod ig/init-key ::handler
[_ cfg] [_ cfg]

View file

@ -11,97 +11,167 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.config :as cfg] [app.config :as cfg]
[app.db :as db] [app.db :as db]
[app.db.sql :as sql]
[app.metrics :as mtx] [app.metrics :as mtx]
[app.util.async :as aa] [app.util.async :as aa]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk] [app.worker :as wrk]
[clojure.core.async :as a] [clojure.core.async :as a]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig])) [integrant.core :as ig]
[ring.middleware.session.store :as rss]))
;; A default cookie name for storing the session. We don't allow ;; A default cookie name for storing the session. We don't allow to configure it.
;; configure it. (def token-cookie-name "auth-token")
(def cookie-name "auth-token")
;; --- IMPL ;; A cookie that we can use to check from other sites of the same domain if a user
;; is registered. Is not intended for on premise installations, although nothing
;; prevents using it if some one wants to.
(def authenticated-cookie-name "authenticated")
(defn- create-session (deftype DatabaseStore [pool tokens]
[{:keys [conn tokens] :as cfg} {:keys [profile-id headers] :as request}] rss/SessionStore
(let [token (tokens :generate {:iss "authentication" (read-session [_ token]
(db/exec-one! pool (sql/select :http-session {:id token})))
(write-session [_ _ data]
(let [profile-id (:profile-id data)
user-agent (:user-agent data)
token (tokens :generate {:iss "authentication"
:iat (dt/now) :iat (dt/now)
:uid profile-id}) :uid profile-id})
now (dt/now) now (dt/now)
params {:user-agent (get headers "user-agent") params {:user-agent user-agent
:profile-id profile-id :profile-id profile-id
:created-at now :created-at now
:updated-at now :updated-at now
:id token}] :id token}]
(db/insert! conn :http-session params))) (db/insert! pool :http-session params)
token))
(delete-session [_ token]
(db/delete! pool :http-session {:id token})
nil))
(deftype MemoryStore [cache tokens]
rss/SessionStore
(read-session [_ token]
(get @cache token))
(write-session [_ _ data]
(let [profile-id (:profile-id data)
user-agent (:user-agent data)
token (tokens :generate {:iss "authentication"
:iat (dt/now)
:uid profile-id})
params {:user-agent user-agent
:profile-id profile-id
:id token}]
(swap! cache assoc token params)
token))
(delete-session [_ token]
(swap! cache dissoc token)
nil))
;; --- IMPL
(defn- create-session
[store request profile-id]
(let [params {:user-agent (get-in request [:headers "user-agent"])
:profile-id profile-id}]
(rss/write-session store nil params)))
(defn- delete-session (defn- delete-session
[{:keys [conn] :as cfg} {:keys [cookies] :as request}] [store {:keys [cookies] :as request}]
(when-let [token (get-in cookies [cookie-name :value])] (when-let [token (get-in cookies [token-cookie-name :value])]
(db/delete! conn :http-session {:id token})) (rss/delete-session store token)))
nil)
(defn- retrieve-session (defn- retrieve-session
[{:keys [conn] :as cfg} id] [store token]
(when id (when token
(db/exec-one! conn ["select id, profile_id from http_session where id = ?" id]))) (rss/read-session store token)))
(defn- retrieve-from-request (defn- retrieve-from-request
[cfg {:keys [cookies] :as request}] [store {:keys [cookies] :as request}]
(->> (get-in cookies [cookie-name :value]) (->> (get-in cookies [token-cookie-name :value])
(retrieve-session cfg))) (retrieve-session store)))
(defn- add-cookies (defn- add-cookies
[response {:keys [id] :as session}] [response token]
(let [cors? (contains? cfg/flags :cors) (let [cors? (contains? cfg/flags :cors)
secure? (contains? cfg/flags :secure-session-cookies)] secure? (contains? cfg/flags :secure-session-cookies)
(assoc response :cookies {cookie-name {:path "/" authenticated-cookie-domain (cfg/get :authenticated-cookie-domain)]
(update response :cookies
(fn [cookies]
(cond-> cookies
:always
(assoc token-cookie-name {:path "/"
:http-only true :http-only true
:value id :value token
:same-site (if cors? :none :lax) :same-site (if cors? :none :lax)
:secure secure?}}))) :secure secure?})
(some? authenticated-cookie-domain)
(assoc authenticated-cookie-name {:domain authenticated-cookie-domain
:path "/"
:value true
:same-site :strict
:secure secure?}))))))
(defn- clear-cookies (defn- clear-cookies
[response] [response]
(assoc response :cookies {cookie-name {:value "" :max-age -1}})) (let [authenticated-cookie-domain (cfg/get :authenticated-cookie-domain)]
(assoc response :cookies {token-cookie-name {:path "/"
:value ""
:max-age -1}
authenticated-cookie-name {:domain authenticated-cookie-domain
:path "/"
:value ""
:max-age -1}})))
(defn- middleware (defn- middleware
[cfg handler] [events-ch store handler]
(fn [request] (fn [request respond raise]
(if-let [{:keys [id profile-id] :as session} (retrieve-from-request cfg request)] (if-let [{:keys [id profile-id] :as session} (retrieve-from-request store request)]
(do (do
(a/>!! (::events-ch cfg) id) (a/>!! events-ch id)
(l/set-context! {:profile-id profile-id}) (l/set-context! {:profile-id profile-id})
(handler (assoc request :profile-id profile-id :session-id id))) (handler (assoc request :profile-id profile-id :session-id id) respond raise))
(handler request)))) (handler request respond raise))))
;; --- STATE INIT: SESSION ;; --- STATE INIT: SESSION
(s/def ::tokens fn?)
(defmethod ig/pre-init-spec ::session [_] (defmethod ig/pre-init-spec ::session [_]
(s/keys :req-un [::db/pool])) (s/keys :req-un [::db/pool ::tokens]))
(defmethod ig/prep-key ::session (defmethod ig/prep-key ::session
[_ cfg] [_ cfg]
(d/merge {:buffer-size 128} (d/without-nils cfg))) (d/merge {:buffer-size 128}
(d/without-nils cfg)))
(defmethod ig/init-key ::session (defmethod ig/init-key ::session
[_ {:keys [pool] :as cfg}] [_ {:keys [pool tokens] :as cfg}]
(let [events (a/chan (a/dropping-buffer (:buffer-size cfg))) (let [events-ch (a/chan (a/dropping-buffer (:buffer-size cfg)))
cfg (-> cfg store (if (db/read-only? pool)
(assoc :conn pool) (->MemoryStore (atom {}) tokens)
(assoc ::events-ch events))] (->DatabaseStore pool tokens))]
(when (db/read-only? pool)
(l/warn :hint "sessions module initialized with in-memory store"))
(-> cfg (-> cfg
(assoc :middleware #(middleware cfg %)) (assoc ::events-ch events-ch)
(assoc :middleware (partial middleware events-ch store))
(assoc :create (fn [profile-id] (assoc :create (fn [profile-id]
(fn [request response] (fn [request response]
(let [request (assoc request :profile-id profile-id) (let [token (create-session store request profile-id)]
session (create-session cfg request)] (add-cookies response token)))))
(add-cookies response session)))))
(assoc :delete (fn [request response] (assoc :delete (fn [request response]
(delete-session cfg request) (delete-session store request)
(-> response (-> response
(assoc :status 204) (assoc :status 204)
(assoc :body "") (assoc :body "")
@ -138,16 +208,11 @@
:max-batch-size (str (:max-batch-size cfg))) :max-batch-size (str (:max-batch-size cfg)))
(let [input (aa/batch (::events-ch session) (let [input (aa/batch (::events-ch session)
{:max-batch-size (:max-batch-size cfg) {:max-batch-size (:max-batch-size cfg)
:max-batch-age (inst-ms (:max-batch-age cfg))}) :max-batch-age (inst-ms (:max-batch-age cfg))})]
mcnt (mtx/create
{:name "http_session_update_total"
:help "A counter of session update batch events."
:registry (:registry metrics)
:type :counter})]
(a/go-loop [] (a/go-loop []
(when-let [[reason batch] (a/<! input)] (when-let [[reason batch] (a/<! input)]
(let [result (a/<! (update-sessions cfg batch))] (let [result (a/<! (update-sessions cfg batch))]
(mcnt :inc) (mtx/run! metrics {:id :session-update-total :inc 1})
(cond (cond
(ex/exception? result) (ex/exception? result)
(l/error :task "updater" (l/error :task "updater"
@ -159,6 +224,7 @@
:hint "update sessions" :hint "update sessions"
:reason (name reason) :reason (name reason)
:count result)) :count result))
(recur)))))) (recur))))))
(defn- update-sessions (defn- update-sessions

View file

@ -13,7 +13,6 @@
[app.db :as db] [app.db :as db]
[app.metrics :as mtx] [app.metrics :as mtx]
[app.util.websocket :as ws] [app.util.websocket :as ws]
[app.worker :as wrk]
[clojure.core.async :as a] [clojure.core.async :as a]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig] [integrant.core :as ig]
@ -100,36 +99,36 @@
(s/keys :req-un [::file-id ::session-id])) (s/keys :req-un [::file-id ::session-id]))
(defmethod ig/pre-init-spec ::handler [_] (defmethod ig/pre-init-spec ::handler [_]
(s/keys :req-un [::msgbus ::db/pool ::mtx/metrics ::wrk/executor])) (s/keys :req-un [::msgbus ::db/pool ::mtx/metrics]))
(defmethod ig/init-key ::handler (defmethod ig/init-key ::handler
[_ {:keys [metrics pool] :as cfg}] [_ {:keys [pool] :as cfg}]
(let [metrics {:connections (get-in metrics [:definitions :websocket-active-connections]) (fn [{:keys [profile-id params] :as req} respond raise]
:messages (get-in metrics [:definitions :websocket-messages-total])
:sessions (get-in metrics [:definitions :websocket-session-timing])}]
(fn [{:keys [profile-id params] :as req}]
(let [params (us/conform ::handler-params params) (let [params (us/conform ::handler-params params)
file (retrieve-file pool (:file-id params)) file (retrieve-file pool (:file-id params))
cfg (-> (merge cfg params) cfg (-> (merge cfg params)
(assoc :profile-id profile-id) (assoc :profile-id profile-id)
(assoc :team-id (:team-id file)) (assoc :team-id (:team-id file)))]
(assoc ::ws/metrics metrics))]
(when-not profile-id (cond
(ex/raise :type :authentication (not profile-id)
(raise (ex/error :type :authentication
:hint "Authentication required.")) :hint "Authentication required."))
(when-not file (not file)
(ex/raise :type :not-found (raise (ex/error :type :not-found
:code :object-not-found)) :code :object-not-found))
(when-not (yws/upgrade-request? req)
(ex/raise :type :validation (not (yws/upgrade-request? req))
(raise (ex/error :type :validation
:code :websocket-request-expected :code :websocket-request-expected
:hint "this endpoint only accepts websocket connections")) :hint "this endpoint only accepts websocket connections"))
:else
(->> (ws/handler handle-message cfg) (->> (ws/handler handle-message cfg)
(yws/upgrade req)))))) (yws/upgrade req)
(respond))))))
(def ^:private (def ^:private
sql:retrieve-file sql:retrieve-file

View file

@ -24,6 +24,7 @@
[cuerdas.core :as str] [cuerdas.core :as str]
[integrant.core :as ig] [integrant.core :as ig]
[lambdaisland.uri :as u] [lambdaisland.uri :as u]
[promesa.core :as p]
[promesa.exec :as px])) [promesa.exec :as px]))
(defn parse-client-ip (defn parse-client-ip
@ -41,33 +42,26 @@
(defn clean-props (defn clean-props
[{:keys [profile-id] :as event}] [{:keys [profile-id] :as event}]
(letfn [(clean-common [props] (let [invalid-keys #{:session-id
(-> props :password
(dissoc :session-id) :old-password
(dissoc :password) :token}
(dissoc :old-password) xform (comp
(dissoc :token))) (remove (fn [kv]
(qualified-keyword? (first kv))))
(clean-profile-id [props] (remove (fn [kv]
(cond-> props (contains? invalid-keys (first kv))))
(= profile-id (:profile-id props)) (remove (fn [[k v]]
(dissoc :profile-id))) (and (= k :profile-id)
(= v profile-id))))
(clean-complex-data [props] (filter (fn [[_ v]]
(reduce-kv (fn [props k v]
(cond-> props
(or (string? v) (or (string? v)
(keyword? v)
(uuid? v) (uuid? v)
(boolean? v) (boolean? v)
(number? v)) (number? v)))))]
(assoc k v)
(keyword? v) (update event :props #(into {} xform %))))
(assoc k (name v))))
{}
props))]
(update event :props #(-> % clean-common clean-profile-id clean-complex-data))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HTTP Handler ;; HTTP Handler
@ -82,30 +76,48 @@
(s/def ::timestamp dt/instant?) (s/def ::timestamp dt/instant?)
(s/def ::context (s/map-of ::us/keyword any?)) (s/def ::context (s/map-of ::us/keyword any?))
(s/def ::event (s/def ::frontend-event
(s/keys :req-un [::type ::name ::props ::timestamp ::profile-id] (s/keys :req-un [::type ::name ::props ::timestamp ::profile-id]
:opt-un [::context])) :opt-un [::context]))
(s/def ::events (s/every ::event)) (s/def ::frontend-events (s/every ::frontend-event))
(defmethod ig/init-key ::http-handler (defmethod ig/init-key ::http-handler
[_ {:keys [executor] :as cfg}] [_ {:keys [executor pool] :as cfg}]
(fn [{:keys [params profile-id] :as request}] (if (or (db/read-only? pool) (not (contains? cf/flags :audit-log)))
(when (contains? cf/flags :audit-log) (do
(l/warn :hint "audit log http handler disabled or db is read-only")
(fn [_ respond _]
(respond {:status 204 :body ""})))
(letfn [(handler [{:keys [params profile-id] :as request}]
(let [events (->> (:events params) (let [events (->> (:events params)
(remove #(not= profile-id (:profile-id %))) (remove #(not= profile-id (:profile-id %)))
(us/conform ::events)) (us/conform ::frontend-events))
ip-addr (parse-client-ip request) ip-addr (parse-client-ip request)
cfg (-> cfg cfg (-> cfg
(assoc :source "frontend") (assoc :source "frontend")
(assoc :events events) (assoc :events events)
(assoc :ip-addr ip-addr))] (assoc :ip-addr ip-addr))]
(px/run! executor #(persist-http-events cfg)))) (persist-http-events cfg)))
{:status 204 :body ""}))
(handle-error [cause]
(let [xdata (ex-data cause)]
(if (= :spec-validation (:code xdata))
(l/error ::l/raw (str "spec validation on persist-events:\n" (us/pretty-explain xdata)))
(l/error :hint "error on persist-events" :cause cause))))]
(fn [request respond _]
;; Fire and forget, log error in case of errro
(-> (px/submit! executor #(handler request))
(p/catch handle-error))
(respond {:status 204 :body ""})))))
(defn- persist-http-events (defn- persist-http-events
[{:keys [pool events ip-addr source] :as cfg}] [{:keys [pool events ip-addr source] :as cfg}]
(try
(let [columns [:id :name :source :type :tracked-at :profile-id :ip-addr :props :context] (let [columns [:id :name :source :type :tracked-at :profile-id :ip-addr :props :context]
prepare-xf (map (fn [event] prepare-xf (map (fn [event]
[(uuid/next) [(uuid/next)
@ -116,18 +128,10 @@
(:profile-id event) (:profile-id event)
(db/inet ip-addr) (db/inet ip-addr)
(db/tjson (:props event)) (db/tjson (:props event))
(db/tjson (d/without-nils (:context event)))])) (db/tjson (d/without-nils (:context event)))]))]
events (us/conform ::events events)]
(when (seq events) (when (seq events)
(->> (into [] prepare-xf events) (->> (into [] prepare-xf events)
(db/insert-multi! pool :audit-log columns)))) (db/insert-multi! pool :audit-log columns)))))
(catch Throwable e
(let [xdata (ex-data e)]
(if (= :spec-validation (:code xdata))
(l/error ::l/raw (str "spec validation on persist-events:\n"
(:explain xdata)))
(l/error :hint "error on persist-events"
:cause e))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Collector ;; Collector
@ -142,41 +146,57 @@
(defmethod ig/pre-init-spec ::collector [_] (defmethod ig/pre-init-spec ::collector [_]
(s/keys :req-un [::db/pool ::wrk/executor])) (s/keys :req-un [::db/pool ::wrk/executor]))
(def event-xform (s/def ::ip-addr string?)
(s/def ::backend-event
(s/keys :req-un [::type ::name ::profile-id]
:opt-un [::ip-addr ::props]))
(def ^:private backend-event-xform
(comp (comp
(filter :profile-id) (filter #(us/valid? ::backend-event %))
(map clean-props))) (map clean-props)))
(defmethod ig/init-key ::collector (defmethod ig/init-key ::collector
[_ cfg] [_ {:keys [pool] :as cfg}]
(when (contains? cf/flags :audit-log) (cond
(l/info :msg "initializing audit log collector") (not (contains? cf/flags :audit-log))
(let [input (a/chan 512 event-xform) (do
(l/info :hint "audit log collection disabled")
(constantly nil))
(db/read-only? pool)
(do
(l/warn :hint "audit log collection disabled, db is read-only")
(constantly nil))
:else
(let [input (a/chan 512 backend-event-xform)
buffer (aa/batch input {:max-batch-size 100 buffer (aa/batch input {:max-batch-size 100
:max-batch-age (* 10 1000) ; 10s :max-batch-age (* 10 1000) ; 10s
:init []})] :init []})]
(l/info :hint "audit log collector initialized")
(a/go-loop [] (a/go-loop []
(when-let [[_type events] (a/<! buffer)] (when-let [[_type events] (a/<! buffer)]
(let [res (a/<! (persist-events cfg events))] (let [res (a/<! (persist-events cfg events))]
(when (ex/exception? res) (when (ex/exception? res)
(l/error :hint "error on persisting events" (l/error :hint "error on persisting events" :cause res))
:cause res))) (recur))))
(recur)))
(fn [& {:keys [cmd] :as params}] (fn [& {:keys [cmd] :as params}]
(case cmd
:stop
(a/close! input)
:submit
(let [params (-> params (let [params (-> params
(dissoc :cmd) (dissoc :cmd)
(assoc :tracked-at (dt/now)))] (assoc :tracked-at (dt/now)))]
(case cmd (when-not (a/offer! input params)
:stop (a/close! input) (l/warn :hint "activity channel is full"))))))))
:submit (when-not (a/offer! input params)
(l/warn :msg "activity channel is full"))))))))
(defn- persist-events (defn- persist-events
[{:keys [pool executor] :as cfg} events] [{:keys [pool executor] :as cfg} events]
(letfn [(event->row [event] (letfn [(event->row [event]
(when (:profile-id event)
[(uuid/next) [(uuid/next)
(:name event) (:name event)
(:type event) (:type event)
@ -184,7 +204,7 @@
(:tracked-at event) (:tracked-at event)
(some-> (:ip-addr event) db/inet) (some-> (:ip-addr event) db/inet)
(db/tjson (:props event)) (db/tjson (:props event))
"backend"]))] "backend"])]
(aa/with-thread executor (aa/with-thread executor
(when (seq events) (when (seq events)
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
@ -217,6 +237,7 @@
(:enabled props false)) (:enabled props false))
uri (or uri (:uri props)) uri (or uri (:uri props))
cfg (assoc cfg :uri uri)] cfg (assoc cfg :uri uri)]
(when (and enabled (not uri)) (when (and enabled (not uri))
(ex/raise :type :internal (ex/raise :type :internal
:code :task-not-configured :code :task-not-configured

View file

@ -27,8 +27,9 @@
(defonce enabled (atom true)) (defonce enabled (atom true))
(defn- persist-on-database! (defn- persist-on-database!
[{:keys [pool]} {:keys [id] :as event}] [{:keys [pool] :as cfg} {:keys [id] :as event}]
(db/insert! pool :server-error-report {:id id :content (db/tjson event)})) (when-not (db/read-only? pool)
(db/insert! pool :server-error-report {:id id :content (db/tjson event)})))
(defn- parse-event-data (defn- parse-event-data
[event] [event]

View file

@ -17,11 +17,37 @@
{:uri (cf/get :database-uri) {:uri (cf/get :database-uri)
:username (cf/get :database-username) :username (cf/get :database-username)
:password (cf/get :database-password) :password (cf/get :database-password)
:read-only (cf/get :database-readonly false)
:metrics (ig/ref :app.metrics/metrics) :metrics (ig/ref :app.metrics/metrics)
:migrations (ig/ref :app.migrations/all) :migrations (ig/ref :app.migrations/all)
:name :main :name :main
:min-pool-size 0 :min-size (cf/get :database-min-pool-size 0)
:max-pool-size 60} :max-size (cf/get :database-max-pool-size 30)}
;; Default thread pool for IO operations
[::default :app.worker/executor]
{:parallelism (cf/get :default-executor-parallelism 60)
:prefix :default}
;; Constrained thread pool. Should only be used from high demand
;; RPC methods.
[::blocking :app.worker/executor]
{:parallelism (cf/get :blocking-executor-parallelism 20)
:prefix :blocking}
;; Dedicated thread pool for backround tasks execution.
[::worker :app.worker/executor]
{:parallelism (cf/get :worker-executor-parallelism 10)
:prefix :worker}
:app.worker/executors
{:default (ig/ref [::default :app.worker/executor])
:worker (ig/ref [::worker :app.worker/executor])
:blocking (ig/ref [::blocking :app.worker/executor])}
:app.worker/executors-monitor
{:metrics (ig/ref :app.metrics/metrics)
:executors (ig/ref :app.worker/executors)}
:app.migrations/migrations :app.migrations/migrations
{} {}
@ -32,7 +58,6 @@
:app.migrations/all :app.migrations/all
{:main (ig/ref :app.migrations/migrations)} {:main (ig/ref :app.migrations/migrations)}
:app.msgbus/msgbus :app.msgbus/msgbus
{:backend (cf/get :msgbus-backend :redis) {:backend (cf/get :msgbus-backend :redis)
:redis-uri (cf/get :redis-uri)} :redis-uri (cf/get :redis-uri)}
@ -48,10 +73,6 @@
:app.storage/gc-touched-task :app.storage/gc-touched-task
{:pool (ig/ref :app.db/pool)} {:pool (ig/ref :app.db/pool)}
:app.storage/recheck-task
{:pool (ig/ref :app.db/pool)
:storage (ig/ref :app.storage/storage)}
:app.http.session/session :app.http.session/session
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:tokens (ig/ref :app.tokens/tokens)} :tokens (ig/ref :app.tokens/tokens)}
@ -63,7 +84,7 @@
:app.http.session/updater :app.http.session/updater
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:metrics (ig/ref :app.metrics/metrics) :metrics (ig/ref :app.metrics/metrics)
:executor (ig/ref :app.worker/executor) :executor (ig/ref [::worker :app.worker/executor])
:session (ig/ref :app.http.session/session) :session (ig/ref :app.http.session/session)
:max-batch-age (cf/get :http-session-updater-batch-max-age) :max-batch-age (cf/get :http-session-updater-batch-max-age)
:max-batch-size (cf/get :http-session-updater-batch-max-size)} :max-batch-size (cf/get :http-session-updater-batch-max-size)}
@ -76,7 +97,10 @@
{:port (cf/get :http-server-port) {:port (cf/get :http-server-port)
:host (cf/get :http-server-host) :host (cf/get :http-server-host)
:router (ig/ref :app.http/router) :router (ig/ref :app.http/router)
:metrics (ig/ref :app.metrics/metrics)} :metrics (ig/ref :app.metrics/metrics)
:max-threads (cf/get :http-server-max-threads)
:min-threads (cf/get :http-server-min-threads)}
:app.http/router :app.http/router
{:assets (ig/ref :app.http.assets/handlers) {:assets (ig/ref :app.http.assets/handlers)
@ -94,11 +118,11 @@
:rpc (ig/ref :app.rpc/rpc)} :rpc (ig/ref :app.rpc/rpc)}
:app.http.debug/handlers :app.http.debug/handlers
{:pool (ig/ref :app.db/pool)} {:pool (ig/ref :app.db/pool)
:executor (ig/ref [::default :app.worker/executor])}
:app.http.websocket/handler :app.http.websocket/handler
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor)
:metrics (ig/ref :app.metrics/metrics) :metrics (ig/ref :app.metrics/metrics)
:msgbus (ig/ref :app.msgbus/msgbus)} :msgbus (ig/ref :app.msgbus/msgbus)}
@ -106,11 +130,13 @@
{:metrics (ig/ref :app.metrics/metrics) {:metrics (ig/ref :app.metrics/metrics)
:assets-path (cf/get :assets-path) :assets-path (cf/get :assets-path)
:storage (ig/ref :app.storage/storage) :storage (ig/ref :app.storage/storage)
:executor (ig/ref [::default :app.worker/executor])
:cache-max-age (dt/duration {:hours 24}) :cache-max-age (dt/duration {:hours 24})
:signature-max-age (dt/duration {:hours 24 :minutes 5})} :signature-max-age (dt/duration {:hours 24 :minutes 5})}
:app.http.feedback/handler :app.http.feedback/handler
{:pool (ig/ref :app.db/pool)} {:pool (ig/ref :app.db/pool)
:executor (ig/ref [::default :app.worker/executor])}
:app.http.oauth/handler :app.http.oauth/handler
{:rpc (ig/ref :app.rpc/rpc) {:rpc (ig/ref :app.rpc/rpc)
@ -118,6 +144,7 @@
:pool (ig/ref :app.db/pool) :pool (ig/ref :app.db/pool)
:tokens (ig/ref :app.tokens/tokens) :tokens (ig/ref :app.tokens/tokens)
:audit (ig/ref :app.loggers.audit/collector) :audit (ig/ref :app.loggers.audit/collector)
:executor (ig/ref [::default :app.worker/executor])
:public-uri (cf/get :public-uri)} :public-uri (cf/get :public-uri)}
:app.rpc/rpc :app.rpc/rpc
@ -128,22 +155,17 @@
:storage (ig/ref :app.storage/storage) :storage (ig/ref :app.storage/storage)
:msgbus (ig/ref :app.msgbus/msgbus) :msgbus (ig/ref :app.msgbus/msgbus)
:public-uri (cf/get :public-uri) :public-uri (cf/get :public-uri)
:audit (ig/ref :app.loggers.audit/collector)} :audit (ig/ref :app.loggers.audit/collector)
:executors (ig/ref :app.worker/executors)}
:app.worker/executor
{:min-threads 0
:max-threads 256
:idle-timeout 60000
:name :worker}
:app.worker/worker :app.worker/worker
{:executor (ig/ref :app.worker/executor) {:executor (ig/ref [::worker :app.worker/executor])
:tasks (ig/ref :app.worker/registry) :tasks (ig/ref :app.worker/registry)
:metrics (ig/ref :app.metrics/metrics) :metrics (ig/ref :app.metrics/metrics)
:pool (ig/ref :app.db/pool)} :pool (ig/ref :app.db/pool)}
:app.worker/scheduler :app.worker/scheduler
{:executor (ig/ref :app.worker/executor) {:executor (ig/ref [::worker :app.worker/executor])
:tasks (ig/ref :app.worker/registry) :tasks (ig/ref :app.worker/registry)
:pool (ig/ref :app.db/pool) :pool (ig/ref :app.db/pool)
:schedule :schedule
@ -162,9 +184,6 @@
{:cron #app/cron "0 0 0 * * ?" ;; daily {:cron #app/cron "0 0 0 * * ?" ;; daily
:task :session-gc} :task :session-gc}
{:cron #app/cron "0 0 * * * ?" ;; hourly
:task :storage-recheck}
{:cron #app/cron "0 0 0 * * ?" ;; daily {:cron #app/cron "0 0 0 * * ?" ;; daily
:task :objects-gc} :task :objects-gc}
@ -197,7 +216,6 @@
:file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler) :file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler)
:storage-deleted-gc (ig/ref :app.storage/gc-deleted-task) :storage-deleted-gc (ig/ref :app.storage/gc-deleted-task)
:storage-touched-gc (ig/ref :app.storage/gc-touched-task) :storage-touched-gc (ig/ref :app.storage/gc-touched-task)
:storage-recheck (ig/ref :app.storage/recheck-task)
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler) :tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
:telemetry (ig/ref :app.tasks.telemetry/handler) :telemetry (ig/ref :app.tasks.telemetry/handler)
:session-gc (ig/ref :app.http.session/gc-task) :session-gc (ig/ref :app.http.session/gc-task)
@ -261,11 +279,11 @@
:app.loggers.audit/http-handler :app.loggers.audit/http-handler
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor)} :executor (ig/ref [::worker :app.worker/executor])}
:app.loggers.audit/collector :app.loggers.audit/collector
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor)} :executor (ig/ref [::worker :app.worker/executor])}
:app.loggers.audit/archive-task :app.loggers.audit/archive-task
{:uri (cf/get :audit-log-archive-uri) {:uri (cf/get :audit-log-archive-uri)
@ -279,36 +297,26 @@
:app.loggers.loki/reporter :app.loggers.loki/reporter
{:uri (cf/get :loggers-loki-uri) {:uri (cf/get :loggers-loki-uri)
:receiver (ig/ref :app.loggers.zmq/receiver) :receiver (ig/ref :app.loggers.zmq/receiver)
:executor (ig/ref :app.worker/executor)} :executor (ig/ref [::worker :app.worker/executor])}
:app.loggers.mattermost/reporter :app.loggers.mattermost/reporter
{:uri (cf/get :error-report-webhook) {:uri (cf/get :error-report-webhook)
:receiver (ig/ref :app.loggers.zmq/receiver) :receiver (ig/ref :app.loggers.zmq/receiver)
:pool (ig/ref :app.db/pool) :pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor)} :executor (ig/ref [::worker :app.worker/executor])}
:app.loggers.database/reporter :app.loggers.database/reporter
{:receiver (ig/ref :app.loggers.zmq/receiver) {:receiver (ig/ref :app.loggers.zmq/receiver)
:pool (ig/ref :app.db/pool) :pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor)} :executor (ig/ref [::worker :app.worker/executor])}
:app.loggers.sentry/reporter
{:dsn (cf/get :sentry-dsn)
:trace-sample-rate (cf/get :sentry-trace-sample-rate 1.0)
:attach-stack-trace (cf/get :sentry-attach-stack-trace false)
:debug (cf/get :sentry-debug false)
:receiver (ig/ref :app.loggers.zmq/receiver)
:pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor)}
:app.storage/storage :app.storage/storage
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor) :backends
{:assets-s3 (ig/ref [::assets :app.storage.s3/backend])
:backends {
:assets-s3 (ig/ref [::assets :app.storage.s3/backend])
:assets-db (ig/ref [::assets :app.storage.db/backend]) :assets-db (ig/ref [::assets :app.storage.db/backend])
:assets-fs (ig/ref [::assets :app.storage.fs/backend]) :assets-fs (ig/ref [::assets :app.storage.fs/backend])
:tmp (ig/ref [::tmp :app.storage.fs/backend]) :tmp (ig/ref [::tmp :app.storage.fs/backend])
:fdata-s3 (ig/ref [::fdata :app.storage.s3/backend]) :fdata-s3 (ig/ref [::fdata :app.storage.s3/backend])
@ -319,10 +327,12 @@
[::fdata :app.storage.s3/backend] [::fdata :app.storage.s3/backend]
{:region (cf/get :storage-fdata-s3-region) {:region (cf/get :storage-fdata-s3-region)
:bucket (cf/get :storage-fdata-s3-bucket) :bucket (cf/get :storage-fdata-s3-bucket)
:endpoint (cf/get :storage-fdata-s3-endpoint)
:prefix (cf/get :storage-fdata-s3-prefix)} :prefix (cf/get :storage-fdata-s3-prefix)}
[::assets :app.storage.s3/backend] [::assets :app.storage.s3/backend]
{:region (cf/get :storage-assets-s3-region) {:region (cf/get :storage-assets-s3-region)
:endpoint (cf/get :storage-assets-s3-endpoint)
:bucket (cf/get :storage-assets-s3-bucket)} :bucket (cf/get :storage-assets-s3-bucket)}
[::assets :app.storage.fs/backend] [::assets :app.storage.fs/backend]

View file

@ -326,8 +326,10 @@
(defn configure-assets-storage (defn configure-assets-storage
"Given storage map, returns a storage configured with the appropriate "Given storage map, returns a storage configured with the appropriate
backend for assets." backend for assets."
[storage conn] ([storage]
(assoc storage :backend (cf/get :assets-storage-backend :assets-fs)))
([storage conn]
(-> storage (-> storage
(assoc :conn conn) (assoc :conn conn)
(assoc :backend (cf/get :assets-storage-backend :assets-fs)))) (assoc :backend (cf/get :assets-storage-backend :assets-fs)))))

View file

@ -5,46 +5,40 @@
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.metrics (ns app.metrics
(:refer-clojure :exclude [run!])
(:require (:require
[app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig]) [integrant.core :as ig])
(:import (:import
io.prometheus.client.CollectorRegistry io.prometheus.client.CollectorRegistry
io.prometheus.client.Counter io.prometheus.client.Counter
io.prometheus.client.Counter$Child
io.prometheus.client.Gauge io.prometheus.client.Gauge
io.prometheus.client.Gauge$Child
io.prometheus.client.Summary io.prometheus.client.Summary
io.prometheus.client.Summary$Child
io.prometheus.client.Summary$Builder
io.prometheus.client.Histogram io.prometheus.client.Histogram
io.prometheus.client.Histogram$Child
io.prometheus.client.exporter.common.TextFormat io.prometheus.client.exporter.common.TextFormat
io.prometheus.client.hotspot.DefaultExports io.prometheus.client.hotspot.DefaultExports
io.prometheus.client.jetty.JettyStatisticsCollector io.prometheus.client.jetty.JettyStatisticsCollector
org.eclipse.jetty.server.handler.StatisticsHandler org.eclipse.jetty.server.handler.StatisticsHandler
java.io.StringWriter)) java.io.StringWriter))
(declare instrument-vars!) (set! *warn-on-reflection* true)
(declare instrument)
(declare create-registry) (declare create-registry)
(declare create) (declare create)
(declare handler) (declare handler)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Defaults ;; METRICS SERVICE PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def default-metrics (def default-metrics
{:profile-register {:update-file-changes
{:name "actions_profile_register_count"
:help "A global counter of user registrations."
:type :counter}
:profile-activation
{:name "actions_profile_activation_count"
:help "A global counter of profile activations"
:type :counter}
:update-file-changes
{:name "rpc_update_file_changes_total" {:name "rpc_update_file_changes_total"
:help "A total number of changes submitted to update-file." :help "A total number of changes submitted to update-file."
:type :counter} :type :counter}
@ -54,6 +48,18 @@
:help "A total number of bytes processed by update-file." :help "A total number of bytes processed by update-file."
:type :counter} :type :counter}
:rpc-mutation-timing
{:name "rpc_mutation_timing"
:help "RPC mutation method call timming."
:labels ["name"]
:type :histogram}
:rpc-query-timing
{:name "rpc_query_timing"
:help "RPC query method call timing."
:labels ["name"]
:type :histogram}
:websocket-active-connections :websocket-active-connections
{:name "websocket_active_connections" {:name "websocket_active_connections"
:help "Active websocket connections gauge" :help "Active websocket connections gauge"
@ -68,12 +74,60 @@
:websocket-session-timing :websocket-session-timing
{:name "websocket_session_timing" {:name "websocket_session_timing"
:help "Websocket session timing (seconds)." :help "Websocket session timing (seconds)."
:quantiles [] :type :summary}
:type :summary}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; :session-update-total
;; Entry Point {:name "http_session_update_total"
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; :help "A counter of session update batch events."
:type :counter}
:tasks-timing
{:name "penpot_tasks_timing"
:help "Background tasks timing (milliseconds)."
:labels ["name"]
:type :summary}
:rlimit-queued-submissions
{:name "penpot_rlimit_queued_submissions"
:help "Current number of queued submissions on RLIMIT."
:labels ["name"]
:type :gauge}
:rlimit-used-permits
{:name "penpot_rlimit_used_permits"
:help "Current number of used permits on RLIMIT."
:labels ["name"]
:type :gauge}
:rlimit-acquires-total
{:name "penpot_rlimit_acquires_total"
:help "Total number of acquire operations on RLIMIT."
:labels ["name"]
:type :counter}
:executors-active-threads
{:name "penpot_executors_active_threads"
:help "Current number of threads available in the executor service."
:labels ["name"]
:type :gauge}
:executors-completed-tasks
{:name "penpot_executors_completed_tasks_total"
:help "Aproximate number of completed tasks by the executor."
:labels ["name"]
:type :counter}
:executors-running-threads
{:name "penpot_executors_running_threads"
:help "Current number of threads with state RUNNING."
:labels ["name"]
:type :gauge}
:executors-queued-submissions
{:name "penpot_executors_queued_submissions"
:help "Current number of queued submissions."
:labels ["name"]
:type :gauge}})
(defmethod ig/init-key ::metrics (defmethod ig/init-key ::metrics
[_ _] [_ _]
@ -95,31 +149,44 @@
(s/keys :req-un [::registry ::handler])) (s/keys :req-un [::registry ::handler]))
(defn- handler (defn- handler
[registry _request] [registry _ respond _]
(let [samples (.metricFamilySamples ^CollectorRegistry registry) (let [samples (.metricFamilySamples ^CollectorRegistry registry)
writer (StringWriter.)] writer (StringWriter.)]
(TextFormat/write004 writer samples) (TextFormat/write004 writer samples)
{:headers {"content-type" TextFormat/CONTENT_TYPE_004} (respond {:headers {"content-type" TextFormat/CONTENT_TYPE_004}
:body (.toString writer)})) :body (.toString writer)})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Implementation ;; Implementation
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def default-empty-labels (into-array String []))
(def default-quantiles
[[0.5 0.01]
[0.90 0.01]
[0.99 0.001]])
(def default-histogram-buckets
[1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500])
(defn run!
[{:keys [definitions]} {:keys [id] :as params}]
(when-let [mobj (get definitions id)]
((::fn mobj) params)
true))
(defn create-registry (defn create-registry
[] []
(let [registry (CollectorRegistry.)] (let [registry (CollectorRegistry.)]
(DefaultExports/register registry) (DefaultExports/register registry)
registry)) registry))
(defmacro with-measure (defn- is-array?
[& {:keys [expr cb]}] [o]
`(let [start# (System/nanoTime) (let [oc (class o)]
tdown# ~cb] (and (.isArray ^Class oc)
(try (= (.getComponentType oc) String))))
~expr
(finally
(tdown# (/ (- (System/nanoTime) start#) 1000000))))))
(defn make-counter (defn make-counter
[{:keys [name help registry reg labels] :as props}] [{:keys [name help registry reg labels] :as props}]
@ -132,12 +199,9 @@
instance (.register instance registry)] instance (.register instance registry)]
{::instance instance {::instance instance
::fn (fn [{:keys [by labels] :or {by 1}}] ::fn (fn [{:keys [inc labels] :or {inc 1 labels default-empty-labels}}]
(if labels (let [instance (.labels instance (if (is-array? labels) labels (into-array String labels)))]
(.. ^Counter instance (.inc ^Counter$Child instance (double inc))))}))
(labels (into-array String labels))
(inc by))
(.inc ^Counter instance by)))}))
(defn make-gauge (defn make-gauge
[{:keys [name help registry reg labels] :as props}] [{:keys [name help registry reg labels] :as props}]
@ -148,48 +212,33 @@
_ (when (seq labels) _ (when (seq labels)
(.labelNames instance (into-array String labels))) (.labelNames instance (into-array String labels)))
instance (.register instance registry)] instance (.register instance registry)]
{::instance instance {::instance instance
::fn (fn [{:keys [cmd by labels] :or {by 1}}] ::fn (fn [{:keys [inc dec labels val] :or {labels default-empty-labels}}]
(if labels (let [instance (.labels ^Gauge instance (if (is-array? labels) labels (into-array String labels)))]
(let [labels (into-array String [labels])] (cond (number? inc) (.inc ^Gauge$Child instance (double inc))
(case cmd (number? dec) (.dec ^Gauge$Child instance (double dec))
:inc (.. ^Gauge instance (labels labels) (inc by)) (number? val) (.set ^Gauge$Child instance (double val)))))}))
:dec (.. ^Gauge instance (labels labels) (dec by))))
(case cmd
:inc (.inc ^Gauge instance by)
:dec (.dec ^Gauge instance by))))}))
(def default-quantiles
[[0.75 0.02]
[0.99 0.001]])
(defn make-summary (defn make-summary
[{:keys [name help registry reg labels max-age quantiles buckets] [{:keys [name help registry reg labels max-age quantiles buckets]
:or {max-age 3600 buckets 6 quantiles default-quantiles} :as props}] :or {max-age 3600 buckets 12 quantiles default-quantiles} :as props}]
(let [registry (or registry reg) (let [registry (or registry reg)
instance (doto (Summary/build) builder (doto (Summary/build)
(.name name) (.name name)
(.help help)) (.help help))
_ (when (seq quantiles) _ (when (seq quantiles)
(.maxAgeSeconds ^Summary instance max-age) (.maxAgeSeconds ^Summary$Builder builder ^long max-age)
(.ageBuckets ^Summary instance buckets)) (.ageBuckets ^Summary$Builder builder buckets))
_ (doseq [[q e] quantiles] _ (doseq [[q e] quantiles]
(.quantile ^Summary instance q e)) (.quantile ^Summary$Builder builder q e))
_ (when (seq labels) _ (when (seq labels)
(.labelNames instance (into-array String labels))) (.labelNames ^Summary$Builder builder (into-array String labels)))
instance (.register instance registry)] instance (.register ^Summary$Builder builder registry)]
{::instance instance {::instance instance
::fn (fn [{:keys [val labels]}] ::fn (fn [{:keys [val labels] :or {labels default-empty-labels}}]
(if labels (let [instance (.labels ^Summary instance (if (is-array? labels) labels (into-array String labels)))]
(.. ^Summary instance (.observe ^Summary$Child instance val)))}))
(labels (into-array String labels))
(observe val))
(.observe ^Summary instance val)))}))
(def default-histogram-buckets
[1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500])
(defn make-histogram (defn make-histogram
[{:keys [name help registry reg labels buckets] [{:keys [name help registry reg labels buckets]
@ -204,12 +253,9 @@
instance (.register instance registry)] instance (.register instance registry)]
{::instance instance {::instance instance
::fn (fn [{:keys [val labels]}] ::fn (fn [{:keys [val labels] :or {labels default-empty-labels}}]
(if labels (let [instance (.labels ^Histogram instance (if (is-array? labels) labels (into-array String labels)))]
(.. ^Histogram instance (.observe ^Histogram$Child instance val)))}))
(labels (into-array String labels))
(observe val))
(.observe ^Histogram instance val)))}))
(defn create (defn create
[{:keys [type] :as props}] [{:keys [type] :as props}]
@ -219,114 +265,6 @@
:summary (make-summary props) :summary (make-summary props)
:histogram (make-histogram props))) :histogram (make-histogram props)))
(defn wrap-counter
([rootf mobj]
(let [mdata (meta rootf)
origf (::original mdata rootf)]
(with-meta
(fn
([a]
((::fn mobj) nil)
(origf a))
([a b]
((::fn mobj) nil)
(origf a b))
([a b c]
((::fn mobj) nil)
(origf a b c))
([a b c d]
((::fn mobj) nil)
(origf a b c d))
([a b c d & more]
((::fn mobj) nil)
(apply origf a b c d more)))
(assoc mdata ::original origf))))
([rootf mobj labels]
(let [mdata (meta rootf)
origf (::original mdata rootf)]
(with-meta
(fn
([a]
((::fn mobj) {:labels labels})
(origf a))
([a b]
((::fn mobj) {:labels labels})
(origf a b))
([a b & more]
((::fn mobj) {:labels labels})
(apply origf a b more)))
(assoc mdata ::original origf)))))
(defn wrap-summary
([rootf mobj]
(let [mdata (meta rootf)
origf (::original mdata rootf)]
(with-meta
(fn
([a]
(with-measure
:expr (origf a)
:cb #((::fn mobj) {:val %})))
([a b]
(with-measure
:expr (origf a b)
:cb #((::fn mobj) {:val %})))
([a b & more]
(with-measure
:expr (apply origf a b more)
:cb #((::fn mobj) {:val %}))))
(assoc mdata ::original origf))))
([rootf mobj labels]
(let [mdata (meta rootf)
origf (::original mdata rootf)]
(with-meta
(fn
([a]
(with-measure
:expr (origf a)
:cb #((::fn mobj) {:val % :labels labels})))
([a b]
(with-measure
:expr (origf a b)
:cb #((::fn mobj) {:val % :labels labels})))
([a b & more]
(with-measure
:expr (apply origf a b more)
:cb #((::fn mobj) {:val % :labels labels}))))
(assoc mdata ::original origf)))))
(defn instrument-vars!
[vars {:keys [wrap] :as props}]
(let [obj (create props)]
(cond
(instance? Counter (::instance obj))
(doseq [var vars]
(alter-var-root var (or wrap wrap-counter) obj))
(instance? Summary (::instance obj))
(doseq [var vars]
(alter-var-root var (or wrap wrap-summary) obj))
:else
(ex/raise :type :not-implemented))))
(defn instrument
[f {:keys [wrap] :as props}]
(let [obj (create props)]
(cond
(instance? Counter (::instance obj))
((or wrap wrap-counter) f obj)
(instance? Summary (::instance obj))
((or wrap wrap-summary) f obj)
(instance? Histogram (::instance obj))
((or wrap wrap-summary) f obj)
:else
(ex/raise :type :not-implemented))))
(defn instrument-jetty! (defn instrument-jetty!
[^CollectorRegistry registry ^StatisticsHandler handler] [^CollectorRegistry registry ^StatisticsHandler handler]
(doto (JettyStatisticsCollector. handler) (doto (JettyStatisticsCollector. handler)

View file

@ -205,6 +205,9 @@
{:name "0065-add-trivial-spelling-fixes" {:name "0065-add-trivial-spelling-fixes"
:fn (mg/resource "app/migrations/sql/0065-add-trivial-spelling-fixes.sql")} :fn (mg/resource "app/migrations/sql/0065-add-trivial-spelling-fixes.sql")}
{:name "0066-add-frame-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0066-add-frame-thumbnail-table.sql")}
]) ])

View file

@ -0,0 +1,10 @@
CREATE TABLE file_frame_thumbnail (
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE,
frame_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
data text NULL,
PRIMARY KEY(file_id, frame_id)
);

View file

@ -18,7 +18,6 @@
[integrant.core :as ig] [integrant.core :as ig]
[promesa.core :as p]) [promesa.core :as p])
(:import (:import
java.time.Duration
io.lettuce.core.RedisClient io.lettuce.core.RedisClient
io.lettuce.core.RedisURI io.lettuce.core.RedisURI
io.lettuce.core.api.StatefulConnection io.lettuce.core.api.StatefulConnection
@ -29,7 +28,10 @@
io.lettuce.core.codec.StringCodec io.lettuce.core.codec.StringCodec
io.lettuce.core.pubsub.RedisPubSubListener io.lettuce.core.pubsub.RedisPubSubListener
io.lettuce.core.pubsub.StatefulRedisPubSubConnection io.lettuce.core.pubsub.StatefulRedisPubSubConnection
io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands)) io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands
io.lettuce.core.resource.ClientResources
io.lettuce.core.resource.DefaultClientResources
java.time.Duration))
(def ^:private prefix (cfg/get :tenant)) (def ^:private prefix (cfg/get :tenant))
@ -136,12 +138,18 @@
(declare impl-redis-sub) (declare impl-redis-sub)
(declare impl-redis-unsub) (declare impl-redis-unsub)
(defmethod init-backend :redis (defmethod init-backend :redis
[{:keys [redis-uri] :as cfg}] [{:keys [redis-uri] :as cfg}]
(let [codec (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE) (let [codec (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE)
resources (.. (DefaultClientResources/builder)
(ioThreadPoolSize 4)
(computationThreadPoolSize 4)
(build))
uri (RedisURI/create redis-uri) uri (RedisURI/create redis-uri)
rclient (RedisClient/create ^RedisURI uri) rclient (RedisClient/create ^ClientResources resources ^RedisURI uri)
pub-conn (.connect ^RedisClient rclient ^RedisCodec codec) pub-conn (.connect ^RedisClient rclient ^RedisCodec codec)
sub-conn (.connectPubSub ^RedisClient rclient ^RedisCodec codec)] sub-conn (.connectPubSub ^RedisClient rclient ^RedisCodec codec)]
@ -150,13 +158,15 @@
(.setTimeout ^StatefulRedisPubSubConnection sub-conn ^Duration (dt/duration {:seconds 10})) (.setTimeout ^StatefulRedisPubSubConnection sub-conn ^Duration (dt/duration {:seconds 10}))
(-> cfg (-> cfg
(assoc ::resources resources)
(assoc ::pub-conn pub-conn) (assoc ::pub-conn pub-conn)
(assoc ::sub-conn sub-conn)))) (assoc ::sub-conn sub-conn))))
(defmethod stop-backend :redis (defmethod stop-backend :redis
[{:keys [::pub-conn ::sub-conn] :as cfg}] [{:keys [::pub-conn ::sub-conn ::resources] :as cfg}]
(.close ^StatefulRedisConnection pub-conn) (.close ^StatefulRedisConnection pub-conn)
(.close ^StatefulRedisPubSubConnection sub-conn)) (.close ^StatefulRedisPubSubConnection sub-conn)
(.shutdown ^ClientResources resources))
(defmethod init-pub-loop :redis (defmethod init-pub-loop :redis
[{:keys [::pub-conn ::pub-ch]}] [{:keys [::pub-conn ::pub-ch]}]

View file

@ -13,25 +13,42 @@
[app.db :as db] [app.db :as db]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.metrics :as mtx] [app.metrics :as mtx]
[app.util.retry :as retry] [app.rpc.retry :as retry]
[app.util.rlimit :as rlimit] [app.rpc.rlimit :as rlimit]
[app.util.async :as async]
[app.util.services :as sv] [app.util.services :as sv]
[app.worker :as wrk]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig])) [integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]))
(defn- default-handler (defn- default-handler
[_] [_]
(ex/raise :type :not-found)) (p/rejected (ex/error :type :not-found)))
(defn- run-hook (defn- handle-response-transformation
[hook-fn response] [response request mdata]
(ex/ignoring (hook-fn)) (if-let [transform-fn (:transform-response mdata)]
(transform-fn request response)
response))
(defn- handle-before-comple-hook
[response mdata]
(when-let [hook-fn (:before-complete mdata)]
(ex/ignoring (hook-fn)))
response) response)
(defn- rpc-query-handler (defn- rpc-query-handler
[methods {:keys [profile-id session-id] :as request}] "Ring handler that dispatches query requests and convert between
(let [type (keyword (get-in request [:path-params :type])) internal async flow into ring async flow."
[methods {:keys [profile-id session-id] :as request} respond raise]
(letfn [(handle-response [result]
(let [mdata (meta result)]
(-> {:status 200 :body result}
(handle-response-transformation request mdata))))]
(let [type (keyword (get-in request [:path-params :type]))
data (merge (:params request) data (merge (:params request)
(:body-params request) (:body-params request)
(:uploads request) (:uploads request)
@ -41,15 +58,24 @@
(assoc data :profile-id profile-id ::session-id session-id) (assoc data :profile-id profile-id ::session-id session-id)
(dissoc data :profile-id)) (dissoc data :profile-id))
result ((get methods type default-handler) data) ;; Get the method from methods registry and if method does
mdata (meta result)] ;; not exists asigns it to the default handler.
method (get methods type default-handler)]
(cond->> {:status 200 :body result} (-> (method data)
(fn? (:transform-response mdata)) (p/then #(respond (handle-response %)))
((:transform-response mdata) request)))) (p/catch raise)))))
(defn- rpc-mutation-handler (defn- rpc-mutation-handler
[methods {:keys [profile-id session-id] :as request}] "Ring handler that dispatches mutation requests and convert between
internal async flow into ring async flow."
[methods {:keys [profile-id session-id] :as request} respond raise]
(letfn [(handle-response [result]
(let [mdata (meta result)]
(-> {:status 200 :body result}
(handle-response-transformation request mdata)
(handle-before-comple-hook mdata))))]
(let [type (keyword (get-in request [:path-params :type])) (let [type (keyword (get-in request [:path-params :type]))
data (merge (:params request) data (merge (:params request)
(:body-params request) (:body-params request)
@ -60,51 +86,61 @@
(assoc data :profile-id profile-id ::session-id session-id) (assoc data :profile-id profile-id ::session-id session-id)
(dissoc data :profile-id)) (dissoc data :profile-id))
result ((get methods type default-handler) data) method (get methods type default-handler)]
mdata (meta result)]
(cond->> {:status 200 :body result}
(fn? (:transform-response mdata))
((:transform-response mdata) request)
(fn? (:before-complete mdata)) (-> (method data)
(run-hook (:before-complete mdata))))) (p/then #(respond (handle-response %)))
(p/catch raise)))))
(defn- wrap-with-metrics (defn- wrap-metrics
[cfg f mdata] "Wrap service method with metrics measurement."
(mtx/wrap-summary f (::mobj cfg) [(::sv/name mdata)])) [{:keys [metrics ::metrics-id]} f mdata]
(let [labels (into-array String [(::sv/name mdata)])]
(fn [cfg params]
(let [start (System/nanoTime)]
(p/finally
(f cfg params)
(fn [_ _]
(mtx/run! metrics
{:id metrics-id
:val (/ (- (System/nanoTime) start) 1000000)
:labels labels})))))))
(defn- wrap-impl (defn- wrap-dispatch
[{:keys [audit] :as cfg} f mdata] "Wraps service method into async flow, with the ability to dispatching
(let [f (as-> f $ it to a preconfigured executor service."
(rlimit/wrap-rlimit cfg $ mdata) [{:keys [executors] :as cfg} f mdata]
(retry/wrap-retry cfg $ mdata) (let [dname (::async/dispatch mdata :none)]
(wrap-with-metrics cfg $ mdata)) (if (= :none dname)
spec (or (::sv/spec mdata) (s/spec any?))
auth? (:auth mdata true)]
(l/trace :action "register" :name (::sv/name mdata))
(with-meta (with-meta
(fn [params] (fn [cfg params]
;; Raise authentication error when rpc method requires auth but (p/do! (f cfg params)))
;; no profile-id is found in the request. mdata)
(when (and auth? (not (uuid? (:profile-id params))))
(ex/raise :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint"))
(let [params' (dissoc params ::request) (let [executor (get executors dname)]
params' (us/conform spec params') (when-not executor
result (f cfg params')] (ex/raise :type :internal
:code :executor-not-configured
:hint (format "executor %s not configured" dname)))
(with-meta
(fn [cfg params]
(-> (px/submit! executor #(f cfg params))
(p/bind p/wrap)))
mdata)))))
;; When audit log is enabled (default false). (defn- wrap-audit
(when (fn? audit) [{:keys [audit] :as cfg} f mdata]
(if audit
(with-meta
(fn [cfg {:keys [::request] :as params}]
(p/finally (f cfg params)
(fn [result _]
(when result
(let [resultm (meta result) (let [resultm (meta result)
request (::request params) profile-id (or (:profile-id params)
profile-id (or (:profile-id params')
(:profile-id result) (:profile-id result)
(::audit/profile-id resultm)) (::audit/profile-id resultm))
props (d/merge params' (::audit/props resultm))] props (d/merge params (::audit/props resultm))]
(audit :cmd :submit (audit :cmd :submit
:type (or (::audit/type resultm) :type (or (::audit/type resultm)
(::type cfg)) (::type cfg))
@ -112,26 +148,47 @@
(::sv/name mdata)) (::sv/name mdata))
:profile-id profile-id :profile-id profile-id
:ip-addr (audit/parse-client-ip request) :ip-addr (audit/parse-client-ip request)
:props props))) :props (dissoc props ::request)))))))
mdata)
f))
(defn- wrap
[cfg f mdata]
(let [f (as-> f $
(wrap-dispatch cfg $ mdata)
(rlimit/wrap-rlimit cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-metrics cfg $ mdata)
)
spec (or (::sv/spec mdata) (s/spec any?))
auth? (:auth mdata true)]
(l/trace :action "register" :name (::sv/name mdata))
(with-meta
(fn [{:keys [::request] :as params}]
;; Raise authentication error when rpc method requires auth but
;; no profile-id is found in the request.
(p/do!
(if (and auth? (not (uuid? (:profile-id params))))
(ex/raise :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint")
(let [params (us/conform spec (dissoc params ::request))]
(f cfg (assoc params ::request request))))))
result))
mdata))) mdata)))
(defn- process-method (defn- process-method
[cfg vfn] [cfg vfn]
(let [mdata (meta vfn)] (let [mdata (meta vfn)]
[(keyword (::sv/name mdata)) [(keyword (::sv/name mdata))
(wrap-impl cfg (deref vfn) mdata)])) (wrap cfg (deref vfn) mdata)]))
(defn- resolve-query-methods (defn- resolve-query-methods
[cfg] [cfg]
(let [mobj (mtx/create (let [cfg (assoc cfg ::type "query" ::metrics-id :rpc-query-timing)]
{:name "rpc_query_timing"
:labels ["name"]
:registry (get-in cfg [:metrics :registry])
:type :histogram
:help "Timing of query services."})
cfg (assoc cfg ::mobj mobj ::type "query")]
(->> (sv/scan-ns 'app.rpc.queries.projects (->> (sv/scan-ns 'app.rpc.queries.projects
'app.rpc.queries.files 'app.rpc.queries.files
'app.rpc.queries.teams 'app.rpc.queries.teams
@ -144,13 +201,7 @@
(defn- resolve-mutation-methods (defn- resolve-mutation-methods
[cfg] [cfg]
(let [mobj (mtx/create (let [cfg (assoc cfg ::type "mutation" ::metrics-id :rpc-mutation-timing)]
{:name "rpc_mutation_timing"
:labels ["name"]
:registry (get-in cfg [:metrics :registry])
:type :histogram
:help "Timing of mutation services."})
cfg (assoc cfg ::mobj mobj ::type "mutation")]
(->> (sv/scan-ns 'app.rpc.mutations.demo (->> (sv/scan-ns 'app.rpc.mutations.demo
'app.rpc.mutations.media 'app.rpc.mutations.media
'app.rpc.mutations.profile 'app.rpc.mutations.profile
@ -170,15 +221,16 @@
(s/def ::session map?) (s/def ::session map?)
(s/def ::tokens fn?) (s/def ::tokens fn?)
(s/def ::audit (s/nilable fn?)) (s/def ::audit (s/nilable fn?))
(s/def ::executors (s/map-of keyword? ::wrk/executor))
(defmethod ig/pre-init-spec ::rpc [_] (defmethod ig/pre-init-spec ::rpc [_]
(s/keys :req-un [::storage ::session ::tokens ::audit (s/keys :req-un [::storage ::session ::tokens ::audit
::mtx/metrics ::db/pool])) ::executors ::mtx/metrics ::db/pool]))
(defmethod ig/init-key ::rpc (defmethod ig/init-key ::rpc
[_ cfg] [_ cfg]
(let [mq (resolve-query-methods cfg) (let [mq (resolve-query-methods cfg)
mm (resolve-mutation-methods cfg)] mm (resolve-mutation-methods cfg)]
{:methods {:query mq :mutation mm} {:methods {:query mq :mutation mm}
:query-handler #(rpc-query-handler mq %) :query-handler (partial rpc-query-handler mq)
:mutation-handler #(rpc-mutation-handler mm %)})) :mutation-handler (partial rpc-mutation-handler mm)}))

View file

@ -7,12 +7,13 @@
(ns app.rpc.mutations.comments (ns app.rpc.mutations.comments
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.geom.point :as gpt]
[app.common.spec :as us] [app.common.spec :as us]
[app.db :as db] [app.db :as db]
[app.rpc.queries.comments :as comments] [app.rpc.queries.comments :as comments]
[app.rpc.queries.files :as files] [app.rpc.queries.files :as files]
[app.rpc.retry :as retry]
[app.util.blob :as blob] [app.util.blob :as blob]
[app.util.retry :as retry]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
@ -26,15 +27,14 @@
(s/def ::page-id ::us/uuid) (s/def ::page-id ::us/uuid)
(s/def ::file-id ::us/uuid) (s/def ::file-id ::us/uuid)
(s/def ::profile-id ::us/uuid) (s/def ::profile-id ::us/uuid)
(s/def ::position ::us/point) (s/def ::position ::gpt/point)
(s/def ::content ::us/string) (s/def ::content ::us/string)
(s/def ::create-comment-thread (s/def ::create-comment-thread
(s/keys :req-un [::profile-id ::file-id ::position ::content ::page-id])) (s/keys :req-un [::profile-id ::file-id ::position ::content ::page-id]))
(sv/defmethod ::create-comment-thread (sv/defmethod ::create-comment-thread
{::retry/enabled true {::retry/max-retries 3
::retry/max-retries 3
::retry/matches retry/conflict-db-insert?} ::retry/matches retry/conflict-db-insert?}
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]

View file

@ -18,6 +18,7 @@
[app.rpc.queries.files :as files] [app.rpc.queries.files :as files]
[app.rpc.queries.projects :as proj] [app.rpc.queries.projects :as proj]
[app.storage.impl :as simpl] [app.storage.impl :as simpl]
[app.util.async :as async]
[app.util.blob :as blob] [app.util.blob :as blob]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
@ -27,6 +28,8 @@
;; --- Helpers & Specs ;; --- Helpers & Specs
(s/def ::frame-id ::us/uuid)
(s/def ::file-id ::us/uuid)
(s/def ::id ::us/uuid) (s/def ::id ::us/uuid)
(s/def ::name ::us/string) (s/def ::name ::us/string)
(s/def ::profile-id ::us/uuid) (s/def ::profile-id ::us/uuid)
@ -270,6 +273,7 @@
(contains? o :changes-with-metadata))))) (contains? o :changes-with-metadata)))))
(sv/defmethod ::update-file (sv/defmethod ::update-file
{::async/dispatch :blocking}
[{:keys [pool] :as cfg} {:keys [id profile-id] :as params}] [{:keys [pool] :as cfg} {:keys [id profile-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(db/xact-lock! conn id) (db/xact-lock! conn id)
@ -305,24 +309,21 @@
:context {:incoming-revn (:revn params) :context {:incoming-revn (:revn params)
:stored-revn (:revn file)})) :stored-revn (:revn file)}))
(let [mtx1 (get-in metrics [:definitions :update-file-changes]) (let [changes (if changes-with-metadata
mtx2 (get-in metrics [:definitions :update-file-bytes-processed])
changes (if changes-with-metadata
(mapcat :changes changes-with-metadata) (mapcat :changes changes-with-metadata)
changes) changes)
changes (vec changes) changes (vec changes)
;; Trace the number of changes processed ;; Trace the number of changes processed
_ ((::mtx/fn mtx1) {:by (count changes)}) _ (mtx/run! metrics {:id :update-file-changes :inc (count changes)})
ts (dt/now) ts (dt/now)
file (-> (files/retrieve-data cfg file) file (-> (files/retrieve-data cfg file)
(update :revn inc) (update :revn inc)
(update :data (fn [data] (update :data (fn [data]
;; Trace the length of bytes of processed data ;; Trace the length of bytes of processed data
((::mtx/fn mtx2) {:by (alength data)}) (mtx/run! metrics {:id :update-file-bytes-processed :inc (alength data)})
(-> data (-> data
(blob/decode) (blob/decode)
(assoc :id (:id file)) (assoc :id (:id file))
@ -472,3 +473,25 @@
{:id id}))) {:id id})))
nil))) nil)))
;; --- Mutation: Upsert frame thumbnail
(def sql:upsert-frame-thumbnail
"insert into file_frame_thumbnail(file_id, frame_id, data)
values (?, ?, ?)
on conflict(file_id, frame_id) do
update set data = ?;")
(s/def ::data ::us/string)
(s/def ::upsert-frame-thumbnail
(s/keys :req-un [::profile-id ::file-id ::frame-id ::data]))
(sv/defmethod ::upsert-frame-thumbnail
[{:keys [pool] :as cfg} {:keys [profile-id file-id frame-id data]}]
(db/with-atomic [conn pool]
(files/check-edition-permissions! conn profile-id file-id)
(db/exec-one! conn [sql:upsert-frame-thumbnail file-id frame-id data data])
nil))

View file

@ -9,12 +9,10 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.media :as media] [app.media :as media]
[app.rpc.queries.teams :as teams] [app.rpc.queries.teams :as teams]
[app.storage :as sto] [app.storage :as sto]
[app.util.rlimit :as rlimit]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
@ -39,42 +37,47 @@
::font-id ::font-family ::font-weight ::font-style])) ::font-id ::font-family ::font-weight ::font-style]))
(sv/defmethod ::create-font-variant (sv/defmethod ::create-font-variant
{::rlimit/permits (cf/get :rlimit-font)}
[{:keys [pool] :as cfg} {:keys [team-id profile-id] :as params}] [{:keys [pool] :as cfg} {:keys [team-id profile-id] :as params}]
(db/with-atomic [conn pool] (teams/check-edition-permissions! pool profile-id team-id)
(let [cfg (assoc cfg :conn conn)] (create-font-variant cfg params))
(teams/check-edition-permissions! conn profile-id team-id)
(create-font-variant cfg params))))
(defn create-font-variant (defn create-font-variant
[{:keys [conn storage] :as cfg} {:keys [data] :as params}] [{:keys [storage pool] :as cfg} {:keys [data] :as params}]
(let [data (media/run {:cmd :generate-fonts :input data}) (let [data (media/run {:cmd :generate-fonts :input data})
storage (media/configure-assets-storage storage conn) storage (media/configure-assets-storage storage)]
otf (when-let [fdata (get data "font/otf")] (when (and (not (contains? data "font/otf"))
(sto/put-object storage {:content (sto/content fdata) (not (contains? data "font/ttf"))
:content-type "font/otf"})) (not (contains? data "font/woff"))
(not (contains? data "font/woff2")))
ttf (when-let [fdata (get data "font/ttf")]
(sto/put-object storage {:content (sto/content fdata)
:content-type "font/ttf"}))
woff1 (when-let [fdata (get data "font/woff")]
(sto/put-object storage {:content (sto/content fdata)
:content-type "font/woff"}))
woff2 (when-let [fdata (get data "font/woff2")]
(sto/put-object storage {:content (sto/content fdata)
:content-type "font/woff2"}))]
(when (and (nil? otf)
(nil? ttf)
(nil? woff1)
(nil? woff2))
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-font-upload)) :code :invalid-font-upload))
(db/insert! conn :team-font-variant (let [otf (when-let [fdata (get data "font/otf")]
(sto/put-object storage {:content (sto/content fdata)
:content-type "font/otf"
:reference :team-font-variant
:touched-at (dt/now)}))
ttf (when-let [fdata (get data "font/ttf")]
(sto/put-object storage {:content (sto/content fdata)
:content-type "font/ttf"
:touched-at (dt/now)
:reference :team-font-variant}))
woff1 (when-let [fdata (get data "font/woff")]
(sto/put-object storage {:content (sto/content fdata)
:content-type "font/woff"
:touched-at (dt/now)
:reference :team-font-variant}))
woff2 (when-let [fdata (get data "font/woff2")]
(sto/put-object storage {:content (sto/content fdata)
:content-type "font/woff2"
:touched-at (dt/now)
:reference :team-font-variant}))]
(db/insert! pool :team-font-variant
{:id (uuid/next) {:id (uuid/next)
:team-id (:team-id params) :team-id (:team-id params)
:font-id (:font-id params) :font-id (:font-id params)
@ -84,7 +87,7 @@
:woff1-file-id (:id woff1) :woff1-file-id (:id woff1)
:woff2-file-id (:id woff2) :woff2-file-id (:id woff2)
:otf-file-id (:id otf) :otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))) :ttf-file-id (:id ttf)}))))
;; --- UPDATE FONT FAMILY ;; --- UPDATE FONT FAMILY

View file

@ -56,7 +56,7 @@
(s/keys :req-un [::email ::password] (s/keys :req-un [::email ::password]
:opt-un [::invitation-token])) :opt-un [::invitation-token]))
(sv/defmethod ::login-with-ldap {:auth false :rlimit :password} (sv/defmethod ::login-with-ldap {:auth false}
[{:keys [pool session tokens] :as cfg} params] [{:keys [pool session tokens] :as cfg} params]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [info (authenticate params) (let [info (authenticate params)

View file

@ -14,9 +14,10 @@
[app.db :as db] [app.db :as db]
[app.media :as media] [app.media :as media]
[app.rpc.queries.teams :as teams] [app.rpc.queries.teams :as teams]
[app.rpc.rlimit :as rlimit]
[app.storage :as sto] [app.storage :as sto]
[app.util.async :as async]
[app.util.http :as http] [app.util.http :as http]
[app.util.rlimit :as rlimit]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
@ -49,13 +50,12 @@
:opt-un [::id])) :opt-un [::id]))
(sv/defmethod ::upload-file-media-object (sv/defmethod ::upload-file-media-object
{::rlimit/permits (cf/get :rlimit-image)} {::rlimit/permits (cf/get :rlimit-image)
::async/dispatch :default}
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
(db/with-atomic [conn pool] (let [file (select-file pool file-id)]
(let [file (select-file conn file-id)] (teams/check-edition-permissions! pool profile-id (:team-id file))
(teams/check-edition-permissions! conn profile-id (:team-id file)) (create-file-media-object cfg params)))
(-> (assoc cfg :conn conn)
(create-file-media-object params)))))
(defn- big-enough-for-thumbnail? (defn- big-enough-for-thumbnail?
"Checks if the provided image info is big enough for "Checks if the provided image info is big enough for
@ -77,6 +77,9 @@
:code :unable-to-access-to-url :code :unable-to-access-to-url
:cause e)))) :cause e))))
;; TODO: we need to check the size before fetch resource, if not we
;; can start downloading very big object and cause OOM errors.
(defn- download-media (defn- download-media
[{:keys [storage] :as cfg} url] [{:keys [storage] :as cfg} url]
(let [result (fetch-url url) (let [result (fetch-url url)
@ -90,6 +93,7 @@
(-> (assoc storage :backend :tmp) (-> (assoc storage :backend :tmp)
(sto/put-object {:content (sto/content data) (sto/put-object {:content (sto/content data)
:content-type mtype :content-type mtype
:reference :file-media-object
:expired-at (dt/in-future {:minutes 30})})))) :expired-at (dt/in-future {:minutes 30})}))))
;; NOTE: we use the `on conflict do update` instead of `do nothing` ;; NOTE: we use the `on conflict do update` instead of `do nothing`
@ -102,13 +106,27 @@
on conflict (id) do update set created_at=file_media_object.created_at on conflict (id) do update set created_at=file_media_object.created_at
returning *") returning *")
;; NOTE: the following function executes without a transaction, this
;; means that if something fails in the middle of this function, it
;; will probably leave leaked/unreferenced objects in the database and
;; probably in the storage layer. For handle possible object leakage,
;; we create all media objects marked as touched, this ensures that if
;; something fails, all leaked (already created storage objects) will
;; be eventually marked as deleted by the touched-gc task.
;;
;; The touched-gc task, performs periodic analisis of all touched
;; storage objects and check references of it. This is the reason why
;; `reference` metadata exists: it indicates the name of the table
;; witch holds the reference to storage object (it some kind of
;; inverse, soft referential integrity).
(defn create-file-media-object (defn create-file-media-object
[{:keys [conn storage] :as cfg} {:keys [id file-id is-local name content] :as params}] [{:keys [storage pool] :as cfg} {:keys [id file-id is-local name content] :as params}]
(media/validate-media-type (:content-type content)) (media/validate-media-type (:content-type content))
(let [storage (media/configure-assets-storage storage conn) (let [source-path (fs/path (:tempfile content))
source-path (fs/path (:tempfile content))
source-mtype (:content-type content) source-mtype (:content-type content)
source-info (media/run {:cmd :info :input {:path source-path :mtype source-mtype}}) source-info (media/run {:cmd :info :input {:path source-path :mtype source-mtype}})
storage (media/configure-assets-storage storage)
thumb (when (and (not (svg-image? source-info)) thumb (when (and (not (svg-image? source-info))
(big-enough-for-thumbnail? source-info)) (big-enough-for-thumbnail? source-info))
@ -119,16 +137,25 @@
image (if (= (:mtype source-info) "image/svg+xml") image (if (= (:mtype source-info) "image/svg+xml")
(let [data (slurp source-path)] (let [data (slurp source-path)]
(sto/put-object storage {:content (sto/content data) (sto/put-object storage
:content-type (:mtype source-info)})) {:content (sto/content data)
(sto/put-object storage {:content (sto/content source-path) :content-type (:mtype source-info)
:content-type (:mtype source-info)})) :reference :file-media-object
:touched-at (dt/now)}))
(sto/put-object storage
{:content (sto/content source-path)
:content-type (:mtype source-info)
:reference :file-media-object
:touched-at (dt/now)}))
thumb (when thumb thumb (when thumb
(sto/put-object storage {:content (sto/content (:data thumb) (:size thumb)) (sto/put-object storage
:content-type (:mtype thumb)}))] {:content (sto/content (:data thumb) (:size thumb))
:content-type (:mtype thumb)
:reference :file-media-object
:touched-at (dt/now)}))]
(db/exec-one! conn [sql:create-file-media-object (db/exec-one! pool [sql:create-file-media-object
(or id (uuid/next)) (or id (uuid/next))
file-id is-local name file-id is-local name
(:id image) (:id image)
@ -144,20 +171,19 @@
:opt-un [::id ::name])) :opt-un [::id ::name]))
(sv/defmethod ::create-file-media-object-from-url (sv/defmethod ::create-file-media-object-from-url
{::rlimit/permits (cf/get :rlimit-image)
::async/dispatch :default}
[{:keys [pool storage] :as cfg} {:keys [profile-id file-id url name] :as params}] [{:keys [pool storage] :as cfg} {:keys [profile-id file-id url name] :as params}]
(db/with-atomic [conn pool] (let [file (select-file pool file-id)]
(let [file (select-file conn file-id)] (teams/check-edition-permissions! pool profile-id (:team-id file))
(teams/check-edition-permissions! conn profile-id (:team-id file))
(let [mobj (download-media cfg url) (let [mobj (download-media cfg url)
content {:filename "tempfile" content {:filename "tempfile"
:size (:size mobj) :size (:size mobj)
:tempfile (sto/get-object-path storage mobj) :tempfile (sto/get-object-path storage mobj)
:content-type (:content-type (meta mobj))} :content-type (:content-type (meta mobj))}]
params' (merge params {:content content
:name (or name (:filename content))})]
(-> (assoc cfg :conn conn)
(create-file-media-object params'))))))
(->> (merge params {:content content :name (or name (:filename content))})
(create-file-media-object cfg)))))
;; --- Clone File Media object (Upload and create from url) ;; --- Clone File Media object (Upload and create from url)
@ -189,7 +215,6 @@
:height (:height mobj) :height (:height mobj)
:mtype (:mtype mobj)}))) :mtype (:mtype mobj)})))
;; --- HELPERS ;; --- HELPERS
(def ^:private (def ^:private

View file

@ -15,11 +15,11 @@
[app.http.oauth :refer [extract-utm-props]] [app.http.oauth :refer [extract-utm-props]]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.media :as media] [app.media :as media]
[app.metrics :as mtx]
[app.rpc.mutations.teams :as teams] [app.rpc.mutations.teams :as teams]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.rpc.rlimit :as rlimit]
[app.storage :as sto] [app.storage :as sto]
[app.util.rlimit :as rlimit] [app.util.async :as async]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[buddy.hashers :as hashers] [buddy.hashers :as hashers]
@ -38,7 +38,6 @@
(s/def ::theme ::us/string) (s/def ::theme ::us/string)
(s/def ::invitation-token ::us/not-empty-string) (s/def ::invitation-token ::us/not-empty-string)
(declare annotate-profile-register)
(declare check-profile-existence!) (declare check-profile-existence!)
(declare create-profile) (declare create-profile)
(declare create-profile-relations) (declare create-profile-relations)
@ -102,6 +101,7 @@
(when-not (contains? cf/flags :registration) (when-not (contains? cf/flags :registration)
(ex/raise :type :restriction (ex/raise :type :restriction
:code :registration-disabled)) :code :registration-disabled))
(when-let [domains (cf/get :registration-domain-whitelist)] (when-let [domains (cf/get :registration-domain-whitelist)]
(when-not (email-domain-in-whitelist? domains (:email params)) (when-not (email-domain-in-whitelist? domains (:email params))
(ex/raise :type :validation (ex/raise :type :validation
@ -116,10 +116,17 @@
(check-profile-existence! pool params) (check-profile-existence! pool params)
(let [params (assoc params (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"))
(let [params {:email (:email params)
:invitation-token (:invitation-token params)
:backend "penpot" :backend "penpot"
:iss :prepared-register :iss :prepared-register
:exp (dt/in-future "48h")) :exp (dt/in-future "48h")}
token (tokens :generate params)] token (tokens :generate params)]
{:token token})) {:token token}))
@ -136,16 +143,8 @@
(-> (assoc cfg :conn conn) (-> (assoc cfg :conn conn)
(register-profile params)))) (register-profile params))))
(defn- annotate-profile-register
"A helper for properly increase the profile-register metric once the
transaction is completed."
[metrics]
(fn []
(let [mobj (get-in metrics [:definitions :profile-register])]
((::mtx/fn mobj) {:by 1}))))
(defn register-profile (defn register-profile
[{:keys [conn tokens session metrics] :as cfg} {:keys [token] :as params}] [{:keys [conn tokens session] :as cfg} {:keys [token] :as params}]
(let [claims (tokens :verify {:token token :iss :prepared-register}) (let [claims (tokens :verify {:token token :iss :prepared-register})
params (merge params claims)] params (merge params claims)]
@ -156,23 +155,21 @@
profile (->> (assoc params :is-active is-active) profile (->> (assoc params :is-active is-active)
(create-profile conn) (create-profile conn)
(create-profile-relations conn) (create-profile-relations conn)
(decode-profile-row))] (decode-profile-row))
invitation (when-let [token (:invitation-token params)]
(tokens :verify {:token token :iss :team-invitation}))]
(cond (cond
;; If invitation token comes in params, this is because the ;; If invitation token comes in params, this is because the user comes from team-invitation process;
;; user comes from team-invitation process; in this case, ;; in this case, regenerate token and send back to the user a new invitation token (and mark current
;; regenerate token and send back to the user a new invitation ;; session as logged). This happens only if the invitation email matches with the register email.
;; token (and mark current session as logged). (and (some? invitation) (= (:email profile) (:member-email invitation)))
(some? (:invitation-token params)) (let [claims (assoc invitation :member-id (:id profile))
(let [token (:invitation-token params)
claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims
:member-id (:id profile)
:member-email (:email profile))
token (tokens :generate claims) token (tokens :generate claims)
resp {:invitation-token token}] resp {:invitation-token token}]
(with-meta resp (with-meta resp
{:transform-response ((:create session) (:id profile)) {:transform-response ((:create session) (:id profile))
:before-complete (annotate-profile-register metrics)
::audit/props (audit/profile->props profile) ::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})) ::audit/profile-id (:id profile)}))
@ -182,7 +179,6 @@
(not= "penpot" (:auth-backend profile)) (not= "penpot" (:auth-backend profile))
(with-meta (profile/strip-private-attrs profile) (with-meta (profile/strip-private-attrs profile)
{:transform-response ((:create session) (:id profile)) {:transform-response ((:create session) (:id profile))
:before-complete (annotate-profile-register metrics)
::audit/props (audit/profile->props profile) ::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}) ::audit/profile-id (:id profile)})
@ -191,7 +187,6 @@
(true? is-active) (true? is-active)
(with-meta (profile/strip-private-attrs profile) (with-meta (profile/strip-private-attrs profile)
{:transform-response ((:create session) (:id profile)) {:transform-response ((:create session) (:id profile))
:before-complete (annotate-profile-register metrics)
::audit/props (audit/profile->props profile) ::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}) ::audit/profile-id (:id profile)})
@ -214,8 +209,7 @@
:extra-data ptoken}) :extra-data ptoken})
(with-meta profile (with-meta profile
{:before-complete (annotate-profile-register metrics) {::audit/props (audit/profile->props profile)
::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})))))) ::audit/profile-id (:id profile)}))))))
(defn create-profile (defn create-profile
@ -284,7 +278,9 @@
:opt-un [::scope ::invitation-token])) :opt-un [::scope ::invitation-token]))
(sv/defmethod ::login (sv/defmethod ::login
{:auth false ::rlimit/permits (cf/get :rlimit-password)} {:auth false
::async/dispatch :default
::rlimit/permits (cf/get :rlimit-password)}
[{:keys [pool session tokens] :as cfg} {:keys [email password] :as params}] [{:keys [pool session tokens] :as cfg} {:keys [email password] :as params}]
(letfn [(check-password [profile password] (letfn [(check-password [profile password]
(when (= (:password profile) "!") (when (= (:password profile) "!")
@ -309,28 +305,22 @@
(validate-profile) (validate-profile)
(profile/strip-private-attrs) (profile/strip-private-attrs)
(profile/populate-additional-data conn) (profile/populate-additional-data conn)
(decode-profile-row))] (decode-profile-row))
(if-let [token (:invitation-token params)]
;; If the request comes with an invitation token, this means
;; that user wants to accept it with different user. A very
;; strange case but still can happen. In this case, we
;; proceed in the same way as in register: regenerate the
;; invitation token and return it to the user for proper
;; invitation acceptation.
(let [claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims
:member-id (:id profile)
:member-email (:email profile))
token (tokens :generate claims)]
(with-meta {:invitation-token token}
{:transform-response ((:create session) (:id profile))
::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))
(with-meta profile invitation (when-let [token (:invitation-token params)]
(tokens :verify {: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 loging with other email and
;; accept invitation with other email
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
{:invitation-token (:invitation-token params)}
profile)]
(with-meta response
{:transform-response ((:create session) (:id profile)) {:transform-response ((:create session) (:id profile))
::audit/props (audit/profile->props profile) ::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})))))) ::audit/profile-id (:id profile)})))))
;; --- MUTATION: Logout ;; --- MUTATION: Logout
@ -360,6 +350,7 @@
:opt-un [::lang ::theme])) :opt-un [::lang ::theme]))
(sv/defmethod ::update-profile (sv/defmethod ::update-profile
{::async/dispatch :default}
[{:keys [pool] :as cfg} params] [{:keys [pool] :as cfg} params]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [profile (update-profile conn params)] (let [profile (update-profile conn params)]
@ -381,6 +372,11 @@
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [profile (validate-password! conn params) (let [profile (validate-password! conn params)
session-id (:app.rpc/session-id params)] session-id (:app.rpc/session-id params)]
(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! conn (assoc profile :password password)) (update-profile-password! conn (assoc profile :password password))
(invalidate-profile-session! conn (:id profile) session-id) (invalidate-profile-session! conn (:id profile) session-id)
nil))) nil)))

View file

@ -18,8 +18,8 @@
[app.rpc.permissions :as perms] [app.rpc.permissions :as perms]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.rpc.queries.teams :as teams] [app.rpc.queries.teams :as teams]
[app.rpc.rlimit :as rlimit]
[app.storage :as sto] [app.storage :as sto]
[app.util.rlimit :as rlimit]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
@ -379,8 +379,7 @@
:code :member-is-muted :code :member-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) :hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
;; Secondly check if the invited member email is part of the ;; Secondly check if the invited member email is part of the global spam/bounce report.
;; global spam/bounce report.
(when (eml/has-bounce-reports? conn email) (when (eml/has-bounce-reports? conn email)
(ex/raise :type :validation (ex/raise :type :validation
:code :email-has-permanent-bounces :code :email-has-permanent-bounces
@ -403,13 +402,21 @@
(s/and ::create-team (s/keys :req-un [::emails ::role]))) (s/and ::create-team (s/keys :req-un [::emails ::role])))
(sv/defmethod ::create-team-and-invite-members (sv/defmethod ::create-team-and-invite-members
[{:keys [pool] :as cfg} {:keys [profile-id emails role] :as params}] [{:keys [pool audit] :as cfg} {:keys [profile-id emails role] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [team (create-team conn params) (let [team (create-team conn params)
profile (db/get-by-id conn :profile profile-id)] profile (db/get-by-id conn :profile profile-id)]
;; Create invitations for all provided emails. ;; Create invitations for all provided emails.
(doseq [email emails] (doseq [email emails]
(audit :cmd :submit
:type "mutation"
:name "create-team-invitation"
:profile-id profile-id
:props {:email email
:role role
:profile-id profile-id})
(create-team-invitation (create-team-invitation
(assoc cfg (assoc cfg
:conn conn :conn conn

View file

@ -10,7 +10,6 @@
[app.common.spec :as us] [app.common.spec :as us]
[app.db :as db] [app.db :as db]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.metrics :as mtx]
[app.rpc.mutations.teams :as teams] [app.rpc.mutations.teams :as teams]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.util.services :as sv] [app.util.services :as sv]
@ -44,16 +43,8 @@
::audit/props {:email email} ::audit/props {:email email}
::audit/profile-id profile-id})) ::audit/profile-id profile-id}))
(defn- annotate-profile-activation
"A helper for properly increase the profile-activation metric once the
transaction is completed."
[metrics]
(fn []
(let [mobj (get-in metrics [:definitions :profile-activation])]
((::mtx/fn mobj) {:by 1}))))
(defmethod process-token :verify-email (defmethod process-token :verify-email
[{:keys [conn session metrics] :as cfg} _ {:keys [profile-id] :as claims}] [{:keys [conn session] :as cfg} _ {:keys [profile-id] :as claims}]
(let [profile (profile/retrieve-profile conn profile-id) (let [profile (profile/retrieve-profile conn profile-id)
claims (assoc claims :profile profile)] claims (assoc claims :profile profile)]
@ -69,7 +60,6 @@
(with-meta claims (with-meta claims
{:transform-response ((:create session) profile-id) {:transform-response ((:create session) profile-id)
:before-complete (annotate-profile-activation metrics)
::audit/name "verify-profile-email" ::audit/name "verify-profile-email"
::audit/props (audit/profile->props profile) ::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))) ::audit/profile-id (:id profile)})))
@ -118,77 +108,39 @@
(assoc member :is-active true))) (assoc member :is-active true)))
(defmethod process-token :team-invitation (defmethod process-token :team-invitation
[{:keys [session] :as cfg} {:keys [profile-id token]} {:keys [member-id] :as claims}] [cfg {:keys [profile-id token]} {:keys [member-id] :as claims}]
(us/assert ::team-invitation-claims claims) (us/assert ::team-invitation-claims claims)
(cond (cond
;; This happens when token is filled with member-id and current ;; This happens when token is filled with member-id and current
;; user is already logged in with some account. ;; user is already logged in with exactly invited account.
(and (uuid? profile-id) (and (uuid? profile-id) (uuid? member-id) (= member-id profile-id))
(uuid? member-id))
(let [profile (accept-invitation cfg claims)] (let [profile (accept-invitation cfg claims)]
(if (= member-id profile-id)
;; If the current session is already matches the invited
;; member, then just return the token and leave the frontend
;; app redirect to correct team.
(assoc claims :state :created)
;; If the session does not matches the invited member, replace
;; the session with a new one matching the invited member.
;; This technique should be considered secure because the
;; user clicking the link he already has access to the email
;; account.
(with-meta
(assoc claims :state :created)
{:transform-response ((:create session) member-id)
::audit/name "accept-team-invitation"
::audit/props (merge
(audit/profile->props profile)
{:team-id (:team-id claims)
:role (:role claims)})
::audit/profile-id profile-id})))
;; This happens when member-id is not filled in the invitation but
;; the user already has an account (probably with other mail) and
;; is already logged-in.
(and (uuid? profile-id)
(nil? member-id))
(let [profile (accept-invitation cfg (assoc claims :member-id profile-id))]
(with-meta (with-meta
(assoc claims :state :created) (assoc claims :state :created)
{::audit/name "accept-team-invitation" {::audit/name "accept-team-invitation"
::audit/props (merge
(audit/profile->props profile)
{:team-id (:team-id claims)
:role (:role claims)})
::audit/profile-id profile-id}))
;; This happens when member-id is filled but the accessing user is
;; not logged-in. In this case we proceed to accept invitation and
;; leave the user logged-in.
(and (nil? profile-id)
(uuid? member-id))
(let [profile (accept-invitation cfg claims)]
(with-meta
(assoc claims :state :created)
{:transform-response ((:create session) member-id)
::audit/name "accept-team-invitation"
::audit/props (merge ::audit/props (merge
(audit/profile->props profile) (audit/profile->props profile)
{:team-id (:team-id claims) {:team-id (:team-id claims)
:role (:role claims)}) :role (:role claims)})
::audit/profile-id member-id})) ::audit/profile-id member-id}))
;; In this case, we wait until frontend app redirect user to ;; This case means that invitation token does not match with
;; registration page, the user is correctly registered and the ;; registred user, so we need to indicate to frontend to redirect
;; register mutation call us again with the same token to finally ;; it to register page.
;; create the corresponding team-profile relation from the first (nil? member-id)
;; condition of this if. {:invitation-token token
:iss :team-invitation
:redirect-to :auth-register
:state :pending}
;; In all other cases, just tell to fontend to redirect the user
;; to the login page.
:else :else
{:invitation-token token {:invitation-token token
:iss :team-invitation :iss :team-invitation
:redirect-to :auth-login
:state :pending})) :state :pending}))
;; --- Default ;; --- Default
(defmethod process-token :default (defmethod process-token :default

View file

@ -7,7 +7,7 @@
(ns app.rpc.queries.files (ns app.rpc.queries.files
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.pages :as cp] [app.common.pages.helpers :as cph]
[app.common.pages.migrations :as pmg] [app.common.pages.migrations :as pmg]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
@ -26,6 +26,7 @@
;; --- Helpers & Specs ;; --- Helpers & Specs
(s/def ::frame-id ::us/uuid)
(s/def ::id ::us/uuid) (s/def ::id ::us/uuid)
(s/def ::name ::us/string) (s/def ::name ::us/string)
(s/def ::project-id ::us/uuid) (s/def ::project-id ::us/uuid)
@ -242,13 +243,10 @@
(defn- trim-file-data (defn- trim-file-data
[file {:keys [page-id object-id]}] [file {:keys [page-id object-id]}]
(let [page (get-in file [:data :pages-index page-id]) (let [page (get-in file [:data :pages-index page-id])
objects (->> (:objects page) objects (->> (cph/get-children-with-self (:objects page) object-id)
(cp/get-object-with-children object-id) (map #(dissoc % :thumbnail))
(map #(dissoc % :thumbnail))) (d/index-by :id))
objects (d/index-by :id objects)
page (assoc page :objects objects)] page (assoc page :objects objects)]
(-> file (-> file
(update :data assoc :pages-index {page-id page}) (update :data assoc :pages-index {page-id page})
(update :data assoc :pages [page-id])))) (update :data assoc :pages [page-id]))))
@ -395,6 +393,7 @@
) )
select * from recent_files where row_num <= 10;") select * from recent_files where row_num <= 10;")
(s/def ::team-recent-files (s/def ::team-recent-files
(s/keys :req-un [::profile-id ::team-id])) (s/keys :req-un [::profile-id ::team-id]))
@ -404,6 +403,25 @@
(teams/check-read-permissions! conn profile-id team-id) (teams/check-read-permissions! conn profile-id team-id)
(db/exec! conn [sql:team-recent-files team-id]))) (db/exec! conn [sql:team-recent-files team-id])))
;; --- QUERY: get the thumbnail for an frame
(def ^:private sql:file-frame-thumbnail
"select data
from file_frame_thumbnail
where file_id = ?
and frame_id = ?")
(s/def ::file-frame-thumbnail
(s/keys :req-un [::profile-id ::file-id ::frame-id]))
(sv/defmethod ::file-frame-thumbnail
[{:keys [pool]} {:keys [profile-id file-id frame-id]}]
(with-open [conn (db/open pool)]
(check-read-permissions! conn profile-id file-id)
(db/exec-one! conn [sql:file-frame-thumbnail file-id frame-id])))
;; --- Helpers ;; --- Helpers
(defn decode-row (defn decode-row

View file

@ -35,7 +35,8 @@
(s/def ::profile (s/def ::profile
(s/keys :opt-un [::profile-id])) (s/keys :opt-un [::profile-id]))
(sv/defmethod ::profile {:auth false} (sv/defmethod ::profile
{:auth false}
[{:keys [pool] :as cfg} {:keys [profile-id] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id] :as params}]
;; We need to return the anonymous profile object in two cases, when ;; We need to return the anonymous profile object in two cases, when
;; no profile-id is in session, and when db call raises not found. In all other ;; no profile-id is in session, and when db call raises not found. In all other

View file

@ -0,0 +1,45 @@
;; 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) UXBOX Labs SL
(ns app.rpc.retry
"A fault tolerance helpers. Allow retry some operations that we know
we can retry."
(:require
[app.common.logging :as l]
[app.util.services :as sv]
[promesa.core :as p]))
(defn conflict-db-insert?
"Check if exception matches a insertion conflict on postgresql."
[e]
(and (instance? org.postgresql.util.PSQLException e)
(= "23505" (.getSQLState e))))
(defn wrap-retry
[_ f {:keys [::matches ::sv/name]
:or {matches (constantly false)}
:as mdata}]
(when (::enabled mdata)
(l/debug :hint "wrapping retry" :name name))
(if-let [max-retries (::max-retries mdata)]
(fn [cfg params]
(letfn [(run [retry]
(-> (f cfg params)
(p/catch (partial handle-error retry))))
(handle-error [retry cause]
(if (matches cause)
(let [current-retry (inc retry)]
(l/trace :hint "running retry algorithm" :retry current-retry)
(if (<= current-retry max-retries)
(run current-retry)
(throw cause)))
(throw cause)))]
(run 0)))
f))

View file

@ -0,0 +1,67 @@
;; 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) UXBOX Labs SL
(ns app.rpc.rlimit
"Resource usage limits (in other words: semaphores)."
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.metrics :as mtx]
[app.util.services :as sv]
[promesa.core :as p]))
(defprotocol IAsyncSemaphore
(acquire! [_])
(release! [_]))
(defn semaphore
[{:keys [permits metrics name]}]
(let [name (d/name name)
used (volatile! 0)
queue (volatile! (d/queue))
labels (into-array String [name])]
(reify IAsyncSemaphore
(acquire! [this]
(let [d (p/deferred)]
(locking this
(if (< @used permits)
(do
(vswap! used inc)
(p/resolve! d))
(vswap! queue conj d)))
(mtx/run! metrics {:id :rlimit-used-permits :val @used :labels labels })
(mtx/run! metrics {:id :rlimit-queued-submissions :val (count @queue) :labels labels})
(mtx/run! metrics {:id :rlimit-acquires-total :inc 1 :labels labels})
d))
(release! [this]
(locking this
(if-let [item (peek @queue)]
(do
(vswap! queue pop)
(p/resolve! item))
(when (pos? @used)
(vswap! used dec))))
(mtx/run! metrics {:id :rlimit-used-permits :val @used :labels labels})
(mtx/run! metrics {:id :rlimit-queued-submissions :val (count @queue) :labels labels})
))))
(defn wrap-rlimit
[{:keys [metrics] :as cfg} f mdata]
(if-let [permits (::permits mdata)]
(let [sem (semaphore {:permits permits
:metrics metrics
:name (::sv/name mdata)})]
(l/debug :hint "wrapping rlimit" :handler (::sv/name mdata) :permits permits)
(fn [cfg params]
(-> (acquire! sem)
(p/then (fn [_] (f cfg params)))
(p/finally (fn [_ _] (release! sem))))))
f))

View file

@ -7,6 +7,7 @@
(ns app.setup (ns app.setup
"Initial data setup of instance." "Initial data setup of instance."
(:require (:require
[app.common.logging :as l]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
@ -14,55 +15,49 @@
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig])) [integrant.core :as ig]))
(declare initialize-instance-id!) (defn- generate-random-key
(declare initialize-secret-key!) []
(declare retrieve-all) (-> (bn/random-bytes 64)
(bc/bytes->b64u)
(bc/bytes->str)))
(defn- retrieve-all
[conn]
(->> (db/query conn :server-prop {:preload true})
(filter #(not= "secret-key" (:id %)))
(map (fn [row]
[(keyword (:id row))
(db/decode-transit-pgobject (:content row))]))
(into {})))
(defn- handle-instance-id
[instance-id conn read-only?]
(or instance-id
(let [instance-id (uuid/random)]
(when-not read-only?
(try
(db/insert! conn :server-prop
{:id "instance-id"
:preload true
:content (db/tjson instance-id)})
(catch Throwable cause
(l/warn :hint "unable to persist instance-id"
:instance-id instance-id
:cause cause))))
instance-id)))
(defmethod ig/pre-init-spec ::props [_] (defmethod ig/pre-init-spec ::props [_]
(s/keys :req-un [::db/pool])) (s/keys :req-un [::db/pool]))
(defmethod ig/init-key ::props (defmethod ig/init-key ::props
[_ {:keys [pool] :as cfg}] [_ {:keys [pool key] :as cfg}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [cfg (assoc cfg :conn conn)] (db/xact-lock! conn 0)
(initialize-secret-key! cfg) (when-not key
(initialize-instance-id! cfg) (l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate "
(retrieve-all cfg)))) "all sessions on each restart, it is hightly recommeded setting up the "
"PENPOT_SECRET_KEY environment variable")))
(def sql:upsert-secret-key (let [stored (-> (retrieve-all conn)
"insert into server_prop (id, preload, content) (assoc :secret-key (or key (generate-random-key))))]
values ('secret-key', true, ?::jsonb) (update stored :instance-id handle-instance-id conn (db/read-only? pool)))))
on conflict (id) do update set content = ?::jsonb")
(def sql:insert-secret-key
"insert into server_prop (id, preload, content)
values ('secret-key', true, ?::jsonb)
on conflict (id) do nothing")
(defn- initialize-secret-key!
[{:keys [conn key] :as cfg}]
(if key
(let [key (db/tjson key)]
(db/exec-one! conn [sql:upsert-secret-key key key]))
(let [key (-> (bn/random-bytes 64)
(bc/bytes->b64u)
(bc/bytes->str))
key (db/tjson key)]
(db/exec-one! conn [sql:insert-secret-key key]))))
(defn- initialize-instance-id!
[{:keys [conn] :as cfg}]
(let [iid (uuid/random)]
(db/insert! conn :server-prop
{:id "instance-id"
:preload true
:content (db/tjson iid)}
{:on-conflict-do-nothing true})))
(defn- retrieve-all
[{:keys [conn] :as cfg}]
(reduce (fn [acc row]
(assoc acc (keyword (:id row)) (db/decode-transit-pgobject (:content row))))
{}
(db/query conn :server-prop {:preload true})))

View file

@ -7,7 +7,7 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pages :as cp] [app.common.pages :as cp]
[app.common.pages.migrations :as pmg] [app.common.pages.migrations :as pmg]
[app.common.pages.spec :as spec] [app.common.spec.file :as spec.file]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cfg] [app.config :as cfg]
[app.db :as db] [app.db :as db]
@ -86,7 +86,7 @@
(validate-item [{:keys [id data modified-at] :as file}] (validate-item [{:keys [id data modified-at] :as file}]
(let [data (blob/decode data) (let [data (blob/decode data)
valid? (s/valid? ::spec/data data)] valid? (s/valid? ::spec.file/data data)]
(l/debug :hint "validated file" (l/debug :hint "validated file"
:file-id id :file-id id
@ -98,7 +98,7 @@
:valid valid?) :valid valid?)
(when (and (not valid?) verbose?) (when (and (not valid?) verbose?)
(let [edata (-> (s/explain-data ::spec/data data) (let [edata (-> (s/explain-data ::spec.file/data data)
(update ::s/problems #(take 5 %)))] (update ::s/problems #(take 5 %)))]
(binding [s/*explain-out* expound/printer] (binding [s/*explain-out* expound/printer]
(l/warn ::l/raw (with-out-str (s/explain-out edata)))))) (l/warn ::l/raw (with-out-str (s/explain-out edata))))))

View file

@ -18,11 +18,9 @@
[app.storage.impl :as impl] [app.storage.impl :as impl]
[app.storage.s3 :as ss3] [app.storage.s3 :as ss3]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[datoteka.core :as fs] [datoteka.core :as fs]
[integrant.core :as ig] [integrant.core :as ig]))
[promesa.exec :as px]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Storage Module State ;; Storage Module State
@ -40,7 +38,7 @@
:db ::sdb/backend)))) :db ::sdb/backend))))
(defmethod ig/pre-init-spec ::storage [_] (defmethod ig/pre-init-spec ::storage [_]
(s/keys :req-un [::wrk/executor ::db/pool ::backends])) (s/keys :req-un [::db/pool ::backends]))
(defmethod ig/prep-key ::storage (defmethod ig/prep-key ::storage
[_ {:keys [backends] :as cfg}] [_ {:keys [backends] :as cfg}]
@ -53,78 +51,74 @@
(assoc :backends (d/without-nils backends)))) (assoc :backends (d/without-nils backends))))
(s/def ::storage (s/def ::storage
(s/keys :req-un [::backends ::wrk/executor ::db/pool])) (s/keys :req-un [::backends ::db/pool]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Database Objects ;; Database Objects
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defrecord StorageObject [id size created-at expired-at backend]) (defrecord StorageObject [id size created-at expired-at touched-at backend])
(defn storage-object? (defn storage-object?
[v] [v]
(instance? StorageObject v)) (instance? StorageObject v))
(def ^:private (s/def ::storage-object storage-object?)
sql:insert-storage-object (s/def ::storage-content impl/content?)
"insert into storage_object (id, size, backend, metadata)
values (?, ?, ?, ?::jsonb)
returning *")
(def ^:private
sql:insert-storage-object-with-expiration
"insert into storage_object (id, size, backend, metadata, deleted_at)
values (?, ?, ?, ?::jsonb, ?)
returning *")
(defn- insert-object (defn- clone-database-object
[conn id size backend mdata expiration]
(if expiration
(db/exec-one! conn [sql:insert-storage-object-with-expiration id size backend mdata expiration])
(db/exec-one! conn [sql:insert-storage-object id size backend mdata])))
(defn- create-database-object
[{:keys [conn backend]} {:keys [content] :as object}]
(if (instance? StorageObject object)
;; If we in this condition branch, this means we come from the ;; If we in this condition branch, this means we come from the
;; clone-object, so we just need to clone it with a new backend. ;; clone-object, so we just need to clone it with a new backend.
[{:keys [conn backend]} object]
(let [id (uuid/random) (let [id (uuid/random)
mdata (meta object) mdata (meta object)
result (insert-object conn result (db/insert! conn :storage-object
id {:id id
(:size object) :size (:size object)
(name backend) :backend (name backend)
(db/tjson mdata) :metadata (db/tjson mdata)
(:expired-at object))] :deleted-at (:expired-at object)
:touched-at (:touched-at object)})]
(assoc object (assoc object
:id (:id result) :id (:id result)
:backend backend :backend backend
:created-at (:created-at result))) :created-at (:created-at result)
:touched-at (:touched-at result))))
(defn- create-database-object
[{:keys [conn backend]} {:keys [content] :as object}]
(us/assert ::storage-content content)
(let [id (uuid/random) (let [id (uuid/random)
mdata (dissoc object :content :expired-at) mdata (dissoc object :content :expired-at :touched-at)
result (insert-object conn
id result (db/insert! conn :storage-object
(count content) {:id id
(name backend) :size (count content)
(db/tjson mdata) :backend (name backend)
(:expired-at object))] :metadata (db/tjson mdata)
:deleted-at (:expired-at object)
:touched-at (:touched-at object)})]
(StorageObject. (:id result) (StorageObject. (:id result)
(:size result) (:size result)
(:created-at result) (:created-at result)
(:deleted-at result) (:deleted-at result)
(:touched-at result)
backend backend
mdata mdata
nil)))) nil)))
(def ^:private sql:retrieve-storage-object (def ^:private sql:retrieve-storage-object
"select * from storage_object where id = ? and (deleted_at is null or deleted_at > now())") "select * from storage_object where id = ? and (deleted_at is null or deleted_at > now())")
(defn row->storage-object [res] (defn row->storage-object [res]
(let [mdata (some-> (:metadata res) (db/decode-transit-pgobject))] (let [mdata (or (some-> (:metadata res) (db/decode-transit-pgobject)) {})]
(StorageObject. (:id res) (StorageObject. (:id res)
(:size res) (:size res)
(:created-at res) (:created-at res)
(:deleted-at res) (:deleted-at res)
(:touched-at res)
(keyword (:backend res)) (keyword (:backend res))
mdata mdata
nil))) nil)))
@ -142,10 +136,6 @@
(let [result (db/exec-one! conn [sql:delete-storage-object id])] (let [result (db/exec-one! conn [sql:delete-storage-object id])]
(pos? (:next.jdbc/update-count result)))) (pos? (:next.jdbc/update-count result))))
(defn- register-recheck
[{:keys [pool] :as storage} backend id]
(db/insert! pool :storage-pending {:id id :backend (name backend)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API ;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -170,17 +160,13 @@
(defn put-object (defn put-object
"Creates a new object with the provided content." "Creates a new object with the provided content."
[{:keys [pool conn backend executor] :as storage} {:keys [content] :as params}] [{:keys [pool conn backend] :as storage} {:keys [content] :as params}]
(us/assert ::storage storage) (us/assert ::storage storage)
(us/assert impl/content? content) (us/assert ::storage-content content)
(us/assert ::us/keyword backend)
(let [storage (assoc storage :conn (or conn pool)) (let [storage (assoc storage :conn (or conn pool))
object (create-database-object storage params)] object (create-database-object storage params)]
;; Schedule to execute in background; in an other transaction and
;; register the currently created storage object id for a later
;; recheck.
(px/run! executor #(register-recheck storage backend (:id object)))
;; Store the data finally on the underlying storage subsystem. ;; Store the data finally on the underlying storage subsystem.
(-> (impl/resolve-backend storage backend) (-> (impl/resolve-backend storage backend)
(impl/put-object object content)) (impl/put-object object content))
@ -190,10 +176,12 @@
(defn clone-object (defn clone-object
"Creates a clone of the provided object using backend based efficient "Creates a clone of the provided object using backend based efficient
method. Always clones objects to the configured default." method. Always clones objects to the configured default."
[{:keys [pool conn] :as storage} object] [{:keys [pool conn backend] :as storage} object]
(us/assert ::storage storage) (us/assert ::storage storage)
(us/assert ::storage-object object)
(us/assert ::us/keyword backend)
(let [storage (assoc storage :conn (or conn pool)) (let [storage (assoc storage :conn (or conn pool))
object* (create-database-object storage object)] object* (clone-database-object storage object)]
(if (= (:backend object) (:backend storage)) (if (= (:backend object) (:backend storage))
;; if the source and destination backends are the same, we ;; if the source and destination backends are the same, we
;; proceed to use the fast path with specific copy ;; proceed to use the fast path with specific copy
@ -269,7 +257,7 @@
;; A task responsible to permanently delete already marked as deleted ;; A task responsible to permanently delete already marked as deleted
;; storage files. ;; storage files.
(declare sql:retrieve-deleted-objects) (declare sql:retrieve-deleted-objects-chunk)
(s/def ::min-age ::dt/duration) (s/def ::min-age ::dt/duration)
@ -278,44 +266,46 @@
(defmethod ig/init-key ::gc-deleted-task (defmethod ig/init-key ::gc-deleted-task
[_ {:keys [pool storage min-age] :as cfg}] [_ {:keys [pool storage min-age] :as cfg}]
(letfn [(group-by-backend [rows] (letfn [(retrieve-deleted-objects-chunk [conn cursor]
(let [conj (fnil conj [])] (let [min-age (db/interval min-age)
[(reduce (fn [acc {:keys [id backend]}] rows (db/exec! conn [sql:retrieve-deleted-objects-chunk min-age cursor])]
(update acc (keyword backend) conj id)) [(some-> rows peek :created-at)
{} (some->> (seq rows) (d/group-by' #(-> % :backend keyword) :id) seq)]))
rows)
(count rows)]))
(retrieve-deleted-objects [conn] (retrieve-deleted-objects [conn]
(let [min-age (db/interval min-age) (->> (d/iteration (fn [cursor]
rows (db/exec! conn [sql:retrieve-deleted-objects min-age])] (retrieve-deleted-objects-chunk conn cursor))
(some-> (seq rows) (group-by-backend)))) :initk (dt/now)
:vf second
:kf first)
(sequence cat)))
(delete-in-bulk [conn [backend ids]] (delete-in-bulk [conn backend ids]
(let [backend (impl/resolve-backend storage backend) (let [backend (impl/resolve-backend storage backend)
backend (assoc backend :conn conn)] backend (assoc backend :conn conn)]
(impl/del-objects-in-bulk backend ids)))] (impl/del-objects-in-bulk backend ids)))]
(fn [_] (fn [_]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(loop [n 0] (loop [total 0
(if-let [[groups total] (retrieve-deleted-objects conn)] groups (retrieve-deleted-objects conn)]
(if-let [[backend ids] (first groups)]
(do (do
(run! (partial delete-in-bulk conn) groups) (delete-in-bulk conn backend ids)
(recur (+ n ^long total))) (recur (+ total (count ids))
(rest groups)))
(do (do
(l/info :task "gc-deleted" (l/info :task "gc-deleted" :count total)
:hint "permanently delete items" {:deleted total})))))))
:count n)
{:deleted n})))))))
(def sql:retrieve-deleted-objects (def sql:retrieve-deleted-objects-chunk
"with items_part as ( "with items_part as (
select s.id select s.id
from storage_object as s from storage_object as s
where s.deleted_at is not null where s.deleted_at is not null
and s.deleted_at < (now() - ?::interval) and s.deleted_at < (now() - ?::interval)
order by s.deleted_at and s.created_at < ?
order by s.created_at desc
limit 100 limit 100
) )
delete from storage_object delete from storage_object
@ -326,157 +316,105 @@
;; Garbage Collection: Analyze touched objects ;; Garbage Collection: Analyze touched objects
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; This task is part of the garbage collection of storage objects and ;; This task is part of the garbage collection of storage objects and is responsible on analyzing the touched
;; is responsible on analyzing the touched objects and mark them for deletion ;; objects and mark them for deletion if corresponds.
;; if corresponds.
;; ;;
;; When file_media_object is deleted, the depending storage_object are ;; For example: when file_media_object is deleted, the depending storage_object are marked as touched. This
;; marked as touched. This means that some files that depend on a ;; means that some files that depend on a concrete storage_object are no longer exists and maybe this
;; concrete storage_object are no longer exists and maybe this ;; storage_object is no longer necessary and can be eligible for elimination. This task periodically analyzes
;; storage_object is no longer necessary and can be eligible for ;; touched objects and mark them as freeze (means that has other references and the object is still valid) or
;; elimination. This task periodically analyzes touched objects and ;; deleted (no more references to this object so is ready to be deleted).
;; mark them as freeze (means that has other references and the object
;; is still valid) or deleted (no more references to this object so is
;; ready to be deleted).
(declare sql:retrieve-touched-objects) (declare sql:retrieve-touched-objects-chunk)
(declare sql:retrieve-file-media-object-nrefs)
(declare sql:retrieve-team-font-variant-nrefs)
(defmethod ig/pre-init-spec ::gc-touched-task [_] (defmethod ig/pre-init-spec ::gc-touched-task [_]
(s/keys :req-un [::db/pool])) (s/keys :req-un [::db/pool]))
(defmethod ig/init-key ::gc-touched-task (defmethod ig/init-key ::gc-touched-task
[_ {:keys [pool] :as cfg}] [_ {:keys [pool] :as cfg}]
(letfn [(group-results [rows] (letfn [(has-team-font-variant-nrefs? [conn id]
(let [conj (fnil conj [])] (-> (db/exec-one! conn [sql:retrieve-team-font-variant-nrefs id id id id]) :nrefs pos?))
(reduce (fn [acc {:keys [id nrefs]}]
(if (pos? nrefs)
(update acc :to-freeze conj id)
(update acc :to-delete conj id)))
{}
rows)))
(retrieve-touched [conn] (has-file-media-object-nrefs? [conn id]
(let [rows (db/exec! conn [sql:retrieve-touched-objects])] (-> (db/exec-one! conn [sql:retrieve-file-media-object-nrefs id id]) :nrefs pos?))
(some-> (seq rows) (group-results))))
(mark-delete-in-bulk [conn ids]
(db/exec-one! conn ["update storage_object set deleted_at=now(), touched_at=null where id = ANY(?)"
(db/create-array conn "uuid" (into-array java.util.UUID ids))]))
(mark-freeze-in-bulk [conn ids] (mark-freeze-in-bulk [conn ids]
(db/exec-one! conn ["update storage_object set touched_at=null where id = ANY(?)" (db/exec-one! conn ["update storage_object set touched_at=null where id = ANY(?)"
(db/create-array conn "uuid" (into-array java.util.UUID ids))]))] (db/create-array conn "uuid" ids)]))
(mark-delete-in-bulk [conn ids]
(db/exec-one! conn ["update storage_object set deleted_at=now(), touched_at=null where id = ANY(?)"
(db/create-array conn "uuid" ids)]))
(retrieve-touched-chunk [conn cursor]
(let [rows (->> (db/exec! conn [sql:retrieve-touched-objects-chunk cursor])
(mapv #(d/update-when % :metadata db/decode-transit-pgobject)))
kw (fn [o] (if (keyword? o) o (keyword o)))]
(when (seq rows)
[(-> rows peek :created-at)
;; NOTE: we use the :file-media-object as default value for backward compatibility because when we
;; deploy it we can have old backend instances running in the same time as the new one and we can
;; still have storage-objects created without reference value. And we know that if it does not
;; have value, it means :file-media-object.
(d/group-by' #(or (some-> % :metadata :reference kw) :file-media-object) :id rows)])))
(retrieve-touched [conn]
(->> (d/iteration (fn [cursor]
(retrieve-touched-chunk conn cursor))
:initk (dt/now)
:vf second
:kf first)
(sequence cat)))
(process-objects! [conn pred-fn ids]
(loop [to-freeze #{}
to-delete #{}
ids (seq ids)]
(if-let [id (first ids)]
(if (pred-fn conn id)
(recur (conj to-freeze id) to-delete (rest ids))
(recur to-freeze (conj to-delete id) (rest ids)))
(do
(some->> (seq to-freeze) (mark-freeze-in-bulk conn))
(some->> (seq to-delete) (mark-delete-in-bulk conn))
[(count to-freeze) (count to-delete)]))))
]
(fn [_] (fn [_]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(loop [cntf 0 (loop [to-freeze 0
cntd 0] to-delete 0
(if-let [{:keys [to-delete to-freeze]} (retrieve-touched conn)] groups (retrieve-touched conn)]
(if-let [[reference ids] (first groups)]
(let [[f d] (case reference
:file-media-object (process-objects! conn has-file-media-object-nrefs? ids)
:team-font-variant (process-objects! conn has-team-font-variant-nrefs? ids)
(ex/raise :type :internal
:code :unexpected-unknown-reference
:hint (format "unknown reference %s" (pr-str reference))))]
(recur (+ to-freeze f)
(+ to-delete d)
(rest groups)))
(do (do
(when (seq to-delete) (mark-delete-in-bulk conn to-delete)) (l/info :task "gc-touched" :to-freeze to-freeze :to-delete to-delete)
(when (seq to-freeze) (mark-freeze-in-bulk conn to-freeze)) {:freeze to-freeze :delete to-delete})))))))
(recur (+ cntf (count to-freeze))
(+ cntd (count to-delete))))
(do
(l/info :task "gc-touched"
:hint "mark freeze"
:count cntf)
(l/info :task "gc-touched"
:hint "mark for deletion"
:count cntd)
{:freeze cntf :delete cntd})))))))
(def sql:retrieve-touched-objects (def sql:retrieve-touched-objects-chunk
"select so.id, "select so.* from storage_object as so
((select count(*) from file_media_object where media_id = so.id) +
(select count(*) from file_media_object where thumbnail_id = so.id)) as nrefs
from storage_object as so
where so.touched_at is not null where so.touched_at is not null
order by so.touched_at and so.created_at < ?
limit 100;") order by so.created_at desc
limit 500;")
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def sql:retrieve-file-media-object-nrefs
;; Recheck Stalled Task "select ((select count(*) from file_media_object where media_id = ?) +
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (select count(*) from file_media_object where thumbnail_id = ?)) as nrefs")
;; Because the physical storage (filesystem, s3, ... except db) is not (def sql:retrieve-team-font-variant-nrefs
;; transactional, in some situations we can found physical object "select ((select count(*) from team_font_variant where woff1_file_id = ?) +
;; leakage. That situations happens when the transaction that writes (select count(*) from team_font_variant where woff2_file_id = ?) +
;; the file aborts, leaving the file written to the underlying storage (select count(*) from team_font_variant where otf_file_id = ?) +
;; but the reference on the database is lost with the rollback. (select count(*) from team_font_variant where ttf_file_id = ?)) as nrefs")
;;
;; For this situations we need to write a "log" of inserted files that
;; are checked in some time in future. If physical file exists but the
;; database refence does not exists means that leaked file is found
;; and is immediately deleted. The responsibility of this task is
;; check that write log for possible leaked files.
(def recheck-min-age (dt/duration {:hours 1}))
(declare sql:retrieve-pending-to-recheck)
(declare sql:exists-storage-object)
(defmethod ig/pre-init-spec ::recheck-task [_]
(s/keys :req-un [::storage ::db/pool]))
(defmethod ig/init-key ::recheck-task
[_ {:keys [pool storage] :as cfg}]
(letfn [(group-results [rows]
(let [conj (fnil conj [])]
(reduce (fn [acc {:keys [id exist] :as row}]
(cond-> (update acc :all conj id)
(false? exist)
(update :to-delete conj (dissoc row :exist))))
{}
rows)))
(group-by-backend [rows]
(let [conj (fnil conj [])]
(reduce (fn [acc {:keys [id backend]}]
(update acc (keyword backend) conj id))
{}
rows)))
(retrieve-pending [conn]
(let [rows (db/exec! conn [sql:retrieve-pending-to-recheck (db/interval recheck-min-age)])]
(some-> (seq rows) (group-results))))
(delete-group [conn [backend ids]]
(let [backend (impl/resolve-backend storage backend)
backend (assoc backend :conn conn)]
(impl/del-objects-in-bulk backend ids)))
(delete-all [conn ids]
(let [ids (db/create-array conn "uuid" (into-array java.util.UUID ids))]
(db/exec-one! conn ["delete from storage_pending where id = ANY(?)" ids])))]
(fn [_]
(db/with-atomic [conn pool]
(loop [n 0 d 0]
(if-let [{:keys [all to-delete]} (retrieve-pending conn)]
(let [groups (group-by-backend to-delete)]
(run! (partial delete-group conn) groups)
(delete-all conn all)
(recur (+ n (count all))
(+ d (count to-delete))))
(do
(l/info :task "recheck"
:hint "recheck items"
:processed n
:deleted d)
{:processed n :deleted d})))))))
(def sql:retrieve-pending-to-recheck
"select sp.id,
sp.backend,
sp.created_at,
(case when count(so.id) > 0 then true
else false
end) as exist
from storage_pending as sp
left join storage_object as so
on (so.id = sp.id)
where sp.created_at < now() - ?::interval
group by 1,2,3
order by sp.created_at asc
limit 100")

View file

@ -56,9 +56,10 @@
(s/def ::region #{:eu-central-1}) (s/def ::region #{:eu-central-1})
(s/def ::bucket ::us/string) (s/def ::bucket ::us/string)
(s/def ::prefix ::us/string) (s/def ::prefix ::us/string)
(s/def ::endpoint ::us/string)
(defmethod ig/pre-init-spec ::backend [_] (defmethod ig/pre-init-spec ::backend [_]
(s/keys :opt-un [::region ::bucket ::prefix])) (s/keys :opt-un [::region ::bucket ::prefix ::endpoint]))
(defmethod ig/prep-key ::backend (defmethod ig/prep-key ::backend
[_ {:keys [prefix] :as cfg}] [_ {:keys [prefix] :as cfg}]
@ -119,20 +120,31 @@
(defn- ^Region lookup-region (defn- ^Region lookup-region
[region] [region]
(case region (Region/of (name region)))
:eu-central-1 Region/EU_CENTRAL_1))
(defn build-s3-client (defn build-s3-client
[{:keys [region]}] [{:keys [region endpoint]}]
(if (string? endpoint)
(let [uri (java.net.URI. endpoint)]
(.. (S3Client/builder)
(endpointOverride uri)
(region (lookup-region region))
(build)))
(.. (S3Client/builder) (.. (S3Client/builder)
(region (lookup-region region)) (region (lookup-region region))
(build))) (build))))
(defn build-s3-presigner (defn build-s3-presigner
[{:keys [region]}] [{:keys [region endpoint]}]
(if (string? endpoint)
(let [uri (java.net.URI. endpoint)]
(.. (S3Presigner/builder) (.. (S3Presigner/builder)
(endpointOverride uri)
(region (lookup-region region)) (region (lookup-region region))
(build))) (build)))
(.. (S3Presigner/builder)
(region (lookup-region region))
(build))))
(defn put-object (defn put-object
[{:keys [client bucket prefix]} {:keys [id] :as object} content] [{:keys [client bucket prefix]} {:keys [id] :as object} content]

View file

@ -10,6 +10,7 @@
after some period of inactivity (the default threshold is 72h)." after some period of inactivity (the default threshold is 72h)."
(:require (:require
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pages.helpers :as cph]
[app.common.pages.migrations :as pmg] [app.common.pages.migrations :as pmg]
[app.db :as db] [app.db :as db]
[app.util.blob :as blob] [app.util.blob :as blob]
@ -52,6 +53,7 @@
limit 10 limit 10
for update skip locked") for update skip locked")
(defn- retrieve-candidates (defn- retrieve-candidates
[{:keys [conn max-age] :as cfg}] [{:keys [conn max-age] :as cfg}]
(let [interval (db/interval max-age)] (let [interval (db/interval max-age)]
@ -64,12 +66,11 @@
(comp (comp
(map :objects) (map :objects)
(mapcat vals) (mapcat vals)
(map (fn [{:keys [type] :as obj}] (keep (fn [{:keys [type] :as obj}]
(case type (case type
:path (get-in obj [:fill-image :id]) :path (get-in obj [:fill-image :id])
:image (get-in obj [:metadata :id]) :image (get-in obj [:metadata :id])
nil))) nil)))))
(filter uuid?)))
(defn- collect-used-media (defn- collect-used-media
[data] [data]
@ -80,13 +81,28 @@
(into collect-media-xf pages) (into collect-media-xf pages)
(into (keys (:media data)))))) (into (keys (:media data))))))
(def ^:private
collect-frames-xf
(comp
(map :objects)
(mapcat vals)
(filter cph/frame-shape?)
(keep :id)))
(defn- collect-frames
[data]
(let [pages (concat
(vals (:pages-index data))
(vals (:components data)))]
(into #{} collect-frames-xf pages)))
(defn- process-file (defn- process-file
[{:keys [conn] :as cfg} {:keys [id data age] :as file}] [{:keys [conn] :as cfg} {:keys [id data age] :as file}]
(let [data (-> (blob/decode data) (let [data (-> (blob/decode data)
(assoc :id id) (assoc :id id)
(pmg/migrate-data)) (pmg/migrate-data))]
used (collect-used-media data) (let [used (collect-used-media data)
unused (->> (db/query conn :file-media-object {:file-id id}) unused (->> (db/query conn :file-media-object {:file-id id})
(remove #(contains? used (:id %))))] (remove #(contains? used (:id %))))]
@ -111,6 +127,13 @@
;; objects. The touch mechanism is needed because many files can ;; objects. The touch mechanism is needed because many files can
;; point to the same storage objects and we can't just delete ;; point to the same storage objects and we can't just delete
;; them. ;; them.
(db/delete! conn :file-media-object {:id (:id mobj)})) (db/delete! conn :file-media-object {:id (:id mobj)})))
(let [sql (str "delete from file_frame_thumbnail "
" where file_id = ? and not (frame_id = ANY(?))")
ids (->> (collect-frames data)
(db/create-array conn "uuid"))]
;; delete the unused frame thumbnails
(db/exec! conn [sql (:id file) ids]))
nil)) nil))

View file

@ -38,14 +38,17 @@
(defmethod ig/init-key ::handler (defmethod ig/init-key ::handler
[_ {:keys [pool sprops version] :as cfg}] [_ {:keys [pool sprops version] :as cfg}]
(fn [_] (fn [{:keys [send?] :or {send? true}}]
;; Sleep randomly between 0 to 10s ;; Sleep randomly between 0 to 10s
(thread-sleep (rand-int 10000)) (when send?
(thread-sleep (rand-int 10000)))
(let [instance-id (:instance-id sprops)] (let [instance-id (:instance-id sprops)
(-> (get-stats pool version) stats (-> (get-stats pool version)
(assoc :instance-id instance-id) (assoc :instance-id instance-id))]
(send! cfg))))) (when send?
(send! stats cfg))
stats)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMPL ;; IMPL
@ -137,12 +140,28 @@
(->> [sql:team-averages] (->> [sql:team-averages]
(db/exec-one! conn))) (db/exec-one! conn)))
(defn- retrieve-enabled-auth-providers
[conn]
(let [sql (str "select auth_backend as backend, count(*) as total "
" from profile group by 1")
rows (db/exec! conn [sql])]
(->> rows
(map (fn [{:keys [backend total]}]
(let [backend (or backend "penpot")]
[(keyword (str "auth-backend-" backend))
total])))
(into {}))))
(defn- retrieve-jvm-stats (defn- retrieve-jvm-stats
[] []
(let [^Runtime runtime (Runtime/getRuntime)] (let [^Runtime runtime (Runtime/getRuntime)]
{:jvm-heap-current (.totalMemory runtime) {:jvm-heap-current (.totalMemory runtime)
:jvm-heap-max (.maxMemory runtime) :jvm-heap-max (.maxMemory runtime)
:jvm-cpus (.availableProcessors runtime)})) :jvm-cpus (.availableProcessors runtime)
:os-arch (System/getProperty "os.arch")
:os-name (System/getProperty "os.name")
:os-version (System/getProperty "os.version")
:user-tz (System/getProperty "user.timezone")}))
(defn get-stats (defn get-stats
[conn version] [conn version]
@ -161,6 +180,7 @@
:total-touched-files (retrieve-num-touched-files conn)} :total-touched-files (retrieve-num-touched-files conn)}
(d/merge (d/merge
(retrieve-team-averages conn) (retrieve-team-averages conn)
(retrieve-jvm-stats)) (retrieve-jvm-stats)
(retrieve-enabled-auth-providers conn))
(d/without-nils)))) (d/without-nils))))

View file

@ -7,6 +7,7 @@
(ns app.tokens (ns app.tokens
"Tokens generation service." "Tokens generation service."
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.transit :as t] [app.common.transit :as t]
@ -17,7 +18,7 @@
(defn- generate (defn- generate
[cfg claims] [cfg claims]
(let [payload (t/encode claims)] (let [payload (-> claims d/without-nils t/encode)]
(jwe/encrypt payload (::secret cfg) {:alg :a256kw :enc :a256gcm}))) (jwe/encrypt payload (::secret cfg) {:alg :a256kw :enc :a256gcm})))
(defn- verify (defn- verify

View file

@ -7,7 +7,8 @@
(ns app.util.async (ns app.util.async
(:require (:require
[clojure.core.async :as a] [clojure.core.async :as a]
[clojure.spec.alpha :as s]) [clojure.spec.alpha :as s]
[promesa.exec :as px])
(:import (:import
java.util.concurrent.Executor)) java.util.concurrent.Executor))
@ -54,13 +55,16 @@
(a/close! c) (a/close! c)
c)))) c))))
(defmacro with-thread (defmacro with-thread
[executor & body] [executor & body]
(if (= executor ::default) (if (= executor ::default)
`(a/thread-call (^:once fn* [] (try ~@body (catch Exception e# e#)))) `(a/thread-call (^:once fn* [] (try ~@body (catch Exception e# e#))))
`(thread-call ~executor (^:once fn* [] ~@body)))) `(thread-call ~executor (^:once fn* [] ~@body))))
(defmacro with-dispatch
[executor & body]
`(px/submit! ~executor (^:once fn* [] ~@body)))
(defn batch (defn batch
[in {:keys [max-batch-size [in {:keys [max-batch-size
max-batch-age max-batch-age

View file

@ -1,43 +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) UXBOX Labs SL
(ns app.util.retry
"A fault tolerance helpers. Allow retry some operations that we know
we can retry."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.util.async :as aa]
[app.util.services :as sv]))
(defn conflict-db-insert?
"Check if exception matches a insertion conflict on postgresql."
[e]
(and (instance? org.postgresql.util.PSQLException e)
(= "23505" (.getSQLState e))))
(defn wrap-retry
[_ f {:keys [::max-retries ::matches ::sv/name]
:or {max-retries 3
matches (constantly false)}
:as mdata}]
(when (::enabled mdata)
(l/debug :hint "wrapping retry" :name name))
(if (::enabled mdata)
(fn [cfg params]
(loop [retry 1]
(when (> retry 1)
(l/debug :hint "retrying controlled function" :retry retry :name name))
(let [res (ex/try (f cfg params))]
(if (ex/exception? res)
(if (and (matches res) (< retry max-retries))
(do
(aa/thread-sleep (* 100 retry))
(recur (inc retry)))
(throw res))
res))))
f))

View file

@ -1,36 +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) UXBOX Labs SL
(ns app.util.rlimit
"Resource usage limits (in other words: semaphores)."
(:require
[app.common.logging :as l]
[app.util.services :as sv])
(:import
java.util.concurrent.Semaphore))
(defn acquire!
[sem]
(.acquire ^Semaphore sem))
(defn release!
[sem]
(.release ^Semaphore sem))
(defn wrap-rlimit
[_cfg f mdata]
(if-let [permits (::permits mdata)]
(let [sem (Semaphore. permits)]
(l/debug :hint "wrapping rlimit" :handler (::sv/name mdata) :permits permits)
(fn [cfg params]
(try
(acquire! sem)
(f cfg params)
(finally
(release! sem)))))
f))

View file

@ -27,11 +27,6 @@
(declare ws-ping!) (declare ws-ping!)
(declare ws-send!) (declare ws-send!)
(defmacro call-mtx
[definitions name & args]
`(when-let [mtx-fn# (some-> ~definitions ~name ::mtx/fn)]
(mtx-fn# ~@args)))
(def noop (constantly nil)) (def noop (constantly nil))
(defn handler (defn handler
@ -49,7 +44,7 @@
([handle-message {:keys [::input-buff-size ([handle-message {:keys [::input-buff-size
::output-buff-size ::output-buff-size
::idle-timeout ::idle-timeout
::metrics] metrics]
:or {input-buff-size 64 :or {input-buff-size 64
output-buff-size 64 output-buff-size 64
idle-timeout 30000} idle-timeout 30000}
@ -71,8 +66,8 @@
on-terminate on-terminate
(fn [& _args] (fn [& _args]
(when (compare-and-set! terminated false true) (when (compare-and-set! terminated false true)
(call-mtx metrics :connections {:cmd :dec :by 1}) (mtx/run! metrics {:id :websocket-active-connections :dec 1})
(call-mtx metrics :sessions {:val (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0)}) (mtx/run! metrics {:id :websocket-session-timing :val (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0)})
(a/close! close-ch) (a/close! close-ch)
(a/close! pong-ch) (a/close! pong-ch)
@ -88,7 +83,7 @@
on-connect on-connect
(fn [conn] (fn [conn]
(call-mtx metrics :connections {:cmd :inc :by 1}) (mtx/run! metrics {:id :websocket-active-connections :inc 1})
(let [wsp (atom (assoc options ::conn conn))] (let [wsp (atom (assoc options ::conn conn))]
;; Handle heartbeat ;; Handle heartbeat
@ -102,7 +97,7 @@
;; connection ;; connection
(a/go-loop [] (a/go-loop []
(when-let [val (a/<! output-ch)] (when-let [val (a/<! output-ch)]
(call-mtx metrics :messages {:labels ["send"]}) (mtx/run! metrics {:id :websocket-messages-total :labels ["send"] :inc 1})
(a/<! (ws-send! conn (t/encode-str val))) (a/<! (ws-send! conn (t/encode-str val)))
(recur))) (recur)))
@ -111,7 +106,7 @@
on-message on-message
(fn [_ message] (fn [_ message]
(call-mtx metrics :messages {:labels ["recv"]}) (mtx/run! metrics {:id :websocket-messages-total :labels ["send"] :inc 1})
(try (try
(let [message (t/decode-str message)] (let [message (t/decode-str message)]
(a/offer! input-ch message)) (a/offer! input-ch message))

View file

@ -22,44 +22,100 @@
[integrant.core :as ig] [integrant.core :as ig]
[promesa.exec :as px]) [promesa.exec :as px])
(:import (:import
org.eclipse.jetty.util.thread.QueuedThreadPool
java.util.concurrent.ExecutorService java.util.concurrent.ExecutorService
java.util.concurrent.Executors java.util.concurrent.ForkJoinPool
java.util.concurrent.Executor)) java.util.concurrent.ForkJoinWorkerThread
java.util.concurrent.ForkJoinPool$ForkJoinWorkerThreadFactory
java.util.concurrent.atomic.AtomicLong
java.util.concurrent.Executors))
(s/def ::executor #(instance? Executor %)) (set! *warn-on-reflection* true)
(s/def ::executor #(instance? ExecutorService %))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Executor ;; Executor
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::name keyword?) (s/def ::prefix keyword?)
(s/def ::parallelism ::us/integer)
(s/def ::min-threads ::us/integer) (s/def ::min-threads ::us/integer)
(s/def ::max-threads ::us/integer) (s/def ::max-threads ::us/integer)
(s/def ::idle-timeout ::us/integer) (s/def ::idle-timeout ::us/integer)
(defmethod ig/pre-init-spec ::executor [_] (defmethod ig/pre-init-spec ::executor [_]
(s/keys :req-un [::min-threads ::max-threads ::idle-timeout ::name])) (s/keys :req-un [::prefix ::parallelism]))
(defn- get-thread-factory
^ForkJoinPool$ForkJoinWorkerThreadFactory
[prefix counter]
(reify ForkJoinPool$ForkJoinWorkerThreadFactory
(newThread [_ pool]
(let [^ForkJoinWorkerThread thread (.newThread ForkJoinPool/defaultForkJoinWorkerThreadFactory pool)
^String thread-name (str (name prefix) "-" (.getAndIncrement ^AtomicLong counter))]
(.setName thread thread-name)
thread))))
(defmethod ig/init-key ::executor (defmethod ig/init-key ::executor
[_ {:keys [min-threads max-threads idle-timeout name]}] [_ {:keys [parallelism prefix]}]
(doto (QueuedThreadPool. (int max-threads) (let [counter (AtomicLong. 0)]
(int min-threads) (ForkJoinPool. (int parallelism) (get-thread-factory prefix counter) nil false)))
(int idle-timeout))
(.setStopTimeout 500)
(.setName (d/name name))
(.start)))
(defmethod ig/halt-key! ::executor (defmethod ig/halt-key! ::executor
[_ instance] [_ instance]
(.stop ^QueuedThreadPool instance)) (.shutdown ^ForkJoinPool instance))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Executor Monitor
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::executors (s/map-of keyword? ::executor))
(defmethod ig/pre-init-spec ::executors-monitor [_]
(s/keys :req-un [::executors ::mtx/metrics]))
(defmethod ig/init-key ::executors-monitor
[_ {:keys [executors metrics interval] :or {interval 3000}}]
(letfn [(log-stats [scheduler state]
(doseq [[key ^ForkJoinPool executor] executors]
(let [labels (into-array String [(name key)])
active (.getActiveThreadCount executor)
running (.getRunningThreadCount executor)
queued (.getQueuedSubmissionCount executor)
steals (.getStealCount executor)
steals-increment (- steals (or (get-in @state [key :steals]) 0))
steals-increment (if (neg? steals-increment) 0 steals-increment)]
(mtx/run! metrics {:id :executors-active-threads :labels labels :val active})
(mtx/run! metrics {:id :executors-running-threads :labels labels :val running})
(mtx/run! metrics {:id :executors-queued-submissions :labels labels :val queued})
(mtx/run! metrics {:id :executors-completed-tasks :labels labels :inc steals-increment})
(swap! state update key assoc
:running running
:active active
:queued queued
:steals steals)))
(when-not (.isShutdown scheduler)
(px/schedule! scheduler interval (partial log-stats scheduler state))))]
(let [scheduler (px/scheduled-pool 1)
state (atom {})]
(px/schedule! scheduler interval (partial log-stats scheduler state))
{::scheduler scheduler
::state state})))
(defmethod ig/halt-key! ::executors-monitor
[_ {:keys [::scheduler]}]
(.shutdown ^ExecutorService scheduler))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Worker ;; Worker
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare event-loop-fn) (declare event-loop-fn)
(declare instrument-tasks) (declare event-loop)
(s/def ::queue keyword?) (s/def ::queue keyword?)
(s/def ::parallelism ::us/integer) (s/def ::parallelism ::us/integer)
@ -85,13 +141,10 @@
:queue :default} :queue :default}
(d/without-nils cfg))) (d/without-nils cfg)))
(defmethod ig/init-key ::worker (defn- event-loop
[_ {:keys [pool poll-interval name queue] :as cfg}] "Main, worker eventloop"
(l/info :action "start worker" [{:keys [pool poll-interval close-ch] :as cfg}]
:name (d/name name) (let [poll-ms (inst-ms poll-interval)]
:queue (d/name queue))
(let [close-ch (a/chan 1)
poll-ms (inst-ms poll-interval)]
(a/go-loop [] (a/go-loop []
(let [[val port] (a/alts! [close-ch (event-loop-fn cfg)] :priority true)] (let [[val port] (a/alts! [close-ch (event-loop-fn cfg)] :priority true)]
(cond (cond
@ -100,7 +153,7 @@
(or (= port close-ch) (nil? val)) (or (= port close-ch) (nil? val))
(l/debug :hint "stop condition found") (l/debug :hint "stop condition found")
(db/pool-closed? pool) (db/closed? pool)
(do (do
(l/debug :hint "eventloop aborted because pool is closed") (l/debug :hint "eventloop aborted because pool is closed")
(a/close! close-ch)) (a/close! close-ch))
@ -132,14 +185,27 @@
(= ::empty val) (= ::empty val)
(do (do
(a/<! (a/timeout poll-ms)) (a/<! (a/timeout poll-ms))
(recur))))) (recur)))))))
(defmethod ig/init-key ::worker
[_ {:keys [pool name queue] :as cfg}]
(let [close-ch (a/chan 1)
cfg (assoc cfg :close-ch close-ch)]
(if (db/read-only? pool)
(l/warn :hint "worker not started, db is read-only"
:name (d/name name)
:queue (d/name queue))
(do
(l/info :hint "worker started"
:name (d/name name)
:queue (d/name queue))
(event-loop cfg)))
(reify (reify
java.lang.AutoCloseable java.lang.AutoCloseable
(close [_] (close [_]
(a/close! close-ch))))) (a/close! close-ch)))))
(defmethod ig/halt-key! ::worker (defmethod ig/halt-key! ::worker
[_ instance] [_ instance]
(.close ^java.lang.AutoCloseable instance)) (.close ^java.lang.AutoCloseable instance))
@ -350,9 +416,11 @@
(s/keys :req-un [::executor ::db/pool ::schedule ::tasks])) (s/keys :req-un [::executor ::db/pool ::schedule ::tasks]))
(defmethod ig/init-key ::scheduler (defmethod ig/init-key ::scheduler
[_ {:keys [schedule tasks] :as cfg}] [_ {:keys [schedule tasks pool] :as cfg}]
(let [scheduler (Executors/newScheduledThreadPool (int 1)) (let [scheduler (Executors/newScheduledThreadPool (int 1))]
schedule (->> schedule (if (db/read-only? pool)
(l/warn :hint "scheduler not started, db is read-only")
(let [schedule (->> schedule
(filter some?) (filter some?)
;; If id is not defined, use the task as id. ;; If id is not defined, use the task as id.
(map (fn [{:keys [id task] :as item}] (map (fn [{:keys [id task] :as item}]
@ -371,10 +439,12 @@
cfg (assoc cfg cfg (assoc cfg
:scheduler scheduler :scheduler scheduler
:schedule schedule)] :schedule schedule)]
(l/info :hint "scheduler started"
:registred-tasks (count schedule))
(synchronize-schedule cfg) (synchronize-schedule cfg)
(run! (partial schedule-task cfg) (run! (partial schedule-task cfg)
(filter some? schedule)) (filter some? schedule))))
(reify (reify
java.lang.AutoCloseable java.lang.AutoCloseable
@ -405,11 +475,6 @@
(def sql:lock-scheduled-task (def sql:lock-scheduled-task
"select id from scheduled_task where id=? for update skip locked") "select id from scheduled_task where id=? for update skip locked")
(defn exception->string
[error]
(with-out-str
(.printStackTrace ^Throwable error (java.io.PrintWriter. *out*))))
(defn- execute-scheduled-task (defn- execute-scheduled-task
[{:keys [executor pool] :as cfg} {:keys [id] :as task}] [{:keys [executor pool] :as cfg} {:keys [id] :as task}]
(letfn [(run-task [conn] (letfn [(run-task [conn]
@ -445,59 +510,27 @@
;; --- INSTRUMENTATION ;; --- INSTRUMENTATION
(defn instrument! (defn- wrap-task-handler
[registry] [metrics tname f]
(mtx/instrument-vars! (let [labels (into-array String [tname])]
[#'submit!] (fn [params]
{:registry registry (let [start (System/nanoTime)]
:type :counter (try
:labels ["name"] (f params)
:name "tasks_submit_total" (finally
:help "A counter of task submissions." (mtx/run! metrics
:wrap (fn [rootf mobj] {:id :tasks-timing
(let [mdata (meta rootf) :val (/ (- (System/nanoTime) start) 1000000)
origf (::original mdata rootf)] :labels labels})))))))
(with-meta
(fn [conn params]
(let [tname (:name params)]
(mobj :inc [tname])
(origf conn params)))
{::original origf})))})
(mtx/instrument-vars!
[#'app.worker/run-task]
{:registry registry
:type :summary
:quantiles []
:name "tasks_checkout_timing"
:help "Latency measured between scheduled_at and execution time."
:wrap (fn [rootf mobj]
(let [mdata (meta rootf)
origf (::original mdata rootf)]
(with-meta
(fn [tasks item]
(let [now (inst-ms (dt/now))
sat (inst-ms (:scheduled-at item))]
(mobj :observe (- now sat))
(origf tasks item)))
{::original origf})))}))
(defmethod ig/pre-init-spec ::registry [_] (defmethod ig/pre-init-spec ::registry [_]
(s/keys :req-un [::mtx/metrics ::tasks])) (s/keys :req-un [::mtx/metrics ::tasks]))
(defmethod ig/init-key ::registry (defmethod ig/init-key ::registry
[_ {:keys [metrics tasks]}] [_ {:keys [metrics tasks]}]
(let [mobj (mtx/create
{:registry (:registry metrics)
:type :summary
:labels ["name"]
:quantiles []
:name "tasks_timing"
:help "Background task execution timing."})]
(reduce-kv (fn [res k v] (reduce-kv (fn [res k v]
(let [tname (name k)] (let [tname (name k)]
(l/debug :action "register task" :name tname) (l/debug :hint "register task" :name tname)
(assoc res k (mtx/wrap-summary v mobj [tname])))) (assoc res k (wrap-task-handler metrics tname v))))
{} {}
tasks))) tasks))

View file

@ -174,6 +174,14 @@
:type :image :type :image
:metadata {:id (:id fmo1)}}}]})] :metadata {:id (:id fmo1)}}}]})]
;; If we launch gc-touched-task, we should have 4 items to freeze.
(let [task (:app.storage/gc-touched-task th/*system*)
res (task {})]
(t/is (= 4 (:freeze res)))
(t/is (= 0 (:delete res))))
;; run the task immediately ;; run the task immediately
(let [task (:app.tasks.file-media-gc/handler th/*system*) (let [task (:app.tasks.file-media-gc/handler th/*system*)
res (task {})] res (task {})]
@ -202,16 +210,22 @@
(t/is (some? (sto/get-object storage (:media-id fmo1)))) (t/is (some? (sto/get-object storage (:media-id fmo1))))
(t/is (some? (sto/get-object storage (:thumbnail-id fmo1)))) (t/is (some? (sto/get-object storage (:thumbnail-id fmo1))))
;; but if we pass the touched gc task two of them should disappear ;; now, we have deleted the unused file-media-object, if we
;; execute the touched-gc task, we should see that two of them
;; are marked to be deleted.
(let [task (:app.storage/gc-touched-task th/*system*) (let [task (:app.storage/gc-touched-task th/*system*)
res (task {})] res (task {})]
(t/is (= 0 (:freeze res))) (t/is (= 0 (:freeze res)))
(t/is (= 2 (:delete res))) (t/is (= 2 (:delete res))))
;; Finally, check that some of the objects that are marked as
;; deleted we are unable to retrieve them using standard storage
;; public api.
(t/is (nil? (sto/get-object storage (:media-id fmo2)))) (t/is (nil? (sto/get-object storage (:media-id fmo2))))
(t/is (nil? (sto/get-object storage (:thumbnail-id fmo2)))) (t/is (nil? (sto/get-object storage (:thumbnail-id fmo2))))
(t/is (some? (sto/get-object storage (:media-id fmo1)))) (t/is (some? (sto/get-object storage (:media-id fmo1))))
(t/is (some? (sto/get-object storage (:thumbnail-id fmo1))))) (t/is (some? (sto/get-object storage (:thumbnail-id fmo1))))
))) )))
@ -389,3 +403,73 @@
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found)))) (t/is (= (:type error-data) :not-found))))
)) ))
(t/deftest query-frame-thumbnails
(let [prof (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
data {::th/type :file-frame-thumbnail
:profile-id (:id prof)
:file-id (:id file)
:frame-id (uuid/next)}]
;;insert an entry on the database with a test value for the thumbnail of this frame
(db/exec-one! th/*pool*
["insert into file_frame_thumbnail(file_id, frame_id, data) values (?, ?, ?)"
(:file-id data) (:frame-id data) "testvalue"])
(let [out (th/query! data)]
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 1 (count result)))
(t/is (= "testvalue" (:data result)))))))
(t/deftest insert-frame-thumbnails
(let [prof (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
data {::th/type :upsert-frame-thumbnail
:profile-id (:id prof)
:file-id (:id file)
:frame-id (uuid/next)
:data "test insert new value"}
out (th/mutation! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
;;retrieve the value from the database and check its content
(let [result (db/exec-one!
th/*pool*
["select data from file_frame_thumbnail where file_id = ? and frame_id = ?"
(:file-id data) (:frame-id data)])]
(t/is (= "test insert new value" (:data result))))))
(t/deftest frame-thumbnails
(let [prof (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
data {::th/type :upsert-frame-thumbnail
:profile-id (:id prof)
:file-id (:id file)
:frame-id (uuid/next)
:data "updated value"}]
;;insert an entry on the database with and old value for the thumbnail of this frame
(db/exec-one! th/*pool*
["insert into file_frame_thumbnail(file_id, frame_id, data) values (?, ?, ?)"
(:file-id data) (:frame-id data) "old value"])
(let [out (th/mutation! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
;;retrieve the value from the database and check its content
(let [result (db/exec-one!
th/*pool*
["select data from file_frame_thumbnail where file_id = ? and frame_id = ?"
(:file-id data) (:frame-id data)])]
(t/is (= "updated value" (:data result)))))))

View file

@ -11,6 +11,7 @@
[app.http :as http] [app.http :as http]
[app.storage :as sto] [app.storage :as sto]
[app.test-helpers :as th] [app.test-helpers :as th]
[app.storage-test :refer [configure-storage-backend]]
[clojure.test :as t] [clojure.test :as t]
[buddy.core.bytes :as b] [buddy.core.bytes :as b]
[datoteka.core :as fs])) [datoteka.core :as fs]))
@ -19,7 +20,9 @@
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
(t/deftest duplicate-file (t/deftest duplicate-file
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
sobject (sto/put-object storage {:content (sto/content "content") sobject (sto/put-object storage {:content (sto/content "content")
:content-type "text/plain" :content-type "text/plain"
:other "data"}) :other "data"})
@ -90,7 +93,8 @@
)))) ))))
(t/deftest duplicate-file-with-deleted-rels (t/deftest duplicate-file-with-deleted-rels
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
sobject (sto/put-object storage {:content (sto/content "content") sobject (sto/put-object storage {:content (sto/content "content")
:content-type "text/plain" :content-type "text/plain"
:other "data"}) :other "data"})
@ -151,7 +155,9 @@
)))) ))))
(t/deftest duplicate-project (t/deftest duplicate-project
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
sobject (sto/put-object storage {:content (sto/content "content") sobject (sto/put-object storage {:content (sto/content "content")
:content-type "text/plain" :content-type "text/plain"
:other "data"}) :other "data"})
@ -221,7 +227,8 @@
))))) )))))
(t/deftest duplicate-project-with-deleted-files (t/deftest duplicate-project-with-deleted-files
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
sobject (sto/put-object storage {:content (sto/content "content") sobject (sto/put-object storage {:content (sto/content "content")
:content-type "text/plain" :content-type "text/plain"
:other "data"}) :other "data"})

View file

@ -240,6 +240,16 @@
(t/is (nil? error)) (t/is (nil? error))
(t/is (string? (:token result)))))) (t/is (string? (:token result))))))
(t/deftest test-register-profile-with-email-as-password
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:password "USER@example.com"}]
(let [{:keys [result error] :as out} (th/mutation! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-as-password)))))
(t/deftest test-email-change-request (t/deftest test-email-change-request
(with-mocks [email-send-mock {:target 'app.emails/send! :return nil} (with-mocks [email-send-mock {:target 'app.emails/send! :return nil}
cfg-get-mock {:target 'app.config/get cfg-get-mock {:target 'app.config/get
@ -345,3 +355,39 @@
(t/is (th/ex-of-code? error :email-has-permanent-bounces))) (t/is (th/ex-of-code? error :email-has-permanent-bounces)))
))) )))
(t/deftest update-profile-password
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
:profile-id (:id profile)
:old-password "123123"
:password "foobarfoobar"}
out (th/mutation! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
))
(t/deftest update-profile-password-bad-old-password
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
:profile-id (:id profile)
:old-password "badpassword"
:password "foobarfoobar"}
{:keys [result error] :as out} (th/mutation! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :old-password-not-match))))
(t/deftest update-profile-password-email-as-password
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
:profile-id (:id profile)
:old-password "123123"
:password "profile1.test@nodomain.com"}
{:keys [result error] :as out} (th/mutation! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-as-password))))

View file

@ -7,6 +7,7 @@
(ns app.storage-test (ns app.storage-test
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.storage :as sto] [app.storage :as sto]
[app.test-helpers :as th] [app.test-helpers :as th]
@ -22,9 +23,19 @@
th/database-reset th/database-reset
th/clean-storage)) th/clean-storage))
(defn configure-storage-backend
"Given storage map, returns a storage configured with the appropriate
backend for assets."
([storage]
(assoc storage :backend :tmp))
([storage conn]
(-> storage
(assoc :conn conn)
(assoc :backend :tmp))))
(t/deftest put-and-retrieve-object (t/deftest put-and-retrieve-object
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content (sto/content "content") content (sto/content "content")
object (sto/put-object storage {:content content object (sto/put-object storage {:content content
:content-type "text/plain" :content-type "text/plain"
@ -39,9 +50,9 @@
(t/is (= "content" (slurp (sto/get-object-path storage object)))) (t/is (= "content" (slurp (sto/get-object-path storage object))))
)) ))
(t/deftest put-and-retrieve-expired-object (t/deftest put-and-retrieve-expired-object
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content (sto/content "content") content (sto/content "content")
object (sto/put-object storage {:content content object (sto/put-object storage {:content content
:content-type "text/plain" :content-type "text/plain"
@ -59,7 +70,8 @@
)) ))
(t/deftest put-and-delete-object (t/deftest put-and-delete-object
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content (sto/content "content") content (sto/content "content")
object (sto/put-object storage {:content content object (sto/put-object storage {:content content
:content-type "text/plain" :content-type "text/plain"
@ -79,7 +91,8 @@
)) ))
(t/deftest test-deleted-gc-task (t/deftest test-deleted-gc-task
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content (sto/content "content") content (sto/content "content")
object1 (sto/put-object storage {:content content object1 (sto/put-object storage {:content content
:content-type "text/plain" :content-type "text/plain"
@ -96,14 +109,17 @@
(let [res (db/exec-one! th/*pool* ["select count(*) from storage_object;"])] (let [res (db/exec-one! th/*pool* ["select count(*) from storage_object;"])]
(t/is (= 1 (:count res)))))) (t/is (= 1 (:count res))))))
(t/deftest test-touched-gc-task (t/deftest test-touched-gc-task-1
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
prof (th/create-profile* 1) prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof) proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)}) :team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof) file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof) :project-id (:default-project-id prof)
:is-shared false}) :is-shared false})
mfile {:filename "sample.jpg" mfile {:filename "sample.jpg"
:tempfile (th/tempfile "app/test_files/sample.jpg") :tempfile (th/tempfile "app/test_files/sample.jpg")
:content-type "image/jpeg" :content-type "image/jpeg"
@ -140,12 +156,12 @@
;; now check if the storage objects are touched ;; now check if the storage objects are touched
(let [res (db/exec-one! th/*pool* ["select count(*) from storage_object where touched_at is not null"])] (let [res (db/exec-one! th/*pool* ["select count(*) from storage_object where touched_at is not null"])]
(t/is (= 2 (:count res)))) (t/is (= 4 (:count res))))
;; run the touched gc task ;; run the touched gc task
(let [task (:app.storage/gc-touched-task th/*system*) (let [task (:app.storage/gc-touched-task th/*system*)
res (task {})] res (task {})]
(t/is (= 0 (:freeze res))) (t/is (= 2 (:freeze res)))
(t/is (= 2 (:delete res)))) (t/is (= 2 (:delete res))))
;; now check that there are no touched objects ;; now check that there are no touched objects
@ -157,8 +173,85 @@
(t/is (= 2 (:count res)))) (t/is (= 2 (:count res))))
))) )))
(t/deftest test-touched-gc-task-2
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id team-id})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id proj-id
:is-shared false})
ttfdata (-> (io/resource "app/test_files/font-1.ttf")
(fs/slurp-bytes))
mfile {:filename "sample.jpg"
:tempfile (th/tempfile "app/test_files/sample.jpg")
:content-type "image/jpeg"
:size 312043}
params1 {::th/type :upload-file-media-object
:profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
params2 {::th/type :create-font-variant
:profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:data {"font/ttf" ttfdata}}
out1 (th/mutation! params1)
out2 (th/mutation! params2)]
;; (th/print-result! out)
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
;; run the touched gc task
(let [task (:app.storage/gc-touched-task th/*system*)
res (task {})]
(t/is (= 6 (:freeze res)))
(t/is (= 0 (:delete res)))
(let [result-1 (:result out1)
result-2 (:result out2)]
;; now we proceed to manually delete one team-font-variant
(db/exec-one! th/*pool* ["delete from team_font_variant where id = ?" (:id result-2)])
;; revert touched state to all storage objects
(db/exec-one! th/*pool* ["update storage_object set touched_at=now()"])
;; Run the task again
(let [res (task {})]
(t/is (= 2 (:freeze res)))
(t/is (= 4 (:delete res))))
;; now check that there are no touched objects
(let [res (db/exec-one! th/*pool* ["select count(*) from storage_object where touched_at is not null"])]
(t/is (= 0 (:count res))))
;; now check that all objects are marked to be deleted
(let [res (db/exec-one! th/*pool* ["select count(*) from storage_object where deleted_at is not null"])]
(t/is (= 4 (:count res))))))))
(t/deftest test-touched-gc-task-without-delete (t/deftest test-touched-gc-task-without-delete
(let [storage (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
prof (th/create-profile* 1) prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof) proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)}) :team-id (:default-team-id prof)})
@ -198,72 +291,3 @@
;; check that we have all object in the db ;; check that we have all object in the db
(let [res (db/exec-one! th/*pool* ["select count(*) from storage_object where deleted_at is null"])] (let [res (db/exec-one! th/*pool* ["select count(*) from storage_object where deleted_at is null"])]
(t/is (= 4 (:count res))))))) (t/is (= 4 (:count res)))))))
;; Recheck is the mechanism for delete leaked resources on
;; transaction failure.
(t/deftest test-recheck
(let [storage (:app.storage/storage th/*system*)
content (sto/content "content")
object (sto/put-object storage {:content content
:content-type "text/plain"})]
;; Sleep fo 50ms
(th/sleep 50)
(let [rows (db/exec! th/*pool* ["select * from storage_pending"])]
(t/is (= 1 (count rows)))
(t/is (= (:id object) (:id (first rows)))))
;; Artificially make all storage_pending object 1 hour older.
(db/exec-one! th/*pool* ["update storage_pending set created_at = created_at - '1 hour'::interval"])
;; Sleep fo 50ms
(th/sleep 50)
;; Run recheck task
(let [task (:app.storage/recheck-task th/*system*)
res (task {})]
(t/is (= 1 (:processed res)))
(t/is (= 0 (:deleted res))))
;; After recheck task, storage-pending table should be empty
(let [rows (db/exec! th/*pool* ["select * from storage_pending"])]
(t/is (= 0 (count rows))))))
(t/deftest test-recheck-with-rollback
(let [storage (:app.storage/storage th/*system*)
content (sto/content "content")]
;; check with aborted transaction
(ex/ignoring
(db/with-atomic [conn th/*pool*]
(let [storage (assoc storage :conn conn)] ; make participate storage in the transaction
(sto/put-object storage {:content content
:content-type "text/plain"})
(throw (ex-info "expected" {})))))
;; let a 200ms window for recheck registration thread
;; completion before proceed.
(th/sleep 200)
;; storage_pending table should have the object
;; registered independently of the aborted transaction.
(let [rows (db/exec! th/*pool* ["select * from storage_pending"])]
(t/is (= 1 (count rows))))
;; Artificially make all storage_pending object 1 hour older.
(db/exec-one! th/*pool* ["update storage_pending set created_at = created_at - '1 hour'::interval"])
;; Sleep fo 50ms
(th/sleep 50)
;; Run recheck task
(let [task (:app.storage/recheck-task th/*system*)
res (task {})]
(t/is (= 1 (:processed res)))
(t/is (= 1 (:deleted res))))
;; After recheck task, storage-pending table should be empty
(let [rows (db/exec! th/*pool* ["select * from storage_pending"])]
(t/is (= 0 (count rows))))))

View file

@ -52,7 +52,6 @@
(assoc-in [:app.db/pool :uri] (:database-uri config)) (assoc-in [:app.db/pool :uri] (:database-uri config))
(assoc-in [:app.db/pool :username] (:database-username config)) (assoc-in [:app.db/pool :username] (:database-username config))
(assoc-in [:app.db/pool :password] (:database-password config)) (assoc-in [:app.db/pool :password] (:database-password config))
(assoc-in [[:app.main/main :app.storage.fs/backend] :directory] "/tmp/app/storage")
(dissoc :app.srepl/server (dissoc :app.srepl/server
:app.http/server :app.http/server
:app.http/router :app.http/router
@ -65,8 +64,7 @@
:app.worker/scheduler :app.worker/scheduler
:app.worker/worker) :app.worker/worker)
(d/deep-merge (d/deep-merge
{:app.storage/storage {:backend :tmp} {:app.tasks.file-media-gc/handler {:max-age (dt/duration 300)}}))
:app.tasks.file-media-gc/handler {:max-age (dt/duration 300)}}))
_ (ig/load-namespaces config) _ (ig/load-namespaces config)
system (-> (ig/prep config) system (-> (ig/prep config)
(ig/init))] (ig/init))]
@ -250,7 +248,7 @@
[expr] [expr]
`(try `(try
{:error nil {:error nil
:result ~expr} :result (deref ~expr)}
(catch Exception e# (catch Exception e#
{:error (handle-error e#) {:error (handle-error e#)
:result nil}))) :result nil})))

View file

@ -13,7 +13,7 @@
org.apache.logging.log4j/log4j-slf4j18-impl {:mvn/version "2.17.1"} org.apache.logging.log4j/log4j-slf4j18-impl {:mvn/version "2.17.1"}
org.slf4j/slf4j-api {:mvn/version "2.0.0-alpha1"} org.slf4j/slf4j-api {:mvn/version "2.0.0-alpha1"}
selmer/selmer {:mvn/version "1.12.49"} selmer/selmer {:mvn/version "1.12.50"}
criterium/criterium {:mvn/version "0.4.6"} criterium/criterium {:mvn/version "0.4.6"}
expound/expound {:mvn/version "0.9.0"} expound/expound {:mvn/version "0.9.0"}
@ -21,10 +21,10 @@
com.cognitect/transit-cljs {:mvn/version "0.8.269"} com.cognitect/transit-cljs {:mvn/version "0.8.269"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"} java-http-clj/java-http-clj {:mvn/version "0.4.3"}
funcool/promesa {:mvn/version "6.0.2"} funcool/promesa {:mvn/version "7.0.444"}
funcool/cuerdas {:mvn/version "2022.01.14-391"} funcool/cuerdas {:mvn/version "2022.01.14-391"}
lambdaisland/uri {:mvn/version "1.12.89" lambdaisland/uri {:mvn/version "1.13.95"
:exclusions [org.clojure/data.json]} :exclusions [org.clojure/data.json]}
frankiesardo/linked {:mvn/version "1.3.0"} frankiesardo/linked {:mvn/version "1.3.0"}
@ -42,9 +42,8 @@
{:extra-deps {:extra-deps
{org.clojure/tools.namespace {:mvn/version "RELEASE"} {org.clojure/tools.namespace {:mvn/version "RELEASE"}
org.clojure/test.check {:mvn/version "RELEASE"} org.clojure/test.check {:mvn/version "RELEASE"}
org.clojure/tools.deps.alpha {:mvn/version "RELEASE"} thheller/shadow-cljs {:mvn/version "2.17.3"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"} com.bhauman/rebel-readline {:mvn/version "RELEASE"}
thheller/shadow-cljs {:mvn/version "2.16.12"}
criterium/criterium {:mvn/version "RELEASE"} criterium/criterium {:mvn/version "RELEASE"}
mockery/mockery {:mvn/version "RELEASE"}} mockery/mockery {:mvn/version "RELEASE"}}
:extra-paths ["test" "dev"]} :extra-paths ["test" "dev"]}

View file

@ -13,7 +13,7 @@
"test": "yarn run compile-test && yarn run run-test" "test": "yarn run compile-test && yarn run run-test"
}, },
"devDependencies": { "devDependencies": {
"shadow-cljs": "2.16.12", "shadow-cljs": "2.17.3",
"source-map-support": "^0.5.19", "source-map-support": "^0.5.19",
"ws": "^7.4.6" "ws": "^7.4.6"
} }

View file

@ -15,4 +15,5 @@
(def info "#59B9E2") (def info "#59B9E2")
(def test "#fabada") (def test "#fabada")
(def white "#FFFFFF") (def white "#FFFFFF")
(def primary "#31EFB8")

View file

@ -6,7 +6,7 @@
(ns app.common.data (ns app.common.data
"Data manipulation and query helper functions." "Data manipulation and query helper functions."
(:refer-clojure :exclude [read-string hash-map merge name parse-double]) (:refer-clojure :exclude [read-string hash-map merge name parse-double group-by iteration])
#?(:cljs #?(:cljs
(:require-macros [app.common.data])) (:require-macros [app.common.data]))
(:require (:require
@ -37,6 +37,22 @@
#?(:cljs (instance? lks/LinkedSet o) #?(:cljs (instance? lks/LinkedSet o)
:clj (instance? LinkedSet o))) :clj (instance? LinkedSet o)))
#?(:clj
(defmethod print-method clojure.lang.PersistentQueue [q, w]
;; Overload the printer for queues so they look like fish
(print-method '<- w)
(print-method (seq q) w)
(print-method '-< w)))
(defn queue
([] #?(:clj clojure.lang.PersistentQueue/EMPTY :cljs #queue []))
([a] (into (queue) [a]))
([a & more] (into (queue) (cons a more))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Structures Manipulation
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn deep-merge (defn deep-merge
([a b] ([a b]
(if (map? a) (if (map? a)
@ -45,10 +61,6 @@
([a b & rest] ([a b & rest]
(reduce deep-merge a (cons b rest)))) (reduce deep-merge a (cons b rest))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Structures Manipulation
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn dissoc-in (defn dissoc-in
[m [k & ks]] [m [k & ks]]
(if ks (if ks
@ -151,7 +163,11 @@
"Given a map, return a map removing key-value "Given a map, return a map removing key-value
pairs when value is `nil`." pairs when value is `nil`."
[data] [data]
(into {} (remove (comp nil? second) data))) (into {} (remove (comp nil? second)) data))
(defn without-qualified
[data]
(into {} (remove (comp qualified-keyword? first)) data))
(defn without-keys (defn without-keys
"Return a map without the keys provided "Return a map without the keys provided
@ -609,3 +625,71 @@
(if (or (keyword? k) (string? k)) (if (or (keyword? k) (string? k))
[(keyword (str/kebab (name k))) v] [(keyword (str/kebab (name k))) v]
[k v]))))) [k v])))))
(defn group-by
([kf coll] (group-by kf identity coll))
([kf vf coll]
(let [conj (fnil conj [])]
(reduce (fn [result item]
(update result (kf item) conj (vf item)))
{}
coll))))
(defn group-by'
"A variant of group-by that uses a set for collecting results."
([kf coll] (group-by kf identity coll))
([kf vf coll]
(let [conj (fnil conj #{})]
(reduce (fn [result item]
(update result (kf item) conj (vf item)))
{}
coll))))
;; TEMPORAL COPY of clojure-1.11 iteration function, should be
;; replaced with the builtin on when stable version is released.
#?(:clj
(defn iteration
"Creates a seqable/reducible via repeated calls to step,
a function of some (continuation token) 'k'. The first call to step
will be passed initk, returning 'ret'. Iff (somef ret) is true,
(vf ret) will be included in the iteration, else iteration will
terminate and vf/kf will not be called. If (kf ret) is non-nil it
will be passed to the next step call, else iteration will terminate.
This can be used e.g. to consume APIs that return paginated or batched data.
step - (possibly impure) fn of 'k' -> 'ret'
:somef - fn of 'ret' -> logical true/false, default 'some?'
:vf - fn of 'ret' -> 'v', a value produced by the iteration, default 'identity'
:kf - fn of 'ret' -> 'next-k' or nil (signaling 'do not continue'), default 'identity'
:initk - the first value passed to step, default 'nil'
It is presumed that step with non-initk is unreproducible/non-idempotent.
If step with initk is unreproducible it is on the consumer to not consume twice."
{:added "1.11"}
[step & {:keys [somef vf kf initk]
:or {vf identity
kf identity
somef some?
initk nil}}]
(reify
clojure.lang.Seqable
(seq [_]
((fn next [ret]
(when (somef ret)
(cons (vf ret)
(when-some [k (kf ret)]
(lazy-seq (next (step k)))))))
(step initk)))
clojure.lang.IReduceInit
(reduce [_ rf init]
(loop [acc init
ret (step initk)]
(if (somef ret)
(let [acc (rf acc (vf ret))]
(if (reduced? acc)
@acc
(if-some [k (kf ret)]
(recur acc (step k))
acc)))
acc))))))

View file

@ -13,6 +13,7 @@
[app.common.pages.changes :as ch] [app.common.pages.changes :as ch]
[app.common.pages.init :as init] [app.common.pages.init :as init]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.spec.change :as spec.change]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[cuerdas.core :as str])) [cuerdas.core :as str]))
@ -38,9 +39,9 @@
:frame-id (:current-frame-id file)))] :frame-id (:current-frame-id file)))]
(when fail-on-spec? (when fail-on-spec?
(us/verify :app.common.pages.spec/change change)) (us/verify ::spec.change/change change))
(let [valid? (us/valid? :app.common.pages.spec/change change)] (let [valid? (us/valid? ::spec.change/change change)]
#?(:cljs #?(:cljs
(when-not valid? (.warn js/console "Invalid shape" (clj->js change)))) (when-not valid? (.warn js/console "Invalid shape" (clj->js change))))
@ -568,4 +569,78 @@
(dissoc :current-component-id) (dissoc :current-component-id)
(update :parent-stack pop)))) (update :parent-stack pop))))
(defn delete-object
[file id]
(let [page-id (:current-page-id file)]
(commit-change
file
{:type :del-obj
:page-id page-id
:id id})))
(defn update-object
[file old-obj new-obj]
(let [page-id (:current-page-id file)
new-obj (setup-selrect new-obj)
attrs (d/concat-set (keys old-obj) (keys new-obj))
generate-operation
(fn [changes attr]
(let [old-val (get old-obj attr)
new-val (get new-obj attr)]
(if (= old-val new-val)
changes
(conj changes {:type :set :attr attr :val new-val}))))]
(-> file
(commit-change
{:type :mod-obj
:operations (reduce generate-operation [] attrs)
:page-id page-id
:id (:id old-obj)}))))
(defn get-current-page
[file]
(let [page-id (:current-page-id file)]
(-> file (get-in [:data :pages-index page-id]))))
(defn add-guide
[file guide]
(let [guide (cond-> guide
(nil? (:id guide))
(assoc :id (uuid/next)))
page-id (:current-page-id file)
old-guides (or (get-in file [:data :pages-index page-id :options :guides]) {})
new-guides (assoc old-guides (:id guide) guide)]
(-> file
(commit-change
{:type :set-option
:page-id page-id
:option :guides
:value new-guides})
(assoc :last-id (:id guide)))))
(defn delete-guide
[file id]
(let [page-id (:current-page-id file)
old-guides (or (get-in file [:data :pages-index page-id :options :guides]) {})
new-guides (dissoc old-guides id)]
(-> file
(commit-change
{:type :set-option
:page-id page-id
:option :guides
:value new-guides}))))
(defn update-guide
[file guide]
(let [page-id (:current-page-id file)
old-guides (or (get-in file [:data :pages-index page-id :options :guides]) {})
new-guides (assoc old-guides (:id guide) guide)]
(-> file
(commit-change
{:type :set-option
:page-id page-id
:option :guides
:value new-guides}))))

View file

@ -6,7 +6,6 @@
(ns app.common.geom.align (ns app.common.geom.align
(:require (:require
[app.common.data :as d]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.pages.helpers :refer [get-children]] [app.common.pages.helpers :refer [get-children]]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
@ -20,8 +19,7 @@
(defn- recursive-move (defn- recursive-move
"Move the shape and all its recursive children." "Move the shape and all its recursive children."
[shape dpoint objects] [shape dpoint objects]
(->> (get-children (:id shape) objects) (->> (get-children objects (:id shape))
(map (d/getf objects))
(cons shape) (cons shape)
(map #(gsh/move % dpoint)))) (map #(gsh/move % dpoint))))

View file

@ -10,7 +10,9 @@
:clj [clojure.pprint :as pp]) :clj [clojure.pprint :as pp])
[app.common.data :as d] [app.common.data :as d]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.math :as mth])) [app.common.math :as mth]
[app.common.spec :as us]
[clojure.spec.alpha :as s]))
;; --- Matrix Impl ;; --- Matrix Impl
@ -24,6 +26,21 @@
(toString [_] (toString [_]
(str "matrix(" a "," b "," c "," d "," e "," f ")"))) (str "matrix(" a "," b "," c "," d "," e "," f ")")))
(defn ^boolean matrix?
"Return true if `v` is Matrix instance."
[v]
(instance? Matrix v))
(s/def ::a ::us/safe-number)
(s/def ::b ::us/safe-number)
(s/def ::c ::us/safe-number)
(s/def ::d ::us/safe-number)
(s/def ::e ::us/safe-number)
(s/def ::f ::us/safe-number)
(s/def ::matrix
(s/and (s/keys :req-un [::a ::b ::c ::d ::e ::f]) matrix?))
(defn matrix (defn matrix
"Create a new matrix instance." "Create a new matrix instance."
([] ([]
@ -84,11 +101,6 @@
(- m1a m2a) (- m1b m2b) (- m1c m2c) (- m1a m2a) (- m1b m2b) (- m1c m2c)
(- m1d m2d) (- m1e m2e) (- m1f m2f))) (- m1d m2d) (- m1e m2e) (- m1f m2f)))
(defn ^boolean matrix?
"Return true if `v` is Matrix instance."
[v]
(instance? Matrix v))
(def base (matrix)) (def base (matrix))
(defn base? (defn base?

View file

@ -11,7 +11,9 @@
:clj [clojure.pprint :as pp]) :clj [clojure.pprint :as pp])
#?(:cljs [cljs.core :as c] #?(:cljs [cljs.core :as c]
:clj [clojure.core :as c]) :clj [clojure.core :as c])
[app.common.math :as mth])) [app.common.math :as mth]
[app.common.spec :as us]
[clojure.spec.alpha :as s]))
;; --- Point Impl ;; --- Point Impl
@ -25,6 +27,13 @@
(or (instance? Point v) (or (instance? Point v)
(and (map? v) (contains? v :x) (contains? v :y)))) (and (map? v) (contains? v :x) (contains? v :y))))
(s/def ::x ::us/safe-number)
(s/def ::y ::us/safe-number)
(s/def ::point
(s/and (s/keys :req-un [::x ::y]) point?))
(defn ^boolean point-like? (defn ^boolean point-like?
[{:keys [x y] :as v}] [{:keys [x y] :as v}]
(and (map? v) (and (map? v)

View file

@ -185,3 +185,7 @@
;; Bool ;; Bool
(d/export gsb/update-bool-selrect) (d/export gsb/update-bool-selrect)
(d/export gsb/calc-bool-content) (d/export gsb/calc-bool-content)
;; Constraints
(d/export gct/default-constraints-h)
(d/export gct/default-constraints-v)

View file

@ -11,7 +11,7 @@
[app.common.geom.shapes.common :as gco] [app.common.geom.shapes.common :as gco]
[app.common.geom.shapes.transforms :as gtr] [app.common.geom.shapes.transforms :as gtr]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.pages.spec :as spec])) [app.common.uuid :as uuid]))
;; Auxiliary methods to work in an specifica axis ;; Auxiliary methods to work in an specifica axis
(defn get-delta-start [axis rect tr-rect] (defn get-delta-start [axis rect tr-rect]
@ -138,16 +138,32 @@
:center :center :center :center
:scale :scale}) :scale :scale})
(defn default-constraints-h
[shape]
(if (= (:parent-id shape) uuid/zero)
nil
(if (= (:parent-id shape) (:frame-id shape))
:left
:scale)))
(defn default-constraints-v
[shape]
(if (= (:parent-id shape) uuid/zero)
nil
(if (= (:parent-id shape) (:frame-id shape))
:top
:scale)))
(defn calc-child-modifiers (defn calc-child-modifiers
[parent child modifiers ignore-constraints transformed-parent-rect] [parent child modifiers ignore-constraints transformed-parent-rect]
(let [constraints-h (let [constraints-h
(if-not ignore-constraints (if-not ignore-constraints
(:constraints-h child (spec/default-constraints-h child)) (:constraints-h child (default-constraints-h child))
:scale) :scale)
constraints-v constraints-v
(if-not ignore-constraints (if-not ignore-constraints
(:constraints-v child (spec/default-constraints-v child)) (:constraints-v child (default-constraints-v child))
:scale) :scale)
modifiers-h (constraint-modifier (constraints-h const->type+axis) :x parent child modifiers transformed-parent-rect) modifiers-h (constraint-modifier (constraints-h const->type+axis) :x parent child modifiers transformed-parent-rect)

View file

@ -545,7 +545,6 @@
(defn transform-selrect (defn transform-selrect
[selrect {:keys [displacement resize-transform-inverse resize-vector resize-origin resize-vector-2 resize-origin-2]}] [selrect {:keys [displacement resize-transform-inverse resize-vector resize-origin resize-vector-2 resize-origin-2]}]
;; FIXME: Improve Performance ;; FIXME: Improve Performance
(let [resize-transform-inverse (or resize-transform-inverse (gmt/matrix)) (let [resize-transform-inverse (or resize-transform-inverse (gmt/matrix))

View file

@ -6,6 +6,7 @@
(ns app.common.math (ns app.common.math
"A collection of math utils." "A collection of math utils."
(:refer-clojure :exclude [abs])
#?(:cljs #?(:cljs
(:require [goog.math :as math]))) (:require [goog.math :as math])))

View file

@ -10,11 +10,8 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.pages.changes :as changes] [app.common.pages.changes :as changes]
[app.common.pages.common :as common] [app.common.pages.common :as common]
[app.common.pages.helpers :as helpers]
[app.common.pages.indices :as indices] [app.common.pages.indices :as indices]
[app.common.pages.init :as init] [app.common.pages.init :as init]))
[app.common.pages.spec :as spec]
[clojure.spec.alpha :as s]))
;; Common ;; Common
(d/export common/root) (d/export common/root)
@ -22,55 +19,6 @@
(d/export common/default-color) (d/export common/default-color)
(d/export common/component-sync-attrs) (d/export common/component-sync-attrs)
;; Helpers
(d/export helpers/walk-pages)
(d/export helpers/select-objects)
(d/export helpers/update-object-list)
(d/export helpers/get-component-shape)
(d/export helpers/get-root-shape)
(d/export helpers/make-container)
(d/export helpers/page?)
(d/export helpers/component?)
(d/export helpers/get-container)
(d/export helpers/get-shape)
(d/export helpers/get-component)
(d/export helpers/is-main-of)
(d/export helpers/get-component-root)
(d/export helpers/get-children)
(d/export helpers/get-children-objects)
(d/export helpers/get-object-with-children)
(d/export helpers/select-children)
(d/export helpers/is-shape-grouped)
(d/export helpers/get-parent)
(d/export helpers/get-parents)
(d/export helpers/get-frame)
(d/export helpers/clean-loops)
(d/export helpers/calculate-invalid-targets)
(d/export helpers/valid-frame-target)
(d/export helpers/position-on-parent)
(d/export helpers/insert-at-index)
(d/export helpers/append-at-the-end)
(d/export helpers/select-toplevel-shapes)
(d/export helpers/select-frames)
(d/export helpers/clone-object)
(d/export helpers/indexed-shapes)
(d/export helpers/expand-region-selection)
(d/export helpers/frame-id-by-position)
(d/export helpers/set-touched-group)
(d/export helpers/touched-group?)
(d/export helpers/get-base-shape)
(d/export helpers/is-parent?)
(d/export helpers/get-index-in-parent)
(d/export helpers/split-path)
(d/export helpers/join-path)
(d/export helpers/parse-path-name)
(d/export helpers/merge-path-item)
(d/export helpers/compact-path)
(d/export helpers/compact-name)
(d/export helpers/unframed-shape?)
(d/export helpers/children-seq)
;; Indices ;; Indices
(d/export indices/calculate-z-index) (d/export indices/calculate-z-index)
(d/export indices/update-z-index) (d/export indices/update-z-index)
@ -88,15 +36,3 @@
(d/export init/make-minimal-shape) (d/export init/make-minimal-shape)
(d/export init/make-minimal-group) (d/export init/make-minimal-group)
(d/export init/empty-file-data) (d/export init/empty-file-data)
;; Specs
(s/def ::changes ::spec/changes)
(s/def ::color ::spec/color)
(s/def ::data ::spec/data)
(s/def ::media-object ::spec/media-object)
(s/def ::page ::spec/page)
(s/def ::recent-color ::spec/recent-color)
(s/def ::shape-attrs ::spec/shape-attrs)
(s/def ::typography ::spec/typography)

View file

@ -5,6 +5,7 @@
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.pages.changes (ns app.common.pages.changes
#_:clj-kondo/ignore
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
@ -13,9 +14,9 @@
[app.common.pages.common :refer [component-sync-attrs]] [app.common.pages.common :refer [component-sync-attrs]]
[app.common.pages.helpers :as cph] [app.common.pages.helpers :as cph]
[app.common.pages.init :as init] [app.common.pages.init :as init]
[app.common.pages.spec :as spec] [app.common.spec :as us]
[app.common.spec :as us])) [app.common.spec.change :as spec.change]
[app.common.spec.shape :as spec.shape]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Specific helpers ;; Specific helpers
@ -47,7 +48,7 @@
;; When verify? false we spec the schema validation. Currently used to make just ;; When verify? false we spec the schema validation. Currently used to make just
;; 1 validation even if the changes are applied twice ;; 1 validation even if the changes are applied twice
(when verify? (when verify?
(us/assert ::spec/changes items)) (us/assert ::spec.change/changes items))
(let [result (reduce #(or (process-change %1 %2) %1) data items)] (let [result (reduce #(or (process-change %1 %2) %1) data items)]
;; Validate result shapes (only on the backend) ;; Validate result shapes (only on the backend)
@ -57,7 +58,7 @@
(doseq [[id shape] (:objects page)] (doseq [[id shape] (:objects page)]
(when-not (= shape (get-in data [:pages-index page-id :objects id])) (when-not (= shape (get-in data [:pages-index page-id :objects id]))
;; If object has change verify is correct ;; If object has change verify is correct
(us/verify ::spec/shape shape)))))) (us/verify ::spec.shape/shape shape))))))
result))) result)))
@ -159,10 +160,8 @@
(let [lookup (d/getf objects) (let [lookup (d/getf objects)
update-fn #(d/update-when %1 %2 update-group %1) update-fn #(d/update-when %1 %2 update-group %1)
xform (comp xform (comp
(mapcat #(cons % (cph/get-parents % objects))) (mapcat #(cons % (cph/get-parent-ids objects %)))
(map lookup) (filter #(contains? #{:group :bool} (-> % lookup :type)))
(filter #(contains? #{:group :bool} (:type %)))
(map :id)
(distinct))] (distinct))]
(->> (sequence xform shapes) (->> (sequence xform shapes)
@ -203,11 +202,16 @@
(defmethod process-change :mov-objects (defmethod process-change :mov-objects
[data {:keys [parent-id shapes index page-id component-id ignore-touched]}] [data {:keys [parent-id shapes index page-id component-id ignore-touched]}]
(letfn [(is-valid-move? [objects shape-id] (letfn [(calculate-invalid-targets [objects shape-id]
(let [invalid-targets (cph/calculate-invalid-targets shape-id objects)] (let [reduce-fn #(into %1 (calculate-invalid-targets objects %2))]
(->> (get-in objects [shape-id :shapes])
(reduce reduce-fn #{shape-id}))))
(is-valid-move? [objects shape-id]
(let [invalid-targets (calculate-invalid-targets objects shape-id)]
(and (contains? objects shape-id) (and (contains? objects shape-id)
(not (invalid-targets parent-id)) (not (invalid-targets parent-id))
(cph/valid-frame-target shape-id parent-id objects)))) (cph/valid-frame-target? objects parent-id shape-id))))
(insert-items [prev-shapes index shapes] (insert-items [prev-shapes index shapes]
(let [prev-shapes (or prev-shapes [])] (let [prev-shapes (or prev-shapes [])]

View file

@ -7,19 +7,27 @@
(ns app.common.pages.changes-builder (ns app.common.pages.changes-builder
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.pages :as cp] [app.common.pages.helpers :as cph]))
[app.common.pages.helpers :as h]))
;; Auxiliary functions to help create a set of changes (undo + redo) ;; Auxiliary functions to help create a set of changes (undo + redo)
(defn empty-changes (defn empty-changes
[origin page-id] ([origin page-id]
(let [changes {:redo-changes [] (let [changes (empty-changes origin)]
:undo-changes []
:origin origin}]
(with-meta changes (with-meta changes
{::page-id page-id}))) {::page-id page-id})))
([origin]
{:redo-changes []
:undo-changes []
:origin origin}))
(defn with-page [changes page]
(vary-meta changes assoc
::page page
::page-id (:id page)
::objects (:objects page)))
(defn with-objects [changes objects] (defn with-objects [changes objects]
(vary-meta changes assoc ::objects objects)) (vary-meta changes assoc ::objects objects))
@ -69,7 +77,7 @@
:page-id (::page-id (meta changes)) :page-id (::page-id (meta changes))
:parent-id (:parent-id shape) :parent-id (:parent-id shape)
:shapes [(:id shape)] :shapes [(:id shape)]
:index (cp/position-on-parent (:id shape) objects)}))] :index (cph/get-position-on-parent objects (:id shape))}))]
(-> changes (-> changes
(update :redo-changes conj set-parent-change) (update :redo-changes conj set-parent-change)
@ -162,7 +170,7 @@
:page-id page-id :page-id page-id
:parent-id (:parent-id shape) :parent-id (:parent-id shape)
:shapes [id] :shapes [id]
:index (h/position-on-parent id objects) :index (cph/get-position-on-parent objects id)
:ignore-touched true})))] :ignore-touched true})))]
(-> changes (-> changes
@ -171,10 +179,25 @@
(reduce add-undo-change-parent $ ids) (reduce add-undo-change-parent $ ids)
(reduce add-undo-change-shape $ ids)))))) (reduce add-undo-change-shape $ ids))))))
(defn move-page (defn move-page
[chdata index prev-index] [chdata index prev-index]
(let [page-id (::page-id (meta chdata))] (let [page-id (::page-id (meta chdata))]
(-> chdata (-> chdata
(update :redo-changes conj {:type :mov-page :id page-id :index index}) (update :redo-changes conj {:type :mov-page :id page-id :index index})
(update :undo-changes conj {:type :mov-page :id page-id :index prev-index})))) (update :undo-changes conj {:type :mov-page :id page-id :index prev-index}))))
(defn set-page-option
[chdata option-key option-val]
(let [page-id (::page-id (meta chdata))
page (::page (meta chdata))
old-val (get-in page [:options option-key])]
(-> chdata
(update :redo-changes conj {:type :set-option
:page-id page-id
:option option-key
:value option-val})
(update :undo-changes conj {:type :set-option
:page-id page-id
:option option-key
:value old-val}))))

View file

@ -9,7 +9,7 @@
[app.common.colors :as clr] [app.common.colors :as clr]
[app.common.uuid :as uuid])) [app.common.uuid :as uuid]))
(def file-version 12) (def file-version 13)
(def default-color clr/gray-20) (def default-color clr/gray-20)
(def root uuid/zero) (def root uuid/zero)

View file

@ -0,0 +1,170 @@
;; 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) UXBOX Labs SL
(ns app.common.pages.diff
"Given a page in its old version and the new will retrieve a map with
the differences that will have an impact in the snap data"
(:require
[app.common.data :as d]
[clojure.set :as set]))
(defn calculate-page-diff
[old-page page check-attrs]
(let [old-objects (get old-page :objects)
old-guides (or (get-in old-page [:options :guides]) [])
new-objects (get page :objects)
new-guides (or (get-in page [:options :guides]) [])
changed-object?
(fn [id]
(let [oldv (get old-objects id)
newv (get new-objects id)]
;; Check first without select-keys because is faster if they are
;; the same reference
(and (not= oldv newv)
(not= (select-keys oldv check-attrs)
(select-keys newv check-attrs)))))
frame?
(fn [id]
(or (= :frame (get-in new-objects [id :type]))
(= :frame (get-in old-objects [id :type]))))
changed-guide?
(fn [id]
(not= (get old-guides id)
(get new-guides id)))
deleted-object?
#(and (contains? old-objects %)
(not (contains? new-objects %)))
deleted-guide?
#(and (contains? old-guides %)
(not (contains? new-guides %)))
new-object?
#(and (not (contains? old-objects %))
(contains? new-objects %))
new-guide?
#(and (not (contains? old-guides %))
(contains? new-guides %))
changed-frame-object?
#(and (contains? new-objects %)
(contains? old-objects %)
(not= (get-in old-objects [% :frame-id])
(get-in new-objects [% :frame-id])))
changed-frame-guide?
#(and (contains? new-guides %)
(contains? old-guides %)
(not= (get-in old-objects [% :frame-id])
(get-in new-objects [% :frame-id])))
changed-attrs-object?
#(and (contains? new-objects %)
(contains? old-objects %)
(= (get-in old-objects [% :frame-id])
(get-in new-objects [% :frame-id])))
changed-attrs-guide?
#(and (contains? new-guides %)
(contains? old-guides %)
(= (get-in old-objects [% :frame-id])
(get-in new-objects [% :frame-id])))
changed-object-ids
(into #{}
(filter changed-object?)
(set/union (set (keys old-objects))
(set (keys new-objects))))
changed-guides-ids
(into #{}
(filter changed-guide?)
(set/union (set (keys old-guides))
(set (keys new-guides))))
get-diff-object (fn [id] [(get old-objects id) (get new-objects id)])
get-diff-guide (fn [id] [(get old-guides id) (get new-guides id)])
;; Shapes with different frame owner
change-frame-shapes
(->> changed-object-ids
(into [] (comp (filter changed-frame-object?)
(map get-diff-object))))
;; Guides that changed frames
change-frame-guides
(->> changed-guides-ids
(into [] (comp (filter changed-frame-guide?)
(map get-diff-guide))))
removed-frames
(->> changed-object-ids
(into [] (comp (filter frame?)
(filter deleted-object?)
(map (d/getf old-objects)))))
removed-shapes
(->> changed-object-ids
(into [] (comp (remove frame?)
(filter deleted-object?)
(map (d/getf old-objects)))))
removed-guides
(->> changed-guides-ids
(into [] (comp (filter deleted-guide?)
(map (d/getf old-guides)))))
updated-frames
(->> changed-object-ids
(into [] (comp (filter frame?)
(filter changed-attrs-object?)
(map get-diff-object))))
updated-shapes
(->> changed-object-ids
(into [] (comp (remove frame?)
(filter changed-attrs-object?)
(map get-diff-object))))
updated-guides
(->> changed-guides-ids
(into [] (comp (filter changed-attrs-guide?)
(map get-diff-guide))))
new-frames
(->> changed-object-ids
(into [] (comp (filter frame?)
(filter new-object?)
(map (d/getf new-objects)))))
new-shapes
(->> changed-object-ids
(into [] (comp (remove frame?)
(filter new-object?)
(map (d/getf new-objects)))))
new-guides
(->> changed-guides-ids
(into [] (comp (filter new-guide?)
(map (d/getf new-guides)))))]
{:change-frame-shapes change-frame-shapes
:change-frame-guides change-frame-guides
:removed-frames removed-frames
:removed-shapes removed-shapes
:removed-guides removed-guides
:updated-frames updated-frames
:updated-shapes updated-shapes
:updated-guides updated-guides
:new-frames new-frames
:new-shapes new-shapes
:new-guides new-guides}))

View file

@ -9,87 +9,211 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.types.interactions :as cti] [app.common.spec.page :as spec.page]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(defn walk-pages ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
"Go through all pages of a file and apply a function to each one" ;; GENERIC SHAPE SELECTORS AND PREDICATES
;; The function receives two parameters (page-id and page), and ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; returns the updated page.
[f data]
(update data :pages-index #(d/mapm f %)))
(defn select-objects (defn ^boolean root-frame?
"Get a list of all objects in a container (a page or a component) that [{:keys [id type]}]
satisfy a condition" (and (= type :frame)
[f container] (= id uuid/zero)))
(filter f (vals (get container :objects))))
(defn update-object-list (defn ^boolean frame-shape?
"Update multiple objects in a page at once" [{:keys [type]}]
[page objects-list] (= type :frame))
(update page :objects
#(into % (d/index-by :id objects-list))))
(defn get-component-shape (defn ^boolean group-shape?
"Get the parent shape linked to a component for this shape, if any" [{:keys [type]}]
[shape objects] (= type :group))
(if-not (:shape-ref shape)
nil
(if (:component-id shape)
shape
(if-let [parent-id (:parent-id shape)]
(get-component-shape (get objects parent-id) objects)
nil))))
(defn get-root-shape (defn ^boolean text-shape?
"Get the root shape linked to a component for this shape, if any" [{:keys [type]}]
[shape objects] (= type :text))
(cond (defn ^boolean unframed-shape?
(some? (:component-root? shape)) "Checks if it's a non-frame shape in the top level."
shape [shape]
(and (not (frame-shape? shape))
(some? (:shape-ref shape)) (= (:frame-id shape) uuid/zero)))
(recur (get objects (:parent-id shape))
objects)))
(defn make-container
[page-or-component type]
(assoc page-or-component
:type type))
(defn page?
[container]
(us/assert some? (:type container))
(= (:type container) :page))
(defn component?
[container]
(= (:type container) :component))
(defn get-container
[id type local-file]
(assert (some? type))
(-> (if (= type :page)
(get-in local-file [:pages-index id])
(get-in local-file [:components id]))
(assoc :type type)))
(defn get-shape (defn get-shape
[container shape-id] [container shape-id]
(get-in container [:objects shape-id])) (us/assert ::spec.page/container container)
(us/assert ::us/uuid shape-id)
(-> container
(get :objects)
(get shape-id)))
(defn get-children-ids
[objects id]
(if-let [shapes (-> (get objects id) :shapes (some-> vec))]
(into shapes (mapcat #(get-children-ids objects %)) shapes)
[]))
(defn get-children
[objects id]
(mapv (d/getf objects) (get-children-ids objects id)))
(defn get-children-with-self
[objects id]
(let [lookup (d/getf objects)]
(into [(lookup id)] (map lookup) (get-children-ids objects id))))
(defn get-parent
"Retrieve the id of the parent for the shape-id (if exists)"
[objects id]
(let [lookup (d/getf objects)]
(-> id lookup :parent-id lookup)))
(defn get-parent-id
"Retrieve the id of the parent for the shape-id (if exists)"
[objects id]
(-> objects (get id) :parent-id))
(defn get-parent-ids
"Returns a vector of parents of the specified shape."
[objects shape-id]
(loop [result [] id shape-id]
(if-let [parent-id (->> id (get objects) :parent-id)]
(recur (conj result parent-id) parent-id)
result)))
(defn get-frame
"Get the frame that contains the shape. If the shape is already a
frame, get itself. If no shape is provided, returns the root frame."
([objects]
(get objects uuid/zero))
([objects shape-or-id]
(cond
(map? shape-or-id)
(if (frame-shape? shape-or-id)
shape-or-id
(get objects (:frame-id shape-or-id)))
(= uuid/zero shape-or-id)
(get objects uuid/zero)
:else
(some->> shape-or-id
(get objects)
(get-frame objects)))))
(defn valid-frame-target?
[objects parent-id shape-id]
(let [shape (get objects shape-id)]
(or (not (frame-shape? shape))
(= parent-id uuid/zero))))
(defn get-position-on-parent
[objects id]
(let [obj (get objects id)
pid (:parent-id obj)
prt (get objects pid)]
(d/index-of (:shapes prt) id)))
(defn get-immediate-children
"Retrieve resolved shape objects that are immediate children
of the specified shape-id"
([objects] (get-immediate-children objects uuid/zero))
([objects shape-id]
(let [lookup (d/getf objects)]
(->> (lookup shape-id)
(:shapes)
(keep lookup)))))
(defn get-frames
"Retrieves all frame objects as vector. It is not implemented in
function of `get-immediate-children` for performance reasons. This
function is executed in the render hot path."
[objects]
(let [lookup (d/getf objects)
xform (comp (keep lookup)
(filter frame-shape?))]
(->> (:shapes (lookup uuid/zero))
(into [] xform))))
(defn frame-id-by-position
[objects position]
(let [frames (get-frames objects)]
(or
(->> frames
(reverse)
(d/seek #(and position (gsh/has-point? % position)))
:id)
uuid/zero)))
(declare indexed-shapes)
(defn get-base-shape
"Selects the shape that will be the base to add the shapes over"
[objects selected]
(let [;; Gets the tree-index for all the shapes
indexed-shapes (indexed-shapes objects)
;; Filters the selected and retrieve a list of ids
sorted-ids (->> indexed-shapes
(filter (comp selected second))
(map second))]
;; The first id will be the top-most
(get objects (first sorted-ids))))
(defn is-parent?
"Check if `parent-candidate` is parent of `shape-id`"
[objects shape-id parent-candidate]
(loop [current (get objects parent-candidate)
done #{}
pending (:shapes current)]
(cond
(contains? done (:id current))
(recur (get objects (first pending))
done
(rest pending))
(empty? pending) false
(and current (contains? (set (:shapes current)) shape-id)) true
:else
(recur (get objects (first pending))
(conj done (:id current))
(concat (rest pending) (:shapes current))))))
(defn get-index-in-parent
"Retrieves the index in the parent"
[objects shape-id]
(let [shape (get objects shape-id)
parent (get objects (:parent-id shape))
[parent-idx _] (d/seek (fn [[_idx child-id]] (= child-id shape-id))
(d/enumerate (:shapes parent)))]
parent-idx))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; COMPONENTS HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn set-touched-group
[touched group]
(conj (or touched #{}) group))
(defn touched-group?
[shape group]
((or (:touched shape) #{}) group))
(defn get-component (defn get-component
[component-id library-id local-library libraries] "Retrieve a component from libraries, if no library-id is provided, we
(assert (some? (:id local-library))) iterate over all libraries and find the component on it."
(let [file (if (= library-id (:id local-library)) ([libraries component-id]
local-library (some #(-> % :data :components (get component-id)) (vals libraries)))
(get-in libraries [library-id :data]))] ([libraries library-id component-id]
(get-in file [:components component-id]))) (get-in libraries [library-id :data :components component-id])))
(defn is-main-of (defn ^boolean is-main-of?
[shape-main shape-inst] [shape-main shape-inst]
(and (:shape-ref shape-inst) (and (:shape-ref shape-inst)
(or (= (:shape-ref shape-inst) (:id shape-main)) (or (= (:shape-ref shape-inst) (:id shape-main))
@ -99,92 +223,67 @@
[component] [component]
(get-in component [:objects (:id component)])) (get-in component [:objects (:id component)]))
(defn get-children [id objects] (defn get-component-shape
(if-let [shapes (-> (get objects id) :shapes (some-> vec))] "Get the parent shape linked to a component for this shape, if any"
(into shapes (mapcat #(get-children % objects)) shapes) [objects shape]
[])) (if-not (:shape-ref shape)
nil
(defn get-children-objects (if (:component-id shape)
"Retrieve all children objects recursively for a given object"
[id objects]
(mapv #(get objects %) (get-children id objects)))
(defn get-object-with-children
"Retrieve a vector with an object and all of its children"
[id objects]
(mapv #(get objects %) (cons id (get-children id objects))))
(defn select-children [id objects]
(->> (get-children id objects)
(select-keys objects)))
(defn is-shape-grouped
"Checks if a shape is inside a group"
[shape-id objects]
(let [contains-shape-fn (fn [{:keys [shapes]}] ((set shapes) shape-id))
shapes (remove #(= (:type %) :frame) (vals objects))]
(some contains-shape-fn shapes)))
(defn get-top-frame
[objects]
(get objects uuid/zero))
(defn get-parent
"Retrieve the id of the parent for the shape-id (if exists)"
[shape-id objects]
(let [obj (get objects shape-id)]
(:parent-id obj)))
(defn get-parents
[shape-id objects]
(let [{:keys [parent-id]} (get objects shape-id)]
(when parent-id
(lazy-seq (cons parent-id (get-parents parent-id objects))))))
(defn get-frame
"Get the frame that contains the shape. If the shape is already a frame, get itself."
[shape objects]
(if (= (:type shape) :frame)
shape shape
(get objects (:frame-id shape)))) (if-let [parent-id (:parent-id shape)]
(get-component-shape objects (get objects parent-id))
nil))))
(defn clean-loops (defn get-root-shape
"Clean a list of ids from circular references." "Get the root shape linked to a component for this shape, if any."
[objects ids] [objects shape]
(let [parent-selected? (cond
(fn [id] (some? (:component-root? shape))
(let [parents (get-parents id objects)] shape
(some ids parents)))
add-element (some? (:shape-ref shape))
(fn [result id] (recur objects (get objects (:parent-id shape)))))
(cond-> result
(not (parent-selected? id))
(conj id)))]
(reduce add-element (d/ordered-set) ids))) (defn make-container
[page-or-component type]
(assoc page-or-component :type type))
(defn calculate-invalid-targets (defn page?
[shape-id objects] [container]
(let [result #{shape-id} (= (:type container) :page))
children (get-in objects [shape-id :shapes])
reduce-fn (fn [result child-id]
(into result (calculate-invalid-targets child-id objects)))]
(reduce reduce-fn result children)))
(defn valid-frame-target (defn component?
[shape-id parent-id objects] [container]
(let [shape (get objects shape-id)] (= (:type container) :component))
(or (not= (:type shape) :frame)
(= parent-id uuid/zero))))
(defn position-on-parent (defn get-container
[id objects] [file type id]
(let [obj (get objects id) (us/assert map? file)
pid (:parent-id obj) (us/assert keyword? type)
prt (get objects pid)] (us/assert uuid? id)
(d/index-of (:shapes prt) id)))
(-> (if (= type :page)
(get-in file [:pages-index id])
(get-in file [:components id]))
(assoc :type type)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ALGORITHMS & TRANSFORMATIONS FOR SHAPES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn walk-pages
"Go through all pages of a file and apply a function to each one"
;; The function receives two parameters (page-id and page), and
;; returns the updated page.
[f data]
(update data :pages-index #(d/mapm f %)))
(defn update-object-list
"Update multiple objects in a page at once"
[page objects-list]
(update page :objects
#(into % (d/index-by :id objects-list))))
(defn insert-at-index (defn insert-at-index
[objects index ids] [objects index ids]
@ -204,41 +303,22 @@
(vec prev-ids) (vec prev-ids)
ids)) ids))
(defn select-toplevel-shapes (defn clean-loops
([objects] (select-toplevel-shapes objects nil)) "Clean a list of ids from circular references."
([objects {:keys [include-frames? include-frame-children?] [objects ids]
:or {include-frames? false
include-frame-children? true}}]
(let [lookup #(get objects %) (let [parent-selected?
root (lookup uuid/zero) (fn [id]
root-children (:shapes root) (let [parents (get-parent-ids objects id)]
(some ids parents)))
lookup-shapes add-element
(fn [result id] (fn [result id]
(if (nil? id)
result
(let [obj (lookup id)
typ (:type obj)
children (:shapes obj)]
(cond-> result (cond-> result
(or (not= :frame typ) include-frames?) (not (parent-selected? id))
(conj obj) (conj id)))]
(and (= :frame typ) include-frame-children?) (reduce add-element (d/ordered-set) ids)))
(into (map lookup) children)))))]
(reduce lookup-shapes [] root-children))))
(defn select-frames
[objects]
(let [lookup #(get objects %)
frame? #(= :frame (:type %))
xform (comp (map lookup)
(filter frame?))]
(->> (:shapes (lookup uuid/zero))
(into [] xform))))
(defn clone-object (defn clone-object
"Gets a copy of the object and all its children, with new ids "Gets a copy of the object and all its children, with new ids
@ -305,8 +385,6 @@
(reduce red-fn cur-idx (reverse (:shapes object)))))] (reduce red-fn cur-idx (reverse (:shapes object)))))]
(into {} (rec-index '() uuid/zero)))) (into {} (rec-index '() uuid/zero))))
(defn expand-region-selection (defn expand-region-selection
"Given a selection selects all the shapes between the first and last in "Given a selection selects all the shapes between the first and last in
an indexed manner (shift selection)" an indexed manner (shift selection)"
@ -323,67 +401,9 @@
(map second) (map second)
(into #{})))) (into #{}))))
(defn frame-id-by-position [objects position] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(let [frames (select-frames objects)] ;; SHAPES ORGANIZATION (PATH MANAGEMENT)
(or ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(->> frames
(reverse)
(d/seek #(and position (gsh/has-point? % position)))
:id)
uuid/zero)))
(defn set-touched-group
[touched group]
(conj (or touched #{}) group))
(defn touched-group?
[shape group]
((or (:touched shape) #{}) group))
(defn get-base-shape
"Selects the shape that will be the base to add the shapes over"
[objects selected]
(let [;; Gets the tree-index for all the shapes
indexed-shapes (indexed-shapes objects)
;; Filters the selected and retrieve a list of ids
sorted-ids (->> indexed-shapes
(filter (comp selected second))
(map second))]
;; The first id will be the top-most
(get objects (first sorted-ids))))
(defn is-parent?
"Check if `parent-candidate` is parent of `shape-id`"
[objects shape-id parent-candidate]
(loop [current (get objects parent-candidate)
done #{}
pending (:shapes current)]
(cond
(contains? done (:id current))
(recur (get objects (first pending))
done
(rest pending))
(empty? pending) false
(and current (contains? (set (:shapes current)) shape-id)) true
:else
(recur (get objects (first pending))
(conj done (:id current))
(concat (rest pending) (:shapes current))))))
(defn get-index-in-parent
"Retrieves the index in the parent"
[objects shape-id]
(let [shape (get objects shape-id)
parent (get objects (:parent-id shape))
[parent-idx _] (d/seek (fn [[_idx child-id]] (= child-id shape-id))
(d/enumerate (:shapes parent)))]
parent-idx))
(defn split-path (defn split-path
"Decompose a string in the form 'one / two / three' into "Decompose a string in the form 'one / two / three' into
@ -443,25 +463,3 @@
[path name] [path name]
(let [path-split (split-path path)] (let [path-split (split-path path)]
(merge-path-item (first path-split) name))) (merge-path-item (first path-split) name)))
(defn connected-frame?
"Check if some frame is origin or destination of any navigate interaction
in the page"
[frame-id objects]
(let [children (get-object-with-children frame-id objects)]
(or (some cti/flow-origin? (map :interactions children))
(some #(cti/flow-to? % frame-id) (map :interactions (vals objects))))))
(defn unframed-shape?
"Checks if it's a non-frame shape in the top level."
[shape]
(and (not= (:type shape) :frame)
(= (:frame-id shape) uuid/zero)))
(defn children-seq
"Creates a sequence of shapes through the objects tree"
[shape objects]
(let [getter (partial get objects)]
(tree-seq #(d/not-empty? (get shape :shapes))
#(->> (get % :shapes) (map getter))
shape)))

View file

@ -7,7 +7,7 @@
(ns app.common.pages.indices (ns app.common.pages.indices
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.pages.helpers :as helpers] [app.common.pages.helpers :as cph]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[clojure.set :as set])) [clojure.set :as set]))
@ -45,7 +45,7 @@
means is displayed over other shapes with less index." means is displayed over other shapes with less index."
[objects] [objects]
(let [frames (helpers/select-frames objects) (let [frames (cph/get-frames objects)
z-index (calculate-frame-z-index {} uuid/zero objects)] z-index (calculate-frame-z-index {} uuid/zero objects)]
(->> frames (->> frames
(map :id) (map :id)
@ -61,7 +61,7 @@
changed-frames (set/union old-frames new-frames) changed-frames (set/union old-frames new-frames)
frames (->> (helpers/select-frames new-objects) frames (->> (cph/get-frames new-objects)
(map :id) (map :id)
(filter #(contains? changed-frames %))) (filter #(contains? changed-frames %)))
@ -84,13 +84,10 @@
(generate-child-all-parents-index objects (vals objects))) (generate-child-all-parents-index objects (vals objects)))
([objects shapes] ([objects shapes]
(let [shape->parents (let [xf-parents (comp
(fn [shape] (map :id)
(->> (helpers/get-parents (:id shape) objects) (map #(vector % (cph/get-parent-ids objects %))))]
(into [])))] (into {} xf-parents shapes))))
(->> shapes
(map #(vector (:id %) (shape->parents %)))
(into {})))))
(defn create-clip-index (defn create-clip-index
"Retrieves the mask information for an object" "Retrieves the mask information for an object"

View file

@ -51,7 +51,9 @@
:rx 0 :rx 0
:ry 0} :ry 0}
{:type :image} {:type :image
:rx 0
:ry 0}
{:type :circle {:type :circle
:name "Circle-1" :name "Circle-1"

View file

@ -281,3 +281,22 @@
(d/update-in-when page [:options :saved-grids] #(d/mapm update-grid %)))] (d/update-in-when page [:options :saved-grids] #(d/mapm update-grid %)))]
(update data :pages-index #(d/mapm update-page %)))) (update data :pages-index #(d/mapm update-page %))))
;; Add rx and ry to images
(defmethod migrate 13
[data]
(letfn [(fix-radius [shape]
(if-not (or (contains? shape :rx) (contains? shape :r1))
(-> shape
(assoc :rx 0)
(assoc :ry 0))
shape))
(update-object [_ object]
(cond-> object
(= :image (:type object))
(fix-radius)))
(update-page [_ page]
(update page :objects #(d/mapm update-object %)))]
(update data :pages-index #(d/mapm update-page %))))

View file

@ -1,623 +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) UXBOX Labs SL
(ns app.common.pages.spec
(:require
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.spec :as us]
[app.common.types.interactions :as cti]
[app.common.types.page-options :as cto]
[app.common.types.radius :as ctr]
[app.common.uuid :as uuid]
[clojure.set :as set]
[clojure.spec.alpha :as s]))
;; --- Specs
(s/def ::frame-id uuid?)
(s/def ::id uuid?)
(s/def ::name string?)
(s/def ::path (s/nilable string?))
(s/def ::page-id uuid?)
(s/def ::parent-id uuid?)
(s/def ::string string?)
(s/def ::type keyword?)
(s/def ::uuid uuid?)
(s/def ::component-id uuid?)
(s/def ::component-file uuid?)
(s/def ::component-root? boolean?)
(s/def ::shape-ref uuid?)
(s/def :internal.matrix/a ::us/safe-number)
(s/def :internal.matrix/b ::us/safe-number)
(s/def :internal.matrix/c ::us/safe-number)
(s/def :internal.matrix/d ::us/safe-number)
(s/def :internal.matrix/e ::us/safe-number)
(s/def :internal.matrix/f ::us/safe-number)
(s/def ::matrix
(s/and (s/keys :req-un [:internal.matrix/a
:internal.matrix/b
:internal.matrix/c
:internal.matrix/d
:internal.matrix/e
:internal.matrix/f])
gmt/matrix?))
(s/def :internal.point/x ::us/safe-number)
(s/def :internal.point/y ::us/safe-number)
(s/def ::point
(s/and (s/keys :req-un [:internal.point/x
:internal.point/y])
gpt/point?))
;; GRADIENTS
(s/def :internal.gradient.stop/color ::string)
(s/def :internal.gradient.stop/opacity ::us/safe-number)
(s/def :internal.gradient.stop/offset ::us/safe-number)
(s/def :internal.gradient/type #{:linear :radial})
(s/def :internal.gradient/start-x ::us/safe-number)
(s/def :internal.gradient/start-y ::us/safe-number)
(s/def :internal.gradient/end-x ::us/safe-number)
(s/def :internal.gradient/end-y ::us/safe-number)
(s/def :internal.gradient/width ::us/safe-number)
(s/def :internal.gradient/stop
(s/keys :req-un [:internal.gradient.stop/color
:internal.gradient.stop/opacity
:internal.gradient.stop/offset]))
(s/def :internal.gradient/stops
(s/coll-of :internal.gradient/stop :kind vector?))
(s/def ::gradient
(s/keys :req-un [:internal.gradient/type
:internal.gradient/start-x
:internal.gradient/start-y
:internal.gradient/end-x
:internal.gradient/end-y
:internal.gradient/width
:internal.gradient/stops]))
;;; COLORS
(s/def :internal.color/name ::string)
(s/def :internal.color/path (s/nilable ::string))
(s/def :internal.color/value (s/nilable ::string))
(s/def :internal.color/color (s/nilable ::string))
(s/def :internal.color/opacity (s/nilable ::us/safe-number))
(s/def :internal.color/gradient (s/nilable ::gradient))
(s/def ::color
(s/keys :opt-un [::id
:internal.color/name
:internal.color/path
:internal.color/value
:internal.color/color
:internal.color/opacity
:internal.color/gradient]))
;;; SHADOW EFFECT
(s/def :internal.shadow/id uuid?)
(s/def :internal.shadow/style #{:drop-shadow :inner-shadow})
(s/def :internal.shadow/color ::color)
(s/def :internal.shadow/offset-x ::us/safe-number)
(s/def :internal.shadow/offset-y ::us/safe-number)
(s/def :internal.shadow/blur ::us/safe-number)
(s/def :internal.shadow/spread ::us/safe-number)
(s/def :internal.shadow/hidden boolean?)
(s/def :internal.shadow/shadow
(s/keys :req-un [:internal.shadow/id
:internal.shadow/style
:internal.shadow/color
:internal.shadow/offset-x
:internal.shadow/offset-y
:internal.shadow/blur
:internal.shadow/spread
:internal.shadow/hidden]))
(s/def ::shadow
(s/coll-of :internal.shadow/shadow :kind vector?))
;;; BLUR EFFECT
(s/def :internal.blur/id uuid?)
(s/def :internal.blur/type #{:layer-blur})
(s/def :internal.blur/value ::us/safe-number)
(s/def :internal.blur/hidden boolean?)
(s/def ::blur
(s/keys :req-un [:internal.blur/id
:internal.blur/type
:internal.blur/value
:internal.blur/hidden]))
;; Size constraints
(s/def :internal.shape/constraints-h #{:left :right :leftright :center :scale})
(s/def :internal.shape/constraints-v #{:top :bottom :topbottom :center :scale})
(s/def :internal.shape/fixed-scroll boolean?)
; Shapes in the top frame have no constraints. Shapes directly below some
; frame are left-top constrained. Else (shapes in a group) are scaled.
(defn default-constraints-h
[shape]
(if (= (:parent-id shape) uuid/zero)
nil
(if (= (:parent-id shape) (:frame-id shape))
:left
:scale)))
(defn default-constraints-v
[shape]
(if (= (:parent-id shape) uuid/zero)
nil
(if (= (:parent-id shape) (:frame-id shape))
:top
:scale)))
;; Page Data related
(s/def :internal.shape/blocked boolean?)
(s/def :internal.shape/collapsed boolean?)
(s/def :internal.shape/fill-color string?)
(s/def :internal.shape/fill-opacity ::us/safe-number)
(s/def :internal.shape/fill-color-gradient (s/nilable ::gradient))
(s/def :internal.shape/fill-color-ref-file (s/nilable uuid?))
(s/def :internal.shape/fill-color-ref-id (s/nilable uuid?))
(s/def :internal.shape/hide-fill-on-export boolean?)
(s/def :internal.shape/font-family string?)
(s/def :internal.shape/font-size ::us/safe-integer)
(s/def :internal.shape/font-style string?)
(s/def :internal.shape/font-weight string?)
(s/def :internal.shape/hidden boolean?)
(s/def :internal.shape/letter-spacing ::us/safe-number)
(s/def :internal.shape/line-height ::us/safe-number)
(s/def :internal.shape/locked boolean?)
(s/def :internal.shape/page-id uuid?)
(s/def :internal.shape/proportion ::us/safe-number)
(s/def :internal.shape/proportion-lock boolean?)
(s/def :internal.shape/stroke-color string?)
(s/def :internal.shape/stroke-color-gradient (s/nilable ::gradient))
(s/def :internal.shape/stroke-color-ref-file (s/nilable uuid?))
(s/def :internal.shape/stroke-color-ref-id (s/nilable uuid?))
(s/def :internal.shape/stroke-opacity ::us/safe-number)
(s/def :internal.shape/stroke-style #{:solid :dotted :dashed :mixed :none :svg})
(def stroke-caps-line #{:round :square})
(def stroke-caps-marker #{:line-arrow :triangle-arrow :square-marker :circle-marker :diamond-marker})
(def stroke-caps (set/union stroke-caps-line stroke-caps-marker))
(s/def :internal.shape/stroke-cap-start stroke-caps)
(s/def :internal.shape/stroke-cap-end stroke-caps)
(defn has-caps?
[shape]
(= (:type shape) :path))
(s/def :internal.shape/stroke-width ::us/safe-number)
(s/def :internal.shape/stroke-alignment #{:center :inner :outer})
(s/def :internal.shape/text-align #{"left" "right" "center" "justify"})
(s/def :internal.shape/x ::us/safe-number)
(s/def :internal.shape/y ::us/safe-number)
(s/def :internal.shape/cx ::us/safe-number)
(s/def :internal.shape/cy ::us/safe-number)
(s/def :internal.shape/width ::us/safe-number)
(s/def :internal.shape/height ::us/safe-number)
(s/def :internal.shape/index integer?)
(s/def :internal.shape/shadow ::shadow)
(s/def :internal.shape/blur ::blur)
(s/def :internal.shape/x1 ::us/safe-number)
(s/def :internal.shape/y1 ::us/safe-number)
(s/def :internal.shape/x2 ::us/safe-number)
(s/def :internal.shape/y2 ::us/safe-number)
(s/def :internal.shape.export/suffix string?)
(s/def :internal.shape.export/scale ::us/safe-number)
(s/def :internal.shape/export
(s/keys :req-un [::type
:internal.shape.export/suffix
:internal.shape.export/scale]))
(s/def :internal.shape/exports
(s/coll-of :internal.shape/export :kind vector?))
(s/def :internal.shape/selrect
(s/keys :req-un [:internal.shape/x
:internal.shape/y
:internal.shape/x1
:internal.shape/y1
:internal.shape/x2
:internal.shape/y2
:internal.shape/width
:internal.shape/height]))
(s/def :internal.shape/points
(s/every ::point :kind vector?))
(s/def :internal.shape/shapes
(s/every uuid? :kind vector?))
(s/def :internal.shape/transform ::matrix)
(s/def :internal.shape/transform-inverse ::matrix)
(s/def :internal.shape/opacity ::us/safe-number)
(s/def :internal.shape/blend-mode
#{:normal
:darken
:multiply
:color-burn
:lighten
:screen
:color-dodge
:overlay
:soft-light
:hard-light
:difference
:exclusion
:hue
:saturation
:color
:luminosity})
(s/def ::shape-attrs
(s/keys :opt-un [::id
::type
::name
::component-id
::component-file
::component-root?
::shape-ref
:internal.shape/selrect
:internal.shape/points
:internal.shape/blocked
:internal.shape/collapsed
:internal.shape/fill-color
:internal.shape/fill-opacity
:internal.shape/fill-color-gradient
:internal.shape/fill-color-ref-file
:internal.shape/fill-color-ref-id
:internal.shape/font-family
:internal.shape/font-size
:internal.shape/font-style
:internal.shape/font-weight
:internal.shape/hidden
:internal.shape/letter-spacing
:internal.shape/line-height
:internal.shape/locked
:internal.shape/proportion
:internal.shape/proportion-lock
:internal.shape/constraints-h
:internal.shape/constraints-v
:internal.shape/fixed-scroll
::ctr/rx
::ctr/ry
::ctr/r1
::ctr/r2
::ctr/r3
::ctr/r4
:internal.shape/x
:internal.shape/y
:internal.shape/exports
:internal.shape/shapes
:internal.shape/stroke-color
:internal.shape/stroke-color-ref-file
:internal.shape/stroke-color-ref-id
:internal.shape/stroke-opacity
:internal.shape/stroke-style
:internal.shape/stroke-width
:internal.shape/stroke-alignment
:internal.shape/stroke-cap-start
:internal.shape/stroke-cap-end
:internal.shape/text-align
:internal.shape/transform
:internal.shape/transform-inverse
:internal.shape/width
:internal.shape/height
::cti/interactions
:internal.shape/masked-group?
:internal.shape/shadow
:internal.shape/blur
:internal.shape/opacity
:internal.shape/blend-mode]))
(s/def :internal.shape.text/type #{"root" "paragraph-set" "paragraph"})
(s/def :internal.shape.text/children
(s/coll-of :internal.shape.text/content
:kind vector?
:min-count 1))
(s/def :internal.shape.text/text string?)
(s/def :internal.shape.text/key string?)
(s/def :internal.shape.text/content
(s/nilable
(s/or :text-container
(s/keys :req-un [:internal.shape.text/type
:internal.shape.text/children]
:opt-un [:internal.shape.text/key])
:text-content
(s/keys :req-un [:internal.shape.text/text]))))
(s/def :internal.shape.path/command keyword?)
(s/def :internal.shape.path/params
(s/nilable (s/map-of keyword? any?)))
(s/def :internal.shape.path/command-item
(s/keys :req-un [:internal.shape.path/command]
:opt-un [:internal.shape.path/params]))
(s/def :internal.shape.path/content
(s/coll-of :internal.shape.path/command-item :kind vector?))
(defmulti shape-spec :type)
(defmethod shape-spec :default [_]
(s/spec ::shape-attrs))
(defmethod shape-spec :text [_]
(s/and ::shape-attrs
(s/keys :opt-un [:internal.shape.text/content])))
(defmethod shape-spec :path [_]
(s/and ::shape-attrs
(s/keys :opt-un [:internal.shape.path/content])))
(defmethod shape-spec :frame [_]
(s/and ::shape-attrs
(s/keys :opt-un [:internal.shape/hide-fill-on-export])))
(s/def ::shape
(s/and (s/multi-spec shape-spec :type)
#(contains? % :name)
#(contains? % :type)))
(s/def :internal.page/objects (s/map-of uuid? ::shape))
(s/def ::page
(s/keys :req-un [::id
::name
::cto/options
:internal.page/objects]))
(s/def ::recent-color
(s/keys :opt-un [:internal.color/value
:internal.color/color
:internal.color/opacity
:internal.color/gradient]))
(s/def :internal.media-object/name ::string)
(s/def :internal.media-object/width ::us/safe-integer)
(s/def :internal.media-object/height ::us/safe-integer)
(s/def :internal.media-object/mtype ::string)
(s/def ::media-object
(s/keys :req-un [::id
::name
:internal.media-object/width
:internal.media-object/height
:internal.media-object/mtype]))
(s/def ::media-object-update
(s/keys :req-un [::id]
:opt-un [::name
:internal.media-object/width
:internal.media-object/height
:internal.media-object/mtype]))
(s/def :internal.file/colors
(s/map-of ::uuid ::color))
(s/def :internal.file/recent-colors
(s/coll-of ::recent-color :kind vector?))
(s/def :internal.typography/id ::id)
(s/def :internal.typography/name ::string)
(s/def :internal.typography/path (s/nilable ::string))
(s/def :internal.typography/font-id ::string)
(s/def :internal.typography/font-family ::string)
(s/def :internal.typography/font-variant-id ::string)
(s/def :internal.typography/font-size ::string)
(s/def :internal.typography/font-weight ::string)
(s/def :internal.typography/font-style ::string)
(s/def :internal.typography/line-height ::string)
(s/def :internal.typography/letter-spacing ::string)
(s/def :internal.typography/text-transform ::string)
(s/def ::typography
(s/keys :req-un [:internal.typography/id
:internal.typography/name
:internal.typography/font-id
:internal.typography/font-family
:internal.typography/font-variant-id
:internal.typography/font-size
:internal.typography/font-weight
:internal.typography/font-style
:internal.typography/line-height
:internal.typography/letter-spacing
:internal.typography/text-transform]
:opt-un [:internal.typography/path]))
(s/def :internal.file/pages
(s/coll-of ::uuid :kind vector?))
(s/def :internal.file/media
(s/map-of ::uuid ::media-object))
(s/def :internal.file/pages-index
(s/map-of ::uuid ::page))
(s/def ::data
(s/keys :req-un [:internal.file/pages-index
:internal.file/pages]
:opt-un [:internal.file/colors
:internal.file/recent-colors
:internal.file/media]))
(s/def :internal.container/type #{:page :component})
(s/def ::container
(s/keys :req-un [:internal.container/type
::id
::name
:internal.page/objects]))
(defmulti operation-spec :type)
(s/def :internal.operations.set/attr keyword?)
(s/def :internal.operations.set/val any?)
(s/def :internal.operations.set/touched
(s/nilable (s/every keyword? :kind set?)))
(s/def :internal.operations.set/remote-synced?
(s/nilable boolean?))
(defmethod operation-spec :set [_]
(s/keys :req-un [:internal.operations.set/attr
:internal.operations.set/val]))
(defmethod operation-spec :set-touched [_]
(s/keys :req-un [:internal.operations.set/touched]))
(defmethod operation-spec :set-remote-synced [_]
(s/keys :req-un [:internal.operations.set/remote-synced?]))
(defmulti change-spec :type)
(s/def :internal.changes.set-option/option any?)
(s/def :internal.changes.set-option/value any?)
(defmethod change-spec :set-option [_]
(s/keys :req-un [:internal.changes.set-option/option
:internal.changes.set-option/value]))
(s/def :internal.changes.add-obj/obj ::shape)
(defn- valid-container-id-frame?
[o]
(or (and (contains? o :page-id)
(not (contains? o :component-id))
(some? (:frame-id o)))
(and (contains? o :component-id)
(not (contains? o :page-id))
(nil? (:frame-id o)))))
(defn- valid-container-id?
[o]
(or (and (contains? o :page-id)
(not (contains? o :component-id)))
(and (contains? o :component-id)
(not (contains? o :page-id)))))
(defmethod change-spec :add-obj [_]
(s/and (s/keys :req-un [::id :internal.changes.add-obj/obj]
:opt-un [::page-id ::component-id ::parent-id ::frame-id])
valid-container-id-frame?))
(s/def ::operation (s/multi-spec operation-spec :type))
(s/def ::operations (s/coll-of ::operation))
(defmethod change-spec :mod-obj [_]
(s/and (s/keys :req-un [::id ::operations]
:opt-un [::page-id ::component-id])
valid-container-id?))
(defmethod change-spec :del-obj [_]
(s/and (s/keys :req-un [::id]
:opt-un [::page-id ::component-id])
valid-container-id?))
(s/def :internal.changes.reg-objects/shapes
(s/coll-of uuid? :kind vector?))
(defmethod change-spec :reg-objects [_]
(s/and (s/keys :req-un [:internal.changes.reg-objects/shapes]
:opt-un [::page-id ::component-id])
valid-container-id?))
(defmethod change-spec :mov-objects [_]
(s/and (s/keys :req-un [::parent-id :internal.shape/shapes]
:opt-un [::page-id ::component-id ::index])
valid-container-id?))
(defmethod change-spec :add-page [_]
(s/or :empty (s/keys :req-un [::id ::name])
:complete (s/keys :req-un [::page])))
(defmethod change-spec :mod-page [_]
(s/keys :req-un [::id ::name]))
(defmethod change-spec :del-page [_]
(s/keys :req-un [::id]))
(defmethod change-spec :mov-page [_]
(s/keys :req-un [::id ::index]))
(defmethod change-spec :add-color [_]
(s/keys :req-un [::color]))
(defmethod change-spec :mod-color [_]
(s/keys :req-un [::color]))
(defmethod change-spec :del-color [_]
(s/keys :req-un [::id]))
(s/def :internal.changes.add-recent-color/color ::recent-color)
(defmethod change-spec :add-recent-color [_]
(s/keys :req-un [:internal.changes.add-recent-color/color]))
(s/def :internal.changes.media/object ::media-object)
(defmethod change-spec :add-media [_]
(s/keys :req-un [:internal.changes.media/object]))
(s/def :internal.changes.media.mod/object ::media-object-update)
(defmethod change-spec :mod-media [_]
(s/keys :req-un [:internal.changes.media.mod/object]))
(defmethod change-spec :del-media [_]
(s/keys :req-un [::id]))
(s/def :internal.changes.add-component/shapes
(s/coll-of ::shape))
(defmethod change-spec :add-component [_]
(s/keys :req-un [::id ::name :internal.changes.add-component/shapes]
:opt-un [::path]))
(defmethod change-spec :mod-component [_]
(s/keys :req-un [::id]
:opt-un [::name :internal.changes.add-component/shapes]))
(defmethod change-spec :del-component [_]
(s/keys :req-un [::id]))
(s/def :internal.changes.typography/typography ::typography)
(defmethod change-spec :add-typography [_]
(s/keys :req-un [:internal.changes.typography/typography]))
(defmethod change-spec :mod-typography [_]
(s/keys :req-un [:internal.changes.typography/typography]))
(defmethod change-spec :del-typography [_]
(s/keys :req-un [:internal.typography/id]))
(s/def ::change (s/multi-spec change-spec :type))
(s/def ::changes (s/coll-of ::change))

View file

@ -16,7 +16,6 @@
;; because of some strange interaction with cljs.spec.alpha and ;; because of some strange interaction with cljs.spec.alpha and
;; modules splitting. ;; modules splitting.
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.geom.point :as gpt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[cuerdas.core :as str] [cuerdas.core :as str]
[expound.alpha :as expound])) [expound.alpha :as expound]))
@ -110,7 +109,6 @@
(s/def ::not-empty-string (s/and string? #(not (str/empty? %)))) (s/def ::not-empty-string (s/and string? #(not (str/empty? %))))
(s/def ::url string?) (s/def ::url string?)
(s/def ::fn fn?) (s/def ::fn fn?)
(s/def ::point gpt/point?)
(s/def ::id ::uuid) (s/def ::id ::uuid)
(defn bytes? (defn bytes?
@ -279,5 +277,3 @@
(binding [s/*explain-out* expound/printer] (binding [s/*explain-out* expound/printer]
(with-out-str (with-out-str
(s/explain-out (update data ::s/problems #(take max-problems %)))))))) (s/explain-out (update data ::s/problems #(take max-problems %))))))))

View file

@ -0,0 +1,19 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.blur
(:require
[app.common.spec :as us]
[clojure.spec.alpha :as s]))
(s/def ::id uuid?)
(s/def ::type #{:layer-blur})
(s/def ::value ::us/safe-number)
(s/def ::hidden boolean?)
(s/def ::blur
(s/keys :req-un [::id ::type ::value ::hidden]))

View file

@ -0,0 +1,165 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.change
(:require
[app.common.spec.color :as color]
[app.common.spec.file :as file]
[app.common.spec.page :as page]
[app.common.spec.shape :as shape]
[app.common.spec.typography :as typg]
[clojure.spec.alpha :as s]))
(s/def ::index integer?)
(s/def ::id uuid?)
(s/def ::parent-id uuid?)
(s/def ::frame-id uuid?)
(s/def ::page-id uuid?)
(s/def ::component-id uuid?)
(s/def ::name string?)
(defmulti operation-spec :type)
(s/def :internal.operations.set/attr keyword?)
(s/def :internal.operations.set/val any?)
(s/def :internal.operations.set/touched
(s/nilable (s/every keyword? :kind set?)))
(s/def :internal.operations.set/remote-synced?
(s/nilable boolean?))
(defmethod operation-spec :set [_]
(s/keys :req-un [:internal.operations.set/attr
:internal.operations.set/val]))
(defmethod operation-spec :set-touched [_]
(s/keys :req-un [:internal.operations.set/touched]))
(defmethod operation-spec :set-remote-synced [_]
(s/keys :req-un [:internal.operations.set/remote-synced?]))
(defmulti change-spec :type)
(s/def :internal.changes.set-option/option any?)
(s/def :internal.changes.set-option/value any?)
(defmethod change-spec :set-option [_]
(s/keys :req-un [:internal.changes.set-option/option
:internal.changes.set-option/value]))
(s/def :internal.changes.add-obj/obj ::shape/shape)
(defn- valid-container-id-frame?
[o]
(or (and (contains? o :page-id)
(not (contains? o :component-id))
(some? (:frame-id o)))
(and (contains? o :component-id)
(not (contains? o :page-id))
(nil? (:frame-id o)))))
(defn- valid-container-id?
[o]
(or (and (contains? o :page-id)
(not (contains? o :component-id)))
(and (contains? o :component-id)
(not (contains? o :page-id)))))
(defmethod change-spec :add-obj [_]
(s/and (s/keys :req-un [::id :internal.changes.add-obj/obj]
:opt-un [::page-id ::component-id ::parent-id ::frame-id])
valid-container-id-frame?))
(s/def ::operation (s/multi-spec operation-spec :type))
(s/def ::operations (s/coll-of ::operation))
(defmethod change-spec :mod-obj [_]
(s/and (s/keys :req-un [::id ::operations]
:opt-un [::page-id ::component-id])
valid-container-id?))
(defmethod change-spec :del-obj [_]
(s/and (s/keys :req-un [::id]
:opt-un [::page-id ::component-id])
valid-container-id?))
(defmethod change-spec :reg-objects [_]
(s/and (s/keys :req-un [::shape/shapes]
:opt-un [::page-id ::component-id])
valid-container-id?))
(defmethod change-spec :mov-objects [_]
(s/and (s/keys :req-un [::parent-id ::shape/shapes]
:opt-un [::page-id ::component-id ::index])
valid-container-id?))
(defmethod change-spec :add-page [_]
(s/or :empty (s/keys :req-un [::id ::name])
:complete (s/keys :req-un [::page/page])))
(defmethod change-spec :mod-page [_]
(s/keys :req-un [::id ::name]))
(defmethod change-spec :del-page [_]
(s/keys :req-un [::id]))
(defmethod change-spec :mov-page [_]
(s/keys :req-un [::id ::index]))
(defmethod change-spec :add-color [_]
(s/keys :req-un [::color/color]))
(defmethod change-spec :mod-color [_]
(s/keys :req-un [::color/color]))
(defmethod change-spec :del-color [_]
(s/keys :req-un [::id]))
(s/def :internal.changes.add-recent-color/color ::color/recent-color)
(defmethod change-spec :add-recent-color [_]
(s/keys :req-un [:internal.changes.add-recent-color/color]))
(s/def :internal.changes.media/object ::file/media-object)
(defmethod change-spec :add-media [_]
(s/keys :req-un [:internal.changes.media/object]))
(s/def :internal.changes.media.mod/object
(s/and ::file/media-object #(contains? % :id)))
(defmethod change-spec :mod-media [_]
(s/keys :req-un [:internal.changes.media.mod/object]))
(defmethod change-spec :del-media [_]
(s/keys :req-un [::id]))
(s/def :internal.changes.add-component/shapes
(s/coll-of ::shape/shape))
(defmethod change-spec :add-component [_]
(s/keys :req-un [::id ::name :internal.changes.add-component/shapes]
:opt-un [::path]))
(defmethod change-spec :mod-component [_]
(s/keys :req-un [::id]
:opt-un [::name :internal.changes.add-component/shapes]))
(defmethod change-spec :del-component [_]
(s/keys :req-un [::id]))
(defmethod change-spec :add-typography [_]
(s/keys :req-un [::typg/typography]))
(defmethod change-spec :mod-typography [_]
(s/keys :req-un [::typg/typography]))
(defmethod change-spec :del-typography [_]
(s/keys :req-un [::typg/id]))
(s/def ::change (s/multi-spec change-spec :type))
(s/def ::changes (s/coll-of ::change))

View file

@ -0,0 +1,75 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.color
(:require
[app.common.spec :as us]
[clojure.spec.alpha :as s]))
;; TODO: waiting clojure 1.11 to rename this all :internal.stuff to a
;; more consistent name.
;; TODO: maybe define ::color-hex-string with proper hex color spec?
;; --- GRADIENTS
(s/def ::id uuid?)
(s/def :internal.gradient.stop/color string?)
(s/def :internal.gradient.stop/opacity ::us/safe-number)
(s/def :internal.gradient.stop/offset ::us/safe-number)
(s/def :internal.gradient/type #{:linear :radial})
(s/def :internal.gradient/start-x ::us/safe-number)
(s/def :internal.gradient/start-y ::us/safe-number)
(s/def :internal.gradient/end-x ::us/safe-number)
(s/def :internal.gradient/end-y ::us/safe-number)
(s/def :internal.gradient/width ::us/safe-number)
(s/def :internal.gradient/stop
(s/keys :req-un [:internal.gradient.stop/color
:internal.gradient.stop/opacity
:internal.gradient.stop/offset]))
(s/def :internal.gradient/stops
(s/coll-of :internal.gradient/stop :kind vector?))
(s/def ::gradient
(s/keys :req-un [:internal.gradient/type
:internal.gradient/start-x
:internal.gradient/start-y
:internal.gradient/end-x
:internal.gradient/end-y
:internal.gradient/width
:internal.gradient/stops]))
;;; --- COLORS
(s/def :internal.color/name string?)
(s/def :internal.color/path (s/nilable string?))
(s/def :internal.color/value (s/nilable string?))
(s/def :internal.color/color (s/nilable string?))
(s/def :internal.color/opacity (s/nilable ::us/safe-number))
(s/def :internal.color/gradient (s/nilable ::gradient))
(s/def ::color
(s/keys :opt-un [::id
:internal.color/name
:internal.color/path
:internal.color/value
:internal.color/color
:internal.color/opacity
:internal.color/gradient]))
(s/def ::recent-color
(s/keys :opt-un [:internal.color/value
:internal.color/color
:internal.color/opacity
:internal.color/gradient]))

View file

@ -0,0 +1,22 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.export
(:require
[app.common.spec :as us]
[clojure.spec.alpha :as s]))
(s/def ::suffix string?)
(s/def ::scale ::us/safe-number)
(s/def ::type keyword?)
(s/def ::export
(s/keys :req-un [::type
::suffix
::scale]))

View file

@ -0,0 +1,54 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.file
(:require
[app.common.spec :as us]
[app.common.spec.color :as color]
[app.common.spec.page :as page]
[app.common.spec.typography]
[clojure.spec.alpha :as s]))
(s/def :internal.media-object/name string?)
(s/def :internal.media-object/width ::us/safe-integer)
(s/def :internal.media-object/height ::us/safe-integer)
(s/def :internal.media-object/mtype string?)
(s/def ::media-object
(s/keys :req-un [::id
::name
:internal.media-object/width
:internal.media-object/height
:internal.media-object/mtype]))
(s/def ::colors
(s/map-of uuid? ::color/color))
(s/def ::recent-colors
(s/coll-of ::color/recent-color :kind vector?))
(s/def ::typographies
(s/map-of uuid? :app.common.spec.typography/typography))
(s/def ::pages
(s/coll-of uuid? :kind vector?))
(s/def ::media
(s/map-of uuid? ::media-object))
(s/def ::pages-index
(s/map-of uuid? ::page/page))
(s/def ::components
(s/map-of uuid? ::page/container))
(s/def ::data
(s/keys :req-un [::pages-index
::pages]
:opt-un [::colors
::recent-colors
::typographies
::media]))

View file

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.types.interactions (ns app.common.spec.interactions
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
@ -100,7 +100,7 @@
:bottom-left :bottom-left
:bottom-right :bottom-right
:bottom-center}) :bottom-center})
(s/def ::overlay-position ::us/point) (s/def ::overlay-position ::gpt/point)
(s/def ::url ::us/string) (s/def ::url ::us/string)
(s/def ::close-click-outside ::us/boolean) (s/def ::close-click-outside ::us/boolean)
(s/def ::background-overlay ::us/boolean) (s/def ::background-overlay ::us/boolean)

View file

@ -0,0 +1,128 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.page
(:require
[app.common.data :as d]
[app.common.spec :as us]
[app.common.spec.shape :as shape]
[clojure.spec.alpha :as s]))
;; --- Grid options
(s/def :internal.grid.color/color string?)
(s/def :internal.grid.color/opacity ::us/safe-number)
(s/def :internal.grid/size (s/nilable ::us/safe-integer))
(s/def :internal.grid/item-length (s/nilable ::us/safe-number))
(s/def :internal.grid/color (s/keys :req-un [:internal.grid.color/color
:internal.grid.color/opacity]))
(s/def :internal.grid/type #{:stretch :left :center :right})
(s/def :internal.grid/gutter (s/nilable ::us/safe-integer))
(s/def :internal.grid/margin (s/nilable ::us/safe-integer))
(s/def :internal.grid/square
(s/keys :req-un [:internal.grid/size
:internal.grid/color]))
(s/def :internal.grid/column
(s/keys :req-un [:internal.grid/color]
:opt-un [:internal.grid/size
:internal.grid/type
:internal.grid/item-length
:internal.grid/margin
:internal.grid/gutter]))
(s/def :internal.grid/row :internal.grid/column)
(s/def ::saved-grids
(s/keys :opt-un [:internal.grid/square
:internal.grid/row
:internal.grid/column]))
;; --- Background options
(s/def ::background string?)
;; --- Flow options
(s/def :internal.flow/id uuid?)
(s/def :internal.flow/name string?)
(s/def :internal.flow/starting-frame uuid?)
(s/def ::flow
(s/keys :req-un [:internal.flow/id
:internal.flow/name
:internal.flow/starting-frame]))
(s/def ::flows
(s/coll-of ::flow :kind vector?))
;; --- Guides
(s/def :internal.guides/id uuid?)
(s/def :internal.guides/axis #{:x :y})
(s/def :internal.guides/position ::us/safe-number)
(s/def :internal.guides/frame-id (s/nilable uuid?))
(s/def ::guide
(s/keys :req-un [:internal.guides/id
:internal.guides/axis
:internal.guides/position]
:opt-un [:internal.guides/frame-id]))
(s/def ::guides
(s/map-of uuid? ::guide))
;; --- Page Options
(s/def ::options
(s/keys :opt-un [::background
::saved-grids
::flows
::guides]))
;; --- Page
(s/def ::id uuid?)
(s/def ::name string?)
(s/def ::objects (s/map-of uuid? ::shape/shape))
(s/def ::page
(s/keys :req-un [::id ::name ::objects ::options]))
(s/def ::type #{:page :component})
(s/def ::path (s/nilable string?))
(s/def ::container
(s/keys :req-un [::id ::name ::objects]
:opt-un [::type ::path]))
;; --- Helpers for flow
(defn rename-flow
[flow name]
(assoc flow :name name))
(defn add-flow
[flows flow]
(conj (or flows []) flow))
(defn remove-flow
[flows flow-id]
(d/removev #(= (:id %) flow-id) flows))
(defn update-flow
[flows flow-id update-fn]
(let [index (d/index-of-pred flows #(= (:id %) flow-id))]
(update flows index update-fn)))
(defn get-frame-flow
[flows frame-id]
(d/seek #(= (:starting-frame %) frame-id) flows))

View file

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.types.radius (ns app.common.spec.radius
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))

View file

@ -0,0 +1,37 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.shadow
(:require
[app.common.spec :as us]
[app.common.spec.color :as color]
[clojure.spec.alpha :as s]))
;;; SHADOW EFFECT
(s/def ::id uuid?)
(s/def ::style #{:drop-shadow :inner-shadow})
(s/def ::color ::color/color)
(s/def ::offset-x ::us/safe-number)
(s/def ::offset-y ::us/safe-number)
(s/def ::blur ::us/safe-number)
(s/def ::spread ::us/safe-number)
(s/def ::hidden boolean?)
(s/def ::shadow-props
(s/keys :req-un [:internal.shadow/id
:internal.shadow/style
:internal.shadow/color
:internal.shadow/offset-x
:internal.shadow/offset-y
:internal.shadow/blur
:internal.shadow/spread
:internal.shadow/hidden]))
(s/def ::shadow
(s/coll-of ::shadow-props :kind vector?))

View file

@ -0,0 +1,242 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.shape
(:require
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.spec :as us]
[app.common.spec.blur :as blur]
[app.common.spec.color :as color]
[app.common.spec.export :as export]
[app.common.spec.interactions :as cti]
[app.common.spec.radius :as radius]
[app.common.spec.shadow :as shadow]
[clojure.set :as set]
[clojure.spec.alpha :as s]))
;; --- Specs
(s/def ::frame-id uuid?)
(s/def ::id uuid?)
(s/def ::name string?)
(s/def ::path (s/nilable string?))
(s/def ::page-id uuid?)
(s/def ::parent-id uuid?)
(s/def ::string string?)
(s/def ::type keyword?)
(s/def ::uuid uuid?)
(s/def ::component-id uuid?)
(s/def ::component-file uuid?)
(s/def ::component-root? boolean?)
(s/def ::shape-ref uuid?)
;; Size constraints
(s/def ::constraints-h #{:left :right :leftright :center :scale})
(s/def ::constraints-v #{:top :bottom :topbottom :center :scale})
(s/def ::fixed-scroll boolean?)
;; Page Data related
(s/def ::blocked boolean?)
(s/def ::collapsed boolean?)
(s/def ::fill-color string?)
(s/def ::fill-opacity ::us/safe-number)
(s/def ::fill-color-gradient (s/nilable ::color/gradient))
(s/def ::fill-color-ref-file (s/nilable uuid?))
(s/def ::fill-color-ref-id (s/nilable uuid?))
(s/def ::hide-fill-on-export boolean?)
(s/def ::masked-group? boolean?)
(s/def ::font-family string?)
(s/def ::font-size ::us/safe-integer)
(s/def ::font-style string?)
(s/def ::font-weight string?)
(s/def ::hidden boolean?)
(s/def ::letter-spacing ::us/safe-number)
(s/def ::line-height ::us/safe-number)
(s/def ::locked boolean?)
(s/def ::page-id uuid?)
(s/def ::proportion ::us/safe-number)
(s/def ::proportion-lock boolean?)
(s/def ::stroke-color string?)
(s/def ::stroke-color-gradient (s/nilable ::color/gradient))
(s/def ::stroke-color-ref-file (s/nilable uuid?))
(s/def ::stroke-color-ref-id (s/nilable uuid?))
(s/def ::stroke-opacity ::us/safe-number)
(s/def ::stroke-style #{:solid :dotted :dashed :mixed :none :svg})
(def stroke-caps-line #{:round :square})
(def stroke-caps-marker #{:line-arrow :triangle-arrow :square-marker :circle-marker :diamond-marker})
(def stroke-caps (set/union stroke-caps-line stroke-caps-marker))
(s/def ::stroke-cap-start stroke-caps)
(s/def ::stroke-cap-end stroke-caps)
(s/def ::stroke-width ::us/safe-number)
(s/def ::stroke-alignment #{:center :inner :outer})
(s/def ::text-align #{"left" "right" "center" "justify"})
(s/def ::x ::us/safe-number)
(s/def ::y ::us/safe-number)
(s/def ::cx ::us/safe-number)
(s/def ::cy ::us/safe-number)
(s/def ::width ::us/safe-number)
(s/def ::height ::us/safe-number)
(s/def ::index integer?)
(s/def ::x1 ::us/safe-number)
(s/def ::y1 ::us/safe-number)
(s/def ::x2 ::us/safe-number)
(s/def ::y2 ::us/safe-number)
(s/def ::selrect
(s/keys :req-un [::x ::y ::x1 ::y1 ::x2 ::y2 ::width ::height]))
(s/def ::exports
(s/coll-of ::export/export :kind vector?))
(s/def ::points
(s/every ::gpt/point :kind vector?))
(s/def ::shapes
(s/every uuid? :kind vector?))
(s/def ::transform ::gmt/matrix)
(s/def ::transform-inverse ::gmt/matrix)
(s/def ::opacity ::us/safe-number)
(s/def ::blend-mode
#{:normal
:darken
:multiply
:color-burn
:lighten
:screen
:color-dodge
:overlay
:soft-light
:hard-light
:difference
:exclusion
:hue
:saturation
:color
:luminosity})
(s/def ::shape-attrs
(s/keys :opt-un [::id
::type
::name
::component-id
::component-file
::component-root?
::shape-ref
::selrect
::points
::blocked
::collapsed
::fill-color
::fill-opacity
::fill-color-gradient
::fill-color-ref-file
::fill-color-ref-id
::hide-fill-on-export
::font-family
::font-size
::font-style
::font-weight
::hidden
::letter-spacing
::line-height
::locked
::proportion
::proportion-lock
::constraints-h
::constraints-v
::fixed-scroll
::radius/rx
::radius/ry
::radius/r1
::radius/r2
::radius/r3
::radius/r4
::x
::y
::exports
::shapes
::stroke-color
::stroke-color-ref-file
::stroke-color-ref-id
::stroke-opacity
::stroke-style
::stroke-width
::stroke-alignment
::stroke-cap-start
::stroke-cap-end
::text-align
::transform
::transform-inverse
::width
::height
::masked-group?
::cti/interactions
::shadow/shadow
::blur/blur
::opacity
::blend-mode]))
(s/def :internal.shape.text/type #{"root" "paragraph-set" "paragraph"})
(s/def :internal.shape.text/children
(s/coll-of :internal.shape.text/content
:kind vector?
:min-count 1))
(s/def :internal.shape.text/text string?)
(s/def :internal.shape.text/key string?)
(s/def :internal.shape.text/content
(s/nilable
(s/or :text-container
(s/keys :req-un [:internal.shape.text/type
:internal.shape.text/children]
:opt-un [:internal.shape.text/key])
:text-content
(s/keys :req-un [:internal.shape.text/text]))))
(s/def :internal.shape.path/command keyword?)
(s/def :internal.shape.path/params
(s/nilable (s/map-of keyword? any?)))
(s/def :internal.shape.path/command-item
(s/keys :req-un [:internal.shape.path/command]
:opt-un [:internal.shape.path/params]))
(s/def :internal.shape.path/content
(s/coll-of :internal.shape.path/command-item :kind vector?))
(defmulti shape-spec :type)
(defmethod shape-spec :default [_]
(s/spec ::shape-attrs))
(defmethod shape-spec :text [_]
(s/and ::shape-attrs
(s/keys :opt-un [:internal.shape.text/content])))
(defmethod shape-spec :path [_]
(s/and ::shape-attrs
(s/keys :opt-un [:internal.shape.path/content])))
(defmethod shape-spec :frame [_]
(s/and ::shape-attrs
(s/keys :opt-un [::hide-fill-on-export])))
(s/def ::shape
(s/and (s/multi-spec shape-spec :type)
#(contains? % :type)
#(contains? % :name)))

View file

@ -0,0 +1,38 @@
;; 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) UXBOX Labs SL
(ns app.common.spec.typography
(:require
[clojure.spec.alpha :as s]))
(s/def ::id uuid?)
(s/def ::name string?)
(s/def ::path (s/nilable string?))
(s/def ::font-id string?)
(s/def ::font-family string?)
(s/def ::font-variant-id string?)
(s/def ::font-size string?)
(s/def ::font-weight string?)
(s/def ::font-style string?)
(s/def ::line-height string?)
(s/def ::letter-spacing string?)
(s/def ::text-transform string?)
(s/def ::typography
(s/keys :req-un [::id
::name
::font-id
::font-family
::font-variant-id
::font-size
::font-weight
::font-style
::line-height
::letter-spacing
::text-transform]
:opt-un [::path]))

View file

@ -1,95 +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) UXBOX Labs SL
(ns app.common.types.page-options
(:require
[app.common.data :as d]
[app.common.spec :as us]
[clojure.spec.alpha :as s]))
;; --- Grid options
(s/def :artboard-grid.color/color ::us/string)
(s/def :artboard-grid.color/opacity ::us/safe-number)
(s/def :artboard-grid/size (s/nilable ::us/safe-integer))
(s/def :artboard-grid/item-length (s/nilable ::us/safe-number))
(s/def :artboard-grid/color (s/keys :req-un [:artboard-grid.color/color
:artboard-grid.color/opacity]))
(s/def :artboard-grid/type #{:stretch :left :center :right})
(s/def :artboard-grid/gutter (s/nilable ::us/safe-integer))
(s/def :artboard-grid/margin (s/nilable ::us/safe-integer))
(s/def :artboard-grid/square
(s/keys :req-un [:artboard-grid/size
:artboard-grid/color]))
(s/def :artboard-grid/column
(s/keys :req-un [:artboard-grid/color]
:opt-un [:artboard-grid/size
:artboard-grid/type
:artboard-grid/item-length
:artboard-grid/margin
:artboard-grid/gutter]))
(s/def :artboard-grid/row :artboard-grid/column)
(s/def ::saved-grids
(s/keys :opt-un [:artboard-grid/square
:artboard-grid/row
:artboard-grid/column]))
;; --- Background options
(s/def ::background string?)
;; --- Flow options
(s/def :interactions-flow/id ::us/uuid)
(s/def :interactions-flow/name ::us/string)
(s/def :interactions-flow/starting-frame ::us/uuid)
(s/def ::flow
(s/keys :req-un [:interactions-flow/id
:interactions-flow/name
:interactions-flow/starting-frame]))
(s/def ::flows
(s/coll-of ::flow :kind vector?))
;; --- Options
(s/def ::options
(s/keys :opt-un [::background
::saved-grids
::flows]))
;; --- Helpers for flow
(defn rename-flow
[flow name]
(assoc flow :name name))
;; --- Helpers for flows
(defn add-flow
[flows flow]
(conj (or flows []) flow))
(defn remove-flow
[flows flow-id]
(d/removev #(= (:id %) flow-id) flows))
(defn update-flow
[flows flow-id update-fn]
(let [index (d/index-of-pred flows #(= (:id %) flow-id))]
(update flows index update-fn)))
(defn get-frame-flow
[flows frame-id]
(d/seek #(= (:starting-frame %) frame-id) flows))

View file

@ -4,68 +4,68 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.types-interactions-test (ns app.common.spec-interactions-test
(:require (:require
[clojure.test :as t] [clojure.test :as t]
[clojure.pprint :refer [pprint]] [clojure.pprint :refer [pprint]]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.pages.init :as cpi] [app.common.pages.init :as cpi]
[app.common.types.interactions :as cti] [app.common.spec.interactions :as csi]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.common.geom.point :as gpt])) [app.common.geom.point :as gpt]))
(t/deftest set-event-type (t/deftest set-event-type
(let [interaction cti/default-interaction (let [interaction csi/default-interaction
shape (cpi/make-minimal-shape :rect) shape (cpi/make-minimal-shape :rect)
frame (cpi/make-minimal-shape :frame)] frame (cpi/make-minimal-shape :frame)]
(t/testing "Set event type unchanged" (t/testing "Set event type unchanged"
(let [new-interaction (let [new-interaction
(cti/set-event-type interaction :click shape)] (csi/set-event-type interaction :click shape)]
(t/is (= :click (:event-type new-interaction))))) (t/is (= :click (:event-type new-interaction)))))
(t/testing "Set event type changed" (t/testing "Set event type changed"
(let [new-interaction (let [new-interaction
(cti/set-event-type interaction :mouse-press shape)] (csi/set-event-type interaction :mouse-press shape)]
(t/is (= :mouse-press (:event-type new-interaction))))) (t/is (= :mouse-press (:event-type new-interaction)))))
(t/testing "Set after delay on non-frame" (t/testing "Set after delay on non-frame"
(let [result (ex/try (let [result (ex/try
(cti/set-event-type interaction :after-delay shape))] (csi/set-event-type interaction :after-delay shape))]
(t/is (ex/exception? result)))) (t/is (ex/exception? result))))
(t/testing "Set after delay on frame" (t/testing "Set after delay on frame"
(let [new-interaction (let [new-interaction
(cti/set-event-type interaction :after-delay frame)] (csi/set-event-type interaction :after-delay frame)]
(t/is (= :after-delay (:event-type new-interaction))) (t/is (= :after-delay (:event-type new-interaction)))
(t/is (= 600 (:delay new-interaction))))) (t/is (= 600 (:delay new-interaction)))))
(t/testing "Set after delay with previous data" (t/testing "Set after delay with previous data"
(let [interaction (assoc interaction :delay 300) (let [interaction (assoc interaction :delay 300)
new-interaction new-interaction
(cti/set-event-type interaction :after-delay frame)] (csi/set-event-type interaction :after-delay frame)]
(t/is (= :after-delay (:event-type new-interaction))) (t/is (= :after-delay (:event-type new-interaction)))
(t/is (= 300 (:delay new-interaction))))))) (t/is (= 300 (:delay new-interaction)))))))
(t/deftest set-action-type (t/deftest set-action-type
(let [interaction cti/default-interaction] (let [interaction csi/default-interaction]
(t/testing "Set action type unchanged" (t/testing "Set action type unchanged"
(let [new-interaction (let [new-interaction
(cti/set-action-type interaction :navigate)] (csi/set-action-type interaction :navigate)]
(t/is (= :navigate (:action-type new-interaction))))) (t/is (= :navigate (:action-type new-interaction)))))
(t/testing "Set action type changed" (t/testing "Set action type changed"
(let [new-interaction (let [new-interaction
(cti/set-action-type interaction :prev-screen)] (csi/set-action-type interaction :prev-screen)]
(t/is (= :prev-screen (:action-type new-interaction))))) (t/is (= :prev-screen (:action-type new-interaction)))))
(t/testing "Set action type navigate" (t/testing "Set action type navigate"
(let [interaction {:event-type :click (let [interaction {:event-type :click
:action-type :prev-screen} :action-type :prev-screen}
new-interaction new-interaction
(cti/set-action-type interaction :navigate)] (csi/set-action-type interaction :navigate)]
(t/is (= :navigate (:action-type new-interaction))) (t/is (= :navigate (:action-type new-interaction)))
(t/is (nil? (:destination new-interaction))) (t/is (nil? (:destination new-interaction)))
(t/is (= false (:preserve-scroll new-interaction))))) (t/is (= false (:preserve-scroll new-interaction)))))
@ -77,14 +77,14 @@
:destination destination :destination destination
:preserve-scroll true} :preserve-scroll true}
new-interaction new-interaction
(cti/set-action-type interaction :navigate)] (csi/set-action-type interaction :navigate)]
(t/is (= :navigate (:action-type new-interaction))) (t/is (= :navigate (:action-type new-interaction)))
(t/is (= destination (:destination new-interaction))) (t/is (= destination (:destination new-interaction)))
(t/is (= true (:preserve-scroll new-interaction))))) (t/is (= true (:preserve-scroll new-interaction)))))
(t/testing "Set action type open-overlay" (t/testing "Set action type open-overlay"
(let [new-interaction (let [new-interaction
(cti/set-action-type interaction :open-overlay)] (csi/set-action-type interaction :open-overlay)]
(t/is (= :open-overlay (:action-type new-interaction))) (t/is (= :open-overlay (:action-type new-interaction)))
(t/is (= :center (:overlay-pos-type new-interaction))) (t/is (= :center (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 0 0) (:overlay-position new-interaction))))) (t/is (= (gpt/point 0 0) (:overlay-position new-interaction)))))
@ -93,14 +93,14 @@
(let [interaction (assoc interaction :overlay-pos-type :top-left (let [interaction (assoc interaction :overlay-pos-type :top-left
:overlay-position (gpt/point 100 200)) :overlay-position (gpt/point 100 200))
new-interaction new-interaction
(cti/set-action-type interaction :open-overlay)] (csi/set-action-type interaction :open-overlay)]
(t/is (= :open-overlay (:action-type new-interaction))) (t/is (= :open-overlay (:action-type new-interaction)))
(t/is (= :top-left (:overlay-pos-type new-interaction))) (t/is (= :top-left (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 100 200) (:overlay-position new-interaction))))) (t/is (= (gpt/point 100 200) (:overlay-position new-interaction)))))
(t/testing "Set action type toggle-overlay" (t/testing "Set action type toggle-overlay"
(let [new-interaction (let [new-interaction
(cti/set-action-type interaction :toggle-overlay)] (csi/set-action-type interaction :toggle-overlay)]
(t/is (= :toggle-overlay (:action-type new-interaction))) (t/is (= :toggle-overlay (:action-type new-interaction)))
(t/is (= :center (:overlay-pos-type new-interaction))) (t/is (= :center (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 0 0) (:overlay-position new-interaction))))) (t/is (= (gpt/point 0 0) (:overlay-position new-interaction)))))
@ -109,14 +109,14 @@
(let [interaction (assoc interaction :overlay-pos-type :top-left (let [interaction (assoc interaction :overlay-pos-type :top-left
:overlay-position (gpt/point 100 200)) :overlay-position (gpt/point 100 200))
new-interaction new-interaction
(cti/set-action-type interaction :toggle-overlay)] (csi/set-action-type interaction :toggle-overlay)]
(t/is (= :toggle-overlay (:action-type new-interaction))) (t/is (= :toggle-overlay (:action-type new-interaction)))
(t/is (= :top-left (:overlay-pos-type new-interaction))) (t/is (= :top-left (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 100 200) (:overlay-position new-interaction))))) (t/is (= (gpt/point 100 200) (:overlay-position new-interaction)))))
(t/testing "Set action type close-overlay" (t/testing "Set action type close-overlay"
(let [new-interaction (let [new-interaction
(cti/set-action-type interaction :close-overlay)] (csi/set-action-type interaction :close-overlay)]
(t/is (= :close-overlay (:action-type new-interaction))) (t/is (= :close-overlay (:action-type new-interaction)))
(t/is (nil? (:destination new-interaction))))) (t/is (nil? (:destination new-interaction)))))
@ -124,89 +124,89 @@
(let [destination (uuid/next) (let [destination (uuid/next)
interaction (assoc interaction :destination destination) interaction (assoc interaction :destination destination)
new-interaction new-interaction
(cti/set-action-type interaction :close-overlay)] (csi/set-action-type interaction :close-overlay)]
(t/is (= :close-overlay (:action-type new-interaction))) (t/is (= :close-overlay (:action-type new-interaction)))
(t/is (= destination (:destination new-interaction))))) (t/is (= destination (:destination new-interaction)))))
(t/testing "Set action type prev-screen" (t/testing "Set action type prev-screen"
(let [new-interaction (let [new-interaction
(cti/set-action-type interaction :prev-screen)] (csi/set-action-type interaction :prev-screen)]
(t/is (= :prev-screen (:action-type new-interaction))))) (t/is (= :prev-screen (:action-type new-interaction)))))
(t/testing "Set action type open-url" (t/testing "Set action type open-url"
(let [new-interaction (let [new-interaction
(cti/set-action-type interaction :open-url)] (csi/set-action-type interaction :open-url)]
(t/is (= :open-url (:action-type new-interaction))) (t/is (= :open-url (:action-type new-interaction)))
(t/is (= "" (:url new-interaction))))) (t/is (= "" (:url new-interaction)))))
(t/testing "Set action type open-url with previous data" (t/testing "Set action type open-url with previous data"
(let [interaction (assoc interaction :url "https://example.com") (let [interaction (assoc interaction :url "https://example.com")
new-interaction new-interaction
(cti/set-action-type interaction :open-url)] (csi/set-action-type interaction :open-url)]
(t/is (= :open-url (:action-type new-interaction))) (t/is (= :open-url (:action-type new-interaction)))
(t/is (= "https://example.com" (:url new-interaction))))))) (t/is (= "https://example.com" (:url new-interaction)))))))
(t/deftest option-delay (t/deftest option-delay
(let [frame (cpi/make-minimal-shape :frame) (let [frame (cpi/make-minimal-shape :frame)
i1 cti/default-interaction i1 csi/default-interaction
i2 (cti/set-event-type i1 :after-delay frame)] i2 (csi/set-event-type i1 :after-delay frame)]
(t/testing "Has delay" (t/testing "Has delay"
(t/is (not (cti/has-delay i1))) (t/is (not (csi/has-delay i1)))
(t/is (cti/has-delay i2))) (t/is (csi/has-delay i2)))
(t/testing "Set delay" (t/testing "Set delay"
(let [new-interaction (cti/set-delay i2 1000)] (let [new-interaction (csi/set-delay i2 1000)]
(t/is (= 1000 (:delay new-interaction))))))) (t/is (= 1000 (:delay new-interaction)))))))
(t/deftest option-destination (t/deftest option-destination
(let [destination (uuid/next) (let [destination (uuid/next)
i1 cti/default-interaction i1 csi/default-interaction
i2 (cti/set-action-type i1 :prev-screen) i2 (csi/set-action-type i1 :prev-screen)
i3 (cti/set-action-type i1 :open-overlay)] i3 (csi/set-action-type i1 :open-overlay)]
(t/testing "Has destination" (t/testing "Has destination"
(t/is (cti/has-destination i1)) (t/is (csi/has-destination i1))
(t/is (not (cti/has-destination i2)))) (t/is (not (csi/has-destination i2))))
(t/testing "Set destination" (t/testing "Set destination"
(let [new-interaction (cti/set-destination i1 destination)] (let [new-interaction (csi/set-destination i1 destination)]
(t/is (= destination (:destination new-interaction))) (t/is (= destination (:destination new-interaction)))
(t/is (nil? (:overlay-pos-type new-interaction))) (t/is (nil? (:overlay-pos-type new-interaction)))
(t/is (nil? (:overlay-position new-interaction))))) (t/is (nil? (:overlay-position new-interaction)))))
(t/testing "Set destination of overlay" (t/testing "Set destination of overlay"
(let [new-interaction (cti/set-destination i3 destination)] (let [new-interaction (csi/set-destination i3 destination)]
(t/is (= destination (:destination new-interaction))) (t/is (= destination (:destination new-interaction)))
(t/is (= :center (:overlay-pos-type new-interaction))) (t/is (= :center (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 0 0) (:overlay-position new-interaction))))))) (t/is (= (gpt/point 0 0) (:overlay-position new-interaction)))))))
(t/deftest option-preserve-scroll (t/deftest option-preserve-scroll
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-action-type i1 :prev-screen)] i2 (csi/set-action-type i1 :prev-screen)]
(t/testing "Has preserve-scroll" (t/testing "Has preserve-scroll"
(t/is (cti/has-preserve-scroll i1)) (t/is (csi/has-preserve-scroll i1))
(t/is (not (cti/has-preserve-scroll i2)))) (t/is (not (csi/has-preserve-scroll i2))))
(t/testing "Set preserve-scroll" (t/testing "Set preserve-scroll"
(let [new-interaction (cti/set-preserve-scroll i1 true)] (let [new-interaction (csi/set-preserve-scroll i1 true)]
(t/is (= true (:preserve-scroll new-interaction))))))) (t/is (= true (:preserve-scroll new-interaction)))))))
(t/deftest option-url (t/deftest option-url
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-action-type i1 :open-url)] i2 (csi/set-action-type i1 :open-url)]
(t/testing "Has url" (t/testing "Has url"
(t/is (not (cti/has-url i1))) (t/is (not (csi/has-url i1)))
(t/is (cti/has-url i2))) (t/is (csi/has-url i2)))
(t/testing "Set url" (t/testing "Set url"
(let [new-interaction (cti/set-url i2 "https://example.com")] (let [new-interaction (csi/set-url i2 "https://example.com")]
(t/is (= "https://example.com" (:url new-interaction))))))) (t/is (= "https://example.com" (:url new-interaction)))))))
@ -220,35 +220,35 @@
objects {(:id base-frame) base-frame objects {(:id base-frame) base-frame
(:id overlay-frame) overlay-frame} (:id overlay-frame) overlay-frame}
i1 cti/default-interaction i1 csi/default-interaction
i2 (cti/set-action-type i1 :open-overlay) i2 (csi/set-action-type i1 :open-overlay)
i3 (-> i1 i3 (-> i1
(cti/set-action-type :open-overlay) (csi/set-action-type :open-overlay)
(cti/set-destination (:id overlay-frame)))] (csi/set-destination (:id overlay-frame)))]
(t/testing "Has overlay options" (t/testing "Has overlay options"
(t/is (not (cti/has-overlay-opts i1))) (t/is (not (csi/has-overlay-opts i1)))
(t/is (cti/has-overlay-opts i2))) (t/is (csi/has-overlay-opts i2)))
(t/testing "Set overlay-pos-type without destination" (t/testing "Set overlay-pos-type without destination"
(let [new-interaction (cti/set-overlay-pos-type i2 :top-right base-frame objects)] (let [new-interaction (csi/set-overlay-pos-type i2 :top-right base-frame objects)]
(t/is (= :top-right (:overlay-pos-type new-interaction))) (t/is (= :top-right (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 0 0) (:overlay-position new-interaction))))) (t/is (= (gpt/point 0 0) (:overlay-position new-interaction)))))
(t/testing "Set overlay-pos-type with destination and auto" (t/testing "Set overlay-pos-type with destination and auto"
(let [new-interaction (cti/set-overlay-pos-type i3 :bottom-right base-frame objects)] (let [new-interaction (csi/set-overlay-pos-type i3 :bottom-right base-frame objects)]
(t/is (= :bottom-right (:overlay-pos-type new-interaction))) (t/is (= :bottom-right (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 0 0) (:overlay-position new-interaction))))) (t/is (= (gpt/point 0 0) (:overlay-position new-interaction)))))
(t/testing "Set overlay-pos-type with destination and manual" (t/testing "Set overlay-pos-type with destination and manual"
(let [new-interaction (cti/set-overlay-pos-type i3 :manual base-frame objects)] (let [new-interaction (csi/set-overlay-pos-type i3 :manual base-frame objects)]
(t/is (= :manual (:overlay-pos-type new-interaction))) (t/is (= :manual (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 35 40) (:overlay-position new-interaction))))) (t/is (= (gpt/point 35 40) (:overlay-position new-interaction)))))
(t/testing "Toggle overlay-pos-type" (t/testing "Toggle overlay-pos-type"
(let [new-interaction (cti/toggle-overlay-pos-type i3 :center base-frame objects) (let [new-interaction (csi/toggle-overlay-pos-type i3 :center base-frame objects)
new-interaction-2 (cti/toggle-overlay-pos-type new-interaction :center base-frame objects) new-interaction-2 (csi/toggle-overlay-pos-type new-interaction :center base-frame objects)
new-interaction-3 (cti/toggle-overlay-pos-type new-interaction-2 :top-right base-frame objects)] new-interaction-3 (csi/toggle-overlay-pos-type new-interaction-2 :top-right base-frame objects)]
(t/is (= :manual (:overlay-pos-type new-interaction))) (t/is (= :manual (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 35 40) (:overlay-position new-interaction))) (t/is (= (gpt/point 35 40) (:overlay-position new-interaction)))
(t/is (= :center (:overlay-pos-type new-interaction-2))) (t/is (= :center (:overlay-pos-type new-interaction-2)))
@ -257,73 +257,73 @@
(t/is (= (gpt/point 0 0) (:overlay-position new-interaction-3))))) (t/is (= (gpt/point 0 0) (:overlay-position new-interaction-3)))))
(t/testing "Set overlay-position" (t/testing "Set overlay-position"
(let [new-interaction (cti/set-overlay-position i3 (gpt/point 50 60))] (let [new-interaction (csi/set-overlay-position i3 (gpt/point 50 60))]
(t/is (= :manual (:overlay-pos-type new-interaction))) (t/is (= :manual (:overlay-pos-type new-interaction)))
(t/is (= (gpt/point 50 60) (:overlay-position new-interaction))))) (t/is (= (gpt/point 50 60) (:overlay-position new-interaction)))))
(t/testing "Set close-click-outside" (t/testing "Set close-click-outside"
(let [new-interaction (cti/set-close-click-outside i3 true)] (let [new-interaction (csi/set-close-click-outside i3 true)]
(t/is (not (:close-click-outside i3))) (t/is (not (:close-click-outside i3)))
(t/is (:close-click-outside new-interaction)))) (t/is (:close-click-outside new-interaction))))
(t/testing "Set background-overlay" (t/testing "Set background-overlay"
(let [new-interaction (cti/set-background-overlay i3 true)] (let [new-interaction (csi/set-background-overlay i3 true)]
(t/is (not (:background-overlay i3))) (t/is (not (:background-overlay i3)))
(t/is (:background-overlay new-interaction)))))) (t/is (:background-overlay new-interaction))))))
(t/deftest animation-checks (t/deftest animation-checks
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-action-type i1 :open-overlay) i2 (csi/set-action-type i1 :open-overlay)
i3 (cti/set-action-type i1 :toggle-overlay) i3 (csi/set-action-type i1 :toggle-overlay)
i4 (cti/set-action-type i1 :close-overlay) i4 (csi/set-action-type i1 :close-overlay)
i5 (cti/set-action-type i1 :prev-screen) i5 (csi/set-action-type i1 :prev-screen)
i6 (cti/set-action-type i1 :open-url)] i6 (csi/set-action-type i1 :open-url)]
(t/testing "Has animation?" (t/testing "Has animation?"
(t/is (cti/has-animation? i1)) (t/is (csi/has-animation? i1))
(t/is (cti/has-animation? i2)) (t/is (csi/has-animation? i2))
(t/is (cti/has-animation? i3)) (t/is (csi/has-animation? i3))
(t/is (cti/has-animation? i4)) (t/is (csi/has-animation? i4))
(t/is (not (cti/has-animation? i5))) (t/is (not (csi/has-animation? i5)))
(t/is (not (cti/has-animation? i6)))) (t/is (not (csi/has-animation? i6))))
(t/testing "Valid push?" (t/testing "Valid push?"
(t/is (cti/allow-push? (:action-type i1))) (t/is (csi/allow-push? (:action-type i1)))
(t/is (not (cti/allow-push? (:action-type i2)))) (t/is (not (csi/allow-push? (:action-type i2))))
(t/is (not (cti/allow-push? (:action-type i3)))) (t/is (not (csi/allow-push? (:action-type i3))))
(t/is (not (cti/allow-push? (:action-type i4)))) (t/is (not (csi/allow-push? (:action-type i4))))
(t/is (not (cti/allow-push? (:action-type i5)))) (t/is (not (csi/allow-push? (:action-type i5))))
(t/is (not (cti/allow-push? (:action-type i6))))))) (t/is (not (csi/allow-push? (:action-type i6)))))))
(t/deftest set-animation-type (t/deftest set-animation-type
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-animation-type i1 :dissolve)] i2 (csi/set-animation-type i1 :dissolve)]
(t/testing "Set animation type nil" (t/testing "Set animation type nil"
(let [new-interaction (let [new-interaction
(cti/set-animation-type i1 nil)] (csi/set-animation-type i1 nil)]
(t/is (nil? (-> new-interaction :animation :animation-type))))) (t/is (nil? (-> new-interaction :animation :animation-type)))))
(t/testing "Set animation type unchanged" (t/testing "Set animation type unchanged"
(let [new-interaction (let [new-interaction
(cti/set-animation-type i2 :dissolve)] (csi/set-animation-type i2 :dissolve)]
(t/is (= :dissolve (-> new-interaction :animation :animation-type))))) (t/is (= :dissolve (-> new-interaction :animation :animation-type)))))
(t/testing "Set animation type changed" (t/testing "Set animation type changed"
(let [new-interaction (let [new-interaction
(cti/set-animation-type i2 :slide)] (csi/set-animation-type i2 :slide)]
(t/is (= :slide (-> new-interaction :animation :animation-type))))) (t/is (= :slide (-> new-interaction :animation :animation-type)))))
(t/testing "Set animation type reset" (t/testing "Set animation type reset"
(let [new-interaction (let [new-interaction
(cti/set-animation-type i2 nil)] (csi/set-animation-type i2 nil)]
(t/is (nil? (-> new-interaction :animation))))) (t/is (nil? (-> new-interaction :animation)))))
(t/testing "Set animation type dissolve" (t/testing "Set animation type dissolve"
(let [new-interaction (let [new-interaction
(cti/set-animation-type i1 :dissolve)] (csi/set-animation-type i1 :dissolve)]
(t/is (= :dissolve (-> new-interaction :animation :animation-type))) (t/is (= :dissolve (-> new-interaction :animation :animation-type)))
(t/is (= 300 (-> new-interaction :animation :duration))) (t/is (= 300 (-> new-interaction :animation :duration)))
(t/is (= :linear (-> new-interaction :animation :easing))))) (t/is (= :linear (-> new-interaction :animation :easing)))))
@ -336,14 +336,14 @@
:direction :left :direction :left
:offset-effect true}) :offset-effect true})
new-interaction new-interaction
(cti/set-animation-type interaction :dissolve)] (csi/set-animation-type interaction :dissolve)]
(t/is (= :dissolve (-> new-interaction :animation :animation-type))) (t/is (= :dissolve (-> new-interaction :animation :animation-type)))
(t/is (= 1000 (-> new-interaction :animation :duration))) (t/is (= 1000 (-> new-interaction :animation :duration)))
(t/is (= :ease-out (-> new-interaction :animation :easing))))) (t/is (= :ease-out (-> new-interaction :animation :easing)))))
(t/testing "Set animation type slide" (t/testing "Set animation type slide"
(let [new-interaction (let [new-interaction
(cti/set-animation-type i1 :slide)] (csi/set-animation-type i1 :slide)]
(t/is (= :slide (-> new-interaction :animation :animation-type))) (t/is (= :slide (-> new-interaction :animation :animation-type)))
(t/is (= 300 (-> new-interaction :animation :duration))) (t/is (= 300 (-> new-interaction :animation :duration)))
(t/is (= :linear (-> new-interaction :animation :easing))) (t/is (= :linear (-> new-interaction :animation :easing)))
@ -359,7 +359,7 @@
:direction :left :direction :left
:offset-effect true}) :offset-effect true})
new-interaction new-interaction
(cti/set-animation-type interaction :slide)] (csi/set-animation-type interaction :slide)]
(t/is (= :slide (-> new-interaction :animation :animation-type))) (t/is (= :slide (-> new-interaction :animation :animation-type)))
(t/is (= 1000 (-> new-interaction :animation :duration))) (t/is (= 1000 (-> new-interaction :animation :duration)))
(t/is (= :ease-out (-> new-interaction :animation :easing))) (t/is (= :ease-out (-> new-interaction :animation :easing)))
@ -369,7 +369,7 @@
(t/testing "Set animation type push" (t/testing "Set animation type push"
(let [new-interaction (let [new-interaction
(cti/set-animation-type i1 :push)] (csi/set-animation-type i1 :push)]
(t/is (= :push (-> new-interaction :animation :animation-type))) (t/is (= :push (-> new-interaction :animation :animation-type)))
(t/is (= 300 (-> new-interaction :animation :duration))) (t/is (= 300 (-> new-interaction :animation :duration)))
(t/is (= :linear (-> new-interaction :animation :easing))) (t/is (= :linear (-> new-interaction :animation :easing)))
@ -383,7 +383,7 @@
:direction :left :direction :left
:offset-effect true}) :offset-effect true})
new-interaction new-interaction
(cti/set-animation-type interaction :push)] (csi/set-animation-type interaction :push)]
(t/is (= :push (-> new-interaction :animation :animation-type))) (t/is (= :push (-> new-interaction :animation :animation-type)))
(t/is (= 1000 (-> new-interaction :animation :duration))) (t/is (= 1000 (-> new-interaction :animation :duration)))
(t/is (= :ease-out (-> new-interaction :animation :easing))) (t/is (= :ease-out (-> new-interaction :animation :easing)))
@ -391,9 +391,9 @@
(t/deftest allowed-animation (t/deftest allowed-animation
(let [i1 (cti/set-action-type cti/default-interaction :open-overlay) (let [i1 (csi/set-action-type csi/default-interaction :open-overlay)
i2 (cti/set-action-type cti/default-interaction :close-overlay) i2 (csi/set-action-type csi/default-interaction :close-overlay)
i3 (cti/set-action-type cti/default-interaction :toggle-overlay)] i3 (csi/set-action-type csi/default-interaction :toggle-overlay)]
(t/testing "Cannot use animation push for an overlay action" (t/testing "Cannot use animation push for an overlay action"
(let [bad-interaction-1 (assoc i1 :animation {:animation-type :push (let [bad-interaction-1 (assoc i1 :animation {:animation-type :push
@ -408,72 +408,72 @@
:duration 1000 :duration 1000
:easing :ease-out :easing :ease-out
:direction :left})] :direction :left})]
(t/is (not (cti/allowed-animation? (:action-type bad-interaction-1) (t/is (not (csi/allowed-animation? (:action-type bad-interaction-1)
(-> bad-interaction-1 :animation :animation-type)))) (-> bad-interaction-1 :animation :animation-type))))
(t/is (not (cti/allowed-animation? (:action-type bad-interaction-2) (t/is (not (csi/allowed-animation? (:action-type bad-interaction-2)
(-> bad-interaction-1 :animation :animation-type)))) (-> bad-interaction-1 :animation :animation-type))))
(t/is (not (cti/allowed-animation? (:action-type bad-interaction-3) (t/is (not (csi/allowed-animation? (:action-type bad-interaction-3)
(-> bad-interaction-1 :animation :animation-type)))))) (-> bad-interaction-1 :animation :animation-type))))))
(t/testing "Remove animation if moving to an forbidden state" (t/testing "Remove animation if moving to an forbidden state"
(let [interaction (cti/set-animation-type cti/default-interaction :push) (let [interaction (csi/set-animation-type csi/default-interaction :push)
new-interaction (cti/set-action-type interaction :open-overlay)] new-interaction (csi/set-action-type interaction :open-overlay)]
(t/is (nil? (:animation new-interaction))))))) (t/is (nil? (:animation new-interaction)))))))
(t/deftest option-duration (t/deftest option-duration
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-animation-type cti/default-interaction :dissolve)] i2 (csi/set-animation-type csi/default-interaction :dissolve)]
(t/testing "Has duration?" (t/testing "Has duration?"
(t/is (not (cti/has-duration? i1))) (t/is (not (csi/has-duration? i1)))
(t/is (cti/has-duration? i2))) (t/is (csi/has-duration? i2)))
(t/testing "Set duration" (t/testing "Set duration"
(let [new-interaction (cti/set-duration i2 1000)] (let [new-interaction (csi/set-duration i2 1000)]
(t/is (= 1000 (-> new-interaction :animation :duration))))))) (t/is (= 1000 (-> new-interaction :animation :duration)))))))
(t/deftest option-easing (t/deftest option-easing
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-animation-type cti/default-interaction :dissolve)] i2 (csi/set-animation-type csi/default-interaction :dissolve)]
(t/testing "Has easing?" (t/testing "Has easing?"
(t/is (not (cti/has-easing? i1))) (t/is (not (csi/has-easing? i1)))
(t/is (cti/has-easing? i2))) (t/is (csi/has-easing? i2)))
(t/testing "Set easing" (t/testing "Set easing"
(let [new-interaction (cti/set-easing i2 :ease-in)] (let [new-interaction (csi/set-easing i2 :ease-in)]
(t/is (= :ease-in (-> new-interaction :animation :easing))))))) (t/is (= :ease-in (-> new-interaction :animation :easing)))))))
(t/deftest option-way (t/deftest option-way
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-animation-type cti/default-interaction :slide) i2 (csi/set-animation-type csi/default-interaction :slide)
i3 (cti/set-action-type i2 :open-overlay)] i3 (csi/set-action-type i2 :open-overlay)]
(t/testing "Has way?" (t/testing "Has way?"
(t/is (not (cti/has-way? i1))) (t/is (not (csi/has-way? i1)))
(t/is (cti/has-way? i2)) (t/is (csi/has-way? i2))
(t/is (not (cti/has-way? i3))) (t/is (not (csi/has-way? i3)))
(t/is (some? (-> i3 :animation :way)))) ; <- it exists but is ignored (t/is (some? (-> i3 :animation :way)))) ; <- it exists but is ignored
(t/testing "Set way" (t/testing "Set way"
(let [new-interaction (cti/set-way i2 :out)] (let [new-interaction (csi/set-way i2 :out)]
(t/is (= :out (-> new-interaction :animation :way))))))) (t/is (= :out (-> new-interaction :animation :way)))))))
(t/deftest option-direction (t/deftest option-direction
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-animation-type cti/default-interaction :push) i2 (csi/set-animation-type csi/default-interaction :push)
i3 (cti/set-animation-type cti/default-interaction :dissolve)] i3 (csi/set-animation-type csi/default-interaction :dissolve)]
(t/testing "Has direction?" (t/testing "Has direction?"
(t/is (not (cti/has-direction? i1))) (t/is (not (csi/has-direction? i1)))
(t/is (cti/has-direction? i2))) (t/is (csi/has-direction? i2)))
(t/testing "Set direction" (t/testing "Set direction"
(let [new-interaction (cti/set-direction i2 :left)] (let [new-interaction (csi/set-direction i2 :left)]
(t/is (= :left (-> new-interaction :animation :direction))))) (t/is (= :left (-> new-interaction :animation :direction)))))
(t/testing "Invert direction" (t/testing "Invert direction"
@ -483,12 +483,12 @@
a-up (assoc a-right :direction :up) a-up (assoc a-right :direction :up)
a-down (assoc a-right :direction :down) a-down (assoc a-right :direction :down)
a-nil' (cti/invert-direction nil) a-nil' (csi/invert-direction nil)
a-none' (cti/invert-direction a-none) a-none' (csi/invert-direction a-none)
a-right' (cti/invert-direction a-right) a-right' (csi/invert-direction a-right)
a-left' (cti/invert-direction a-left) a-left' (csi/invert-direction a-left)
a-up' (cti/invert-direction a-up) a-up' (csi/invert-direction a-up)
a-down' (cti/invert-direction a-down)] a-down' (csi/invert-direction a-down)]
(t/is (nil? a-nil')) (t/is (nil? a-nil'))
(t/is (nil? (:direction a-none'))) (t/is (nil? (:direction a-none')))
@ -499,44 +499,44 @@
(t/deftest option-offset-effect (t/deftest option-offset-effect
(let [i1 cti/default-interaction (let [i1 csi/default-interaction
i2 (cti/set-animation-type cti/default-interaction :slide) i2 (csi/set-animation-type csi/default-interaction :slide)
i3 (cti/set-action-type i2 :open-overlay)] i3 (csi/set-action-type i2 :open-overlay)]
(t/testing "Has offset-effect" (t/testing "Has offset-effect"
(t/is (not (cti/has-offset-effect? i1))) (t/is (not (csi/has-offset-effect? i1)))
(t/is (cti/has-offset-effect? i2)) (t/is (csi/has-offset-effect? i2))
(t/is (not (cti/has-offset-effect? i3))) (t/is (not (csi/has-offset-effect? i3)))
(t/is (some? (-> i3 :animation :offset-effect)))) ; <- it exists but is ignored (t/is (some? (-> i3 :animation :offset-effect)))) ; <- it exists but is ignored
(t/testing "Set offset-effect" (t/testing "Set offset-effect"
(let [new-interaction (cti/set-offset-effect i2 true)] (let [new-interaction (csi/set-offset-effect i2 true)]
(t/is (= true (-> new-interaction :animation :offset-effect))))))) (t/is (= true (-> new-interaction :animation :offset-effect)))))))
(t/deftest modify-interactions (t/deftest modify-interactions
(let [i1 (cti/set-action-type cti/default-interaction :open-overlay) (let [i1 (csi/set-action-type csi/default-interaction :open-overlay)
i2 (cti/set-action-type cti/default-interaction :close-overlay) i2 (csi/set-action-type csi/default-interaction :close-overlay)
i3 (cti/set-action-type cti/default-interaction :prev-screen) i3 (csi/set-action-type csi/default-interaction :prev-screen)
interactions [i1 i2]] interactions [i1 i2]]
(t/testing "Add interaction to nil" (t/testing "Add interaction to nil"
(let [new-interactions (cti/add-interaction nil i3)] (let [new-interactions (csi/add-interaction nil i3)]
(t/is (= (count new-interactions) 1)) (t/is (= (count new-interactions) 1))
(t/is (= (:action-type (last new-interactions)) :prev-screen)))) (t/is (= (:action-type (last new-interactions)) :prev-screen))))
(t/testing "Add interaction to normal" (t/testing "Add interaction to normal"
(let [new-interactions (cti/add-interaction interactions i3)] (let [new-interactions (csi/add-interaction interactions i3)]
(t/is (= (count new-interactions) 3)) (t/is (= (count new-interactions) 3))
(t/is (= (:action-type (last new-interactions)) :prev-screen)))) (t/is (= (:action-type (last new-interactions)) :prev-screen))))
(t/testing "Remove interaction" (t/testing "Remove interaction"
(let [new-interactions (cti/remove-interaction interactions 0)] (let [new-interactions (csi/remove-interaction interactions 0)]
(t/is (= (count new-interactions) 1)) (t/is (= (count new-interactions) 1))
(t/is (= (:action-type (last new-interactions)) :close-overlay)))) (t/is (= (:action-type (last new-interactions)) :close-overlay))))
(t/testing "Update interaction" (t/testing "Update interaction"
(let [new-interactions (cti/update-interaction interactions 1 #(cti/set-action-type % :open-url))] (let [new-interactions (csi/update-interaction interactions 1 #(csi/set-action-type % :open-url))]
(t/is (= (count new-interactions) 2)) (t/is (= (count new-interactions) 2))
(t/is (= (:action-type (last new-interactions)) :open-url)))))) (t/is (= (:action-type (last new-interactions)) :open-url))))))
@ -556,16 +556,16 @@
ids-map {(:id frame1) (:id frame4) ids-map {(:id frame1) (:id frame4)
(:id frame2) (:id frame5)} (:id frame2) (:id frame5)}
i1 (cti/set-destination cti/default-interaction (:id frame1)) i1 (csi/set-destination csi/default-interaction (:id frame1))
i2 (cti/set-destination cti/default-interaction (:id frame2)) i2 (csi/set-destination csi/default-interaction (:id frame2))
i3 (cti/set-destination cti/default-interaction (:id frame3)) i3 (csi/set-destination csi/default-interaction (:id frame3))
i4 (cti/set-destination cti/default-interaction nil) i4 (csi/set-destination csi/default-interaction nil)
i5 (cti/set-destination cti/default-interaction (:id frame6)) i5 (csi/set-destination csi/default-interaction (:id frame6))
interactions [i1 i2 i3 i4 i5]] interactions [i1 i2 i3 i4 i5]]
(t/testing "Remap interactions" (t/testing "Remap interactions"
(let [new-interactions (cti/remap-interactions interactions ids-map objects)] (let [new-interactions (csi/remap-interactions interactions ids-map objects)]
(t/is (= (count new-interactions) 4)) (t/is (= (count new-interactions) 4))
(t/is (= (:id frame4) (:destination (get new-interactions 0)))) (t/is (= (:id frame4) (:destination (get new-interactions 0))))
(t/is (= (:id frame5) (:destination (get new-interactions 1)))) (t/is (= (:id frame5) (:destination (get new-interactions 1))))

View file

@ -533,10 +533,10 @@ shadow-cljs-jar@1.3.2:
resolved "https://registry.yarnpkg.com/shadow-cljs-jar/-/shadow-cljs-jar-1.3.2.tgz#97273afe1747b6a2311917c1c88d9e243c81957b" resolved "https://registry.yarnpkg.com/shadow-cljs-jar/-/shadow-cljs-jar-1.3.2.tgz#97273afe1747b6a2311917c1c88d9e243c81957b"
integrity sha512-XmeffAZHv8z7451kzeq9oKh8fh278Ak+UIOGGrapyqrFBB773xN8vMQ3O7J7TYLnb9BUwcqadKkmgaq7q6fhZg== integrity sha512-XmeffAZHv8z7451kzeq9oKh8fh278Ak+UIOGGrapyqrFBB773xN8vMQ3O7J7TYLnb9BUwcqadKkmgaq7q6fhZg==
shadow-cljs@2.16.12: shadow-cljs@2.17.3:
version "2.16.12" version "2.17.3"
resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.16.12.tgz#8757b3079dadfff15ca09192f81eb69b5d25266d" resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.17.3.tgz#748e31f67cffdc401691c0cd1bf733a1da53ab5d"
integrity sha512-6JqOhN5X3n0IkxA/gSUcZ1lImwcW1LmpgzlaBDOC/u/pIysdNm0tiOxpOTEnExl9nKZBS/EYS7bXIIInywPJUA== integrity sha512-GxyczUuCtACq/uEOvdTc61wT/aDOZFy8G/AGc322uTX/oUiZaeTJrwpClXe+0+e7VKG9E9RCqP/cjuG3cAG0fw==
dependencies: dependencies:
node-libs-browser "^2.2.1" node-libs-browser "^2.2.1"
readline-sync "^1.4.7" readline-sync "^1.4.7"

View file

@ -10,6 +10,7 @@ networks:
volumes: volumes:
postgres_data: postgres_data:
user_data: user_data:
minio_data:
services: services:
main: main:
@ -66,6 +67,22 @@ services:
- PENPOT_LDAP_ATTRS_FULLNAME=cn - PENPOT_LDAP_ATTRS_FULLNAME=cn
- PENPOT_LDAP_ATTRS_PHOTO=jpegPhoto - PENPOT_LDAP_ATTRS_PHOTO=jpegPhoto
minio:
profiles: ["full"]
image: "minio/minio:latest"
command: minio server /mnt/data --console-address ":9001"
volumes:
- "minio_data:/mnt/data"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
ports:
- 9000:9000
- 9001:9001
backend: backend:
profiles: ["backend"] profiles: ["backend"]
privileged: true privileged: true
@ -91,6 +108,7 @@ services:
environment: environment:
- EXTERNAL_UID=${CURRENT_USER_ID} - EXTERNAL_UID=${CURRENT_USER_ID}
- PENPOT_SECRET_KEY=super-secret-devenv-key - PENPOT_SECRET_KEY=super-secret-devenv-key
# SMTP setup # SMTP setup
- PENPOT_SMTP_ENABLED=true - PENPOT_SMTP_ENABLED=true
- PENPOT_SMTP_DEFAULT_FROM=no-reply@example.com - PENPOT_SMTP_DEFAULT_FROM=no-reply@example.com

View file

@ -14,7 +14,7 @@
:dev :dev
{:extra-deps {:extra-deps
{thheller/shadow-cljs {:mvn/version "2.16.12"}}} {thheller/shadow-cljs {:mvn/version "2.17.3"}}}
:shadow-cljs :shadow-cljs
{:main-opts ["-m" "shadow.cljs.devtools.cli"]} {:main-opts ["-m" "shadow.cljs.devtools.cli"]}

View file

@ -22,7 +22,7 @@
"xregexp": "^5.0.2" "xregexp": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"shadow-cljs": "^2.16.12", "shadow-cljs": "^2.17.3",
"source-map-support": "^0.5.21" "source-map-support": "^0.5.21"
} }
} }

View file

@ -1010,10 +1010,10 @@ shadow-cljs-jar@1.3.2:
resolved "https://registry.yarnpkg.com/shadow-cljs-jar/-/shadow-cljs-jar-1.3.2.tgz#97273afe1747b6a2311917c1c88d9e243c81957b" resolved "https://registry.yarnpkg.com/shadow-cljs-jar/-/shadow-cljs-jar-1.3.2.tgz#97273afe1747b6a2311917c1c88d9e243c81957b"
integrity sha512-XmeffAZHv8z7451kzeq9oKh8fh278Ak+UIOGGrapyqrFBB773xN8vMQ3O7J7TYLnb9BUwcqadKkmgaq7q6fhZg== integrity sha512-XmeffAZHv8z7451kzeq9oKh8fh278Ak+UIOGGrapyqrFBB773xN8vMQ3O7J7TYLnb9BUwcqadKkmgaq7q6fhZg==
shadow-cljs@^2.16.12: shadow-cljs@^2.17.3:
version "2.16.12" version "2.17.3"
resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.16.12.tgz#8757b3079dadfff15ca09192f81eb69b5d25266d" resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.17.3.tgz#748e31f67cffdc401691c0cd1bf733a1da53ab5d"
integrity sha512-6JqOhN5X3n0IkxA/gSUcZ1lImwcW1LmpgzlaBDOC/u/pIysdNm0tiOxpOTEnExl9nKZBS/EYS7bXIIInywPJUA== integrity sha512-GxyczUuCtACq/uEOvdTc61wT/aDOZFy8G/AGc322uTX/oUiZaeTJrwpClXe+0+e7VKG9E9RCqP/cjuG3cAG0fw==
dependencies: dependencies:
node-libs-browser "^2.2.1" node-libs-browser "^2.2.1"
readline-sync "^1.4.7" readline-sync "^1.4.7"

View file

@ -1 +1,4 @@
{} {
"watchForFileChanges": false,
"video": false
}

Binary file not shown.

Binary file not shown.

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