diff --git a/backend/deps.edn b/backend/deps.edn index 9704c2240..c4cd8b435 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -7,6 +7,7 @@ org.clojure/clojurescript {:mvn/version "1.10.773"} org.clojure/data.json {:mvn/version "1.0.0"} org.clojure/core.async {:mvn/version "1.3.610"} + org.clojure/tools.cli {:mvn/version "1.0.194"} ;; Logging org.clojure/tools.logging {:mvn/version "1.1.0"} diff --git a/backend/scripts/build.sh b/backend/scripts/build.sh index 39d089040..57b2e3057 100755 --- a/backend/scripts/build.sh +++ b/backend/scripts/build.sh @@ -25,12 +25,8 @@ echo $NEWCP > ./target/dist/classpath; tee -a ./target/dist/run.sh >> /dev/null <> /dev/null <&2 echo "Couldn't find 'java'. Please set JAVA_HOME." + exit 1 + fi +fi + +if [ -f ./environ ]; then + source ./environ +fi + +exec \$JAVA_CMD \$JVM_OPTS -classpath \$CP -Dlog4j.configurationFile=./log4j2.xml clojure.main -m app.cli.manage "\$@" +EOF + chmod +x ./target/dist/run.sh +chmod +x ./target/dist/manage.sh + diff --git a/backend/src/app/cli/manage.clj b/backend/src/app/cli/manage.clj new file mode 100644 index 000000000..a3c26d457 --- /dev/null +++ b/backend/src/app/cli/manage.clj @@ -0,0 +1,172 @@ +;; 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.cli.manage + "A manage cli api." + (:require + [app.config :as cfg] + [app.db :as db] + [app.main :as main] + [app.rpc.mutations.profile :as profile] + [app.rpc.queries.profile :refer [retrieve-profile-data-by-email]] + [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] + [clojure.tools.logging :as log] + [integrant.core :as ig]) + (:import + java.io.Console)) + +;; --- IMPL + +(defn init-system + [] + (let [data (-> (main/build-system-config cfg/config) + (select-keys [:app.db/pool :app.metrics/metrics]) + (assoc :app.migrations/all {}))] + (-> data ig/prep ig/init))) + +(defn- read-from-console + [{:keys [label type] :or {type :text}}] + (let [^Console console (System/console)] + (when-not console + (log/error "no console found, can proceed") + (System/exit 1)) + + (binding [*out* (.writer console)] + (print label " ") + (.flush *out*)) + + (case type + :text (.readLine console) + :password (String. (.readPassword console))))) + +(defn create-profile + [options] + (let [system (init-system) + email (or (:email options) + (read-from-console {:label "Email:"})) + fullname (or (:fullname options) + (read-from-console {:label "Full Name:"})) + password (or (:password options) + (read-from-console {:label "Password:" + :type :password}))] + (try + (db/with-atomic [conn (:app.db/pool system)] + (->> (profile/create-profile conn + {:fullname fullname + :email email + :password password + :is-active true + :is-demo false}) + (profile/create-profile-relations conn))) + + (when (pos? (:verbosity options)) + (println "User created successfully.")) + (System/exit 0) + + (catch Exception _e + (when (pos? (:verbosity options)) + (println "Unable to create user, already exists.")) + (System/exit 1))))) + +(defn reset-password + [options] + (let [system (init-system)] + (try + (db/with-atomic [conn (:app.db/pool system)] + (let [email (or (:email options) + (read-from-console {:label "Email:"})) + profile (retrieve-profile-data-by-email conn email)] + (when-not profile + (when (pos? (:verbosity options)) + (println "Profile does not exists.")) + (System/exit 1)) + + (let [password (or (:password options) + (read-from-console {:label "Password:" + :type :password}))] + (profile/update-profile-password! conn (assoc profile :password password)) + (when (pos? (:verbosity options)) + (println "Password changed successfully."))))) + (System/exit 0) + (catch Exception e + (when (pos? (:verbosity options)) + (println "Unable to change password.")) + (when (= 2 (:verbosity options)) + (.printStackTrace e)) + (System/exit 1))))) + +;; --- CLI PARSE + +(def cli-options + ;; An option with a required argument + [["-u" "--email EMAIL" "Email Address"] + ["-p" "--password PASSWORD" "Password"] + ["-n" "--name FULLNAME" "Full Name"] + ["-v" nil "Verbosity level" + :id :verbosity + :default 1 + :update-fn inc] + ["-q" nil "Dont' print to console" + :id :verbosity + :update-fn (constantly 0)] + ["-h" "--help"]]) + +(defn usage + [options-summary] + (->> ["Penpot CLI management." + "" + "Usage: manage [options] action" + "" + "Options:" + options-summary + "" + "Actions:" + " create-profile Create new profile." + " reset-password Reset profile password." + ""] + (str/join \newline))) + +(defn error-msg [errors] + (str "The following errors occurred while parsing your command:\n\n" + (str/join \newline errors))) + +(defn validate-args + "Validate command line arguments. Either return a map indicating the program + should exit (with a error message, and optional ok status), or a map + indicating the action the program should take and the options provided." + [args] + (let [{:keys [options arguments errors summary] :as opts} (parse-opts args cli-options)] + ;; (pp/pprint opts) + (cond + (:help options) ; help => exit OK with usage summary + {:exit-message (usage summary) :ok? true} + + errors ; errors => exit with description of errors + {:exit-message (error-msg errors)} + + ;; custom validation on arguments + :else + (let [action (first arguments)] + (if (#{"create-profile" "reset-password"} action) + {:action (first arguments) :options options} + {:exit-message (usage summary)}))))) + +(defn exit [status msg] + (println msg) + (System/exit status)) + +(defn -main + [& args] + (let [{:keys [action options exit-message ok?]} (validate-args args)] + (if exit-message + (exit (if ok? 0 1) exit-message) + (case action + "create-profile" (create-profile options) + "reset-password" (reset-password options))))) diff --git a/backend/src/app/rpc/mutations/profile.clj b/backend/src/app/rpc/mutations/profile.clj index 53660f315..28f4823dc 100644 --- a/backend/src/app/rpc/mutations/profile.clj +++ b/backend/src/app/rpc/mutations/profile.clj @@ -341,12 +341,8 @@ ;; --- Mutation: Update Password -(defn- validate-password! - [conn {:keys [profile-id old-password] :as params}] - (let [profile (db/get-by-id conn :profile profile-id)] - (when-not (:valid (verify-password old-password (:password profile))) - (ex/raise :type :validation - :code :old-password-not-match)))) +(declare validate-password!) +(declare update-profile-password!) (s/def ::update-profile-password (s/keys :req-un [::profile-id ::password ::old-password])) @@ -354,12 +350,23 @@ (sv/defmethod ::update-profile-password {:rlimit :password} [{:keys [pool] :as cfg} {:keys [password profile-id] :as params}] (db/with-atomic [conn pool] - (validate-password! conn params) - (db/update! conn :profile - {:password (derive-password password)} - {:id profile-id}) - nil)) + (let [profile (validate-password! conn params)] + (update-profile-password! conn (assoc profile :password password)) + nil))) +(defn- validate-password! + [conn {:keys [profile-id old-password] :as params}] + (let [profile (db/get-by-id conn :profile profile-id)] + (when-not (:valid (verify-password old-password (:password profile))) + (ex/raise :type :validation + :code :old-password-not-match)) + profile)) + +(defn update-profile-password! + [conn {:keys [id password] :as profile}] + (db/update! conn :profile + {:password (derive-password password)} + {:id id})) ;; --- Mutation: Update Photo @@ -393,45 +400,68 @@ {:id profile-id}) nil) + ;; --- Mutation: Request Email Change +(declare request-email-change) +(declare change-email-inmediatelly) + (s/def ::request-email-change (s/keys :req-un [::email])) (sv/defmethod ::request-email-change - [{:keys [pool tokens] :as cfg} {:keys [profile-id email] :as params}] + [{:keys [pool] :as cfg} {:keys [profile-id email] :as params}] (db/with-atomic [conn pool] - (let [email (str/lower email) - profile (db/get-by-id conn :profile profile-id) - token (tokens :generate - {:iss :change-email - :exp (dt/in-future "15m") - :profile-id profile-id - :email email}) - ptoken (tokens :generate-predefined - {:iss :profile-identity - :profile-id (:id profile)})] + (let [profile (db/get-by-id conn :profile profile-id) + cfg (assoc cfg :conn conn) + params (assoc params + :profile profile + :email (str/lower email))] + (if (cfg/get :smtp-enabled) + (request-email-change cfg params) + (change-email-inmediatelly cfg params))))) - (when (not= email (:email profile)) - (check-profile-existence! conn params)) +(defn- change-email-inmediatelly + [{:keys [conn]} {:keys [profile email] :as params}] + (when (not= email (:email profile)) + (check-profile-existence! conn params)) + (db/update! conn :profile + {:email email} + {:id (:id profile)}) + {:changed true}) - (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.")) +(defn- request-email-change + [{:keys [conn tokens]} {:keys [profile email] :as params}] + (let [token (tokens :generate + {:iss :change-email + :exp (dt/in-future "15m") + :profile-id (:id profile) + :email email}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] - (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")) + (when (not= email (:email profile)) + (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 + {:to (:email profile) + :name (:fullname profile) + :pending-email email + :token token + :extra-data ptoken}) + nil)) - (emails/send! conn emails/change-email - {:to (:email profile) - :name (:fullname profile) - :pending-email email - :token token - :extra-data ptoken}) - nil))) (defn select-profile-for-update [conn id] diff --git a/backend/tests/app/tests/test_services_profile.clj b/backend/tests/app/tests/test_services_profile.clj index b355285e5..f48fc8801 100644 --- a/backend/tests/app/tests/test_services_profile.clj +++ b/backend/tests/app/tests/test_services_profile.clj @@ -281,18 +281,21 @@ (t/is (= (:email data) (:email result))))))) (t/deftest test-email-change-request - (with-mocks [mock {:target 'app.emails/send! :return nil}] + (with-mocks [email-send-mock {:target 'app.emails/send! :return nil} + cfg-get-mock {:target 'app.config/get + :return (th/mock-config-get-with + {:smtp-enabled true})}] (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"}] + 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)] + (let [mock (deref email-send-mock)] (t/is (= 1 (:call-count mock))) (t/is (true? (:called? mock))))) @@ -301,7 +304,7 @@ (let [out (th/mutation! data)] ;; (th/print-result! out) (t/is (nil? (:result out))) - (t/is (= 2 (:call-count (deref mock))))) + (t/is (= 2 (:call-count (deref email-send-mock))))) ;; with bounces (th/create-global-complaint-for pool {:type :bounce :email (:email data)}) @@ -311,7 +314,27 @@ (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/is (= 2 (:call-count (deref email-send-mock)))))))) + + +(t/deftest test-email-change-request-without-smtp + (with-mocks [email-send-mock {:target 'app.emails/send! :return nil} + cfg-get-mock {:target 'app.config/get + :return (th/mock-config-get-with + {:smtp-enabled false})}] + (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) + res (:result out)] + (t/is (= {:changed true} res)) + (let [mock (deref email-send-mock)] + (t/is (false? (:called? mock)))))))) + (t/deftest test-request-profile-recovery (with-mocks [mock {:target 'app.emails/send! :return nil}] diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index b35bc8af3..63a1ea84a 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -58,10 +58,13 @@ (defn- on-success [form data] - (let [email (get-in @form [:clean-data :email-1]) - message (tr "notifications.validation-email-sent" email)] - (st/emit! (dm/info message) - (modal/hide)))) + (if (:changed data) + (st/emit! (du/fetch-profile) + (modal/hide)) + (let [email (get-in @form [:clean-data :email-1]) + message (tr "notifications.validation-email-sent" email)] + (st/emit! (dm/info message) + (modal/hide))))) (defn- on-submit [form event]