Skip to content
Snippets Groups Projects
Unverified Commit b389c059 authored by Jerry Huang's avatar Jerry Huang Committed by GitHub
Browse files

Allow non-Metabase users to unsubscribe from alerts (#30597)

* initial changes

* add job + tests

* add test for job

* fix spacing

* address comments

* update variable
parent 0281e962
No related branches found
No related tags found
No related merge requests found
(ns metabase.api.session
"/api/session endpoints"
(:require
[buddy.core.codecs :as codecs]
[cheshire.core :as json]
[compojure.core :refer [DELETE GET POST]]
[java-time :as t]
[metabase.analytics.snowplow :as snowplow]
......@@ -11,6 +13,7 @@
[metabase.events :as events]
[metabase.integrations.google :as google]
[metabase.integrations.ldap :as ldap]
[metabase.models :refer [PulseChannel]]
[metabase.models.login-history :refer [LoginHistory]]
[metabase.models.session :refer [Session]]
[metabase.models.setting :as setting]
......@@ -19,8 +22,10 @@
[metabase.server.middleware.session :as mw.session]
[metabase.server.request.util :as request.u]
[metabase.util :as u]
[metabase.util.encryption :as encryption]
[metabase.util.i18n :refer [deferred-tru trs tru]]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[metabase.util.password :as u.password]
[metabase.util.schema :as su]
[schema.core :as s]
......@@ -313,4 +318,57 @@
(log/error e (trs "Authentication endpoint error"))
(throw e)))))
;;; ----------------------------------------------------- Unsubscribe non-users from pulses -----------------------------------------------
(def ^:private unsubscribe-throttler (throttle/make-throttler :unsubscribe, :attempts-threshold 50))
(defn generate-hash
"Generates hash to allow for non-users to unsubscribe from pulses/subscriptions."
[pulse-id email]
(codecs/bytes->hex
(encryption/validate-and-hash-secret-key
(json/generate-string {:salt public-settings/site-uuid-for-unsubscribing-url
:email email
:pulse-id pulse-id}))))
(defn- check-hash [pulse-id email hash ip-address]
(throttle-check unsubscribe-throttler ip-address)
(when (not= hash (generate-hash pulse-id email))
(throw (ex-info (tru "Invalid hash.")
{:type type
:status-code 400}))))
(api/defendpoint POST "/pulse/unsubscribe"
"Allow non-users to unsubscribe from pulses/subscriptions, with the hash given through email."
[:as {{:keys [email hash pulse-id]} :body, :as request}]
{pulse-id ms/PositiveInt
email :string
hash :string}
(check-hash pulse-id email hash (request.u/ip-address request))
(api/let-404 [pulse-channel (t2/select-one PulseChannel :pulse_id pulse-id :channel_type "email")]
(let [emails (get-in pulse-channel [:details :emails])]
(if (some #{email} emails)
(t2/update! PulseChannel (:id pulse-channel) (assoc-in pulse-channel [:details :emails] (remove #{email} emails)))
(throw (ex-info (tru "Email for pulse-id doesn't exist.")
{:type type
:status-code 400}))))
{:status :success}))
(api/defendpoint POST "/pulse/unsubscribe/undo"
"Allow non-users to undo an unsubscribe from pulses/subscriptions, with the hash given through email."
[:as {{:keys [email hash pulse-id]} :body, :as request}]
{pulse-id ms/PositiveInt
email :string
hash :string}
(check-hash pulse-id email hash (request.u/ip-address request))
(api/let-404 [pulse-channel (t2/select-one PulseChannel :pulse_id pulse-id :channel_type "email")]
(let [emails (get-in pulse-channel [:details :emails])
given-email? #(= % email)]
(if (some given-email? emails)
(throw (ex-info (tru "Email for pulse-id already exists.")
{:type type
:status-code 400}))
(t2/update! PulseChannel (:id pulse-channel) (update-in pulse-channel [:details :emails] conj email)))))
{:status :success})
(api/define-routes +log-all-request-failures)
......@@ -120,6 +120,13 @@
:setter :none
:type ::uuid-nonce)
(defsetting site-uuid-for-unsubscribing-url
"UUID that we use for generating urls users to unsubscribe from alerts. The hash is generated by
hash(secret_uuid + email + subscription_id) = url. Do not use this for any other applications. (See #29955)"
:visibility :internal
:setter :none
:type ::uuid-nonce)
(defn- normalize-site-url [^String s]
(let [ ;; remove trailing slashes
s (str/replace s #"/$" "")
......
......@@ -7,6 +7,7 @@
[clojurewerkz.quartzite.schedule.cron :as cron]
[clojurewerkz.quartzite.triggers :as triggers]
[metabase.driver :as driver]
[metabase.models :refer [PulseChannel PulseChannelRecipient]]
[metabase.models.pulse :as pulse]
[metabase.models.pulse-channel :as pulse-channel]
[metabase.models.task-history :as task-history]
......@@ -14,7 +15,8 @@
[metabase.task :as task]
[metabase.util.i18n :refer [trs]]
[metabase.util.log :as log]
[schema.core :as s]))
[schema.core :as s]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
......@@ -61,6 +63,14 @@
(catch Throwable e
(on-error pulse-id e)))))))
(s/defn ^:private clear-pulse-channels!
[]
(doseq [channel (t2/select PulseChannel)]
(let [pulse-channel-id (:id channel)]
(when (and (nil? (get-in channel [:details :emails]))
(nil? (get-in channel [:details :channel]))
(zero? (t2/count PulseChannelRecipient :pulse_channel_id pulse-channel-id)))
(t2/delete! PulseChannel :id pulse-channel-id)))))
;;; ------------------------------------------------------ Task ------------------------------------------------------
......@@ -96,7 +106,8 @@
:id)
curr-monthday (monthday now)
curr-monthweek (monthweek now)]
(send-pulses! curr-hour curr-weekday curr-monthday curr-monthweek)))
(send-pulses! curr-hour curr-weekday curr-monthday curr-monthweek))
(clear-pulse-channels!))
(catch Throwable e
(log/error e (trs "SendPulses task failed")))))
......
......@@ -8,11 +8,8 @@
[metabase.driver.h2 :as h2]
[metabase.http-client :as client]
[metabase.models
:refer [LoginHistory
PermissionsGroup
PermissionsGroupMembership
Session
User]]
:refer [LoginHistory PermissionsGroup PermissionsGroupMembership Pulse
PulseChannel Session User]]
[metabase.models.setting :as setting]
[metabase.public-settings :as public-settings]
[metabase.server.middleware.session :as mw.session]
......@@ -489,3 +486,59 @@
clojure.lang.ExceptionInfo
#"Password did not match stored password"
(#'api.session/login (:email user) "password" device-info)))))))
;;; ------------------------------------------- TESTS FOR UNSUBSCRIBING NONUSERS STUFF --------------------------------------------
(deftest unsubscribe-test
(testing "POST /pulse/unsubscribe"
(let [email "test@metabase.com"]
(testing "Invalid hash"
(is (= "Invalid hash."
(mt/client :post 400 "session/pulse/unsubscribe" {:pulse-id 1
:email email
:hash "fake-hash"}))))
(testing "Valid hash but not email"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [_ {:pulse_id pulse-id}]]
(is (= "Email for pulse-id doesnt exist."
(mt/client :post 400 "session/pulse/unsubscribe" {:pulse-id pulse-id
:email email
:hash (api.session/generate-hash pulse-id email)})))))
(testing "Valid hash and email"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [_ {:pulse_id pulse-id
:channel_type "email"
:details {:emails [email]}}]]
(is (= {:status "success"}
(mt/client :post 200 "session/pulse/unsubscribe" {:pulse-id pulse-id
:email email
:hash (api.session/generate-hash pulse-id email)}))))))))
(deftest unsubscribe-undo-test
(testing "POST /pulse/unsubscribe/undo"
(let [email "test@metabase.com"]
(testing "Invalid hash"
(is (= "Invalid hash."
(mt/client :post 400 "session/pulse/unsubscribe/undo" {:pulse-id 1
:email email
:hash "fake-hash"}))))
(testing "Valid hash and email doesn't exist"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [_ {:pulse_id pulse-id}]]
(is (= {:status "success"}
(mt/client :post 200 "session/pulse/unsubscribe/undo" {:pulse-id pulse-id
:email email
:hash (api.session/generate-hash pulse-id email)})))))
(testing "Valid hash and email already exists"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [_ {:pulse_id pulse-id
:channel_type "email"
:details {:emails [email]}}]]
(is (= "Email for pulse-id already exists."
(mt/client :post 400 "session/pulse/unsubscribe/undo" {:pulse-id pulse-id
:email email
:hash (api.session/generate-hash pulse-id email)}))))))))
......@@ -10,7 +10,8 @@
[metabase.models.pulse-channel-recipient :refer [PulseChannelRecipient]]
[metabase.pulse.test-util :refer [checkins-query-card]]
[metabase.task.send-pulses :as send-pulses]
[metabase.test :as mt]))
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest send-pulses-test
(mt/with-temp* [Card [{card-id :id} (assoc (checkins-query-card {:breakout [[:field (mt/id :checkins :date) {:temporal-unit :day}]]})
......@@ -79,3 +80,40 @@
(testing "There shouldn't be any failures, just skipping over the archived pulse"
(is (= []
@exceptions))))))))
(deftest clear-pulse-channels-test
(testing "Removes empty PulseChannel"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [_ {:pulse_id pulse-id}]]
(#'send-pulses/clear-pulse-channels!)
(is (= 0
(t2/count PulseChannel)))
(is (:archived (t2/select-one Pulse :id pulse-id)))))
(testing "Has PulseChannelRecipient"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id
:channel_type :email}]
PulseChannelRecipient [_ {:user_id (mt/user->id :rasta)
:pulse_channel_id pc-id}]]
(#'send-pulses/clear-pulse-channels!)
(is (= 1
(t2/count PulseChannel)))))
(testing "Has email"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [_ {:pulse_id pulse-id
:channel_type :email
:details {:emails ["test@metabase.com"]}}]]
(#'send-pulses/clear-pulse-channels!)
(is (= 1
(t2/count PulseChannel)))))
(testing "Has channel"
(mt/with-temp* [Pulse [{pulse-id :id} {}]
PulseChannel [_ {:pulse_id pulse-id
:channel_type :slack
:details {:channel ["#test"]}}]]
(#'send-pulses/clear-pulse-channels!)
(is (= 1
(t2/count PulseChannel))))))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment