diff --git a/data/tito/registrations.edn b/data/tito/registrations.edn index ffd628e..56cc5b8 100644 --- a/data/tito/registrations.edn +++ b/data/tito/registrations.edn @@ -1,40 +1,50 @@ -({:email "elisabeth.rau@rauandsons.net", - :id 3694535, - :name "Elisabeth Rau", - :reference "UAA3"} - {:email "ellsworth81@doyle-doyle.info", - :id 5870786, - :name "Ellsworth Doyle", - :reference "AZCE"} - {:email "willie97@schowalterschow.name", - :id 7495219, - :name "Willie Schowalter", - :reference "U1L3"} - {:email "augustus@rogahnrogahnand.net", - :id 928267, - :name "Augustus Rogahn", - :reference "7N7G"} - {:email "preston.rice@ricericeandrice.biz", - :id 1295322, - :name "Preston Rice", - :reference "722H"} - {:email "myrtis85@gorczanyinc.com", - :id 7430047, - :name "Myrtis Gorczany", - :reference "ZDAO"} - {:email "glynda82@nikolausnikolau.com", - :id 8078654, - :name "Glynda Nikolaus", - :reference "TFYW"} - {:email "aurelia@damoredamoreand.com", - :id 9037751, - :name "Aurelia D'Amore", - :reference "W2WB"} - {:email "janay@predovicpredovi.io", - :id 2405625, - :name "Janay Predovic", - :reference "B01E"} - {:email "juana@powlowskipowlow.co", - :id 8833862, - :name "Juana Powlowski", - :reference "78W6"}) +({:email "damon82@purdypurdyandpu.biz", + :id 3943464, + :name "Amb. Damon Purdy", + :reference "QUTU", + :state "complete"} + {:email "calvin@howellhowelland.name", + :id 7425449, + :name "Pres. Calvin Howell", + :reference "8A3F", + :state "complete"} + {:email "brendonrogahn@rogahn-rogahn.org", + :id 8328992, + :name "Brendon Rogahn", + :reference "M0Q4", + :state "complete"} + {:email "reanna68@kingkingandking.info", + :id 4421735, + :name "Reanna King", + :reference "7TL1", + :state "complete"} + {:email "thomashodkiewicz@hodkiewiczandso.biz", + :id 2195685, + :name "Thomas Hodkiewicz", + :reference "22G5", + :state "complete"} + {:email "adelina@kreigerllc.info", + :id 662414, + :name "Rep. Adelina Kreiger", + :reference "9891", + :state "complete"} + {:email "nichelle5@langworthinc.biz", + :id 7191893, + :name "Ms. Nichelle Langworth", + :reference "T7Y3", + :state "complete"} + {:email "guillermina.sawayn@sawaynsawaynand.co", + :id 6741258, + :name "Msgr. Guillermina Sawayn", + :reference "41L3", + :state "complete"} + {:email "zachariah@mrazmrazandmraz.co", + :id 5894895, + :name "Zachariah Mraz", + :reference "P3C3", + :state "complete"} + {:email "mitchell@donnellydonnell.info", + :id 1123123, + :name "Mitchell Donnelly CPA", + :reference "M80Q", + :state "complete"}) diff --git a/data/tito/tickets.edn b/data/tito/tickets.edn index 10826cc..ef9031e 100644 --- a/data/tito/tickets.edn +++ b/data/tito/tickets.edn @@ -1,91 +1,98 @@ -({:email "thuy.green@greengroup.net", - :id 9350106, - :name "Thuy Green", - :reference "UAA3-1", - :registration-id 3694535, - :release-id 1494672, - :state "reminder"} - {:email "santiago.mraz@mrazandsons.name", - :id 1409765, - :name "Santiago Mraz", - :reference "AZCE-1", - :registration-id 5870786, - :release-id 1492465, +({:email "yetta@gusikowski-gusi.info", + :id 1511427, + :name "Yetta Gusikowski", + :reference "QUTU-1", + :registration-id 3943464, + :release-id 1499881, :state "reminder"} - {:email "tomiquigley@quigley-quigley.com", - :id 2292401, - :name "Tomi Quigley", - :reference "U1L3-1", - :registration-id 7495219, - :release-id 1492844, - :state "incomplete"} - {:email "werner@collierinc.net", - :id 7892017, - :name "Werner Collier", - :reference "7N7G-1", - :registration-id 928267, + {:email "ferdinand@emmerichemmeric.biz", + :id 4091138, + :name "Ferdinand Emmerich", + :reference "QUTU-2", + :registration-id 3943464, + :release-id 1509582, + :state "complete"} + {:email "rodrick76@okunevaokunevaa.biz", + :id 3046436, + :name "Rodrick Okuneva", + :reference "QUTU-3", + :registration-id 3943464, :release-id 1509582, :state "new"} - {:email "bella@klingklingandkl.com", - :id 5004792, - :name "Prof. Bella Kling", - :reference "722H-1", - :registration-id 1295322, - :release-id 1492846, - :state "reminder"} - {:email "osvaldowilliamson@williamson-will.io", - :id 2094205, - :name "Osvaldo Williamson", - :reference "ZDAO-1", - :registration-id 7430047, - :release-id 1494678, + {:email "lenhickle@hickle-hickle.com", + :id 3774432, + :name "Len Hickle", + :reference "8A3F-1", + :registration-id 7425449, + :release-id 1492845, + :state "new"} + {:email "josefine@herzogherzogand.name", + :id 3923717, + :name "Josefine Herzog I", + :reference "M0Q4-1", + :registration-id 8328992, + :release-id 1503936, + :state "complete"} + {:email "vannessalittle@little-little.net", + :id 6905618, + :name "Vannessa Little", + :reference "7TL1-1", + :registration-id 4421735, + :release-id 1503936, :state "incomplete"} - {:email "adalberto@huelsllc.org", - :id 9301116, - :name "The Hon. Adalberto Huels", - :reference "TFYW-1", - :registration-id 8078654, - :release-id 1507003, + {:email "guadalupe80@fritschfritscha.org", + :id 8806928, + :name "Guadalupe Fritsch", + :reference "22G5-1", + :registration-id 2195685, + :release-id 1494678, :state "incomplete"} - {:email "alton35@walter-walter.co", - :id 4821390, - :name "Alton Walter", - :reference "TFYW-2", - :registration-id 8078654, - :release-id 1494672, + {:email "muriel@murraygroup.biz", + :id 8009514, + :name "Muriel Murray", + :reference "22G5-2", + :registration-id 2195685, + :release-id 1503936, :state "incomplete"} - {:email "marquis26@keeblergroup.info", - :id 5249373, - :name "Marquis Keebler", - :reference "TFYW-3", - :registration-id 8078654, + {:email "sharonlind@lind-lind.biz", + :id 6813580, + :name "Sharon Lind I", + :reference "9891-1", + :registration-id 662414, :release-id 1494678, + :state "complete"} + {:email "jasper@osinskiosinskia.net", + :id 4875657, + :name "Jasper Osinski", + :reference "T7Y3-1", + :registration-id 7191893, + :release-id 1494672, + :state "new"} + {:email "keshia.kerluke@kerlukellc.net", + :id 7202708, + :name "Keshia Kerluke DDS", + :reference "41L3-1", + :registration-id 6741258, + :release-id 1492844, :state "reminder"} - {:email "elmawalter@waltergroup.com", - :id 7506318, - :name "Elma Walter", - :reference "W2WB-1", - :registration-id 9037751, - :release-id 1492846, - :state "reminder"} - {:email "tanja83@weberllc.org", - :id 1736733, - :name "Tanja Weber", - :reference "B01E-1", - :registration-id 2405625, - :release-id 1509582, + {:email "maxwell@hintzinc.info", + :id 4196913, + :name "Maxwell Hintz", + :reference "41L3-2", + :registration-id 6741258, + :release-id 1494672, :state "incomplete"} - {:email "trentfeest@feest-feest.net", - :id 4358782, - :name "Trent Feest DO", - :reference "B01E-2", - :registration-id 2405625, + {:email "bok@kunze-kunze.net", + :id 7193569, + :name "Bok Kunze IV", + :reference "P3C3-1", + :registration-id 5894895, :release-id 1494678, :state "incomplete"} - {:email "teddy@dibbertandsons.org", - :id 3883988, - :name "Prof. Teddy Dibbert", - :reference "78W6-1", - :registration-id 8833862, - :release-id 1503936, - :state "complete"}) + {:email "william@mclaughlininc.net", + :id 8690234, + :name "William McLaughlin III", + :reference "M80Q-1", + :registration-id 1123123, + :release-id 1507003, + :state "reminder"}) diff --git a/resources/public/css/styles.css b/resources/public/css/styles.css index b01a098..41981d5 100644 --- a/resources/public/css/styles.css +++ b/resources/public/css/styles.css @@ -516,4 +516,4 @@ body { display: grid; grid-template-columns: 10rem 1fr; gap: var(--size-2); -} \ No newline at end of file +} diff --git a/src/co/gaiwan/compass/http/oauth.clj b/src/co/gaiwan/compass/http/oauth.clj index bb8133b..f55fa57 100644 --- a/src/co/gaiwan/compass/http/oauth.clj +++ b/src/co/gaiwan/compass/http/oauth.clj @@ -1,14 +1,21 @@ (ns co.gaiwan.compass.http.oauth + "Namespace for generic OAuth2 handling (authorization code flow). + + For now, this contains Discord-specific scopes and URLs, like the routes/oauth namespace as well. + This can eventually be changed." (:require [clojure.string :as str] [co.gaiwan.compass.config :as config] [co.gaiwan.compass.db :as db] - [lambdaisland.uri :as uri])) + [co.gaiwan.compass.util :as util] + [hato.client :as hato] + [lambdaisland.uri :as uri] + [io.pedestal.log :as log]) + (:import (java.time Instant))) (def discord-oauth-endpoint "https://discord.com/oauth2/authorize") -(def discord-api-endpoint "https://discord.com/api/v10") -(def default-scopes ["email" "identify"]) +(def default-scopes ["email" "identify" "guilds.join"]) (defn flow-init-url ([] @@ -27,3 +34,54 @@ :scope (str/join " " scopes) :state state}))))) +;; Add as bot to server +#_ +(println (str (flow-init-url ["bot" "applications.commands"]))) + +(defn request-token [params] + (hato/post + "https://discord.com/api/oauth2/token" + {:as :auto + :form-params params + :basic-auth + {:user (config/value :discord/client-id) + :pass (config/value :discord/client-secret)}})) + +(defn exchange-code [code] + (request-token + {:grant_type "authorization_code" + :code code + :redirect_uri (str (config/value :compass/origin) "/oauth2/discord/callback")})) + +(defn refresh-token [refresh-token] + (request-token + {:grant_type "refresh_token" + :refresh_token refresh-token})) + +(defn current-access-token + "Get a valid Discord bearer access token for a user. + + This assumes there already is an access token in the database. + The function performs a token refresh if the old access token is considered to be expired. + If refreshing the token fails, returns `nil`. + There are no guarantees regarding the lifetime of the returned access token, so this function + should be called for every request to Discord." + [user-id] + (let [oauth-data + (db/q '[:find (pull ?u [:discord/access-token + :discord/refresh-token + :discord/expires-at]) + :in $ ?uid + :where [?u :user/uuid ?uid]] + (db/db) + user-id)] + ;; check if token is expired, do a refresh in that case + (if (.isAfter (Instant/now) (:discord/expires-at oauth-data)) + (let [{:keys [body status]} (refresh-token (:discord/refresh-token oauth-data))] + (when (= status 200) + (db/transact [{:user/uuid user-id + :discord/access-token (:access_token body) + :discord/refresh-token (:refresh_token body) + :discord/expires-at (util/expires-in->instant (:discord/expires_in body))}]) + (:access_token body))) + (:discord/access-token oauth-data)))) diff --git a/src/co/gaiwan/compass/routes/oauth.clj b/src/co/gaiwan/compass/routes/oauth.clj index 5f9c1f2..f4ec096 100644 --- a/src/co/gaiwan/compass/routes/oauth.clj +++ b/src/co/gaiwan/compass/routes/oauth.clj @@ -1,39 +1,16 @@ (ns co.gaiwan.compass.routes.oauth (:require - [clojure.string :as str] - [co.gaiwan.compass.config :as config] [co.gaiwan.compass.db :as db] [co.gaiwan.compass.html.auth :as auth-html] [co.gaiwan.compass.http.oauth :as oauth] [co.gaiwan.compass.util :as util] + [co.gaiwan.compass.services.discord :as discord] [datomic.api :as d] - [hato.client :as hato] - [lambdaisland.uri :as uri] [ring.util.response :as response])) -(defn exchange-code [code] - (hato/post - (str oauth/discord-api-endpoint "/oauth2/token") - {:as :auto - :form-params - {:grant_type "authorization_code" - :code code - :redirect_uri (str (config/value :compass/origin) "/oauth2/discord/callback")} - :basic-auth - {:user (config/value :discord/client-id) - :pass (config/value :discord/client-secret)}})) - -(defn fetch-user-info [token] - (:body - (hato/get (str oauth/discord-api-endpoint "/users/@me") - {:as :auto - :headers {"Authorization" (str "Bearer " token)}}))) - (defn GET-callback [{:keys [query-params]}] (let [code (get query-params "code") - state (get query-params "state") - {:keys [status body]} (exchange-code code) - redirect-url (:oauth/redirect-url (db/entity [:oauth/state-id (parse-uuid state)]))] + {:keys [status body]} (oauth/exchange-code code)] (if (not= 200 status) {:status 302 :headers {"Location" "/"} @@ -42,25 +19,24 @@ [:pre (util/pprint-str body)]] :session {:identity nil}} (let [{:keys [access_token refresh_token expires_in]} body - {:keys [id global_name email username] :as user-info} (fetch-user-info access_token) - avatar-url (str "https://cdn.discordapp.com/avatars/" id "/" (:avatar user-info) ".png") - ;;_ (prn :avatar-url avatar-url) + {:keys [id global_name email username]} (discord/fetch-user-info access_token) user-uuid (:user/uuid (d/entity (db/db) [:user/email email]) (random-uuid)) tx-data [{:user/uuid user-uuid :user/email email :user/name global_name :user/handle username - :discord/avatar-url avatar-url :discord/id id :discord/access-token access_token :discord/refresh-token refresh_token - :discord/expires-at (.plusSeconds (java.time.Instant/now) (- expires_in 60))}]] - (def tx-data tx-data) + :discord/expires-at (util/expires-in->instant expires_in)}] + {:keys [status]} (discord/join-server access_token)] @(db/transact tx-data) {:status 302 - :headers {"Location" (or redirect-url "/")} - #_#_:flash [:p "You are signed in!"] + :headers {"Location" "/"} + :flash [:p "You are signed in!" + (when-not (= 2 (quot status 100)) + [:br "Unfortunately, adding you to our Discord server didn't work."])] :session {:identity user-uuid}})))) (defn GET-login [req] @@ -72,8 +48,7 @@ ["/oauth2" ["/discord" ["/callback" - {:get {:handler GET-callback}}]]] - ["/login" {:get {:handler GET-login}}] + {:get {:handler #'GET-callback}}]]] ["/logout" {:get {:handler (fn [req] (assoc diff --git a/src/co/gaiwan/compass/services/discord.clj b/src/co/gaiwan/compass/services/discord.clj new file mode 100644 index 0000000..d092391 --- /dev/null +++ b/src/co/gaiwan/compass/services/discord.clj @@ -0,0 +1,100 @@ +(ns co.gaiwan.compass.services.discord + (:require + [co.gaiwan.compass.config :as config] + [co.gaiwan.compass.services.tito :as tito] + [hato.client :as hato] + [io.pedestal.log :as log] + [clojure.string :as str] + [clojure.set :refer [map-invert]])) + +(def discord-api-endpoint "https://discord.com/api/v10") + +(defn bot-auth-headers [] + {"Authorization" (str "Bot " (config/value :discord/bot-token))}) + +(defn discord-bot-request + ([method endpoint] + (discord-bot-request method endpoint nil)) + ([method endpoint body] + (hato/request + (cond-> {:method method + :url (str discord-api-endpoint endpoint) + :as :auto + :headers (bot-auth-headers)} + body (assoc :content-type :json :form-params body))))) + +(defn fetch-user-info [token] + (:body + (hato/get (str discord-api-endpoint "/users/@me") + {:as :auto + :oauth-token token}))) + +(defn join-server [token] + (let [{:keys [id username]} (fetch-user-info token) + response + (discord-bot-request + :put + (str "/guilds/" (config/value :discord/server-id) "/members/" id) + {:access_token token})] + (log/trace :discord/user-add username :discord/add-guild-member-response response) + response)) + +(defn get-application + [] + (:body (discord-bot-request :get "/applications/@me"))) + +;; NOTE: we originally wanted to use the linked roles Discord feature, but the UX of that turned out to be crap. +;; So now we don't use it anymore and assign configured roles directly +(comment + (def role-connection-metadata + "Metadata field for roles linked to ticket type/tier. Type/tier is represented by an integer." + [{:type 7 ;; BOOLEAN_EQUAL + :key "tito_ticket_holder" + :name "Ticket holder?" + :description "User must be a ticket holder"} + {:type 3 ;; INTEGER_EQUAL + :key "tito_release_slug_idx" + :name "Tito release type" + :description + (->> (config/value :discord/ticket-roles) + (map-indexed (fn [i v] (str i " = " v))) + (str/join ", ") + (str "User must have a specific type of ticket: "))}]) + + (defn update-role-connection + "Update a user's Discord role connection according to tito ticket information. + + `token` is an OAuth2 token with role_connections.write scope. + `ticket` is a ticket map containing the associated release or `nil`, if the user should not be considered a ticket holder (anymore)." + [token {{:keys [tito.release/slug]} :tito.ticket/release :as ticket}] + (hato/put + (str discord-api-endpoint "/users/@me/applications/" (:id (get-application)) "/role-connection") + {:as :auto + :oauth-token token + :content-type :json + :form-params + {:platform_name "Compass" + :metadata + ;; Find if release slug is among the "special" role slugs declared in the config + (let [slug-idx (->> (config/value :discord/ticket-roles) + (map-indexed vector) + (some (fn [[i v]] (when (= v slug) i))))] + ;; Ticket holder property is "true" if user _has_ ticket + (cond-> {"tito_ticket_holder" (if (some? ticket) 1 0)} + ;; slug idx is set if user has ticket with special release type + slug-idx (assoc "tito_release_slug_idx" slug-idx)))}}))) + +(defn assign-ticket-role + [user-id {{:keys [tito.release/slug]} :tito.ticket/release :as _ticket}] + (let [role-endpoint (str "/guilds/" (config/value :discord/server-id) "/members/" user-id "/roles/")] + (discord-bot-request :put (str role-endpoint (config/value :discord/ticket-holder-role))) + (when-let [special-role (get (config/value :discord/ticket-roles) slug)] + (discord-bot-request :put (str role-endpoint special-role))))) + +(comment + ;; Register role connection metadata + (discord-bot-request :put (str "/applications/" (:id (get-application)) "/role-connections/metadata") role-connection-metadata) + ;; get user role connection + (hato/get (str discord-api-endpoint "/users/@me/applications/" (:id (get-application)) "/role-connection") + {:oauth-token "" + :as :auto})) diff --git a/src/co/gaiwan/compass/services/tito.clj b/src/co/gaiwan/compass/services/tito.clj index 39e4598..bf60c34 100644 --- a/src/co/gaiwan/compass/services/tito.clj +++ b/src/co/gaiwan/compass/services/tito.clj @@ -105,10 +105,27 @@ :tito.release/slug slug})) (defn sync! [] - (db/transact - (concat (releases-tx) - (registrations-tx))) - (db/transact (tickets-tx))) + @(db/transact + (concat (releases-tx) + (registrations-tx))) + @(db/transact (tickets-tx))) + +(defn find-assigned-ticket + "Look up a ticket from a registration reference and an email address. + + Returns nil if not found or a ticket map (including release information) if found." + [reference email] + (db/q + '[:find + (pull ?ticket [* {:tito.ticket/release [*]}]) . + :in $ ?ref ?email + :where + [?reg :tito.registration/reference ?ref] + (or [?reg :tito.registration/state "complete"] + [?reg :tito.registration/state "incomplete"]) + [?ticket :tito.ticket/registration ?reg] + [?ticket :tito.ticket/email ?email]] + (db/db) reference email)) (comment (sync!) @@ -128,7 +145,8 @@ (faker/fake {:id (partial rand-int 9999999) :reference #"[A-Z0-9]{4}" :email [:internet :email] - :name [:name :name]})) + :name [:name :name] + :state "complete"})) tickets (for [{:keys [reference id]} registrations i (map inc (range (rand-nth [1 1 1 2 3])))] diff --git a/src/co/gaiwan/compass/util.clj b/src/co/gaiwan/compass/util.clj index 1414414..b145884 100644 --- a/src/co/gaiwan/compass/util.clj +++ b/src/co/gaiwan/compass/util.clj @@ -96,3 +96,7 @@ (vector? res) (mapv deep-underscorize-keys res) (map? res) (update-vals (underscorize-keys res) deep-underscorize-keys) :else res)) + +(defn expires-in->instant + [expires-in] + (.plusSeconds (Instant/now) (- expires-in 60)))