<commit_before>(ns pc.http.sente
  (:require [clj-statsd :as statsd]
            [clj-time.core :as time]
            [clojure.core.async :as async]
            [clojure.set :as set]
            [clojure.string :as str]
            [clojure.tools.logging :as log]
            [datomic.api :refer [db q] :as d]
            [pc.auth :as auth]
            [pc.datomic :as pcd]
            [pc.datomic.schema :as schema]
            [pc.datomic.web-peer :as web-peer]
            [pc.email :as email]
            [pc.http.datomic2 :as datomic2]
            [pc.http.datomic.common :as datomic-common]
            [pc.models.access-grant :as access-grant-model]
            [pc.models.access-request :as access-request-model]
            [pc.models.chat :as chat]
            [pc.models.cust :as cust]
            [pc.models.doc :as doc-model]
            [pc.models.layer :as layer]
            [pc.models.permission :as permission-model]
            [pc.models.team :as team-model]
            [pc.replay :as replay]
            [pc.rollbar :as rollbar]
            [pc.utils :as utils]
            [slingshot.slingshot :refer (try+ throw+)]
            [taoensso.sente :as sente]
            [taoensso.sente.server-adapters.immutant :refer (sente-web-server-adapter)])
  (:import java.util.UUID))

;; TODO: find a way to restart sente
(defonce sente-state (atom {}))

(defn uuid
  []
  (UUID/randomUUID))

(defn user-id-fn [req]
  ;; {:pre [(seq (get-in req [:session :sente-id]))
  ;;        (seq (get-in req [:params :tab-id]))]}
  (when (empty? (get-in req [:session :sente-id]))
    (let [msg (format "sente-id is nil for %s on %s" (:remote-addr req) (:uri req))]
      (rollbar/report-exception (Exception. msg) :request (:ring-req req) :cust (some-> req :ring-req :auth :cust))
      (log/errorf msg)))

  (when (empty? (get-in req [:params :tab-id]))
    (let [msg (format "tab-id is nil for %s on %s" (:remote-addr req) (:uri req))]
      (rollbar/report-exception (Exception. msg) :request (:ring-req req) :cust (some-> req :ring-req :auth :cust))
      (log/errorf msg)))

  (str (get-in req [:session :sente-id])
       "-"
       (get-in req [:params :tab-id])))

;; hash-map of document-id to connected users
;; Used to keep track of which transactions to send to which user
;; sente's channel handling stuff is not much fun to work with :(
;; e.g {:12345 {:uuid-1 {show-mouse?: true} :uuid-1 {:show-mouse? false}}}
(defonce document-subs (atom {}))
(defonce team-subs (atom {}))

(defonce client-stats (atom {}))

(defn notify-document-transaction [db {:keys [read-only-data admin-data]}]
  (let [doc (:transaction/document read-only-data)
        doc-id (:db/id doc)]
    (doseq [[uid {:keys [auth]}] (dissoc (get @document-subs doc-id) (:session/client-id read-only-data))
            :let [max-scope (auth/max-document-scope db doc auth)]]
      (log/infof "notifying %s about new transactions for %s" uid doc-id)
      ((:send-fn @sente-state) uid [:datomic/transaction (cond (auth/contains-scope? auth/scope-heirarchy max-scope :admin)
                                                               admin-data
                                                               (auth/contains-scope? auth/scope-heirarchy max-scope :read)
                                                               read-only-data)]))
    (when-let [server-timestamps (seq (filter #(= :server/timestamp (:a %)) (:tx-data admin-data)))]
      (log/infof "notifying %s about new server timestamp for %s" (:session/uuid admin-data) doc-id)
      ;; they made the tx, so we can send them the admin data
      ((:send-fn @sente-state) (str (:session/client-id admin-data)) [:datomic/transaction (assoc admin-data :tx-data server-timestamps)]))))

;; Note: this assumes that everyone subscribe to the team is an admin
(defn notify-team-transaction [db {:keys [read-only-data admin-data]}]
  (let [team-uuid (:team/uuid (:transaction/team admin-data))]
    (doseq [[uid _] (dissoc (get @team-subs team-uuid) (:session/client-id admin-data))]
      (log/infof "notifying %s about new team transactions for %s" uid team-uuid)
      ((:send-fn @sente-state) uid [:team/transaction admin-data]))
    (when-let [server-timestamps (seq (filter #(= :server/timestamp (:a %)) (:tx-data admin-data)))]
      (log/infof "notifying %s about new team server timestamp for %s" (:session/uuid admin-data) team-uuid)
      ((:send-fn @sente-state) (str (:session/client-id admin-data)) [:team/transaction (assoc admin-data :tx-data server-timestamps)]))))

(defn ws-handler-dispatch-fn [req]
  (-> req :event first))

(defn has-document-access? [doc-id req scope]
  (let [doc (doc-model/find-by-id (:db req) doc-id)]
    (auth/has-document-permission? (:db req) doc (-> req :ring-req :auth) scope)))

(defn check-document-access-from-auth [doc-id req scope]
  (if (has-document-access? doc-id req scope)
    true
    (if (auth/logged-in? (:ring-req req))
      (throw+ {:status 403
               :error-msg "This document is private. Please request access."
               :error-key :document-requires-invite})
      (throw+ {:status 401
               :error-msg "This document is private. Please log in to access it."
               :error-key :document-requires-login}))))

;; TODO: make sure to kick the user out of subscribed if he loses access
(defn check-subscribed [doc-id req scope]
  ;; TODO: we're making a simplifying assumption that subscribed == :admin
  ;;       That needs to be fixed at some point
  (when (= scope :admin)
    (get-in @document-subs [doc-id (-> req :client-id)])))

(defn check-document-access [doc-id req scope]
  {:pre [doc-id]}
  (check-document-access-from-auth doc-id req scope))

(defn check-team-subscribed [team-uuid req scope]
  (when (= scope :admin)
    (get-in @team-subs [team-uuid (-> req :client-id)])))

(defn check-team-access-from-auth [team-uuid req scope]
  (let [team (team-model/find-by-uuid (:db req) team-uuid)]
    (when-not (auth/has-team-permission? (:db req) team (-> req :ring-req :auth) scope)
      (if (auth/logged-in? (:ring-req req))
        (throw+ {:status 403
                 :error-msg "This team is private. Please request access."
                 :error-key :team-requires-invite})
        (throw+ {:status 401
                 :error-msg "This team is private. Please log in to access it."
                 :error-key :team-requires-login})))))

(defn check-team-access [team-uuid req scope]
  {:pre [team-uuid]}
  (or (check-team-subscribed team-uuid req scope)
      (check-team-access-from-auth team-uuid req scope)))

(defn choose-frontend-id-seed [db document-id subs requested-remainder]
  (let [available-remainders (apply disj web-peer/remainders (map (comp :remainder :frontend-id-seed)
                                                                  (vals subs)))]
    (if-let [remainder (if (contains? available-remainders requested-remainder)
                         requested-remainder
                         (first available-remainders))]
      (let [used-client-parts (web-peer/client-parts-for-ns db document-id)
            used-from-partition (set (filter #(= remainder (mod % web-peer/multiple)) used-client-parts))
            start-eid (first (remove #(contains? used-from-partition %)
                                     (iterate (partial + web-peer/multiple) (+ web-peer/multiple remainder))))]
        {:remainder remainder :multiple web-peer/multiple :next-id start-eid})
      (throw+ {:status 403
               :error-msg "There are too many users in the document."
               :error-key :too-many-subscribers}))))

(defn subscribe-to-doc [db document-id uuid cust & {:keys [requested-color requested-remainder]}]
  (swap! client-stats update-in [uuid] merge {:document {:db/id document-id}})
  (swap! document-subs update-in [document-id]
         (fn [subs]
           (-> subs
             (assoc-in [uuid :client-id] uuid)
             (assoc-in [uuid :auth] {:cust cust})
             (update-in [uuid] merge (select-keys cust [:cust/uuid :cust/color-name :cust/name]))
             (assoc-in [uuid :show-mouse?] true)
             (assoc-in [uuid :frontend-id-seed] (choose-frontend-id-seed db document-id subs requested-remainder))))))

(defn clean-document-subs [client-id]
  (swap! document-subs (fn [ds]
                         ;; Could be optimized...
                         (reduce (fn [acc [document-id client-ids]]
                                   (if-not (contains? client-ids client-id)
                                     acc
                                     (let [new-client-ids (dissoc client-ids client-id)]
                                       (if (empty? new-client-ids)
                                         (dissoc acc document-id)
                                         (assoc acc document-id new-client-ids)))))
                                 ds ds))))

(defn clean-team-subs [client-id]
  (swap! team-subs (fn [ds]
                     ;; Could be optimized...
                     (reduce (fn [acc [team-uuid client-ids]]
                               (if-not (contains? client-ids client-id)
                                 acc
                                 (let [new-client-ids (dissoc client-ids client-id)]
                                   (if (empty? new-client-ids)
                                     (dissoc acc team-uuid)
                                     (assoc acc team-uuid new-client-ids)))))
                             ds ds))))

(defn close-connection [client-id]
  (log/infof "closing connection for %s" client-id)
  (doseq [uid (reduce (fn [acc [doc-id clients]]
                        (if (contains? clients client-id)
                          (set/union acc (keys clients))
                          acc))
                      #{} @document-subs)]
    (log/infof "notifying %s about %s leaving" uid client-id)
    ((:send-fn @sente-state) uid [:frontend/subscriber-left {:client-id client-id}]))
  (clean-team-subs client-id)
  (clean-document-subs client-id)
  (swap! client-stats dissoc client-id))

(defn subscribe-to-team [team-uuid uuid cust]
  (swap! client-stats update-in [uuid] merge {:team {:db/id team-uuid}})
  (swap! team-subs update-in [team-uuid]
         (fn [subs]
           (-> subs
             (assoc-in [uuid] {:client-id uuid
                               :cust/uuid (:cust/uuid cust)})))))

(defmulti ws-handler ws-handler-dispatch-fn)

(defmethod ws-handler :default [req]
  (log/infof "%s for %s" (:event req) (:client-id req)))

(defmethod ws-handler :chsk/uidport-close [{:keys [client-id] :as req}]
  (close-connection client-id))

(defmethod ws-handler :frontend/close-connection [{:keys [client-id] :as req}]
  (close-connection client-id))

(defmethod ws-handler :frontend/unsubscribe [{:keys [client-id ?data ?reply-fn] :as req}]
  (check-document-access (-> ?data :document-id) req :read)
  (let [document-id (-> ?data :document-id)]
    (log/infof "unsubscribing %s from %s" client-id document-id)
    (close-connection client-id)
    (doseq [[uid _] (get @document-subs document-id)]
      ((:send-fn @sente-state) uid [:frontend/subscriber-left {:client-id client-id}]))))

(defmethod ws-handler :team/subscribe [{:keys [client-id ?data ?reply-fn] :as req}]
  (let [team-uuid (-> ?data :team/uuid)
        team (team-model/find-by-uuid (:db req) team-uuid)
        send-fn (:send-fn @sente-state)]
    (check-team-access team-uuid req :admin)
    (subscribe-to-team team-uuid client-id (get-in req [:ring-req :auth :cust]))

    (log/infof "sending permission-data for team %s to %s" (:team/subdomain team) client-id)
    (send-fn client-id [:team/db-entities
                        {:team/uuid team-uuid
                         :entities (map (partial permission-model/read-api (:db req))
                                        (filter :permission/cust-ref
                                                (permission-model/find-by-team (:db req)
                                                                               team)))
                         :entity-type :permission}])
    (send-fn client-id [:team/db-entities
                        {:team/uuid team-uuid
                         :entities (map access-grant-model/read-api
                                        (access-grant-model/find-by-team (:db req)
                                                                         team))
                         :entity-type :access-grant}])

    (send-fn client-id [:team/db-entities
                        {:team/uuid team-uuid
                         :entities (map (partial access-request-model/read-api (:db req))
                                        (access-request-model/find-by-team (:db req)
                                                                           team))
                         :entity-type :access-request}])))

;; TODO: subscribe should be the only function you need when you get to a doc, then it should send
;;       all of the data asynchronously
(defmethod ws-handler :frontend/subscribe [{:keys [client-id ?data ?reply-fn] :as req}]
  (try+
   (check-document-access (-> ?data :document-id) req :read)
   (catch :status t
     (?reply-fn [:subscribe/error])
     (throw+)))
  (let [document-id (-> ?data :document-id)
        send-fn (:send-fn @sente-state)
        _ (log/infof "subscribing %s to %s" client-id document-id)
        subs (subscribe-to-doc (:db req)
                               document-id
                               client-id
                               (-> req :ring-req :auth :cust)
                               :requested-color (:requested-color ?data)
                               :requested-remainder (:requested-remainder ?data))
        doc (doc-model/find-by-id (:db req) document-id)]

    (?reply-fn [:frontend/frontend-id-state {:frontend-id-state (get-in subs [document-id client-id :frontend-id-seed])
                                             :max-document-scope (auth/max-document-scope (:db req) doc (get-in req [:ring-req :auth]))}])

    (doseq [[uid _] (get @document-subs document-id)]
      (send-fn uid [:frontend/subscriber-joined (merge {:client-id client-id}
                                                       (get-in subs [document-id client-id]))]))

    ;; TODO: we'll need a read-api or something here at some point
    (log/infof "sending document for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/db-entities
                        {:document/id document-id
                         :entities [(doc-model/read-api (doc-model/find-by-id (:db req) document-id))]
                         :entity-type :document}])

    (log/infof "sending layers for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/db-entities
                        {:document/id document-id
                         :entities (map layer/read-api (layer/find-by-document (:db req) {:db/id document-id}))
                         :entity-type :layer}])

    (log/infof "sending custs for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/custs
                        {:document/id document-id
                         :uuid->cust (->> document-id
                                       (cust/cust-uuids-for-doc (:db req))
                                       (cons (auth/prcrsr-bot-uuid (:db req)))
                                       set
                                       (set/union (disj (set (map :cust/uuid (vals (get subs document-id))))
                                                        nil))
                                       (cust/public-read-api-per-uuids (:db req)))}])

    (log/infof "sending chats %s to %s" document-id client-id)
    (send-fn client-id [:frontend/db-entities
                        {:document/id document-id
                         :entities (map chat/read-api (chat/find-by-document (:db req) {:db/id document-id}))
                         :entity-type :chat}])
    (log/infof "sending subscribers for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/subscribers
                        {:document/id document-id
                         :subscribers (get subs document-id)}])

    ;; These are interesting b/c they're read-only. And by "interesting", I mean "bad"
    ;; We should find a way to let the frontend edit things
    ;; TODO: only send this stuff when it's needed
    (when (has-document-access? document-id req :admin)
      (log/infof "sending permission-data for %s to %s" document-id client-id)
      (send-fn client-id [:frontend/db-entities
                          {:document/id document-id
                           :entities (map (partial permission-model/read-api (:db req))
                                          (filter :permission/cust-ref
                                                  (permission-model/find-by-document (:db req)
                                                                                     {:db/id document-id})))
                           :entity-type :permission}])

      (send-fn client-id [:frontend/db-entities
                          {:document/id document-id
                           :entities (map access-grant-model/read-api
                                          (access-grant-model/find-by-document (:db req)
                                                                               {:db/id document-id}))
                           :entity-type :access-grant}])

      (send-fn client-id [:frontend/db-entities
                          {:document/id document-id
                           :entities (map (partial access-request-model/read-api (:db req))
                                          (access-request-model/find-by-document (:db req)
                                                                                 {:db/id document-id}))
                           :entity-type :access-request}]))))

(defmethod ws-handler :frontend/fetch-touched [{:keys [client-id ?data ?reply-fn] :as req}]
  (when-let [cust (-> req :ring-req :auth :cust)]
    (let [;; TODO: at some point we may want to limit, but it's just a
          ;; list of longs, so meh
          ;; limit (get ?data :limit 100)
          ;; offset (get ?data :offset 0)
          doc-ids (if-let [team (get-in req [:ring-req :team])]
                    (doc-model/find-touched-by-cust-in-team (:db req) cust team)
                    (doc-model/find-touched-by-cust (:db req) cust))]
      (log/infof "fetched %s touched for %s" (count doc-ids) client-id)
      (?reply-fn {:docs (map (fn [doc-id] {:db/id doc-id
                                           :last-updated-instant (doc-model/last-updated-time (:db req) doc-id)})
                             doc-ids)}))))

(defmethod ws-handler :team/fetch-touched [{:keys [client-id ?data ?reply-fn] :as req}]
  (let [team-uuid (-> ?data :team/uuid)
        team (team-model/find-by-uuid (:db req) team-uuid)]
    (check-team-access team-uuid req :admin)
    (let [ ;; TODO: at some point we may want to limit, but it's just a
          ;; list of longs, so meh
          ;; limit (get ?data :limit 100)
          ;; offset (get ?data :offset 0)
          doc-ids (team-model/find-doc-ids (:db req) team)]
      (log/infof "fetched %s touched in %s for %s" (count doc-ids) (:team/subdomain team) client-id)
      (?reply-fn {:docs (map (fn [doc-id] {:db/id doc-id
                                           :last-updated-instant (doc-model/last-updated-time (:db req) doc-id)})
                             doc-ids)}))))

(defn determine-type [datom datoms]
  (let [e (:e datom)
        attr-nses (map (comp namespace :a) (filter #(= (:e %) (:e datom)) datoms))]
    (cond (first (filter #(= "layer" %) attr-nses))
          :layer/document

          (first (filter #(= "chat" %) attr-nses))
          :chat/document

          :else (throw+ {:error :invalid-type-fordocument-id
                         :datom datom
                         :datoms datoms}))))

(defmethod ws-handler :frontend/transaction [{:keys [client-id ?data ?reply-fn] :as req}]
  (let [document-id (-> ?data :document/id)
        access-scope (if (has-document-access? document-id req :admin)
                       :admin
                       ;; will throw, so we can't get any further
                       (when (check-document-access document-id req :read)
                         :read))
        _ (assert (keyword? access-scope)) ;; just in case
        datoms (->> ?data
                 :datoms
                 (remove (comp nil? :v))
                 ;; Don't let people sneak layers into other documents
                 (reduce (fn [acc d]
                           (if (= "document" (name (:a d)))
                             (conj acc
                                   (assoc d :v document-id))
                             (conj acc d)))
                         []))
        cust-uuid (-> req :ring-req :auth :cust :cust/uuid)
        ;; note that these aren't all of the rejected datoms, just the ones not on the whitelist
        rejects (remove (comp (partial datomic2/whitelisted? access-scope)
                              pcd/datom->transaction)
                        datoms)]
    (when ?reply-fn
      (?reply-fn {:rejected-datoms rejects}))
    (log/infof "transacting %s datoms (minus %s rejects) on %s for %s" (count datoms) (count rejects) document-id client-id)
    (datomic2/transact! datoms
                        {:document-id document-id
                         :access-scope access-scope
                         :client-id client-id
                         :cust-uuid cust-uuid
                         :frontend-id-seed (get-in @document-subs [document-id client-id :frontend-id-seed])
                         :session-uuid (UUID/fromString (get-in req [:ring-req :session :sente-id]))
                         :timestamp (:receive-instant req)})))

(defmethod ws-handler :team/transaction [{:keys [client-id ?data] :as req}]
  (check-team-access (-> ?data :team/uuid) req :admin)
  (let [team (team-model/find-by-uuid (:db req) (:team/uuid ?data))
        datoms (->> ?data
                 :datoms
                 (remove (comp nil? :v))
                 ;; Don't let people sneak into other teams
                 (map (fn [d]
                        (if (= "team" (name (:a d)))
                          (assoc d :v (:db/id team))
                          d))))
        cust-uuid (-> req :ring-req :auth :cust :cust/uuid)]
    (log/infof "transacting %s datoms on %s for %s" (count datoms) (:team/uuid team) client-id)
    (datomic2/transact! datoms
                        {:team-id (:db/id team)
                         :client-id client-id
                         :cust-uuid cust-uuid
                         :session-uuid (UUID/fromString (get-in req [:ring-req :session :sente-id]))
                         :timestamp (:receive-instant req)})))

(defmethod ws-handler :frontend/mouse-position [{:keys [client-id ?data] :as req}]
  (check-document-access (-> ?data :document/id) req :read)
  (let [document-id (-> ?data :document/id)
        mouse-position (-> ?data :mouse-position)
        tool (-> ?data :tool)<commit_msg>don't handle team transactions, we don't send any and it could cause a security bug<commit_after>(ns pc.http.sente
  (:require [clj-statsd :as statsd]
            [clj-time.core :as time]
            [clojure.core.async :as async]
            [clojure.set :as set]
            [clojure.string :as str]
            [clojure.tools.logging :as log]
            [datomic.api :refer [db q] :as d]
            [pc.auth :as auth]
            [pc.datomic :as pcd]
            [pc.datomic.schema :as schema]
            [pc.datomic.web-peer :as web-peer]
            [pc.email :as email]
            [pc.http.datomic2 :as datomic2]
            [pc.http.datomic.common :as datomic-common]
            [pc.models.access-grant :as access-grant-model]
            [pc.models.access-request :as access-request-model]
            [pc.models.chat :as chat]
            [pc.models.cust :as cust]
            [pc.models.doc :as doc-model]
            [pc.models.layer :as layer]
            [pc.models.permission :as permission-model]
            [pc.models.team :as team-model]
            [pc.replay :as replay]
            [pc.rollbar :as rollbar]
            [pc.utils :as utils]
            [slingshot.slingshot :refer (try+ throw+)]
            [taoensso.sente :as sente]
            [taoensso.sente.server-adapters.immutant :refer (sente-web-server-adapter)])
  (:import java.util.UUID))

;; TODO: find a way to restart sente
(defonce sente-state (atom {}))

(defn uuid
  []
  (UUID/randomUUID))

(defn user-id-fn [req]
  ;; {:pre [(seq (get-in req [:session :sente-id]))
  ;;        (seq (get-in req [:params :tab-id]))]}
  (when (empty? (get-in req [:session :sente-id]))
    (let [msg (format "sente-id is nil for %s on %s" (:remote-addr req) (:uri req))]
      (rollbar/report-exception (Exception. msg) :request (:ring-req req) :cust (some-> req :ring-req :auth :cust))
      (log/errorf msg)))

  (when (empty? (get-in req [:params :tab-id]))
    (let [msg (format "tab-id is nil for %s on %s" (:remote-addr req) (:uri req))]
      (rollbar/report-exception (Exception. msg) :request (:ring-req req) :cust (some-> req :ring-req :auth :cust))
      (log/errorf msg)))

  (str (get-in req [:session :sente-id])
       "-"
       (get-in req [:params :tab-id])))

;; hash-map of document-id to connected users
;; Used to keep track of which transactions to send to which user
;; sente's channel handling stuff is not much fun to work with :(
;; e.g {:12345 {:uuid-1 {show-mouse?: true} :uuid-1 {:show-mouse? false}}}
(defonce document-subs (atom {}))
(defonce team-subs (atom {}))

(defonce client-stats (atom {}))

(defn notify-document-transaction [db {:keys [read-only-data admin-data]}]
  (let [doc (:transaction/document read-only-data)
        doc-id (:db/id doc)]
    (doseq [[uid {:keys [auth]}] (dissoc (get @document-subs doc-id) (:session/client-id read-only-data))
            :let [max-scope (auth/max-document-scope db doc auth)]]
      (log/infof "notifying %s about new transactions for %s" uid doc-id)
      ((:send-fn @sente-state) uid [:datomic/transaction (cond (auth/contains-scope? auth/scope-heirarchy max-scope :admin)
                                                               admin-data
                                                               (auth/contains-scope? auth/scope-heirarchy max-scope :read)
                                                               read-only-data)]))
    (when-let [server-timestamps (seq (filter #(= :server/timestamp (:a %)) (:tx-data admin-data)))]
      (log/infof "notifying %s about new server timestamp for %s" (:session/uuid admin-data) doc-id)
      ;; they made the tx, so we can send them the admin data
      ((:send-fn @sente-state) (str (:session/client-id admin-data)) [:datomic/transaction (assoc admin-data :tx-data server-timestamps)]))))

;; Note: this assumes that everyone subscribe to the team is an admin
(defn notify-team-transaction [db {:keys [read-only-data admin-data]}]
  (let [team-uuid (:team/uuid (:transaction/team admin-data))]
    (doseq [[uid _] (dissoc (get @team-subs team-uuid) (:session/client-id admin-data))]
      (log/infof "notifying %s about new team transactions for %s" uid team-uuid)
      ((:send-fn @sente-state) uid [:team/transaction admin-data]))
    (when-let [server-timestamps (seq (filter #(= :server/timestamp (:a %)) (:tx-data admin-data)))]
      (log/infof "notifying %s about new team server timestamp for %s" (:session/uuid admin-data) team-uuid)
      ((:send-fn @sente-state) (str (:session/client-id admin-data)) [:team/transaction (assoc admin-data :tx-data server-timestamps)]))))

(defn ws-handler-dispatch-fn [req]
  (-> req :event first))

(defn has-document-access? [doc-id req scope]
  (let [doc (doc-model/find-by-id (:db req) doc-id)]
    (auth/has-document-permission? (:db req) doc (-> req :ring-req :auth) scope)))

(defn check-document-access-from-auth [doc-id req scope]
  (if (has-document-access? doc-id req scope)
    true
    (if (auth/logged-in? (:ring-req req))
      (throw+ {:status 403
               :error-msg "This document is private. Please request access."
               :error-key :document-requires-invite})
      (throw+ {:status 401
               :error-msg "This document is private. Please log in to access it."
               :error-key :document-requires-login}))))

;; TODO: make sure to kick the user out of subscribed if he loses access
(defn check-subscribed [doc-id req scope]
  ;; TODO: we're making a simplifying assumption that subscribed == :admin
  ;;       That needs to be fixed at some point
  (when (= scope :admin)
    (get-in @document-subs [doc-id (-> req :client-id)])))

(defn check-document-access [doc-id req scope]
  {:pre [doc-id]}
  (check-document-access-from-auth doc-id req scope))

(defn check-team-subscribed [team-uuid req scope]
  (when (= scope :admin)
    (get-in @team-subs [team-uuid (-> req :client-id)])))

(defn check-team-access-from-auth [team-uuid req scope]
  (let [team (team-model/find-by-uuid (:db req) team-uuid)]
    (when-not (auth/has-team-permission? (:db req) team (-> req :ring-req :auth) scope)
      (if (auth/logged-in? (:ring-req req))
        (throw+ {:status 403
                 :error-msg "This team is private. Please request access."
                 :error-key :team-requires-invite})
        (throw+ {:status 401
                 :error-msg "This team is private. Please log in to access it."
                 :error-key :team-requires-login})))))

(defn check-team-access [team-uuid req scope]
  {:pre [team-uuid]}
  (or (check-team-subscribed team-uuid req scope)
      (check-team-access-from-auth team-uuid req scope)))

(defn choose-frontend-id-seed [db document-id subs requested-remainder]
  (let [available-remainders (apply disj web-peer/remainders (map (comp :remainder :frontend-id-seed)
                                                                  (vals subs)))]
    (if-let [remainder (if (contains? available-remainders requested-remainder)
                         requested-remainder
                         (first available-remainders))]
      (let [used-client-parts (web-peer/client-parts-for-ns db document-id)
            used-from-partition (set (filter #(= remainder (mod % web-peer/multiple)) used-client-parts))
            start-eid (first (remove #(contains? used-from-partition %)
                                     (iterate (partial + web-peer/multiple) (+ web-peer/multiple remainder))))]
        {:remainder remainder :multiple web-peer/multiple :next-id start-eid})
      (throw+ {:status 403
               :error-msg "There are too many users in the document."
               :error-key :too-many-subscribers}))))

(defn subscribe-to-doc [db document-id uuid cust & {:keys [requested-color requested-remainder]}]
  (swap! client-stats update-in [uuid] merge {:document {:db/id document-id}})
  (swap! document-subs update-in [document-id]
         (fn [subs]
           (-> subs
             (assoc-in [uuid :client-id] uuid)
             (assoc-in [uuid :auth] {:cust cust})
             (update-in [uuid] merge (select-keys cust [:cust/uuid :cust/color-name :cust/name]))
             (assoc-in [uuid :show-mouse?] true)
             (assoc-in [uuid :frontend-id-seed] (choose-frontend-id-seed db document-id subs requested-remainder))))))

(defn clean-document-subs [client-id]
  (swap! document-subs (fn [ds]
                         ;; Could be optimized...
                         (reduce (fn [acc [document-id client-ids]]
                                   (if-not (contains? client-ids client-id)
                                     acc
                                     (let [new-client-ids (dissoc client-ids client-id)]
                                       (if (empty? new-client-ids)
                                         (dissoc acc document-id)
                                         (assoc acc document-id new-client-ids)))))
                                 ds ds))))

(defn clean-team-subs [client-id]
  (swap! team-subs (fn [ds]
                     ;; Could be optimized...
                     (reduce (fn [acc [team-uuid client-ids]]
                               (if-not (contains? client-ids client-id)
                                 acc
                                 (let [new-client-ids (dissoc client-ids client-id)]
                                   (if (empty? new-client-ids)
                                     (dissoc acc team-uuid)
                                     (assoc acc team-uuid new-client-ids)))))
                             ds ds))))

(defn close-connection [client-id]
  (log/infof "closing connection for %s" client-id)
  (doseq [uid (reduce (fn [acc [doc-id clients]]
                        (if (contains? clients client-id)
                          (set/union acc (keys clients))
                          acc))
                      #{} @document-subs)]
    (log/infof "notifying %s about %s leaving" uid client-id)
    ((:send-fn @sente-state) uid [:frontend/subscriber-left {:client-id client-id}]))
  (clean-team-subs client-id)
  (clean-document-subs client-id)
  (swap! client-stats dissoc client-id))

(defn subscribe-to-team [team-uuid uuid cust]
  (swap! client-stats update-in [uuid] merge {:team {:db/id team-uuid}})
  (swap! team-subs update-in [team-uuid]
         (fn [subs]
           (-> subs
             (assoc-in [uuid] {:client-id uuid
                               :cust/uuid (:cust/uuid cust)})))))

(defmulti ws-handler ws-handler-dispatch-fn)

(defmethod ws-handler :default [req]
  (log/infof "%s for %s" (:event req) (:client-id req)))

(defmethod ws-handler :chsk/uidport-close [{:keys [client-id] :as req}]
  (close-connection client-id))

(defmethod ws-handler :frontend/close-connection [{:keys [client-id] :as req}]
  (close-connection client-id))

(defmethod ws-handler :frontend/unsubscribe [{:keys [client-id ?data ?reply-fn] :as req}]
  (check-document-access (-> ?data :document-id) req :read)
  (let [document-id (-> ?data :document-id)]
    (log/infof "unsubscribing %s from %s" client-id document-id)
    (close-connection client-id)
    (doseq [[uid _] (get @document-subs document-id)]
      ((:send-fn @sente-state) uid [:frontend/subscriber-left {:client-id client-id}]))))

(defmethod ws-handler :team/subscribe [{:keys [client-id ?data ?reply-fn] :as req}]
  (let [team-uuid (-> ?data :team/uuid)
        team (team-model/find-by-uuid (:db req) team-uuid)
        send-fn (:send-fn @sente-state)]
    (check-team-access team-uuid req :admin)
    (subscribe-to-team team-uuid client-id (get-in req [:ring-req :auth :cust]))

    (log/infof "sending permission-data for team %s to %s" (:team/subdomain team) client-id)
    (send-fn client-id [:team/db-entities
                        {:team/uuid team-uuid
                         :entities (map (partial permission-model/read-api (:db req))
                                        (filter :permission/cust-ref
                                                (permission-model/find-by-team (:db req)
                                                                               team)))
                         :entity-type :permission}])
    (send-fn client-id [:team/db-entities
                        {:team/uuid team-uuid
                         :entities (map access-grant-model/read-api
                                        (access-grant-model/find-by-team (:db req)
                                                                         team))
                         :entity-type :access-grant}])

    (send-fn client-id [:team/db-entities
                        {:team/uuid team-uuid
                         :entities (map (partial access-request-model/read-api (:db req))
                                        (access-request-model/find-by-team (:db req)
                                                                           team))
                         :entity-type :access-request}])))

;; TODO: subscribe should be the only function you need when you get to a doc, then it should send
;;       all of the data asynchronously
(defmethod ws-handler :frontend/subscribe [{:keys [client-id ?data ?reply-fn] :as req}]
  (try+
   (check-document-access (-> ?data :document-id) req :read)
   (catch :status t
     (?reply-fn [:subscribe/error])
     (throw+)))
  (let [document-id (-> ?data :document-id)
        send-fn (:send-fn @sente-state)
        _ (log/infof "subscribing %s to %s" client-id document-id)
        subs (subscribe-to-doc (:db req)
                               document-id
                               client-id
                               (-> req :ring-req :auth :cust)
                               :requested-color (:requested-color ?data)
                               :requested-remainder (:requested-remainder ?data))
        doc (doc-model/find-by-id (:db req) document-id)]

    (?reply-fn [:frontend/frontend-id-state {:frontend-id-state (get-in subs [document-id client-id :frontend-id-seed])
                                             :max-document-scope (auth/max-document-scope (:db req) doc (get-in req [:ring-req :auth]))}])

    (doseq [[uid _] (get @document-subs document-id)]
      (send-fn uid [:frontend/subscriber-joined (merge {:client-id client-id}
                                                       (get-in subs [document-id client-id]))]))

    ;; TODO: we'll need a read-api or something here at some point
    (log/infof "sending document for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/db-entities
                        {:document/id document-id
                         :entities [(doc-model/read-api (doc-model/find-by-id (:db req) document-id))]
                         :entity-type :document}])

    (log/infof "sending layers for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/db-entities
                        {:document/id document-id
                         :entities (map layer/read-api (layer/find-by-document (:db req) {:db/id document-id}))
                         :entity-type :layer}])

    (log/infof "sending custs for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/custs
                        {:document/id document-id
                         :uuid->cust (->> document-id
                                       (cust/cust-uuids-for-doc (:db req))
                                       (cons (auth/prcrsr-bot-uuid (:db req)))
                                       set
                                       (set/union (disj (set (map :cust/uuid (vals (get subs document-id))))
                                                        nil))
                                       (cust/public-read-api-per-uuids (:db req)))}])

    (log/infof "sending chats %s to %s" document-id client-id)
    (send-fn client-id [:frontend/db-entities
                        {:document/id document-id
                         :entities (map chat/read-api (chat/find-by-document (:db req) {:db/id document-id}))
                         :entity-type :chat}])
    (log/infof "sending subscribers for %s to %s" document-id client-id)
    (send-fn client-id [:frontend/subscribers
                        {:document/id document-id
                         :subscribers (get subs document-id)}])

    ;; These are interesting b/c they're read-only. And by "interesting", I mean "bad"
    ;; We should find a way to let the frontend edit things
    ;; TODO: only send this stuff when it's needed
    (when (has-document-access? document-id req :admin)
      (log/infof "sending permission-data for %s to %s" document-id client-id)
      (send-fn client-id [:frontend/db-entities
                          {:document/id document-id
                           :entities (map (partial permission-model/read-api (:db req))
                                          (filter :permission/cust-ref
                                                  (permission-model/find-by-document (:db req)
                                                                                     {:db/id document-id})))
                           :entity-type :permission}])

      (send-fn client-id [:frontend/db-entities
                          {:document/id document-id
                           :entities (map access-grant-model/read-api
                                          (access-grant-model/find-by-document (:db req)
                                                                               {:db/id document-id}))
                           :entity-type :access-grant}])

      (send-fn client-id [:frontend/db-entities
                          {:document/id document-id
                           :entities (map (partial access-request-model/read-api (:db req))
                                          (access-request-model/find-by-document (:db req)
                                                                                 {:db/id document-id}))
                           :entity-type :access-request}]))))

(defmethod ws-handler :frontend/fetch-touched [{:keys [client-id ?data ?reply-fn] :as req}]
  (when-let [cust (-> req :ring-req :auth :cust)]
    (let [;; TODO: at some point we may want to limit, but it's just a
          ;; list of longs, so meh
          ;; limit (get ?data :limit 100)
          ;; offset (get ?data :offset 0)
          doc-ids (if-let [team (get-in req [:ring-req :team])]
                    (doc-model/find-touched-by-cust-in-team (:db req) cust team)
                    (doc-model/find-touched-by-cust (:db req) cust))]
      (log/infof "fetched %s touched for %s" (count doc-ids) client-id)
      (?reply-fn {:docs (map (fn [doc-id] {:db/id doc-id
                                           :last-updated-instant (doc-model/last-updated-time (:db req) doc-id)})
                             doc-ids)}))))

(defmethod ws-handler :team/fetch-touched [{:keys [client-id ?data ?reply-fn] :as req}]
  (let [team-uuid (-> ?data :team/uuid)
        team (team-model/find-by-uuid (:db req) team-uuid)]
    (check-team-access team-uuid req :admin)
    (let [ ;; TODO: at some point we may want to limit, but it's just a
          ;; list of longs, so meh
          ;; limit (get ?data :limit 100)
          ;; offset (get ?data :offset 0)
          doc-ids (team-model/find-doc-ids (:db req) team)]
      (log/infof "fetched %s touched in %s for %s" (count doc-ids) (:team/subdomain team) client-id)
      (?reply-fn {:docs (map (fn [doc-id] {:db/id doc-id
                                           :last-updated-instant (doc-model/last-updated-time (:db req) doc-id)})
                             doc-ids)}))))

(defn determine-type [datom datoms]
  (let [e (:e datom)
        attr-nses (map (comp namespace :a) (filter #(= (:e %) (:e datom)) datoms))]
    (cond (first (filter #(= "layer" %) attr-nses))
          :layer/document

          (first (filter #(= "chat" %) attr-nses))
          :chat/document

          :else (throw+ {:error :invalid-type-fordocument-id
                         :datom datom
                         :datoms datoms}))))

(defmethod ws-handler :frontend/transaction [{:keys [client-id ?data ?reply-fn] :as req}]
  (let [document-id (-> ?data :document/id)
        access-scope (if (has-document-access? document-id req :admin)
                       :admin
                       ;; will throw, so we can't get any further
                       (when (check-document-access document-id req :read)
                         :read))
        _ (assert (keyword? access-scope)) ;; just in case
        datoms (->> ?data
                 :datoms
                 (remove (comp nil? :v))
                 ;; Don't let people sneak layers into other documents
                 (reduce (fn [acc d]
                           (if (= "document" (name (:a d)))
                             (conj acc
                                   (assoc d :v document-id))
                             (conj acc d)))
                         []))
        cust-uuid (-> req :ring-req :auth :cust :cust/uuid)
        ;; note that these aren't all of the rejected datoms, just the ones not on the whitelist
        rejects (remove (comp (partial datomic2/whitelisted? access-scope)
                              pcd/datom->transaction)
                        datoms)]
    (when ?reply-fn
      (?reply-fn {:rejected-datoms rejects}))
    (log/infof "transacting %s datoms (minus %s rejects) on %s for %s" (count datoms) (count rejects) document-id client-id)
    (datomic2/transact! datoms
                        {:document-id document-id
                         :access-scope access-scope
                         :client-id client-id
                         :cust-uuid cust-uuid
                         :frontend-id-seed (get-in @document-subs [document-id client-id :frontend-id-seed])
                         :session-uuid (UUID/fromString (get-in req [:ring-req :session :sente-id]))
                         :timestamp (:receive-instant req)})))

(defmethod ws-handler :frontend/mouse-position [{:keys [client-id ?data] :as req}]
  (check-document-access (-> ?data :document/id) req :read)
  (let [document-id (-> ?data :document/id)
        mouse-position (-> ?data :mouse-position)
        tool (-> ?data :tool)
{'fraction_non_alphanumeric': 0.13814401266045367, 'fraction_numerical': 0.0015166168454369615, 'mean_word_length': 1.6872009922627134, 'pattern_counts': {'":': 0, '<': 3, '<?xml version=': 0, '>': 65, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 146, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5357268', 'n_tokens_mistral': 13825, 'n_tokens_neox': 13226, 'n_words': 3655}