Merge pull request #635 from penpot/niwinz/bounce-handling

Bounce & Complaint handling (on AWS only)
This commit is contained in:
Andrés Moya 2021-02-12 16:38:24 +01:00 committed by GitHub
commit 9c0dc54cfe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1357 additions and 323 deletions

View file

@ -3,8 +3,16 @@
## Next ## Next
### New features ### New features
- Bounce & Complaint handling.
### Bugs fixed ### Bugs fixed
- Properly handle errors on github, gitlab and ldap auth backends.
- Properly mark profile auth backend (on first register/ auth with 3rd party auth provider).
## 1.2.0-alpha ## 1.2.0-alpha

View file

@ -9,10 +9,12 @@
(ns app.config (ns app.config
"A configuration management." "A configuration management."
(:refer-clojure :exclude [get])
(:require (:require
[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]
[clojure.core :as c]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[environ.core :refer [env]])) [environ.core :refer [env]]))
@ -52,6 +54,12 @@
:smtp-default-reply-to "Penpot <no-reply@example.com>" :smtp-default-reply-to "Penpot <no-reply@example.com>"
:smtp-default-from "Penpot <no-reply@example.com>" :smtp-default-from "Penpot <no-reply@example.com>"
:profile-complaint-max-age (dt/duration {:days 7})
:profile-complaint-threshold 2
:profile-bounce-max-age (dt/duration {:days 7})
:profile-bounce-threshold 10
:allow-demo-users true :allow-demo-users true
:registration-enabled true :registration-enabled true
:registration-domain-whitelist "" :registration-domain-whitelist ""
@ -98,6 +106,11 @@
(s/def ::feedback-enabled ::us/boolean) (s/def ::feedback-enabled ::us/boolean)
(s/def ::feedback-destination ::us/string) (s/def ::feedback-destination ::us/string)
(s/def ::profile-complaint-max-age ::dt/duration)
(s/def ::profile-complaint-threshold ::us/integer)
(s/def ::profile-bounce-max-age ::dt/duration)
(s/def ::profile-bounce-threshold ::us/integer)
(s/def ::error-report-webhook ::us/string) (s/def ::error-report-webhook ::us/string)
(s/def ::smtp-enabled ::us/boolean) (s/def ::smtp-enabled ::us/boolean)
@ -185,6 +198,10 @@
::ldap-bind-dn ::ldap-bind-dn
::ldap-bind-password ::ldap-bind-password
::public-uri ::public-uri
::profile-complaint-threshold
::profile-bounce-threshold
::profile-complaint-max-age
::profile-bounce-max-age
::redis-uri ::redis-uri
::registration-domain-whitelist ::registration-domain-whitelist
::registration-enabled ::registration-enabled
@ -247,3 +264,10 @@
(def deletion-delay (def deletion-delay
(dt/duration {:days 7})) (dt/duration {:days 7}))
(defn get
"A configuration getter. Helps code be more testable."
([key]
(c/get config key))
([key default]
(c/get config key default)))

View file

@ -19,6 +19,7 @@
[app.util.transit :as t] [app.util.transit :as t]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[clojure.tools.logging :as log]
[integrant.core :as ig] [integrant.core :as ig]
[next.jdbc :as jdbc] [next.jdbc :as jdbc]
[next.jdbc.date-time :as jdbc-dt]) [next.jdbc.date-time :as jdbc-dt])
@ -55,6 +56,7 @@
(defmethod ig/init-key ::pool (defmethod ig/init-key ::pool
[_ {:keys [migrations] :as cfg}] [_ {:keys [migrations] :as cfg}]
(log/debugf "initialize connection pool %s with uri %s" (:name cfg) (:uri cfg))
(let [pool (create-pool cfg)] (let [pool (create-pool cfg)]
(when (seq migrations) (when (seq migrations)
(with-open [conn (open pool)] (with-open [conn (open pool)]

View file

@ -5,13 +5,15 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0. ;; defined by the Mozilla Public License, v. 2.0.
;; ;;
;; Copyright (c) 2020 UXBOX Labs SL ;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.emails (ns app.emails
"Main api for send emails." "Main api for send emails."
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cfg] [app.config :as cfg]
[app.db :as db]
[app.db.sql :as sql]
[app.tasks :as tasks] [app.tasks :as tasks]
[app.util.emails :as emails] [app.util.emails :as emails]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
@ -41,6 +43,54 @@
:priority 200 :priority 200
:props email}))) :props email})))
(def sql:profile-complaint-report
"select (select count(*)
from profile_complaint_report
where type = 'complaint'
and profile_id = ?
and created_at > now() - ?::interval) as complaints,
(select count(*)
from profile_complaint_report
where type = 'bounce'
and profile_id = ?
and created_at > now() - ?::interval) as bounces;")
(defn allow-send-emails?
[conn profile]
(when-not (:is-muted profile false)
(let [complaint-threshold (cfg/get :profile-complaint-threshold)
complaint-max-age (cfg/get :profile-complaint-max-age)
bounce-threshold (cfg/get :profile-bounce-threshold)
bounce-max-age (cfg/get :profile-bounce-max-age)
{:keys [complaints bounces] :as result}
(db/exec-one! conn [sql:profile-complaint-report
(:id profile)
(db/interval complaint-max-age)
(:id profile)
(db/interval bounce-max-age)])]
(and (< complaints complaint-threshold)
(< bounces bounce-threshold)))))
(defn has-complaint-reports?
([conn email] (has-complaint-reports? conn email nil))
([conn email {:keys [threshold] :or {threshold 1}}]
(let [reports (db/exec! conn (sql/select :global-complaint-report
{:email email :type "complaint"}
{:limit 10}))]
(>= (count reports) threshold))))
(defn has-bounce-reports?
([conn email] (has-bounce-reports? conn email nil))
([conn email {:keys [threshold] :or {threshold 1}}]
(let [reports (db/exec! conn (sql/select :global-complaint-report
{:email email :type "bounce"}
{:limit 10}))]
(>= (count reports) threshold))))
;; --- Emails ;; --- Emails
(s/def ::subject ::us/string) (s/def ::subject ::us/string)

View file

@ -12,7 +12,6 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cfg] [app.config :as cfg]
[app.http.auth :as auth]
[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]
@ -127,6 +126,9 @@
["/dbg" ["/dbg"
["/error-by-id/:id" {:get (:error-report-handler cfg)}]] ["/error-by-id/:id" {:get (:error-report-handler cfg)}]]
["/webhooks"
["/sns" {:post (:sns-webhook cfg)}]]
["/api" {:middleware [[middleware/format-response-body] ["/api" {:middleware [[middleware/format-response-body]
[middleware/params] [middleware/params]
[middleware/multipart-params] [middleware/multipart-params]
@ -147,9 +149,6 @@
["/github" {:post (:auth-handler github-auth)}] ["/github" {:post (:auth-handler github-auth)}]
["/github/callback" {:get (:callback-handler github-auth)}]] ["/github/callback" {:get (:callback-handler github-auth)}]]
["/login" {:post #(auth/login-handler cfg %)}]
["/logout" {:post #(auth/logout-handler cfg %)}]
["/login-ldap" {:post ldap-auth}] ["/login-ldap" {:post ldap-auth}]
["/rpc" {:middleware [(:middleware session)]} ["/rpc" {:middleware [(:middleware session)]}

View file

@ -1,31 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.http.auth
(:require
[app.http.session :as session]))
(defn login-handler
[{:keys [session rpc] :as cfg} request]
(let [data (:params request)
uagent (get-in request [:headers "user-agent"])
method (get-in rpc [:methods :mutation :login])
profile (method data)
id (session/create! session {:profile-id (:id profile)
:user-agent uagent})]
{:status 200
:cookies (session/cookies session {:value id})
:body profile}))
(defn logout-handler
[{:keys [session] :as cfg} request]
(session/delete! cfg request)
{:status 204
:cookies (session/cookies session {:value "" :max-age -1})
:body ""})

View file

@ -12,7 +12,6 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cfg] [app.config :as cfg]
[app.http.session :as session]
[app.util.http :as http] [app.util.http :as http]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.data.json :as json] [clojure.data.json :as json]
@ -38,7 +37,6 @@
(def scope "user:email") (def scope "user:email")
(defn- build-redirect-url (defn- build-redirect-url
[cfg] [cfg]
(let [public (u/uri (:public-uri cfg))] (let [public (u/uri (:public-uri cfg))]
@ -46,6 +44,7 @@
(defn- get-access-token (defn- get-access-token
[cfg state code] [cfg state code]
(try
(let [params {:client_id (:client-id cfg) (let [params {:client_id (:client-id cfg)
:client_secret (:client-secret cfg) :client_secret (:client-secret cfg)
:code code :code code
@ -55,48 +54,37 @@
:headers {"content-type" "application/x-www-form-urlencoded" :headers {"content-type" "application/x-www-form-urlencoded"
"accept" "application/json"} "accept" "application/json"}
:uri (str token-url) :uri (str token-url)
:timeout 6000
:body (u/map->query-string params)} :body (u/map->query-string params)}
res (http/send! req)] res (http/send! req)]
(when (not= 200 (:status res)) (when (= 200 (:status res))
(ex/raise :type :internal (-> (json/read-str (:body res))
:code :invalid-response-from-github (get "access_token"))))
:context {:status (:status res)
:body (:body res)})) (catch Exception e
(try (log/error e "unexpected error on get-access-token")
(let [data (json/read-str (:body res))] nil)))
(get data "access_token"))
(catch Throwable e
(log/error "unexpected error on parsing response body from github access token request" e)
nil))))
(defn- get-user-info (defn- get-user-info
[token] [token]
(try
(let [req {:uri (str user-info-url) (let [req {:uri (str user-info-url)
:headers {"authorization" (str "token " token)} :headers {"authorization" (str "token " token)}
:timeout 6000
:method :get} :method :get}
res (http/send! req)] res (http/send! req)]
(when (= 200 (:status res))
(when (not= 200 (:status res))
(ex/raise :type :internal
:code :invalid-response-from-github
:context {:status (:status res)
:body (:body res)}))
(try
(let [data (json/read-str (:body res))] (let [data (json/read-str (:body res))]
{:email (get data "email") {:email (get data "email")
:fullname (get data "name")}) :fullname (get data "name")})))
(catch Throwable e (catch Exception e
(log/error "unexpected error on parsing response body from github access token request" e) (log/error e "unexpected exception on get-user-info")
nil)))) nil)))
(defn auth (defn auth
[{:keys [tokens] :as cfg} _request] [{:keys [tokens] :as cfg} _request]
(let [state (tokens :generate (let [state (tokens :generate {:iss :github-oauth :exp (dt/in-future "15m")})
{:iss :github-oauth
:exp (dt/in-future "15m")})
params {:client_id (:client-id cfg/config) params {:client_id (:client-id cfg/config)
:redirect_uri (build-redirect-url cfg) :redirect_uri (build-redirect-url cfg)
:state state :state state
@ -109,37 +97,38 @@
(defn callback (defn callback
[{:keys [tokens rpc session] :as cfg} request] [{:keys [tokens rpc session] :as cfg} request]
(try
(let [state (get-in request [:params :state]) (let [state (get-in request [:params :state])
_ (tokens :verify {:token state :iss :github-oauth}) _ (tokens :verify {:token state :iss :github-oauth})
info (some->> (get-in request [:params :code]) info (some->> (get-in request [:params :code])
(get-access-token cfg state) (get-access-token cfg state)
(get-user-info))] (get-user-info))
(when-not info _ (when-not info
(ex/raise :type :authentication (ex/raise :type :internal
:code :unable-to-authenticate-with-github)) :code :unable-to-auth))
(let [method-fn (get-in rpc [:methods :mutation :login-or-register]) method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "github"
:fullname (:fullname info)}) :fullname (:fullname info)})
uagent (get-in request [:headers "user-agent"])
token (tokens :generate token (tokens :generate
{:iss :auth {:iss :auth
:exp (dt/in-future "15m") :exp (dt/in-future "15m")
:profile-id (:id profile)}) :profile-id (:id profile)})
uri (-> (u/uri (:public-uri cfg/config)) uri (-> (u/uri (:public-uri cfg/config))
(assoc :path "/#/auth/verify-token") (assoc :path "/#/auth/verify-token")
(assoc :query (u/map->query-string {:token token}))) (assoc :query (u/map->query-string {:token token})))
sxf ((:create session) (:id profile))
sid (session/create! session {:profile-id (:id profile) rsp {:status 302 :headers {"location" (str uri)} :body ""}]
:user-agent uagent})] (sxf request rsp))
(catch Exception _e
(let [uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/login")
(assoc :query (u/map->query-string {:error "unable-to-auth"})))]
{:status 302 {:status 302
:headers {"location" (str uri)} :headers {"location" (str uri)}
:cookies (session/cookies session/cookies {:value sid}) :body ""}))))
:body ""})))
;; --- ENTRY POINT ;; --- ENTRY POINT

View file

@ -12,42 +12,39 @@
[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.http.session :as session]
[app.util.http :as http] [app.util.http :as http]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.data.json :as json] [clojure.data.json :as json]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[integrant.core :as ig] [integrant.core :as ig]
[lambdaisland.uri :as uri])) [lambdaisland.uri :as u]))
(def scope "read_user") (def scope "read_user")
(defn- build-redirect-url (defn- build-redirect-url
[cfg] [cfg]
(let [public (uri/uri (:public-uri cfg))] (let [public (u/uri (:public-uri cfg))]
(str (assoc public :path "/api/oauth/gitlab/callback")))) (str (assoc public :path "/api/oauth/gitlab/callback"))))
(defn- build-oauth-uri (defn- build-oauth-uri
[cfg] [cfg]
(let [base-uri (uri/uri (:base-uri cfg))] (let [base-uri (u/uri (:base-uri cfg))]
(assoc base-uri :path "/oauth/authorize"))) (assoc base-uri :path "/oauth/authorize")))
(defn- build-token-url (defn- build-token-url
[cfg] [cfg]
(let [base-uri (uri/uri (:base-uri cfg))] (let [base-uri (u/uri (:base-uri cfg))]
(str (assoc base-uri :path "/oauth/token")))) (str (assoc base-uri :path "/oauth/token"))))
(defn- build-user-info-url (defn- build-user-info-url
[cfg] [cfg]
(let [base-uri (uri/uri (:base-uri cfg))] (let [base-uri (u/uri (:base-uri cfg))]
(str (assoc base-uri :path "/api/v4/user")))) (str (assoc base-uri :path "/api/v4/user"))))
(defn- get-access-token (defn- get-access-token
[cfg code] [cfg code]
(try
(let [params {:client_id (:client-id cfg) (let [params {:client_id (:client-id cfg)
:client_secret (:client-secret cfg) :client_secret (:client-secret cfg)
:code code :code code
@ -56,44 +53,34 @@
req {:method :post req {:method :post
:headers {"content-type" "application/x-www-form-urlencoded"} :headers {"content-type" "application/x-www-form-urlencoded"}
:uri (build-token-url cfg) :uri (build-token-url cfg)
:body (uri/map->query-string params)} :body (u/map->query-string params)}
res (http/send! req)] res (http/send! req)]
(when (not= 200 (:status res)) (when (= 200 (:status res))
(ex/raise :type :internal (-> (json/read-str (:body res))
:code :invalid-response-from-gitlab (get "access_token"))))
:context {:status (:status res)
:body (:body res)}))
(try
(let [data (json/read-str (:body res))]
(get data "access_token"))
(catch Throwable e
(log/error "unexpected error on parsing response body from gitlab access token request" e)
nil))))
(catch Exception e
(log/error e "unexpected error on get-access-token")
nil)))
(defn- get-user-info (defn- get-user-info
[cfg token] [cfg token]
(try
(let [req {:uri (build-user-info-url cfg) (let [req {:uri (build-user-info-url cfg)
:headers {"Authorization" (str "Bearer " token)} :headers {"Authorization" (str "Bearer " token)}
:timeout 6000
:method :get} :method :get}
res (http/send! req)] res (http/send! req)]
(when (not= 200 (:status res)) (when (= 200 (:status res))
(ex/raise :type :internal
:code :invalid-response-from-gitlab
:context {:status (:status res)
:body (:body res)}))
(try
(let [data (json/read-str (:body res))] (let [data (json/read-str (:body res))]
;; (clojure.pprint/pprint data)
{:email (get data "email") {:email (get data "email")
:fullname (get data "name")}) :fullname (get data "name")})))
(catch Throwable e
(log/error "unexpected error on parsing response body from gitlab access token request" e) (catch Exception e
nil)))) (log/error e "unexpected exception on get-user-info")
nil)))
(defn auth (defn auth
[{:keys [tokens] :as cfg} _request] [{:keys [tokens] :as cfg} _request]
@ -105,7 +92,7 @@
:response_type "code" :response_type "code"
:state token :state token
:scope scope} :scope scope}
query (uri/map->query-string params) query (u/map->query-string params)
uri (-> (build-oauth-uri cfg) uri (-> (build-oauth-uri cfg)
(assoc :query query))] (assoc :query query))]
{:status 200 {:status 200
@ -113,36 +100,38 @@
(defn callback (defn callback
[{:keys [tokens rpc session] :as cfg} request] [{:keys [tokens rpc session] :as cfg} request]
(try
(let [token (get-in request [:params :state]) (let [token (get-in request [:params :state])
_ (tokens :verify {:token token :iss :gitlab-oauth}) _ (tokens :verify {:token token :iss :gitlab-oauth})
info (some->> (get-in request [:params :code]) info (some->> (get-in request [:params :code])
(get-access-token cfg) (get-access-token cfg)
(get-user-info cfg))] (get-user-info cfg))
_ (when-not info
(ex/raise :type :internal
:code :unable-to-auth))
(when-not info method-fn (get-in rpc [:methods :mutation :login-or-register])
(ex/raise :type :authentication
:code :unable-to-authenticate-with-gitlab))
(let [method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "gitlab"
:fullname (:fullname info)}) :fullname (:fullname info)})
uagent (get-in request [:headers "user-agent"])
token (tokens :generate {:iss :auth token (tokens :generate {:iss :auth
:exp (dt/in-future "15m") :exp (dt/in-future "15m")
:profile-id (:id profile)}) :profile-id (:id profile)})
uri (-> (uri/uri (:public-uri cfg)) uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/verify-token") (assoc :path "/#/auth/verify-token")
(assoc :query (uri/map->query-string {:token token}))) (assoc :query (u/map->query-string {:token token})))
sid (session/create! session {:profile-id (:id profile) sxf ((:create session) (:id profile))
:user-agent uagent})] rsp {:status 302 :headers {"location" (str uri)} :body ""}]
(sxf request rsp))
(catch Exception _e
(let [uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/login")
(assoc :query (u/map->query-string {:error "unable-to-auth"})))]
{:status 302 {:status 302
:headers {"location" (str uri)} :headers {"location" (str uri)}
:cookies (session/cookies session {:value sid}) :body ""}))))
:body ""})))
(s/def ::client-id ::us/not-empty-string) (s/def ::client-id ::us/not-empty-string)
(s/def ::client-secret ::us/not-empty-string) (s/def ::client-secret ::us/not-empty-string)

View file

@ -11,14 +11,13 @@
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.http.session :as session]
[app.util.http :as http] [app.util.http :as http]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.data.json :as json] [clojure.data.json :as json]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[integrant.core :as ig] [integrant.core :as ig]
[lambdaisland.uri :as uri])) [lambdaisland.uri :as u]))
(def base-goauth-uri "https://accounts.google.com/o/oauth2/v2/auth") (def base-goauth-uri "https://accounts.google.com/o/oauth2/v2/auth")
@ -30,7 +29,7 @@
(defn- build-redirect-url (defn- build-redirect-url
[cfg] [cfg]
(let [public (uri/uri (:public-uri cfg))] (let [public (u/uri (:public-uri cfg))]
(str (assoc public :path "/api/oauth/google/callback")))) (str (assoc public :path "/api/oauth/google/callback"))))
(defn- get-access-token (defn- get-access-token
@ -44,7 +43,7 @@
req {:method :post req {:method :post
:headers {"content-type" "application/x-www-form-urlencoded"} :headers {"content-type" "application/x-www-form-urlencoded"}
:uri "https://oauth2.googleapis.com/token" :uri "https://oauth2.googleapis.com/token"
:body (uri/map->query-string params)} :body (u/map->query-string params)}
res (http/send! req)] res (http/send! req)]
(when (= 200 (:status res)) (when (= 200 (:status res))
@ -80,8 +79,8 @@
:response_type "code" :response_type "code"
:redirect_uri (build-redirect-url cfg) :redirect_uri (build-redirect-url cfg)
:client_id (:client-id cfg)} :client_id (:client-id cfg)}
query (uri/map->query-string params) query (u/map->query-string params)
uri (-> (uri/uri base-goauth-uri) uri (-> (u/uri base-goauth-uri)
(assoc :query query))] (assoc :query query))]
{:status 200 {:status 200
:body {:redirect-uri (str uri)}})) :body {:redirect-uri (str uri)}}))
@ -99,25 +98,22 @@
:code :unable-to-auth)) :code :unable-to-auth))
method-fn (get-in rpc [:methods :mutation :login-or-register]) method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "google"
:fullname (:fullname info)}) :fullname (:fullname info)})
uagent (get-in request [:headers "user-agent"])
token (tokens :generate {:iss :auth token (tokens :generate {:iss :auth
:exp (dt/in-future "15m") :exp (dt/in-future "15m")
:profile-id (:id profile)}) :profile-id (:id profile)})
uri (-> (uri/uri (:public-uri cfg)) uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/verify-token") (assoc :path "/#/auth/verify-token")
(assoc :query (uri/map->query-string {:token token}))) (assoc :query (u/map->query-string {:token token})))
sid (session/create! session {:profile-id (:id profile) sxf ((:create session) (:id profile))
:user-agent uagent})] rsp {:status 302 :headers {"location" (str uri)} :body ""}]
{:status 302 (sxf request rsp))
:headers {"location" (str uri)}
:cookies (session/cookies session {:value sid})
:body ""})
(catch Exception _e (catch Exception _e
(let [uri (-> (uri/uri (:public-uri cfg)) (let [uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/login") (assoc :path "/#/auth/login")
(assoc :query (uri/map->query-string {:error "unable-to-auth"})))] (assoc :query (u/map->query-string {:error "unable-to-auth"})))]
{:status 302 {:status 302
:headers {"location" (str uri)} :headers {"location" (str uri)}
:body ""})))) :body ""}))))

View file

@ -11,7 +11,6 @@
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.config :as cfg] [app.config :as cfg]
[app.http.session :as session]
[clj-ldap.client :as client] [clj-ldap.client :as client]
[clojure.set :as set] [clojure.set :as set]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
@ -65,13 +64,12 @@
:password (:password data)))] :password (:password data)))]
(let [method-fn (get-in rpc [:methods :mutation :login-or-register]) (let [method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "ldap"
:fullname (:fullname info)}) :fullname (:fullname info)})
uagent (get-in request [:headers "user-agent"])
sid (session/create! session {:profile-id (:id profile) sxf ((:create session) (:id profile))
:user-agent uagent})] rsp {:status 200 :body profile}]
{:status 200 (sxf request rsp)))))
:cookies (session/cookies session {:value sid})
:body profile}))))
{::conn conn}))) {::conn conn})))
(defmethod ig/halt-key! ::client (defmethod ig/halt-key! ::client

View file

@ -0,0 +1,207 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 Andrey Antukh <niwi@niwi.nz>
(ns app.http.awsns
"AWS SNS webhook handler for bounces."
(:require
[app.common.exceptions :as ex]
[app.db :as db]
[app.db.sql :as sql]
[app.util.http :as http]
[clojure.pprint :refer [pprint]]
[clojure.spec.alpha :as s]
[clojure.tools.logging :as log]
[cuerdas.core :as str]
[integrant.core :as ig]
[jsonista.core :as j]))
(declare parse-json)
(declare parse-notification)
(declare process-report)
(defn- pprint-report
[message]
(binding [clojure.pprint/*print-right-margin* 120]
(with-out-str (pprint message))))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req-un [::db/pool]))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [request]
(let [body (parse-json (slurp (:body request)))
mtype (get body "Type")]
(cond
(= mtype "SubscriptionConfirmation")
(let [surl (get body "SubscribeURL")
stopic (get body "TopicArn")]
(log/infof "Subscription received (topic=%s, url=%s)" stopic surl)
(http/send! {:uri surl :method :post :timeout 10000}))
(= mtype "Notification")
(when-let [message (parse-json (get body "Message"))]
;; (log/infof "Received: %s" (pr-str message))
(let [notification (parse-notification cfg message)]
(process-report cfg notification)))
:else
(log/warn (str "Unexpected data received.\n"
(pprint-report body))))
{:status 200 :body ""})))
(defn- parse-bounce
[data]
{:type "bounce"
:kind (str/lower (get data "bounceType"))
:category (str/lower (get data "bounceSubType"))
:feedback-id (get data "feedbackId")
:timestamp (get data "timestamp")
:recipients (->> (get data "bouncedRecipients")
(mapv (fn [item]
{:email (str/lower (get item "emailAddress"))
:status (get item "status")
:action (get item "action")
:dcode (get item "diagnosticCode")})))})
(defn- parse-complaint
[data]
{:type "complaint"
:user-agent (get data "userAgent")
:kind (get data "complaintFeedbackType")
:category (get data "complaintSubType")
:timestamp (get data "arrivalDate")
:feedback-id (get data "feedbackId")
:recipients (->> (get data "complainedRecipients")
(mapv #(get % "emailAddress"))
(mapv str/lower))})
(defn- extract-headers
[mail]
(reduce (fn [acc item]
(let [key (get item "name")
val (get item "value")]
(assoc acc (str/lower key) val)))
{}
(get mail "headers")))
(defn- extract-identity
[{:keys [tokens] :as cfg} headers]
(let [tdata (get headers "x-penpot-data")]
(when-not (str/empty? tdata)
(let [result (tokens :verify {:token tdata :iss :profile-identity})]
(:profile-id result)))))
(defn- parse-notification
[cfg message]
(let [type (get message "notificationType")
data (case type
"Bounce" (parse-bounce (get message "bounce"))
"Complaint" (parse-complaint (get message "complaint"))
{:type (keyword (str/lower type))
:message message})]
(when data
(let [mail (get message "mail")]
(when-not mail
(ex/raise :type :internal
:code :incomplete-notification
:hint "no email data received, please enable full headers report"))
(let [headers (extract-headers mail)
mail {:destination (get mail "destination")
:source (get mail "source")
:timestamp (get mail "timestamp")
:subject (get-in mail ["commonHeaders" "subject"])
:headers headers}]
(assoc data
:mail mail
:profile-id (extract-identity cfg headers)))))))
(defn- parse-json
[v]
(ex/ignoring
(j/read-value v)))
(defn- register-bounce-for-profile
[{:keys [pool]} {:keys [type kind profile-id] :as report}]
(when (= kind "permanent")
(db/with-atomic [conn pool]
(db/insert! conn :profile-complaint-report
{:profile-id profile-id
:type (name type)
:content (db/tjson report)})
;; TODO: maybe also try to find profiles by mail and if exists
;; register profile reports for them?
(doseq [recipient (:recipients report)]
(db/insert! conn :global-complaint-report
{:email (:email recipient)
:type (name type)
:content (db/tjson report)}))
(let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))]
(when (some #(= (:email profile) (:email %)) (:recipients report))
;; If the report matches the profile email, this means that
;; the report is for itself, can be caused when a user
;; registers with an invalid email or the user email is
;; permanently rejecting receiving the email. In this case we
;; have no option to mark the user as muted (and in this case
;; the profile will be also inactive.
(db/update! conn :profile
{:is-muted true}
{:id profile-id}))))))
(defn- register-complaint-for-profile
[{:keys [pool]} {:keys [type profile-id] :as report}]
(db/with-atomic [conn pool]
(db/insert! conn :profile-complaint-report
{:profile-id profile-id
:type (name type)
:content (db/tjson report)})
;; TODO: maybe also try to find profiles by email and if exists
;; register profile reports for them?
(doseq [email (:recipients report)]
(db/insert! conn :global-complaint-report
{:email email
:type (name type)
:content (db/tjson report)}))
(let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))]
(when (some #(= % (:email profile)) (:recipients report))
;; If the report matches the profile email, this means that
;; the report is for itself, rare case but can happen; In this
;; case just mark profile as muted (very rare case).
(db/update! conn :profile
{:is-muted true}
{:id profile-id})))))
(defn- process-report
[cfg {:keys [type profile-id] :as report}]
(log/debug (str "Procesing report:\n" (pprint-report report)))
(cond
;; In this case we receive a bounce/complaint notification without
;; confirmed identity, we just emit a warning but do nothing about
;; it because this is not a normal case. All notifications should
;; come with profile identity.
(nil? profile-id)
(log/warn (str "A notification without identity recevied from AWS\n"
(pprint-report report)))
(= "bounce" type)
(register-bounce-for-profile cfg report)
(= "complaint" type)
(register-complaint-for-profile cfg report)
:else
(log/warn (str "Unrecognized report received from AWS\n"
(pprint-report report)))))

View file

@ -16,14 +16,16 @@
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[integrant.core :as ig])) [integrant.core :as ig]))
(defn next-session-id ;; --- IMPL
(defn- next-session-id
([] (next-session-id 96)) ([] (next-session-id 96))
([n] ([n]
(-> (bn/random-nonce n) (-> (bn/random-nonce n)
(bc/bytes->b64u) (bc/bytes->b64u)
(bc/bytes->str)))) (bc/bytes->str))))
(defn create! (defn- create
[{:keys [conn] :as cfg} {:keys [profile-id user-agent]}] [{:keys [conn] :as cfg} {:keys [profile-id user-agent]}]
(let [id (next-session-id)] (let [id (next-session-id)]
(db/insert! conn :http-session {:id id (db/insert! conn :http-session {:id id
@ -31,28 +33,28 @@
:user-agent user-agent}) :user-agent user-agent})
id)) id))
(defn delete! (defn- delete
[{:keys [conn cookie-name] :as cfg} request] [{:keys [conn cookie-name] :as cfg} {:keys [cookies] :as request}]
(when-let [token (get-in request [:cookies cookie-name :value])] (when-let [token (get-in cookies [cookie-name :value])]
(db/delete! conn :http-session {:id token})) (db/delete! conn :http-session {:id token}))
nil) nil)
(defn retrieve (defn- retrieve
[{:keys [conn] :as cfg} token] [{:keys [conn] :as cfg} token]
(when token (when token
(-> (db/exec-one! conn ["select profile_id from http_session where id = ?" token]) (-> (db/exec-one! conn ["select profile_id from http_session where id = ?" token])
(:profile-id)))) (:profile-id))))
(defn retrieve-from-request (defn- retrieve-from-request
[{:keys [cookie-name] :as cfg} request] [{:keys [cookie-name] :as cfg} {:keys [cookies] :as request}]
(->> (get-in request [:cookies cookie-name :value]) (->> (get-in cookies [cookie-name :value])
(retrieve cfg))) (retrieve cfg)))
(defn cookies (defn- cookies
[{:keys [cookie-name] :as cfg} vals] [{:keys [cookie-name] :as cfg} vals]
{cookie-name (merge vals {:path "/" :http-only true})}) {cookie-name (merge vals {:path "/" :http-only true})})
(defn middleware (defn- middleware
[cfg handler] [cfg handler]
(fn [request] (fn [request]
(if-let [profile-id (retrieve-from-request cfg request)] (if-let [profile-id (retrieve-from-request cfg request)]
@ -61,6 +63,8 @@
(handler (assoc request :profile-id profile-id))) (handler (assoc request :profile-id profile-id)))
(handler request)))) (handler request))))
;; --- STATE INIT
(defmethod ig/pre-init-spec ::session [_] (defmethod ig/pre-init-spec ::session [_]
(s/keys :req-un [::db/pool])) (s/keys :req-un [::db/pool]))
@ -71,4 +75,17 @@
(defmethod ig/init-key ::session (defmethod ig/init-key ::session
[_ {:keys [pool] :as cfg}] [_ {:keys [pool] :as cfg}]
(let [cfg (assoc cfg :conn pool)] (let [cfg (assoc cfg :conn pool)]
(merge cfg {:middleware #(middleware cfg %)}))) (-> cfg
(assoc :middleware #(middleware cfg %))
(assoc :create (fn [profile-id]
(fn [request response]
(let [uagent (get-in request [:headers "user-agent"])
value (create cfg {:profile-id profile-id :user-agent uagent})]
(assoc response :cookies (cookies cfg {:value value}))))))
(assoc :delete (fn [request response]
(delete cfg request)
(assoc response
:status 204
:body ""
:cookies (cookies cfg {:value "" :max-age -1})))))))

View file

@ -71,6 +71,10 @@
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:cookie-name "auth-token"} :cookie-name "auth-token"}
:app.http.awsns/handler
{:tokens (ig/ref :app.tokens/tokens)
:pool (ig/ref :app.db/pool)}
:app.http/server :app.http/server
{:port (:http-server-port config) {:port (:http-server-port config)
:handler (ig/ref :app.http/router) :handler (ig/ref :app.http/router)
@ -90,6 +94,7 @@
:assets (ig/ref :app.http.assets/handlers) :assets (ig/ref :app.http.assets/handlers)
:svgparse (ig/ref :app.svgparse/handler) :svgparse (ig/ref :app.svgparse/handler)
:storage (ig/ref :app.storage/storage) :storage (ig/ref :app.storage/storage)
:sns-webhook (ig/ref :app.http.awsns/handler)
:error-report-handler (ig/ref :app.error-reporter/handler)} :error-report-handler (ig/ref :app.error-reporter/handler)}
:app.http.assets/handlers :app.http.assets/handlers

View file

@ -148,6 +148,10 @@
{:name "0045-add-index-to-file-change-table" {:name "0045-add-index-to-file-change-table"
:fn (mg/resource "app/migrations/sql/0045-add-index-to-file-change-table.sql")} :fn (mg/resource "app/migrations/sql/0045-add-index-to-file-change-table.sql")}
{:name "0046-add-profile-complaint-table"
:fn (mg/resource "app/migrations/sql/0046-add-profile-complaint-table.sql")}
]) ])

View file

@ -0,0 +1,45 @@
CREATE TABLE profile_complaint_report (
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
type text NOT NULL,
content jsonb,
PRIMARY KEY (profile_id, created_at)
);
ALTER TABLE profile_complaint_report
ALTER COLUMN type SET STORAGE external,
ALTER COLUMN content SET STORAGE external;
ALTER TABLE profile
ADD COLUMN is_muted boolean DEFAULT false,
ADD COLUMN auth_backend text NULL;
ALTER TABLE profile
ALTER COLUMN auth_backend SET STORAGE external;
UPDATE profile
SET auth_backend = 'google'
WHERE password = '!';
UPDATE profile
SET auth_backend = 'penpot'
WHERE password != '!';
-- Table storing a permanent complaint table for register all
-- permanent bounces and spam reports (complaints) and avoid sending
-- more emails there.
CREATE TABLE global_complaint_report (
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
type text NOT NULL,
content jsonb,
PRIMARY KEY (email, created_at)
);
ALTER TABLE global_complaint_report
ALTER COLUMN type SET STORAGE external,
ALTER COLUMN content SET STORAGE external;

View file

@ -16,7 +16,6 @@
[app.db :as db] [app.db :as db]
[app.db.profile-initial-data :refer [create-profile-initial-data]] [app.db.profile-initial-data :refer [create-profile-initial-data]]
[app.emails :as emails] [app.emails :as emails]
[app.http.session :as session]
[app.media :as media] [app.media :as media]
[app.rpc.mutations.projects :as projects] [app.rpc.mutations.projects :as projects]
[app.rpc.mutations.teams :as teams] [app.rpc.mutations.teams :as teams]
@ -56,12 +55,11 @@
(sv/defmethod ::register-profile {:auth false :rlimit :password} (sv/defmethod ::register-profile {:auth false :rlimit :password}
[{:keys [pool tokens session] :as cfg} {:keys [token] :as params}] [{:keys [pool tokens session] :as cfg} {:keys [token] :as params}]
(when-not (:registration-enabled cfg/config) (when-not (cfg/get :registration-enabled)
(ex/raise :type :restriction (ex/raise :type :restriction
:code :registration-disabled)) :code :registration-disabled))
(when-not (email-domain-in-whitelist? (:registration-domain-whitelist cfg/config) (when-not (email-domain-in-whitelist? (cfg/get :registration-domain-whitelist) (:email params))
(:email params))
(ex/raise :type :validation (ex/raise :type :validation
:code :email-domain-is-not-allowed)) :code :email-domain-is-not-allowed))
@ -95,29 +93,33 @@
(with-meta (assoc profile (with-meta (assoc profile
:is-active true :is-active true
:claims claims) :claims claims)
{:transform-response {:transform-response ((:create session) (:id profile))}))
(fn [request response]
(let [uagent (get-in request [:headers "user-agent"])
id (session/create! session {:profile-id (:id profile)
:user-agent uagent})]
(assoc response
:cookies (session/cookies session {:value id}))))}))
;; If no token is provided, send a verification email ;; If no token is provided, send a verification email
(let [token (tokens :generate (let [vtoken (tokens :generate
{:iss :verify-email {:iss :verify-email
:exp (dt/in-future "48h") :exp (dt/in-future "48h")
:profile-id (:id profile) :profile-id (:id profile)
:email (:email profile)})] :email (:email profile)})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
;; Don't allow proceed in register page if the email is
;; already reported as permanent bounced
(when (emails/has-bounce-reports? conn (:email profile))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email has one or many bounces reported"))
(emails/send! conn emails/register (emails/send! conn emails/register
{:to (:email profile) {:to (:email profile)
:name (:fullname profile) :name (:fullname profile)
:token token}) :token vtoken
:extra-data ptoken})
profile))))) profile)))))
(defn email-domain-in-whitelist? (defn email-domain-in-whitelist?
"Returns true if email's domain is in the given whitelist or if given "Returns true if email's domain is in the given whitelist or if given
whitelist is an empty string." whitelist is an empty string."
@ -162,8 +164,8 @@
(defn- create-profile (defn- create-profile
"Create the profile entry on the database with limited input "Create the profile entry on the database with limited input
filling all the other fields with defaults." filling all the other fields with defaults."
[conn {:keys [id fullname email password demo? props is-active] [conn {:keys [id fullname email password demo? props is-active is-muted]
:or {is-active false} :or {is-active false is-muted false}
:as params}] :as params}]
(let [id (or id (uuid/next)) (let [id (or id (uuid/next))
demo? (if (boolean? demo?) demo? false) demo? (if (boolean? demo?) demo? false)
@ -175,9 +177,11 @@
{:id id {:id id
:fullname fullname :fullname fullname
:email (str/lower email) :email (str/lower email)
:auth-backend "penpot"
:password password :password password
:props props :props props
:is-active active? :is-active active?
:is-muted is-muted
:is-demo demo?}) :is-demo demo?})
(update :props db/decode-transit-pgobject)) (update :props db/decode-transit-pgobject))
(catch org.postgresql.util.PSQLException e (catch org.postgresql.util.PSQLException e
@ -217,7 +221,7 @@
:opt-un [::scope])) :opt-un [::scope]))
(sv/defmethod ::login {:auth false :rlimit :password} (sv/defmethod ::login {:auth false :rlimit :password}
[{:keys [pool] :as cfg} {:keys [email password scope] :as params}] [{:keys [pool session] :as cfg} {:keys [email password scope] :as params}]
(letfn [(check-password [profile password] (letfn [(check-password [profile password]
(when (= (:password profile) "!") (when (= (:password profile) "!")
(ex/raise :type :validation (ex/raise :type :validation
@ -240,17 +244,31 @@
(let [prof (-> (profile/retrieve-profile-data-by-email conn email) (let [prof (-> (profile/retrieve-profile-data-by-email conn email)
(validate-profile) (validate-profile)
(profile/strip-private-attrs)) (profile/strip-private-attrs))
addt (profile/retrieve-additional-data conn (:id prof))] addt (profile/retrieve-additional-data conn (:id prof))
(merge prof addt))))) prof (merge prof addt)]
(with-meta prof
{:transform-response ((:create session) (:id prof))})))))
;; --- Mutation: Logout
(s/def ::logout
(s/keys :req-un [::profile-id]))
(sv/defmethod ::logout
[{:keys [pool session] :as cfg} {:keys [profile-id] :as params}]
(with-meta {}
{:transform-response (:delete session)}))
;; --- Mutation: Register if not exists ;; --- Mutation: Register if not exists
(s/def ::backend ::us/string)
(s/def ::login-or-register (s/def ::login-or-register
(s/keys :req-un [::email ::fullname])) (s/keys :req-un [::email ::fullname ::backend]))
(sv/defmethod ::login-or-register {:auth false} (sv/defmethod ::login-or-register {:auth false}
[{:keys [pool] :as cfg} {:keys [email fullname] :as params}] [{:keys [pool] :as cfg} {:keys [email backend fullname] :as params}]
(letfn [(populate-additional-data [conn profile] (letfn [(populate-additional-data [conn profile]
(let [data (profile/retrieve-additional-data conn (:id profile))] (let [data (profile/retrieve-additional-data conn (:id profile))]
(merge profile data))) (merge profile data)))
@ -260,6 +278,7 @@
{:id (uuid/next) {:id (uuid/next)
:fullname fullname :fullname fullname
:email (str/lower email) :email (str/lower email)
:auth-backend backend
:is-active true :is-active true
:password "!" :password "!"
:is-demo false})) :is-demo false}))
@ -366,16 +385,30 @@
{:iss :change-email {:iss :change-email
:exp (dt/in-future "15m") :exp (dt/in-future "15m")
:profile-id profile-id :profile-id profile-id
:email email})] :email email})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(when (not= email (:email profile)) (when (not= email (:email profile))
(check-profile-existence! conn params)) (check-profile-existence! conn params))
(when-not (emails/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 (emails/has-bounce-reports? conn email)
(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"))
(emails/send! conn emails/change-email (emails/send! conn emails/change-email
{:to (:email profile) {:to (:email profile)
:name (:fullname profile) :name (:fullname profile)
:pending-email email :pending-email email
:token token}) :token token
:extra-data ptoken})
nil))) nil)))
(defn select-profile-for-update (defn select-profile-for-update
@ -397,11 +430,15 @@
(assoc profile :token token))) (assoc profile :token token)))
(send-email-notification [conn profile] (send-email-notification [conn profile]
(let [ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(emails/send! conn emails/password-recovery (emails/send! conn emails/password-recovery
{:to (:email profile) {:to (:email profile)
:token (:token profile) :token (:token profile)
:name (:fullname profile)}) :name (:fullname profile)
nil)] :extra-data ptoken})
nil))]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(when-let [profile (profile/retrieve-profile-data-by-email conn email)] (when-let [profile (profile/retrieve-profile-data-by-email conn email)]
@ -409,6 +446,17 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :profile-not-verified :code :profile-not-verified
:hint "the user need to validate profile before recover password")) :hint "the user need to validate profile before recover password"))
(when-not (emails/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 (emails/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 (->> profile
(create-recovery-token) (create-recovery-token)
(send-email-notification conn)))))) (send-email-notification conn))))))
@ -480,11 +528,7 @@
{:id profile-id}) {:id profile-id})
(with-meta {} (with-meta {}
{:transform-response {:transform-response (:delete session)})))
(fn [request response]
(session/delete! session request)
(assoc response
:cookies (session/cookies session {:value "" :max-age -1})))})))
(def sql:owned-teams (def sql:owned-teams
"with owner_teams as ( "with owner_teams as (

View file

@ -301,22 +301,44 @@
profile (db/get-by-id conn :profile profile-id) profile (db/get-by-id conn :profile profile-id)
member (profile/retrieve-profile-data-by-email conn email) member (profile/retrieve-profile-data-by-email conn email)
team (db/get-by-id conn :team team-id) team (db/get-by-id conn :team team-id)
token (tokens :generate itoken (tokens :generate
{:iss :team-invitation {:iss :team-invitation
:exp (dt/in-future "24h") :exp (dt/in-future "24h")
:profile-id (:id profile) :profile-id (:id profile)
:role role :role role
:team-id team-id :team-id team-id
:member-email (:email member email) :member-email (:email member email)
:member-id (:id member)})] :member-id (:id member)})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(when-not (some :is-admin perms) (when-not (some :is-admin perms)
(ex/raise :type :validation (ex/raise :type :validation
:code :insufficient-permissions)) :code :insufficient-permissions))
;; First check if the current profile is allowed to send emails.
(when-not (emails/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 (and member (not (emails/allow-send-emails? conn member)))
(ex/raise :type :validation
:code :member-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
;; Secondly check if the invited member email is part of the
;; global spam/bounce report.
(when (emails/has-bounce-reports? conn email)
(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"))
(emails/send! conn emails/invite-to-team (emails/send! conn emails/invite-to-team
{:to email {:to email
:invited-by (:fullname profile) :invited-by (:fullname profile)
:team (:name team) :team (:name team)
:token token}) :token itoken
:extra-data ptoken})
nil))) nil)))

View file

@ -5,14 +5,13 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0. ;; defined by the Mozilla Public License, v. 2.0.
;; ;;
;; Copyright (c) 2020 UXBOX Labs SL ;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.rpc.mutations.verify-token (ns app.rpc.mutations.verify-token
(:require (:require
[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.http.session :as session]
[app.rpc.mutations.teams :as teams] [app.rpc.mutations.teams :as teams]
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.util.services :as sv] [app.util.services :as sv]
@ -57,14 +56,7 @@
{:id (:id profile)})) {:id (:id profile)}))
(with-meta claims (with-meta claims
{:transform-response {:transform-response ((:create session) profile-id)})))
(fn [request response]
(let [uagent (get-in request [:headers "user-agent"])
id (session/create! session {:profile-id profile-id
:user-agent uagent})]
(assoc response
:cookies (session/cookies session {:value id}))))})))
(defmethod process-token :auth (defmethod process-token :auth
[{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}] [{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}]
@ -98,11 +90,19 @@
(let [params (merge {:team-id team-id (let [params (merge {:team-id team-id
:profile-id member-id} :profile-id member-id}
(teams/role->params role)) (teams/role->params role))
claims (assoc claims :state :created)] claims (assoc claims :state :created)
member (profile/retrieve-profile conn member-id)]
(db/insert! conn :team-profile-rel params (db/insert! conn :team-profile-rel params
{:on-conflict-do-nothing true}) {:on-conflict-do-nothing true})
;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification.
(when-not (:is-active member)
(db/update! conn :profile
{:is-active true}
{:id member-id}))
(if (and (uuid? profile-id) (if (and (uuid? profile-id)
(= member-id profile-id)) (= member-id profile-id))
;; If the current session is already matches the invited ;; If the current session is already matches the invited
@ -116,13 +116,7 @@
;; the user clicking the link he already has access to the ;; the user clicking the link he already has access to the
;; email account. ;; email account.
(with-meta claims (with-meta claims
{:transform-response {:transform-response ((:create session) member-id)})))
(fn [request response]
(let [uagent (get-in request [:headers "user-agent"])
id (session/create! session {:profile-id member-id
:user-agent uagent})]
(assoc response
:cookies (session/cookies session {:value id}))))})))
;; In this case, we wait until frontend app redirect user to ;; In this case, we wait until frontend app redirect user to
;; registeration page, the user is correctly registered and the ;; registeration page, the user is correctly registered and the

View file

@ -60,11 +60,25 @@
(defmethod ig/pre-init-spec ::tokens [_] (defmethod ig/pre-init-spec ::tokens [_]
(s/keys :req-un [::sprops])) (s/keys :req-un [::sprops]))
(defn- generate-predefined
[cfg {:keys [iss profile-id] :as params}]
(case iss
:profile-identity
(do
(us/verify uuid? profile-id)
(generate cfg (assoc params
:exp (dt/in-future {:days 30}))))
(ex/raise :type :internal
:code :not-implemented
:hint "no predefined token")))
(defmethod ig/init-key ::tokens (defmethod ig/init-key ::tokens
[_ {:keys [sprops] :as cfg}] [_ {:keys [sprops] :as cfg}]
(let [secret (derive-tokens-secret (:secret-key sprops)) (let [secret (derive-tokens-secret (:secret-key sprops))
cfg (assoc cfg ::secret secret)] cfg (assoc cfg ::secret secret)]
(fn [action params] (fn [action params]
(case action (case action
:generate-predefined (generate-predefined cfg params)
:verify (verify cfg params) :verify (verify cfg params)
:generate (generate cfg params))))) :generate (generate cfg params)))))

View file

@ -31,15 +31,24 @@
[environ.core :refer [env]] [environ.core :refer [env]]
[expound.alpha :as expound] [expound.alpha :as expound]
[integrant.core :as ig] [integrant.core :as ig]
[mockery.core :as mk]
[promesa.core :as p]) [promesa.core :as p])
(:import org.postgresql.ds.PGSimpleDataSource)) (:import org.postgresql.ds.PGSimpleDataSource))
(def ^:dynamic *system* nil) (def ^:dynamic *system* nil)
(def ^:dynamic *pool* nil) (def ^:dynamic *pool* nil)
(def config
(merge {:redis-uri "redis://redis/1"
:database-uri "postgresql://postgres/penpot_test"
:storage-fs-directory "/tmp/app/storage"
:migrations-verbose false}
cfg/config))
(defn state-init (defn state-init
[next] [next]
(let [config (-> (main/build-system-config cfg/test-config) (let [config (-> (main/build-system-config config)
(dissoc :app.srepl/server (dissoc :app.srepl/server
:app.http/server :app.http/server
:app.http/router :app.http/router
@ -300,3 +309,31 @@
(defn sleep (defn sleep
[ms] [ms]
(Thread/sleep ms)) (Thread/sleep ms))
(defn mock-config-get-with
"Helper for mock app.config/get"
[data]
(fn
([key] (get (merge config data) key))
([key default] (get (merge config data) key default))))
(defn create-complaint-for
[conn {:keys [id created-at type]}]
(db/insert! conn :profile-complaint-report
{:profile-id id
:created-at (or created-at (dt/now))
:type (name type)
:content (db/tjson {})}))
(defn create-global-complaint-for
[conn {:keys [email type created-at]}]
(db/insert! conn :global-complaint-report
{:email email
:type (name type)
:created-at (or created-at (dt/now))
:content (db/tjson {})}))
(defn reset-mock!
[m]
(reset! m @(mk/make-mock {})))

View file

@ -0,0 +1,316 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2021 UXBOX Labs SL
(ns app.tests.test-bounces-handling
(:require
[clojure.pprint :refer [pprint]]
[app.http.awsns :as awsns]
[app.emails :as emails]
[app.tests.helpers :as th]
[app.db :as db]
[app.util.time :as dt]
[mockery.core :refer [with-mocks]]
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
;; (with-mocks [mock {:target 'app.tasks/submit! :return nil}]
;; Right now we have many different scenarios what can cause a
;; bounce/complain report.
(defn- decode-row
[{:keys [content] :as row}]
(cond-> row
(db/pgobject? content)
(assoc :content (db/decode-transit-pgobject content))))
(defn bounce-report
[{:keys [token email] :or {email "user@example.com"}}]
{"notificationType" "Bounce",
"bounce" {"feedbackId""010701776d7dd251-c08d280d-9f47-41aa-b959-0094fec779d9-000000",
"bounceType" "Permanent",
"bounceSubType" "General",
"bouncedRecipients" [{"emailAddress" email,
"action" "failed",
"status" "5.1.1",
"diagnosticCode" "smtp; 550 5.1.1 user unknown"}]
"timestamp" "2021-02-04T14:41:38.000Z",
"remoteMtaIp" "22.22.22.22",
"reportingMTA" "dsn; b224-13.smtp-out.eu-central-1.amazonses.com"}
"mail" {"timestamp" "2021-02-04T14:41:37.020Z",
"source" "no-reply@penpot.app",
"sourceArn" "arn:aws:ses:eu-central-1:1111111111:identity/penpot.app",
"sourceIp" "22.22.22.22",
"sendingAccountId" "1111111111",
"messageId" "010701776d7dccfc-3c0094e7-01d7-458d-8100-893320186028-000000",
"destination" [email],
"headersTruncated" false,
"headers" [{"name" "Received","value" "from app-pre"},
{"name" "Date","value" "Thu, 4 Feb 2021 14:41:36 +0000 (UTC)"},
{"name" "From","value" "Penpot <no-reply@penpot.app>"},
{"name" "Reply-To","value" "Penpot <no-reply@penpot.app>"},
{"name" "To","value" email},
{"name" "Message-ID","value" "<2054501.5.1612449696846@penpot.app>"},
{"name" "Subject","value" "test"},
{"name" "MIME-Version","value" "1.0"},
{"name" "Content-Type","value" "multipart/mixed; boundary=\"----=_Part_3_1150363050.1612449696845\""},
{"name" "X-Penpot-Data","value" token}],
"commonHeaders" {"from" ["Penpot <no-reply@penpot.app>"],
"replyTo" ["Penpot <no-reply@penpot.app>"],
"date" "Thu, 4 Feb 2021 14:41:36 +0000 (UTC)",
"to" [email],
"messageId" "<2054501.5.1612449696846@penpot.app>",
"subject" "test"}}})
(defn complaint-report
[{:keys [token email] :or {email "user@example.com"}}]
{"notificationType" "Complaint",
"complaint" {"feedbackId" "0107017771528618-dcf4d61f-c889-4c8b-a6ff-6f0b6553b837-000000",
"complaintSubType" nil,
"complainedRecipients" [{"emailAddress" email}],
"timestamp" "2021-02-05T08:32:49.000Z",
"userAgent" "Yahoo!-Mail-Feedback/2.0",
"complaintFeedbackType" "abuse",
"arrivalDate" "2021-02-05T08:31:15.000Z"},
"mail" {"timestamp" "2021-02-05T08:31:13.715Z",
"source" "no-reply@penpot.app",
"sourceArn" "arn:aws:ses:eu-central-1:111111111:identity/penpot.app",
"sourceIp" "22.22.22.22",
"sendingAccountId" "11111111111",
"messageId" "0107017771510f33-a0696d28-859c-4f08-9211-8392d1b5c226-000000",
"destination" ["user@yahoo.com"],
"headersTruncated" false,
"headers" [{"name" "Received","value" "from smtp"},
{"name" "Date","value" "Fri, 5 Feb 2021 08:31:13 +0000 (UTC)"},
{"name" "From","value" "Penpot <no-reply@penpot.app>"},
{"name" "Reply-To","value" "Penpot <no-reply@penpot.app>"},
{"name" "To","value" email},
{"name" "Message-ID","value" "<1833063698.279.1612513873536@penpot.app>"},
{"name" "Subject","value" "Verify email."},
{"name" "MIME-Version","value" "1.0"},
{"name" "Content-Type","value" "multipart/mixed; boundary=\"----=_Part_276_1174403980.1612513873535\""},
{"name" "X-Penpot-Data","value" token}],
"commonHeaders" {"from" ["Penpot <no-reply@penpot.app>"],
"replyTo" ["Penpot <no-reply@penpot.app>"],
"date" "Fri, 5 Feb 2021 08:31:13 +0000 (UTC)",
"to" [email],
"messageId" "<1833063698.279.1612513873536@penpot.app>",
"subject" "Verify email."}}})
(t/deftest test-parse-bounce-report
(let [profile (th/create-profile* 1)
tokens (:app.tokens/tokens th/*system*)
cfg {:tokens tokens}
report (bounce-report {:token (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})})
result (#'awsns/parse-notification cfg report)]
;; (pprint result)
(t/is (= "bounce" (:type result)))
(t/is (= "permanent" (:kind result)))
(t/is (= "general" (:category result)))
(t/is (= ["user@example.com"] (mapv :email (:recipients result))))
(t/is (= (:id profile) (:profile-id result)))
))
(t/deftest test-parse-complaint-report
(let [profile (th/create-profile* 1)
tokens (:app.tokens/tokens th/*system*)
cfg {:tokens tokens}
report (complaint-report {:token (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})})
result (#'awsns/parse-notification cfg report)]
;; (pprint result)
(t/is (= "complaint" (:type result)))
(t/is (= "abuse" (:kind result)))
(t/is (= nil (:category result)))
(t/is (= ["user@example.com"] (into [] (:recipients result))))
(t/is (= (:id profile) (:profile-id result)))
))
(t/deftest test-parse-complaint-report-without-token
(let [tokens (:app.tokens/tokens th/*system*)
cfg {:tokens tokens}
report (complaint-report {:token ""})
result (#'awsns/parse-notification cfg report)]
(t/is (= "complaint" (:type result)))
(t/is (= "abuse" (:kind result)))
(t/is (= nil (:category result)))
(t/is (= ["user@example.com"] (into [] (:recipients result))))
(t/is (= nil (:profile-id result)))
))
(t/deftest test-process-bounce-report
(let [profile (th/create-profile* 1)
tokens (:app.tokens/tokens th/*system*)
pool (:app.db/pool th/*system*)
cfg {:tokens tokens :pool pool}
report (bounce-report {:token (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
(let [rows (->> (db/query pool :profile-complaint-report {:profile-id (:id profile)})
(mapv decode-row))]
(t/is (= 1 (count rows)))
(t/is (= "bounce" (get-in rows [0 :type])))
(t/is (= "2021-02-04T14:41:38.000Z" (get-in rows [0 :content :timestamp]))))
(let [rows (->> (db/query pool :global-complaint-report :all)
(mapv decode-row))]
(t/is (= 1 (count rows)))
(t/is (= "bounce" (get-in rows [0 :type])))
(t/is (= "user@example.com" (get-in rows [0 :email]))))
(let [prof (db/get-by-id pool :profile (:id profile))]
(t/is (false? (:is-muted prof))))
))
(t/deftest test-process-complaint-report
(let [profile (th/create-profile* 1)
tokens (:app.tokens/tokens th/*system*)
pool (:app.db/pool th/*system*)
cfg {:tokens tokens :pool pool}
report (complaint-report {:token (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
(let [rows (->> (db/query pool :profile-complaint-report {:profile-id (:id profile)})
(mapv decode-row))]
(t/is (= 1 (count rows)))
(t/is (= "complaint" (get-in rows [0 :type])))
(t/is (= "2021-02-05T08:31:15.000Z" (get-in rows [0 :content :timestamp]))))
(let [rows (->> (db/query pool :global-complaint-report :all)
(mapv decode-row))]
(t/is (= 1 (count rows)))
(t/is (= "complaint" (get-in rows [0 :type])))
(t/is (= "user@example.com" (get-in rows [0 :email]))))
(let [prof (db/get-by-id pool :profile (:id profile))]
(t/is (false? (:is-muted prof))))
))
(t/deftest test-process-bounce-report-to-self
(let [profile (th/create-profile* 1)
tokens (:app.tokens/tokens th/*system*)
pool (:app.db/pool th/*system*)
cfg {:tokens tokens :pool pool}
report (bounce-report {:email (:email profile)
:token (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
(let [rows (db/query pool :profile-complaint-report {:profile-id (:id profile)})]
(t/is (= 1 (count rows))))
(let [rows (db/query pool :global-complaint-report :all)]
(t/is (= 1 (count rows))))
(let [prof (db/get-by-id pool :profile (:id profile))]
(t/is (true? (:is-muted prof))))))
(t/deftest test-process-complaint-report-to-self
(let [profile (th/create-profile* 1)
tokens (:app.tokens/tokens th/*system*)
pool (:app.db/pool th/*system*)
cfg {:tokens tokens :pool pool}
report (complaint-report {:email (:email profile)
:token (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})})
report (#'awsns/parse-notification cfg report)]
(#'awsns/process-report cfg report)
(let [rows (db/query pool :profile-complaint-report {:profile-id (:id profile)})]
(t/is (= 1 (count rows))))
(let [rows (db/query pool :global-complaint-report :all)]
(t/is (= 1 (count rows))))
(let [prof (db/get-by-id pool :profile (:id profile))]
(t/is (true? (:is-muted prof))))))
(t/deftest test-allow-send-messages-predicate-with-bounces
(with-mocks [mock {:target 'app.config/get
:return (th/mock-config-get-with
{:profile-bounce-threshold 3
:profile-complaint-threshold 2})}]
(let [profile (th/create-profile* 1)
pool (:app.db/pool th/*system*)]
(th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})})
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
(t/is (true? (emails/allow-send-emails? pool profile)))
(t/is (= 4 (:call-count (deref mock))))
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
(t/is (false? (emails/allow-send-emails? pool profile))))))
(t/deftest test-allow-send-messages-predicate-with-complaints
(with-mocks [mock {:target 'app.config/get
:return (th/mock-config-get-with
{:profile-bounce-threshold 3
:profile-complaint-threshold 2})}]
(let [profile (th/create-profile* 1)
pool (:app.db/pool th/*system*)]
(th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})})
(th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})})
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
(th/create-complaint-for pool {:type :complaint :id (:id profile)})
(t/is (true? (emails/allow-send-emails? pool profile)))
(t/is (= 4 (:call-count (deref mock))))
(th/create-complaint-for pool {:type :complaint :id (:id profile)})
(t/is (false? (emails/allow-send-emails? pool profile))))))
(t/deftest test-has-complaint-reports-predicate
(let [profile (th/create-profile* 1)
pool (:app.db/pool th/*system*)]
(t/is (false? (emails/has-complaint-reports? pool (:email profile))))
(th/create-global-complaint-for pool {:type :bounce :email (:email profile)})
(t/is (false? (emails/has-complaint-reports? pool (:email profile))))
(th/create-global-complaint-for pool {:type :complaint :email (:email profile)})
(t/is (true? (emails/has-complaint-reports? pool (:email profile))))))
(t/deftest test-has-bounce-reports-predicate
(let [profile (th/create-profile* 1)
pool (:app.db/pool th/*system*)]
(t/is (false? (emails/has-bounce-reports? pool (:email profile))))
(th/create-global-complaint-for pool {:type :complaint :email (:email profile)})
(t/is (false? (emails/has-bounce-reports? pool (:email profile))))
(th/create-global-complaint-for pool {:type :bounce :email (:email profile)})
(t/is (true? (emails/has-bounce-reports? pool (:email profile))))))

View file

@ -11,7 +11,6 @@
(:require (:require
[clojure.test :as t] [clojure.test :as t]
[promesa.core :as p] [promesa.core :as p]
[mockery.core :refer [with-mock]]
[app.db :as db] [app.db :as db]
[app.emails :as emails] [app.emails :as emails]
[app.tests.helpers :as th])) [app.tests.helpers :as th]))

View file

@ -18,6 +18,9 @@
[app.rpc.mutations.profile :as profile] [app.rpc.mutations.profile :as profile]
[app.tests.helpers :as th])) [app.tests.helpers :as th]))
;; TODO: profile deletion with teams
;; TODO: profile deletion with owner teams
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
@ -187,7 +190,175 @@
(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? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com"))))))
;; TODO: profile deletion with teams (t/deftest test-register-when-registration-disabled
;; TODO: profile deletion with owner teams (with-mocks [mock {:target 'app.config/get
;; TODO: profile registration :return (th/mock-config-get-with
;; TODO: profile password recovery {:registration-enabled false})}]
(let [data {::th/type :register-profile
:email "user@example.com"
:password "foobar"
:fullname "foobar"}
out (th/mutation! data)
error (:error out)
edata (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type edata) :restriction))
(t/is (= (:code edata) :registration-disabled)))))
(t/deftest test-register-existing-profile
(let [profile (th/create-profile* 1)
data {::th/type :register-profile
:email (:email profile)
:password "foobar"
:fullname "foobar"}
out (th/mutation! data)
error (:error out)
edata (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type edata) :validation))
(t/is (= (:code edata) :email-already-exists))))
(t/deftest test-register-profile
(with-mocks [mock {:target 'app.emails/send!
:return nil}]
(let [pool (:app.db/pool th/*system*)
data {::th/type :register-profile
:email "user@example.com"
:password "foobar"
:fullname "foobar"}
out (th/mutation! data)]
;; (th/print-result! out)
(let [mock (deref mock)
[_ _ params] (:call-args mock)]
;; (clojure.pprint/pprint params)
(t/is (:called? mock))
(t/is (= (:email data) (:to params)))
(t/is (contains? params :extra-data))
(t/is (contains? params :token)))
(let [result (:result out)]
(t/is (false? (:is-demo result)))
(t/is (= (:email data) (:email result)))
(t/is (= "penpot" (:auth-backend result)))
(t/is (= "foobar" (:fullname result)))
(t/is (not (contains? result :password)))))))
(t/deftest test-register-profile-with-bounced-email
(with-mocks [mock {:target 'app.emails/send!
:return nil}]
(let [pool (:app.db/pool th/*system*)
data {::th/type :register-profile
:email "user@example.com"
:password "foobar"
:fullname "foobar"}
_ (th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
out (th/mutation! data)]
;; (th/print-result! out)
(let [mock (deref mock)]
(t/is (false? (:called? mock))))
(let [error (:error out)
edata (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type edata) :validation))
(t/is (= (:code edata) :email-has-permanent-bounces))))))
(t/deftest test-register-profile-with-complained-email
(with-mocks [mock {:target 'app.emails/send! :return nil}]
(let [pool (:app.db/pool th/*system*)
data {::th/type :register-profile
:email "user@example.com"
:password "foobar"
:fullname "foobar"}
_ (th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
out (th/mutation! data)]
(let [mock (deref mock)]
(t/is (true? (:called? mock))))
(let [result (:result out)]
(t/is (= (:email data) (:email result)))))))
(t/deftest test-email-change-request
(with-mocks [mock {:target 'app.emails/send! :return nil}]
(let [profile (th/create-profile* 1)
pool (:app.db/pool th/*system*)
data {::th/type :request-email-change
:profile-id (:id profile)
:email "user1@example.com"}]
;; without complaints
(let [out (th/mutation! data)]
;; (th/print-result! out)
(t/is (nil? (:result out)))
(let [mock (deref mock)]
(t/is (= 1 (:call-count mock)))
(t/is (true? (:called? mock)))))
;; with complaints
(th/create-global-complaint-for pool {:type :complaint :email (:email data)})
(let [out (th/mutation! data)]
;; (th/print-result! out)
(t/is (nil? (:result out)))
(t/is (= 2 (:call-count (deref mock)))))
;; with bounces
(th/create-global-complaint-for pool {:type :bounce :email (:email data)})
(let [out (th/mutation! data)
error (:error out)]
;; (th/print-result! out)
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-has-permanent-bounces))
(t/is (= 2 (:call-count (deref mock))))))))
(t/deftest test-request-profile-recovery
(with-mocks [mock {:target 'app.emails/send! :return nil}]
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2 {:is-active true})
pool (:app.db/pool th/*system*)
data {::th/type :request-profile-recovery}]
;; with invalid email
(let [data (assoc data :email "foo@bar.com")
out (th/mutation! data)]
(t/is (nil? (:result out)))
(t/is (= 0 (:call-count (deref mock)))))
;; with valid email inactive user
(let [data (assoc data :email (:email profile1))
out (th/mutation! data)
error (:error out)]
(t/is (= 0 (:call-count (deref mock))))
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :profile-not-verified)))
;; with valid email and active user
(let [data (assoc data :email (:email profile2))
out (th/mutation! data)]
;; (th/print-result! out)
(t/is (nil? (:result out)))
(t/is (= 1 (:call-count (deref mock)))))
;; with valid email and active user with global complaints
(th/create-global-complaint-for pool {:type :complaint :email (:email profile2)})
(let [data (assoc data :email (:email profile2))
out (th/mutation! data)]
;; (th/print-result! out)
(t/is (nil? (:result out)))
(t/is (= 2 (:call-count (deref mock)))))
;; with valid email and active user with global bounce
(th/create-global-complaint-for pool {:type :bounce :email (:email profile2)})
(let [data (assoc data :email (:email profile2))
out (th/mutation! data)
error (:error out)]
;; (th/print-result! out)
(t/is (= 2 (:call-count (deref mock))))
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-has-permanent-bounces)))
)))

View file

@ -0,0 +1,88 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.tests.test-services-teams
(:require
[app.common.uuid :as uuid]
[app.db :as db]
[app.http :as http]
[app.storage :as sto]
[app.tests.helpers :as th]
[mockery.core :refer [with-mocks]]
[clojure.test :as t]
[datoteka.core :as fs]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest test-invite-team-member
(with-mocks [mock {:target 'app.emails/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
profile3 (th/create-profile* 3 {:is-active true :is-muted true})
team (th/create-team* 1 {:profile-id (:id profile1)})
pool (:app.db/pool th/*system*)
data {::th/type :invite-team-member
:team-id (:id team)
:role :editor
:profile-id (:id profile1)}]
;; (th/print-result! out)
;; invite external user without complaints
(let [data (assoc data :email "foo@bar.com")
out (th/mutation! data)]
(t/is (nil? (:result out)))
(t/is (= 1 (:call-count (deref mock)))))
;; invite internal user without complaints
(th/reset-mock! mock)
(let [data (assoc data :email (:email profile2))
out (th/mutation! data)]
(t/is (nil? (:result out)))
(t/is (= 1 (:call-count (deref mock)))))
;; invite user with complaint
(th/create-global-complaint-for pool {:type :complaint :email "foo@bar.com"})
(th/reset-mock! mock)
(let [data (assoc data :email "foo@bar.com")
out (th/mutation! data)]
(t/is (nil? (:result out)))
(t/is (= 1 (:call-count (deref mock)))))
;; invite user with bounce
(th/reset-mock! mock)
(th/create-global-complaint-for pool {:type :bounce :email "foo@bar.com"})
(let [data (assoc data :email "foo@bar.com")
out (th/mutation! data)
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-has-permanent-bounces))
(t/is (= 0 (:call-count (deref mock)))))
;; invite internal user that is muted
(th/reset-mock! mock)
(let [data (assoc data :email (:email profile3))
out (th/mutation! data)
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :member-is-muted))
(t/is (= 0 (:call-count (deref mock)))))
)))

View file

@ -99,6 +99,10 @@ http {
proxy_pass http://127.0.0.1:6060/api; proxy_pass http://127.0.0.1:6060/api;
} }
location /webhooks {
proxy_pass http://127.0.0.1:6060/webhooks;
}
location /dbg { location /dbg {
proxy_pass http://127.0.0.1:6060/dbg; proxy_pass http://127.0.0.1:6060/dbg;
} }

View file

@ -836,6 +836,28 @@
"es" : "Autenticación con google esta dehabilitada en el servidor" "es" : "Autenticación con google esta dehabilitada en el servidor"
} }
}, },
"errors.profile-is-muted" : {
"translations" : {
"en" : "Your profile has emails muted (spam reports or high bounces).",
"es" : "Tu perfil tiene los emails silenciados (por reportes de spam o alto indice de rebote)."
}
},
"errors.member-is-muted" : {
"translations" : {
"en" : "The profile you inviting has emails muted (spam reports or high bounces).",
"es" : "El perfil que esta invitando tiene los emails silenciados (por reportes de spam o alto indice de rebote)."
}
},
"errors.email-has-permanent-bounces" : {
"translations" : {
"en" : "The email «%s» has many permanent bounce reports.",
"es" : "El email «%s» tiene varios reportes de rebote permanente."
}
},
"errors.auth.unauthorized" : { "errors.auth.unauthorized" : {
"used-in" : [ "src/app/main/ui/auth/login.cljs:89" ], "used-in" : [ "src/app/main/ui/auth/login.cljs:89" ],
"translations" : { "translations" : {

View file

@ -122,18 +122,6 @@
(seq params)) (seq params))
(send-mutation! id form))) (send-mutation! id form)))
(defmethod mutation :login
[id params]
(let [uri (str cfg/public-uri "/api/login")]
(->> (http/send! {:method :post :uri uri :body params})
(rx/mapcat handle-response))))
(defmethod mutation :logout
[id params]
(let [uri (str cfg/public-uri "/api/logout")]
(->> (http/send! {:method :post :uri uri :body params})
(rx/mapcat handle-response))))
(defmethod mutation :login-with-ldap (defmethod mutation :login-with-ldap
[id params] [id params]
(let [uri (str cfg/public-uri "/api/login-ldap")] (let [uri (str cfg/public-uri "/api/login-ldap")]

View file

@ -63,7 +63,6 @@
on-error on-error
(fn [form event] (fn [form event]
(js/console.log error?)
(reset! error? true)) (reset! error? true))
on-submit on-submit
@ -107,8 +106,7 @@
:help-icon i/eye :help-icon i/eye
:label (tr "auth.password")}]] :label (tr "auth.password")}]]
[:& fm/submit-button [:& fm/submit-button
{:label (tr "auth.login-submit") {:label (tr "auth.login-submit")}]
:on-click on-submit}]
(when cfg/login-with-ldap (when cfg/login-with-ldap
[:& fm/submit-button [:& fm/submit-button

View file

@ -18,9 +18,9 @@
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr t]] [app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt] [app.util.router :as rt]
[beicon.core :as rx]
[cljs.spec.alpha :as s] [cljs.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[beicon.core :as rx]
[rumext.alpha :as mf])) [rumext.alpha :as mf]))
(s/def ::email ::us/email) (s/def ::email ::us/email)
@ -28,37 +28,41 @@
(mf/defc recovery-form (mf/defc recovery-form
[] []
(let [form (fm/use-form :spec ::recovery-request-form (let [form (fm/use-form :spec ::recovery-request-form :initial {})
:initial {})
submitted (mf/use-state false) submitted (mf/use-state false)
on-error
(mf/use-callback
(fn [{:keys [code] :as error}]
(reset! submitted false)
(if (= code :profile-not-verified)
(rx/of (dm/error (tr "auth.notifications.profile-not-verified")
{:timeout nil}))
(rx/throw error))))
on-success on-success
(mf/use-callback (mf/use-callback
(fn [] (fn [data]
(reset! submitted false) (reset! submitted false)
(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent")) (st/emit! (dm/info (tr "auth.notifications.recovery-token-sent"))
(rt/nav :auth-login)))) (rt/nav :auth-login))))
on-error
(mf/use-callback
(fn [data {:keys [code] :as error}]
(reset! submitted false)
(case code
:profile-not-verified
(rx/of (dm/error (tr "auth.notifications.profile-not-verified") {:timeout nil}))
:profile-is-muted
(rx/of (dm/error (tr "errors.profile-is-muted")))
:email-has-permanent-bounces
(rx/of (dm/error (tr "errors.email-has-permanent-bounces" (:email data))))
(rx/throw error))))
on-submit on-submit
(mf/use-callback (mf/use-callback
(fn [] (fn []
(reset! submitted true) (reset! submitted true)
(->> (with-meta (:clean-data @form) (let [cdata (:clean-data @form)
{:on-success on-success params (with-meta cdata
:on-error on-error}) {:on-success #(on-success cdata %)
(uda/request-profile-recovery) :on-error #(on-error cdata %)})]
(st/emit!))))] (st/emit! (uda/request-profile-recovery params)))))]
[:& fm/form {:on-submit on-submit [:& fm/form {:on-submit on-submit
:form form} :form form}

View file

@ -64,13 +64,17 @@
(reset! submitted? false) (reset! submitted? false)
(case (:code error) (case (:code error)
:registration-disabled :registration-disabled
(st/emit! (dm/error (tr "errors.registration-disabled"))) (rx/of (dm/error (tr "errors.registration-disabled")))
:email-has-permanent-bounces
(let [email (get @form [:data :email])]
(rx/of (dm/error (tr "errors.email-has-permanent-bounces" email))))
:email-already-exists :email-already-exists
(swap! form assoc-in [:errors :email] (swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"}) {:message "errors.email-already-exists"})
(st/emit! (dm/error (tr "errors.unexpected-error")))))) (rx/throw error))))
on-success on-success
(mf/use-callback (mf/use-callback

View file

@ -97,13 +97,34 @@
(st/emitf (dm/success "Invitation sent successfully") (st/emitf (dm/success "Invitation sent successfully")
(modal/hide))) (modal/hide)))
on-error
(mf/use-callback
(mf/deps team)
(fn [form {:keys [type code] :as error}]
(let [email (get @form [:data :email])]
(cond
(and (= :validation type)
(= :profile-is-muted code))
(dm/error (tr "errors.profile-is-muted"))
(and (= :validation type)
(= :member-is-muted code))
(dm/error (tr "errors.member-is-muted"))
(and (= :validation type)
(= :email-has-permanent-bounces))
(dm/error (tr "errors.email-has-permanent-bounces" email))
:else
(dm/error (tr "errors.generic"))))))
on-submit on-submit
(mf/use-callback (mf/use-callback
(mf/deps team) (mf/deps team)
(fn [form] (fn [form]
(let [params (:clean-data @form) (let [params (:clean-data @form)
mdata {:on-success (partial on-success form)}] mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}]
(st/emit! (dd/invite-team-member (with-meta params mdata))))))] (st/emit! (dd/invite-team-member (with-meta params mdata))))))]
[:div.modal.dashboard-invite-modal.form-container [:div.modal.dashboard-invite-modal.form-container

View file

@ -40,14 +40,20 @@
(s/keys :req-un [::email-1 ::email-2])) (s/keys :req-un [::email-1 ::email-2]))
(defn- on-error (defn- on-error
[form error] [form {:keys [code] :as error}]
(cond (case code
(= (:code error) :email-already-exists) :email-already-exists
(swap! form (fn [data] (swap! form (fn [data]
(let [error {:message (tr "errors.email-already-exists")}] (let [error {:message (tr "errors.email-already-exists")}]
(assoc-in data [:errors :email-1] error)))) (assoc-in data [:errors :email-1] error))))
:else :profile-is-muted
(rx/of (dm/error (tr "errors.profile-is-muted")))
:email-has-permanent-bounces
(let [email (get @form [:data email])]
(rx/of (dm/error (tr "errors.email-has-permanent-bounces" email))))
(rx/throw error))) (rx/throw error)))
(defn- on-success (defn- on-success