Merge branch 'develop' into staging

This commit is contained in:
Andrey Antukh 2022-07-12 11:55:13 +02:00
commit 40415bb0d8
313 changed files with 9532 additions and 5230 deletions

View File

@ -1,5 +1,39 @@
# CHANGELOG # CHANGELOG
## :rocket: Next
### :boom: Breaking changes & Deprecations
- The `PENPOT_LOGIN_WITH_LDAP` environment variable is finally removed (after
many version with deprecation). It is replaced with the
`enable-login-with-ldap` flag.
- The `PENPOT_LDAP_ATTRS_PHOTO` finally removed, it was unused for many
versions.
- If you are using social login (google, github, gitlab or generic OIDC) you
will need to ensure to add the following flags respectivelly to let them
enabled: `enable-login-with-google`, `enable-login-with-github`,
`enable-login-with-gitlab` and `enable-login-with-oidc`. If not, they will
remain disabled after application start independently if you set the client-id
and client-sectet options.
- The `PENPOT_REGISTRATION_ENABLED` is finally removed in favour of
`<enable|disable>-registration` flag.
- The OIDC providers are now initialized synchronously, and if you are using the
discovery mechanism of the generic OIDC integration, the start time of the
application will depend on how fast the OIDC provider responds to the
discovery http request.
### :sparkles: New features
- Allow for nested and rotated boards inside other boards and groups [Taiga #2874](https://tree.taiga.io/project/penpot/us/2874?milestone=319982)
- View mode improvements to enable access and use in different conditions [Taiga #3023](https://tree.taiga.io/project/penpot/us/3023)
- Improved share link options. Now you can allow non-team members to comment and/or inspect [Taiga #3056] (https://tree.taiga.io/project/penpot/us/3056)
- Signin/Signup from shared link [Taiga #3472](https://tree.taiga.io/project/penpot/us/3472)
- Support for import/export binary format [Taiga #2991](https://tree.taiga.io/project/penpot/us/2991)
### :bug: Bugs fixed
### :arrow_up: Deps updates
### :heart: Community contributions by (Thank you!)
## 1.14.2-beta ## 1.14.2-beta
### :bug: Bugs fixed ### :bug: Bugs fixed

View File

@ -1,13 +1,13 @@
{:deps {:deps
{penpot/common {:local/root "../common"} {penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.10.3"} org.clojure/clojure {:mvn/version "1.11.1"}
org.clojure/core.async {:mvn/version "1.5.648"} org.clojure/core.async {:mvn/version "1.5.648"}
;; Logging ;; Logging
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.2-2"} com.github.luben/zstd-jni {:mvn/version "1.5.2-3"}
org.clojure/data.fressian {:mvn/version "1.0.0"} org.clojure/data.fressian {:mvn/version "1.0.0"}
io.prometheus/simpleclient {:mvn/version "0.15.0"} io.prometheus/simpleclient {:mvn/version "0.15.0"}
@ -17,24 +17,27 @@
org.eclipse.jetty/jetty-servlet]} org.eclipse.jetty/jetty-servlet]}
io.prometheus/simpleclient_httpserver {:mvn/version "0.15.0"} io.prometheus/simpleclient_httpserver {:mvn/version "0.15.0"}
io.lettuce/lettuce-core {:mvn/version "6.1.6.RELEASE"} io.lettuce/lettuce-core {:mvn/version "6.1.8.RELEASE"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"} java-http-clj/java-http-clj {:mvn/version "0.4.3"}
funcool/yetti {:git/tag "v9.1" :git/sha "63f35d9" funcool/yetti {:git/tag "v9.2" :git/sha "4ddcc03"
:git/url "https://github.com/funcool/yetti.git" :git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]} :exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc {:mvn/version "1.2.772"} com.github.seancorfield/next.jdbc {:mvn/version "1.2.780"}
metosin/reitit-core {:mvn/version "0.5.16"} metosin/reitit-core {:mvn/version "0.5.18"}
org.postgresql/postgresql {:mvn/version "42.3.3"} org.postgresql/postgresql {:mvn/version "42.4.0"}
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 "3.0.64"}
buddy/buddy-hashers {:mvn/version "1.8.158"} buddy/buddy-hashers {:mvn/version "1.8.158"}
buddy/buddy-sign {:mvn/version "3.4.333"} buddy/buddy-sign {:mvn/version "3.4.333"}
org.jsoup/jsoup {:mvn/version "1.14.3"} org.jsoup/jsoup {:mvn/version "1.15.1"}
org.im4java/im4java {:mvn/version "1.4.0"} org.im4java/im4java {:git/tag "1.4.0-penpot-2" :git/sha "e2b3e16"
:git/url "https://github.com/penpot/im4java"}
org.lz4/lz4-java {:mvn/version "1.8.0"} org.lz4/lz4-java {:mvn/version "1.8.0"}
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
@ -43,11 +46,11 @@
io.sentry/sentry {:mvn/version "5.6.1"} io.sentry/sentry {:mvn/version "5.6.1"}
dawran6/emoji {:mvn/version "0.1.5"} dawran6/emoji {:mvn/version "0.1.5"}
markdown-clj/markdown-clj {:mvn/version "1.11.0"} markdown-clj/markdown-clj {:mvn/version "1.11.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.136"}} software.amazon.awssdk/s3 {:mvn/version "2.17.209"}}
:paths ["src" "resources" "target/classes"] :paths ["src" "resources" "target/classes"]
:aliases :aliases
@ -64,7 +67,7 @@
:build :build
{:extra-deps {:extra-deps
{io.github.clojure/tools.build {:git/tag "v0.7.7" :git/sha "1474ad6"}} {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
:ns-default build} :ns-default build}
:test :test

View File

@ -85,6 +85,16 @@ header {
.rpc-row-info > .name { .rpc-row-info > .name {
width: 280px; width: 280px;
/* font-weight: bold; */ /* font-weight: bold; */
border-right: 1px dotted #777;
padding-right: 10px;
}
.rpc-row-info > .module {
width: 120px;
font-weight: bold;
border-right: 1px dotted #777;
text-align: right;
padding-right: 10px;
} }
.rpc-row-info > .tags > .tag > span:first-child { .rpc-row-info > .tags > .tag > span:first-child {

View File

@ -20,12 +20,42 @@
</header> </header>
<section class="rpc-doc-content"> <section class="rpc-doc-content">
<h2>RPC COMMAND METHODS:</h2>
<ul class="rpc-items">
{% for item in command-methods %}
<li class="rpc-item">
<div class="rpc-row-info">
{# <div class="type">{{item.type}}</div> #}
<div class="module">{{item.module}}:</div>
<div class="name">{{item.name}}</div>
<div class="tags">
<span class="tag">
<span>Auth:</span>
<span>{% if item.auth %}YES{% else %}NO{% endif %}</span>
</span>
</div>
</div>
<div class="rpc-row-detail hidden">
{% if item.docs %}
<h3>DOCSTRING:</h3>
<p>{{item.docs}}</p>
{% endif %}
<h3>SPEC EXPLAIN:</h3>
<pre>{{item.spec}}</pre>
</div>
</li>
{% endfor %}
</ul>
<h2>RPC QUERY METHODS:</h2> <h2>RPC QUERY METHODS:</h2>
<ul class="rpc-items"> <ul class="rpc-items">
{% for item in query-methods %} {% for item in query-methods %}
<li class="rpc-item"> <li class="rpc-item">
<div class="rpc-row-info"> <div class="rpc-row-info">
{# <div class="type">{{item.type}}</div> #} {# <div class="type">{{item.type}}</div> #}
<div class="module">{{item.module}}:</div>
<div class="name">{{item.name}}</div> <div class="name">{{item.name}}</div>
<div class="tags"> <div class="tags">
<span class="tag"> <span class="tag">
@ -53,6 +83,7 @@
<li class="rpc-item"> <li class="rpc-item">
<div class="rpc-row-info"> <div class="rpc-row-info">
{# <div class="type">{{item.type}}</div> #} {# <div class="type">{{item.type}}</div> #}
<div class="module">{{item.module}}:</div>
<div class="name">{{item.name}}</div> <div class="name">{{item.name}}</div>
<div class="tags"> <div class="tags">
<span class="tag"> <span class="tag">

View File

@ -20,11 +20,17 @@
</Appenders> </Appenders>
<Loggers> <Loggers>
<Logger name="com.zaxxer.hikari" level="error"/>
<Logger name="io.lettuce" level="error" /> <Logger name="io.lettuce" level="error" />
<Logger name="org.eclipse.jetty" level="error" /> <Logger name="com.zaxxer.hikari" level="error"/>
<Logger name="org.postgresql" level="error" /> <Logger name="org.postgresql" level="error" />
<Logger name="app.rpc.commands.binfile" level="debug" />
<Logger name="app.storage.tmp" level="trace" />
<Logger name="app.worker" level="info" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app.cli" level="debug" additivity="false"> <Logger name="app.cli" level="debug" additivity="false">
<AppenderRef ref="console"/> <AppenderRef ref="console"/>
</Logger> </Logger>
@ -38,11 +44,6 @@
<AppenderRef ref="zmq" level="debug" /> <AppenderRef ref="zmq" level="debug" />
</Logger> </Logger>
<Logger name="penpot" level="debug" additivity="false">
<AppenderRef ref="main" level="debug" />
<AppenderRef ref="zmq" level="debug" />
</Logger>
<Logger name="user" level="trace" additivity="false"> <Logger name="user" level="trace" additivity="false">
<AppenderRef ref="main" level="trace" /> <AppenderRef ref="main" level="trace" />
</Logger> </Logger>

View File

@ -7,17 +7,21 @@
</Appenders> </Appenders>
<Loggers> <Loggers>
<Logger name="io.lettuce" level="error" />
<Logger name="com.zaxxer.hikari" level="error" /> <Logger name="com.zaxxer.hikari" level="error" />
<Logger name="org.eclipse.jetty" level="error" /> <Logger name="org.postgresql" level="error" />
<Logger name="app.rpc.commands.binfile" level="info" />
<Logger name="app.storage.tmp" level="info" />
<Logger name="app.worker" level="info" />
<Logger name="app.msgbus" level="info" />
<Logger name="app.http.websocket" level="info" />
<Logger name="app.util.websocket" level="info" />
<Logger name="app" level="debug" additivity="false"> <Logger name="app" level="debug" additivity="false">
<AppenderRef ref="console" /> <AppenderRef ref="console" />
</Logger> </Logger>
<Logger name="penpot" level="fatal" additivity="false">
<AppenderRef ref="console" />
</Logger>
<Root level="info"> <Root level="info">
<AppenderRef ref="console" /> <AppenderRef ref="console" />
</Root> </Root>

View File

@ -10,23 +10,118 @@ Debug Main Page
<div>[<a href="/dbg/error">ERRORS</a>]</div> <div>[<a href="/dbg/error">ERRORS</a>]</div>
</nav> </nav>
<main class="index"> <main class="index">
<section> <section class="widget">
<h2>Download file data:</h2> <fieldset>
<desc>Given an FILE-ID, downloads the file data as file. The file data is encoded using transit.</desc> <legend>Download file data:</legend>
<form method="get" action="/dbg/file/data"> <desc>Given an FILE-ID, downloads the file data as file. The file data is encoded using transit.</desc>
<input type="text" style="width:300px" name="file-id" placeholder="file-id" /> <form method="get" action="/dbg/file/data">
<input type="hidden" name="download" value="1" /> <div class="row">
<input type="submit" value="Download" /> <input type="text" style="width:300px" name="file-id" placeholder="file-id" />
</form> </div>
<div class="row">
<input type="submit" name="download" value="Download" />
<input type="submit" name="clone" value="Clone" />
</div>
</form>
</fieldset>
<fieldset>
<legend>Upload File Data:</legend>
<desc>Create a new file on your draft projects using the file downloaded from the previous section.</desc>
<form method="post" enctype="multipart/form-data" action="/dbg/file/data">
<div class="row">
<input type="file" name="file" value="" />
</div>
<div class="row">
<label>Import with same id?</label>
<input type="checkbox" name="reuseid" />
</div>
<input type="submit" value="Upload" />
</form>
</fieldset>
</section> </section>
<section> <section class="widget">
<h2>Upload File Data:</h2> <fieldset>
<desc>Create a new file on your draft projects using the file downloaded from the previous section.</desc> <legend>Export binfile:</legend>
<form method="post" enctype="multipart/form-data" action="/dbg/file/data"> <desc>Given an FILE-ID, downloads the file and optionally all
<input type="file" name="file" value="" /> the related libraries in a single custom formatted binary
<input type="submit" value="Upload" /> file.</desc>
</form>
<form method="get" action="/dbg/file/export">
<div class="row set-of-inputs">
<input type="text" style="width:300px" name="file-ids" placeholder="file-id" />
<input type="text" style="width:300px" name="file-ids" placeholder="file-id" />
<input type="text" style="width:300px" name="file-ids" placeholder="file-id" />
<input type="text" style="width:300px" name="file-ids" placeholder="file-id" />
</div>
<div class="row">
<label>Include libraries?</label>
<input type="checkbox" name="includelibs" />
</div>
<div class="row">
<label>Embed assets?</label>
<input type="checkbox" name="embedassets" checked/>
</div>
<div class="row">
<input type="submit" name="download" value="Download" />
<input type="submit" name="clone" value="Clone" />
</div>
</form>
</fieldset>
<fieldset>
<legend>Import binfile:</legend>
<desc>Import penpot file in binary
format. If <strong>overwrite</strong> is checked, all files will
be overwriten using the same ids found in the file instead of
generating a new ones.</desc>
<form method="post" enctype="multipart/form-data" action="/dbg/file/import">
<div class="row">
<input type="file" name="file" value="" />
</div>
<div class="row">
<label>Overwrite?</label>
<input type="checkbox" name="overwrite" />
<br />
<small>
Instead of creating a new file with all relations remaped,
reuses all ids and updates/overwrites the objects that are
already exists on the database.
<strong>Warning, this operation should be used with caution.</strong>
</small>
</div>
<div class="row">
<label>Migrate?</label>
<input type="checkbox" name="migrate" />
<br />
<small>
Applies the file migrations on the importation process.
</small>
</div>
<div class="row">
<label>Ignore index errors?</label>
<input type="checkbox" name="ignore-index-errors" checked/>
<br />
<small>
Do not break on index lookup erros (remap operation).
Useful when importing a broken file that has broken
relations or missing pieces.
</small>
</div>
<div class="row">
<input type="submit" name="upload" value="Upload" />
</div>
</form>
</fieldset>
</section> </section>
</main> </main>
{% endblock %} {% endblock %}

View File

@ -14,7 +14,6 @@ pre {
} }
desc { desc {
display: flex;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 10px; font-size: 10px;
color: #666; color: #666;
@ -28,6 +27,15 @@ main {
margin: 20px; margin: 20px;
} }
small {
font-size: 9px;
color: #888;
}
small > strong {
font-size: 9px;
}
nav { nav {
position: fixed; position: fixed;
width: 100vw; width: 100vw;
@ -95,17 +103,25 @@ nav > div:not(:last-child) {
.index { .index {
margin-top: 40px; margin-top: 40px;
display: flex;
} }
.index > section { .index > section {
padding: 10px; padding: 10px;
background-color: #e3e3e3; background-color: #e3e3e3;
max-width: 400px;
margin: 5px;
height: fit-content;
} }
.index > section:not(:last-child) { .index fieldset:not(:first-child) {
margin-bottom: 10px; margin-top: 15px;
} }
/* .index > section:not(:last-child) { */
/* margin-bottom: 10px; */
/* } */
.index > section > h2 { .index > section > h2 {
margin-top: 0px; margin-top: 0px;
@ -148,3 +164,16 @@ nav > div:not(:last-child) {
color: inherit; color: inherit;
} }
form .row {
padding: 5px 0;
}
.set-of-inputs {
flex-direction: column;
display: flex;
}
.set-of-inputs input:not(:last-child) {
margin-bottom: 3px;
}

View File

@ -24,9 +24,8 @@ mc mb penpot-s3/penpot -p
export AWS_ACCESS_KEY_ID=penpot-devenv export AWS_ACCESS_KEY_ID=penpot-devenv
export AWS_SECRET_ACCESS_KEY=penpot-devenv export AWS_SECRET_ACCESS_KEY=penpot-devenv
export PENPOT_ASSETS_STORAGE_BACKEND=assets-fs export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3
export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000 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 PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
export OPTIONS=" export OPTIONS="
@ -40,6 +39,9 @@ export OPTIONS="
-J-XX:+UnlockDiagnosticVMOptions \ -J-XX:+UnlockDiagnosticVMOptions \
-J-XX:+DebugNonSafepoints"; -J-XX:+DebugNonSafepoints";
# Uncomment for use the ImageMagick v7.x
# export OPTIONS="-J-Dim4java.useV7=true $OPTIONS";
export OPTIONS_EVAL="nil" export OPTIONS_EVAL="nil"
# export OPTIONS_EVAL="(set! *warn-on-reflection* true)" # export OPTIONS_EVAL="(set! *warn-on-reflection* true)"

View File

@ -0,0 +1,137 @@
;; 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.auth.ldap
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[clj-ldap.client :as ldap]
[clojure.spec.alpha :as s]
[clojure.string]
[integrant.core :as ig]))
(defn- prepare-params
[cfg]
{:ssl? (:ssl cfg)
:startTLS? (:tls cfg)
:bind-dn (:bind-dn cfg)
:password (:bind-password cfg)
:host {:address (:host cfg)
:port (:port cfg)}})
(defn- connect
"Connects to the LDAP provider and returns a connection. An
exception is raised if no connection is possible."
^java.lang.AutoCloseable
[cfg]
(try
(-> cfg prepare-params ldap/connect)
(catch Throwable cause
(ex/raise :type :restriction
:code :unable-to-connect-to-ldap
:hint "unable to connect to ldap server"
:cause cause))))
(defn- replace-several [s & {:as replacements}]
(reduce-kv clojure.string/replace s replacements))
(defn- search-user
[{:keys [conn attrs base-dn] :as cfg} email]
(let [query (replace-several (:query cfg) ":username" email)
params {:filter query
:sizelimit 1
:attributes attrs}]
(first (ldap/search conn base-dn params))))
(defn- retrieve-user
[{:keys [conn] :as cfg} {:keys [email password]}]
(when-let [{:keys [dn] :as user} (search-user cfg email)]
(when (ldap/bind? conn dn password)
{:fullname (get user (-> cfg :attrs-fullname keyword))
:email email
:backend "ldap"})))
(s/def ::fullname ::us/not-empty-string)
(s/def ::email ::us/email)
(s/def ::backend ::us/not-empty-string)
(s/def ::info-data
(s/keys :req-un [::fullname ::email ::backend]))
(defn authenticate
[cfg params]
(with-open [conn (connect cfg)]
(when-let [user (-> (assoc cfg :conn conn)
(retrieve-user params))]
(when-not (s/valid? ::info-data user)
(let [explain (s/explain-str ::info-data user)]
(l/warn ::l/raw (str "invalid response from ldap, looks like ldap is not configured correctly\n" explain))
(ex/raise :type :restriction
:code :wrong-ldap-response
:explain explain)))
user)))
(defn- try-connectivity
[cfg]
;; If we have ldap parameters, try to establish connection
(when (and (:bind-dn cfg)
(:bind-password cfg)
(:host cfg)
(:port cfg))
(try
(with-open [_ (connect cfg)]
(l/info :hint "provider initialized"
:provider "ldap"
:host (:host cfg)
:port (:port cfg)
:tls? (:tls cfg)
:ssl? (:ssl cfg)
:bind-dn (:bind-dn cfg)
:base-dn (:base-dn cfg)
:query (:query cfg))
cfg)
(catch Throwable cause
(l/error :hint "unable to connect to LDAP server (LDAP auth provider disabled)"
:host (:host cfg) :port (:port cfg) :cause cause)
nil))))
(defn- prepare-attributes
[cfg]
(assoc cfg :attrs [(:attrs-username cfg)
(:attrs-email cfg)
(:attrs-fullname cfg)]))
(defmethod ig/init-key ::provider
[_ cfg]
(when (:enabled? cfg)
(some-> cfg try-connectivity prepare-attributes)))
(s/def ::enabled? ::us/boolean)
(s/def ::host ::cf/ldap-host)
(s/def ::port ::cf/ldap-port)
(s/def ::ssl ::cf/ldap-ssl)
(s/def ::tls ::cf/ldap-starttls)
(s/def ::query ::cf/ldap-user-query)
(s/def ::base-dn ::cf/ldap-base-dn)
(s/def ::bind-dn ::cf/ldap-bind-dn)
(s/def ::bind-password ::cf/ldap-bind-password)
(s/def ::attrs-email ::cf/ldap-attrs-email)
(s/def ::attrs-fullname ::cf/ldap-attrs-fullname)
(s/def ::attrs-username ::cf/ldap-attrs-username)
(defmethod ig/pre-init-spec ::provider
[_]
(s/keys :opt-un [::host ::port
::ssl ::tls
::enabled?
::bind-dn
::bind-password
::query
::attrs-email
::attrs-username
::attrs-fullname]))

View File

@ -4,19 +4,23 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.http.oauth (ns app.auth.oidc
"OIDC client implementation."
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[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.common.uri :as u] [app.common.uri :as u]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http.middleware :as hmw]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.util.json :as json] [app.util.json :as json]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk]
[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]
@ -25,6 +29,218 @@
[promesa.exec :as px] [promesa.exec :as px]
[yetti.response :as yrs])) [yetti.response :as yrs]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- obfuscate-string
[s]
(if (< (count s) 10)
(apply str (take (count s) (repeat "*")))
(str (subs s 0 5)
(apply str (take (- (count s) 5) (repeat "*"))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; OIDC PROVIDER (GENERIC)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- discover-oidc-config
[{:keys [http-client]} {:keys [base-uri] :as opts}]
(let [discovery-uri (u/join base-uri ".well-known/openid-configuration")
response (ex/try (http-client {:method :get :uri (str discovery-uri)} {:sync? true}))]
(cond
(ex/exception? response)
(do
(l/warn :hint "unable to discover oidc configuration"
:discover-uri (str discovery-uri)
:cause response)
nil)
(= 200 (:status response))
(let [data (json/read (:body response))]
{:token-uri (get data :token_endpoint)
:auth-uri (get data :authorization_endpoint)
:user-uri (get data :userinfo_endpoint)})
:else
(do
(l/warn :hint "unable to discover OIDC configuration"
:uri (str discovery-uri)
:response-status-code (:status response))
nil))))
(defn- prepare-oidc-opts
[cfg]
(let [opts {:base-uri (:base-uri cfg)
:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:token-uri (:token-uri cfg)
:auth-uri (:auth-uri cfg)
:user-uri (:user-uri cfg)
:scopes (:scopes cfg #{"openid" "profile" "email"})
:roles-attr (:roles-attr cfg)
:roles (:roles cfg)
:name "oidc"}
opts (d/without-nils opts)]
(when (and (string? (:base-uri opts))
(string? (:client-id opts))
(string? (:client-secret opts)))
(if (and (string? (:token-uri opts))
(string? (:user-uri opts))
(string? (:auth-uri opts)))
opts
(some-> (discover-oidc-config cfg opts)
(merge opts {:discover? true}))))))
(defmethod ig/prep-key ::generic-provider
[_ cfg]
(d/without-nils cfg))
(defmethod ig/init-key ::generic-provider
[_ cfg]
(when (:enabled? cfg)
(if-let [opts (prepare-oidc-opts cfg)]
(do
(l/info :hint "provider initialized"
:provider :oidc
:method (if (:discover? opts) "discover" "manual")
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts))
:scopes (str/join "," (:scopes opts))
:auth-uri (:auth-uri opts)
:user-uri (:user-uri opts)
:token-uri (:token-uri opts)
:roles-attr (:roles-attr opts)
:roles (:roles opts))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :oidc)
nil))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GOOGLE AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/prep-key ::google-provider
[_ cfg]
(d/without-nils cfg))
(defmethod ig/init-key ::google-provider
[_ cfg]
(let [opts {:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:scopes #{"openid" "email" "profile"}
:auth-uri "https://accounts.google.com/o/oauth2/v2/auth"
:token-uri "https://oauth2.googleapis.com/token"
:user-uri "https://openidconnect.googleapis.com/v1/userinfo"
:name "google"}]
(when (:enabled? cfg)
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :hint "provider initialized"
:provider :google
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :google)
nil)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GITHUB AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- retrieve-github-email
[{:keys [http-client]} tdata info]
(or (some-> info :email p/resolved)
(-> (http-client {:uri "https://api.github.com/user/emails"
:headers {"Authorization" (dm/str (:type tdata) " " (:token tdata))}
:timeout 6000
:method :get})
(p/then (fn [{:keys [status body] :as response}]
(when-not (s/int-in-range? 200 300 status)
(ex/raise :type :internal
:code :unable-to-retrieve-github-emails
:hint "unable to retrieve github emails"
:http-status status
:http-body body))
(->> response :body json/read (filter :primary) first :email))))))
(defmethod ig/prep-key ::github-provider
[_ cfg]
(d/without-nils cfg))
(defmethod ig/init-key ::github-provider
[_ cfg]
(let [opts {:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:scopes #{"read:user" "user:email"}
:auth-uri "https://github.com/login/oauth/authorize"
:token-uri "https://github.com/login/oauth/access_token"
:user-uri "https://api.github.com/user"
:name "github"
;; Additional hooks for provider specific way of
;; retrieve emails.
:get-email-fn (partial retrieve-github-email cfg)}]
(when (:enabled? cfg)
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :hint "provider initialized"
:provider :github
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :github)
nil)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GITLAB AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/prep-key ::gitlab-provider
[_ cfg]
(d/without-nils cfg))
(defmethod ig/init-key ::gitlab-provider
[_ cfg]
(let [base (:base-uri cfg "https://gitlab.com")
opts {:base-uri base
:client-id (:client-id cfg)
:client-secret (:client-secret cfg)
:scopes #{"openid" "profile" "email"}
:auth-uri (str base "/oauth/authorize")
:token-uri (str base "/oauth/token")
:user-uri (str base "/oauth/userinfo")
:name "gitlab"}]
(when (:enabled? cfg)
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :hint "provider initialized"
:provider :gitlab
:base-uri base
:client-id (:client-id opts)
:client-secret (obfuscate-string (:client-secret opts)))
opts)
(do
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider :gitlab)
nil)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HANDLERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- build-redirect-uri (defn- build-redirect-uri
[{:keys [provider] :as cfg}] [{:keys [provider] :as cfg}]
(let [public (u/uri (:public-uri cfg))] (let [public (u/uri (:public-uri cfg))]
@ -81,47 +297,35 @@
:timeout 6000 :timeout 6000
:method :get})) :method :get}))
(retrieve-emails [] (validate-response [response]
(if (some? (:emails-uri provider)) (when-not (s/int-in-range? 200 300 (:status response))
(http-client {:uri (:emails-uri provider)
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
:timeout 6000
:method :get})
(p/resolved {:status 200})))
(validate-response [[retrieve-res emails-res]]
(when-not (s/int-in-range? 200 300 (:status retrieve-res))
(ex/raise :type :internal (ex/raise :type :internal
:code :unable-to-retrieve-user-info :code :unable-to-retrieve-user-info
:hint "unable to retrieve user info" :hint "unable to retrieve user info"
:http-status (:status retrieve-res) :http-status (:status response)
:http-body (:body retrieve-res))) :http-body (:body response)))
(when-not (s/int-in-range? 200 300 (:status emails-res)) response)
(ex/raise :type :internal
:code :unable-to-retrieve-user-info
:hint "unable to retrieve user info"
:http-status (:status emails-res)
:http-body (:body emails-res)))
[retrieve-res emails-res])
(get-email [info] (get-email [info]
(let [attr-kw (cf/get :oidc-email-attr :email)] ;; Allow providers hook into this for custom email
(get info attr-kw))) ;; retrieval method.
(if-let [get-email-fn (:get-email-fn provider)]
(get-email-fn tdata info)
(let [attr-kw (cf/get :oidc-email-attr :email)]
(get info attr-kw))))
(get-name [info] (get-name [info]
(let [attr-kw (cf/get :oidc-name-attr :name)] (let [attr-kw (cf/get :oidc-name-attr :name)]
(get info attr-kw))) (get info attr-kw)))
(process-response [[retrieve-res emails-res]] (process-response [response]
(let [info (json/read (:body retrieve-res)) (p/let [info (-> response :body json/read)
email (if (some? (:extract-email-callback provider)) email (get-email info)]
((:extract-email-callback provider) emails-res)
(get-email info))]
{:backend (:name provider) {:backend (:name provider)
:email email :email email
:fullname (or (get-name info) email) :fullname (or (get-name info) email)
:props (->> (dissoc info :name :email) :props (->> (dissoc info :name :email)
(qualify-props provider))})) (qualify-props provider))}))
(validate-info [info] (validate-info [info]
(when-not (s/valid? ::info info) (when-not (s/valid? ::info info)
@ -133,10 +337,10 @@
:info info)) :info info))
info)] info)]
(-> (p/all [(retrieve) (retrieve-emails)]) (-> (retrieve)
(p/then' validate-response) (p/then validate-response)
(p/then' process-response) (p/then process-response)
(p/then' validate-info)))) (p/then validate-info))))
(s/def ::backend ::us/not-empty-string) (s/def ::backend ::us/not-empty-string)
(s/def ::email ::us/not-empty-string) (s/def ::email ::us/not-empty-string)
@ -195,8 +399,6 @@
(p/then' validate-oidc) (p/then' validate-oidc)
(p/then' (partial post-process state)))))) (p/then' (partial post-process state))))))
;; --- HTTP HANDLERS
(defn- retrieve-profile (defn- retrieve-profile
[{:keys [pool executor] :as cfg} info] [{:keys [pool executor] :as cfg} info]
(px/with-dispatch executor (px/with-dispatch executor
@ -256,21 +458,18 @@
(redirect-response uri)))) (redirect-response uri))))
(defn- auth-handler (defn- auth-handler
[{:keys [tokens] :as cfg} {:keys [params] :as request} respond raise] [{:keys [tokens] :as cfg} {:keys [params] :as request}]
(try (let [props (audit/extract-utm-params params)
(let [props (audit/extract-utm-params params) state (tokens :generate
state (tokens :generate {:iss :oauth
{:iss :oauth :invitation-token (:invitation-token params)
:invitation-token (:invitation-token params) :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)] (yrs/response 200 {:redirect-uri uri})))
(respond (yrs/response 200 {:redirect-uri uri})))
(catch Throwable cause
(raise cause))))
(defn- callback-handler (defn- callback-handler
[cfg request respond _] [cfg request]
(letfn [(process-request [] (letfn [(process-request []
(p/let [info (retrieve-info cfg request) (p/let [info (retrieve-info cfg request)
profile (retrieve-profile cfg info)] profile (retrieve-profile cfg info)]
@ -278,182 +477,62 @@
(handle-error [cause] (handle-error [cause]
(l/error :hint "error on oauth process" :cause cause) (l/error :hint "error on oauth process" :cause cause)
(respond (generate-error-redirect cfg cause)))] (generate-error-redirect cfg cause))]
(-> (process-request) (-> (process-request)
(p/then respond)
(p/catch handle-error)))) (p/catch handle-error))))
;; --- INIT (def provider-lookup
{:compile
(declare initialize) (fn [& _]
(fn [handler]
(fn [{:keys [providers] :as cfg} request]
(let [provider (some-> request :path-params :provider keyword)]
(if-let [provider (get providers provider)]
(handler (assoc cfg :provider provider) request)
(ex/raise :type :restriction
:code :provider-not-configured
:provider provider
:hint "provider not configured"))))))})
(s/def ::public-uri ::us/not-empty-string) (s/def ::public-uri ::us/not-empty-string)
(s/def ::http-client fn?)
(s/def ::session map?) (s/def ::session map?)
(s/def ::tokens fn?) (s/def ::tokens fn?)
(s/def ::rpc map?) (s/def ::providers map?)
(defmethod ig/pre-init-spec ::handler [_] (defmethod ig/pre-init-spec ::routes
(s/keys :req-un [::public-uri ::session ::tokens ::rpc ::db/pool])) [_]
(s/keys :req-un [::public-uri
::session
::tokens
::http-client
::providers
::db/pool
::wrk/executor]))
(defn wrap-handler (defmethod ig/init-key ::routes
[cfg handler] [_ {:keys [executor session] :as cfg}]
(fn [request respond raise] (let [cfg (update cfg :provider d/without-nils)]
(let [provider (get-in request [:path-params :provider]) ["" {:middleware [[(:middleware session)]
provider (get-in @cfg [:providers provider])] [hmw/with-promise-async executor]
(if provider [hmw/with-config cfg]
(handler (assoc @cfg :provider provider) [provider-lookup]
request ]}
respond ;; We maintain the both URI prefixes for backward compatibility.
raise)
(raise
(ex/error
:type :not-found
:provider provider
:hint "provider not configured"))))))
(defmethod ig/init-key ::handler ["/auth/oauth"
[_ cfg] ["/:provider"
(let [cfg (initialize cfg)] {:handler auth-handler
{:handler (wrap-handler cfg auth-handler) :allowed-methods #{:post}}]
:callback-handler (wrap-handler cfg callback-handler)})) ["/:provider/callback"
{:handler callback-handler
:allowed-methods #{:get}}]]
(defn- discover-oidc-config ["/auth/oidc"
[{:keys [http-client]} {:keys [base-uri] :as opts}] ["/:provider"
{:handler auth-handler
(let [discovery-uri (u/join base-uri ".well-known/openid-configuration") :allowed-methods #{:post}}]
response (ex/try (http-client {:method :get :uri (str discovery-uri)} {:sync? true}))] ["/:provider/callback"
(cond {:handler callback-handler
(ex/exception? response) :allowed-methods #{:get}}]]]))
(do
(l/warn :hint "unable to discover oidc configuration"
:discover-uri (str discovery-uri)
:cause response)
nil)
(= 200 (:status response))
(let [data (json/read (:body response))]
{:token-uri (get data :token_endpoint)
:auth-uri (get data :authorization_endpoint)
:user-uri (get data :userinfo_endpoint)})
:else
(do
(l/warn :hint "unable to discover OIDC configuration"
:uri (str discovery-uri)
:response-status-code (:status response))
nil))))
(defn- obfuscate-string
[s]
(if (< (count s) 10)
(apply str (take (count s) (repeat "*")))
(str (subs s 0 5)
(apply str (take (- (count s) 5) (repeat "*"))))))
(defn- initialize-oidc-provider
[cfg]
(let [opts {:base-uri (cf/get :oidc-base-uri)
:client-id (cf/get :oidc-client-id)
:client-secret (cf/get :oidc-client-secret)
:token-uri (cf/get :oidc-token-uri)
:auth-uri (cf/get :oidc-auth-uri)
:user-uri (cf/get :oidc-user-uri)
:scopes (cf/get :oidc-scopes #{"openid" "profile" "email"})
:roles-attr (cf/get :oidc-roles-attr)
:roles (cf/get :oidc-roles)
:name "oidc"}]
(if (and (string? (:base-uri opts))
(string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/debug :hint "initialize oidc provider" :name "generic-oidc"
:opts (update opts :client-secret obfuscate-string))
(if (and (string? (:token-uri opts))
(string? (:user-uri opts))
(string? (:auth-uri opts)))
(do
(l/debug :hint "initialized with user provided configuration")
(assoc-in cfg [:providers "oidc"] opts))
(do
(l/debug :hint "trying to discover oidc provider configuration using BASE_URI")
(if-let [opts' (discover-oidc-config cfg opts)]
(do
(l/debug :hint "discovered opts" :additional-opts opts')
(assoc-in cfg [:providers "oidc"] (merge opts opts')))
cfg))))
cfg)))
(defn- initialize-google-provider
[cfg]
(let [opts {:client-id (cf/get :google-client-id)
:client-secret (cf/get :google-client-secret)
:scopes #{"openid" "email" "profile"}
:auth-uri "https://accounts.google.com/o/oauth2/v2/auth"
:token-uri "https://oauth2.googleapis.com/token"
:user-uri "https://openidconnect.googleapis.com/v1/userinfo"
:name "google"}]
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :action "initialize" :provider "google"
:opts (pr-str (update opts :client-secret obfuscate-string)))
(assoc-in cfg [:providers "google"] opts))
cfg)))
(defn extract-github-email
[response]
(let [emails (json/read (:body response))
primary-email (->> emails
(filter #(:primary %))
first)]
(:email primary-email)))
(defn- initialize-github-provider
[cfg]
(let [opts {:client-id (cf/get :github-client-id)
:client-secret (cf/get :github-client-secret)
:scopes #{"read:user" "user:email"}
:auth-uri "https://github.com/login/oauth/authorize"
:token-uri "https://github.com/login/oauth/access_token"
:emails-uri "https://api.github.com/user/emails"
:extract-email-callback extract-github-email
:user-uri "https://api.github.com/user"
:name "github"}]
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :action "initialize" :provider "github"
:opts (pr-str (update opts :client-secret obfuscate-string)))
(assoc-in cfg [:providers "github"] opts))
cfg)))
(defn- initialize-gitlab-provider
[cfg]
(let [base (cf/get :gitlab-base-uri "https://gitlab.com")
opts {:base-uri base
:client-id (cf/get :gitlab-client-id)
:client-secret (cf/get :gitlab-client-secret)
:scopes #{"openid" "profile" "email"}
:auth-uri (str base "/oauth/authorize")
:token-uri (str base "/oauth/token")
:user-uri (str base "/oauth/userinfo")
:name "gitlab"}]
(if (and (string? (:client-id opts))
(string? (:client-secret opts)))
(do
(l/info :action "initialize" :provider "gitlab"
:opts (pr-str (update opts :client-secret obfuscate-string)))
(assoc-in cfg [:providers "gitlab"] opts))
cfg)))
(defn- initialize
[cfg]
(let [cfg (agent cfg :error-mode :continue)]
(send-off cfg initialize-google-provider)
(send-off cfg initialize-gitlab-provider)
(send-off cfg initialize-github-provider)
(send-off cfg initialize-oidc-provider)
cfg))

View File

@ -10,6 +10,7 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.db :as db] [app.db :as db]
[app.main :as main] [app.main :as main]
[app.rpc.commands.auth :as cmd.auth]
[app.rpc.mutations.profile :as profile] [app.rpc.mutations.profile :as profile]
[app.rpc.queries.profile :refer [retrieve-profile-data-by-email]] [app.rpc.queries.profile :refer [retrieve-profile-data-by-email]]
[clojure.string :as str] [clojure.string :as str]
@ -54,13 +55,13 @@
:type :password}))] :type :password}))]
(try (try
(db/with-atomic [conn (:app.db/pool system)] (db/with-atomic [conn (:app.db/pool system)]
(->> (profile/create-profile conn (->> (cmd.auth/create-profile conn
{:fullname fullname {:fullname fullname
:email email :email email
:password password :password password
:is-active true :is-active true
:is-demo false}) :is-demo false})
(profile/create-profile-relations conn))) (cmd.auth/create-profile-relations conn)))
(when (pos? (:verbosity options)) (when (pos? (:verbosity options))
(println "User created successfully.")) (println "User created successfully."))

View File

@ -11,6 +11,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.flags :as flags] [app.common.flags :as flags]
[app.common.logging :as l]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.version :as v] [app.common.version :as v]
[app.util.time :as dt] [app.util.time :as dt]
@ -79,18 +80,14 @@
:ldap-attrs-username "uid" :ldap-attrs-username "uid"
:ldap-attrs-email "mail" :ldap-attrs-email "mail"
:ldap-attrs-fullname "cn" :ldap-attrs-fullname "cn"
:ldap-attrs-photo "jpegPhoto"
;; a server prop key where initial project is stored. ;; a server prop key where initial project is stored.
:initial-project-skey "initial-project"}) :initial-project-skey "initial-project"})
(s/def ::flags ::us/set-of-keywords) (s/def ::flags ::us/vec-of-keywords)
;; DEPRECATED PROPERTIES ;; DEPRECATED PROPERTIES
(s/def ::registration-enabled ::us/boolean)
(s/def ::smtp-enabled ::us/boolean)
(s/def ::telemetry-enabled ::us/boolean) (s/def ::telemetry-enabled ::us/boolean)
(s/def ::asserts-enabled ::us/boolean)
;; END DEPRECATED ;; END DEPRECATED
(s/def ::audit-log-archive-uri ::us/string) (s/def ::audit-log-archive-uri ::us/string)
@ -149,7 +146,6 @@
(s/def ::initial-project-skey ::us/string) (s/def ::initial-project-skey ::us/string)
(s/def ::ldap-attrs-email ::us/string) (s/def ::ldap-attrs-email ::us/string)
(s/def ::ldap-attrs-fullname ::us/string) (s/def ::ldap-attrs-fullname ::us/string)
(s/def ::ldap-attrs-photo ::us/string)
(s/def ::ldap-attrs-username ::us/string) (s/def ::ldap-attrs-username ::us/string)
(s/def ::ldap-base-dn ::us/string) (s/def ::ldap-base-dn ::us/string)
(s/def ::ldap-bind-dn ::us/string) (s/def ::ldap-bind-dn ::us/string)
@ -256,7 +252,6 @@
::initial-project-skey ::initial-project-skey
::ldap-attrs-email ::ldap-attrs-email
::ldap-attrs-fullname ::ldap-attrs-fullname
::ldap-attrs-photo
::ldap-attrs-username ::ldap-attrs-username
::ldap-base-dn ::ldap-base-dn
::ldap-bind-dn ::ldap-bind-dn
@ -276,7 +271,6 @@
::public-uri ::public-uri
::redis-uri ::redis-uri
::registration-domain-whitelist ::registration-domain-whitelist
::registration-enabled
::rlimit-font ::rlimit-font
::rlimit-file-update ::rlimit-file-update
::rlimit-image ::rlimit-image
@ -287,7 +281,6 @@
::sentry-trace-sample-rate ::sentry-trace-sample-rate
::smtp-default-from ::smtp-default-from
::smtp-default-reply-to ::smtp-default-reply-to
::smtp-enabled
::smtp-host ::smtp-host
::smtp-password ::smtp-password
::smtp-port ::smtp-port
@ -354,8 +347,12 @@
(str/trim)) (str/trim))
"%version%"))) "%version%")))
(def ^:dynamic config (read-config)) (defonce ^:dynamic config (read-config))
(def ^:dynamic flags (parse-flags config))
(defonce ^:dynamic flags
(let [flags (parse-flags config)]
(l/info :hint "flags initialized" :flags (str/join "," (map name flags)))
flags))
(def deletion-delay (def deletion-delay
(dt/duration {:days 7})) (dt/duration {:days 7}))

View File

@ -55,54 +55,66 @@
(s/def ::migrations map?) (s/def ::migrations map?)
(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 ::uri ::us/not-empty-string) (s/def ::uri ::us/not-empty-string)
(s/def ::username ::us/string) (s/def ::username ::us/string)
(s/def ::validation-timeout ::us/integer) (s/def ::validation-timeout ::us/integer)
(s/def ::read-only? ::us/boolean)
(defmethod ig/pre-init-spec ::pool [_] (s/def ::pool-options
(s/keys :req-un [::uri ::name (s/keys :opt-un [::uri ::name
::min-size ::min-size
::max-size ::max-size
::connection-timeout ::connection-timeout
::validation-timeout] ::validation-timeout
:opt-un [::migrations ::migrations
::username ::username
::password ::password
::mtx/metrics ::mtx/metrics
::read-only])) ::read-only?]))
(def defaults
{: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})
(defmethod ig/prep-key ::pool (defmethod ig/prep-key ::pool
[_ cfg] [_ cfg]
(merge {:name :main (merge defaults (d/without-nils cfg)))
:min-size 0
:max-size 30 ;; Don't validate here, just validate that a map is received.
:connection-timeout 10000 (defmethod ig/pre-init-spec ::pool [_] ::pool-options)
: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 name read-only] :as cfg}] [_ {:keys [migrations read-only? uri] :as cfg}]
(l/info :hint "initialize connection pool" (if uri
:name (d/name name) (let [pool (create-pool cfg)]
:uri (:uri cfg) (l/info :hint "initialize connection pool"
:read-only read-only :name (d/name (:name cfg))
:with-credentials (and (contains? cfg :username) :uri uri
(contains? cfg :password)) :read-only read-only?
:min-size (:min-size cfg) :with-credentials (and (contains? cfg :username)
:max-size (:max-size cfg)) (contains? cfg :password))
:min-size (:min-size cfg)
:max-size (:max-size cfg))
(when-not read-only?
(some->> (seq migrations) (apply-migrations! pool)))
pool)
(let [pool (create-pool cfg)] (do
(when-not read-only (l/warn :hint "unable to initialize pool, missing url"
(some->> (seq migrations) (apply-migrations! pool))) :name (d/name (:name cfg))
pool)) :read-only read-only?)
nil)))
(defmethod ig/halt-key! ::pool (defmethod ig/halt-key! ::pool
[_ pool] [_ pool]
(.close ^HikariDataSource pool)) (when pool
(.close ^HikariDataSource pool)))
(defn- apply-migrations! (defn- apply-migrations!
[pool migrations] [pool migrations]
@ -126,7 +138,7 @@
(.setJdbcUrl (str "jdbc:" uri)) (.setJdbcUrl (str "jdbc:" uri))
(.setPoolName (d/name (:name cfg))) (.setPoolName (d/name (:name cfg)))
(.setAutoCommit true) (.setAutoCommit true)
(.setReadOnly (:read-only cfg)) (.setReadOnly (:read-only? cfg))
(.setConnectionTimeout (:connection-timeout cfg)) (.setConnectionTimeout (:connection-timeout cfg))
(.setValidationTimeout (:validation-timeout cfg)) (.setValidationTimeout (:validation-timeout cfg))
(.setIdleTimeout (:idle-timeout cfg)) (.setIdleTimeout (:idle-timeout cfg))
@ -213,7 +225,7 @@
[& args] [& args]
`(jdbc/with-transaction ~@args)) `(jdbc/with-transaction ~@args))
(defn ^Connection open (defn open
[pool] [pool]
(jdbc/get-connection pool)) (jdbc/get-connection pool))

View File

@ -9,7 +9,6 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.transit :as t] [app.common.transit :as t]
[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]
[app.metrics :as mtx] [app.metrics :as mtx]
@ -67,8 +66,10 @@
:xnio/worker-threads (:worker-threads cfg) :xnio/worker-threads (:worker-threads cfg)
:xnio/dispatch (:executor cfg) :xnio/dispatch (:executor cfg)
:ring/async true} :ring/async true}
handler (if (some? router) handler (if (some? router)
(wrap-router router) (wrap-router router)
handler) handler)
server (yt/server handler (d/without-nils options))] server (yt/server handler (d/without-nils options))]
(assoc cfg :server (yt/start! server)))) (assoc cfg :server (yt/start! server))))
@ -113,23 +114,35 @@
;; HTTP ROUTER ;; HTTP ROUTER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::rpc map?)
(s/def ::oauth map?) (s/def ::oauth map?)
(s/def ::storage map?) (s/def ::storage map?)
(s/def ::assets map?) (s/def ::assets map?)
(s/def ::feedback fn?) (s/def ::feedback fn?)
(s/def ::ws fn?) (s/def ::ws fn?)
(s/def ::audit-handler fn?) (s/def ::audit-handler fn?)
(s/def ::debug map?)
(s/def ::awsns-handler fn?) (s/def ::awsns-handler fn?)
(s/def ::session map?) (s/def ::session map?)
(s/def ::rpc-routes (s/nilable vector?))
(s/def ::debug-routes (s/nilable vector?))
(s/def ::oidc-routes (s/nilable vector?))
(s/def ::doc-routes (s/nilable vector?))
(defmethod ig/pre-init-spec ::router [_] (defmethod ig/pre-init-spec ::router [_]
(s/keys :req-un [::rpc ::mtx/metrics ::ws ::oauth ::storage ::assets (s/keys :req-un [::mtx/metrics
::session ::feedback ::awsns-handler ::debug ::audit-handler])) ::ws
::storage
::assets
::session
::feedback
::awsns-handler
::debug-routes
::oidc-routes
::audit-handler
::rpc-routes
::doc-routes]))
(defmethod ig/init-key ::router (defmethod ig/init-key ::router
[_ {:keys [ws session rpc oauth metrics assets feedback debug] :as cfg}] [_ {:keys [ws session metrics assets feedback] :as cfg}]
(rr/router (rr/router
[["" {:middleware [[middleware/server-timing] [["" {:middleware [[middleware/server-timing]
[middleware/format-response] [middleware/format-response]
@ -137,20 +150,14 @@
[middleware/parse-request] [middleware/parse-request]
[middleware/errors errors/handle] [middleware/errors errors/handle]
[middleware/restrict-methods]]} [middleware/restrict-methods]]}
["/metrics" {:handler (:handler metrics)}] ["/metrics" {:handler (:handler metrics)}]
["/assets" {:middleware [(:middleware session)]} ["/assets" {:middleware [(:middleware session)]}
["/by-id/:id" {:handler (:objects-handler assets)}] ["/by-id/:id" {:handler (:objects-handler assets)}]
["/by-file-media-id/:id" {:handler (:file-objects-handler assets)}] ["/by-file-media-id/:id" {:handler (:file-objects-handler assets)}]
["/by-file-media-id/:id/thumbnail" {:handler (:file-thumbnails-handler assets)}]] ["/by-file-media-id/:id/thumbnail" {:handler (:file-thumbnails-handler assets)}]]
["/dbg" {:middleware [(:middleware session)]} (:debug-routes cfg)
["" {:handler (:index debug)}]
["/changelog" {:handler (:changelog debug)}]
["/error-by-id/:id" {:handler (:retrieve-error debug)}]
["/error/:id" {:handler (:retrieve-error debug)}]
["/error" {:handler (:retrieve-error-list debug)}]
["/file/data" {:handler (:file-data debug)}]
["/file/changes" {:handler (:retrieve-file-changes debug)}]]
["/webhooks" ["/webhooks"
["/sns" {:handler (:awsns-handler cfg) ["/sns" {:handler (:awsns-handler cfg)
@ -161,22 +168,12 @@
:allowed-methods #{:get}}] :allowed-methods #{:get}}]
["/api" {:middleware [[middleware/cors] ["/api" {:middleware [[middleware/cors]
(:middleware session)]} [(:middleware session)]]}
["/health" {:handler (:health-check debug)}]
["/_doc" {:handler (doc/handler rpc)
:allowed-methods #{:get}}]
["/feedback" {:handler feedback
:allowed-methods #{:post}}]
["/auth/oauth/:provider" {:handler (:handler oauth)
:allowed-methods #{:post}}]
["/auth/oauth/:provider/callback" {:handler (:callback-handler oauth)
:allowed-methods #{:get}}]
["/audit/events" {:handler (:audit-handler cfg) ["/audit/events" {:handler (:audit-handler cfg)
:allowed-methods #{:post}}] :allowed-methods #{:post}}]
["/feedback" {:handler feedback
:allowed-methods #{:post}}]
(:doc-routes cfg)
(:oidc-routes cfg)
(:rpc-routes cfg)]]]))
["/rpc"
["/query/:type" {:handler (:query-handler rpc)}]
["/mutation/:type" {:handler (:mutation-handler rpc)
:allowed-methods #{:post}}]]]]]))

View File

@ -5,36 +5,39 @@
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.http.debug (ns app.http.debug
(:refer-clojure :exclude [error-handler])
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.pprint :as pp]
[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.config :as cf]
[app.db :as db] [app.db :as db]
[app.db.sql :as sql] [app.http.middleware :as mw]
[app.rpc.mutations.files :as m.files] [app.rpc.commands.binfile :as binf]
[app.rpc.mutations.files :refer [create-file]]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.util.blob :as blob] [app.util.blob :as blob]
[app.util.bytes :as bs]
[app.util.template :as tmpl] [app.util.template :as tmpl]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk] [app.worker :as wrk]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[datoteka.core :as fs]
[emoji.core :as emj] [emoji.core :as emj]
[fipp.edn :as fpp]
[integrant.core :as ig] [integrant.core :as ig]
[markdown.core :as md] [markdown.core :as md]
[markdown.transformers :as mdt] [markdown.transformers :as mdt]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.request :as yrq] [yetti.request :as yrq]
[yetti.response :as yrs])) [yetti.response :as yrs]))
;; (selmer.parser/cache-off!) ;; (selmer.parser/cache-off!)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn authorized? (defn authorized?
[pool {:keys [profile-id]}] [pool {:keys [profile-id]}]
(or (= "devenv" (cf/get :host)) (or (= "devenv" (cf/get :host))
@ -42,7 +45,22 @@
admins (or (cf/get :admins) #{})] admins (or (cf/get :admins) #{})]
(contains? admins (:email profile))))) (contains? admins (:email profile)))))
(defn index (defn prepare-response
[body]
(let [headers {"content-type" "application/transit+json"}]
(yrs/response :status 200 :body body :headers headers)))
(defn prepare-download-response
[body filename]
(let [headers {"content-disposition" (str "attachment; filename=" filename)
"content-type" "application/octet-stream"}]
(yrs/response :status 200 :body body :headers headers)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INDEX
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn index-handler
[{:keys [pool]} request] [{:keys [pool]} request]
(when-not (authorized? pool request) (when-not (authorized? pool request)
(ex/raise :type :authentication (ex/raise :type :authentication
@ -52,6 +70,9 @@
:body (-> (io/resource "templates/debug.tmpl") :body (-> (io/resource "templates/debug.tmpl")
(tmpl/render {})))) (tmpl/render {}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FILE CHANGES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def sql:retrieve-range-of-changes (def sql:retrieve-range-of-changes
"select revn, changes from file_change where file_id=? and revn >= ? and revn <= ? order by revn") "select revn, changes from file_change where file_id=? and revn >= ? and revn <= ? order by revn")
@ -59,28 +80,16 @@
(def sql:retrieve-single-change (def sql:retrieve-single-change
"select revn, changes, data from file_change where file_id=? and revn = ?") "select revn, changes, data from file_change where file_id=? and revn = ?")
(defn prepare-response
[{:keys [params] :as request} body filename]
(when-not body
(ex/raise :type :not-found
:code :enpty-data
:hint "empty response"))
(cond-> (yrs/response :status 200
:body body
:headers {"content-type" "application/transit+json"})
(contains? params :download)
(update :headers assoc "content-disposition" (str "attachment; filename=" filename))))
(defn- retrieve-file-data (defn- retrieve-file-data
[{:keys [pool]} {:keys [params] :as request}] [{:keys [pool]} {:keys [params profile-id] :as request}]
(when-not (authorized? pool request) (when-not (authorized? pool request)
(ex/raise :type :authentication (ex/raise :type :authentication
:code :only-admins-allowed)) :code :only-admins-allowed))
(let [file-id (some-> (get-in request [:params :file-id]) uuid/uuid) (let [file-id (some-> params :file-id parse-uuid)
revn (some-> (get-in request [:params :revn]) d/parse-integer) revn (some-> params :revn parse-long)
filename (str file-id)] filename (str file-id)]
(when-not file-id (when-not file-id
(ex/raise :type :validation (ex/raise :type :validation
:code :missing-arguments)) :code :missing-arguments))
@ -88,35 +97,63 @@
(let [data (if (integer? revn) (let [data (if (integer? revn)
(some-> (db/exec-one! pool [sql:retrieve-single-change file-id revn]) :data) (some-> (db/exec-one! pool [sql:retrieve-single-change file-id revn]) :data)
(some-> (db/get-by-id pool :file file-id) :data))] (some-> (db/get-by-id pool :file file-id) :data))]
(if (contains? params :download)
(-> (prepare-response request data filename) (when-not data
(update :headers assoc "content-type" "application/octet-stream")) (ex/raise :type :not-found
(prepare-response request (some-> data blob/decode) filename))))) :code :enpty-data
:hint "empty response"))
(cond
(contains? params :download)
(prepare-download-response data filename)
(contains? params :clone)
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
data (some-> data blob/decode)]
(create-file pool {:id (uuid/next)
:name (str "Cloned file: " filename)
:project-id project-id
:profile-id profile-id
:data data})
(yrs/response 201 "OK CREATED"))
:else
(prepare-response (some-> data blob/decode))))))
(defn- is-file-exists?
[pool id]
(let [sql "select exists (select 1 from file where id=?) as exists;"]
(-> (db/exec-one! pool [sql id]) :exists)))
(defn- upload-file-data (defn- upload-file-data
[{:keys [pool]} {:keys [profile-id params] :as request}] [{:keys [pool]} {:keys [profile-id params] :as request}]
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id) (let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
data (some-> params :file :path fs/slurp-bytes blob/decode)] data (some-> params :file :path bs/read-as-bytes blob/decode)]
(if (and data project-id) (if (and data project-id)
(let [fname (str "imported-file-" (dt/now)) (let [fname (str "Imported file *: " (dt/now))
file-id (try overwrite? (contains? params :overwrite?)
(uuid/uuid (-> params :file :filename)) file-id (or (and overwrite? (ex/ignoring (-> params :file :filename parse-uuid)))
(catch Exception _ (uuid/next))) (uuid/next))]
file (db/exec-one! pool (sql/select :file {:id file-id}))]
(if file (if (and overwrite? file-id
(db/update! pool :file (is-file-exists? pool file-id))
{:data (blob/encode data)} (do
{:id file-id}) (db/update! pool :file
(m.files/create-file pool {:id file-id {:data (blob/encode data)}
:name fname {:id file-id})
:project-id project-id (yrs/response 200 "OK UPDATED"))
:profile-id profile-id
:data data})) (do
(yrs/response 200 "OK")) (create-file pool {:id file-id
:name fname
:project-id project-id
:profile-id profile-id
:data data})
(yrs/response 201 "OK CREATED"))))
(yrs/response 500 "ERROR")))) (yrs/response 500 "ERROR"))))
(defn file-data (defn file-data-handler
[cfg request] [cfg request]
(case (yrq/method request) (case (yrq/method request)
:get (retrieve-file-data cfg request) :get (retrieve-file-data cfg request)
@ -124,43 +161,47 @@
(ex/raise :type :http (ex/raise :type :http
:code :method-not-found))) :code :method-not-found)))
(defn retrieve-file-changes (defn file-changes-handler
[{:keys [pool]} request] [{:keys [pool]} {:keys [params] :as request}]
(when-not (authorized? pool request) (when-not (authorized? pool request)
(ex/raise :type :authentication (ex/raise :type :authentication
:code :only-admins-allowed)) :code :only-admins-allowed))
(let [file-id (some-> (get-in request [:params :id]) uuid/uuid) (letfn [(retrieve-changes [file-id revn]
revn (or (get-in request [:params :revn]) "latest") (if (str/includes? revn ":")
filename (str file-id)] (let [[start end] (->> (str/split revn #":")
(map str/trim)
(map parse-long))]
(some->> (db/exec! pool [sql:retrieve-range-of-changes file-id start end])
(map :changes)
(map blob/decode)
(mapcat identity)
(vec)))
(when (or (not file-id) (not revn)) (if-let [revn (parse-long revn)]
(ex/raise :type :validation (let [item (db/exec-one! pool [sql:retrieve-single-change file-id revn])]
:code :invalid-arguments (some-> item :changes blob/decode vec))
:hint "missing arguments")) (ex/raise :type :validation :code :invalid-arguments))))]
(cond (let [file-id (some-> params :id parse-uuid)
(d/num-string? revn) revn (or (some-> params :revn parse-long) "latest")
(let [item (db/exec-one! pool [sql:retrieve-single-change file-id (d/parse-integer revn)])] filename (str file-id)]
(prepare-response request (some-> item :changes blob/decode vec) filename))
(str/includes? revn ":") (when (or (not file-id) (not revn))
(let [[start end] (->> (str/split revn #":") (ex/raise :type :validation
(map str/trim) :code :invalid-arguments
(map d/parse-integer)) :hint "missing arguments"))
items (db/exec! pool [sql:retrieve-range-of-changes file-id start end])]
(prepare-response request
(some->> items
(map :changes)
(map blob/decode)
(mapcat identity)
(vec))
filename))
:else
(ex/raise :type :validation :code :invalid-arguments))))
(let [data (retrieve-changes file-id revn)]
(if (contains? params :download)
(prepare-download-response data filename)
(prepare-response data))))))
(defn retrieve-error ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ERROR BROWSER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn error-handler
[{:keys [pool]} request] [{:keys [pool]} request]
(letfn [(parse-id [request] (letfn [(parse-id [request]
(let [id (get-in request [:path-params :id]) (let [id (get-in request [:path-params :id])
@ -176,9 +217,8 @@
(let [context (dissoc report (let [context (dissoc report
:trace :cause :params :data :spec-problems :trace :cause :params :data :spec-problems
:spec-explain :spec-value :error :explain :hint) :spec-explain :spec-value :error :explain :hint)
params {:context (with-out-str params {:context (pp/pprint-str context :width 200)
(fpp/pprint context {:width 200})) :hint (:hint report)
:hint (:hint report)
:spec-explain (:spec-explain report) :spec-explain (:spec-explain report)
:spec-problems (:spec-problems report) :spec-problems (:spec-problems report)
:spec-value (:spec-value report) :spec-value (:spec-value report)
@ -206,7 +246,7 @@
(def sql:error-reports (def sql:error-reports
"select id, created_at from server_error_report order by created_at desc limit 100") "select id, created_at from server_error_report order by created_at desc limit 100")
(defn retrieve-error-list (defn error-list-handler
[{:keys [pool]} request] [{:keys [pool]} request]
(when-not (authorized? pool request) (when-not (authorized? pool request)
(ex/raise :type :authentication (ex/raise :type :authentication
@ -219,14 +259,94 @@
:headers {"content-type" "text/html; charset=utf-8" :headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}))) "x-robots-tag" "noindex"})))
(defn health-check ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; EXPORT/IMPORT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn export-handler
[{:keys [pool] :as cfg} {:keys [params profile-id] :as request}]
(let [file-ids (->> (:file-ids params)
(remove empty?)
(mapv parse-uuid))
libs? (contains? params :includelibs)
clone? (contains? params :clone)
embed? (contains? params :embedassets)]
(when-not (seq file-ids)
(ex/raise :type :validation
:code :missing-arguments))
(let [path (-> cfg
(assoc ::binf/file-ids file-ids)
(assoc ::binf/embed-assets? embed?)
(assoc ::binf/include-libraries? libs?)
(binf/export!))]
(if clone?
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)]
(binf/import!
(assoc cfg
::binf/input path
::binf/overwrite? false
::binf/ignore-index-errors? true
::binf/profile-id profile-id
::binf/project-id project-id))
(yrs/response
:status 200
:headers {"content-type" "text/plain"}
:body "OK CLONED"))
(yrs/response
:status 200
:headers {"content-type" "application/octet-stream"
"content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}
:body (io/input-stream path))))))
(defn import-handler
[{:keys [pool] :as cfg} {:keys [params profile-id] :as request}]
(when-not (contains? params :file)
(ex/raise :type :validation
:code :missing-upload-file
:hint "missing upload file"))
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
overwrite? (contains? params :overwrite)
migrate? (contains? params :migrate)
ignore-index-errors? (contains? params :ignore-index-errors)]
(when-not project-id
(ex/raise :type :validation
:code :missing-project
:hint "project not found"))
(binf/import!
(assoc cfg
::binf/input (-> params :file :path)
::binf/overwrite? overwrite?
::binf/migrate? migrate?
::binf/ignore-index-errors? ignore-index-errors?
::binf/profile-id profile-id
::binf/project-id project-id))
(yrs/response
:status 200
:headers {"content-type" "text/plain"}
:body "OK")))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; OTHER SMALL VIEWS/HANDLERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn health-handler
"Mainly a task that performs a health check." "Mainly a task that performs a health check."
[{:keys [pool]} _] [{:keys [pool]} _]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(db/exec-one! conn ["select count(*) as count from server_prop;"]) (db/exec-one! conn ["select count(*) as count from server_prop;"])
(yrs/response 200 "OK"))) (yrs/response 200 "OK")))
(defn changelog (defn changelog-handler
[_ _] [_ _]
(letfn [(transform-emoji [text state] (letfn [(transform-emoji [text state]
[(emj/emojify text) state]) [(emj/emojify text) state])
@ -238,22 +358,39 @@
:body (-> clog slurp md->html)) :body (-> clog slurp md->html))
(yrs/response :status 404 :body "NOT FOUND")))) (yrs/response :status 404 :body "NOT FOUND"))))
(defn- wrap-async ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[{:keys [executor] :as cfg} f] ;; INIT
(fn [request respond raise] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(-> (px/submit! executor #(f cfg request))
(p/then respond)
(p/catch raise))))
(defmethod ig/pre-init-spec ::handlers [_] (def with-authorization
(s/keys :req-un [::db/pool ::wrk/executor])) {:compile
(fn [& _]
(fn [handler pool]
(fn [request respond raise]
(if (authorized? pool request)
(handler request respond raise)
(raise (ex/error :type :authentication
:code :only-admins-allowed))))))})
(defmethod ig/init-key ::handlers
[_ cfg] (s/def ::session map?)
{:index (wrap-async cfg index)
:health-check (wrap-async cfg health-check) (defmethod ig/pre-init-spec ::routes [_]
:retrieve-file-changes (wrap-async cfg retrieve-file-changes) (s/keys :req-un [::db/pool ::wrk/executor ::session]))
:retrieve-error (wrap-async cfg retrieve-error)
:retrieve-error-list (wrap-async cfg retrieve-error-list) (defmethod ig/init-key ::routes
:file-data (wrap-async cfg file-data) [_ {:keys [session pool executor] :as cfg}]
:changelog (wrap-async cfg changelog)}) ["/dbg" {:middleware [[(:middleware session)]
[with-authorization pool]
[mw/with-promise-async executor]
[mw/with-config cfg]]}
["" {:handler index-handler}]
["/health" {:handler health-handler}]
["/changelog" {:handler changelog-handler}]
;; ["/error-by-id/:id" {:handler error-handler}]
["/error/:id" {:handler error-handler}]
["/error" {:handler error-list-handler}]
["/file/export" {:handler export-handler}]
["/file/import" {:handler import-handler}]
["/file/data" {:handler file-data-handler}]
["/file/changes" {:handler file-changes-handler}]])

View File

@ -9,14 +9,17 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.config :as cf] [app.config :as cf]
[app.rpc :as-alias rpc]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.template :as tmpl] [app.util.template :as tmpl]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[pretty-spec.core :as ps] [pretty-spec.core :as ps]
[yetti.response :as yrs])) [yetti.response :as yrs]))
(defn get-spec-str (defn- get-spec-str
[k] [k]
(with-out-str (with-out-str
(ps/pprint (s/form k) (ps/pprint (s/form k)
@ -24,31 +27,47 @@
"clojure.core.specs.alpha" "score" "clojure.core.specs.alpha" "score"
"clojure.core" nil}}))) "clojure.core" nil}})))
(defn prepare-context (defn- prepare-context
[rpc] [methods]
(letfn [(gen-doc [type [name f]] (letfn [(gen-doc [type [name f]]
(let [mdata (meta f)] (let [mdata (meta f)]
;; (prn name mdata)
{:type (d/name type) {:type (d/name type)
:name (d/name name) :name (d/name name)
:module (-> (:ns mdata) (str/split ".") last)
:auth (:auth mdata true) :auth (:auth mdata true)
:docs (::sv/docs mdata) :docs (::sv/docs mdata)
:spec (get-spec-str (::sv/spec mdata))}))] :spec (get-spec-str (::sv/spec mdata))}))]
{:query-methods
(into []
(map (partial gen-doc :query))
(->> rpc :methods :query (sort-by first)))
:mutation-methods
(into []
(map (partial gen-doc :mutation))
(->> rpc :methods :mutation (sort-by first)))}))
(defn handler {:command-methods
[rpc] (->> (:commands methods)
(let [context (prepare-context rpc)] (map (partial gen-doc :command))
(if (contains? cf/flags :backend-api-doc) (sort-by (juxt :module :name)))
:query-methods
(->> (:queries methods)
(map (partial gen-doc :query))
(sort-by (juxt :module :name)))
:mutation-methods
(->> (:mutations methods)
(map (partial gen-doc :query))
(sort-by (juxt :module :name)))}))
(defn- handler
[methods]
(if (contains? cf/flags :backend-api-doc)
(let [context (prepare-context methods)]
(fn [_ respond _] (fn [_ respond _]
(respond (yrs/response 200 (-> (io/resource "api-doc.tmpl") (respond (yrs/response 200 (-> (io/resource "api-doc.tmpl")
(tmpl/render context))))) (tmpl/render context))))))
(fn [_ respond _] (fn [_ respond _]
(respond (yrs/response 404)))))) (respond (yrs/response 404)))))
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req-un [::rpc/methods]))
(defmethod ig/init-key ::routes
[_ {:keys [methods] :as cfg}]
["/_doc" {:handler (handler methods)
:allowed-methods #{:get}}])

View File

@ -71,7 +71,7 @@
[error request] [error request]
(let [edata (ex-data error) (let [edata (ex-data error)
explain (us/pretty-explain edata)] explain (us/pretty-explain edata)]
(l/error ::l/raw (ex-message error) (l/error ::l/raw (str (ex-message error) "\n" explain)
::l/context (get-context request) ::l/context (get-context request)
:cause error) :cause error)
(yrs/response :status 500 (yrs/response :status 500
@ -143,13 +143,11 @@
(defn handle (defn handle
[cause request] [cause request]
(cond (cond
(or (instance? java.util.concurrent.CompletionException cause) (or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause)) (instance? java.util.concurrent.ExecutionException cause))
(handle-exception (.getCause ^Throwable cause) request) (handle-exception (.getCause ^Throwable cause) request)
(ex/wrapped? cause) (ex/wrapped? cause)
(let [context (meta cause) (let [context (meta cause)
cause (deref cause)] cause (deref cause)]

View File

@ -12,6 +12,8 @@
[app.config :as cf] [app.config :as cf]
[app.util.json :as json] [app.util.json :as json]
[cuerdas.core :as str] [cuerdas.core :as str]
[promesa.core :as p]
[promesa.exec :as px]
[yetti.adapter :as yt] [yetti.adapter :as yt]
[yetti.middleware :as ymw] [yetti.middleware :as ymw]
[yetti.request :as yrq] [yetti.request :as yrq]
@ -192,3 +194,21 @@
(def restrict-methods (def restrict-methods
{:name ::restrict-methods {:name ::restrict-methods
:compile compile-restrict-methods}) :compile compile-restrict-methods})
(def with-promise-async
{:compile
(fn [& _]
(fn [handler executor]
(fn [request respond raise]
(-> (px/submit! executor #(handler request))
(p/bind p/wrap)
(p/then respond)
(p/catch raise)))))})
(def with-config
{:compile
(fn [& _]
(fn [handler config]
(fn
([request] (handler config request))
([request respond raise] (handler config request respond raise)))))})

View File

@ -162,21 +162,22 @@
(defn- make-middleware (defn- make-middleware
[{:keys [::events-ch store] :as cfg}] [{:keys [::events-ch store] :as cfg}]
{:name :session-middleware {:name :session
:wrap (fn [handler] :compile (fn [& _]
(fn [request respond raise] (fn [handler]
(try (fn [request respond raise]
(-> (retrieve-session store request) (try
(p/then' #(merge request %)) (-> (retrieve-session store request)
(p/finally (fn [request cause] (p/then' #(merge request %))
(if cause (p/finally (fn [request cause]
(raise cause) (if cause
(do (raise cause)
(do
(when-let [session-id (:session-id request)] (when-let [session-id (:session-id request)]
(a/offer! events-ch session-id)) (a/offer! events-ch session-id))
(handler request respond raise)))))) (handler request respond raise))))))
(catch Throwable cause (catch Throwable cause
(raise cause)))))}) (raise cause))))))})
;; --- STATE INIT: SESSION ;; --- STATE INIT: SESSION

View File

@ -9,28 +9,103 @@
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.spec :as us] [app.common.spec :as us]
[app.db :as db] [app.db :as db]
[app.metrics :as mtx] [app.metrics :as mtx]
[app.util.time :as dt]
[app.util.websocket :as ws] [app.util.websocket :as ws]
[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]
[yetti.websocket :as yws])) [yetti.websocket :as yws]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; WEBSOCKET HOOKS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def state (atom {}))
(defn- on-connect
[{:keys [metrics]} wsp]
(let [created-at (dt/now)]
(swap! state assoc (::ws/id @wsp) wsp)
(mtx/run! metrics {:id :websocket-active-connections :inc 1})
(fn []
(swap! state dissoc (::ws/id @wsp))
(mtx/run! metrics {:id :websocket-active-connections :dec 1})
(mtx/run! metrics {:id :websocket-session-timing
:val (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0)}))))
(defn- on-rcv-message
[{:keys [metrics]} _ message]
(mtx/run! metrics {:id :websocket-messages-total :labels ["recv"] :inc 1})
message)
(defn- on-snd-message
[{:keys [metrics]} _ message]
(mtx/run! metrics {:id :websocket-messages-total :labels ["send"] :inc 1})
message)
;; REPL HELPERS
(defn repl-get-connections-for-file
[file-id]
(->> (vals @state)
(filter #(= file-id (-> % deref ::file-subscription :file-id)))
(map deref)
(map ::ws/id)))
(defn repl-get-connections-for-team
[team-id]
(->> (vals @state)
(filter #(= team-id (-> % deref ::team-subscription :team-id)))
(map deref)
(map ::ws/id)))
(defn repl-close-connection
[id]
(when-let [wsp (get @state id)]
(a/>!! (::ws/close-ch @wsp) [8899 "closed from server"])
(a/close! (::ws/close-ch @wsp))))
(defn repl-get-connection-info
[id]
(when-let [wsp (get @state id)]
{:id id
:created-at (dt/instant id)
:profile-id (::profile-id @wsp)
:session-id (::session-id @wsp)
:user-agent (::ws/user-agent @wsp)
:ip-addr (::ws/remote-addr @wsp)
:last-activity-at (::ws/last-activity-at @wsp)
:http-session-id (::ws/http-session-id @wsp)
:subscribed-file (-> wsp deref ::file-subscription :file-id)
:subscribed-team (-> wsp deref ::team-subscription :team-id)}))
(defn repl-print-connection-info
[id]
(some-> id repl-get-connection-info pp/pprint))
(defn repl-print-connection-info-for-file
[file-id]
(some->> (repl-get-connections-for-file file-id)
(map repl-get-connection-info)
(pp/pprint)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; WEBSOCKET HANDLER ;; WEBSOCKET HANDLER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmulti handle-message (defmulti handle-message
(fn [_ message] (fn [_ _ message]
(:type message))) (:type message)))
(defmethod handle-message :connect (defmethod handle-message :connect
[wsp _] [cfg wsp _]
(l/trace :fn "handle-message" :event :connect)
(let [msgbus-fn (:msgbus @wsp) (let [msgbus-fn (:msgbus cfg)
conn-id (::ws/id @wsp)
profile-id (::profile-id @wsp) profile-id (::profile-id @wsp)
session-id (::session-id @wsp) session-id (::session-id @wsp)
output-ch (::ws/output-ch @wsp) output-ch (::ws/output-ch @wsp)
@ -38,94 +113,122 @@
xform (remove #(= (:session-id %) session-id)) xform (remove #(= (:session-id %) session-id))
channel (a/chan (a/dropping-buffer 16) xform)] channel (a/chan (a/dropping-buffer 16) xform)]
(swap! wsp assoc ::profile-subs-channel channel) (l/trace :fn "handle-message" :event :connect :conn-id conn-id)
;; Subscribe to the profile channel and forward all messages to
;; websocket output channel (send them to the client).
(swap! wsp assoc ::profile-subscription channel)
(a/pipe channel output-ch false) (a/pipe channel output-ch false)
(msgbus-fn :cmd :sub :topic profile-id :chan channel))) (msgbus-fn :cmd :sub :topic profile-id :chan channel)))
(defmethod handle-message :disconnect (defmethod handle-message :disconnect
[wsp _] [cfg wsp _]
(l/trace :fn "handle-message" :event :disconnect) (let [msgbus-fn (:msgbus cfg)
(a/go conn-id (::ws/id @wsp)
(let [msgbus-fn (:msgbus @wsp) profile-id (::profile-id @wsp)
profile-id (::profile-id @wsp) session-id (::session-id @wsp)
session-id (::session-id @wsp) profile-ch (::profile-subscription @wsp)
profile-ch (::profile-subs-channel @wsp) fsub (::file-subscription @wsp)
subs (::subscriptions @wsp)] tsub (::team-subscription @wsp)
message {:type :disconnect
:subs-id profile-id
:profile-id profile-id
:session-id session-id}]
(l/trace :fn "handle-message"
:event :disconnect
:conn-id conn-id)
(a/go
;; Close the main profile subscription ;; Close the main profile subscription
(a/close! profile-ch) (a/close! profile-ch)
(a/<! (msgbus-fn :cmd :purge :chans [profile-ch])) (a/<! (msgbus-fn :cmd :purge :chans [profile-ch]))
;; Close all other active subscrption on this websocket context. ;; Close tram subscription if exists
(doseq [{:keys [channel topic]} (map second subs)] (when-let [channel (:channel tsub)]
(a/close! channel) (a/close! channel)
(a/<! (msgbus-fn :cmd :pub :topic topic (a/<! (msgbus-fn :cmd :purge :chans [channel])))
:message {:type :disconnect
:profile-id profile-id (when-let [{:keys [topic channel]} fsub]
:session-id session-id})) (a/close! channel)
(a/<! (msgbus-fn :cmd :purge :chans [channel])))))) (a/<! (msgbus-fn :cmd :purge :chans [channel]))
(a/<! (msgbus-fn :cmd :pub :topic topic :message message))))))
(defmethod handle-message :subscribe-team (defmethod handle-message :subscribe-team
[wsp {:keys [team-id] :as params}] [cfg wsp {:keys [team-id] :as params}]
(l/trace :fn "handle-message" :event :subscribe-team :team-id team-id) (let [msgbus-fn (:msgbus cfg)
conn-id (::ws/id @wsp)
(let [msgbus-fn (:msgbus @wsp)
session-id (::session-id @wsp) session-id (::session-id @wsp)
output-ch (::ws/output-ch @wsp) output-ch (::ws/output-ch @wsp)
subs (get-in @wsp [::subscriptions team-id]) prev-subs (get @wsp ::team-subscription)
xform (comp xform (comp
(remove #(= (:session-id %) session-id)) (remove #(= (:session-id %) session-id))
(map #(assoc % :subs-id team-id)))] (map #(assoc % :subs-id team-id)))
(a/go
(when (not= (:team-id subs) team-id)
;; if it exists we just need to close that
(when-let [channel (:channel subs)]
(a/close! channel)
(a/<! (msgbus-fn :cmd :purge :chans [channel])))
(let [channel (a/chan (a/dropping-buffer 64) xform)]
;; Message forwarding
(a/pipe channel output-ch false)
(let [state {:team-id team-id :channel channel :topic team-id}]
(swap! wsp update ::subscriptions assoc team-id state))
(a/<! (msgbus-fn :cmd :sub :topic team-id :chan channel)))))))
(defmethod handle-message :subscribe-file
[wsp {:keys [subs-id file-id] :as params}]
(l/trace :fn "handle-message" :event :subscribe-file :subs-id subs-id :file-id file-id)
(let [msgbus-fn (:msgbus @wsp)
profile-id (::profile-id @wsp)
session-id (::session-id @wsp)
output-ch (::ws/output-ch @wsp)
xform (comp
(remove #(= (:session-id %) session-id))
(map #(assoc % :subs-id subs-id)))
channel (a/chan (a/dropping-buffer 64) xform)] channel (a/chan (a/dropping-buffer 64) xform)]
;; Message forwarding (l/trace :fn "handle-message"
(a/go-loop [] :event :subscribe-team
(when-let [{:keys [type] :as message} (a/<! channel)] :team-id team-id
(when (or (= :join-file type) :conn-id conn-id)
(= :leave-file type)
(= :disconnect type)) (a/pipe channel output-ch false)
(let [message {:type :presence
:file-id file-id (let [state {:team-id team-id :channel channel :topic team-id}]
:session-id session-id (swap! wsp assoc ::team-subscription state))
:profile-id profile-id}]
(a/<! (msgbus-fn :cmd :pub (a/go
:topic file-id ;; Close previous subscription if exists
:message message)))) (when-let [channel (:channel prev-subs)]
(a/>! output-ch message) (a/close! channel)
(recur))) (a/<! (msgbus-fn :cmd :purge :chans [channel]))))
(a/go
(a/<! (msgbus-fn :cmd :sub :topic team-id :chan channel)))))
(defmethod handle-message :subscribe-file
[cfg wsp {:keys [file-id] :as params}]
(let [msgbus-fn (:msgbus cfg)
conn-id (::ws/id @wsp)
profile-id (::profile-id @wsp)
session-id (::session-id @wsp)
output-ch (::ws/output-ch @wsp)
prev-subs (::file-subscription @wsp)
xform (comp (remove #(= (:session-id %) session-id))
(map #(assoc % :subs-id file-id)))
channel (a/chan (a/dropping-buffer 64) xform)]
(l/trace :fn "handle-message"
:event :subscribe-file
:file-id file-id
:conn-id conn-id)
(let [state {:file-id file-id :channel channel :topic file-id}] (let [state {:file-id file-id :channel channel :topic file-id}]
(swap! wsp update ::subscriptions assoc subs-id state)) (swap! wsp assoc ::file-subscription state))
(a/go
;; Close previous subscription if exists
(when-let [channel (:channel prev-subs)]
(a/close! channel)
(a/<! (msgbus-fn :cmd :purge :chans [channel]))))
;; Message forwarding
(a/go
(loop []
(when-let [{:keys [type] :as message} (a/<! channel)]
(when (or (= :join-file type)
(= :leave-file type)
(= :disconnect type))
(let [message {:type :presence
:file-id file-id
:session-id session-id
:profile-id profile-id}]
(a/<! (msgbus-fn :cmd :pub
:topic file-id
:message message))))
(a/>! output-ch message)
(recur))))
(a/go (a/go
;; Subscribe to file topic ;; Subscribe to file topic
@ -134,6 +237,7 @@
;; Notifify the rest of participants of the new connection. ;; Notifify the rest of participants of the new connection.
(let [message {:type :join-file (let [message {:type :join-file
:file-id file-id :file-id file-id
:subs-id file-id
:session-id session-id :session-id session-id
:profile-id profile-id}] :profile-id profile-id}]
(a/<! (msgbus-fn :cmd :pub (a/<! (msgbus-fn :cmd :pub
@ -141,49 +245,59 @@
:message message)))))) :message message))))))
(defmethod handle-message :unsubscribe-file (defmethod handle-message :unsubscribe-file
[wsp {:keys [subs-id] :as params}] [cfg wsp {:keys [file-id] :as params}]
(l/trace :fn "handle-message" :event :unsubscribe-file :subs-id subs-id) (let [msgbus-fn (:msgbus cfg)
(let [msgbus-fn (:msgbus @wsp) conn-id (::ws/id @wsp)
session-id (::session-id @wsp) session-id (::session-id @wsp)
profile-id (::profile-id @wsp)] profile-id (::profile-id @wsp)
subs (::file-subscription @wsp)
message {:type :leave-file
:file-id file-id
:session-id session-id
:profile-id profile-id}]
(l/trace :fn "handle-message"
:event :unsubscribe-file
:file-id file-id
:conn-id conn-id)
(a/go (a/go
(when-let [{:keys [file-id channel]} (get-in @wsp [::subscriptions subs-id])] (when (= (:file-id subs) file-id)
(let [message {:type :leave-file (let [channel (:channel subs)]
:file-id file-id
:session-id session-id
:profile-id profile-id}]
(a/close! channel) (a/close! channel)
(a/<! (msgbus-fn :cmd :pub :topic file-id :message message)) (a/<! (msgbus-fn :cmd :purge :chans [channel]))
(a/<! (msgbus-fn :cmd :purge :chans [channel]))))))) (a/<! (msgbus-fn :cmd :pub :topic file-id :message message)))))))
(defmethod handle-message :keepalive (defmethod handle-message :keepalive
[_ _] [_ _ _]
(l/trace :fn "handle-message" :event :keepalive) (l/trace :fn "handle-message" :event :keepalive)
(a/go :nothing)) (a/go :nothing))
(defmethod handle-message :pointer-update (defmethod handle-message :pointer-update
[wsp {:keys [subs-id] :as message}] [cfg wsp {:keys [file-id] :as message}]
(a/go (let [msgbus-fn (:msgbus cfg)
;; Only allow receive pointer updates when active subscription profile-id (::profile-id @wsp)
(when-let [{:keys [topic]} (get-in @wsp [::subscriptions subs-id])] session-id (::session-id @wsp)
(let [msgbus-fn (:msgbus @wsp) subs (::file-subscription @wsp)
profile-id (::profile-id @wsp) message (-> message
session-id (::session-id @wsp) (assoc :subs-id file-id)
message (-> message (assoc :profile-id profile-id)
(dissoc :subs-id) (assoc :session-id session-id))]
(assoc :profile-id profile-id) (a/go
(assoc :session-id session-id))] ;; Only allow receive pointer updates when active subscription
(when subs
(a/<! (msgbus-fn :cmd :pub (a/<! (msgbus-fn :cmd :pub
:topic topic :topic file-id
:message message)))))) :message message))))))
(defmethod handle-message :default (defmethod handle-message :default
[_ message] [_ wsp message]
(a/go (let [conn-id (::ws/id @wsp)]
(l/log :level :warn (l/warn :hint "received unexpected message"
:msg "received unexpected message" :message message
:message message))) :conn-id conn-id)
(a/go :none)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HTTP HANDLER ;; HTTP HANDLER
@ -201,12 +315,7 @@
(defmethod ig/init-key ::handler (defmethod ig/init-key ::handler
[_ cfg] [_ cfg]
(fn [{:keys [profile-id params] :as req} respond raise] (fn [{:keys [profile-id params] :as req} respond raise]
(let [{:keys [session-id]} (us/conform ::handler-params params) (let [{:keys [session-id]} (us/conform ::handler-params params)]
cfg (-> cfg
(assoc ::profile-id profile-id)
(assoc ::session-id session-id))]
(l/trace :hint "http request to websocket" :profile-id profile-id :session-id session-id)
(cond (cond
(not profile-id) (not profile-id)
(raise (ex/error :type :authentication (raise (ex/error :type :authentication
@ -218,6 +327,15 @@
:hint "this endpoint only accepts websocket connections")) :hint "this endpoint only accepts websocket connections"))
:else :else
(->> (ws/handler handle-message cfg) (do
(yws/upgrade req) (l/trace :hint "websocket request" :profile-id profile-id :session-id session-id)
(respond))))))
(->> (ws/handler
::ws/on-rcv-message (partial on-rcv-message cfg)
::ws/on-snd-message (partial on-snd-message cfg)
::ws/on-connect (partial on-connect cfg)
::ws/handler (partial handle-message cfg)
::profile-id profile-id
::session-id session-id)
(yws/upgrade req)
(respond)))))))

View File

@ -32,7 +32,7 @@
[request] [request]
(or (some-> (yrq/get-header request "x-forwarded-for") (str/split ",") first) (or (some-> (yrq/get-header request "x-forwarded-for") (str/split ",") first)
(yrq/get-header request "x-real-ip") (yrq/get-header request "x-real-ip")
(yrq/remote-addr request))) (some-> (yrq/remote-addr request) str)))
(defn extract-utm-params (defn extract-utm-params
"Extracts additional data from params and namespace them under "Extracts additional data from params and namespace them under
@ -257,12 +257,16 @@
(ex/raise :type :internal (ex/raise :type :internal
:code :task-not-configured :code :task-not-configured
:hint "archive task not configured, missing uri")) :hint "archive task not configured, missing uri"))
(when enabled (when enabled
(loop [] (loop [total 0]
(let [res (archive-events cfg)] (let [n (archive-events cfg)]
(when (= res :continue) (if n
(aa/thread-sleep 200) (do
(recur)))))))) (aa/thread-sleep 200)
(recur (+ total n)))
(when (pos? total)
(l/trace :hint "events chunk archived" :num total)))))))))
(def sql:retrieve-batch-of-audit-log (def sql:retrieve-batch-of-audit-log
"select * from audit_log "select * from audit_log
@ -332,7 +336,7 @@
(l/debug :action "archive-events" :uri uri :events (count events)) (l/debug :action "archive-events" :uri uri :events (count events))
(when (send events) (when (send events)
(mark-as-archived conn rows) (mark-as-archived conn rows)
:continue)))))) (count events)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GC Task ;; GC Task

View File

@ -6,6 +6,7 @@
(ns app.main (ns app.main
(:require (:require
[app.auth.oidc]
[app.common.logging :as l] [app.common.logging :as l]
[app.config :as cf] [app.config :as cf]
[app.util.time :as dt] [app.util.time :as dt]
@ -71,6 +72,10 @@
:app.tokens/tokens :app.tokens/tokens
{:keys (ig/ref :app.setup/keys)} {:keys (ig/ref :app.setup/keys)}
:app.storage.tmp/cleaner
{:executor (ig/ref [::worker :app.worker/executor])
:scheduler (ig/ref :app.worker/scheduler)}
:app.storage/gc-deleted-task :app.storage/gc-deleted-task
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:storage (ig/ref :app.storage/storage) :storage (ig/ref :app.storage/storage)
@ -86,6 +91,9 @@
:app.http/session :app.http/session
{:store (ig/ref :app.http.session/store)} {:store (ig/ref :app.http.session/store)}
:app.http.doc/routes
{:methods (ig/ref :app.rpc/methods)}
:app.http.session/store :app.http.session/store
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:tokens (ig/ref :app.tokens/tokens) :tokens (ig/ref :app.tokens/tokens)
@ -119,25 +127,88 @@
:max-body-size (cf/get :http-server-max-body-size) :max-body-size (cf/get :http-server-max-body-size)
:max-multipart-body-size (cf/get :http-server-max-multipart-body-size)} :max-multipart-body-size (cf/get :http-server-max-multipart-body-size)}
:app.auth.ldap/provider
{:host (cf/get :ldap-host)
:port (cf/get :ldap-port)
:ssl (cf/get :ldap-ssl)
:tls (cf/get :ldap-starttls)
:query (cf/get :ldap-user-query)
:attrs-email (cf/get :ldap-attrs-email)
:attrs-fullname (cf/get :ldap-attrs-fullname)
:attrs-username (cf/get :ldap-attrs-username)
:base-dn (cf/get :ldap-base-dn)
:bind-dn (cf/get :ldap-bind-dn)
:bind-password (cf/get :ldap-bind-password)
:enabled? (contains? cf/flags :login-with-ldap)}
:app.auth.oidc/google-provider
{:enabled? (contains? cf/flags :login-with-google)
:client-id (cf/get :google-client-id)
:client-secret (cf/get :google-client-secret)}
:app.auth.oidc/github-provider
{:enabled? (contains? cf/flags :login-with-github)
:http-client (ig/ref :app.http/client)
:client-id (cf/get :github-client-id)
:client-secret (cf/get :github-client-secret)}
:app.auth.oidc/gitlab-provider
{:enabled? (contains? cf/flags :login-with-gitlab)
:base-uri (cf/get :gitlab-base-uri "https://gitlab.com")
:client-id (cf/get :gitlab-client-id)
:client-secret (cf/get :gitlab-client-secret)}
:app.auth.oidc/generic-provider
{:enabled? (contains? cf/flags :login-with-oidc)
:http-client (ig/ref :app.http/client)
:client-id (cf/get :oidc-client-id)
:client-secret (cf/get :oidc-client-secret)
:base-uri (cf/get :oidc-base-uri)
:token-uri (cf/get :oidc-token-uri)
:auth-uri (cf/get :oidc-auth-uri)
:user-uri (cf/get :oidc-user-uri)
:scopes (cf/get :oidc-scopes)
:roles-attr (cf/get :oidc-roles-attr)
:roles (cf/get :oidc-roles)}
:app.auth.oidc/routes
{:providers {:google (ig/ref :app.auth.oidc/google-provider)
:github (ig/ref :app.auth.oidc/github-provider)
:gitlab (ig/ref :app.auth.oidc/gitlab-provider)
:oidc (ig/ref :app.auth.oidc/generic-provider)}
:tokens (ig/ref :app.tokens/tokens)
:http-client (ig/ref :app.http/client)
:pool (ig/ref :app.db/pool)
:session (ig/ref :app.http/session)
:public-uri (cf/get :public-uri)
:executor (ig/ref [::default :app.worker/executor])}
:app.http/router :app.http/router
{:assets (ig/ref :app.http.assets/handlers) {:assets (ig/ref :app.http.assets/handlers)
:feedback (ig/ref :app.http.feedback/handler) :feedback (ig/ref :app.http.feedback/handler)
:session (ig/ref :app.http/session) :session (ig/ref :app.http/session)
:awsns-handler (ig/ref :app.http.awsns/handler) :awsns-handler (ig/ref :app.http.awsns/handler)
:oauth (ig/ref :app.http.oauth/handler) :debug-routes (ig/ref :app.http.debug/routes)
:debug (ig/ref :app.http.debug/handlers) :oidc-routes (ig/ref :app.auth.oidc/routes)
:ws (ig/ref :app.http.websocket/handler) :ws (ig/ref :app.http.websocket/handler)
:metrics (ig/ref :app.metrics/metrics) :metrics (ig/ref :app.metrics/metrics)
:public-uri (cf/get :public-uri) :public-uri (cf/get :public-uri)
:storage (ig/ref :app.storage/storage) :storage (ig/ref :app.storage/storage)
:tokens (ig/ref :app.tokens/tokens) :tokens (ig/ref :app.tokens/tokens)
:audit-handler (ig/ref :app.loggers.audit/http-handler) :audit-handler (ig/ref :app.loggers.audit/http-handler)
:rpc (ig/ref :app.rpc/rpc) :rpc-routes (ig/ref :app.rpc/routes)
:doc-routes (ig/ref :app.http.doc/routes)
:executor (ig/ref [::default :app.worker/executor])} :executor (ig/ref [::default :app.worker/executor])}
:app.http.debug/handlers :app.http.debug/routes
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:executor (ig/ref [::worker :app.worker/executor])} :executor (ig/ref [::worker :app.worker/executor])
:storage (ig/ref :app.storage/storage)
:session (ig/ref :app.http/session)}
:app.http.websocket/handler :app.http.websocket/handler
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
@ -156,17 +227,7 @@
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:executor (ig/ref [::default :app.worker/executor])} :executor (ig/ref [::default :app.worker/executor])}
:app.http.oauth/handler :app.rpc/methods
{:rpc (ig/ref :app.rpc/rpc)
:session (ig/ref :app.http/session)
:pool (ig/ref :app.db/pool)
:tokens (ig/ref :app.tokens/tokens)
:audit (ig/ref :app.loggers.audit/collector)
:executor (ig/ref [::default :app.worker/executor])
:http-client (ig/ref :app.http/client)
:public-uri (cf/get :public-uri)}
:app.rpc/rpc
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:session (ig/ref :app.http/session) :session (ig/ref :app.http/session)
:tokens (ig/ref :app.tokens/tokens) :tokens (ig/ref :app.tokens/tokens)
@ -175,9 +236,13 @@
: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)
:ldap (ig/ref :app.auth.ldap/provider)
:http-client (ig/ref :app.http/client) :http-client (ig/ref :app.http/client)
:executors (ig/ref :app.worker/executors)} :executors (ig/ref :app.worker/executors)}
:app.rpc/routes
{:methods (ig/ref :app.rpc/methods)}
:app.worker/worker :app.worker/worker
{:executor (ig/ref [::worker :app.worker/executor]) {:executor (ig/ref [::worker :app.worker/executor])
:tasks (ig/ref :app.worker/registry) :tasks (ig/ref :app.worker/registry)
@ -336,23 +401,12 @@
:backends :backends
{:assets-s3 (ig/ref [::assets :app.storage.s3/backend]) {:assets-s3 (ig/ref [::assets :app.storage.s3/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])
:fdata-s3 (ig/ref [::fdata :app.storage.s3/backend])
;; keep this for backward compatibility ;; keep this for backward compatibility
:s3 (ig/ref [::assets :app.storage.s3/backend]) :s3 (ig/ref [::assets :app.storage.s3/backend])
:fs (ig/ref [::assets :app.storage.fs/backend])}} :fs (ig/ref [::assets :app.storage.fs/backend])}}
[::fdata :app.storage.s3/backend]
{:region (cf/get :storage-fdata-s3-region)
:bucket (cf/get :storage-fdata-s3-bucket)
:endpoint (cf/get :storage-fdata-s3-endpoint)
:prefix (cf/get :storage-fdata-s3-prefix)
:executor (ig/ref [::default :app.worker/executor])}
[::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) :endpoint (cf/get :storage-assets-s3-endpoint)
@ -361,12 +415,7 @@
[::assets :app.storage.fs/backend] [::assets :app.storage.fs/backend]
{:directory (cf/get :storage-assets-fs-directory)} {:directory (cf/get :storage-assets-fs-directory)}
})
[::tmp :app.storage.fs/backend]
{:directory "/tmp/penpot"}
[::assets :app.storage.db/backend]
{:pool (ig/ref :app.db/pool)}})
(def system nil) (def system nil)

View File

@ -12,18 +12,16 @@
[app.common.media :as cm] [app.common.media :as cm]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cf] [app.config :as cf]
[app.storage.tmp :as tmp]
[app.util.bytes :as bs]
[app.util.svg :as svg] [app.util.svg :as svg]
[buddy.core.bytes :as bb] [buddy.core.bytes :as bb]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
[clojure.java.io :as io]
[clojure.java.shell :as sh] [clojure.java.shell :as sh]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[datoteka.core :as fs]) [datoteka.core :as fs])
(:import (:import
java.io.ByteArrayInputStream
java.io.OutputStream
org.apache.commons.io.IOUtils
org.im4java.core.ConvertCmd org.im4java.core.ConvertCmd
org.im4java.core.IMOperation org.im4java.core.IMOperation
org.im4java.core.Info)) org.im4java.core.Info))
@ -93,18 +91,16 @@
(let [{:keys [path mtype]} input (let [{:keys [path mtype]} input
format (or (cm/mtype->format mtype) format) format (or (cm/mtype->format mtype) format)
ext (cm/format->extension format) ext (cm/format->extension format)
tmp (fs/create-tempfile :suffix ext)] tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)]
(doto (ConvertCmd.) (doto (ConvertCmd.)
(.run operation (into-array (map str [path tmp])))) (.run operation (into-array (map str [path tmp]))))
(let [thumbnail-data (fs/slurp-bytes tmp)] (assoc params
(fs/delete tmp) :format format
(assoc params :mtype (cm/format->mtype format)
:format format :size (fs/size tmp)
:mtype (cm/format->mtype format) :data tmp)))
:size (alength ^bytes thumbnail-data)
:data (ByteArrayInputStream. thumbnail-data)))))
(defmethod process :generic-thumbnail (defmethod process :generic-thumbnail
[{:keys [quality width height] :as params}] [{:keys [quality width height] :as params}]
@ -201,59 +197,54 @@
(defmethod process :generate-fonts (defmethod process :generate-fonts
[{:keys [input] :as params}] [{:keys [input] :as params}]
(letfn [(ttf->otf [data] (letfn [(ttf->otf [data]
(let [input-file (fs/create-tempfile :prefix "penpot") (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
output-file (fs/path (str input-file ".otf")) foutput (fs/path (str finput ".otf"))
_ (with-open [out (io/output-stream input-file)] _ (bs/write-to-file! data finput)
(IOUtils/writeChunked ^bytes data ^OutputStream out) res (sh/sh "fontforge" "-lang=ff" "-c"
(.flush ^OutputStream out)) (str/fmt "Open('%s'); Generate('%s')"
res (sh/sh "fontforge" "-lang=ff" "-c" (str finput)
(str/fmt "Open('%s'); Generate('%s')" (str foutput)))]
(str input-file)
(str output-file)))]
(when (zero? (:exit res)) (when (zero? (:exit res))
(fs/slurp-bytes output-file)))) foutput)))
(otf->ttf [data] (otf->ttf [data]
(let [input-file (fs/create-tempfile :prefix "penpot") (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
output-file (fs/path (str input-file ".ttf")) foutput (fs/path (str finput ".ttf"))
_ (with-open [out (io/output-stream input-file)] _ (bs/write-to-file! data finput)
(IOUtils/writeChunked ^bytes data ^OutputStream out) res (sh/sh "fontforge" "-lang=ff" "-c"
(.flush ^OutputStream out)) (str/fmt "Open('%s'); Generate('%s')"
res (sh/sh "fontforge" "-lang=ff" "-c" (str finput)
(str/fmt "Open('%s'); Generate('%s')" (str foutput)))]
(str input-file)
(str output-file)))]
(when (zero? (:exit res)) (when (zero? (:exit res))
(fs/slurp-bytes output-file)))) foutput)))
(ttf-or-otf->woff [data] (ttf-or-otf->woff [data]
(let [input-file (fs/create-tempfile :prefix "penpot" :suffix "") ;; NOTE: foutput is not used directly, it represents the
output-file (fs/path (str input-file ".woff")) ;; default output of the exection of the underlying
_ (with-open [out (io/output-stream input-file)] ;; command.
(IOUtils/writeChunked ^bytes data ^OutputStream out) (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
(.flush ^OutputStream out)) foutput (fs/path (str finput ".woff"))
res (sh/sh "sfnt2woff" (str input-file))] _ (bs/write-to-file! data finput)
res (sh/sh "sfnt2woff" (str finput))]
(when (zero? (:exit res)) (when (zero? (:exit res))
(fs/slurp-bytes output-file)))) foutput)))
(ttf-or-otf->woff2 [data] (ttf-or-otf->woff2 [data]
(let [input-file (fs/create-tempfile :prefix "penpot" :suffix "") ;; NOTE: foutput is not used directly, it represents the
output-file (fs/path (str input-file ".woff2")) ;; default output of the exection of the underlying
_ (with-open [out (io/output-stream input-file)] ;; command.
(IOUtils/writeChunked ^bytes data ^OutputStream out) (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".tmp")
(.flush ^OutputStream out)) foutput (fs/path (str (fs/base finput) ".woff2"))
res (sh/sh "woff2_compress" (str input-file))] _ (bs/write-to-file! data finput)
res (sh/sh "woff2_compress" (str finput))]
(when (zero? (:exit res)) (when (zero? (:exit res))
(fs/slurp-bytes output-file)))) foutput)))
(woff->sfnt [data] (woff->sfnt [data]
(let [input-file (fs/create-tempfile :prefix "penpot" :suffix "") (let [finput (tmp/tempfile :prefix "penpot" :suffix "")
_ (with-open [out (io/output-stream input-file)] _ (bs/write-to-file! data finput)
(IOUtils/writeChunked ^bytes data ^OutputStream out) res (sh/sh "woff2sfnt" (str finput)
(.flush ^OutputStream out)) :out-enc :bytes)]
res (sh/sh "woff2sfnt" (str input-file)
:out-enc :bytes)]
(when (zero? (:exit res)) (when (zero? (:exit res))
(:out res)))) (:out res))))

View File

@ -226,6 +226,15 @@
{:name "0072-mod-file-object-thumbnail-table" {:name "0072-mod-file-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0072-mod-file-object-thumbnail-table.sql")} :fn (mg/resource "app/migrations/sql/0072-mod-file-object-thumbnail-table.sql")}
{:name "0073-mod-file-media-object-constraints"
:fn (mg/resource "app/migrations/sql/0073-mod-file-media-object-constraints.sql")}
{:name "0074-mod-file-library-rel-constraints"
:fn (mg/resource "app/migrations/sql/0074-mod-file-library-rel-constraints.sql")}
{:name "0075-mod-share-link-table"
:fn (mg/resource "app/migrations/sql/0075-mod-share-link-table.sql")}
]) ])

View File

@ -0,0 +1,11 @@
ALTER TABLE file_media_object
ALTER CONSTRAINT file_media_object_media_id_fkey DEFERRABLE INITIALLY IMMEDIATE;
ALTER TABLE file_media_object
ALTER CONSTRAINT file_media_object_thumbnail_id_fkey DEFERRABLE INITIALLY IMMEDIATE;
ALTER TABLE file_media_object
RENAME CONSTRAINT media_object_file_id_fkey TO file_media_object_file_id_fkey;
ALTER TABLE file_media_object
ALTER CONSTRAINT file_media_object_file_id_fkey DEFERRABLE INITIALLY IMMEDIATE;

View File

@ -0,0 +1,5 @@
ALTER TABLE file_library_rel
ALTER CONSTRAINT file_library_rel_file_id_fkey DEFERRABLE INITIALLY IMMEDIATE;
ALTER TABLE file_library_rel
ALTER CONSTRAINT file_library_rel_library_file_id_fkey DEFERRABLE INITIALLY IMMEDIATE;

View File

@ -0,0 +1,5 @@
ALTER TABLE share_link
ADD COLUMN who_comment text NOT NULL DEFAULT('team'),
ADD COLUMN who_inspect text NOT NULL DEFAULT('team');
--- TODO: remove flags column in 1.15.x

View File

@ -160,7 +160,6 @@
"Function responsible to attach local subscription to the "Function responsible to attach local subscription to the
state. Intended to be used in agent." state. Intended to be used in agent."
[state cfg topics chan done-ch] [state cfg topics chan done-ch]
(l/trace :hint "subscribe-to-topics" :topics topics ::l/async false)
(aa/with-closing done-ch (aa/with-closing done-ch
(let [state (update state :chans assoc chan topics)] (let [state (update state :chans assoc chan topics)]
(reduce (fn [state topic] (reduce (fn [state topic]
@ -184,15 +183,15 @@
useful when client disconnects or in-bulk unsubscribe useful when client disconnects or in-bulk unsubscribe
operations. Intended to be executed in agent." operations. Intended to be executed in agent."
[state cfg channels done-ch] [state cfg channels done-ch]
(l/trace :hint "unsubscribe-channels" :chans (count channels) ::l/async false)
(aa/with-closing done-ch (aa/with-closing done-ch
(reduce #(unsubscribe-single-channel %1 cfg %2) state channels))) (reduce #(unsubscribe-single-channel %1 cfg %2) state channels)))
(defn- subscribe (defn- subscribe
[{:keys [::state executor] :as cfg} {:keys [topic topics chan]}] [{:keys [::state executor] :as cfg} {:keys [topic topics chan]}]
(let [done-ch (a/chan) (let [done-ch (a/chan)
topics (into [] (map prefix-topic) (if topic [topic] topics))] topics (into [] (map prefix-topic) (if topic [topic] topics))]
(l/trace :hint "subscribe" :topics topics) (l/debug :hint "subscribe" :topics topics)
(send-via executor state subscribe-to-topics cfg topics chan done-ch) (send-via executor state subscribe-to-topics cfg topics chan done-ch)
done-ch)) done-ch))

View File

@ -86,6 +86,30 @@
(let [context {:profile-id profile-id}] (let [context {:profile-id profile-id}]
(raise (ex/wrap-with-context cause context))))))))) (raise (ex/wrap-with-context cause context)))))))))
(defn- rpc-command-handler
"Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow."
[methods {:keys [profile-id session-id params] :as request} respond raise]
(letfn [(handle-response [result]
(let [mdata (meta result)]
(p/-> (yrs/response 200 result)
(handle-response-transformation request mdata)
(handle-before-comple-hook mdata))))]
(let [cmd (keyword (:command params))
data (into {::request request} params)
data (if profile-id
(assoc data :profile-id profile-id ::session-id session-id)
(dissoc data :profile-id))
method (get methods cmd default-handler)]
(-> (method data)
(p/then handle-response)
(p/then respond)
(p/catch (fn [cause]
(let [context {:profile-id profile-id}]
(raise (ex/wrap-with-context cause context)))))))))
(defn- wrap-metrics (defn- wrap-metrics
"Wrap service method with metrics measurement." "Wrap service method with metrics measurement."
[{:keys [metrics ::metrics-id]} f mdata] [{:keys [metrics ::metrics-id]} f mdata]
@ -180,8 +204,9 @@
(defn- process-method (defn- process-method
[cfg vfn] [cfg vfn]
(let [mdata (meta vfn)] (let [mdata (meta vfn)]
;; (prn mdata)
[(keyword (::sv/name mdata)) [(keyword (::sv/name mdata))
(wrap cfg (deref vfn) mdata)])) (wrap cfg vfn mdata)]))
(defn- resolve-query-methods (defn- resolve-query-methods
[cfg] [cfg]
@ -199,35 +224,81 @@
(defn- resolve-mutation-methods (defn- resolve-mutation-methods
[cfg] [cfg]
(let [cfg (assoc cfg ::type "mutation" ::metrics-id :rpc-mutation-timing)] (let [cfg (assoc cfg ::type "mutation" ::metrics-id :rpc-mutation-timing)]
(->> (sv/scan-ns 'app.rpc.mutations.demo (->> (sv/scan-ns 'app.rpc.mutations.media
'app.rpc.mutations.media
'app.rpc.mutations.profile 'app.rpc.mutations.profile
'app.rpc.mutations.files 'app.rpc.mutations.files
'app.rpc.mutations.comments 'app.rpc.mutations.comments
'app.rpc.mutations.projects 'app.rpc.mutations.projects
'app.rpc.mutations.teams 'app.rpc.mutations.teams
'app.rpc.mutations.management 'app.rpc.mutations.management
'app.rpc.mutations.ldap
'app.rpc.mutations.fonts 'app.rpc.mutations.fonts
'app.rpc.mutations.share-link 'app.rpc.mutations.share-link
'app.rpc.mutations.verify-token) 'app.rpc.mutations.verify-token)
(map (partial process-method cfg)) (map (partial process-method cfg))
(into {})))) (into {}))))
(s/def ::storage some?) (defn- resolve-command-methods
(s/def ::session map?) [cfg]
(s/def ::tokens fn?) (let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
(->> (sv/scan-ns 'app.rpc.commands.binfile
'app.rpc.commands.auth
'app.rpc.commands.ldap
'app.rpc.commands.demo)
(map (partial process-method cfg))
(into {}))))
(s/def ::audit (s/nilable fn?)) (s/def ::audit (s/nilable fn?))
(s/def ::executors (s/map-of keyword? ::wrk/executor)) (s/def ::executors (s/map-of keyword? ::wrk/executor))
(s/def ::executors map?)
(s/def ::http-client fn?)
(s/def ::ldap (s/nilable map?))
(s/def ::msgbus fn?)
(s/def ::public-uri ::us/not-empty-string)
(s/def ::session map?)
(s/def ::storage some?)
(s/def ::tokens fn?)
(defmethod ig/pre-init-spec ::rpc [_] (defmethod ig/pre-init-spec ::methods [_]
(s/keys :req-un [::storage ::session ::tokens ::audit (s/keys :req-un [::storage
::executors ::mtx/metrics ::db/pool])) ::session
::tokens
::audit
::executors
::public-uri
::msgbus
::http-client
::mtx/metrics
::db/pool
::ldap]))
(defmethod ig/init-key ::rpc (defmethod ig/init-key ::methods
[_ cfg] [_ cfg]
(let [mq (resolve-query-methods cfg) {:mutations (resolve-mutation-methods cfg)
mm (resolve-mutation-methods cfg)] :queries (resolve-query-methods cfg)
{:methods {:query mq :mutation mm} :commands (resolve-command-methods cfg)})
:query-handler (partial rpc-query-handler mq)
:mutation-handler (partial rpc-mutation-handler mm)})) (s/def ::mutations
(s/map-of keyword? fn?))
(s/def ::queries
(s/map-of keyword? fn?))
(s/def ::commands
(s/map-of keyword? fn?))
(s/def ::methods
(s/keys :req-un [::mutations
::queries
::commands]))
(defmethod ig/pre-init-spec ::routes [_]
(s/keys :req-un [::methods]))
(defmethod ig/init-key ::routes
[_ {:keys [methods] :as cfg}]
[["/rpc"
["/command/:command" {:handler (partial rpc-command-handler (:commands methods))}]
["/query/:type" {:handler (partial rpc-query-handler (:queries methods))}]
["/mutation/:type" {:handler (partial rpc-mutation-handler (:mutations methods))
:allowed-methods #{:post}}]]])

View File

@ -0,0 +1,416 @@
;; 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.commands.auth
(:require
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.emails :as eml]
[app.loggers.audit :as audit]
[app.rpc.mutations.teams :as teams]
[app.rpc.queries.profile :as profile]
[app.rpc.rlimit :as rlimit]
[app.util.services :as sv]
[app.util.time :as dt]
[buddy.hashers :as hashers]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
(s/def ::email ::us/email)
(s/def ::fullname ::us/not-empty-string)
(s/def ::lang ::us/string)
(s/def ::path ::us/string)
(s/def ::profile-id ::us/uuid)
(s/def ::password ::us/not-empty-string)
(s/def ::old-password ::us/not-empty-string)
(s/def ::theme ::us/string)
(s/def ::invitation-token ::us/not-empty-string)
(s/def ::token ::us/not-empty-string)
;; ---- HELPERS
(defn derive-password
[password]
(hashers/derive password
{:alg :argon2id
:memory 16384
:iterations 20
:parallelism 2}))
(defn verify-password
[attempt password]
(try
(hashers/verify attempt password)
(catch Exception _e
{:update false
:valid false})))
(defn email-domain-in-whitelist?
"Returns true if email's domain is in the given whitelist or if
given whitelist is an empty string."
[domains email]
(if (or (empty? domains)
(nil? domains))
true
(let [[_ candidate] (-> (str/lower email)
(str/split #"@" 2))]
(contains? domains candidate))))
(def ^:private sql:profile-existence
"select exists (select * from profile
where email = ?
and deleted_at is null) as val")
(defn check-profile-existence!
[conn {:keys [email] :as params}]
(let [email (str/lower email)
result (db/exec-one! conn [sql:profile-existence email])]
(when (:val result)
(ex/raise :type :validation
:code :email-already-exists))
params))
;; ---- COMMAND: login with password
(defn login-with-password
[{:keys [pool session tokens] :as cfg} {:keys [email password] :as params}]
(when-not (contains? cf/flags :login)
(ex/raise :type :restriction
:code :login-disabled
:hint "login is disabled in this instance"))
(letfn [(check-password [profile password]
(when (= (:password profile) "!")
(ex/raise :type :validation
:code :account-without-password
:hint "the current account does not have password"))
(:valid (verify-password password (:password profile))))
(validate-profile [profile]
(when-not (:is-active profile)
(ex/raise :type :validation
:code :wrong-credentials))
(when-not profile
(ex/raise :type :validation
:code :wrong-credentials))
(when-not (check-password profile password)
(ex/raise :type :validation
:code :wrong-credentials))
profile)]
(db/with-atomic [conn pool]
(let [profile (->> (profile/retrieve-profile-data-by-email conn email)
(validate-profile)
(profile/strip-private-attrs)
(profile/populate-additional-data conn)
(profile/decode-profile-row))
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))
::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})))))
(s/def ::login-with-password
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(sv/defmethod ::login-with-password
"Performs authentication using penpot password."
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[cfg params]
(login-with-password cfg params))
;; ---- COMMAND: Logout
(s/def ::logout
(s/keys :opt-un [::profile-id]))
(sv/defmethod ::logout
"Clears the authentication cookie and logout the current session."
{:auth false}
[{:keys [session] :as cfg} _]
(with-meta {}
{:transform-response (:delete session)}))
;; ---- COMMAND: Recover Profile
(defn recover-profile
[{:keys [pool tokens] :as cfg} {:keys [token password]}]
(letfn [(validate-token [token]
(let [tdata (tokens :verify {:token token :iss :password-recovery})]
(:profile-id tdata)))
(update-password [conn profile-id]
(let [pwd (derive-password password)]
(db/update! conn :profile {:password pwd} {:id profile-id})))]
(db/with-atomic [conn pool]
(->> (validate-token token)
(update-password conn))
nil)))
(s/def ::token ::us/not-empty-string)
(s/def ::recover-profile
(s/keys :req-un [::token ::password]))
(sv/defmethod ::recover-profile
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[cfg params]
(recover-profile cfg params))
;; ---- COMMAND: Prepare Register
(defn prepare-register
[{:keys [pool tokens] :as cfg} params]
(when-not (contains? cf/flags :registration)
(if-not (contains? params :invitation-token)
(ex/raise :type :restriction
:code :registration-disabled)
(let [invitation (tokens :verify {:token (:invitation-token params) :iss :team-invitation})]
(when-not (= (:email params) (:member-email invitation))
(ex/raise :type :restriction
:code :email-does-not-match-invitation
:hint "email should match the invitation")))))
(when-let [domains (cf/get :registration-domain-whitelist)]
(when-not (email-domain-in-whitelist? domains (:email params))
(ex/raise :type :validation
:code :email-domain-is-not-allowed)))
;; Don't allow proceed in preparing registration if the profile is
;; already reported as spammer.
(when (eml/has-bounce-reports? pool (:email params))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email has one or many bounces reported"))
(check-profile-existence! pool 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)
:password (:password params)
:invitation-token (:invitation-token params)
:backend "penpot"
:iss :prepared-register
:exp (dt/in-future "48h")}
token (tokens :generate params)]
(with-meta {:token token}
{::audit/profile-id uuid/zero})))
(s/def ::prepare-register-profile
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(sv/defmethod ::prepare-register-profile {:auth false}
[cfg params]
(prepare-register cfg params))
;; ---- COMMAND: Register Profile
(defn create-profile
"Create the profile entry on the database with limited input filling
all the other fields with defaults."
[conn params]
(let [id (or (:id params) (uuid/next))
props (-> (audit/extract-utm-params params)
(merge (:props params))
(db/tjson))
password (if-let [password (:password params)]
(derive-password password)
"!")
locale (:locale params)
locale (when (and (string? locale) (not (str/blank? locale)))
locale)
backend (:backend params "penpot")
is-demo (:is-demo params false)
is-muted (:is-muted params false)
is-active (:is-active params false)
email (str/lower (:email params))
params {:id id
:fullname (:fullname params)
:email email
:auth-backend backend
:lang locale
:password password
:deleted-at (:deleted-at params)
:props props
:is-active is-active
:is-muted is-muted
:is-demo is-demo}]
(try
(-> (db/insert! conn :profile params)
(profile/decode-profile-row))
(catch org.postgresql.util.PSQLException e
(let [state (.getSQLState e)]
(if (not= state "23505")
(throw e)
(ex/raise :type :validation
:code :email-already-exists
:cause e)))))))
(defn create-profile-relations
[conn profile]
(let [team (teams/create-team conn {:profile-id (:id profile)
:name "Default"
:is-default true})]
(-> profile
(profile/strip-private-attrs)
(assoc :default-team-id (:id team))
(assoc :default-project-id (:default-project-id team)))))
(defn register-profile
[{:keys [conn tokens session] :as cfg} {:keys [token] :as params}]
(let [claims (tokens :verify {:token token :iss :prepared-register})
params (merge params claims)]
(check-profile-existence! conn params)
(let [is-active (or (:is-active params)
(contains? cf/flags :insecure-register))
profile (->> (assoc params :is-active is-active)
(create-profile conn)
(create-profile-relations conn)
(profile/decode-profile-row))
invitation (when-let [token (:invitation-token params)]
(tokens :verify {:token token :iss :team-invitation}))]
(cond
;; If invitation token comes in params, this is because the user comes from team-invitation process;
;; in this case, regenerate token and send back to the user a new invitation token (and mark current
;; session as logged). This happens only if the invitation email matches with the register email.
(and (some? invitation) (= (:email profile) (:member-email invitation)))
(let [claims (assoc invitation :member-id (:id profile))
token (tokens :generate claims)
resp {:invitation-token token}]
(with-meta resp
{:transform-response ((:create session) (:id profile))
::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)}))
;; If auth backend is different from "penpot" means user is
;; registering using third party auth mechanism; in this case
;; we need to mark this session as logged.
(not= "penpot" (:auth-backend profile))
(with-meta (profile/strip-private-attrs profile)
{:transform-response ((:create session) (:id profile))
::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)})
;; If the `:enable-insecure-register` flag is set, we proceed
;; to sign in the user directly, without email verification.
(true? is-active)
(with-meta (profile/strip-private-attrs profile)
{:transform-response ((:create session) (:id profile))
::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)})
;; In all other cases, send a verification email.
:else
(let [vtoken (tokens :generate
{:iss :verify-email
:exp (dt/in-future "48h")
:profile-id (:id profile)
:email (:email profile)})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(eml/send! {::eml/conn conn
::eml/factory eml/register
:public-uri (:public-uri cfg)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken})
(with-meta profile
{::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))))
(s/def ::register-profile
(s/keys :req-un [::token ::fullname]))
(sv/defmethod ::register-profile
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[{:keys [pool] :as cfg} params]
(db/with-atomic [conn pool]
(-> (assoc cfg :conn conn)
(register-profile params))))
;; ---- COMMAND: Request Profile Recovery
(defn request-profile-recovery
[{:keys [pool tokens] :as cfg} {:keys [email] :as params}]
(letfn [(create-recovery-token [{:keys [id] :as profile}]
(let [token (tokens :generate
{:iss :password-recovery
:exp (dt/in-future "15m")
:profile-id id})]
(assoc profile :token token)))
(send-email-notification [conn profile]
(let [ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(eml/send! {::eml/conn conn
::eml/factory eml/password-recovery
:public-uri (:public-uri cfg)
:to (:email profile)
:token (:token profile)
:name (:fullname profile)
:extra-data ptoken})
nil))]
(db/with-atomic [conn pool]
(when-let [profile (profile/retrieve-profile-data-by-email conn email)]
(when-not (eml/allow-send-emails? conn profile)
(ex/raise :type :validation
:code :profile-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
(when-not (:is-active profile)
(ex/raise :type :validation
:code :profile-not-verified
:hint "the user need to validate profile before recover password"))
(when (eml/has-bounce-reports? conn (:email profile))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
(->> profile
(create-recovery-token)
(send-email-notification conn))))))
(s/def ::request-profile-recovery
(s/keys :req-un [::email]))
(sv/defmethod ::request-profile-recovery {:auth false}
[cfg params]
(request-profile-recovery cfg params))

View File

@ -0,0 +1,836 @@
;; 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.commands.binfile
(:refer-clojure :exclude [assert])
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pages.migrations :as pmg]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.media :as media]
[app.rpc.queries.files :as files]
[app.rpc.queries.projects :as projects]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.tasks.file-gc]
[app.util.blob :as blob]
[app.util.bytes :as bs]
[app.util.fressian :as fres]
[app.util.services :as sv]
[app.util.time :as dt]
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
[clojure.walk :as walk]
[cuerdas.core :as str]
[yetti.adapter :as yt])
(:import
java.io.DataInputStream
java.io.DataOutputStream
java.io.InputStream
java.io.OutputStream
java.lang.AutoCloseable))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; VARS & DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Threshold in MiB when we pass from using
;; in-memory byte-array's to use temporal files.
(def temp-file-threshold
(* 1024 1024 2))
;; Represents the current processing file-id on
;; export process.
(def ^:dynamic *file-id*)
;; Stores all media file object references of
;; processed files on import process.
(def ^:dynamic *media*)
;; Stores the objects index on reamping subprocess
;; part of the import process.
(def ^:dynamic *index*)
;; Has the current connection used on the import
;; process.
(def ^:dynamic *conn*)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; LOW LEVEL STREAM IO API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:const buffer-size (:xnio/buffer-size yt/defaults))
(def ^:const penpot-magic-number 800099563638710213)
(def ^:const max-object-size (* 1024 1024 100)) ; Only allow 100MiB max file size.
(def ^:dynamic *position* nil)
(defn get-mark
[id]
(case id
:header 1
:stream 2
:uuid 3
:label 4
:obj 5
(ex/raise :type :validation
:code :invalid-mark-id
:hint (format "invalid mark id %s" id))))
(defmacro assert
[expr hint]
`(when-not ~expr
(ex/raise :type :validation
:code :unexpected-condition
:hint ~hint)))
(defmacro assert-mark
[v type]
`(let [expected# (get-mark ~type)
val# (long ~v)]
(when (not= val# expected#)
(ex/raise :type :validation
:code :unexpected-mark
:hint (format "received mark %s, expected %s" val# expected#)))))
(defmacro assert-label
[expr label]
`(let [v# ~expr]
(when (not= v# ~label)
(ex/raise :type :assertion
:code :unexpected-label
:hint (format "received label %s, expected %s" v# ~label)))))
;; --- PRIMITIVE IO
(defn write-byte!
[^DataOutputStream output data]
(l/trace :fn "write-byte!" :data data :position @*position* ::l/async false)
(.writeByte output (byte data))
(swap! *position* inc))
(defn read-byte!
[^DataInputStream input]
(let [v (.readByte input)]
(l/trace :fn "read-byte!" :val v :position @*position* ::l/async false)
(swap! *position* inc)
v))
(defn write-long!
[^DataOutputStream output data]
(l/trace :fn "write-long!" :data data :position @*position* ::l/async false)
(.writeLong output (long data))
(swap! *position* + 8))
(defn read-long!
[^DataInputStream input]
(let [v (.readLong input)]
(l/trace :fn "read-long!" :val v :position @*position* ::l/async false)
(swap! *position* + 8)
v))
(defn write-bytes!
[^DataOutputStream output ^bytes data]
(let [size (alength data)]
(l/trace :fn "write-bytes!" :size size :position @*position* ::l/async false)
(.write output data 0 size)
(swap! *position* + size)))
(defn read-bytes!
[^InputStream input ^bytes buff]
(let [size (alength buff)
readed (.readNBytes input buff 0 size)]
(l/trace :fn "read-bytes!" :expected (alength buff) :readed readed :position @*position* ::l/async false)
(swap! *position* + readed)
readed))
;; --- COMPOSITE IO
(defn write-uuid!
[^DataOutputStream output id]
(l/trace :fn "write-uuid!" :position @*position* :WRITTEN? (.size output) ::l/async false)
(doto output
(write-byte! (get-mark :uuid))
(write-long! (uuid/get-word-high id))
(write-long! (uuid/get-word-low id))))
(defn read-uuid!
[^DataInputStream input]
(l/trace :fn "read-uuid!" :position @*position* ::l/async false)
(let [m (read-byte! input)]
(assert-mark m :uuid)
(let [a (read-long! input)
b (read-long! input)]
(uuid/custom a b))))
(defn write-obj!
[^DataOutputStream output data]
(l/trace :fn "write-obj!" :position @*position* ::l/async false)
(let [^bytes data (fres/encode data)]
(doto output
(write-byte! (get-mark :obj))
(write-long! (alength data))
(write-bytes! data))))
(defn read-obj!
[^DataInputStream input]
(l/trace :fn "read-obj!" :position @*position* ::l/async false)
(let [m (read-byte! input)]
(assert-mark m :obj)
(let [size (read-long! input)]
(assert (pos? size) "incorrect header size found on reading header")
(let [buff (byte-array size)]
(read-bytes! input buff)
(fres/decode buff)))))
(defn write-label!
[^DataOutputStream output label]
(l/trace :fn "write-label!" :label label :position @*position* ::l/async false)
(doto output
(write-byte! (get-mark :label))
(write-obj! label)))
(defn read-label!
[^DataInputStream input]
(l/trace :fn "read-label!" :position @*position* ::l/async false)
(let [m (read-byte! input)]
(assert-mark m :label)
(read-obj! input)))
(defn write-header!
[^DataOutputStream output & {:keys [version metadata]}]
(l/trace :fn "write-header!"
:version version
:metadata metadata
:position @*position*
::l/async false)
(doto output
(write-byte! (get-mark :header))
(write-long! penpot-magic-number)
(write-long! version)
(write-obj! metadata)))
(defn read-header!
[^DataInputStream input]
(l/trace :fn "read-header!" :position @*position* ::l/async false)
(let [mark (read-byte! input)
mnum (read-long! input)
vers (read-long! input)]
(when (or (not= mark (get-mark :header))
(not= mnum penpot-magic-number))
(ex/raise :type :validation
:code :invalid-penpot-file))
(-> (read-obj! input)
(assoc ::version vers))))
(defn copy-stream!
[^OutputStream output ^InputStream input ^long size]
(let [written (bs/copy! input output :size size)]
(l/trace :fn "copy-stream!" :position @*position* :size size :written written ::l/async false)
(swap! *position* + written)
written))
(defn write-stream!
[^DataOutputStream output stream size]
(l/trace :fn "write-stream!" :position @*position* ::l/async false :size size)
(doto output
(write-byte! (get-mark :stream))
(write-long! size))
(copy-stream! output stream size))
(defn read-stream!
[^DataInputStream input]
(l/trace :fn "read-stream!" :position @*position* ::l/async false)
(let [m (read-byte! input)
s (read-long! input)
p (tmp/tempfile :prefix "penpot.binfile.")]
(assert-mark m :stream)
(when (> s max-object-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str/ffmt "unable to import storage object with size % bytes" s)))
(if (> s temp-file-threshold)
(with-open [^OutputStream output (io/output-stream p)]
(let [readed (bs/copy! input output :offset 0 :size s)]
(l/trace :fn "read-stream*!" :expected s :readed readed :position @*position* ::l/async false)
(swap! *position* + readed)
[s p]))
[s (bs/read-as-bytes input :size s)])))
(defmacro assert-read-label!
[input expected-label]
`(let [readed# (read-label! ~input)
expected# ~expected-label]
(when (not= readed# expected#)
(ex/raise :type :validation
:code :unexpected-label
:hint (format "unxpected label found: %s, expected: %s" readed# expected#)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- HELPERS
(defn- retrieve-file
[pool file-id]
(->> (db/query pool :file {:id file-id})
(map files/decode-row)
(first)))
(def ^:private sql:file-media-objects
"SELECT * FROM file_media_object WHERE id = ANY(?)")
(defn- retrieve-file-media
[pool {:keys [data] :as file}]
(with-open [^AutoCloseable conn (db/open pool)]
(let [ids (app.tasks.file-gc/collect-used-media data)
ids (db/create-array conn "uuid" ids)]
(db/exec! conn [sql:file-media-objects ids]))))
(def ^:private storage-object-id-xf
(comp
(mapcat (juxt :media-id :thumbnail-id))
(filter uuid?)))
(def ^:private sql:file-libraries
"WITH RECURSIVE libs AS (
SELECT fl.id, fl.deleted_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
WHERE flr.file_id = ANY(?)
UNION
SELECT fl.id, fl.deleted_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
JOIN libs AS l ON (flr.file_id = l.id)
)
SELECT DISTINCT l.id
FROM libs AS l
WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
(defn- retrieve-libraries
[pool ids]
(with-open [^AutoCloseable conn (db/open pool)]
(let [ids (db/create-array conn "uuid" ids)]
(map :id (db/exec! pool [sql:file-libraries ids])))))
(def ^:private sql:file-library-rels
"SELECT * FROM file_library_rel
WHERE file_id = ANY(?)")
(defn- retrieve-library-relations
[pool ids]
(with-open [^AutoCloseable conn (db/open pool)]
(db/exec! conn [sql:file-library-rels (db/create-array conn "uuid" ids)])))
;; --- EXPORT WRITTER
(s/def ::output bs/output-stream?)
(s/def ::file-ids (s/every ::us/uuid :kind vector? :min-count 1))
(s/def ::include-libraries? (s/nilable ::us/boolean))
(s/def ::embed-assets? (s/nilable ::us/boolean))
(s/def ::write-export-options
(s/keys :req-un [::db/pool ::sto/storage]
:req [::output ::file-ids]
:opt [::include-libraries? ::embed-assets?]))
(defn write-export!
"Do the exportation of a speficied file in custom penpot binary
format. There are some options available for customize the output:
`::include-libraries?`: additionaly to the specified file, all the
linked libraries also will be included (including transitive
dependencies).
`::embed-assets?`: instead of including the libraryes, embedd in the
same file library all assets used from external libraries.
"
[{:keys [pool storage ::output ::file-ids ::include-libraries? ::embed-assets?] :as options}]
(us/assert! ::write-export-options options)
(us/verify!
:expr (not (and include-libraries? embed-assets?))
:hint "the `include-libraries?` and `embed-assets?` are mutally excluding options")
(letfn [(write-header [output files]
(let [sections [:v1/files :v1/rels :v1/sobjects]
mdata {:penpot-version (:full cf/version)
:sections sections
:files files}]
(write-header! output :version 1 :metadata mdata)))
(write-files [output files sids]
(l/debug :hint "write section" :section :v1/files :total (count files) ::l/async false)
(write-label! output :v1/files)
(doseq [file-id files]
(let [file (cond-> (retrieve-file pool file-id)
embed-assets? (update :data embed-file-assets file-id))
media (retrieve-file-media pool file)]
;; Collect all storage ids for later write them all under
;; specific storage objects section.
(vswap! sids into (sequence storage-object-id-xf media))
(l/trace :hint "write penpot file"
:id file-id
:media (count media)
::l/async false)
(doto output
(write-obj! file)
(write-obj! media)))))
(write-rels [output files]
(let [rels (when include-libraries? (retrieve-library-relations pool files))]
(l/debug :hint "write section" :section :v1/rels :total (count rels) ::l/async false)
(doto output
(write-label! :v1/rels)
(write-obj! rels))))
(write-sobjects [output sids]
(l/debug :hint "write section"
:section :v1/sobjects
:items (count sids)
::l/async false)
;; Write all collected storage objects
(doto output
(write-label! :v1/sobjects)
(write-obj! sids))
(let [storage (media/configure-assets-storage storage)]
(doseq [id sids]
(let [{:keys [size] :as obj} @(sto/get-object storage id)]
(l/trace :hint "write sobject" :id id ::l/async false)
(doto output
(write-uuid! id)
(write-obj! (meta obj)))
(with-open [^InputStream stream @(sto/get-object-data storage obj)]
(let [written (write-stream! output stream size)]
(when (not= written size)
(ex/raise :type :validation
:code :mismatch-readed-size
:hint (str/ffmt "found unexpected object size; size=% written=%" size written)))))))))
(embed-file-assets [data file-id]
(binding [*file-id* file-id]
(let [assets (volatile! [])]
(walk/postwalk #(cond-> % (map? %) (walk-map-form assets)) data)
(->> (deref assets)
(filter #(as-> (first %) $ (and (uuid? $) (not= $ file-id))))
(d/group-by first rest)
(reduce process-group-of-assets data)))))
(walk-map-form [form state]
(cond
(uuid? (:fill-color-ref-file form))
(do
(vswap! state conj [(:fill-color-ref-file form) :colors (:fill-color-ref-id form)])
(assoc form :fill-color-ref-file *file-id*))
(uuid? (:stroke-color-ref-file form))
(do
(vswap! state conj [(:stroke-color-ref-file form) :colors (:stroke-color-ref-id form)])
(assoc form :stroke-color-ref-file *file-id*))
(uuid? (:typography-ref-file form))
(do
(vswap! state conj [(:typography-ref-file form) :typographies (:typography-ref-id form)])
(assoc form :typography-ref-file *file-id*))
(uuid? (:component-file form))
(do
(vswap! state conj [(:component-file form) :components (:component-id form)])
(assoc form :component-file *file-id*))
:else
form))
(process-group-of-assets [data [lib-id items]]
;; NOTE: there are a posibility that shape refers to a not
;; existing file because the file was removed. In this
;; case we just ignore the asset.
(if-let [lib (retrieve-file pool lib-id)]
(reduce #(process-asset %1 lib %2) data items)
data))
(process-asset [data lib [bucket asset-id]]
(let [asset (get-in lib [:data bucket asset-id])
;; Add a special case for colors that need to have
;; correctly set the :file-id prop (pending of the
;; refactor that will remove it).
asset (cond-> asset
(= bucket :colors) (assoc :file-id *file-id*))]
(update data bucket assoc asset-id asset)))]
(with-open [output (bs/zstd-output-stream output :level 12)]
(with-open [output (bs/data-output-stream output)]
(let [libs (when include-libraries? (retrieve-libraries pool file-ids))
files (into file-ids libs)
sids (volatile! #{})]
;; Write header with metadata
(l/debug :hint "exportation summary"
:files (count files)
:embed-assets? embed-assets?
:include-libs? include-libraries?
::l/async false)
(write-header output files)
(write-files output files sids)
(write-rels output files)
(write-sobjects output (vec @sids)))))))
(s/def ::project-id ::us/uuid)
(s/def ::input bs/input-stream?)
(s/def ::overwrite? (s/nilable ::us/boolean))
(s/def ::migrate? (s/nilable ::us/boolean))
(s/def ::ignore-index-errors? (s/nilable ::us/boolean))
(s/def ::read-import-options
(s/keys :req-un [::db/pool ::sto/storage]
:req [::project-id ::input]
:opt [::overwrite? ::migrate? ::ignore-index-errors?]))
(defn read-import!
"Do the importation of the specified resource in penpot custom binary
format. There are some options for customize the importation
behavior:
`::overwrite?`: if true, instead of creating new files and remaping id references,
it reuses all ids and updates existing objects; defaults to `false`.
`::migrate?`: if true, applies the migration before persisting the
file data; defaults to `false`.
`::ignore-index-errors?`: if true, do not fail on index lookup errors, can
happen with broken files; defaults to: `false`.
"
[{:keys [pool storage ::project-id ::timestamp ::input ::overwrite? ::migrate? ::ignore-index-errors?]
:or {overwrite? false migrate? false timestamp (dt/now)}
:as options}]
(us/assert! ::read-import-options options)
(letfn [(lookup-index [id]
(if ignore-index-errors?
(or (get @*index* id) id)
(let [val (get @*index* id)]
(l/trace :fn "lookup-index" :id id :val val ::l/async false)
(when-not val
(ex/raise :type :validation
:code :incomplete-index
:hint "looks like index has missing data"))
val)))
(update-index [index coll]
(loop [items (seq coll)
index index]
(if-let [id (first items)]
(let [new-id (if overwrite? id (uuid/next))]
(l/trace :fn "update-index" :id id :new-id new-id ::l/async false)
(recur (rest items)
(assoc index id new-id)))
index)))
(process-map-form [form]
(cond-> form
;; Relink Image Shapes
(and (map? (:metadata form))
(= :image (:type form)))
(update-in [:metadata :id] lookup-index)
;; This covers old shapes and the new :fills.
(uuid? (:fill-color-ref-file form))
(update :fill-color-ref-file lookup-index)
;; This covers the old shapes and the new :strokes
(uuid? (:storage-color-ref-file form))
(update :stroke-color-ref-file lookup-index)
;; This covers all text shapes that have typography referenced
(uuid? (:typography-ref-file form))
(update :typography-ref-file lookup-index)
;; This covers the shadows and grids (they have directly
;; the :file-id prop)
(uuid? (:file-id form))
(update :file-id lookup-index)))
;; a function responsible to analyze all file data and
;; replace the old :component-file reference with the new
;; ones, using the provided file-index
(relink-shapes [data]
(walk/postwalk (fn [form]
(if (map? form)
(try
(process-map-form form)
(catch Throwable cause
(l/trace :hint "failed form" :form (pr-str form) ::l/async false)
(throw cause)))
form))
data))
;; A function responsible of process the :media attr of file
;; data and remap the old ids with the new ones.
(relink-media [media]
(reduce-kv (fn [res k v]
(let [id (lookup-index k)]
(if (uuid? id)
(-> res
(assoc id (assoc v :id id))
(dissoc k))
res)))
media
media))
(create-or-update-file [params]
(let [sql (str "INSERT INTO file (id, project_id, name, revn, is_shared, data, created_at, modified_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT (id) DO UPDATE SET data=?")]
(db/exec-one! *conn* [sql
(:id params)
(:project-id params)
(:name params)
(:revn params)
(:is-shared params)
(:data params)
(:created-at params)
(:modified-at params)
(:data params)])))
(read-files-section! [input expected-files]
(l/debug :hint "reading section" :section :v1/files ::l/async false)
(assert-read-label! input :v1/files)
;; Process/Read all file
(doseq [expected-file-id expected-files]
(let [file (read-obj! input)
media' (read-obj! input)
file-id (:id file)]
(when (not= file-id expected-file-id)
(ex/raise :type :validation
:code :inconsistent-penpot-file
:hint "the penpot file seems corrupt, found unexpected uuid (file-id)"))
;; Update index using with media
(l/trace :hint "update index with media" ::l/async false)
(vswap! *index* update-index (map :id media'))
;; Store file media for later insertion
(l/trace :hint "update media references" ::l/async false)
(vswap! *media* into (map #(update % :id lookup-index)) media')
(l/trace :hint "procesing file" :file-id file-id ::l/async false)
(let [file-id' (lookup-index file-id)
data (-> (:data file)
(assoc :id file-id')
(cond-> migrate? (pmg/migrate-data))
(update :pages-index relink-shapes)
(update :components relink-shapes)
(update :media relink-media))
params {:id file-id'
:project-id project-id
:name (str "Imported: " (:name file))
:revn (:revn file)
:is-shared (:is-shared file)
:data (blob/encode data)
:created-at timestamp
:modified-at timestamp}]
(l/trace :hint "create file" :id file-id' ::l/async false)
(if overwrite?
(create-or-update-file params)
(db/insert! *conn* :file params))
(when overwrite?
(db/delete! *conn* :file-thumbnail {:file-id file-id'}))))))
(read-rels-section! [input]
(l/debug :hint "reading section" :section :v1/rels ::l/async false)
(assert-read-label! input :v1/rels)
(let [rels (read-obj! input)]
;; Insert all file relations
(doseq [rel rels]
(let [rel (-> rel
(assoc :synced-at timestamp)
(update :file-id lookup-index)
(update :library-file-id lookup-index))]
(l/trace :hint "create file library link"
:file-id (:file-id rel)
:lib-id (:library-file-id rel)
::l/async false)
(db/insert! *conn* :file-library-rel rel)))))
(read-sobjects-section! [input]
(l/debug :hint "reading section" :section :v1/sobjects ::l/async false)
(assert-read-label! input :v1/sobjects)
(let [storage (media/configure-assets-storage storage)
ids (read-obj! input)]
;; Step 1: process all storage objects
(doseq [expected-storage-id ids]
(let [id (read-uuid! input)
mdata (read-obj! input)]
(when (not= id expected-storage-id)
(ex/raise :type :validation
:code :inconsistent-penpot-file
:hint "the penpot file seems corrupt, found unexpected uuid (storage-object-id)"))
(l/trace :hint "readed storage object" :id id ::l/async false)
(let [[size resource] (read-stream! input)
hash (sto/calculate-hash resource)
content (-> (sto/content resource size)
(sto/wrap-with-hash hash))
params (-> mdata
(assoc ::sto/deduplicate? true)
(assoc ::sto/content content)
(assoc ::sto/touched-at (dt/now)))
sobject @(sto/put-object! storage params)]
(l/trace :hint "persisted storage object" :id id :new-id (:id sobject) ::l/async false)
(vswap! *index* assoc id (:id sobject)))))
;; Step 2: insert all file-media-object rows with correct
;; storage-id reference.
(doseq [item @*media*]
(l/trace :hint "inserting file media objects" :id (:id item) ::l/async false)
(db/insert! *conn* :file-media-object
(-> item
(update :file-id lookup-index)
(d/update-when :media-id lookup-index)
(d/update-when :thumbnail-id lookup-index))
{:on-conflict-do-nothing overwrite?}))))]
(with-open [input (bs/zstd-input-stream input)]
(with-open [input (bs/data-input-stream input)]
(db/with-atomic [conn pool]
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED;"])
;; Verify that we received a proper .penpot file
(let [{:keys [sections files]} (read-header! input)]
(l/debug :hint "import verified" :files files :overwrite? overwrite?)
(binding [*index* (volatile! (update-index {} files))
*media* (volatile! [])
*conn* conn]
(doseq [section sections]
(case section
:v1/rels (read-rels-section! input)
:v1/files (read-files-section! input files)
:v1/sobjects (read-sobjects-section! input))))))))))
(defn export!
[cfg]
(let [path (tmp/tempfile :prefix "penpot.export.")
id (uuid/next)
ts (dt/now)
cs (volatile! nil)]
(try
(l/info :hint "start exportation" :export-id id)
(with-open [output (io/output-stream path)]
(binding [*position* (atom 0)]
(write-export! (assoc cfg ::output output))
path))
(catch Throwable cause
(vreset! cs cause)
(throw cause))
(finally
(l/info :hint "exportation finished" :export-id id
:elapsed (str (inst-ms (dt/diff ts (dt/now))) "ms")
:cause @cs)))))
(defn import!
[{:keys [::input] :as cfg}]
(let [id (uuid/next)
ts (dt/now)
cs (volatile! nil)]
(try
(l/info :hint "start importation" :import-id id)
(binding [*position* (atom 0)]
(with-open [input (io/input-stream input)]
(read-import! (assoc cfg ::input input))))
(catch Throwable cause
(vreset! cs cause)
(throw cause))
(finally
(l/info :hint "importation finished" :import-id id
:elapsed (str (inst-ms (dt/diff ts (dt/now))) "ms")
:error? (some? @cs)
:cause @cs)))))
;; --- Command: export-binfile
(s/def ::file-id ::us/uuid)
(s/def ::profile-id ::us/uuid)
(s/def ::include-libraries? ::us/boolean)
(s/def ::embed-assets? ::us/boolean)
(s/def ::export-binfile
(s/keys :req-un [::profile-id ::file-id ::include-libraries? ::embed-assets?]))
(sv/defmethod ::export-binfile
"Export a penpot file in a binary format."
[{:keys [pool] :as cfg} {:keys [profile-id file-id include-libraries? embed-assets?] :as params}]
(db/with-atomic [conn pool]
(files/check-read-permissions! conn profile-id file-id)
(let [path (export! (assoc cfg
::file-ids [file-id]
::embed-assets? embed-assets?
::include-libraries? include-libraries?))]
(with-meta {}
{:transform-response (fn [_ response]
(assoc response
:body (io/input-stream path)
:headers {"content-type" "application/octet-stream"}))}))))
(s/def ::file ::media/upload)
(s/def ::import-binfile
(s/keys :req-un [::profile-id ::project-id ::file]))
(sv/defmethod ::import-binfile
"Import a penpot file in a binary format."
[{:keys [pool] :as cfg} {:keys [profile-id project-id file] :as params}]
(db/with-atomic [conn pool]
(projects/check-read-permissions! conn profile-id project-id)
(import! (assoc cfg
::input (:path file)
::project-id project-id
::ignore-index-errors? true))))

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.rpc.mutations.demo (ns app.rpc.commands.demo
"A demo specific mutations." "A demo specific mutations."
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
@ -12,7 +12,7 @@
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.rpc.mutations.profile :as profile] [app.rpc.commands.auth :as cmd.auth]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
@ -45,8 +45,8 @@
:hint "Demo users are disabled by config.")) :hint "Demo users are disabled by config."))
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(->> (#'profile/create-profile conn params) (->> (cmd.auth/create-profile conn params)
(#'profile/create-profile-relations conn)) (cmd.auth/create-profile-relations conn))
(with-meta {:email email (with-meta {:email email
:password password} :password password}

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.rpc.commands.ldap
(:require
[app.auth.ldap :as ldap]
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.db :as db]
[app.loggers.audit :as-alias audit]
[app.rpc.commands.auth :as cmd.auth]
[app.rpc.queries.profile :as profile]
[app.util.services :as sv]
[clojure.spec.alpha :as s]))
;; --- COMMAND: login-with-ldap
(declare login-or-register)
(s/def ::email ::us/email)
(s/def ::password ::us/string)
(s/def ::invitation-token ::us/string)
(s/def ::login-with-ldap
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(sv/defmethod ::login-with-ldap {:auth false}
[{:keys [session tokens ldap] :as cfg} params]
(when-not ldap
(ex/raise :type :restriction
:code :ldap-not-initialized
:hide "ldap auth provider is not initialized"))
(let [info (ldap/authenticate ldap params)]
(when-not info
(ex/raise :type :validation
:code :wrong-credentials))
(let [profile (login-or-register cfg info)]
(if-let [token (:invitation-token params)]
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged).
(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 (:props profile)
::audit/profile-id (:id profile)}))
(with-meta profile
{:transform-response ((:create session) (:id profile))
::audit/props (:props profile)
::audit/profile-id (:id profile)})))))
(defn- login-or-register
[{:keys [pool] :as cfg} info]
(db/with-atomic [conn pool]
(or (some->> (:email info)
(profile/retrieve-profile-data-by-email conn)
(profile/populate-additional-data conn)
(profile/decode-profile-row))
(->> (assoc info :is-active true :is-demo false)
(cmd.auth/create-profile conn)
(cmd.auth/create-profile-relations conn)
(profile/strip-private-attrs)))))

View File

@ -26,19 +26,21 @@
(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 ::share-id (s/nilable ::us/uuid))
(s/def ::profile-id ::us/uuid) (s/def ::profile-id ::us/uuid)
(s/def ::position ::gpt/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]
:opt-un [::share-id]))
(sv/defmethod ::create-comment-thread (sv/defmethod ::create-comment-thread
{::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 share-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(files/check-read-permissions! conn profile-id file-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(create-comment-thread conn params))) (create-comment-thread conn params)))
(defn- retrieve-next-seqn (defn- retrieve-next-seqn
@ -92,18 +94,20 @@
;; --- Mutation: Update Comment Thread Status ;; --- Mutation: Update Comment Thread Status
(s/def ::id ::us/uuid) (s/def ::id ::us/uuid)
(s/def ::share-id (s/nilable ::us/uuid))
(s/def ::update-comment-thread-status (s/def ::update-comment-thread-status
(s/keys :req-un [::profile-id ::id])) (s/keys :req-un [::profile-id ::id]
:opt-un [::share-id]))
(sv/defmethod ::update-comment-thread-status (sv/defmethod ::update-comment-thread-status
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id id share-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [cthr (db/get-by-id conn :comment-thread id {:for-update true})] (let [cthr (db/get-by-id conn :comment-thread id {:for-update true})]
(when-not cthr (when-not cthr
(ex/raise :type :not-found)) (ex/raise :type :not-found))
(files/check-read-permissions! conn profile-id (:file-id cthr)) (files/check-comment-permissions! conn profile-id (:file-id cthr) share-id)
(upsert-comment-thread-status! conn profile-id (:id cthr))))) (upsert-comment-thread-status! conn profile-id (:id cthr)))))
(def sql:upsert-comment-thread-status (def sql:upsert-comment-thread-status
@ -122,16 +126,17 @@
(s/def ::is-resolved ::us/boolean) (s/def ::is-resolved ::us/boolean)
(s/def ::update-comment-thread (s/def ::update-comment-thread
(s/keys :req-un [::profile-id ::id ::is-resolved])) (s/keys :req-un [::profile-id ::id ::is-resolved]
:opt-un [::share-id]))
(sv/defmethod ::update-comment-thread (sv/defmethod ::update-comment-thread
[{:keys [pool] :as cfg} {:keys [profile-id id is-resolved] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id id is-resolved share-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [thread (db/get-by-id conn :comment-thread id {:for-update true})] (let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
(when-not thread (when-not thread
(ex/raise :type :not-found)) (ex/raise :type :not-found))
(files/check-read-permissions! conn profile-id (:file-id thread)) (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
(db/update! conn :comment-thread (db/update! conn :comment-thread
{:is-resolved is-resolved} {:is-resolved is-resolved}
@ -142,10 +147,11 @@
;; --- Mutation: Add Comment ;; --- Mutation: Add Comment
(s/def ::add-comment (s/def ::add-comment
(s/keys :req-un [::profile-id ::thread-id ::content])) (s/keys :req-un [::profile-id ::thread-id ::content]
:opt-un [::share-id]))
(sv/defmethod ::add-comment (sv/defmethod ::add-comment
[{:keys [pool] :as cfg} {:keys [profile-id thread-id content] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id thread-id content share-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [thread (-> (db/get-by-id conn :comment-thread thread-id {:for-update true}) (let [thread (-> (db/get-by-id conn :comment-thread thread-id {:for-update true})
(comments/decode-row)) (comments/decode-row))
@ -155,7 +161,7 @@
(when-not thread (ex/raise :type :not-found)) (when-not thread (ex/raise :type :not-found))
;; Permission Checks ;; Permission Checks
(files/check-read-permissions! conn profile-id (:file-id thread)) (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
;; Update the page-name cachedattribute on comment thread table. ;; Update the page-name cachedattribute on comment thread table.
(when (not= pname (:page-name thread)) (when (not= pname (:page-name thread))
@ -199,10 +205,11 @@
;; --- Mutation: Update Comment ;; --- Mutation: Update Comment
(s/def ::update-comment (s/def ::update-comment
(s/keys :req-un [::profile-id ::id ::content])) (s/keys :req-un [::profile-id ::id ::content]
:opt-un [::share-id]))
(sv/defmethod ::update-comment (sv/defmethod ::update-comment
[{:keys [pool] :as cfg} {:keys [profile-id id content] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id id content share-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [comment (db/get-by-id conn :comment id {:for-update true}) (let [comment (db/get-by-id conn :comment id {:for-update true})
_ (when-not comment (ex/raise :type :not-found)) _ (when-not comment (ex/raise :type :not-found))
@ -210,7 +217,7 @@
_ (when-not thread (ex/raise :type :not-found)) _ (when-not thread (ex/raise :type :not-found))
pname (retrieve-page-name conn thread)] pname (retrieve-page-name conn thread)]
(files/check-read-permissions! conn profile-id (:file-id thread)) (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
;; Don't allow edit comments to not owners ;; Don't allow edit comments to not owners
(when-not (= (:owner-id thread) profile-id) (when-not (= (:owner-id thread) profile-id)

View File

@ -6,6 +6,7 @@
(ns app.rpc.mutations.files (ns app.rpc.mutations.files
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.pages :as cp] [app.common.pages :as cp]
[app.common.pages.migrations :as pmg] [app.common.pages.migrations :as pmg]
@ -63,21 +64,23 @@
(db/insert! conn :file-profile-rel)))) (db/insert! conn :file-profile-rel))))
(defn create-file (defn create-file
[conn {:keys [id name project-id is-shared data deleted-at revn] [conn {:keys [id name project-id is-shared data revn
:or {is-shared false modified-at deleted-at ignore-sync-until]
revn 0 :or {is-shared false revn 0}
deleted-at nil}
:as params}] :as params}]
(let [id (or id (:id data) (uuid/next)) (let [id (or id (:id data) (uuid/next))
data (or data (cp/make-file-data id)) data (or data (cp/make-file-data id))
file (db/insert! conn :file file (db/insert! conn :file
{:id id (d/without-nils
:project-id project-id {:id id
:name name :project-id project-id
:revn revn :name name
:is-shared is-shared :revn revn
:data (blob/encode data) :is-shared is-shared
:deleted-at deleted-at})] :data (blob/encode data)
:ignore-sync-until ignore-sync-until
:modified-at modified-at
:deleted-at deleted-at}))]
(->> (assoc params :file-id id :role :owner) (->> (assoc params :file-id id :role :owner)
(create-file-role conn)) (create-file-role conn))

View File

@ -71,9 +71,9 @@
data) data)
(persist-font-object [data mtype] (persist-font-object [data mtype]
(when-let [fdata (get data mtype)] (when-let [resource (get data mtype)]
(p/let [hash (calculate-hash fdata) (p/let [hash (calculate-hash resource)
content (-> (sto/content fdata) content (-> (sto/content resource)
(sto/wrap-with-hash hash))] (sto/wrap-with-hash hash))]
(sto/put-object! storage {::sto/content content (sto/put-object! storage {::sto/content content
::sto/touched-at (dt/now) ::sto/touched-at (dt/now)

View File

@ -1,140 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.rpc.mutations.ldap
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cfg]
[app.db :as db]
[app.loggers.audit :as audit]
[app.rpc.mutations.profile :as profile-m]
[app.rpc.queries.profile :as profile-q]
[app.util.services :as sv]
[clj-ldap.client :as ldap]
[clojure.spec.alpha :as s]
[clojure.string]))
(s/def ::fullname ::us/not-empty-string)
(s/def ::email ::us/email)
(s/def ::backend ::us/not-empty-string)
(s/def ::info-data
(s/keys :req-un [::fullname ::email ::backend]))
(defn ^java.lang.AutoCloseable connect
[]
(let [params {:ssl? (cfg/get :ldap-ssl)
:startTLS? (cfg/get :ldap-starttls)
:bind-dn (cfg/get :ldap-bind-dn)
:password (cfg/get :ldap-bind-password)
:host {:address (cfg/get :ldap-host)
:port (cfg/get :ldap-port)}}]
(try
(ldap/connect params)
(catch Exception e
(ex/raise :type :restriction
:code :ldap-disabled
:hint "ldap disabled or unable to connect"
:cause e)))))
;; --- Mutation: login-with-ldap
(declare authenticate)
(declare login-or-register)
(s/def ::email ::us/email)
(s/def ::password ::us/string)
(s/def ::invitation-token ::us/string)
(s/def ::login-with-ldap
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(sv/defmethod ::login-with-ldap {:auth false}
[{:keys [pool session tokens] :as cfg} params]
(db/with-atomic [conn pool]
(let [info (authenticate params)
cfg (assoc cfg :conn conn)]
(when-not info
(ex/raise :type :validation
:code :wrong-credentials))
(when-not (s/valid? ::info-data info)
(let [explain (s/explain-str ::info-data info)]
(l/warn ::l/raw (str "invalid response from ldap, looks like ldap is not configured correctly\n" explain))
(ex/raise :type :restriction
:code :wrong-ldap-response
:reason explain)))
(let [profile (login-or-register cfg {:email (:email info)
:backend (:backend info)
:fullname (:fullname info)})]
(if-let [token (:invitation-token params)]
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged).
(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 (:props profile)
::audit/profile-id (:id profile)}))
(with-meta profile
{:transform-response ((:create session) (:id profile))
::audit/props (:props profile)
::audit/profile-id (:id profile)}))))))
(defn- replace-several [s & {:as replacements}]
(reduce-kv clojure.string/replace s replacements))
(defn- get-ldap-user
[cpool {:keys [email] :as params}]
(let [query (-> (cfg/get :ldap-user-query)
(replace-several ":username" email))
attrs [(cfg/get :ldap-attrs-username)
(cfg/get :ldap-attrs-email)
(cfg/get :ldap-attrs-photo)
(cfg/get :ldap-attrs-fullname)]
base-dn (cfg/get :ldap-base-dn)
params {:filter query
:sizelimit 1
:attributes attrs}]
(first (ldap/search cpool base-dn params))))
(defn- authenticate
[{:keys [password email] :as params}]
(with-open [conn (connect)]
(when-let [{:keys [dn] :as luser} (get-ldap-user conn params)]
(when (ldap/bind? conn dn password)
{:photo (get luser (keyword (cfg/get :ldap-attrs-photo)))
:fullname (get luser (keyword (cfg/get :ldap-attrs-fullname)))
:email email
:backend "ldap"}))))
(defn- login-or-register
[{:keys [conn] :as cfg} info]
(or (some->> (:email info)
(profile-q/retrieve-profile-data-by-email conn)
(profile-q/populate-additional-data conn)
(profile-q/decode-profile-row))
(let [params (-> info
(assoc :is-active true)
(assoc :is-demo false))]
(->> params
(profile-m/create-profile conn)
(profile-m/create-profile-relations conn)
(profile-q/strip-private-attrs)))))

View File

@ -17,6 +17,8 @@
[app.rpc.queries.teams :as teams] [app.rpc.queries.teams :as teams]
[app.rpc.rlimit :as rlimit] [app.rpc.rlimit :as rlimit]
[app.storage :as sto] [app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.bytes :as bs]
[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]
@ -179,11 +181,12 @@
(* 1024 1024 100)) ; 100MiB (* 1024 1024 100)) ; 100MiB
(defn- create-file-media-object-from-url (defn- create-file-media-object-from-url
[{:keys [storage http-client] :as cfg} {:keys [url name] :as params}] [{:keys [http-client] :as cfg} {:keys [url name] :as params}]
(letfn [(parse-and-validate-size [headers] (letfn [(parse-and-validate-size [headers]
(let [size (some-> (get headers "content-length") d/parse-integer) (let [size (some-> (get headers "content-length") d/parse-integer)
mtype (get headers "content-type") mtype (get headers "content-type")
format (cm/mtype->format mtype)] format (cm/mtype->format mtype)]
(when-not size (when-not size
(ex/raise :type :validation (ex/raise :type :validation
:code :unknown-size :code :unknown-size
@ -203,24 +206,24 @@
:mtype mtype :mtype mtype
:format format})) :format format}))
(get-upload-object [sobj]
(p/let [path (sto/get-object-path storage sobj)
mdata (meta sobj)]
{:filename "tempfile"
:size (:size sobj)
:path path
:mtype (:content-type mdata)}))
(download-media [uri] (download-media [uri]
(p/let [{:keys [body headers]} (http-client {:method :get :uri uri} {:response-type :input-stream}) (-> (http-client {:method :get :uri uri} {:response-type :input-stream})
{:keys [size mtype]} (parse-and-validate-size headers)] (p/then process-response)))
(-> (assoc storage :backend :tmp) (process-response [{:keys [body headers] :as response}]
(sto/put-object! {::sto/content (sto/content body size) (let [{:keys [size mtype]} (parse-and-validate-size headers)
::sto/expired-at (dt/in-future {:minutes 30}) path (tmp/tempfile :prefix "penpot.media.download.")
:content-type mtype written (bs/write-to-file! body path :size size)]
:bucket "file-media-object"})
(p/then get-upload-object))))] (when (not= written size)
(ex/raise :type :internal
:code :mismatch-write-size
:hint "unexpected state: unable to write to file"))
{:filename "tempfile"
:size size
:path path
:mtype mtype}))]
(p/let [content (download-media url)] (p/let [content (download-media url)]
(->> (merge params {:content content :name (or name (:filename content))}) (->> (merge params {:content content :name (or name (:filename content))})

View File

@ -9,19 +9,18 @@
[app.common.data :as d] [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.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.emails :as eml] [app.emails :as eml]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.media :as media] [app.media :as media]
[app.rpc.commands.auth :as cmd.auth]
[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.rpc.rlimit :as rlimit]
[app.storage :as sto] [app.storage :as sto]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[buddy.hashers :as hashers]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[promesa.core :as p] [promesa.core :as p]
@ -37,310 +36,6 @@
(s/def ::password ::us/not-empty-string) (s/def ::password ::us/not-empty-string)
(s/def ::old-password ::us/not-empty-string) (s/def ::old-password ::us/not-empty-string)
(s/def ::theme ::us/string) (s/def ::theme ::us/string)
(s/def ::invitation-token ::us/not-empty-string)
(declare check-profile-existence!)
(declare create-profile)
(declare create-profile-relations)
(declare register-profile)
(defn email-domain-in-whitelist?
"Returns true if email's domain is in the given whitelist or if
given whitelist is an empty string."
[domains email]
(if (or (empty? domains)
(nil? domains))
true
(let [[_ candidate] (-> (str/lower email)
(str/split #"@" 2))]
(contains? domains candidate))))
(def ^:private sql:profile-existence
"select exists (select * from profile
where email = ?
and deleted_at is null) as val")
(defn check-profile-existence!
[conn {:keys [email] :as params}]
(let [email (str/lower email)
result (db/exec-one! conn [sql:profile-existence email])]
(when (:val result)
(ex/raise :type :validation
:code :email-already-exists))
params))
(defn derive-password
[password]
(hashers/derive password
{:alg :argon2id
:memory 16384
:iterations 20
:parallelism 2}))
(defn verify-password
[attempt password]
(try
(hashers/verify attempt password)
(catch Exception _e
{:update false
:valid false})))
(defn decode-profile-row
[{:keys [props] :as profile}]
(cond-> profile
(db/pgobject? props "jsonb")
(assoc :props (db/decode-transit-pgobject props))))
;; --- MUTATION: Prepare Register
(s/def ::prepare-register-profile
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(sv/defmethod ::prepare-register-profile {:auth false}
[{:keys [pool tokens] :as cfg} params]
(when-not (contains? cf/flags :registration)
(if-not (contains? params :invitation-token)
(ex/raise :type :restriction
:code :registration-disabled)
(let [invitation (tokens :verify {:token (:invitation-token params) :iss :team-invitation})]
(when-not (= (:email params) (:member-email invitation))
(ex/raise :type :restriction
:code :email-does-not-match-invitation
:hint "email should match the invitation")))))
(when-let [domains (cf/get :registration-domain-whitelist)]
(when-not (email-domain-in-whitelist? domains (:email params))
(ex/raise :type :validation
:code :email-domain-is-not-allowed)))
;; Don't allow proceed in preparing registration if the profile is
;; already reported as spammer.
(when (eml/has-bounce-reports? pool (:email params))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email has one or many bounces reported"))
(check-profile-existence! pool 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)
:password (:password params)
:invitation-token (:invitation-token params)
:backend "penpot"
:iss :prepared-register
:exp (dt/in-future "48h")}
token (tokens :generate params)]
(with-meta {:token token}
{::audit/profile-id uuid/zero})))
;; --- MUTATION: Register Profile
(s/def ::token ::us/not-empty-string)
(s/def ::register-profile
(s/keys :req-un [::token ::fullname]))
(sv/defmethod ::register-profile
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[{:keys [pool] :as cfg} params]
(db/with-atomic [conn pool]
(-> (assoc cfg :conn conn)
(register-profile params))))
(defn register-profile
[{:keys [conn tokens session] :as cfg} {:keys [token] :as params}]
(let [claims (tokens :verify {:token token :iss :prepared-register})
params (merge params claims)]
(check-profile-existence! conn params)
(let [is-active (or (:is-active params)
(contains? cf/flags :insecure-register))
profile (->> (assoc params :is-active is-active)
(create-profile conn)
(create-profile-relations conn)
(decode-profile-row))
invitation (when-let [token (:invitation-token params)]
(tokens :verify {:token token :iss :team-invitation}))]
(cond
;; If invitation token comes in params, this is because the user comes from team-invitation process;
;; in this case, regenerate token and send back to the user a new invitation token (and mark current
;; session as logged). This happens only if the invitation email matches with the register email.
(and (some? invitation) (= (:email profile) (:member-email invitation)))
(let [claims (assoc invitation :member-id (:id profile))
token (tokens :generate claims)
resp {:invitation-token token}]
(with-meta resp
{:transform-response ((:create session) (:id profile))
::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)}))
;; If auth backend is different from "penpot" means user is
;; registering using third party auth mechanism; in this case
;; we need to mark this session as logged.
(not= "penpot" (:auth-backend profile))
(with-meta (profile/strip-private-attrs profile)
{:transform-response ((:create session) (:id profile))
::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)})
;; If the `:enable-insecure-register` flag is set, we proceed
;; to sign in the user directly, without email verification.
(true? is-active)
(with-meta (profile/strip-private-attrs profile)
{:transform-response ((:create session) (:id profile))
::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)})
;; In all other cases, send a verification email.
:else
(let [vtoken (tokens :generate
{:iss :verify-email
:exp (dt/in-future "48h")
:profile-id (:id profile)
:email (:email profile)})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(eml/send! {::eml/conn conn
::eml/factory eml/register
:public-uri (:public-uri cfg)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken})
(with-meta profile
{::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))))
(defn create-profile
"Create the profile entry on the database with limited input filling
all the other fields with defaults."
[conn params]
(let [id (or (:id params) (uuid/next))
props (-> (audit/extract-utm-params params)
(merge (:props params))
(db/tjson))
password (if-let [password (:password params)]
(derive-password password)
"!")
locale (:locale params)
locale (when (and (string? locale) (not (str/blank? locale)))
locale)
backend (:backend params "penpot")
is-demo (:is-demo params false)
is-muted (:is-muted params false)
is-active (:is-active params false)
email (str/lower (:email params))
params {:id id
:fullname (:fullname params)
:email email
:auth-backend backend
:lang locale
:password password
:deleted-at (:deleted-at params)
:props props
:is-active is-active
:is-muted is-muted
:is-demo is-demo}]
(try
(-> (db/insert! conn :profile params)
(decode-profile-row))
(catch org.postgresql.util.PSQLException e
(let [state (.getSQLState e)]
(if (not= state "23505")
(throw e)
(ex/raise :type :validation
:code :email-already-exists
:cause e)))))))
(defn create-profile-relations
[conn profile]
(let [team (teams/create-team conn {:profile-id (:id profile)
:name "Default"
:is-default true})]
(-> profile
(profile/strip-private-attrs)
(assoc :default-team-id (:id team))
(assoc :default-project-id (:default-project-id team)))))
;; --- MUTATION: Login
(s/def ::email ::us/email)
(s/def ::scope ::us/string)
(s/def ::login
(s/keys :req-un [::email ::password]
:opt-un [::scope ::invitation-token]))
(sv/defmethod ::login
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[{:keys [pool session tokens] :as cfg} {:keys [email password] :as params}]
(when-not (contains? cf/flags :login)
(ex/raise :type :restriction
:code :login-disabled
:hint "login is disabled in this instance"))
(letfn [(check-password [profile password]
(when (= (:password profile) "!")
(ex/raise :type :validation
:code :account-without-password))
(:valid (verify-password password (:password profile))))
(validate-profile [profile]
(when-not (:is-active profile)
(ex/raise :type :validation
:code :wrong-credentials))
(when-not profile
(ex/raise :type :validation
:code :wrong-credentials))
(when-not (check-password profile password)
(ex/raise :type :validation
:code :wrong-credentials))
profile)]
(db/with-atomic [conn pool]
(let [profile (->> (profile/retrieve-profile-data-by-email conn email)
(validate-profile)
(profile/strip-private-attrs)
(profile/populate-additional-data conn)
(decode-profile-row))
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))
::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})))))
;; --- MUTATION: Logout
(s/def ::logout
(s/keys :opt-un [::profile-id]))
(sv/defmethod ::logout {:auth false}
[{:keys [session] :as cfg} _]
(with-meta {}
{:transform-response (:delete session)}))
;; --- MUTATION: Update Profile (own) ;; --- MUTATION: Update Profile (own)
@ -414,7 +109,7 @@
(defn- validate-password! (defn- validate-password!
[conn {:keys [profile-id old-password] :as params}] [conn {:keys [profile-id old-password] :as params}]
(let [profile (db/get-by-id conn :profile profile-id)] (let [profile (db/get-by-id conn :profile profile-id)]
(when-not (:valid (verify-password old-password (:password profile))) (when-not (:valid (cmd.auth/verify-password old-password (:password profile)))
(ex/raise :type :validation (ex/raise :type :validation
:code :old-password-not-match)) :code :old-password-not-match))
profile)) profile))
@ -422,7 +117,7 @@
(defn update-profile-password! (defn update-profile-password!
[conn {:keys [id password] :as profile}] [conn {:keys [id password] :as profile}]
(db/update! conn :profile (db/update! conn :profile
{:password (derive-password password)} {:password (cmd.auth/derive-password password)}
{:id id})) {:id id}))
;; --- MUTATION: Update Photo ;; --- MUTATION: Update Photo
@ -481,7 +176,7 @@
(defn- change-email-immediately (defn- change-email-immediately
[{:keys [conn]} {:keys [profile email] :as params}] [{:keys [conn]} {:keys [profile email] :as params}]
(when (not= email (:email profile)) (when (not= email (:email profile))
(check-profile-existence! conn params)) (cmd.auth/check-profile-existence! conn params))
(db/update! conn :profile (db/update! conn :profile
{:email email} {:email email}
{:id (:id profile)}) {:id (:id profile)})
@ -499,7 +194,7 @@
:profile-id (:id profile)})] :profile-id (:id profile)})]
(when (not= email (:email profile)) (when (not= email (:email profile))
(check-profile-existence! conn params)) (cmd.auth/check-profile-existence! conn params))
(when-not (eml/allow-send-emails? conn profile) (when-not (eml/allow-send-emails? conn profile)
(ex/raise :type :validation (ex/raise :type :validation
@ -526,76 +221,6 @@
[conn id] [conn id]
(db/get-by-id conn :profile id {:for-update true})) (db/get-by-id conn :profile id {:for-update true}))
;; --- MUTATION: Request Profile Recovery
(s/def ::request-profile-recovery
(s/keys :req-un [::email]))
(sv/defmethod ::request-profile-recovery {:auth false}
[{:keys [pool tokens] :as cfg} {:keys [email] :as params}]
(letfn [(create-recovery-token [{:keys [id] :as profile}]
(let [token (tokens :generate
{:iss :password-recovery
:exp (dt/in-future "15m")
:profile-id id})]
(assoc profile :token token)))
(send-email-notification [conn profile]
(let [ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(eml/send! {::eml/conn conn
::eml/factory eml/password-recovery
:public-uri (:public-uri cfg)
:to (:email profile)
:token (:token profile)
:name (:fullname profile)
:extra-data ptoken})
nil))]
(db/with-atomic [conn pool]
(when-let [profile (profile/retrieve-profile-data-by-email conn email)]
(when-not (eml/allow-send-emails? conn profile)
(ex/raise :type :validation
:code :profile-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
(when-not (:is-active profile)
(ex/raise :type :validation
:code :profile-not-verified
:hint "the user need to validate profile before recover password"))
(when (eml/has-bounce-reports? conn (:email profile))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
(->> profile
(create-recovery-token)
(send-email-notification conn))))))
;; --- MUTATION: Recover Profile
(s/def ::token ::us/not-empty-string)
(s/def ::recover-profile
(s/keys :req-un [::token ::password]))
(sv/defmethod ::recover-profile
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[{:keys [pool tokens] :as cfg} {:keys [token password]}]
(letfn [(validate-token [token]
(let [tdata (tokens :verify {:token token :iss :password-recovery})]
(:profile-id tdata)))
(update-password [conn profile-id]
(let [pwd (derive-password password)]
(db/update! conn :profile {:password pwd} {:id profile-id})))]
(db/with-atomic [conn pool]
(->> (validate-token token)
(update-password conn))
nil)))
;; --- MUTATION: Update Profile Props ;; --- MUTATION: Update Profile Props
@ -668,3 +293,61 @@
:code :owner-teams-with-people :code :owner-teams-with-people
:hint "The user need to transfer ownership of owned teams." :hint "The user need to transfer ownership of owned teams."
:context {:teams (mapv :team-id rows)})))) :context {:teams (mapv :team-id rows)}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; DEPRECATED METHODS (TO BE REMOVED ON 1.16.x)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- MUTATION: Login
(s/def ::login ::cmd.auth/login-with-password)
(sv/defmethod ::login
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[cfg params]
(cmd.auth/login-with-password cfg params))
;; --- MUTATION: Logout
(s/def ::logout ::cmd.auth/logout)
(sv/defmethod ::logout {:auth false}
[{:keys [session] :as cfg} _]
(with-meta {}
{:transform-response (:delete session)}))
;; --- MUTATION: Recover Profile
(s/def ::recover-profile ::cmd.auth/recover-profile)
(sv/defmethod ::recover-profile
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[cfg params]
(cmd.auth/recover-profile cfg params))
;; --- MUTATION: Prepare Register
(s/def ::prepare-register-profile ::cmd.auth/prepare-register-profile)
(sv/defmethod ::prepare-register-profile {:auth false}
[cfg params]
(cmd.auth/prepare-register cfg params))
;; --- MUTATION: Register Profile
(s/def ::register-profile ::cmd.auth/register-profile)
(sv/defmethod ::register-profile
{:auth false ::rlimit/permits (cf/get :rlimit-password)}
[{:keys [pool] :as cfg} params]
(db/with-atomic [conn pool]
(-> (assoc cfg :conn conn)
(cmd.auth/register-profile params))))
;; --- MUTATION: Request Profile Recovery
(s/def ::request-profile-recovery ::cmd.auth/request-profile-recovery)
(sv/defmethod ::request-profile-recovery {:auth false}
[cfg params]
(cmd.auth/request-profile-recovery cfg params))

View File

@ -19,7 +19,8 @@
(s/def ::id ::us/uuid) (s/def ::id ::us/uuid)
(s/def ::profile-id ::us/uuid) (s/def ::profile-id ::us/uuid)
(s/def ::file-id ::us/uuid) (s/def ::file-id ::us/uuid)
(s/def ::flags (s/every ::us/string :kind set?)) (s/def ::who-comment ::us/string)
(s/def ::who-inspect ::us/string)
(s/def ::pages (s/every ::us/uuid :kind set?)) (s/def ::pages (s/every ::us/uuid :kind set?))
;; --- Mutation: Create Share Link ;; --- Mutation: Create Share Link
@ -27,14 +28,13 @@
(declare create-share-link) (declare create-share-link)
(s/def ::create-share-link (s/def ::create-share-link
(s/keys :req-un [::profile-id ::file-id ::flags] (s/keys :req-un [::profile-id ::file-id ::who-comment ::who-inspect ::pages]))
:opt-un [::pages]))
(sv/defmethod ::create-share-link (sv/defmethod ::create-share-link
"Creates a share-link object. "Creates a share-link object.
Share links are resources that allows external users access to Share links are resources that allows external users access to specific
specific files with specific permissions (flags)." pages of a file with specific permissions (who-comment and who-inspect)."
[{: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]
@ -42,19 +42,17 @@
(create-share-link conn params))) (create-share-link conn params)))
(defn create-share-link (defn create-share-link
[conn {:keys [profile-id file-id pages flags]}] [conn {:keys [profile-id file-id pages who-comment who-inspect]}]
(let [pages (db/create-array conn "uuid" pages) (let [pages (db/create-array conn "uuid" pages)
flags (->> (map name flags)
(db/create-array conn "text"))
slink (db/insert! conn :share-link slink (db/insert! conn :share-link
{:id (uuid/next) {:id (uuid/next)
:file-id file-id :file-id file-id
:flags flags :who-comment who-comment
:who-inspect who-inspect
:pages pages :pages pages
:owner-id profile-id})] :owner-id profile-id})]
(-> slink (-> slink
(update :pages db/decode-pgarray #{}) (update :pages db/decode-pgarray #{}))))
(update :flags db/decode-pgarray #{}))))
;; --- Mutation: Delete Share Link ;; --- Mutation: Delete Share Link

View File

@ -53,6 +53,16 @@
([perms] (:can-read perms)) ([perms] (:can-read perms))
([conn & args] (check (apply qfn conn args))))) ([conn & args] (check (apply qfn conn args)))))
(defn make-comment-predicate-fn
"A simple factory for comment permission predicate functions."
[qfn]
(us/assert fn? qfn)
(fn check
([perms]
(and (:is-logged perms) (= (:who-comment perms) "all")))
([conn & args]
(check (apply qfn conn args)))))
(defn make-check-fn (defn make-check-fn
"Helper that converts a predicate permission function to a check "Helper that converts a predicate permission function to a check
function (function that raises an exception)." function (function that raises an exception)."

View File

@ -25,16 +25,16 @@
(s/def ::team-id ::us/uuid) (s/def ::team-id ::us/uuid)
(s/def ::file-id ::us/uuid) (s/def ::file-id ::us/uuid)
(s/def ::share-id (s/nilable ::us/uuid))
(s/def ::comment-threads (s/def ::comment-threads
(s/and (s/keys :req-un [::profile-id] (s/and (s/keys :req-un [::profile-id]
:opt-un [::file-id ::team-id]) :opt-un [::file-id ::share-id ::team-id])
#(or (:file-id %) (:team-id %)))) #(or (:file-id %) (:team-id %))))
(sv/defmethod ::comment-threads (sv/defmethod ::comment-threads
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}] [{:keys [pool] :as cfg} params]
(with-open [conn (db/open pool)] (with-open [conn (db/open pool)]
(files/check-read-permissions! conn profile-id file-id)
(retrieve-comment-threads conn params))) (retrieve-comment-threads conn params)))
(def sql:comment-threads (def sql:comment-threads
@ -60,8 +60,8 @@
window w as (partition by c.thread_id order by c.created_at asc)") window w as (partition by c.thread_id order by c.created_at asc)")
(defn- retrieve-comment-threads (defn- retrieve-comment-threads
[conn {:keys [profile-id file-id]}] [conn {:keys [profile-id file-id share-id]}]
(files/check-read-permissions! conn profile-id file-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(->> (db/exec! conn [sql:comment-threads profile-id file-id]) (->> (db/exec! conn [sql:comment-threads profile-id file-id])
(into [] (map decode-row)))) (into [] (map decode-row))))
@ -116,13 +116,15 @@
;; --- Query: Single Comment Thread ;; --- Query: Single Comment Thread
(s/def ::id ::us/uuid) (s/def ::id ::us/uuid)
(s/def ::share-id (s/nilable ::us/uuid))
(s/def ::comment-thread (s/def ::comment-thread
(s/keys :req-un [::profile-id ::file-id ::id])) (s/keys :req-un [::profile-id ::file-id ::id]
:opt-un [::share-id]))
(sv/defmethod ::comment-thread (sv/defmethod ::comment-thread
[{:keys [pool] :as cfg} {:keys [profile-id file-id id] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id file-id id share-id] :as params}]
(with-open [conn (db/open pool)] (with-open [conn (db/open pool)]
(files/check-read-permissions! conn profile-id file-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(let [sql (str "with threads as (" sql:comment-threads ")" (let [sql (str "with threads as (" sql:comment-threads ")"
"select * from threads where id = ?")] "select * from threads where id = ?")]
(-> (db/exec-one! conn [sql profile-id file-id id]) (-> (db/exec-one! conn [sql profile-id file-id id])
@ -133,15 +135,17 @@
(declare retrieve-comments) (declare retrieve-comments)
(s/def ::file-id ::us/uuid) (s/def ::file-id ::us/uuid)
(s/def ::share-id (s/nilable ::us/uuid))
(s/def ::thread-id ::us/uuid) (s/def ::thread-id ::us/uuid)
(s/def ::comments (s/def ::comments
(s/keys :req-un [::profile-id ::thread-id])) (s/keys :req-un [::profile-id ::thread-id]
:opt-un [::share-id]))
(sv/defmethod ::comments (sv/defmethod ::comments
[{:keys [pool] :as cfg} {:keys [profile-id thread-id] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id thread-id share-id] :as params}]
(with-open [conn (db/open pool)] (with-open [conn (db/open pool)]
(let [thread (db/get-by-id conn :comment-thread thread-id)] (let [thread (db/get-by-id conn :comment-thread thread-id)]
(files/check-read-permissions! conn profile-id (:file-id thread)) (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
(retrieve-comments conn thread-id)))) (retrieve-comments conn thread-id))))
(def sql:comments (def sql:comments
@ -153,3 +157,40 @@
[conn thread-id] [conn thread-id]
(->> (db/exec! conn [sql:comments thread-id]) (->> (db/exec! conn [sql:comments thread-id])
(into [] (map decode-row)))) (into [] (map decode-row))))
;; file-comments-users
(declare retrieve-file-comments-users)
(s/def ::file-id ::us/uuid)
(s/def ::share-id (s/nilable ::us/uuid))
(s/def ::file-comments-users
(s/keys :req-un [::profile-id ::file-id]
:opt-un [::share-id]))
(sv/defmethod ::file-comments-users
[{:keys [pool] :as cfg} {:keys [profile-id file-id share-id]}]
(with-open [conn (db/open pool)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(retrieve-file-comments-users conn file-id profile-id)))
(def sql:file-comment-users
"select p.id,
p.email,
p.fullname as name,
p.fullname as fullname,
p.photo_id,
p.is_active
from profile p
where p.id in
(select owner_id from comment
where thread_id in
(select id from comment_thread
where file_id=?))
or p.id=?
") ;; all the users that had comment the file, plus the current user
(defn retrieve-file-comments-users
[conn file-id profile-id]
(db/exec! conn [sql:file-comment-users file-id profile-id]))

View File

@ -9,6 +9,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.geom.shapes :as gsh]
[app.common.pages.helpers :as cph] [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]
@ -84,7 +85,8 @@
:is-owner is-owner :is-owner is-owner
:is-admin (or is-owner is-admin) :is-admin (or is-owner is-admin)
:can-edit (or is-owner is-admin can-edit) :can-edit (or is-owner is-admin can-edit)
:can-read true}))) :can-read true
:is-logged (some? profile-id)})))
([conn profile-id file-id share-id] ([conn profile-id file-id share-id]
(let [perms (get-permissions conn profile-id file-id) (let [perms (get-permissions conn profile-id file-id)
ldata (retrieve-share-link conn file-id share-id)] ldata (retrieve-share-link conn file-id share-id)]
@ -97,7 +99,9 @@
(some? perms) perms (some? perms) perms
(some? ldata) {:type :share-link (some? ldata) {:type :share-link
:can-read true :can-read true
:flags (:flags ldata)})))) :is-logged (some? profile-id)
:who-comment (:who-comment ldata)
:who-inspect (:who-inspect ldata)}))))
(def has-edit-permissions? (def has-edit-permissions?
(perms/make-edition-predicate-fn get-permissions)) (perms/make-edition-predicate-fn get-permissions))
@ -105,12 +109,26 @@
(def has-read-permissions? (def has-read-permissions?
(perms/make-read-predicate-fn get-permissions)) (perms/make-read-predicate-fn get-permissions))
(def has-comment-permissions?
(perms/make-comment-predicate-fn get-permissions))
(def check-edition-permissions! (def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?)) (perms/make-check-fn has-edit-permissions?))
(def check-read-permissions! (def check-read-permissions!
(perms/make-check-fn has-read-permissions?)) (perms/make-check-fn has-read-permissions?))
;; A user has comment permissions if she has read permissions, or comment permissions
(defn check-comment-permissions!
[conn profile-id file-id share-id]
(let [can-read (has-read-permissions? conn profile-id file-id)
can-comment (has-comment-permissions? conn profile-id file-id share-id)
]
(when-not (or can-read can-comment)
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))))
;; --- Query: Files search ;; --- Query: Files search
;; TODO: this query need to a good refactor ;; TODO: this query need to a good refactor
@ -289,7 +307,7 @@
frame (-> page :objects cph/get-frames)] frame (-> page :objects cph/get-frames)]
(assoc frame :page-id (:id page))))) (assoc frame :page-id (:id page)))))
;; function responsible to filter objects data strucuture of ;; function responsible to filter objects data structure of
;; all unneded shapes if a concrete frame is provided. If no ;; all unneded shapes if a concrete frame is provided. If no
;; frame, the objects is returned untouched. ;; frame, the objects is returned untouched.
(filter-objects [objects frame-id] (filter-objects [objects frame-id]
@ -307,10 +325,24 @@
object-id (str page-id frame-id) object-id (str page-id frame-id)
frame (if-let [thumb (get thumbnails object-id)] frame (if-let [thumb (get thumbnails object-id)]
(assoc frame :thumbnail thumb :shapes []) (assoc frame :thumbnail thumb :shapes [])
(dissoc frame :thumbnail))] (dissoc frame :thumbnail))
children-ids
(cph/get-children-ids objects frame-id)
bounds
(when (:show-content frame)
(gsh/selection-rect (concat [frame] (->> children-ids (map (d/getf objects))))))
frame
(cond-> frame
(some? bounds)
(assoc :children-bounds bounds))]
(if (:thumbnail frame) (if (:thumbnail frame)
(recur (-> (assoc objects frame-id frame) (recur (-> objects
(d/without-keys (cph/get-children-ids objects frame-id))) (assoc frame-id frame)
(d/without-keys children-ids))
(rest frames)) (rest frames))
(recur (assoc objects frame-id frame) (recur (assoc objects frame-id frame)
(rest frames)))) (rest frames))))

View File

@ -11,7 +11,6 @@
(defn decode-share-link-row (defn decode-share-link-row
[row] [row]
(-> row (-> row
(update :flags db/decode-pgarray #{})
(update :pages db/decode-pgarray #{}))) (update :pages db/decode-pgarray #{})))
(defn retrieve-share-link (defn retrieve-share-link

View File

@ -9,9 +9,9 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[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.files :as files] [app.rpc.queries.files :as files]
[app.rpc.queries.share-link :as slnk] [app.rpc.queries.share-link :as slnk]
[app.rpc.queries.teams :as teams]
[app.util.services :as sv] [app.util.services :as sv]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[promesa.core :as p])) [promesa.core :as p]))
@ -23,11 +23,11 @@
(db/get-by-id pool :project id {:columns [:id :name :team-id]})) (db/get-by-id pool :project id {:columns [:id :name :team-id]}))
(defn- retrieve-bundle (defn- retrieve-bundle
[{:keys [pool] :as cfg} file-id] [{:keys [pool] :as cfg} file-id profile-id]
(p/let [file (files/retrieve-file cfg file-id) (p/let [file (files/retrieve-file cfg file-id)
project (retrieve-project pool (:project-id file)) project (retrieve-project pool (:project-id file))
libs (files/retrieve-file-libraries cfg false file-id) libs (files/retrieve-file-libraries cfg false file-id)
users (teams/retrieve-users pool (:team-id project)) users (comments/retrieve-file-comments-users pool file-id profile-id)
links (->> (db/query pool :share-link {:file-id file-id}) links (->> (db/query pool :share-link {:file-id file-id})
(mapv slnk/decode-share-link-row)) (mapv slnk/decode-share-link-row))
@ -54,7 +54,7 @@
(p/let [slink (slnk/retrieve-share-link pool file-id share-id) (p/let [slink (slnk/retrieve-share-link pool file-id share-id)
perms (files/get-permissions pool profile-id file-id share-id) perms (files/get-permissions pool profile-id file-id share-id)
thumbs (files/retrieve-object-thumbnails cfg file-id) thumbs (files/retrieve-object-thumbnails cfg file-id)
bundle (p/-> (retrieve-bundle cfg file-id) bundle (p/-> (retrieve-bundle cfg file-id profile-id)
(assoc :permissions perms) (assoc :permissions perms)
(assoc-in [:file :thumbnails] thumbs))] (assoc-in [:file :thumbnails] thumbs))]

View File

@ -3,7 +3,7 @@
(:require (:require
[app.db :as db] [app.db :as db]
[app.config :as cfg] [app.config :as cfg]
[app.rpc.mutations.profile :refer [derive-password]] [app.rpc.commands.auth :refer [derive-password]]
[app.main :refer [system]])) [app.main :refer [system]]))
(defn reset-passwords (defn reset-passwords

View File

@ -7,7 +7,6 @@
[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.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]

View File

@ -14,7 +14,6 @@
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.storage.db :as sdb]
[app.storage.fs :as sfs] [app.storage.fs :as sfs]
[app.storage.impl :as impl] [app.storage.impl :as impl]
[app.storage.s3 :as ss3] [app.storage.s3 :as ss3]
@ -32,14 +31,12 @@
(s/def ::s3 ::ss3/backend) (s/def ::s3 ::ss3/backend)
(s/def ::fs ::sfs/backend) (s/def ::fs ::sfs/backend)
(s/def ::db ::sdb/backend)
(s/def ::backends (s/def ::backends
(s/map-of ::us/keyword (s/map-of ::us/keyword
(s/nilable (s/nilable
(s/or :s3 ::ss3/backend (s/or :s3 ::ss3/backend
:fs ::sfs/backend :fs ::sfs/backend))))
:db ::sdb/backend))))
(defmethod ig/pre-init-spec ::storage [_] (defmethod ig/pre-init-spec ::storage [_]
(s/keys :req-un [::db/pool ::wrk/executor ::backends])) (s/keys :req-un [::db/pool ::wrk/executor ::backends]))
@ -84,13 +81,14 @@
" and backend = ?" " and backend = ?"
" and deleted_at is null" " and deleted_at is null"
" limit 1")] " limit 1")]
(db/exec-one! conn [sql hash bucket (name backend)]))) (some-> (db/exec-one! conn [sql hash bucket (name backend)])
(update :metadata db/decode-transit-pgobject))))
(defn- create-database-object (defn- create-database-object
[{:keys [conn backend executor]} {:keys [::content ::expired-at ::touched-at] :as params}] [{:keys [conn backend executor]} {:keys [::content ::expired-at ::touched-at] :as params}]
(us/assert ::storage-content content) (us/assert ::storage-content content)
(px/with-dispatch executor (px/with-dispatch executor
(let [id (uuid/random) (let [id (uuid/next)
mdata (cond-> (get-metadata params) mdata (cond-> (get-metadata params)
(satisfies? impl/IContentHash content) (satisfies? impl/IContentHash content)
@ -106,13 +104,15 @@
(get-database-object-by-hash conn backend (:bucket mdata) (:hash mdata))) (get-database-object-by-hash conn backend (:bucket mdata) (:hash mdata)))
result (or result result (or result
(db/insert! conn :storage-object (-> (db/insert! conn :storage-object
{:id id {:id id
:size (count content) :size (impl/get-size content)
:backend (name backend) :backend (name backend)
:metadata (db/tjson mdata) :metadata (db/tjson mdata)
:deleted-at expired-at :deleted-at expired-at
:touched-at touched-at}))] :touched-at touched-at})
(update :metadata db/decode-transit-pgobject)
(update :metadata assoc ::created? true)))]
(StorageObject. (:id result) (StorageObject. (:id result)
(:size result) (:size result)
@ -120,7 +120,7 @@
(:deleted-at result) (:deleted-at result)
(:touched-at result) (:touched-at result)
backend backend
mdata (:metadata result)
nil)))) nil))))
(def ^:private sql:retrieve-storage-object (def ^:private sql:retrieve-storage-object
@ -173,9 +173,10 @@
(p/let [storage (assoc storage :conn (or conn pool)) (p/let [storage (assoc storage :conn (or conn pool))
object (create-database-object storage params)] object (create-database-object storage params)]
;; Store the data finally on the underlying storage subsystem. (when (::created? (meta object))
(-> (impl/resolve-backend storage backend) ;; Store the data finally on the underlying storage subsystem.
(impl/put-object object content)) (-> (impl/resolve-backend storage backend)
(impl/put-object object content)))
object)) object))
@ -259,7 +260,8 @@
;; A task responsible to permanently delete already marked as deleted ;; A task responsible to permanently delete already marked as deleted
;; storage files. The storage objects are practically never marked to ;; storage files. The storage objects are practically never marked to
;; be deleted directly by the api call. The touched-gc is responsible ;; be deleted directly by the api call. The touched-gc is responsible
;; of collecting the usage of the object and mark it as deleted. ;; of collecting the usage of the object and mark it as deleted. Only
;; the TMP files are are created with expiration date in future.
(declare sql:retrieve-deleted-objects-chunk) (declare sql:retrieve-deleted-objects-chunk)

View File

@ -1,67 +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.storage.db
(:require
[app.common.spec :as us]
[app.db :as db]
[app.storage.impl :as impl]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.exec :as px])
(:import
java.io.ByteArrayInputStream))
;; --- BACKEND INIT
(defmethod ig/pre-init-spec ::backend [_]
(s/keys :opt-un [::db/pool]))
(defmethod ig/init-key ::backend
[_ cfg]
(assoc cfg :type :db))
(s/def ::type ::us/keyword)
(s/def ::backend
(s/keys :req-un [::type ::db/pool]))
;; --- API IMPL
(defmethod impl/put-object :db
[{:keys [conn executor] :as storage} {:keys [id] :as object} content]
(px/with-dispatch executor
(let [data (impl/slurp-bytes content)]
(db/insert! conn :storage-data {:id id :data data})
object)))
(defmethod impl/get-object-data :db
[{:keys [conn executor] :as backend} {:keys [id] :as object}]
(px/with-dispatch executor
(let [result (db/exec-one! conn ["select data from storage_data where id=?" id])]
(ByteArrayInputStream. (:data result)))))
(defmethod impl/get-object-bytes :db
[{:keys [conn executor] :as backend} {:keys [id] :as object}]
(px/with-dispatch executor
(let [result (db/exec-one! conn ["select data from storage_data where id=?" id])]
(:data result))))
(defmethod impl/get-object-url :db
[_ _]
(throw (UnsupportedOperationException. "not supported")))
(defmethod impl/del-object :db
[_ _]
;; NOOP: because deleting the row already deletes the file data from
;; the database.
nil)
(defmethod impl/del-objects-in-bulk :db
[_ _]
;; NOOP: because deleting the row already deletes the file data from
;; the database.
nil)

View File

@ -10,11 +10,13 @@
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uri :as u] [app.common.uri :as u]
[app.storage.impl :as impl] [app.storage.impl :as impl]
[app.util.bytes :as bs]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[datoteka.core :as fs] [datoteka.core :as fs]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]) [promesa.exec :as px])
(:import (:import
java.io.InputStream java.io.InputStream
@ -72,9 +74,10 @@
(io/input-stream full)))) (io/input-stream full))))
(defmethod impl/get-object-bytes :fs (defmethod impl/get-object-bytes :fs
[{:keys [executor] :as backend} object] [backend object]
(px/with-dispatch executor (p/let [input (impl/get-object-data backend object)]
(fs/slurp-bytes (impl/get-object-data backend object)))) (ex/with-always (bs/close! input)
(bs/read-as-bytes input))))
(defmethod impl/get-object-url :fs (defmethod impl/get-object-url :fs
[{:keys [uri executor] :as backend} {:keys [id] :as object} _] [{:keys [uri executor] :as backend} {:keys [id] :as object} _]

View File

@ -9,18 +9,15 @@
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.uuid :as uuid] [app.util.bytes :as bs]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
[buddy.core.hash :as bh] [buddy.core.hash :as bh]
[clojure.java.io :as io]) [clojure.java.io :as io])
(:import (:import
java.nio.ByteBuffer java.nio.ByteBuffer
java.util.UUID
java.io.ByteArrayInputStream
java.io.InputStream
java.nio.file.Files java.nio.file.Files
org.apache.commons.io.input.BoundedInputStream java.nio.file.Path
)) java.util.UUID))
;; --- API Definition ;; --- API Definition
@ -95,23 +92,23 @@
(defn coerce-id (defn coerce-id
[id] [id]
(cond (cond
(string? id) (uuid/uuid id) (string? id) (parse-uuid id)
(uuid? id) id (uuid? id) id
:else (ex/raise :type :internal :else (ex/raise :type :internal
:code :invalid-id-type :code :invalid-id-type
:hint "id should be string or uuid"))) :hint "id should be string or uuid")))
(defprotocol IContentObject (defprotocol IContentObject
(size [_] "get object size")) (get-size [_] "get object size"))
(defprotocol IContentHash (defprotocol IContentHash
(get-hash [_] "get precalculated hash")) (get-hash [_] "get precalculated hash"))
(defn- make-content (defn- path->content
[^InputStream is ^long size] [^Path path ^long size]
(reify (reify
IContentObject IContentObject
(size [_] size) (get-size [_] size)
io/IOFactory io/IOFactory
(make-reader [this opts] (make-reader [this opts]
@ -119,47 +116,53 @@
(make-writer [_ _] (make-writer [_ _]
(throw (UnsupportedOperationException. "not implemented"))) (throw (UnsupportedOperationException. "not implemented")))
(make-input-stream [_ _] (make-input-stream [_ _]
(doto (BoundedInputStream. is size) (-> (io/input-stream path)
(.setPropagateClose false))) (bs/bounded-input-stream size)))
(make-output-stream [_ _] (make-output-stream [_ _]
(throw (UnsupportedOperationException. "not implemented")))))
(defn- bytes->content
[^bytes data ^long size]
(reify
IContentObject
(get-size [_] size)
io/IOFactory
(make-reader [this opts]
(io/make-reader this opts))
(make-writer [_ _]
(throw (UnsupportedOperationException. "not implemented"))) (throw (UnsupportedOperationException. "not implemented")))
(make-input-stream [_ _]
clojure.lang.Counted (-> (bs/bytes-input-stream data)
(count [_] size) (bs/bounded-input-stream size)))
(make-output-stream [_ _]
java.lang.AutoCloseable (throw (UnsupportedOperationException. "not implemented")))))
(close [_]
(.close is))))
(defn content (defn content
([data] (content data nil)) ([data] (content data nil))
([data size] ([data size]
(cond (cond
(instance? java.nio.file.Path data) (instance? java.nio.file.Path data)
(make-content (io/input-stream data) (path->content data (or size (Files/size data)))
(Files/size data))
(instance? java.io.File data) (instance? java.io.File data)
(content (.toPath ^java.io.File data) nil) (content (.toPath ^java.io.File data) size)
(instance? String data) (instance? String data)
(let [data (.getBytes data "UTF-8") (let [data (.getBytes data "UTF-8")]
bais (ByteArrayInputStream. ^bytes data)] (bytes->content data (alength data)))
(make-content bais (alength data)))
(bytes? data) (bytes? data)
(let [size (alength ^bytes data) (bytes->content data (or size (alength ^bytes data)))
bais (ByteArrayInputStream. ^bytes data)]
(make-content bais size))
(instance? InputStream data) ;; (instance? InputStream data)
(do ;; (do
(when-not size ;; (when-not size
(throw (UnsupportedOperationException. "size should be provided on InputStream"))) ;; (throw (UnsupportedOperationException. "size should be provided on InputStream")))
(make-content data size)) ;; (make-content data size))
:else :else
(throw (UnsupportedOperationException. "type not supported"))))) (throw (IllegalArgumentException. "invalid argument type")))))
(defn wrap-with-hash (defn wrap-with-hash
[content ^String hash] [content ^String hash]
@ -171,7 +174,7 @@
(reify (reify
IContentObject IContentObject
(size [_] (size content)) (get-size [_] (get-size content))
IContentHash IContentHash
(get-hash [_] hash) (get-hash [_] hash)
@ -184,43 +187,17 @@
(make-input-stream [_ opts] (make-input-stream [_ opts]
(io/make-input-stream content opts)) (io/make-input-stream content opts))
(make-output-stream [_ opts] (make-output-stream [_ opts]
(io/make-output-stream content opts)) (io/make-output-stream content opts))))
clojure.lang.Counted
(count [_] (count content))
java.lang.AutoCloseable
(close [_]
(.close ^java.lang.AutoCloseable content))))
(defn content? (defn content?
[v] [v]
(satisfies? IContentObject v)) (satisfies? IContentObject v))
(defn slurp-bytes
[content]
(with-open [input (io/input-stream content)
output (java.io.ByteArrayOutputStream. (count content))]
(io/copy input output)
(.toByteArray output)))
(defn calculate-hash (defn calculate-hash
[path-or-stream] [resource]
(let [result (cond (let [result (with-open [input (io/input-stream resource)]
(instance? InputStream path-or-stream) (-> (bh/blake2b-256 input)
(let [result (-> (bh/blake2b-256 path-or-stream) (bc/bytes->hex)))]
(bc/bytes->hex))]
(.reset path-or-stream)
result)
(string? path-or-stream)
(-> (bh/blake2b-256 path-or-stream)
(bc/bytes->hex))
:else
(with-open [is (io/input-stream path-or-stream)]
(-> (bh/blake2b-256 is)
(bc/bytes->hex))))]
(str "blake2b:" result))) (str "blake2b:" result)))
(defn resolve-backend (defn resolve-backend

View File

@ -12,14 +12,17 @@
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uri :as u] [app.common.uri :as u]
[app.storage.impl :as impl] [app.storage.impl :as impl]
[app.storage.tmp :as tmp]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk] [app.worker :as wrk]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[datoteka.core :as fs]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.core :as p] [promesa.core :as p]
[promesa.exec :as px]) [promesa.exec :as px])
(:import (:import
java.io.FilterInputStream
java.io.InputStream java.io.InputStream
java.nio.ByteBuffer java.nio.ByteBuffer
java.time.Duration java.time.Duration
@ -30,6 +33,7 @@
org.reactivestreams.Subscription org.reactivestreams.Subscription
software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.core.ResponseBytes
software.amazon.awssdk.core.async.AsyncRequestBody software.amazon.awssdk.core.async.AsyncRequestBody
software.amazon.awssdk.core.async.AsyncResponseTransformer
software.amazon.awssdk.core.client.config.ClientAsyncConfiguration software.amazon.awssdk.core.client.config.ClientAsyncConfiguration
software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption
software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
@ -68,9 +72,10 @@
(s/keys :opt-un [::region ::bucket ::prefix ::endpoint ::wrk/executor])) (s/keys :opt-un [::region ::bucket ::prefix ::endpoint ::wrk/executor]))
(defmethod ig/prep-key ::backend (defmethod ig/prep-key ::backend
[_ {:keys [prefix] :as cfg}] [_ {:keys [prefix region] :as cfg}]
(cond-> (d/without-nils cfg) (cond-> (d/without-nils cfg)
prefix (assoc :prefix prefix))) (some? prefix) (assoc :prefix prefix)
(nil? region) (assoc :region :eu-central-1)))
(defmethod ig/init-key ::backend (defmethod ig/init-key ::backend
[_ cfg] [_ cfg]
@ -106,7 +111,16 @@
(defmethod impl/get-object-data :s3 (defmethod impl/get-object-data :s3
[backend object] [backend object]
(get-object-data backend object)) (letfn [(no-such-key? [cause]
(instance? software.amazon.awssdk.services.s3.model.NoSuchKeyException cause))
(handle-not-found [cause]
(ex/raise :type :not-found
:code :object-not-found
:hint "s3 object not found"
:cause cause))]
(-> (get-object-data backend object)
(p/catch no-such-key? handle-not-found))))
(defmethod impl/get-object-bytes :s3 (defmethod impl/get-object-bytes :s3
[backend object] [backend object]
@ -130,7 +144,8 @@
(def default-timeout (def default-timeout
(dt/duration {:seconds 30})) (dt/duration {:seconds 30}))
(defn- ^Region lookup-region (defn- lookup-region
^Region
[region] [region]
(Region/of (name region))) (Region/of (name region)))
@ -202,7 +217,7 @@
(reify (reify
AsyncRequestBody AsyncRequestBody
(contentLength [_] (contentLength [_]
(Optional/of (long (count content)))) (Optional/of (long (impl/get-size content))))
(^void subscribe [_ ^Subscriber s] (^void subscribe [_ ^Subscriber s]
(let [thread (Thread. #(writer-fn s))] (let [thread (Thread. #(writer-fn s))]
@ -214,7 +229,6 @@
(cancel [_] (cancel [_]
(.interrupt thread) (.interrupt thread)
(.release sem 1)) (.release sem 1))
(request [_ n] (request [_ n]
(.release sem (int n)))))))))) (.release sem (int n))))))))))
@ -236,16 +250,31 @@
^AsyncRequestBody content)))) ^AsyncRequestBody content))))
(defn get-object-data (defn get-object-data
[{:keys [client bucket prefix]} {:keys [id]}] [{:keys [client bucket prefix]} {:keys [id size]}]
(p/let [gor (.. (GetObjectRequest/builder) (let [gor (.. (GetObjectRequest/builder)
(bucket bucket) (bucket bucket)
(key (str prefix (impl/id->path id))) (key (str prefix (impl/id->path id)))
(build)) (build))]
obj (.getObject ^S3AsyncClient client ^GetObjectRequest gor)
;; rsp (.response ^ResponseInputStream obj) ;; If the file size is greater than 2MiB then stream the content
;; len (.contentLength ^GetObjectResponse rsp) ;; to the filesystem and then read with buffered inputstream; if
] ;; not, read the contento into memory using bytearrays.
(io/input-stream obj))) (if (> size (* 1024 1024 2))
(p/let [path (tmp/tempfile :prefix "penpot.storage.s3.")
rxf (AsyncResponseTransformer/toFile path)
_ (.getObject ^S3AsyncClient client
^GetObjectRequest gor
^AsyncResponseTransformer rxf)]
(proxy [FilterInputStream] [(io/input-stream path)]
(close []
(fs/delete path)
(proxy-super close))))
(p/let [rxf (AsyncResponseTransformer/toBytes)
obj (.getObject ^S3AsyncClient client
^GetObjectRequest gor
^AsyncResponseTransformer rxf)]
(.asInputStream ^ResponseBytes obj)))))
(defn get-object-bytes (defn get-object-bytes
[{:keys [client bucket prefix]} {:keys [id]}] [{:keys [client bucket prefix]} {:keys [id]}]
@ -253,7 +282,10 @@
(bucket bucket) (bucket bucket)
(key (str prefix (impl/id->path id))) (key (str prefix (impl/id->path id)))
(build)) (build))
obj (.getObjectAsBytes ^S3AsyncClient client ^GetObjectRequest gor)] rxf (AsyncResponseTransformer/toBytes)
obj (.getObjectAsBytes ^S3AsyncClient client
^GetObjectRequest gor
^AsyncResponseTransformer rxf)]
(.asByteArray ^ResponseBytes obj))) (.asByteArray ^ResponseBytes obj)))
(def default-max-age (def default-max-age

View File

@ -0,0 +1,83 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.storage.tmp
"Temporal files service all created files will be tried to clean after
1 hour afrer creation. This is a best effort, if this process fails,
the operating system cleaning task should be responsible of
permanently delete these files (look at systemd-tempfiles)."
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.core.async :as a]
[clojure.spec.alpha :as s]
[datoteka.core :as fs]
[integrant.core :as ig]
[promesa.exec :as px]))
(declare remove-temp-file)
(defonce queue (a/chan 128))
(s/def ::min-age ::dt/duration)
(defmethod ig/pre-init-spec ::cleaner [_]
(s/keys :req-un [::min-age ::wrk/scheduler ::wrk/executor]))
(defmethod ig/prep-key ::cleaner
[_ cfg]
(merge {:min-age (dt/duration {:minutes 30})}
(d/without-nils cfg)))
(defmethod ig/init-key ::cleaner
[_ {:keys [scheduler executor min-age] :as cfg}]
(l/info :hint "starting tempfile cleaner service")
(let [cch (a/chan)]
(a/go-loop []
(let [[path port] (a/alts! [queue cch])]
(when (not= port cch)
(l/trace :hint "schedule tempfile deletion" :path path
:expires-at (dt/plus (dt/now) min-age))
(px/schedule! scheduler
(inst-ms min-age)
(partial remove-temp-file executor path))
(recur))))
cch))
(defmethod ig/halt-key! ::cleaner
[_ close-ch]
(l/info :hint "stoping tempfile cleaner service")
(some-> close-ch a/close!))
(defn- remove-temp-file
"Permanently delete tempfile"
[executor path]
(px/with-dispatch executor
(l/trace :hint "permanently delete tempfile" :path path)
(when (fs/exists? path)
(fs/delete path))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn tempfile
"Returns a tmpfile candidate (without creating it)"
[& {:keys [suffix prefix]
:or {prefix "penpot."
suffix ".tmp"}}]
(let [candidate (fs/tempfile :suffix suffix :prefix prefix)]
(a/offer! queue candidate)
candidate))
(defn create-tempfile
[& {:keys [suffix prefix]
:or {prefix "penpot."
suffix ".tmp"}}]
(let [path (fs/create-tempfile :suffix suffix :prefix prefix)]
(a/offer! queue path)
path))

View File

@ -82,7 +82,7 @@
:kf first :kf first
:initk (dt/now))))) :initk (dt/now)))))
(defn- collect-used-media (defn collect-used-media
[data] [data]
(let [xform (comp (let [xform (comp
(map :objects) (map :objects)

View File

@ -0,0 +1,126 @@
;; 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.bytes
"Bytes & Byte Streams helpers"
(:require
[clojure.java.io :as io]
[datoteka.core :as fs]
[yetti.adapter :as yt])
(:import
com.github.luben.zstd.ZstdInputStream
com.github.luben.zstd.ZstdOutputStream
java.io.ByteArrayInputStream
java.io.ByteArrayOutputStream
java.io.DataInputStream
java.io.DataOutputStream
java.io.OutputStream
java.io.InputStream
java.lang.AutoCloseable
org.apache.commons.io.IOUtils
org.apache.commons.io.input.BoundedInputStream))
(set! *warn-on-reflection* true)
(def ^:const default-buffer-size
(:xnio/buffer-size yt/defaults))
(defn input-stream?
[s]
(instance? InputStream s))
(defn output-stream?
[s]
(instance? OutputStream s))
(defn data-input-stream?
[s]
(instance? DataInputStream s))
(defn data-output-stream?
[s]
(instance? DataOutputStream s))
(defn copy!
[src dst & {:keys [offset size buffer-size]
:or {offset 0 buffer-size default-buffer-size}}]
(let [^bytes buff (byte-array buffer-size)]
(if size
(IOUtils/copyLarge ^InputStream src ^OutputStream dst (long offset) (long size) buff)
(IOUtils/copyLarge ^InputStream src ^OutputStream dst buff))))
(defn write-to-file!
[src dst & {:keys [size]}]
(with-open [^OutputStream output (io/output-stream dst)]
(cond
(bytes? src)
(if size
(with-open [^InputStream input (ByteArrayInputStream. ^bytes src)]
(with-open [^InputStream input (BoundedInputStream. input (or size (alength ^bytes src)))]
(copy! input output :size size)))
(do
(IOUtils/writeChunked ^bytes src output)
(.flush ^OutputStream output)
(alength ^bytes src)))
(instance? InputStream src)
(copy! src output :size size)
:else
(throw (IllegalArgumentException. "invalid arguments")))))
(defn read-as-bytes
"Read input stream as byte array."
[input & {:keys [size]}]
(cond
(instance? InputStream input)
(with-open [output (ByteArrayOutputStream. (or size (.available ^InputStream input)))]
(copy! input output :size size)
(.toByteArray output))
(fs/path? input)
(with-open [input (io/input-stream input)
output (ByteArrayOutputStream. (or size (.available input)))]
(copy! input output :size size)
(.toByteArray output))
:else
(throw (IllegalArgumentException. "invalid arguments"))))
(defn bytes-input-stream
"Creates an instance of ByteArrayInputStream."
[^bytes data]
(ByteArrayInputStream. data))
(defn bounded-input-stream
[input size & {:keys [close?] :or {close? true}}]
(doto (BoundedInputStream. ^InputStream input ^long size)
(.setPropagateClose close?)))
(defn zstd-input-stream
^InputStream
[input]
(ZstdInputStream. ^InputStream input))
(defn zstd-output-stream
^OutputStream
[output & {:keys [level] :or {level 0}}]
(ZstdOutputStream. ^OutputStream output (int level)))
(defn data-input-stream
^DataInputStream
[input]
(DataInputStream. ^InputStream input))
(defn data-output-stream
^DataOutputStream
[output]
(DataOutputStream. ^OutputStream output))
(defn close!
[^AutoCloseable stream]
(.close stream))

View File

@ -30,7 +30,8 @@
[v] [v]
(InternetAddress/parse ^String v)) (InternetAddress/parse ^String v))
(defn- ^Message$RecipientType resolve-recipient-type (defn- resolve-recipient-type
^Message$RecipientType
[type] [type]
(case type (case type
:to Message$RecipientType/TO :to Message$RecipientType/TO
@ -157,7 +158,8 @@
(.setDebug session debug) (.setDebug session debug)
session)) session))
(defn ^MimeMessage smtp-message (defn smtp-message
^MimeMessage
[cfg message] [cfg message]
(let [^Session session (smtp-session cfg)] (let [^Session session (smtp-session cfg)]
(build-message cfg session message))) (build-message cfg session message)))

View File

@ -40,9 +40,14 @@
(comp (comp
(d/domap require) (d/domap require)
(map find-ns) (map find-ns)
(mapcat ns-publics) (mapcat (fn [ns]
(map second) (->> (ns-publics ns)
(filter #(::spec (meta %))))) (map second)
(filter #(::spec (meta %)))
(map (fn [fvar]
(with-meta (deref fvar)
(-> (meta fvar)
(assoc :ns (-> ns ns-name str)))))))))))
(defn scan-ns (defn scan-ns
[& nsyms] [& nsyms]

View File

@ -10,9 +10,10 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.transit :as t] [app.common.transit :as t]
[app.metrics :as mtx] [app.loggers.audit :refer [parse-client-ip]]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.core.async :as a] [clojure.core.async :as a]
[yetti.request :as yr]
[yetti.util :as yu] [yetti.util :as yu]
[yetti.websocket :as yws]) [yetti.websocket :as yws])
(:import (:import
@ -25,8 +26,10 @@
(declare process-output) (declare process-output)
(declare ws-ping!) (declare ws-ping!)
(declare ws-send!) (declare ws-send!)
(declare filter-options)
(def noop (constantly nil)) (def noop (constantly nil))
(def identity-3 (fn [_ _ o] o))
(defn handler (defn handler
"A WebSocket upgrade handler factory. Returns a handler that can be "A WebSocket upgrade handler factory. Returns a handler that can be
@ -39,94 +42,123 @@
It also accepts some options that allows you parametrize the It also accepts some options that allows you parametrize the
protocol behavior. The options map will be used as-as for the protocol behavior. The options map will be used as-as for the
initial data of the `ws` data structure" initial data of the `ws` data structure"
([handle-message] (handler handle-message {})) [& {:keys [::on-rcv-message
([handle-message {:keys [::input-buff-size ::on-snd-message
::output-buff-size ::on-connect
::idle-timeout ::input-buff-size
metrics] ::output-buff-size
:or {input-buff-size 64 ::handler
output-buff-size 64 ::idle-timeout]
idle-timeout 30000} :or {input-buff-size 64
:as options}] output-buff-size 64
(fn [{:keys [::yws/channel] :as request}] idle-timeout 30000
(let [input-ch (a/chan input-buff-size) on-connect noop
output-ch (a/chan output-buff-size) on-snd-message identity-3
pong-ch (a/chan (a/sliding-buffer 6)) on-rcv-message identity-3}
close-ch (a/chan) :as options}]
options (atom (assert (fn? on-rcv-message) "'on-rcv-message' should be a function")
(-> options (assert (fn? on-snd-message) "'on-snd-message' should be a function")
(assoc ::input-ch input-ch) (assert (fn? on-connect) "'on-connect' should be a function")
(assoc ::output-ch output-ch)
(assoc ::close-ch close-ch)
(assoc ::channel channel)
(dissoc ::metrics)))
terminated (atom false) (fn [{:keys [::yws/channel session-id] :as request}]
created-at (dt/now) (let [input-ch (a/chan input-buff-size)
output-ch (a/chan output-buff-size)
pong-ch (a/chan (a/sliding-buffer 6))
close-ch (a/chan)
stop-ch (a/chan)
on-open ip-addr (parse-client-ip request)
(fn [channel] uagent (yr/get-header request "user-agent")
(mtx/run! metrics {:id :websocket-active-connections :inc 1}) id (inst-ms (dt/now))
(yws/idle-timeout! channel (dt/duration idle-timeout)))
on-terminate options (-> (filter-options options)
(fn [& _args] (merge {::id id
(when (compare-and-set! terminated false true) ::input-ch input-ch
(mtx/run! metrics {:id :websocket-active-connections :dec 1}) ::output-ch output-ch
(mtx/run! metrics {:id :websocket-session-timing :val (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0)}) ::close-ch close-ch
::stop-ch stop-ch
::channel channel
::remote-addr ip-addr
::http-session-id session-id
::user-agent uagent})
(atom))
(a/close! close-ch) ;; call the on-connect hook and memoize the on-terminate instance
(a/close! pong-ch) on-terminate (on-connect options)
(a/close! output-ch)
(a/close! input-ch)))
on-error on-ws-open
(fn [_ error] (fn [channel]
(on-terminate) (l/trace :fn "on-ws-open" :conn-id id)
;; TODO: properly log timeout exceptions (yws/idle-timeout! channel (dt/duration idle-timeout)))
(when-not (or (instance? java.nio.channels.ClosedChannelException error)
(instance? java.net.SocketException error))
(l/error :hint (ex-message error) :cause error)))
on-message on-ws-terminate
(fn [_ message] (fn [_ code reason]
(mtx/run! metrics {:id :websocket-messages-total :labels ["recv"] :inc 1}) (l/trace :fn "on-ws-terminate" :conn-id id :code code :reason reason)
(try (a/close! close-ch))
(let [message (t/decode-str message)]
(a/offer! input-ch message))
(catch Throwable e
(l/warn :hint "error on decoding incoming message from websocket"
:wsmsg (pr-str message)
:cause e)
(on-terminate))))
on-pong on-ws-error
(fn [_ buffers] (fn [_ error]
(a/>!! pong-ch (yu/copy-many buffers)))] (a/close! close-ch)
(when-not (or (instance? java.nio.channels.ClosedChannelException error)
(instance? java.net.SocketException error))
(l/error :hint (ex-message error) :cause error)))
;; launch heartbeat process on-ws-message
(-> @options (fn [_ message]
(assoc ::pong-ch pong-ch) (try
(assoc ::on-close on-terminate) (let [message (on-rcv-message options message)
(process-heartbeat)) message (t/decode-str message)]
(a/offer! input-ch message)
(swap! options assoc ::last-activity-at (dt/now)))
(catch Throwable e
(l/warn :hint "error on decoding incoming message from websocket"
:wsmsg (pr-str message)
:cause e)
(a/>! close-ch [8801 "decode error"])
(a/close! close-ch))))
;; Forward all messages from output-ch to the websocket on-ws-pong
;; connection (fn [_ buffers]
(a/go-loop [] (a/>!! pong-ch (yu/copy-many buffers)))]
(when-let [val (a/<! output-ch)]
(mtx/run! metrics {:id :websocket-messages-total :labels ["send"] :inc 1})
(a/<! (ws-send! channel (t/encode-str val)))
(recur)))
;; React on messages received from the client ;; Launch heartbeat process
(process-input options handle-message) (-> @options
(assoc ::pong-ch pong-ch)
(process-heartbeat))
{:on-open on-open ;; Wait a close signal
:on-error on-error (a/go
:on-close on-terminate (let [[code reason] (a/<! close-ch)]
:on-text on-message (a/close! stop-ch)
:on-pong on-pong})))) (a/close! pong-ch)
(a/close! output-ch)
(a/close! input-ch)
(when (and code reason)
(l/trace :hint "close channel condition" :code code :reason reason)
(yws/close! channel code reason))
(when (fn? on-terminate)
(on-terminate))))
;; Forward all messages from output-ch to the websocket
;; connection
(a/go-loop []
(when-let [val (a/<! output-ch)]
(let [val (on-snd-message options val)]
(a/<! (ws-send! channel (t/encode-str val)))
(recur))))
;; React on messages received from the client
(process-input options handler)
{:on-open on-ws-open
:on-error on-ws-error
:on-close on-ws-terminate
:on-text on-ws-message
:on-pong on-ws-pong})))
(defn- ws-send! (defn- ws-send!
[channel s] [channel s]
@ -172,14 +204,14 @@
(defn- process-input (defn- process-input
[wsp handler] [wsp handler]
(let [{:keys [::input-ch ::output-ch ::close-ch]} @wsp (let [{:keys [::input-ch ::output-ch ::stop-ch]} @wsp
handler (wrap-handler handler)] handler (wrap-handler handler)]
(a/go (a/go
(a/<! (handler wsp {:type :connect})) (a/<! (handler wsp {:type :connect}))
(a/<! (a/go-loop [] (a/<! (a/go-loop []
(when-let [message (a/<! input-ch)] (when-let [message (a/<! input-ch)]
(let [[val port] (a/alts! [(handler wsp message) close-ch])] (let [[val port] (a/alts! [stop-ch (handler wsp message)] :priority true)]
(when-not (= port close-ch) (when-not (= port stop-ch)
(cond (cond
(ex/ex-info? val) (ex/ex-info? val)
(a/>! output-ch {:type :error :error (ex-data val)}) (a/>! output-ch {:type :error :error (ex-data val)})
@ -193,19 +225,21 @@
(a/<! (handler wsp {:type :disconnect}))))) (a/<! (handler wsp {:type :disconnect})))))
(defn- process-heartbeat (defn- process-heartbeat
[{:keys [::channel ::close-ch ::on-close ::pong-ch [{:keys [::channel ::stop-ch ::close-ch ::pong-ch
::heartbeat-interval ::max-missed-heartbeats] ::heartbeat-interval ::max-missed-heartbeats]
:or {heartbeat-interval 2000 :or {heartbeat-interval 2000
max-missed-heartbeats 4}}] max-missed-heartbeats 4}}]
(let [beats (atom #{})] (let [beats (atom #{})]
(a/go-loop [i 0] (a/go-loop [i 0]
(let [[_ port] (a/alts! [close-ch (a/timeout heartbeat-interval)])] (let [[_ port] (a/alts! [stop-ch (a/timeout heartbeat-interval)] :priority true)]
(when (and (yws/connected? channel) (when (and (yws/connected? channel)
(not= port close-ch)) (not= port stop-ch))
(a/<! (ws-ping! channel (encode-beat i))) (a/<! (ws-ping! channel (encode-beat i)))
(let [issued (swap! beats conj (long i))] (let [issued (swap! beats conj (long i))]
(if (>= (count issued) max-missed-heartbeats) (if (>= (count issued) max-missed-heartbeats)
(on-close channel -1 "heartbeat-timeout") (do
(a/>! close-ch [8802 "heart-beat timeout"])
(a/close! close-ch))
(recur (inc i))))))) (recur (inc i)))))))
(a/go-loop [] (a/go-loop []
@ -213,3 +247,11 @@
(swap! beats disj (decode-beat buffer)) (swap! beats disj (decode-beat buffer))
(recur))))) (recur)))))
(defn- filter-options
"Remove from options all namespace qualified keys that matches the
current namespace."
[options]
(into {}
(remove (fn [[key]]
(= (namespace key) "app.util.websocket")))
options))

View File

@ -203,8 +203,7 @@
(instance? Exception val) (instance? Exception val)
(do (do
(l/warn :cause val (l/warn :hint "unexpected error ocurried on polling the database (will resume in some instants)" :cause val)
:hint "unexpected error ocurried on polling the database (will resume in some instants)")
(a/<! (a/timeout poll-ms)) (a/<! (a/timeout poll-ms))
(recur)) (recur))
@ -377,7 +376,7 @@
[{:keys [tasks]} item] [{:keys [tasks]} item]
(let [name (d/name (:name item))] (let [name (d/name (:name item))]
(try (try
(l/debug :action "execute task" (l/trace :action "execute task"
:id (:id item) :id (:id item)
:name name :name name
:retry (:retry-num item)) :retry (:retry-num item))
@ -425,7 +424,7 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare schedule-cron-task) (declare schedule-cron-task)
(declare synchronize-cron-entries) (declare synchronize-cron-entries!)
(s/def ::fn (s/or :var var? :fn fn?)) (s/def ::fn (s/or :var var? :fn fn?))
(s/def ::id keyword?) (s/def ::id keyword?)
@ -466,8 +465,8 @@
cfg (assoc cfg :entries entries :running running)] cfg (assoc cfg :entries entries :running running)]
(l/info :hint "cron started" :registred-tasks (count entries)) (l/info :hint "cron initialized" :tasks (count entries))
(synchronize-cron-entries cfg) (synchronize-cron-entries! cfg)
(->> (filter some? entries) (->> (filter some? entries)
(run! (partial schedule-cron-task cfg))) (run! (partial schedule-cron-task cfg)))
@ -494,16 +493,12 @@
on conflict (id) on conflict (id)
do update set cron_expr=?") do update set cron_expr=?")
(defn- synchronize-cron-item (defn- synchronize-cron-entries!
[conn {:keys [id cron]}] [{:keys [pool entries]}]
(let [cron (str cron)]
(l/debug :action "initialize scheduled task" :id id :cron cron)
(db/exec-one! conn [sql:upsert-cron-task id cron cron])))
(defn- synchronize-cron-entries
[{:keys [pool schedule]}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(run! (partial synchronize-cron-item conn) schedule))) (doseq [{:keys [id cron]} entries]
(l/trace :hint "register cron task" :id id :cron (str cron))
(db/exec-one! conn [sql:upsert-cron-task id (str cron) (str cron)]))))
(def sql:lock-cron-task (def sql:lock-cron-task
"select id from scheduled_task where id=? for update skip locked") "select id from scheduled_task where id=? for update skip locked")
@ -512,7 +507,7 @@
[{: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]
(when (db/exec-one! conn [sql:lock-cron-task (d/name id)]) (when (db/exec-one! conn [sql:lock-cron-task (d/name id)])
(l/debug :action "execute scheduled task" :id id) (l/trace :hint "execute cron task" :id id)
((:fn task) task))) ((:fn task) task)))
(handle-task [] (handle-task []
@ -567,9 +562,10 @@
(defmethod ig/init-key ::registry (defmethod ig/init-key ::registry
[_ {:keys [metrics tasks]}] [_ {:keys [metrics tasks]}]
(l/info :hint "registry initialized" :tasks (count tasks))
(reduce-kv (fn [res k v] (reduce-kv (fn [res k v]
(let [tname (name k)] (let [tname (name k)]
(l/debug :hint "register task" :name tname) (l/trace :hint "register task" :name tname)
(assoc res k (wrap-task-handler metrics tname v)))) (assoc res k (wrap-task-handler metrics tname v))))
{} {}
tasks)) tasks))

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.util.bytes :as bs]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.test :as t] [clojure.test :as t]
[datoteka.core :as fs])) [datoteka.core :as fs]))
@ -25,7 +26,8 @@
font-id (uuid/custom 10 1) font-id (uuid/custom 10 1)
ttfdata (-> (io/resource "app/test_files/font-1.ttf") ttfdata (-> (io/resource "app/test_files/font-1.ttf")
(fs/slurp-bytes)) io/input-stream
bs/read-as-bytes)
params {::th/type :create-font-variant params {::th/type :create-font-variant
:profile-id (:id prof) :profile-id (:id prof)
@ -60,7 +62,8 @@
font-id (uuid/custom 10 1) font-id (uuid/custom 10 1)
data (-> (io/resource "app/test_files/font-1.woff") data (-> (io/resource "app/test_files/font-1.woff")
(fs/slurp-bytes)) io/input-stream
bs/read-as-bytes)
params {::th/type :create-font-variant params {::th/type :create-font-variant
:profile-id (:id prof) :profile-id (:id prof)

View File

@ -46,7 +46,13 @@
(t/is (sto/storage-object? mobj1)) (t/is (sto/storage-object? mobj1))
(t/is (sto/storage-object? mobj2)) (t/is (sto/storage-object? mobj2))
(t/is (= 122785 (:size mobj1))) (t/is (= 122785 (:size mobj1)))
(t/is (= 3303 (:size mobj2)))))
;; This is because in ubuntu 21.04 generates different
;; thumbnail that in ubuntu 22.04. This hack should be removed
;; when we all use the ubuntu 22.04 devenv image.
(t/is (or
(= 3302 (:size mobj2))
(= 3303 (:size mobj2))))))
)) ))
(t/deftest media-object-upload (t/deftest media-object-upload

View File

@ -10,6 +10,7 @@
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.rpc.mutations.profile :as profile] [app.rpc.mutations.profile :as profile]
[app.rpc.commands.auth :as cauth]
[app.test-helpers :as th] [app.test-helpers :as th]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.java.io :as io] [clojure.java.io :as io]
@ -27,11 +28,10 @@
;; Test with wrong credentials ;; Test with wrong credentials
(t/deftest profile-login-failed-1 (t/deftest profile-login-failed-1
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
data {::th/type :login data {::th/type :login-with-password
:email "profile1.test@nodomain.com" :email "profile1.test@nodomain.com"
:password "foobar" :password "foobar"}
:scope "foobar"} out (th/command! data)]
out (th/mutation! data)]
#_(th/print-result! out) #_(th/print-result! out)
(let [error (:error out)] (let [error (:error out)]
@ -42,11 +42,10 @@
;; Test with good credentials but profile not activated. ;; Test with good credentials but profile not activated.
(t/deftest profile-login-failed-2 (t/deftest profile-login-failed-2
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
data {::th/type :login data {::th/type :login-with-password
:email "profile1.test@nodomain.com" :email "profile1.test@nodomain.com"
:password "123123" :password "123123"}
:scope "foobar"} out (th/command! data)]
out (th/mutation! data)]
;; (th/print-result! out) ;; (th/print-result! out)
(let [error (:error out)] (let [error (:error out)]
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
@ -58,8 +57,7 @@
(let [profile (th/create-profile* 1 {:is-active true}) (let [profile (th/create-profile* 1 {:is-active true})
data {::th/type :login data {::th/type :login
:email "profile1.test@nodomain.com" :email "profile1.test@nodomain.com"
:password "123123" :password "123123"}
:scope "foobar"}
out (th/mutation! data)] out (th/mutation! data)]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
@ -161,11 +159,11 @@
(t/deftest registration-domain-whitelist (t/deftest registration-domain-whitelist
(let [whitelist #{"gmail.com" "hey.com" "ya.ru"}] (let [whitelist #{"gmail.com" "hey.com" "ya.ru"}]
(t/testing "allowed email domain" (t/testing "allowed email domain"
(t/is (true? (profile/email-domain-in-whitelist? whitelist "username@ya.ru"))) (t/is (true? (cauth/email-domain-in-whitelist? whitelist "username@ya.ru")))
(t/is (true? (profile/email-domain-in-whitelist? #{} "username@somedomain.com")))) (t/is (true? (cauth/email-domain-in-whitelist? #{} "username@somedomain.com"))))
(t/testing "not allowed email domain" (t/testing "not allowed email domain"
(t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com")))))) (t/is (false? (cauth/email-domain-in-whitelist? whitelist "username@somedomain.com"))))))
(t/deftest prepare-register-and-register-profile (t/deftest prepare-register-and-register-profile
(let [data {::th/type :prepare-register-profile (let [data {::th/type :prepare-register-profile

View File

@ -49,7 +49,8 @@
:profile-id (:id prof) :profile-id (:id prof)
:file-id (:id file) :file-id (:id file)
:pages #{(get-in file [:data :pages 0])} :pages #{(get-in file [:data :pages 0])}
:flags #{}} :who-comment "team"
:who-inspect "all"}
out (th/mutation! data)] out (th/mutation! data)]
;; (th/print-result! out) ;; (th/print-result! out)

View File

@ -12,6 +12,7 @@
[app.storage :as sto] [app.storage :as sto]
[app.test-helpers :as th] [app.test-helpers :as th]
[app.util.time :as dt] [app.util.time :as dt]
[app.util.bytes :as bs]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.test :as t] [clojure.test :as t]
[cuerdas.core :as str] [cuerdas.core :as str]
@ -27,11 +28,11 @@
"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] ([storage]
(assoc storage :backend :tmp)) (assoc storage :backend :assets-fs))
([storage conn] ([storage conn]
(-> storage (-> storage
(assoc :conn conn) (assoc :conn conn)
(assoc :backend :tmp)))) (assoc :backend :assets-fs))))
(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*)
@ -43,7 +44,7 @@
(t/is (sto/storage-object? object)) (t/is (sto/storage-object? object))
(t/is (fs/path? @(sto/get-object-path storage object))) (t/is (fs/path? @(sto/get-object-path storage object)))
(t/is (nil? (:expired-at object))) (t/is (nil? (:expired-at object)))
(t/is (= :tmp (:backend object))) (t/is (= :assets-fs (:backend object)))
(t/is (= "data" (:other (meta object)))) (t/is (= "data" (:other (meta object))))
(t/is (= "text/plain" (:content-type (meta object)))) (t/is (= "text/plain" (:content-type (meta object))))
(t/is (= "content" (slurp @(sto/get-object-data storage object)))) (t/is (= "content" (slurp @(sto/get-object-data storage object))))
@ -197,7 +198,8 @@
:is-shared false}) :is-shared false})
ttfdata (-> (io/resource "app/test_files/font-1.ttf") ttfdata (-> (io/resource "app/test_files/font-1.ttf")
(fs/slurp-bytes)) io/input-stream
bs/read-as-bytes)
mfile {:filename "sample.jpg" mfile {:filename "sample.jpg"
:path (th/tempfile "app/test_files/sample.jpg") :path (th/tempfile "app/test_files/sample.jpg")

View File

@ -9,14 +9,15 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.flags :as flags] [app.common.flags :as flags]
[app.common.pages :as cp] [app.common.pages :as cp]
[app.common.pprint :as pp]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.common.pprint :as pp]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.main :as main] [app.main :as main]
[app.media] [app.media]
[app.migrations] [app.migrations]
[app.rpc.commands.auth :as cmd.auth]
[app.rpc.mutations.files :as files] [app.rpc.mutations.files :as files]
[app.rpc.mutations.profile :as profile] [app.rpc.mutations.profile :as profile]
[app.rpc.mutations.projects :as projects] [app.rpc.mutations.projects :as projects]
@ -31,8 +32,8 @@
[expound.alpha :as expound] [expound.alpha :as expound]
[integrant.core :as ig] [integrant.core :as ig]
[mockery.core :as mk] [mockery.core :as mk]
[yetti.request :as yrq] [promesa.core :as p]
[promesa.core :as p]) [yetti.request :as yrq])
(:import org.postgresql.ds.PGSimpleDataSource)) (:import org.postgresql.ds.PGSimpleDataSource))
(def ^:dynamic *system* nil) (def ^:dynamic *system* nil)
@ -59,10 +60,12 @@
:app.http/router :app.http/router
:app.http.awsns/handler :app.http.awsns/handler
:app.http.session/updater :app.http.session/updater
:app.http.oauth/google :app.auth.oidc/google-provider
:app.http.oauth/gitlab :app.auth.oidc/gitlab-provider
:app.http.oauth/github :app.auth.oidc/github-provider
:app.http.oauth/all :app.auth.oidc/generic-provider
:app.auth.oidc/routes
;; :app.auth.ldap/provider
:app.worker/executors-monitor :app.worker/executors-monitor
:app.http.oauth/handler :app.http.oauth/handler
:app.notifications/handler :app.notifications/handler
@ -81,9 +84,9 @@
(try (try
(binding [*system* system (binding [*system* system
*pool* (:app.db/pool system)] *pool* (:app.db/pool system)]
(mk/with-mocks [mock1 {:target 'app.rpc.mutations.profile/derive-password (mk/with-mocks [mock1 {:target 'app.rpc.commands.auth/derive-password
:return identity} :return identity}
mock2 {:target 'app.rpc.mutations.profile/verify-password mock2 {:target 'app.rpc.commands.auth/verify-password
:return (fn [a b] {:valid (= a b)})}] :return (fn [a b] {:valid (= a b)})}]
(next))) (next)))
(finally (finally
@ -140,8 +143,8 @@
:is-demo false} :is-demo false}
params)] params)]
(->> params (->> params
(#'profile/create-profile conn) (cmd.auth/create-profile conn)
(#'profile/create-profile-relations conn))))) (cmd.auth/create-profile-relations conn)))))
(defn create-project* (defn create-project*
([i params] (create-project* *pool* i params)) ([i params] (create-project* *pool* i params))
@ -267,17 +270,21 @@
{:error (handle-error e#) {:error (handle-error e#)
:result nil}))) :result nil})))
(defn command!
[{:keys [::type] :as data}]
(let [method-fn (get-in *system* [:app.rpc/methods :commands type])]
;; (app.common.pprint/pprint (:app.rpc/methods *system*))
(try-on! (method-fn (dissoc data ::type)))))
(defn mutation! (defn mutation!
[{:keys [::type] :as data}] [{:keys [::type] :as data}]
(let [method-fn (get-in *system* [:app.rpc/rpc :methods :mutation type])] (let [method-fn (get-in *system* [:app.rpc/methods :mutations type])]
(try-on! (try-on! (method-fn (dissoc data ::type)))))
(method-fn (dissoc data ::type)))))
(defn query! (defn query!
[{:keys [::type] :as data}] [{:keys [::type] :as data}]
(let [method-fn (get-in *system* [:app.rpc/rpc :methods :query type])] (let [method-fn (get-in *system* [:app.rpc/methods :queries type])]
(try-on! (try-on! (method-fn (dissoc data ::type)))))
(method-fn (dissoc data ::type)))))
;; --- UTILS ;; --- UTILS

View File

@ -1,9 +1,9 @@
{:deps {:deps
{org.clojure/clojure {:mvn/version "1.10.3"} {org.clojure/clojure {:mvn/version "1.11.1"}
org.clojure/data.json {:mvn/version "2.4.0"} org.clojure/data.json {:mvn/version "2.4.0"}
org.clojure/tools.cli {:mvn/version "1.0.206"} org.clojure/tools.cli {:mvn/version "1.0.206"}
metosin/jsonista {:mvn/version "0.3.5"} metosin/jsonista {:mvn/version "0.3.6"}
org.clojure/clojurescript {:mvn/version "1.11.4"} org.clojure/clojurescript {:mvn/version "1.11.57"}
;; Logging ;; Logging
org.apache.logging.log4j/log4j-api {:mvn/version "2.17.2"} org.apache.logging.log4j/log4j-api {:mvn/version "2.17.2"}
@ -13,7 +13,7 @@
org.apache.logging.log4j/log4j-slf4j18-impl {:mvn/version "2.17.2"} org.apache.logging.log4j/log4j-slf4j18-impl {:mvn/version "2.17.2"}
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.50"} selmer/selmer {:mvn/version "1.12.51"}
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"}
@ -22,7 +22,7 @@
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 "8.0.450"} funcool/promesa {:mvn/version "8.0.450"}
funcool/cuerdas {:mvn/version "2022.03.27-397"} funcool/cuerdas {:mvn/version "2022.06.16-403"}
lambdaisland/uri {:mvn/version "1.13.95" lambdaisland/uri {:mvn/version "1.13.95"
:exclusions [org.clojure/data.json]} :exclusions [org.clojure/data.json]}
@ -33,7 +33,7 @@
com.sun.mail/jakarta.mail {:mvn/version "2.0.1"} com.sun.mail/jakarta.mail {:mvn/version "2.0.1"}
;; exception printing ;; exception printing
fipp/fipp {:mvn/version "0.6.25"} fipp/fipp {:mvn/version "0.6.26"}
io.aviso/pretty {:mvn/version "1.1.1"} io.aviso/pretty {:mvn/version "1.1.1"}
environ/environ {:mvn/version "1.2.0"}} environ/environ {:mvn/version "1.2.0"}}
:paths ["src"] :paths ["src"]
@ -42,7 +42,7 @@
{: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"}
thheller/shadow-cljs {:mvn/version "2.17.8"} thheller/shadow-cljs {:mvn/version "2.19.5"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"} com.bhauman/rebel-readline {:mvn/version "RELEASE"}
criterium/criterium {:mvn/version "RELEASE"} criterium/criterium {:mvn/version "RELEASE"}
mockery/mockery {:mvn/version "RELEASE"}} mockery/mockery {:mvn/version "RELEASE"}}

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.17.8", "shadow-cljs": "2.19.5",
"source-map-support": "^0.5.19", "source-map-support": "^0.5.19",
"ws": "^7.4.6" "ws": "^7.4.6"
} }

View File

@ -23,6 +23,9 @@
#?(:clj #?(:clj
(:import linked.set.LinkedSet))) (:import linked.set.LinkedSet)))
(def boolean-or-nil?
(some-fn nil? boolean?))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Structures ;; Data Structures
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -50,6 +50,12 @@
[& exprs] [& exprs]
`(try* (^:once fn* [] ~@exprs) identity)) `(try* (^:once fn* [] ~@exprs) identity))
(defn with-always
"A helper that evaluates an exptession independently if the body
raises exception or not."
[always-expr & body]
`(try ~@body (finally ~always-expr)))
(defn ex-info? (defn ex-info?
[v] [v]
(instance? #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core.ExceptionInfo) v)) (instance? #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core.ExceptionInfo) v))

View File

@ -11,9 +11,9 @@
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.pages.changes :as ch] [app.common.pages.changes :as ch]
[app.common.pages.changes-spec :as pcs]
[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]))
@ -44,9 +44,9 @@
:frame-id (:current-frame-id file)))] :frame-id (:current-frame-id file)))]
(when fail-on-spec? (when fail-on-spec?
(us/verify ::spec.change/change change)) (us/verify ::pcs/change change))
(let [valid? (us/valid? ::spec.change/change change)] (let [valid? (us/valid? ::pcs/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))))
@ -222,9 +222,13 @@
(defn close-artboard [file] (defn close-artboard [file]
(assert (nil? (:current-component-id file))) (assert (nil? (:current-component-id file)))
(-> file
(assoc :current-frame-id root-frame) (let [parent-id (-> file :parent-id peek)
(update :parent-stack pop))) parent (lookup-shape file parent-id)
current-frame-id (or (:frame-id parent) root-frame)]
(-> file
(assoc :current-frame-id current-frame-id)
(update :parent-stack pop))))
(defn add-group [file data] (defn add-group [file data]
(let [frame-id (:current-frame-id file) (let [frame-id (:current-frame-id file)

View File

@ -98,13 +98,6 @@
(defn distance-shapes [shape other] (defn distance-shapes [shape other]
(distance-selrect (:selrect shape) (:selrect other))) (distance-selrect (:selrect shape) (:selrect other)))
(defn shape-stroke-margin
[shape stroke-width]
(if (= (:type shape) :path)
;; TODO: Calculate with the stroke offset (not implemented yet
(mth/sqrt (* 2 stroke-width stroke-width))
(- (mth/sqrt (* 2 stroke-width stroke-width)) stroke-width)))
(defn close-attrs? (defn close-attrs?
"Compares two shapes attributes to see if they are equal or almost "Compares two shapes attributes to see if they are equal or almost
equal (in case of numeric). Takes into account attributes that are equal (in case of numeric). Takes into account attributes that are
@ -159,6 +152,7 @@
(dm/export gtr/move) (dm/export gtr/move)
(dm/export gtr/absolute-move) (dm/export gtr/absolute-move)
(dm/export gtr/transform-matrix) (dm/export gtr/transform-matrix)
(dm/export gtr/transform-str)
(dm/export gtr/inverse-transform-matrix) (dm/export gtr/inverse-transform-matrix)
(dm/export gtr/transform-point-center) (dm/export gtr/transform-point-center)
(dm/export gtr/transform-rect) (dm/export gtr/transform-rect)
@ -171,6 +165,7 @@
(dm/export gtr/merge-modifiers) (dm/export gtr/merge-modifiers)
(dm/export gtr/transform-shape) (dm/export gtr/transform-shape)
(dm/export gtr/transform-selrect) (dm/export gtr/transform-selrect)
(dm/export gtr/transform-selrect-matrix)
(dm/export gtr/transform-bounds) (dm/export gtr/transform-bounds)
(dm/export gtr/modifiers->transform) (dm/export gtr/modifiers->transform)
(dm/export gtr/empty-modifiers?) (dm/export gtr/empty-modifiers?)

View File

@ -0,0 +1,157 @@
;; 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.geom.shapes.bounds
(:require
[app.common.data :as d]
[app.common.geom.shapes.rect :as gsr]
[app.common.math :as mth]
[app.common.pages.helpers :as cph]))
(defn shape-stroke-margin
[shape stroke-width]
(if (= (:type shape) :path)
;; TODO: Calculate with the stroke offset (not implemented yet
(mth/sqrt (* 2 stroke-width stroke-width))
(- (mth/sqrt (* 2 stroke-width stroke-width)) stroke-width)))
(defn blur-filters [type value]
(->> [value]
(remove :hidden)
(filter #(= (:type %) type))
(map #(hash-map :id (str "filter_" (:id %))
:type (:type %)
:params %))))
(defn shadow-filters [type filters]
(->> filters
(remove :hidden)
(filter #(= (:style %) type))
(map #(hash-map :id (str "filter_" (:id %))
:type (:style %)
:params %))))
(defn shape->filters
[shape]
(d/concat-vec
[{:id "BackgroundImageFix" :type :image-fix}]
;; Background blur won't work in current SVG specification
;; We can revisit this in the future
#_(->> shape :blur (blur-filters :background-blur))
(->> shape :shadow (shadow-filters :drop-shadow))
[{:id "shape" :type :blend-filters}]
(->> shape :shadow (shadow-filters :inner-shadow))
(->> shape :blur (blur-filters :layer-blur))))
(defn calculate-filter-bounds [{:keys [x y width height]} filter-entry]
(let [{:keys [offset-x offset-y blur spread] :or {offset-x 0 offset-y 0 blur 0 spread 0}} (:params filter-entry)
filter-x (min x (+ x offset-x (- spread) (- blur) -5))
filter-y (min y (+ y offset-y (- spread) (- blur) -5))
filter-width (+ width (mth/abs offset-x) (* spread 2) (* blur 2) 10)
filter-height (+ height (mth/abs offset-y) (* spread 2) (* blur 2) 10)]
(gsr/make-selrect filter-x filter-y filter-width filter-height)))
(defn get-rect-filter-bounds
[selrect filters blur-value]
(let [filter-bounds (->> filters
(filter #(= :drop-shadow (:type %)))
(map (partial calculate-filter-bounds selrect))
(concat [selrect])
(gsr/join-selrects))
delta-blur (* blur-value 2)
result
(-> filter-bounds
(update :x - delta-blur)
(update :y - delta-blur)
(update :x1 - delta-blur)
(update :x1 - delta-blur)
(update :x2 + delta-blur)
(update :y2 + delta-blur)
(update :width + (* delta-blur 2))
(update :height + (* delta-blur 2)))]
result))
(defn get-shape-filter-bounds
([shape]
(let [svg-root? (and (= :svg-raw (:type shape)) (not= :svg (get-in shape [:content :tag])))]
(if svg-root?
(:selrect shape)
(let [filters (shape->filters shape)
blur-value (or (-> shape :blur :value) 0)]
(get-rect-filter-bounds (-> shape :points gsr/points->selrect) filters blur-value))))))
(defn calculate-padding
([shape]
(calculate-padding shape false))
([shape ignore-margin?]
(let [stroke-width (apply max 0 (map #(case (:stroke-alignment % :center)
:center (/ (:stroke-width % 0) 2)
:outer (:stroke-width % 0)
0) (:strokes shape)))
margin (if ignore-margin?
0
(apply max 0 (map #(shape-stroke-margin % stroke-width) (:strokes shape))))
shadow-width (apply max 0 (map #(case (:style % :drop-shadow)
:drop-shadow (+ (mth/abs (:offset-x %)) (* (:spread %) 2) (* (:blur %) 2) 10)
0) (:shadow shape)))
shadow-height (apply max 0 (map #(case (:style % :drop-shadow)
:drop-shadow (+ (mth/abs (:offset-y %)) (* (:spread %) 2) (* (:blur %) 2) 10)
0) (:shadow shape)))]
{:horizontal (+ stroke-width margin shadow-width)
:vertical (+ stroke-width margin shadow-height)})))
(defn- add-padding
[bounds padding]
(-> bounds
(update :x - (:horizontal padding))
(update :y - (:vertical padding))
(update :width + (* 2 (:horizontal padding)))
(update :height + (* 2 (:vertical padding)))))
(defn get-object-bounds
[objects shape]
(let [calculate-base-bounds
(fn [shape]
(-> (get-shape-filter-bounds shape)
(add-padding (calculate-padding shape true))))
bounds
(cph/reduce-objects
objects
(fn [shape]
(and (d/not-empty? (:shapes shape))
(or (not (cph/frame-shape? shape))
(:show-content shape))
(or (not (cph/group-shape? shape))
(not (:masked-group? shape)))))
(:id shape)
(fn [result shape]
(conj result (get-object-bounds objects shape)))
[(calculate-base-bounds shape)])
children-bounds (or (:children-bounds shape) (gsr/join-selrects bounds))
filters (shape->filters shape)
blur-value (or (-> shape :blur :value) 0)]
(get-rect-filter-bounds children-bounds filters blur-value)))

View File

@ -7,6 +7,7 @@
(ns app.common.geom.shapes.transforms (ns app.common.geom.shapes.transforms
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.shapes.common :as gco] [app.common.geom.shapes.common :as gco]
@ -143,16 +144,30 @@
([shape params] ([shape params]
(transform-matrix shape params (or (gco/center-shape shape) (gpt/point 0 0)))) (transform-matrix shape params (or (gco/center-shape shape) (gpt/point 0 0))))
([{:keys [flip-x flip-y] :as shape} {:keys [no-flip]} shape-center] ([{:keys [flip-x flip-y transform] :as shape} {:keys [no-flip]} shape-center]
(-> (gmt/matrix) (-> (gmt/matrix)
(gmt/translate shape-center) (gmt/translate shape-center)
(gmt/multiply (:transform shape (gmt/matrix))) (cond-> (some? transform)
(gmt/multiply transform))
(cond-> (cond->
(and (not no-flip) flip-x) (gmt/scale (gpt/point -1 1)) (and (not no-flip) flip-x) (gmt/scale (gpt/point -1 1))
(and (not no-flip) flip-y) (gmt/scale (gpt/point 1 -1))) (and (not no-flip) flip-y) (gmt/scale (gpt/point 1 -1)))
(gmt/translate (gpt/negate shape-center))))) (gmt/translate (gpt/negate shape-center)))))
(defn transform-str
([shape]
(transform-str shape nil))
([{:keys [transform flip-x flip-y] :as shape} {:keys [no-flip]}]
(if (and (some? shape)
(or (some? transform)
(and (not no-flip) flip-x)
(and (not no-flip) flip-y)))
(dm/str (transform-matrix shape))
"")))
(defn inverse-transform-matrix (defn inverse-transform-matrix
([shape] ([shape]
(let [shape-center (or (gco/center-shape shape) (let [shape-center (or (gco/center-shape shape)
@ -632,6 +647,13 @@
(transform-bounds center modifiers) (transform-bounds center modifiers)
(gpr/points->selrect)))) (gpr/points->selrect))))
(defn transform-selrect-matrix
[selrect mtx]
(-> selrect
(gpr/rect->points)
(gco/transform-points mtx)
(gpr/points->selrect)))
(defn selection-rect (defn selection-rect
"Returns a rect that contains all the shapes and is aware of the "Returns a rect that contains all the shapes and is aware of the
rotation of each shape. Mainly used for multiple selection." rotation of each shape. Mainly used for multiple selection."

View File

@ -26,8 +26,8 @@
(dm/export focus/is-in-focus?) (dm/export focus/is-in-focus?)
;; Indices ;; Indices
(dm/export indices/calculate-z-index) #_(dm/export indices/calculate-z-index)
(dm/export indices/update-z-index) #_(dm/export indices/update-z-index)
(dm/export indices/generate-child-all-parents-index) (dm/export indices/generate-child-all-parents-index)
(dm/export indices/generate-child-parent-index) (dm/export indices/generate-child-parent-index)
(dm/export indices/create-clip-index) (dm/export indices/create-clip-index)

View File

@ -16,8 +16,8 @@
[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.spec :as us] [app.common.spec :as us]
[app.common.spec.change :as spec.change] [app.common.pages.changes-spec :as pcs]
[app.common.spec.shape :as spec.shape])) [app.common.types.shape :as cts]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Specific helpers ;; Specific helpers
@ -49,7 +49,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.change/changes items)) (us/assert ::pcs/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)
@ -59,7 +59,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 shape)))))) (us/verify ::cts/shape shape))))))
result))) result)))
@ -211,7 +211,7 @@
(let [invalid-targets (calculate-invalid-targets 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? objects parent-id shape-id)))) #_(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

@ -4,14 +4,14 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.change (ns app.common.pages.changes-spec
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.common.spec.color :as color] [app.common.types.color :as ctc]
[app.common.spec.file :as file] [app.common.types.file :as ctf]
[app.common.spec.page :as page] [app.common.types.page :as ctp]
[app.common.spec.shape :as shape] [app.common.types.shape :as cts]
[app.common.spec.typography :as typg] [app.common.types.typography :as ctt]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
(s/def ::index integer?) (s/def ::index integer?)
@ -52,7 +52,7 @@
(s/keys :req-un [:internal.changes.set-option/option (s/keys :req-un [:internal.changes.set-option/option
:internal.changes.set-option/value])) :internal.changes.set-option/value]))
(s/def :internal.changes.add-obj/obj ::shape/shape) (s/def :internal.changes.add-obj/obj ::cts/shape)
(defn- valid-container-id-frame? (defn- valid-container-id-frame?
[o] [o]
@ -89,18 +89,18 @@
valid-container-id?)) valid-container-id?))
(defmethod change-spec :reg-objects [_] (defmethod change-spec :reg-objects [_]
(s/and (s/keys :req-un [::shape/shapes] (s/and (s/keys :req-un [::cts/shapes]
:opt-un [::page-id ::component-id]) :opt-un [::page-id ::component-id])
valid-container-id?)) valid-container-id?))
(defmethod change-spec :mov-objects [_] (defmethod change-spec :mov-objects [_]
(s/and (s/keys :req-un [::parent-id ::shape/shapes] (s/and (s/keys :req-un [::parent-id ::cts/shapes]
:opt-un [::page-id ::component-id ::index]) :opt-un [::page-id ::component-id ::index])
valid-container-id?)) valid-container-id?))
(defmethod change-spec :add-page [_] (defmethod change-spec :add-page [_]
(s/or :empty (s/keys :req-un [::id ::name]) (s/or :empty (s/keys :req-un [::id ::name])
:complete (s/keys :req-un [::page/page]))) :complete (s/keys :req-un [::ctp/page])))
(defmethod change-spec :mod-page [_] (defmethod change-spec :mod-page [_]
(s/keys :req-un [::id ::name])) (s/keys :req-un [::id ::name]))
@ -112,21 +112,21 @@
(s/keys :req-un [::id ::index])) (s/keys :req-un [::id ::index]))
(defmethod change-spec :add-color [_] (defmethod change-spec :add-color [_]
(s/keys :req-un [::color/color])) (s/keys :req-un [::ctc/color]))
(defmethod change-spec :mod-color [_] (defmethod change-spec :mod-color [_]
(s/keys :req-un [::color/color])) (s/keys :req-un [::ctc/color]))
(defmethod change-spec :del-color [_] (defmethod change-spec :del-color [_]
(s/keys :req-un [::id])) (s/keys :req-un [::id]))
(s/def :internal.changes.add-recent-color/color ::color/recent-color) (s/def :internal.changes.add-recent-color/color ::ctc/recent-color)
(defmethod change-spec :add-recent-color [_] (defmethod change-spec :add-recent-color [_]
(s/keys :req-un [:internal.changes.add-recent-color/color])) (s/keys :req-un [:internal.changes.add-recent-color/color]))
(s/def :internal.changes.add-media/object ::file/media-object) (s/def :internal.changes.add-media/object ::ctf/media-object)
(defmethod change-spec :add-media [_] (defmethod change-spec :add-media [_]
(s/keys :req-un [:internal.changes.add-media/object])) (s/keys :req-un [:internal.changes.add-media/object]))
@ -149,7 +149,7 @@
(s/keys :req-un [::id])) (s/keys :req-un [::id]))
(s/def :internal.changes.add-component/shapes (s/def :internal.changes.add-component/shapes
(s/coll-of ::shape/shape)) (s/coll-of ::cts/shape))
(defmethod change-spec :add-component [_] (defmethod change-spec :add-component [_]
(s/keys :req-un [::id ::name :internal.changes.add-component/shapes] (s/keys :req-un [::id ::name :internal.changes.add-component/shapes]
@ -163,13 +163,13 @@
(s/keys :req-un [::id])) (s/keys :req-un [::id]))
(defmethod change-spec :add-typography [_] (defmethod change-spec :add-typography [_]
(s/keys :req-un [::typg/typography])) (s/keys :req-un [::ctt/typography]))
(defmethod change-spec :mod-typography [_] (defmethod change-spec :mod-typography [_]
(s/keys :req-un [::typg/typography])) (s/keys :req-un [::ctt/typography]))
(defmethod change-spec :del-typography [_] (defmethod change-spec :del-typography [_]
(s/keys :req-un [::typg/id])) (s/keys :req-un [::ctt/id]))
(s/def ::change (s/multi-spec change-spec :type)) (s/def ::change (s/multi-spec change-spec :type))
(s/def ::changes (s/coll-of ::change)) (s/def ::changes (s/coll-of ::change))

View File

@ -80,8 +80,11 @@
:x :y :x :y
:rx :ry :rx :ry
:r1 :r2 :r3 :r4 :r1 :r2 :r3 :r4
:rotation
:selrect :selrect
:points :points
:show-content
:hide-in-viewer
:opacity :opacity
:blend-mode :blend-mode

View File

@ -8,26 +8,20 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.pages.helpers :as cph] [app.common.pages.helpers :as cph]
[app.common.pages.indices :as cpi]
[app.common.uuid :as uuid])) [app.common.uuid :as uuid]))
(defn focus-objects (defn focus-objects
[objects focus] [objects focus]
(let [[ids-with-children z-index] (let [ids-with-children
(when (d/not-empty? focus) (when (d/not-empty? focus)
[(into (conj focus uuid/zero) (into (conj focus uuid/zero)
(mapcat (partial cph/get-children-ids objects)) (mapcat (partial cph/get-children-ids objects))
focus) focus))]
(cpi/calculate-z-index objects)])
sort-by-z-index
(fn [coll]
(->> coll (sort-by (fn [a b] (- (get z-index a) (get z-index b))))))]
(cond-> objects (cond-> objects
(some? ids-with-children) (some? ids-with-children)
(-> (select-keys ids-with-children) (-> (select-keys ids-with-children)
(assoc-in [uuid/zero :shapes] (sort-by-z-index focus)))))) (assoc-in [uuid/zero :shapes] (cph/sort-z-index objects focus))))))
(defn filter-not-focus (defn filter-not-focus
[objects focus ids] [objects focus ids]

View File

@ -9,23 +9,36 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.spec.page :as spec.page] [app.common.types.page :as ctp]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(declare reduce-objects)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GENERIC SHAPE SELECTORS AND PREDICATES ;; GENERIC SHAPE SELECTORS AND PREDICATES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn root-frame? (defn root?
[{:keys [id type]}] [{:keys [id type]}]
(and (= type :frame) (and (= type :frame) (= id uuid/zero)))
(= id uuid/zero)))
(defn root-frame?
([objects id]
(root-frame? (get objects id)))
([{:keys [frame-id type]}]
(and (= type :frame)
(= frame-id uuid/zero))))
(defn frame-shape? (defn frame-shape?
[{:keys [type]}] ([objects id]
(= type :frame)) (frame-shape? (get objects id)))
([{:keys [type]}]
(= type :frame)))
(defn group-shape? (defn group-shape?
[{:keys [type]}] [{:keys [type]}]
@ -39,6 +52,10 @@
[{:keys [type]}] [{:keys [type]}]
(= type :image)) (= type :image))
(defn svg-raw-shape?
[{:keys [type]}]
(= type :svg-raw))
(defn unframed-shape? (defn unframed-shape?
"Checks if it's a non-frame shape in the top level." "Checks if it's a non-frame shape in the top level."
[shape] [shape]
@ -47,7 +64,7 @@
(defn get-shape (defn get-shape
[container shape-id] [container shape-id]
(us/assert ::spec.page/container container) (us/assert ::ctp/container container)
(us/assert ::us/uuid shape-id) (us/assert ::us/uuid shape-id)
(-> container (-> container
(get :objects) (get :objects)
@ -93,9 +110,10 @@
"Returns a vector of parents of the specified shape." "Returns a vector of parents of the specified shape."
[objects shape-id] [objects shape-id]
(loop [result [] id shape-id] (loop [result [] id shape-id]
(if-let [parent-id (dm/get-in objects [id :parent-id])] (let [parent-id (dm/get-in objects [id :parent-id])]
(recur (conj result parent-id) parent-id) (if (and (some? parent-id) (not= parent-id id))
result))) (recur (conj result parent-id) parent-id)
result))))
(defn get-frame (defn get-frame
"Get the frame that contains the shape. If the shape is already a "Get the frame that contains the shape. If the shape is already a
@ -140,38 +158,146 @@
(:shapes) (:shapes)
(keep lookup))))) (keep lookup)))))
(defn get-frames-ids
"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?)
(map :id))]
(->> (:shapes (lookup uuid/zero))
(into [] xform))))
(defn get-frames (defn get-frames
"Retrieves all frame objects as vector"
[objects]
(if (contains? (meta objects) ::index-frames)
(::index-frames (meta objects))
(let [lookup (d/getf objects)
xform (comp (remove #(= uuid/zero %))
(keep lookup)
(filter frame-shape?))]
(->> (keys objects)
(into [] xform)))))
(defn get-frames-ids
"Retrieves all frame ids as vector"
[objects]
(->> (get-frames objects)
(mapv :id)))
(defn get-nested-frames
[objects frame-id]
(into #{}
(comp (filter frame-shape?)
(map :id))
(get-children objects frame-id)))
(defn get-root-frames-ids
"Retrieves all frame objects as vector. It is not implemented in "Retrieves all frame objects as vector. It is not implemented in
function of `get-immediate-children` for performance reasons. This function of `get-immediate-children` for performance reasons. This
function is executed in the render hot path." function is executed in the render hot path."
[objects] [objects]
(let [lookup (d/getf objects) (let [add-frame
xform (comp (keep lookup) (fn [result shape]
(filter frame-shape?))] (cond-> result
(->> (:shapes (lookup uuid/zero)) (frame-shape? shape)
(into [] xform)))) (conj (:id shape))))]
(reduce-objects objects (complement frame-shape?) add-frame [])))
(defn get-root-objects
"Get all the objects under the root object"
[objects]
(let [add-shape
(fn [result shape]
(conj result shape))]
(reduce-objects objects (complement frame-shape?) add-shape [])))
(defn get-root-shapes
"Get all shapes that are not frames"
[objects]
(let [add-shape
(fn [result shape]
(cond-> result
(not (frame-shape? shape))
(conj shape)))]
(reduce-objects objects (complement frame-shape?) add-shape [])))
(defn get-root-shapes-ids
[objects]
(->> (get-root-shapes objects)
(mapv :id)))
(defn- get-base
[objects id-a id-b]
(let [parents-a (reverse (get-parents-seq objects id-a))
parents-b (reverse (get-parents-seq objects id-b))
[base base-child-a base-child-b]
(loop [parents-a (rest parents-a)
parents-b (rest parents-b)
base uuid/zero]
(cond
(not= (first parents-a) (first parents-b))
[base (first parents-a) (first parents-b)]
(or (empty? parents-a) (empty? parents-b))
[uuid/zero (first parents-a) (first parents-b)]
:else
(recur (rest parents-a) (rest parents-b) (first parents-a))))
index-base-a (when base-child-a (get-position-on-parent objects base-child-a))
index-base-b (when base-child-b (get-position-on-parent objects base-child-b))]
[base index-base-a index-base-b]))
(defn is-shape-over-shape?
[objects base-shape-id over-shape-id {:keys [top-frames?]}]
(let [[base index-a index-b] (get-base objects base-shape-id over-shape-id)]
(cond
(= base base-shape-id)
(and (not top-frames?)
(frame-shape? objects base-shape-id)
(root-frame? objects base-shape-id))
(= base over-shape-id)
(or top-frames?
(not (frame-shape? objects over-shape-id))
(not (root-frame? objects over-shape-id)))
:else
(< index-a index-b))))
(defn sort-z-index
([objects ids]
(sort-z-index objects ids nil))
([objects ids {:keys [bottom-frames?] :as options}]
(letfn [(comp [id-a id-b]
(let [type-a (dm/get-in objects [id-a :type])
type-b (dm/get-in objects [id-b :type])]
(cond
(and bottom-frames? (= :frame type-a) (not= :frame type-b))
1
(and bottom-frames? (not= :frame type-a) (= :frame type-b))
-1
(= id-a id-b)
0
(is-shape-over-shape? objects id-a id-b options)
1
:else
-1)))]
(sort comp ids))))
(defn frame-id-by-position (defn frame-id-by-position
[objects position] [objects position]
(let [frames (get-frames objects)] (let [top-frame
(or (->> (get-frames-ids objects)
(->> frames (sort-z-index objects)
(reverse) (d/seek #(and position (gsh/has-point? (get objects %) position))))]
(d/seek #(and position (gsh/has-point? % position))) (or top-frame uuid/zero)))
:id)
uuid/zero))) (defn frame-by-position
[objects position]
(let [frame-id (frame-id-by-position objects position)]
(get objects frame-id)))
(declare indexed-shapes) (declare indexed-shapes)
@ -520,3 +646,87 @@
(-> (select-keys objects selected+parents) (-> (select-keys objects selected+parents)
(d/update-vals remove-children)))) (d/update-vals remove-children))))
(defn is-child?
[objects parent-id candidate-child-id]
(let [parents (get-parents-seq objects candidate-child-id)]
(some? (d/seek #(= % parent-id) parents))))
(defn reduce-objects
([objects reducer-fn init-val]
(reduce-objects objects nil reducer-fn init-val))
([objects check-children? reducer-fn init-val]
(reduce-objects objects check-children? uuid/zero reducer-fn init-val))
([objects check-children? root-id reducer-fn init-val]
(let [root-children (get-in objects [root-id :shapes])]
(if (empty? root-children)
init-val
(loop [current-val init-val
current-id (first root-children)
pending-ids (rest root-children)]
(let [current-shape (get objects current-id)
next-val (reducer-fn current-val current-shape)
next-pending-ids
(if (or (nil? check-children?) (check-children? current-shape))
(concat (or (:shapes current-shape) []) pending-ids)
pending-ids)]
(if (empty? next-pending-ids)
next-val
(recur next-val (first next-pending-ids) (rest next-pending-ids)))))))))
(defn selected-with-children
[objects selected]
(into selected
(mapcat #(get-children-ids objects %))
selected))
(defn get-shape-id-root-frame
[objects shape-id]
(->> (get-parents-seq objects shape-id)
(map (d/getf objects))
(d/seek #(and (= :frame (:type %))
(= uuid/zero (:frame-id %))))
:id))
(defn get-viewer-frames
([objects]
(get-viewer-frames objects nil))
([objects {:keys [all-frames?]}]
(into []
(comp (map (d/getf objects))
(if all-frames?
identity
(remove :hide-in-viewer)))
(sort-z-index objects (get-frames-ids objects) {:top-frames? true}))))
(defn start-page-index
[objects]
(with-meta objects {::index-frames (get-frames (with-meta objects nil))}))
(defn update-page-index
[objects]
(with-meta objects {::index-frames (get-frames (with-meta objects nil))}))
(defn start-object-indices
[file]
(letfn [(process-index [page-index page-id]
(update-in page-index [page-id :objects] start-page-index))]
(update file :pages-index #(reduce process-index % (keys %)))))
(defn update-object-indices
[file page-id]
(update-in file [:pages-index page-id :objects] update-page-index))
(defn rotated-frame?
[frame]
(not (mth/almost-zero? (:rotation frame 0))))

View File

@ -8,76 +8,7 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.pages.helpers :as cph] [app.common.pages.helpers :as cph]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]))
[clojure.set :as set]))
(defn calculate-frame-z-index
[z-index frame-id base-idx objects]
(let [is-frame? (fn [id] (= :frame (get-in objects [id :type])))
children (or (get-in objects [frame-id :shapes]) [])]
(if (empty? children)
z-index
(loop [current (peek children)
pending (pop children)
current-idx base-idx
z-index z-index]
(let [children (get-in objects [current :shapes])
is-frame? (is-frame? current)
pending (if (not is-frame?)
(d/concat-vec pending children)
pending)]
(if (empty? pending)
(assoc z-index current current-idx)
(recur (peek pending)
(pop pending)
(dec current-idx)
(assoc z-index current current-idx))))))))
;; The z-index is really calculated per-frame. Every frame will have its own
;; internal z-index. To calculate the "final" z-index we add the shape z-index with
;; the z-index of its frame. This way we can update the z-index per frame without
;; the need of recalculate all the frames
(defn calculate-z-index
"Given a collection of shapes calculates their z-index. Greater index
means is displayed over other shapes with less index."
[objects]
(let [frames (cph/get-frames objects)
by-frame (cph/objects-by-frame objects)
frame-base-idx (d/update-vals by-frame count)
z-index (calculate-frame-z-index {} uuid/zero (get frame-base-idx uuid/zero) objects)]
(->> frames
(reduce
(fn [z-index {:keys [id]}]
(calculate-frame-z-index z-index id (get frame-base-idx id) objects)) z-index))))
(defn update-z-index
"Updates the z-index given a set of ids to change and the old and new objects
representations"
[z-index changed-ids old-objects new-objects]
(let [old-frames (into #{} (map #(get-in old-objects [% :frame-id])) changed-ids)
new-frames (into #{} (map #(get-in new-objects [% :frame-id])) changed-ids)
changed-frames (set/union old-frames new-frames)
frames (->> (cph/get-frames new-objects)
(map :id)
(filter #(contains? changed-frames %)))
by-frame (cph/objects-by-frame new-objects)
frame-base-idx (d/update-vals by-frame count)
z-index (calculate-frame-z-index z-index uuid/zero (get frame-base-idx uuid/zero) new-objects)]
(->> frames
(reduce (fn [z-index id]
(calculate-frame-z-index z-index id (get frame-base-idx id) new-objects)) z-index))))
(defn generate-child-parent-index (defn generate-child-parent-index
[objects] [objects]
@ -102,11 +33,16 @@
"Retrieves the mask information for an object" "Retrieves the mask information for an object"
[objects parents-index] [objects parents-index]
(let [retrieve-clips (let [retrieve-clips
(fn [_ parents] (fn [parents]
(let [lookup-object (fn [id] (get objects id)) (let [lookup-object (fn [id] (get objects id))
get-clip-parents get-clip-parents
(fn [shape] (fn [shape]
(cond-> [] (cond-> []
(and (= :frame (:type shape))
(not (:show-content shape))
(not= uuid/zero (:id shape)))
(conj shape)
(:masked-group? shape) (:masked-group? shape)
(conj (get objects (->> shape :shapes first))) (conj (get objects (->> shape :shapes first)))
@ -117,5 +53,5 @@
(comp (map lookup-object) (comp (map lookup-object)
(mapcat get-clip-parents)) (mapcat get-clip-parents))
parents)))] parents)))]
(->> parents-index (-> parents-index
(d/mapm retrieve-clips)))) (d/update-vals retrieve-clips))))

View File

@ -15,7 +15,7 @@
[app.common.geom.shapes.path :as gsp] [app.common.geom.shapes.path :as gsp]
[app.common.path.bool :as pb] [app.common.path.bool :as pb]
[app.common.path.commands :as pc] [app.common.path.commands :as pc]
[app.common.spec.radius :as ctr])) [app.common.types.shape.radius :as ctsr]))
(def ^:const bezier-circle-c 0.551915024494) (def ^:const bezier-circle-c 0.551915024494)
@ -152,7 +152,7 @@
(defn rect->path (defn rect->path
"Creates a bezier curve that approximates a rounded corner rectangle" "Creates a bezier curve that approximates a rounded corner rectangle"
[{:keys [x y width height] :as shape}] [{:keys [x y width height] :as shape}]
(case (ctr/radius-mode shape) (case (ctsr/radius-mode shape)
:radius-1 :radius-1
(let [radius (gso/shape-corners-1 shape)] (let [radius (gso/shape-corners-1 shape)]
(draw-rounded-rect-path x y width height radius)) (draw-rounded-rect-path x y width height radius))

View File

@ -7,21 +7,16 @@
(ns app.common.pprint (ns app.common.pprint
(:refer-clojure :exclude [prn]) (:refer-clojure :exclude [prn])
(:require (:require
[cuerdas.core :as str]
[fipp.edn :as fpp])) [fipp.edn :as fpp]))
(defn pprint-str (defn pprint-str
[expr] [expr & {:keys [width level length]
(binding [*print-level* 8 :or {width 110 level 8 length 25}}]
*print-length* 25] (binding [*print-level* level
*print-length* length]
(with-out-str (with-out-str
(fpp/pprint expr {:width 110})))) (fpp/pprint expr {:width width}))))
(defn pprint (defn pprint
([expr] [expr & {:as opts}]
(println (pprint-str expr))) (println (pprint-str expr opts)))
([label expr]
(println (str/concat "============ " label "============"))
(pprint expr)))

View File

@ -5,7 +5,7 @@
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec (ns app.common.spec
"Data manipulation and query helper functions." "Data validation & assertion helpers."
(:refer-clojure :exclude [assert bytes?]) (:refer-clojure :exclude [assert bytes?])
#?(:cljs (:require-macros [app.common.spec :refer [assert]])) #?(:cljs (:require-macros [app.common.spec :refer [assert]]))
(:require (:require
@ -31,8 +31,6 @@
(def max-safe-int (int 1e6)) (def max-safe-int (int 1e6))
(def min-safe-int (int -1e6)) (def min-safe-int (int -1e6))
(def valid? s/valid?)
;; --- Conformers ;; --- Conformers
(defn uuid-conformer (defn uuid-conformer
@ -220,46 +218,102 @@
(fn [s] (fn [s]
(str/join "," s)))) (str/join "," s))))
;; --- Macros ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MACROS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn spec-assert* (defn explain-data
[spec val hint ctx] [spec value]
(if (s/valid? spec val) (s/explain-data spec value))
val
(let [data (s/explain-data spec val)]
(ex/raise :type :assertion
:code :spec-validation
:hint hint
::ex/data (merge ctx data)))))
(defmacro assert (defn valid?
"Development only assertion macro." [spec value]
[spec x] (s/valid? spec value))
(when *assert*
(let [nsdata (:ns &env)
context (if nsdata
{:ns (str (:name nsdata))
:name (pr-str spec)
:line (:line &env)
:file (:file (:meta nsdata))}
(let [mdata (meta &form)]
{:ns (str (ns-name *ns*))
:name (pr-str spec)
:line (:line mdata)}))
message (str "spec assert: '" (pr-str spec) "'")]
`(spec-assert* ~spec ~x ~message ~context))))
(defmacro verify (defmacro assert-expr*
"Always active assertion macro (does not obey to :elide-asserts)" "Auxiliar macro for expression assertion."
[spec x] [expr hint]
(let [nsdata (:ns &env) `(when-not ~expr
context (when nsdata (ex/raise :type :assertion
:code :expr-validation
:hint ~hint)))
(defmacro assert-spec*
"Auxiliar macro for spec assertion."
[spec value hint]
(let [context (if-let [nsdata (:ns &env)]
{:ns (str (:name nsdata)) {:ns (str (:name nsdata))
:name (pr-str spec) :name (pr-str spec)
:line (:line &env) :line (:line &env)
:file (:file (:meta nsdata))}) :file (:file (:meta nsdata))}
message (str "spec verify: '" (pr-str spec) "'")] {:ns (str (ns-name *ns*))
`(spec-assert* ~spec ~x ~message ~context))) :name (pr-str spec)
:line (:line (meta &form))})
hint (or hint (str "spec assert: " (pr-str spec)))]
`(if (valid? ~spec ~value)
~value
(let [data# (explain-data ~spec ~value)]
(ex/raise :type :assertion
:code :spec-validation
:hint ~hint
::ex/data (merge ~context data#))))))
(defmacro assert
"Is a spec specific assertion macro that only evaluates if *assert*
is true. DEPRECATED: it should be replaced by the new, general
purpose assert! macro."
[spec value]
(when *assert*
`(assert-spec* ~spec ~value nil)))
(defmacro verify
"Is a spec specific assertion macro that evaluates always,
independently of *assert* value. DEPRECATED: should be replaced by
the new, general purpose `verify!` macro."
[spec value]
`(assert-spec* ~spec ~value nil))
(defmacro assert!
"General purpose assertion macro."
[& params]
;; If we only receive two arguments, this means we use the simplified form
(let [pcnt (count params)]
(cond
;; When we have a single argument, this means a simplified form
;; of expr assertion
(= 1 pcnt)
(let [expr (first params)
hint (str "expr assert failed:" (pr-str expr))]
(when *assert*
`(assert-expr* ~expr ~hint)))
;; If we have two arguments, this can be spec or expr
;; assertion. The spec assertion is determined if the first
;; argument is a qualified keyword.
(= 2 pcnt)
(let [[spec-or-expr value-or-msg] params]
(if (qualified-keyword? spec-or-expr)
`(assert-spec* ~spec-or-expr ~value-or-msg nil)
`(assert-expr* ~spec-or-expr ~value-or-msg)))
(= 3 pcnt)
(let [[spec value hint] params]
`(assert-spec* ~spec ~value ~hint))
:else
(let [{:keys [spec expr hint always? val]} params]
(when (or always? *assert*)
(if spec
`(assert-spec* ~spec ~val ~hint)
`(assert-expr* ~expr ~hint)))))))
(defmacro verify!
"A variant of `assert!` macro that evaluates always, independently
of the *assert* value."
[& params]
(binding [*assert* true]
`(assert! ~@params)))
;; --- Public Api ;; --- Public Api

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.color (ns app.common.types.color
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.spec :as us] [app.common.spec :as us]

View File

@ -4,12 +4,11 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.file (ns app.common.types.file
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.common.spec.color :as color] [app.common.types.color :as ctc]
[app.common.spec.page :as page] [app.common.types.page :as ctp]
[app.common.spec.typography]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
(s/def :internal.media-object/name string?) (s/def :internal.media-object/name string?)
@ -31,13 +30,13 @@
:opt-un [:internal.media-object/path])) :opt-un [:internal.media-object/path]))
(s/def ::colors (s/def ::colors
(s/map-of uuid? ::color/color)) (s/map-of uuid? ::ctc/color))
(s/def ::recent-colors (s/def ::recent-colors
(s/coll-of ::color/recent-color :kind vector?)) (s/coll-of ::ctc/recent-color :kind vector?))
(s/def ::typographies (s/def ::typographies
(s/map-of uuid? :app.common.spec.typography/typography)) (s/map-of uuid? :ctst/typography))
(s/def ::pages (s/def ::pages
(s/coll-of uuid? :kind vector?)) (s/coll-of uuid? :kind vector?))
@ -46,10 +45,10 @@
(s/map-of uuid? ::media-object)) (s/map-of uuid? ::media-object))
(s/def ::pages-index (s/def ::pages-index
(s/map-of uuid? ::page/page)) (s/map-of uuid? ::ctp/page))
(s/def ::components (s/def ::components
(s/map-of uuid? ::page/container)) (s/map-of uuid? ::ctp/container))
(s/def ::data (s/def ::data
(s/keys :req-un [::pages-index (s/keys :req-un [::pages-index

View File

@ -4,11 +4,11 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.page (ns app.common.types.page
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.spec.shape :as shape] [app.common.types.shape :as cts]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
;; --- Grid options ;; --- Grid options
@ -90,7 +90,7 @@
(s/def ::id uuid?) (s/def ::id uuid?)
(s/def ::name string?) (s/def ::name string?)
(s/def ::objects (s/map-of uuid? ::shape/shape)) (s/def ::objects (s/map-of uuid? ::cts/shape))
(s/def ::page (s/def ::page
(s/keys :req-un [::id ::name ::objects ::options])) (s/keys :req-un [::id ::name ::objects ::options]))

View File

@ -4,17 +4,17 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.shape (ns app.common.types.shape
(:require (:require
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.spec.blur :as blur] [app.common.types.color :as ctc]
[app.common.spec.color :as color] [app.common.types.shape.blur :as ctsb]
[app.common.spec.export :as export] [app.common.types.shape.export :as ctse]
[app.common.spec.interactions :as cti] [app.common.types.shape.interactions :as ctsi]
[app.common.spec.radius :as radius] [app.common.types.shape.radius :as ctsr]
[app.common.spec.shadow :as shadow] [app.common.types.shape.shadow :as ctss]
[clojure.set :as set] [clojure.set :as set]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
@ -47,11 +47,13 @@
(s/def ::fill-color string?) (s/def ::fill-color string?)
(s/def ::fill-opacity ::us/safe-number) (s/def ::fill-opacity ::us/safe-number)
(s/def ::fill-color-gradient (s/nilable ::color/gradient)) (s/def ::fill-color-gradient (s/nilable ::ctc/gradient))
(s/def ::fill-color-ref-file (s/nilable uuid?)) (s/def ::fill-color-ref-file (s/nilable uuid?))
(s/def ::fill-color-ref-id (s/nilable uuid?)) (s/def ::fill-color-ref-id (s/nilable uuid?))
(s/def ::hide-fill-on-export boolean?) (s/def ::hide-fill-on-export boolean?)
(s/def ::show-content boolean?)
(s/def ::hide-in-viewer boolean?)
(s/def ::file-thumbnail boolean?) (s/def ::file-thumbnail boolean?)
(s/def ::masked-group? boolean?) (s/def ::masked-group? boolean?)
@ -67,7 +69,7 @@
(s/def ::proportion ::us/safe-number) (s/def ::proportion ::us/safe-number)
(s/def ::proportion-lock boolean?) (s/def ::proportion-lock boolean?)
(s/def ::stroke-color string?) (s/def ::stroke-color string?)
(s/def ::stroke-color-gradient (s/nilable ::color/gradient)) (s/def ::stroke-color-gradient (s/nilable ::ctc/gradient))
(s/def ::stroke-color-ref-file (s/nilable uuid?)) (s/def ::stroke-color-ref-file (s/nilable uuid?))
(s/def ::stroke-color-ref-id (s/nilable uuid?)) (s/def ::stroke-color-ref-id (s/nilable uuid?))
(s/def ::stroke-opacity ::us/safe-number) (s/def ::stroke-opacity ::us/safe-number)
@ -100,7 +102,7 @@
(s/keys :req-un [::x ::y ::x1 ::y1 ::x2 ::y2 ::width ::height])) (s/keys :req-un [::x ::y ::x1 ::y1 ::x2 ::y2 ::width ::height]))
(s/def ::exports (s/def ::exports
(s/coll-of ::export/export :kind vector?)) (s/coll-of ::ctse/export :kind vector?))
(s/def ::points (s/def ::points
(s/every ::gpt/point :kind vector?)) (s/every ::gpt/point :kind vector?))
@ -185,12 +187,12 @@
::constraints-h ::constraints-h
::constraints-v ::constraints-v
::fixed-scroll ::fixed-scroll
::radius/rx ::ctsr/rx
::radius/ry ::ctsr/ry
::radius/r1 ::ctsr/r1
::radius/r2 ::ctsr/r2
::radius/r3 ::ctsr/r3
::radius/r4 ::ctsr/r4
::x ::x
::y ::y
::exports ::exports
@ -211,9 +213,9 @@
::width ::width
::height ::height
::masked-group? ::masked-group?
::cti/interactions ::ctsi/interactions
::shadow/shadow ::ctss/shadow
::blur/blur ::ctsb/blur
::opacity ::opacity
::blend-mode])) ::blend-mode]))
@ -254,8 +256,7 @@
:internal.shape.text.position-data/rtl :internal.shape.text.position-data/rtl
:internal.shape.text.position-data/text :internal.shape.text.position-data/text
:internal.shape.text.position-data/text-decoration :internal.shape.text.position-data/text-decoration
:internal.shape.text.position-data/text-transform] :internal.shape.text.position-data/text-transform]))
))
(s/def :internal.shape.text.position-data/x ::us/safe-number) (s/def :internal.shape.text.position-data/x ::us/safe-number)
(s/def :internal.shape.text.position-data/y ::us/safe-number) (s/def :internal.shape.text.position-data/y ::us/safe-number)
@ -303,7 +304,9 @@
(defmethod shape-spec :frame [_] (defmethod shape-spec :frame [_]
(s/and ::shape-attrs (s/and ::shape-attrs
(s/keys :opt-un [::file-thumbnail (s/keys :opt-un [::file-thumbnail
::hide-fill-on-export]))) ::hide-fill-on-export
::show-content
::hide-in-viewer])))
(s/def ::shape (s/def ::shape
(s/and (s/multi-spec shape-spec :type) (s/and (s/multi-spec shape-spec :type)

View File

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

View File

@ -4,12 +4,11 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.export (ns app.common.types.shape.export
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
(s/def ::suffix string?) (s/def ::suffix string?)
(s/def ::scale ::us/safe-number) (s/def ::scale ::us/safe-number)
(s/def ::type keyword?) (s/def ::type keyword?)
@ -19,4 +18,3 @@
::suffix ::suffix
::scale])) ::scale]))

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.interactions (ns app.common.types.shape.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]

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.radius (ns app.common.types.shape.radius
(:require (:require
[app.common.pages.common :refer [editable-attrs]] [app.common.pages.common :refer [editable-attrs]]
[app.common.spec :as us] [app.common.spec :as us]

View File

@ -4,10 +4,10 @@
;; ;;
;; Copyright (c) UXBOX Labs SL ;; Copyright (c) UXBOX Labs SL
(ns app.common.spec.shadow (ns app.common.types.shape.shadow
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.common.spec.color :as color] [app.common.types.color :as ctc]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
@ -24,7 +24,7 @@
(s/def ::color string?) (s/def ::color string?)
(s/def ::opacity ::us/safe-number) (s/def ::opacity ::us/safe-number)
(s/def ::gradient (s/nilable ::color/gradient)) (s/def ::gradient (s/nilable ::ctc/gradient))
(s/def ::file-id (s/nilable uuid?)) (s/def ::file-id (s/nilable uuid?))
(s/def ::ref-id (s/nilable uuid?)) (s/def ::ref-id (s/nilable uuid?))

View File

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

View File

@ -48,3 +48,6 @@
#?(:clj #?(:clj
(dm/export impl/get-word-high)) (dm/export impl/get-word-high))
#?(:clj
(dm/export impl/get-word-low))

View File

@ -10,62 +10,62 @@
[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.spec.interactions :as csi] [app.common.types.shape.interactions :as ctsi]
[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 csi/default-interaction (let [interaction ctsi/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
(csi/set-event-type interaction :click shape)] (ctsi/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
(csi/set-event-type interaction :mouse-press shape)] (ctsi/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
(csi/set-event-type interaction :after-delay shape))] (ctsi/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
(csi/set-event-type interaction :after-delay frame)] (ctsi/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
(csi/set-event-type interaction :after-delay frame)] (ctsi/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 csi/default-interaction] (let [interaction ctsi/default-interaction]
(t/testing "Set action type unchanged" (t/testing "Set action type unchanged"
(let [new-interaction (let [new-interaction
(csi/set-action-type interaction :navigate)] (ctsi/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
(csi/set-action-type interaction :prev-screen)] (ctsi/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
(csi/set-action-type interaction :navigate)] (ctsi/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
(csi/set-action-type interaction :navigate)] (ctsi/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
(csi/set-action-type interaction :open-overlay)] (ctsi/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
(csi/set-action-type interaction :open-overlay)] (ctsi/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
(csi/set-action-type interaction :toggle-overlay)] (ctsi/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
(csi/set-action-type interaction :toggle-overlay)] (ctsi/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
(csi/set-action-type interaction :close-overlay)] (ctsi/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
(csi/set-action-type interaction :close-overlay)] (ctsi/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
(csi/set-action-type interaction :prev-screen)] (ctsi/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
(csi/set-action-type interaction :open-url)] (ctsi/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
(csi/set-action-type interaction :open-url)] (ctsi/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 csi/default-interaction i1 ctsi/default-interaction
i2 (csi/set-event-type i1 :after-delay frame)] i2 (ctsi/set-event-type i1 :after-delay frame)]
(t/testing "Has delay" (t/testing "Has delay"
(t/is (not (csi/has-delay i1))) (t/is (not (ctsi/has-delay i1)))
(t/is (csi/has-delay i2))) (t/is (ctsi/has-delay i2)))
(t/testing "Set delay" (t/testing "Set delay"
(let [new-interaction (csi/set-delay i2 1000)] (let [new-interaction (ctsi/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 csi/default-interaction i1 ctsi/default-interaction
i2 (csi/set-action-type i1 :prev-screen) i2 (ctsi/set-action-type i1 :prev-screen)
i3 (csi/set-action-type i1 :open-overlay)] i3 (ctsi/set-action-type i1 :open-overlay)]
(t/testing "Has destination" (t/testing "Has destination"
(t/is (csi/has-destination i1)) (t/is (ctsi/has-destination i1))
(t/is (not (csi/has-destination i2)))) (t/is (not (ctsi/has-destination i2))))
(t/testing "Set destination" (t/testing "Set destination"
(let [new-interaction (csi/set-destination i1 destination)] (let [new-interaction (ctsi/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 (csi/set-destination i3 destination)] (let [new-interaction (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-action-type i1 :prev-screen)] i2 (ctsi/set-action-type i1 :prev-screen)]
(t/testing "Has preserve-scroll" (t/testing "Has preserve-scroll"
(t/is (csi/has-preserve-scroll i1)) (t/is (ctsi/has-preserve-scroll i1))
(t/is (not (csi/has-preserve-scroll i2)))) (t/is (not (ctsi/has-preserve-scroll i2))))
(t/testing "Set preserve-scroll" (t/testing "Set preserve-scroll"
(let [new-interaction (csi/set-preserve-scroll i1 true)] (let [new-interaction (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-action-type i1 :open-url)] i2 (ctsi/set-action-type i1 :open-url)]
(t/testing "Has url" (t/testing "Has url"
(t/is (not (csi/has-url i1))) (t/is (not (ctsi/has-url i1)))
(t/is (csi/has-url i2))) (t/is (ctsi/has-url i2)))
(t/testing "Set url" (t/testing "Set url"
(let [new-interaction (csi/set-url i2 "https://example.com")] (let [new-interaction (ctsi/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 csi/default-interaction i1 ctsi/default-interaction
i2 (csi/set-action-type i1 :open-overlay) i2 (ctsi/set-action-type i1 :open-overlay)
i3 (-> i1 i3 (-> i1
(csi/set-action-type :open-overlay) (ctsi/set-action-type :open-overlay)
(csi/set-destination (:id overlay-frame)))] (ctsi/set-destination (:id overlay-frame)))]
(t/testing "Has overlay options" (t/testing "Has overlay options"
(t/is (not (csi/has-overlay-opts i1))) (t/is (not (ctsi/has-overlay-opts i1)))
(t/is (csi/has-overlay-opts i2))) (t/is (ctsi/has-overlay-opts i2)))
(t/testing "Set overlay-pos-type without destination" (t/testing "Set overlay-pos-type without destination"
(let [new-interaction (csi/set-overlay-pos-type i2 :top-right base-frame objects)] (let [new-interaction (ctsi/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 (csi/set-overlay-pos-type i3 :bottom-right base-frame objects)] (let [new-interaction (ctsi/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 (csi/set-overlay-pos-type i3 :manual base-frame objects)] (let [new-interaction (ctsi/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 (csi/toggle-overlay-pos-type i3 :center base-frame objects) (let [new-interaction (ctsi/toggle-overlay-pos-type i3 :center base-frame objects)
new-interaction-2 (csi/toggle-overlay-pos-type new-interaction :center base-frame objects) new-interaction-2 (ctsi/toggle-overlay-pos-type new-interaction :center base-frame objects)
new-interaction-3 (csi/toggle-overlay-pos-type new-interaction-2 :top-right base-frame objects)] new-interaction-3 (ctsi/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 (csi/set-overlay-position i3 (gpt/point 50 60))] (let [new-interaction (ctsi/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 (csi/set-close-click-outside i3 true)] (let [new-interaction (ctsi/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 (csi/set-background-overlay i3 true)] (let [new-interaction (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-action-type i1 :open-overlay) i2 (ctsi/set-action-type i1 :open-overlay)
i3 (csi/set-action-type i1 :toggle-overlay) i3 (ctsi/set-action-type i1 :toggle-overlay)
i4 (csi/set-action-type i1 :close-overlay) i4 (ctsi/set-action-type i1 :close-overlay)
i5 (csi/set-action-type i1 :prev-screen) i5 (ctsi/set-action-type i1 :prev-screen)
i6 (csi/set-action-type i1 :open-url)] i6 (ctsi/set-action-type i1 :open-url)]
(t/testing "Has animation?" (t/testing "Has animation?"
(t/is (csi/has-animation? i1)) (t/is (ctsi/has-animation? i1))
(t/is (csi/has-animation? i2)) (t/is (ctsi/has-animation? i2))
(t/is (csi/has-animation? i3)) (t/is (ctsi/has-animation? i3))
(t/is (csi/has-animation? i4)) (t/is (ctsi/has-animation? i4))
(t/is (not (csi/has-animation? i5))) (t/is (not (ctsi/has-animation? i5)))
(t/is (not (csi/has-animation? i6)))) (t/is (not (ctsi/has-animation? i6))))
(t/testing "Valid push?" (t/testing "Valid push?"
(t/is (csi/allow-push? (:action-type i1))) (t/is (ctsi/allow-push? (:action-type i1)))
(t/is (not (csi/allow-push? (:action-type i2)))) (t/is (not (ctsi/allow-push? (:action-type i2))))
(t/is (not (csi/allow-push? (:action-type i3)))) (t/is (not (ctsi/allow-push? (:action-type i3))))
(t/is (not (csi/allow-push? (:action-type i4)))) (t/is (not (ctsi/allow-push? (:action-type i4))))
(t/is (not (csi/allow-push? (:action-type i5)))) (t/is (not (ctsi/allow-push? (:action-type i5))))
(t/is (not (csi/allow-push? (:action-type i6))))))) (t/is (not (ctsi/allow-push? (:action-type i6)))))))
(t/deftest set-animation-type (t/deftest set-animation-type
(let [i1 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-animation-type i1 :dissolve)] i2 (ctsi/set-animation-type i1 :dissolve)]
(t/testing "Set animation type nil" (t/testing "Set animation type nil"
(let [new-interaction (let [new-interaction
(csi/set-animation-type i1 nil)] (ctsi/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
(csi/set-animation-type i2 :dissolve)] (ctsi/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
(csi/set-animation-type i2 :slide)] (ctsi/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
(csi/set-animation-type i2 nil)] (ctsi/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
(csi/set-animation-type i1 :dissolve)] (ctsi/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
(csi/set-animation-type interaction :dissolve)] (ctsi/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
(csi/set-animation-type i1 :slide)] (ctsi/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
(csi/set-animation-type interaction :slide)] (ctsi/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
(csi/set-animation-type i1 :push)] (ctsi/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
(csi/set-animation-type interaction :push)] (ctsi/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 (csi/set-action-type csi/default-interaction :open-overlay) (let [i1 (ctsi/set-action-type ctsi/default-interaction :open-overlay)
i2 (csi/set-action-type csi/default-interaction :close-overlay) i2 (ctsi/set-action-type ctsi/default-interaction :close-overlay)
i3 (csi/set-action-type csi/default-interaction :toggle-overlay)] i3 (ctsi/set-action-type ctsi/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 (csi/allowed-animation? (:action-type bad-interaction-1) (t/is (not (ctsi/allowed-animation? (:action-type bad-interaction-1)
(-> bad-interaction-1 :animation :animation-type)))) (-> bad-interaction-1 :animation :animation-type))))
(t/is (not (csi/allowed-animation? (:action-type bad-interaction-2) (t/is (not (ctsi/allowed-animation? (:action-type bad-interaction-2)
(-> bad-interaction-1 :animation :animation-type)))) (-> bad-interaction-1 :animation :animation-type))))
(t/is (not (csi/allowed-animation? (:action-type bad-interaction-3) (t/is (not (ctsi/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 (csi/set-animation-type csi/default-interaction :push) (let [interaction (ctsi/set-animation-type ctsi/default-interaction :push)
new-interaction (csi/set-action-type interaction :open-overlay)] new-interaction (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-animation-type csi/default-interaction :dissolve)] i2 (ctsi/set-animation-type ctsi/default-interaction :dissolve)]
(t/testing "Has duration?" (t/testing "Has duration?"
(t/is (not (csi/has-duration? i1))) (t/is (not (ctsi/has-duration? i1)))
(t/is (csi/has-duration? i2))) (t/is (ctsi/has-duration? i2)))
(t/testing "Set duration" (t/testing "Set duration"
(let [new-interaction (csi/set-duration i2 1000)] (let [new-interaction (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-animation-type csi/default-interaction :dissolve)] i2 (ctsi/set-animation-type ctsi/default-interaction :dissolve)]
(t/testing "Has easing?" (t/testing "Has easing?"
(t/is (not (csi/has-easing? i1))) (t/is (not (ctsi/has-easing? i1)))
(t/is (csi/has-easing? i2))) (t/is (ctsi/has-easing? i2)))
(t/testing "Set easing" (t/testing "Set easing"
(let [new-interaction (csi/set-easing i2 :ease-in)] (let [new-interaction (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-animation-type csi/default-interaction :slide) i2 (ctsi/set-animation-type ctsi/default-interaction :slide)
i3 (csi/set-action-type i2 :open-overlay)] i3 (ctsi/set-action-type i2 :open-overlay)]
(t/testing "Has way?" (t/testing "Has way?"
(t/is (not (csi/has-way? i1))) (t/is (not (ctsi/has-way? i1)))
(t/is (csi/has-way? i2)) (t/is (ctsi/has-way? i2))
(t/is (not (csi/has-way? i3))) (t/is (not (ctsi/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 (csi/set-way i2 :out)] (let [new-interaction (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-animation-type csi/default-interaction :push) i2 (ctsi/set-animation-type ctsi/default-interaction :push)
i3 (csi/set-animation-type csi/default-interaction :dissolve)] i3 (ctsi/set-animation-type ctsi/default-interaction :dissolve)]
(t/testing "Has direction?" (t/testing "Has direction?"
(t/is (not (csi/has-direction? i1))) (t/is (not (ctsi/has-direction? i1)))
(t/is (csi/has-direction? i2))) (t/is (ctsi/has-direction? i2)))
(t/testing "Set direction" (t/testing "Set direction"
(let [new-interaction (csi/set-direction i2 :left)] (let [new-interaction (ctsi/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' (csi/invert-direction nil) a-nil' (ctsi/invert-direction nil)
a-none' (csi/invert-direction a-none) a-none' (ctsi/invert-direction a-none)
a-right' (csi/invert-direction a-right) a-right' (ctsi/invert-direction a-right)
a-left' (csi/invert-direction a-left) a-left' (ctsi/invert-direction a-left)
a-up' (csi/invert-direction a-up) a-up' (ctsi/invert-direction a-up)
a-down' (csi/invert-direction a-down)] a-down' (ctsi/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 csi/default-interaction (let [i1 ctsi/default-interaction
i2 (csi/set-animation-type csi/default-interaction :slide) i2 (ctsi/set-animation-type ctsi/default-interaction :slide)
i3 (csi/set-action-type i2 :open-overlay)] i3 (ctsi/set-action-type i2 :open-overlay)]
(t/testing "Has offset-effect" (t/testing "Has offset-effect"
(t/is (not (csi/has-offset-effect? i1))) (t/is (not (ctsi/has-offset-effect? i1)))
(t/is (csi/has-offset-effect? i2)) (t/is (ctsi/has-offset-effect? i2))
(t/is (not (csi/has-offset-effect? i3))) (t/is (not (ctsi/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 (csi/set-offset-effect i2 true)] (let [new-interaction (ctsi/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 (csi/set-action-type csi/default-interaction :open-overlay) (let [i1 (ctsi/set-action-type ctsi/default-interaction :open-overlay)
i2 (csi/set-action-type csi/default-interaction :close-overlay) i2 (ctsi/set-action-type ctsi/default-interaction :close-overlay)
i3 (csi/set-action-type csi/default-interaction :prev-screen) i3 (ctsi/set-action-type ctsi/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 (csi/add-interaction nil i3)] (let [new-interactions (ctsi/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 (csi/add-interaction interactions i3)] (let [new-interactions (ctsi/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 (csi/remove-interaction interactions 0)] (let [new-interactions (ctsi/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 (csi/update-interaction interactions 1 #(csi/set-action-type % :open-url))] (let [new-interactions (ctsi/update-interaction interactions 1 #(ctsi/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 (csi/set-destination csi/default-interaction (:id frame1)) i1 (ctsi/set-destination ctsi/default-interaction (:id frame1))
i2 (csi/set-destination csi/default-interaction (:id frame2)) i2 (ctsi/set-destination ctsi/default-interaction (:id frame2))
i3 (csi/set-destination csi/default-interaction (:id frame3)) i3 (ctsi/set-destination ctsi/default-interaction (:id frame3))
i4 (csi/set-destination csi/default-interaction nil) i4 (ctsi/set-destination ctsi/default-interaction nil)
i5 (csi/set-destination csi/default-interaction (:id frame6)) i5 (ctsi/set-destination ctsi/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 (csi/remap-interactions interactions ids-map objects)] (let [new-interactions (ctsi/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.17.3: shadow-cljs@2.19.3:
version "2.17.3" version "2.19.3"
resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.17.3.tgz#748e31f67cffdc401691c0cd1bf733a1da53ab5d" resolved "https://registry.yarnpkg.com/shadow-cljs/-/shadow-cljs-2.19.3.tgz#115a33917f8bca1495e0f815dca7ec3957f669af"
integrity sha512-GxyczUuCtACq/uEOvdTc61wT/aDOZFy8G/AGc322uTX/oUiZaeTJrwpClXe+0+e7VKG9E9RCqP/cjuG3cAG0fw== integrity sha512-9TsTCRlmR8m1g2ekwblgomRUgJpbifQI99VlRrlH9NMqEzklev3zYAD1dvy4d5h8BoAhgdxOOEg7ld2d45CWTA==
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,12 +1,12 @@
FROM ubuntu:20.04 FROM ubuntu:22.04
LABEL maintainer="Andrey Antukh <niwi@niwi.nz>" LABEL maintainer="Andrey Antukh <niwi@niwi.nz>"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ENV NODE_VERSION=v16.14.2 \ ENV NODE_VERSION=v16.15.1 \
CLOJURE_VERSION=1.11.0.1100 \ CLOJURE_VERSION=1.11.1.1149 \
CLJKONDO_VERSION=2022.03.09 \ CLJKONDO_VERSION=2022.06.22 \
BABASHKA_VERSION=0.8.0 \ BABASHKA_VERSION=0.8.156 \
LANG=en_US.UTF-8 \ LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 LC_ALL=en_US.UTF-8
@ -44,7 +44,6 @@ RUN set -ex; \
RUN set -ex; \ RUN set -ex; \
apt-get -qq update; \ apt-get -qq update; \
apt-get -qqy install --no-install-recommends \ apt-get -qqy install --no-install-recommends \
python \
build-essential \ build-essential \
imagemagick \ imagemagick \
ghostscript \ ghostscript \
@ -104,7 +103,7 @@ RUN set -ex; \
rm -rf /var/lib/apt/lists/*; rm -rf /var/lib/apt/lists/*;
RUN set -ex; \ RUN set -ex; \
curl -LfsSo /tmp/openjdk.tar.gz https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18%2B36/OpenJDK18U-jdk_x64_linux_hotspot_18_36.tar.gz; \ curl -LfsSo /tmp/openjdk.tar.gz https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_linux_hotspot_18.0.1_10.tar.gz; \
mkdir -p /usr/lib/jvm/openjdk; \ mkdir -p /usr/lib/jvm/openjdk; \
cd /usr/lib/jvm/openjdk; \ cd /usr/lib/jvm/openjdk; \
tar -xf /tmp/openjdk.tar.gz --strip-components=1; \ tar -xf /tmp/openjdk.tar.gz --strip-components=1; \
@ -120,7 +119,7 @@ RUN set -ex; \
RUN set -ex; \ RUN set -ex; \
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -; \ curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -; \
echo "deb http://apt.postgresql.org/pub/repos/apt focal-pgdg main" >> /etc/apt/sources.list.d/postgresql.list; \ echo "deb http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" >> /etc/apt/sources.list.d/postgresql.list; \
apt-get -qq update; \ apt-get -qq update; \
apt-get -qqy install postgresql-client-13; \ apt-get -qqy install postgresql-client-13; \
rm -rf /var/lib/apt/lists/*; rm -rf /var/lib/apt/lists/*;
@ -132,8 +131,8 @@ RUN set -ex; \
tar -xf /tmp/nodejs.tar.xz --strip-components=1; \ tar -xf /tmp/nodejs.tar.xz --strip-components=1; \
chown -R root /usr/local/nodejs; \ chown -R root /usr/local/nodejs; \
PATH="$PATH:/usr/local/nodejs/bin"; \ PATH="$PATH:/usr/local/nodejs/bin"; \
/usr/local/nodejs/bin/npm install -g yarn; \ /usr/local/nodejs/bin/npm install --location=global yarn; \
/usr/local/nodejs/bin/npm install -g svgo; \ /usr/local/nodejs/bin/npm install --location=global svgo; \
rm -rf /tmp/nodejs.tar.xz; rm -rf /tmp/nodejs.tar.xz;
# Install clj-kondo # Install clj-kondo
@ -143,7 +142,6 @@ RUN set -ex; \
unzip /tmp/clj-kondo.zip; \ unzip /tmp/clj-kondo.zip; \
rm /tmp/clj-kondo.zip; rm /tmp/clj-kondo.zip;
# Install babashka
RUN set -ex; \ RUN set -ex; \
cd /tmp; \ cd /tmp; \
curl -LfsSo /tmp/babashka.tar.gz https://github.com/babashka/babashka/releases/download/v$BABASHKA_VERSION/babashka-$BABASHKA_VERSION-linux-amd64.tar.gz; \ curl -LfsSo /tmp/babashka.tar.gz https://github.com/babashka/babashka/releases/download/v$BABASHKA_VERSION/babashka-$BABASHKA_VERSION-linux-amd64.tar.gz; \
@ -151,8 +149,10 @@ RUN set -ex; \
tar -xf /tmp/babashka.tar.gz; \ tar -xf /tmp/babashka.tar.gz; \
rm -rf /tmp/babashka.tar.gz; rm -rf /tmp/babashka.tar.gz;
# Install minio client
RUN set -ex; \ RUN set -ex; \
curl -LfsSo /tmp/mc https://dl.min.io/client/mc/release/linux-amd64/mc --user-agent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15"; \ wget -O /tmp/mc https://dl.min.io/client/mc/release/linux-amd64/mc; \
mv /tmp/mc /usr/local/bin/; \ mv /tmp/mc /usr/local/bin/; \
chmod +x /usr/local/bin/mc; chmod +x /usr/local/bin/mc;

View File

@ -1,4 +1,4 @@
FROM ubuntu:20.04 FROM ubuntu:22.04
LABEL maintainer="Andrey Antukh <niwi@niwi.nz>" LABEL maintainer="Andrey Antukh <niwi@niwi.nz>"
ENV LANG='en_US.UTF-8' LC_ALL='en_US.UTF-8' ENV LANG='en_US.UTF-8' LC_ALL='en_US.UTF-8'
@ -27,16 +27,16 @@ RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \ ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \ case "${ARCH}" in \
aarch64|arm64) \ aarch64|arm64) \
ESUM='7217a9f9be3b0c8dfc78538f95fd2deb493eb651152d975062920566492b2574'; \ ESUM='37ceaf232a85cce46bcccfd71839854e8b14bf3160e7ef72a676b9cae45ee8af'; \
BINARY_URL='https://github.com/AdoptOpenJDK/openjdk16-binaries/releases/download/jdk-16%2B36/OpenJDK16-jdk_aarch64_linux_hotspot_16_36.tar.gz'; \ BINARY_URL='https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_aarch64_linux_hotspot_18.0.1_10.tar.gz'; \
;; \ ;; \
armhf|armv7l) \ armhf|armv7l) \
ESUM='f1d32ba01a40c98889f31368c0e987d6bbda65a7c50b8c088623b48e3a90104a'; \ ESUM='0ddec3c165ab0b662a57a845db3fdaeb840660b493f164696b03df76aadf61c8'; \
BINARY_URL='https://github.com/AdoptOpenJDK/openjdk16-binaries/releases/download/jdk-16%2B36/OpenJDK16-jdk_arm_linux_hotspot_16_36.tar.gz'; \ BINARY_URL='https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_arm_linux_hotspot_18.0.1_10.tar.gz'; \
;; \ ;; \
amd64|x86_64) \ amd64|x86_64) \
ESUM='2e031cf37018161c9e59b45fa4b98ff2ce4ce9297b824c512989d579a70f8422'; \ ESUM='16b1d9d75f22c157af04a1fd9c664324c7f4b5163c022b382a2f2e8897c1b0a2'; \
BINARY_URL='https://github.com/AdoptOpenJDK/openjdk16-binaries/releases/download/jdk-16%2B36/OpenJDK16-jdk_x64_linux_hotspot_16_36.tar.gz'; \ BINARY_URL='https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_linux_hotspot_18.0.1_10.tar.gz'; \
;; \ ;; \
*) \ *) \
echo "Unsupported arch: ${ARCH}"; \ echo "Unsupported arch: ${ARCH}"; \

View File

@ -1,11 +1,11 @@
FROM ubuntu:20.04 FROM ubuntu:22.04
LABEL maintainer="Andrey Antukh <niwi@niwi.nz>" LABEL maintainer="Andrey Antukh <niwi@niwi.nz>"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ENV LANG=en_US.UTF-8 \ ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \
NODE_VERSION=v16.14.2 NODE_VERSION=v16.15.1
RUN set -ex; \ RUN set -ex; \
mkdir -p /etc/resolvconf/resolv.conf.d; \ mkdir -p /etc/resolvconf/resolv.conf.d; \
@ -95,7 +95,7 @@ WORKDIR /opt/app
ADD ./bundle-exporter/ /opt/app/ ADD ./bundle-exporter/ /opt/app/
RUN set -ex; \ RUN set -ex; \
yarn install; \ yarn; \
npx playwright install chromium; npx playwright install chromium;
CMD ["/usr/local/nodejs/bin/node", "app.js"] CMD ["/usr/local/nodejs/bin/node", "app.js"]

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