;; 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 (:require [app.binfile.common :as bfc] [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.features :as cfeat] [app.common.files.helpers :as cfh] [app.common.files.migrations :as fmg] [app.common.logging :as l] [app.common.schema :as sm] [app.common.schema.desc-js-like :as-alias smdj] [app.common.time :as ct] [app.common.transit :as t] [app.common.types.components-list :as ctkl] [app.common.types.file :as ctf] [app.common.uri :as uri] [app.config :as cf] [app.db :as db] [app.db.sql :as-alias sql] [app.features.fdata :as feat.fdata] [app.features.logical-deletion :as ldel] [app.http.sse :as sse] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.msgbus :as mbus] [app.redis :as rds] [app.rpc :as-alias rpc] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.cond :as-alias cond] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.util.blob :as blob] [app.util.events :as events] [app.util.pointer-map :as pmap] [app.util.services :as sv] [app.worker :as wrk] [cuerdas.core :as str])) ;; --- FEATURES (defn resolve-public-uri [media-id] (when media-id (str (cf/get :public-uri) "/assets/by-id/" media-id))) ;; --- HELPERS (def long-cache-duration (ct/duration {:days 7})) (defn decode-row [{:keys [features] :as row}] (when row (cond-> row (db/pgarray? features) (assoc :features (db/decode-pgarray features #{}))))) (defn check-version! [file] (let [version (:version file)] (when (> version fmg/version) (ex/raise :type :restriction :code :file-version-not-supported :hint "file version is greated that the maximum" :file-version version :max-version fmg/version)) file)) ;; --- FILE DATA ;; --- FILE PERMISSIONS (def has-edit-permissions? (perms/make-edition-predicate-fn bfc/get-file-permissions)) (def has-read-permissions? (perms/make-read-predicate-fn bfc/get-file-permissions)) (def has-comment-permissions? (perms/make-comment-predicate-fn bfc/get-file-permissions)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) (def check-read-permissions! (perms/make-check-fn has-read-permissions?)) ;; A user has comment permissions if she has read permissions, or ;; explicit comment permissions through the share-id (defn check-comment-permissions! [conn profile-id file-id share-id] (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) can-read (has-read-permissions? perms) can-comment (has-comment-permissions? perms)] (when-not (or can-read can-comment) (ex/raise :type :not-found :code :object-not-found :hint "not found")))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUERY COMMANDS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; --- COMMAND QUERY: get-file (by id) (def schema:file [:map {:title "File"} [:id ::sm/uuid] [:features ::cfeat/features] [:has-media-trimmed ::sm/boolean] [:comment-thread-seqn [::sm/int {:min 0}]] [:name [:string {:max 250}]] [:revn [::sm/int {:min 0}]] [:vern [::sm/int {:min 0}]] [:modified-at ::ct/inst] [:is-shared ::sm/boolean] [:project-id ::sm/uuid] [:created-at ::ct/inst] [:data {:optional true} ::sm/any]]) (def schema:permissions-mixin [:map {:title "PermissionsMixin"} [:permissions perms/schema:permissions]]) (def schema:file-with-permissions [:merge {:title "FileWithPermissions"} schema:file schema:permissions-mixin]) (def ^:private schema:get-file [:map {:title "get-file"} [:features {:optional true} ::cfeat/features] [:id ::sm/uuid]]) (defn get-minimal-file [cfg id & {:as opts}] (let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :vern])] (db/get cfg :file {:id id} opts))) (defn- get-minimal-file-with-perms [cfg {:keys [:id ::rpc/profile-id]}] (let [mfile (get-minimal-file cfg id) perms (bfc/get-file-permissions cfg profile-id id)] (assoc mfile :permissions perms))) (defn get-file-etag [{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}] (str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/" (ct/format-inst modified-at :iso) "/" (uri/map->query-string permissions))) (sv/defmethod ::get-file "Retrieve a file by its ID. Only authenticated users." {::doc/added "1.17" ::cond/get-object #(get-minimal-file-with-perms %1 %2) ::cond/key-fn get-file-etag ::sm/params schema:get-file ::sm/result schema:file-with-permissions ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}] ;; The COND middleware makes initial request for a file and ;; permissions when the incoming request comes with an ;; ETAG. When ETAG does not matches, the request is resolved ;; and this code is executed, in this case the permissions ;; will be already prefetched and we just reuse them instead ;; of making an additional database queries. (let [perms (or (:permissions (::cond/object params)) (bfc/get-file-permissions conn profile-id id))] (check-read-permissions! perms) (let [team (teams/get-team conn :profile-id profile-id :project-id project-id :file-id id) file (-> (bfc/get-file cfg id :project-id project-id) (assoc :permissions perms) (check-version!))] (-> (cfeat/get-team-enabled-features cf/flags team) (cfeat/check-client-features! (:features params)) (cfeat/check-file-features! (:features file))) (as-> file file ;; This operation is needed for backward comapatibility with ;; frontends that does not support pointer-map resolution ;; mechanism; this just resolves the pointers on backend and ;; return a complete file (if (and (contains? (:features file) "fdata/pointer-map") (not (contains? (:features params) "fdata/pointer-map"))) (feat.fdata/realize-pointers cfg file) file) ;; This operation is needed for backward comapatibility with ;; frontends that does not support objects-map mechanism; this ;; just converts all objects map instaces to plain maps (if (and (contains? (:features file) "fdata/objects-map") (not (contains? (:features params) "fdata/objects-map"))) (feat.fdata/realize-objects cfg file) file))))) ;; --- COMMAND QUERY: get-file-fragment (by id) (def schema:file-fragment [:map {:title "FileFragment"} [:id ::sm/uuid] [:file-id ::sm/uuid] [:created-at ::ct/inst] [:content ::sm/any]]) (def schema:get-file-fragment [:map {:title "get-file-fragment"} [:file-id ::sm/uuid] [:fragment-id ::sm/uuid] [:share-id {:optional true} ::sm/uuid]]) (defn- get-file-fragment [cfg file-id fragment-id] (some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"}) (update :data blob/decode))) (sv/defmethod ::get-file-fragment "Retrieve a file fragment by its ID. Only authenticated users." {::doc/added "1.17" ::rpc/auth false ::sm/params schema:get-file-fragment ::sm/result schema:file-fragment} [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] (let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)] (check-read-permissions! perms) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) ;; --- COMMAND QUERY: get-project-files (def ^:private sql:project-files "select f.id, f.project_id, f.created_at, f.modified_at, f.name, f.revn, f.vern, f.is_shared, ft.media_id AS thumbnail_id, p.team_id from file as f inner join project as p on (p.id = f.project_id) left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn and ft.deleted_at is null) where f.project_id = ? and f.deleted_at is null order by f.modified_at desc") (defn get-project-files [conn project-id] (db/exec! conn [sql:project-files project-id])) (def schema:get-project-files [:map {:title "get-project-files"} [:project-id ::sm/uuid]]) (def schema:files [:vector schema:file]) (sv/defmethod ::get-project-files "Get all files for the specified project." {::doc/added "1.17" ::sm/params schema:get-project-files ::sm/result schema:files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}] (projects/check-read-permissions! pool profile-id project-id) (get-project-files pool project-id)) ;; --- COMMAND QUERY: has-file-libraries (declare get-has-file-libraries) (def schema:has-file-libraries [:map {:title "has-file-libraries"} [:file-id ::sm/uuid]]) (sv/defmethod ::has-file-libraries "Checks if the file has libraries. Returns a boolean" {::doc/added "1.15.1" ::sm/params schema:has-file-libraries ::sm/result ::sm/boolean} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! pool profile-id file-id) (get-has-file-libraries conn file-id))) (def ^:private sql:has-file-libraries "SELECT COUNT(*) > 0 AS has_libraries FROM file_library_rel AS flr JOIN file AS fl ON (flr.library_file_id = fl.id) WHERE flr.file_id = ?::uuid AND (fl.deleted_at IS NULL OR fl.deleted_at > now())") (defn- get-has-file-libraries [conn file-id] (let [row (db/exec-one! conn [sql:has-file-libraries file-id])] (:has-libraries row))) ;; --- COMMAND QUERY: get-library-usage (declare get-library-usage) (def schema:get-library-usage [:map {:title "get-library-usage"} [:file-id ::sm/uuid]]) :sample (sv/defmethod ::get-library-usage "Gets the number of files that use the specified library." {::doc/added "2.10.0" ::sm/params schema:get-library-usage ::sm/result ::sm/int} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! pool profile-id file-id) (get-library-usage conn file-id))) (def ^:private sql:get-library-usage "SELECT COUNT(*) AS used FROM file_library_rel AS flr JOIN file AS fl ON (flr.library_file_id = fl.id) WHERE flr.library_file_id = ?::uuid AND (fl.deleted_at IS NULL OR fl.deleted_at > now())") (defn- get-library-usage [conn file-id] (let [row (db/exec-one! conn [sql:get-library-usage file-id])] {:used-in (:used row)})) ;; --- QUERY COMMAND: get-page (defn- prune-objects "Given the page data and the object-id returns the page data with all other not needed objects removed from the `:objects` data structure." [page id-or-ids] (update page :objects (fn [objects] (reduce (fn [result object-id] (->> (cfh/get-children-with-self objects object-id) (filter some?) (d/index-by :id) (merge result))) {} (if (uuid? id-or-ids) [id-or-ids] id-or-ids))))) (defn- prune-thumbnails "Given the page data, removes the `:thumbnail` prop from all shapes." [page] (update page :objects update-vals #(dissoc % :thumbnail))) (defn get-page [{:keys [::db/conn] :as cfg} {:keys [profile-id file-id page-id object-id share-id] :as params}] (when (and (uuid? object-id) (not (uuid? page-id))) (ex/raise :type :validation :code :params-validation :hint "page-id is required when object-id is provided")) (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) file (bfc/get-file cfg file-id :read-only? true) proj (db/get conn :project {:id (:project-id file)}) team (-> (db/get conn :team {:id (:team-id proj)}) (teams/decode-row)) _ (-> (cfeat/get-team-enabled-features cf/flags team) (cfeat/check-client-features! (:features params)) (cfeat/check-file-features! (:features file))) page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] (let [page-id (or page-id (-> file :data :pages first)) page (dm/get-in file [:data :pages-index page-id])] (if (pmap/pointer-map? page) (deref page) page)))] (when-not perms (ex/raise :type :not-found :code :object-not-found :hint "object not found")) (cond-> (prune-thumbnails page) (some? object-id) (prune-objects object-id)))) (def schema:get-page [:map {:title "get-page"} [:file-id ::sm/uuid] [:page-id {:optional true} ::sm/uuid] [:share-id {:optional true} ::sm/uuid] [:object-id {:optional true} [:or ::sm/uuid [::sm/set ::sm/uuid]]] [:features {:optional true} ::cfeat/features]]) (sv/defmethod ::get-page "Retrieves the page data from file and returns it. If no page-id is specified, the first page will be returned. If object-id is specified, only that object and its children will be returned in the page objects data structure. If you specify the object-id, the page-id parameter becomes mandatory. Mainly used for rendering purposes on the exporter. It does not accepts client features." {::doc/added "1.17" ::sm/params schema:get-page} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (check-read-permissions! conn profile-id file-id share-id) (get-page cfg (assoc params :profile-id profile-id))))) ;; --- COMMAND QUERY: get-team-shared-files (defn- get-components-with-variants "Return a set with all the variant-ids, and a list of components, but with only one component by variant. Returns a vector of unique components and a set of all variant ids" [fdata] (loop [variant-ids #{} components' [] components (ctkl/components-seq fdata)] (if-let [{:keys [variant-id] :as component} (first components)] (cond (nil? variant-id) (recur variant-ids (conj components' component) (rest components)) (contains? variant-ids variant-id) (recur variant-ids components' (rest components)) :else (recur (conj variant-ids variant-id) (conj components' component) (rest components))) [(d/index-by :id components') variant-ids]))) (defn- sample-assets [assets limit] (let [assets (into [] (map val) assets)] {:count (count assets) :sample (->> assets (sort-by #(str/lower (:name %))) (into [] (take limit)))})) (defn- calculate-library-summary "Calculate the file library summary (counters and samples)" [{:keys [data] :as file}] (let [load-objects (fn [sample] (mapv #(ctf/load-component-objects data %) sample)) [components variant-ids] (get-components-with-variants data) components-sample (-> (sample-assets components 4) (update :sample load-objects))] {:components components-sample :variants {:count (count variant-ids)} :colors (sample-assets (:colors data) 3) :typographies (sample-assets (:typographies data) 3)})) (def ^:private file-summary-cache-key-ttl (ct/duration {:days 30})) (def file-summary-cache-key-prefix "penpot.library-summary.") (defn- get-file-with-summary "Get a file without data with a summary of its local library content" [cfg id] (let [get-from-cache (fn [{:keys [::rds/conn]} cache-key] (when-let [result (rds/get conn cache-key)] (let [file (bfc/get-file cfg id :load-data? false) summary (t/decode-str result)] (-> (assoc file :library-summary summary) (dissoc :data))))) calculate-from-db (fn [] (let [file (bfc/get-file cfg id) result (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] (calculate-library-summary file))] (-> file (assoc :library-summary result) (dissoc :legacy-data) (dissoc :data)))) persist-to-cache (fn [{:keys [::rds/conn]} data cache-key] (rds/set conn cache-key (t/encode-str data) (rds/build-set-args {:ex file-summary-cache-key-ttl})))] (if (contains? cf/flags :redis-cache) (let [cache-key (str file-summary-cache-key-prefix id)] (or (rds/run! cfg get-from-cache cache-key) (let [file (calculate-from-db)] (rds/run! cfg persist-to-cache (:library-summary file) cache-key) file))) (calculate-from-db)))) (def ^:private sql:team-shared-files "WITH file_library_agg AS ( SELECT flr.file_id, coalesce(array_agg(flr.library_file_id) filter (WHERE flr.library_file_id IS NOT NULL), '{}') AS library_file_ids FROM file_library_rel flr GROUP BY flr.file_id ) SELECT f.id, fla.library_file_ids, ft.media_id AS thumbnail_id FROM file AS f INNER JOIN project AS p ON (p.id = f.project_id) LEFT JOIN file_thumbnail AS ft ON (ft.file_id = f.id AND ft.revn = f.revn AND ft.deleted_at IS NULL) LEFT JOIN file_library_agg AS fla ON (fla.file_id = f.id) WHERE f.is_shared = true AND f.deleted_at IS NULL AND p.deleted_at IS NULL AND p.team_id = ? ORDER BY f.modified_at DESC") (defn- get-team-shared-files [{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}] (teams/check-read-permissions! conn profile-id team-id) (let [process-row (fn [{:keys [id library-file-ids]}] (let [file (get-file-with-summary cfg id)] (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{})))) xform (map process-row)] (->> (db/plan conn [sql:team-shared-files team-id] {:fetch-size 1}) (transduce xform conj #{})))) (def ^:private schema:get-team-shared-files [:map {:title "get-team-shared-files"} [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-shared-files "Get all file (libraries) for the specified team." {::doc/added "1.17" ::sm/params schema:get-team-shared-files} [cfg {:keys [::rpc/profile-id] :as params}] (db/tx-run! cfg get-team-shared-files (assoc params :profile-id profile-id))) ;; --- COMMAND QUERY: get-file-summary (defn- get-file-summary [cfg id] (let [file (get-file-with-summary cfg id)] (-> (:library-summary file) (assoc :name (:name file))))) (def ^:private schema:get-file-summary [:map {:title "get-file-summary"} [:id ::sm/uuid]]) (sv/defmethod ::get-file-summary "Retrieve a file summary by its ID. Only authenticated users." {::doc/added "1.20" ::sm/params schema:get-file-summary} [cfg {:keys [::rpc/profile-id id] :as params}] (check-read-permissions! cfg profile-id id) (get-file-summary cfg id)) ;; --- COMMAND QUERY: get-file-libraries (def ^:private schema:get-file-libraries [:map {:title "get-file-libraries"} [:file-id ::sm/uuid]]) (sv/defmethod ::get-file-libraries "Get libraries used by the specified file." {::doc/added "1.17" ::sm/params schema:get-file-libraries} [cfg {:keys [::rpc/profile-id file-id]}] (bfc/check-file-exists cfg file-id) (check-read-permissions! cfg profile-id file-id) (bfc/get-file-libraries cfg file-id)) ;; --- COMMAND QUERY: Files that use this File library (def ^:private sql:library-using-files "SELECT f.id, f.name FROM file_library_rel AS flr JOIN file AS f ON (f.id = flr.file_id) WHERE flr.library_file_id = ? AND (f.deleted_at IS NULL OR f.deleted_at > now())") (defn get-library-file-references [conn file-id] (db/exec! conn [sql:library-using-files file-id])) (def ^:private schema:get-library-file-references [:map {:title "get-library-file-references"} [:file-id ::sm/uuid]]) (sv/defmethod ::get-library-file-references "Returns all the file references that use specified file (library) id." {::doc/added "1.17" ::sm/params schema:get-library-file-references} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (dm/with-open [conn (db/open pool)] (check-read-permissions! conn profile-id file-id) (get-library-file-references conn file-id))) ;; --- COMMAND QUERY: get-team-recent-files (def sql:team-recent-files "with recent_files as ( select f.id, f.revn, f.vern, f.project_id, f.created_at, f.modified_at, f.name, f.is_shared, ft.media_id AS thumbnail_id, row_number() over w as row_num, p.team_id from file as f inner join project as p on (p.id = f.project_id) left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn and ft.deleted_at is null) where p.team_id = ? and p.deleted_at is null and f.deleted_at is null window w as (partition by f.project_id order by f.modified_at desc) order by f.modified_at desc ) select * from recent_files where row_num <= 10;") (defn get-team-recent-files [conn team-id] (db/exec! conn [sql:team-recent-files team-id])) (def ^:private schema:get-team-recent-files [:map {:title "get-team-recent-files"} [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-recent-files {::doc/added "1.17" ::sm/params schema:get-team-recent-files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (teams/check-read-permissions! conn profile-id team-id) (get-team-recent-files conn team-id))) ;; --- COMMAND QUERY: get-team-deleted-files (def sql:team-deleted-files "WITH deleted_files AS ( SELECT f.id, f.revn, f.vern, f.project_id, f.created_at, f.modified_at, f.name, f.deleted_at AS will_be_deleted_at, ft.media_id AS thumbnail_id, row_number() OVER w AS row_num, p.team_id FROM file AS f INNER JOIN project AS p ON (p.id = f.project_id) LEFT JOIN file_thumbnail AS ft on (ft.file_id = f.id AND ft.revn = f.revn) WHERE p.team_id = ? AND (p.deleted_at > ?::timestamptz OR f.deleted_at > ?::timestamptz) WINDOW w AS (PARTITION BY f.project_id ORDER BY f.modified_at DESC) ORDER BY f.modified_at DESC ) SELECT * FROM deleted_files") (defn get-team-deleted-files [conn team-id] (let [now (ct/now)] (db/exec! conn [sql:team-deleted-files team-id now now]))) (def ^:private schema:get-team-deleted-files [:map {:title "get-team-deleted-files"} [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-deleted-files {::doc/added "2.12" ::sm/params schema:get-team-deleted-files} [cfg {:keys [::rpc/profile-id team-id]}] (db/run! cfg (fn [{:keys [::db/conn]}] (teams/check-read-permissions! conn profile-id team-id) (get-team-deleted-files conn team-id)))) ;; --- COMMAND QUERY: get-file-info (defn- get-file-info [{:keys [::db/conn] :as cfg} {:keys [id] :as params}] (db/get conn :file {:id id} {::sql/columns [:id :deleted-at]})) (sv/defmethod ::get-file-info "Retrieve minimal file info by its ID." {::rpc/auth false ::doc/added "2.2.0" ::sm/params schema:get-file} [cfg params] (db/tx-run! cfg get-file-info params)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; MUTATION COMMANDS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; --- MUTATION COMMAND: rename-file (defn rename-file [conn {:keys [id name]}] (db/update! conn :file {:name name :modified-at (ct/now)} {:id id} {::db/return-keys true})) (sv/defmethod ::rename-file {::doc/added "1.17" ::webhooks/event? true ::sm/webhook [:map {:title "RenameFileEvent"} [:id ::sm/uuid] [:project-id ::sm/uuid] [:name [:string {:max 250}]] [:created-at ::ct/inst] [:modified-at ::ct/inst]] ::sm/params [:map {:title "RenameFileParams"} [:name [:string {:min 1 :max 250}]] [:id ::sm/uuid]] ::sm/result [:map {:title "SimplifiedFile"} [:id ::sm/uuid] [:name [:string {:max 250}]] [:created-at ::ct/inst] [:modified-at ::ct/inst]] ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}] (check-edition-permissions! conn profile-id id) (let [file (rename-file conn params)] (rph/with-meta (select-keys file [:id :name :created-at :modified-at]) {::audit/props {:project-id (:project-id file) :created-at (:created-at file) :modified-at (:modified-at file)}}))) ;; --- MUTATION COMMAND: set-file-shared (def ^:private sql:get-referenced-files "SELECT f.id FROM file_library_rel AS flr INNER JOIN file AS f ON (f.id = flr.file_id) WHERE flr.library_file_id = ? AND (f.deleted_at IS NULL OR f.deleted_at > now()) ORDER BY f.created_at ASC;") (defn- absorb-library-by-file [cfg ldata file-id] (assert (db/connection-map? cfg) "expected cfg with valid connection") (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id) pmap/*tracked* (pmap/create-tracked)] (let [file (-> (bfc/get-file cfg file-id :include-deleted? true :lock-for-update? true) (update :data ctf/absorb-assets ldata))] (l/trc :hint "library absorbed" :library-id (str (:id ldata)) :file-id (str file-id)) (bfc/update-file! cfg {:id file-id :migrations (:migrations file) :revn (inc (:revn file)) :data (:data file) :modified-at (ct/now) :has-media-trimmed false})))) (defn- absorb-library* "Find all files using a shared library, and absorb all library assets into the file local libraries" [cfg {:keys [id data] :as library}] (assert (db/connection-map? cfg) "expected cfg with valid connection") (let [ids (->> (db/exec! cfg [sql:get-referenced-files id]) (sequence bfc/xf-map-id))] (l/trc :hint "absorbing library" :library-id (str id) :files (str/join "," (map str ids))) (run! (partial absorb-library-by-file cfg data) ids) library)) (defn absorb-library [{:keys [::db/conn] :as cfg} id] (let [file (-> (bfc/get-file cfg id :realize? true :lock-for-update? true :include-deleted? true) (check-version!)) proj (db/get* conn :project {:id (:project-id file)} {::db/remove-deleted false}) team (-> (db/get* conn :team {:id (:team-id proj)} {::db/remove-deleted false}) (teams/decode-row))] (-> (cfeat/get-team-enabled-features cf/flags team) (cfeat/check-file-features! (:features file))) (absorb-library* cfg file))) (defn- set-file-shared [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] (check-edition-permissions! conn profile-id id) (let [file (db/get-by-id conn :file id {:columns [:id :name :is-shared]}) file (cond (and (true? (:is-shared file)) (false? (:is-shared params))) ;; When we disable shared flag on an already shared ;; file, we need to perform more complex operation, ;; so in this case we retrieve the complete file and ;; perform all required validations. (let [file (-> (absorb-library cfg id) (assoc :is-shared false))] (db/delete! conn :file-library-rel {:library-file-id id}) (db/update! conn :file {:is-shared false :modified-at (ct/now)} {:id id}) file) (and (false? (:is-shared file)) (true? (:is-shared params))) (let [file (assoc file :is-shared true)] (db/update! conn :file {:is-shared true :modified-at (ct/now)} {:id id}) file) :else (ex/raise :type :validation :code :invalid-shared-state :hint "unexpected state found" :params-is-shared (:is-shared params) :file-is-shared (:is-shared file)))] (rph/with-meta (select-keys file [:id :name :is-shared]) {::audit/props {:name (:name file) :project-id (:project-id file) :is-shared (:is-shared file)}}))) (def ^:private schema:set-file-shared [:map {:title "set-file-shared"} [:id ::sm/uuid] [:is-shared ::sm/boolean]]) (sv/defmethod ::set-file-shared {::doc/added "1.17" ::webhooks/event? true ::sm/params schema:set-file-shared} [cfg {:keys [::rpc/profile-id] :as params}] (db/tx-run! cfg set-file-shared (assoc params :profile-id profile-id))) ;; --- MUTATION COMMAND: delete-file (defn- mark-file-deleted [conn team file-id] (let [delay (ldel/get-deletion-delay team) file (db/update! conn :file {:deleted-at (ct/in-future delay)} {:id file-id} {::db/return-keys [:id :name :is-shared :deleted-at :project-id :created-at :modified-at]})] ;; Remove all possible relations for that file (db/delete! conn :file-library-rel {:library-file-id file-id}) (wrk/submit! {::db/conn conn ::wrk/task :delete-object ::wrk/params {:object :file :deleted-at (:deleted-at file) :id file-id}}) file)) (def ^:private schema:delete-file [:map {:title "delete-file"} [:id ::sm/uuid]]) (defn- delete-file [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] (check-edition-permissions! conn profile-id id) (let [team (teams/get-team conn :profile-id profile-id :file-id id) file (mark-file-deleted conn team id) msgbus (::mbus/msgbus cfg)] (mbus/pub! msgbus :topic id :message {:type :file-deleted :file-id id :profile-id profile-id}) (rph/with-meta (rph/wrap) {::audit/props {:project-id (:project-id file) :name (:name file) :created-at (:created-at file) :modified-at (:modified-at file)}}))) (sv/defmethod ::delete-file {::doc/added "1.17" ::webhooks/event? true ::sm/params schema:delete-file} [cfg {:keys [::rpc/profile-id] :as params}] (db/tx-run! cfg delete-file (assoc params :profile-id profile-id))) ;; --- MUTATION COMMAND: link-file-to-library (def sql:link-file-to-library "insert into file_library_rel (file_id, library_file_id) values (?, ?) on conflict do nothing;") (defn link-file-to-library [conn {:keys [file-id library-id] :as params}] (db/exec-one! conn [sql:link-file-to-library file-id library-id])) (def ^:private schema:link-file-to-library [:map {:title "link-file-to-library"} [:file-id ::sm/uuid] [:library-id ::sm/uuid]]) (sv/defmethod ::link-file-to-library "Link a file to a library. Returns the recursive list of libraries used by that library" {::doc/added "1.17" ::webhooks/event? true ::sm/params schema:link-file-to-library ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] (when (= file-id library-id) (ex/raise :type :validation :code :invalid-library :hint "A file cannot be linked to itself")) (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) (link-file-to-library conn params) (bfc/get-libraries cfg [library-id])) ;; --- MUTATION COMMAND: unlink-file-from-library (defn unlink-file-from-library [conn {:keys [file-id library-id]}] (db/delete! conn :file-library-rel {:file-id file-id :library-file-id library-id})) (def ^:private schema:unlink-file-to-library [:map {:title "unlink-file-to-library"} [:file-id ::sm/uuid] [:library-id ::sm/uuid]]) (sv/defmethod ::unlink-file-from-library {::doc/added "1.17" ::webhooks/event? true ::sm/params schema:unlink-file-to-library ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) (unlink-file-from-library conn params) nil) ;; --- MUTATION COMMAND: update-sync (defn update-sync [conn {:keys [file-id library-id] :as params}] (db/update! conn :file-library-rel {:synced-at (ct/now)} {:file-id file-id :library-file-id library-id} {::db/return-keys true})) (def ^:private schema:update-file-library-sync-status [:map {:title "update-file-library-sync-status"} [:file-id ::sm/uuid] [:library-id ::sm/uuid]]) (sv/defmethod ::update-file-library-sync-status "Update the synchronization status of a file->library link" {::doc/added "1.17" ::sm/params schema:update-file-library-sync-status ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) (update-sync conn params)) ;; --- MUTATION COMMAND: ignore-sync (defn ignore-sync [conn {:keys [file-id date] :as params}] (db/update! conn :file {:ignore-sync-until date :modified-at (ct/now)} {:id file-id} {::db/return-keys true})) (def ^:private schema:ignore-file-library-sync-status [:map {:title "ignore-file-library-sync-status"} [:file-id ::sm/uuid] [:date ::ct/inst]]) ;; TODO: improve naming (sv/defmethod ::ignore-file-library-sync-status "Ignore updates in linked files" {::doc/added "1.17" ::sm/params schema:ignore-file-library-sync-status ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}] (check-edition-permissions! conn profile-id file-id) (-> (ignore-sync conn params) (update :features db/decode-pgarray #{}))) ;; --- MUTATION COMMAND: delete-files-immediatelly (def ^:private sql:get-delete-team-files-candidates "SELECT f.id FROM file AS f JOIN project AS p ON (p.id = f.project_id) JOIN team AS t ON (t.id = p.team_id) WHERE t.deleted_at IS NULL AND t.id = ? AND f.id = ANY(?::uuid[])") (def ^:private schema:permanently-delete-team-files [:map {:title "permanently-delete-team-files"} [:team-id ::sm/uuid] [:ids [::sm/set ::sm/uuid]]]) (defn- permanently-delete-team-files [{:keys [::db/conn]} {:keys [::rpc/request-at team-id ids]}] (let [ids (into #{} d/xf:map-id (db/exec! conn [sql:get-delete-team-files-candidates team-id (db/create-array conn "uuid" ids)]))] (reduce (fn [acc id] (events/tap :progress {:file-id id :index (inc (count acc)) :total (count ids)}) (db/update! conn :file {:deleted-at request-at} {:id id} {::db/return-keys false}) (wrk/submit! {::db/conn conn ::wrk/task :delete-object ::wrk/params {:object :file :deleted-at request-at :id id}}) (conj acc id)) #{} ids))) (sv/defmethod ::permanently-delete-team-files "Mark the specified files to be deleted immediatelly on the specified team. The team-id on params will be used to filter and check writable permissons on team." {::doc/added "2.13" ::sm/params schema:permanently-delete-team-files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] (teams/check-edition-permissions! pool profile-id team-id) (sse/response #(db/tx-run! cfg permanently-delete-team-files params))) ;; --- MUTATION COMMAND: restore-files-immediatelly (def ^:private sql:resolve-editable-files "SELECT f.id, f.project_id FROM file AS f JOIN project AS p ON (p.id = f.project_id) JOIN team AS t ON (t.id = p.team_id) WHERE t.deleted_at IS NULL AND t.id = ? AND f.id = ANY(?::uuid[])") (defn- restore-file [conn file-id] (db/update! conn :file {:deleted-at nil :has-media-trimmed false} {:id file-id} {::db/return-keys false}) (db/update! conn :file-media-object {:deleted-at nil} {:file-id file-id} {::db/return-keys false}) (db/update! conn :file-change {:deleted-at nil} {:file-id file-id} {::db/return-keys false}) (db/update! conn :file-data {:deleted-at nil} {:file-id file-id} {::db/return-keys false}) (db/update! conn :file-thumbnail {:deleted-at nil} {:file-id file-id} {::db/return-keys false}) (db/update! conn :file-tagged-object-thumbnail {:deleted-at nil} {:file-id file-id} {::db/return-keys false})) (def ^:private sql:restore-projects "UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])") (defn- restore-projects [conn project-ids] (let [project-ids (db/create-array conn "uuid" project-ids)] (->> (db/exec-one! conn [sql:restore-projects project-ids]) (db/get-update-count)))) (defn- restore-deleted-team-files [{:keys [::db/conn]} {:keys [::rpc/profile-id team-id ids]}] (teams/check-edition-permissions! conn profile-id team-id) (let [total-files (count ids) {:keys [files projects]} (reduce (fn [result {:keys [id project-id]}] (let [index (-> result :files count)] (events/tap :progress {:file-id id :index (inc index) :total total-files}) (restore-file conn id) (-> result (update :files conj id) (update :projects conj project-id)))) {:files #{} :projectes #{}} (db/plan conn [sql:resolve-editable-files team-id (db/create-array conn "uuid" ids)]))] (restore-projects conn projects) files)) (def ^:private schema:restore-deleted-team-files [:map {:title "restore-deleted-team-files"} [:team-id ::sm/uuid] [:ids [::sm/set ::sm/uuid]]]) (sv/defmethod ::restore-deleted-team-files "Removes the deletion mark from the specified files (and respective projects) on the specified team." {::doc/added "2.13" ::sse/stream? true ::sm/params schema:restore-deleted-team-files} [cfg params] (sse/response #(db/tx-run! cfg restore-deleted-team-files params)))