mirror of
https://github.com/penpot/penpot.git
synced 2025-05-02 06:05:53 +02:00
391 lines
14 KiB
Clojure
391 lines
14 KiB
Clojure
;; 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/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.rpc.commands.files-update
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.features :as cfeat]
|
|
[app.common.files.changes :as cpc]
|
|
[app.common.files.migrations :as fmg]
|
|
[app.common.files.validate :as val]
|
|
[app.common.logging :as l]
|
|
[app.common.schema :as sm]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.features.fdata :as feat.fdata]
|
|
[app.http.errors :as errors]
|
|
[app.loggers.audit :as audit]
|
|
[app.loggers.webhooks :as webhooks]
|
|
[app.metrics :as mtx]
|
|
[app.msgbus :as mbus]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.climit :as climit]
|
|
[app.rpc.commands.files :as files]
|
|
[app.rpc.commands.teams :as teams]
|
|
[app.rpc.doc :as-alias doc]
|
|
[app.rpc.helpers :as rph]
|
|
[app.util.blob :as blob]
|
|
[app.util.objects-map :as omap]
|
|
[app.util.pointer-map :as pmap]
|
|
[app.util.services :as sv]
|
|
[app.util.time :as dt]
|
|
[app.worker :as-alias wrk]
|
|
[clojure.set :as set]))
|
|
|
|
;; --- SCHEMA
|
|
|
|
(def ^:private
|
|
schema:update-file
|
|
(sm/define
|
|
[:map {:title "update-file"}
|
|
[:id ::sm/uuid]
|
|
[:session-id ::sm/uuid]
|
|
[:revn {:min 0} :int]
|
|
[:features {:optional true} ::cfeat/features]
|
|
[:changes {:optional true} [:vector ::cpc/change]]
|
|
[:changes-with-metadata {:optional true}
|
|
[:vector [:map
|
|
[:changes [:vector ::cpc/change]]
|
|
[:hint-origin {:optional true} :keyword]
|
|
[:hint-events {:optional true} [:vector :string]]]]]
|
|
[:skip-validate {:optional true} :boolean]]))
|
|
|
|
(def ^:private
|
|
schema:update-file-result
|
|
(sm/define
|
|
[:vector {:title "update-file-result"}
|
|
[:map
|
|
[:changes [:vector ::cpc/change]]
|
|
[:file-id ::sm/uuid]
|
|
[:id ::sm/uuid]
|
|
[:revn {:min 0} :int]
|
|
[:session-id ::sm/uuid]]]))
|
|
|
|
;; --- HELPERS
|
|
|
|
;; File changes that affect to the library, and must be notified
|
|
;; to all clients using it.
|
|
|
|
(def ^:private library-change-types
|
|
#{:add-color :mod-color :del-color
|
|
:add-media :mod-media :del-media
|
|
:add-component :mod-component :del-component :restore-component
|
|
:add-typography :mod-typography :del-typography})
|
|
|
|
(def ^:private file-change-types
|
|
#{:add-obj :mod-obj :del-obj
|
|
:reg-objects :mov-objects})
|
|
|
|
(defn- library-change?
|
|
[{:keys [type] :as change}]
|
|
(or (contains? library-change-types type)
|
|
(contains? file-change-types type)))
|
|
|
|
(def ^:private sql:get-file
|
|
"SELECT f.*, p.team_id
|
|
FROM file AS f
|
|
JOIN project AS p ON (p.id = f.project_id)
|
|
WHERE f.id = ?
|
|
AND (f.deleted_at IS NULL OR
|
|
f.deleted_at > now())
|
|
FOR KEY SHARE")
|
|
|
|
(defn get-file
|
|
[conn id]
|
|
(let [file (db/exec-one! conn [sql:get-file id])]
|
|
(when-not file
|
|
(ex/raise :type :not-found
|
|
:code :object-not-found
|
|
:hint (format "file with id '%s' does not exists" id)))
|
|
(update file :features db/decode-pgarray #{})))
|
|
|
|
(defn- wrap-with-pointer-map-context
|
|
[f]
|
|
(fn [cfg {:keys [id] :as file}]
|
|
(binding [pmap/*tracked* (pmap/create-tracked)
|
|
pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
|
|
cfeat/*wrap-with-pointer-map-fn* pmap/wrap]
|
|
(let [result (f cfg file)]
|
|
(feat.fdata/persist-pointers! cfg id)
|
|
result))))
|
|
|
|
(defn- wrap-with-objects-map-context
|
|
[f]
|
|
(fn [cfg file]
|
|
(binding [cfeat/*wrap-with-objects-map-fn* omap/wrap]
|
|
(f cfg file))))
|
|
|
|
(declare get-lagged-changes)
|
|
(declare send-notifications!)
|
|
(declare update-file)
|
|
(declare update-file*)
|
|
(declare update-file-data)
|
|
(declare take-snapshot?)
|
|
|
|
;; If features are specified from params and the final feature
|
|
;; set is different than the persisted one, update it on the
|
|
;; database.
|
|
|
|
(sv/defmethod ::update-file
|
|
{::climit/id :update-file/by-profile
|
|
::climit/key-fn ::rpc/profile-id
|
|
::webhooks/event? true
|
|
::webhooks/batch-timeout (dt/duration "2m")
|
|
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
|
|
|
::sm/params schema:update-file
|
|
::sm/result schema:update-file-result
|
|
::doc/module :files
|
|
::doc/added "1.17"}
|
|
[cfg {:keys [::rpc/profile-id id] :as params}]
|
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
|
(files/check-edition-permissions! conn profile-id id)
|
|
(db/xact-lock! conn id)
|
|
|
|
(let [file (get-file conn id)
|
|
team (teams/get-team conn
|
|
:profile-id profile-id
|
|
:team-id (:team-id file))
|
|
|
|
features (-> (cfeat/get-team-enabled-features cf/flags team)
|
|
(cfeat/check-client-features! (:features params))
|
|
(cfeat/check-file-features! (:features file) (:features params)))
|
|
|
|
params (assoc params
|
|
:profile-id profile-id
|
|
:features features
|
|
:team team
|
|
:file file)
|
|
|
|
tpoint (dt/tpoint)]
|
|
|
|
;; When newly computed features does not match exactly with
|
|
;; the features defined on team row, we update it.
|
|
(when (not= features (:features team))
|
|
(let [features (db/create-array conn "text" features)]
|
|
(db/update! conn :team
|
|
{:features features}
|
|
{:id (:id team)})))
|
|
|
|
(binding [l/*context* (some-> (meta params)
|
|
(get :app.http/request)
|
|
(errors/request->context))]
|
|
(-> (update-file cfg params)
|
|
(rph/with-defer #(let [elapsed (tpoint)]
|
|
(l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))))
|
|
|
|
(defn update-file
|
|
[{:keys [::db/conn ::mtx/metrics] :as cfg}
|
|
{:keys [id file features changes changes-with-metadata] :as params}]
|
|
(binding [cfeat/*current* features
|
|
cfeat/*previous* (:features file)]
|
|
(let [update-fn (cond-> update-file*
|
|
(contains? features "fdata/pointer-map")
|
|
(wrap-with-pointer-map-context)
|
|
|
|
(contains? features "fdata/objects-map")
|
|
(wrap-with-objects-map-context))
|
|
|
|
changes (if changes-with-metadata
|
|
(->> changes-with-metadata (mapcat :changes) vec)
|
|
(vec changes))
|
|
|
|
features (-> features
|
|
(set/difference cfeat/frontend-only-features)
|
|
(set/union (:features file)))]
|
|
|
|
(when (> (:revn params)
|
|
(:revn file))
|
|
(ex/raise :type :validation
|
|
:code :revn-conflict
|
|
:hint "The incoming revision number is greater that stored version."
|
|
:context {:incoming-revn (:revn params)
|
|
:stored-revn (:revn file)}))
|
|
|
|
(mtx/run! metrics {:id :update-file-changes :inc (count changes)})
|
|
|
|
(when (not= features (:features file))
|
|
(let [features (db/create-array conn "text" features)]
|
|
(db/update! conn :file
|
|
{:features features}
|
|
{:id id})))
|
|
|
|
(let [file (assoc file :features features)
|
|
params (-> params
|
|
(assoc :file file)
|
|
(assoc :changes changes)
|
|
(assoc ::created-at (dt/now)))]
|
|
|
|
(-> (update-fn cfg params)
|
|
(vary-meta assoc ::audit/replace-props
|
|
{:id (:id file)
|
|
:name (:name file)
|
|
:features (:features file)
|
|
:project-id (:project-id file)
|
|
:team-id (:team-id file)}))))))
|
|
|
|
(defn- update-file*
|
|
[{:keys [::db/conn ::wrk/executor] :as cfg}
|
|
{:keys [profile-id file changes session-id ::created-at skip-validate] :as params}]
|
|
(let [;; Process the file data in the CLIMIT context; scheduling it
|
|
;; to be executed on a separated executor for avoid to do the
|
|
;; CPU intensive operation on vthread.
|
|
|
|
update-fdata-fn (partial update-file-data cfg file changes skip-validate)
|
|
file (-> (climit/configure cfg :update-file/global)
|
|
(climit/run! update-fdata-fn executor))]
|
|
|
|
(db/insert! conn :file-change
|
|
{:id (uuid/next)
|
|
:session-id session-id
|
|
:profile-id profile-id
|
|
:created-at created-at
|
|
:file-id (:id file)
|
|
:revn (:revn file)
|
|
:features (db/create-array conn "text" (:features file))
|
|
:data (when (take-snapshot? file)
|
|
(:data file))
|
|
:changes (blob/encode changes)})
|
|
|
|
(db/update! conn :file
|
|
{:revn (:revn file)
|
|
:data (:data file)
|
|
:data-backend nil
|
|
:modified-at created-at
|
|
:has-media-trimmed false}
|
|
{:id (:id file)})
|
|
|
|
(db/update! conn :project
|
|
{:modified-at created-at}
|
|
{:id (:project-id file)})
|
|
|
|
(let [params (assoc params :file file)]
|
|
;; Send asynchronous notifications
|
|
(send-notifications! cfg params)
|
|
|
|
;; Retrieve and return lagged data
|
|
(get-lagged-changes conn params))))
|
|
|
|
(defn- soft-validate-file-schema!
|
|
[file]
|
|
(try
|
|
(val/validate-file-schema! file)
|
|
(catch Throwable cause
|
|
(l/error :hint "file schema validation error" :cause cause))))
|
|
|
|
(defn- soft-validate-file!
|
|
[file libs]
|
|
(try
|
|
(val/validate-file! file libs)
|
|
(catch Throwable cause
|
|
(l/error :hint "file validation error"
|
|
:cause cause))))
|
|
|
|
(defn- update-file-data
|
|
[{:keys [::db/conn] :as cfg} file changes skip-validate]
|
|
(let [file (update file :data (fn [data]
|
|
(-> data
|
|
(blob/decode)
|
|
(assoc :id (:id file))
|
|
(fmg/migrate-data)
|
|
(d/without-nils))))
|
|
|
|
;; WARNING: this ruins performance; maybe we need to find
|
|
;; some other way to do general validation
|
|
libs (when (and (or (contains? cf/flags :file-validation)
|
|
(contains? cf/flags :soft-file-validation))
|
|
(not skip-validate))
|
|
(->> (files/get-file-libraries conn (:id file))
|
|
(into [file] (map (fn [{:keys [id]}]
|
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
|
|
pmap/*tracked* nil]
|
|
(-> (files/get-file cfg id :migrate? false)
|
|
(feat.fdata/process-pointers deref) ; ensure all pointers resolved
|
|
(fmg/migrate-file))))))
|
|
(d/index-by :id)))
|
|
|
|
file (-> (files/check-version! file)
|
|
(update :revn inc)
|
|
(update :data cpc/process-changes changes))]
|
|
|
|
(when (contains? cf/flags :soft-file-validation)
|
|
(soft-validate-file! file libs))
|
|
|
|
(when (contains? cf/flags :soft-file-schema-validation)
|
|
(soft-validate-file-schema! file))
|
|
|
|
(when (and (contains? cf/flags :file-validation)
|
|
(not skip-validate))
|
|
(val/validate-file! file libs))
|
|
|
|
(when (and (contains? cf/flags :file-schema-validation)
|
|
(not skip-validate))
|
|
(val/validate-file-schema! file))
|
|
|
|
(cond-> file
|
|
(and (contains? cfeat/*current* "fdata/objects-map")
|
|
(not (contains? cfeat/*previous* "fdata/objects-map")))
|
|
(feat.fdata/enable-objects-map)
|
|
|
|
(and (contains? cfeat/*current* "fdata/pointer-map")
|
|
(not (contains? cfeat/*previous* "fdata/pointer-map")))
|
|
(feat.fdata/enable-pointer-map)
|
|
|
|
:always
|
|
(update :data blob/encode))))
|
|
|
|
(defn- take-snapshot?
|
|
"Defines the rule when file `data` snapshot should be saved."
|
|
[{:keys [revn modified-at] :as file}]
|
|
(let [freq (or (cf/get :file-change-snapshot-every) 20)
|
|
timeout (or (cf/get :file-change-snapshot-timeout)
|
|
(dt/duration {:hours 1}))]
|
|
(or (= 1 freq)
|
|
(zero? (mod revn freq))
|
|
(> (inst-ms (dt/diff modified-at (dt/now)))
|
|
(inst-ms timeout)))))
|
|
|
|
(def ^:private
|
|
sql:lagged-changes
|
|
"select s.id, s.revn, s.file_id,
|
|
s.session_id, s.changes
|
|
from file_change as s
|
|
where s.file_id = ?
|
|
and s.revn > ?
|
|
order by s.created_at asc")
|
|
|
|
(defn- get-lagged-changes
|
|
[conn {:keys [id revn] :as params}]
|
|
(->> (db/exec! conn [sql:lagged-changes id revn])
|
|
(map files/decode-row)
|
|
(vec)))
|
|
|
|
(defn- send-notifications!
|
|
[cfg {:keys [file team changes session-id] :as params}]
|
|
(let [lchanges (filter library-change? changes)
|
|
msgbus (::mbus/msgbus cfg)]
|
|
|
|
(mbus/pub! msgbus
|
|
:topic (:id file)
|
|
:message {:type :file-change
|
|
:profile-id (:profile-id params)
|
|
:file-id (:id file)
|
|
:session-id (:session-id params)
|
|
:revn (:revn file)
|
|
:changes changes})
|
|
|
|
(when (and (:is-shared file) (seq lchanges))
|
|
(mbus/pub! msgbus
|
|
:topic (:id team)
|
|
:message {:type :library-change
|
|
:profile-id (:profile-id params)
|
|
:file-id (:id file)
|
|
:session-id session-id
|
|
:revn (:revn file)
|
|
:modified-at (dt/now)
|
|
:changes lchanges}))))
|