Andrey Antukh 52573be074
🐛 Enforce share-link page scope in get-page and get-file-fragment RPC commands (#11284)
The get-page RPC command did not validate that the requested page-id was within the share-link's authorized :pages set, allowing share-link holders to read out-of-scope pages. The get-file-fragment command had the same issue.

This fix adds page scope validation to get-page, rejecting requests for pages not in the share-link's :pages set with a :not-found error. For get-file-fragment, share-link access is denied entirely as fragments lack direct page-id mapping.

The fix aligns these commands with the existing correct behavior in get-view-only-bundle, which already filters pages based on share-link scope.

Closes #11281

AI-assisted-by: qwen3.7-plus
2026-08-25 09:37:42 +02:00

1365 lines
46 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 Sucursal en España SL
(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.files.stats :as cfs]
[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 perms/get-file-read-permissions))
(def has-comment-permissions?
(perms/make-comment-predicate-fn perms/get-file-read-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!
[cfg profile-id file-id share-id]
(let [perms (perms/get-file-read-permissions cfg 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 (perms/get-file-read-permissions cfg profile-id id)]
(assoc mfile :permissions perms)))
(defn get-file-etag
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
(ct/format-inst modified-at :iso)
"/"
(uri/map->query-string permissions)
"/"
(some-> deleted-at (ct/format-inst :iso))))
(sv/defmethod ::get-file
"Retrieve a file by its ID. Only authenticated users."
{::doc/added "1.17"
::rpc/id-type :file
::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}
[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))
(perms/get-file-read-permissions cfg profile-id id))]
(check-read-permissions! perms)
(let [team (teams/get-team cfg
: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 (perms/get-file-read-permissions cfg profile-id file-id share-id)]
(when (= :share-link (:type perms))
(ex/raise :type :not-found
:code :object-not-found
:hint "object not found"))
(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! cfg 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! cfg 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! cfg 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 (perms/get-file-read-permissions cfg profile-id file-id share-id)
file (bfc/get-file cfg file-id :read-only? true)
resolved-page-id (or page-id (-> file :data :pages first))
_ (when (and (= :share-link (:type perms))
(not (contains? (:pages perms) resolved-page-id)))
(ex/raise :type :not-found
:code :object-not-found
:hint "object not found"))
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 (dm/get-in file [:data :pages-index resolved-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 [cfg]
(check-read-permissions! cfg 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! cfg 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"
::rpc/id-type :file
::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-stats
(def ^:private sql:file-stats-library-counts
"SELECT
(SELECT COUNT(*)
FROM file_library_rel AS flr
JOIN file AS fl ON (fl.id = flr.library_file_id)
WHERE flr.file_id = ?::uuid
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS library_count,
(SELECT COUNT(*)
FROM file_library_rel AS flr
JOIN file AS fl ON (fl.id = flr.file_id)
WHERE flr.library_file_id = ?::uuid
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS referenced_by_count")
(defn- get-file-stats-library-counts
[conn file-id]
(let [row (db/exec-one! conn [sql:file-stats-library-counts file-id file-id])]
{:library-count (or (:library-count row) 0)
:referenced-by-count (or (:referenced-by-count row) 0)}))
(defn- get-file-stats
[{:keys [::db/conn] :as cfg} file-id]
(let [file (bfc/get-file cfg file-id)
base (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(cfs/calc-file-stats (:data file)))
lib-cnt (get-file-stats-library-counts conn file-id)]
(-> base
(merge lib-cnt)
(assoc :file-id file-id
:revn (:revn file)
:updated-at (:modified-at file)))))
(def ^:private schema:shape-counts
[:map {:title "FileStatsShapeCounts"}
[:total [::sm/int {:min 0}]]
[:by-type [:map-of :keyword [::sm/int {:min 0}]]]])
(def ^:private schema:get-file-stats-result
[:map {:title "FileStats"}
[:file-id ::sm/uuid]
[:page-count [::sm/int {:min 0}]]
[:shape-counts schema:shape-counts]
[:component-count [::sm/int {:min 0}]]
[:deleted-component-count [::sm/int {:min 0}]]
[:color-count [::sm/int {:min 0}]]
[:typography-count [::sm/int {:min 0}]]
[:library-count [::sm/int {:min 0}]]
[:referenced-by-count [::sm/int {:min 0}]]
[:revn [::sm/int {:min 0}]]
[:updated-at ::ct/inst]])
(def ^:private schema:get-file-stats
[:map {:title "get-file-stats"}
[:id ::sm/uuid]])
(sv/defmethod ::get-file-stats
"Return aggregate statistics for a single file: page count, shape
counts by type, component/color/typography counts, and inbound and
outbound library reference counts. Cheap alternative to `get-file`
when only metrics are needed."
{::doc/added "2.17"
::rpc/id-type :file
::sm/params schema:get-file-stats
::sm/result schema:get-file-stats-result
::db/transaction true}
[cfg {:keys [::rpc/profile-id id]}]
(check-read-permissions! cfg profile-id id)
(get-file-stats 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! cfg 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! cfg 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] :as cfg}]
(teams/check-read-permissions! cfg 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"
::rpc/id-type :file
::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)
(= (:is-shared file) (:is-shared params))
;; File is already in the desired state (idempotent);
;; this can happen when the frontend sends a duplicate
;; request due to optimistic updates or race conditions.
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"
::rpc/id-type :file
::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"
::rpc/id-type :file
::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)))
;; --- Library relation helpers
(defn- check-library-team-ownership!
"Verify that file and library belong to the same team.
Prevents cross-team library relation injection."
[conn file-id library-id]
(let [sql "SELECT EXISTS (
SELECT 1 FROM file AS f
JOIN project AS fp ON (fp.id = f.project_id)
JOIN file AS l ON (l.id = ?)
JOIN project AS lp ON (lp.id = l.project_id)
WHERE f.id = ? AND fp.team_id = lp.team_id
) AS ok"
row (db/exec-one! conn [sql library-id file-id])]
(when-not (:ok row)
(ex/raise :type :not-found
:code :object-not-found
:hint "file and library must belong to the same team"))))
;; --- 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])
(bfc/upsert-file-library-sync! conn {:file-id file-id
:library-file-id library-id
:synced-at (ct/now)}))
(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)
(check-library-team-ownership! conn file-id library-id)
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
(when (contains? transitive-deps file-id)
(ex/raise :type :validation
:code :circular-library-reference
:hint "linking this library would create a circular dependency")))
(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)
(check-library-team-ownership! conn file-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}]
(bfc/upsert-file-library-sync! conn {:file-id file-id
:library-file-id library-id
:synced-at (ct/now)}))
(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)
(check-library-team-ownership! conn file-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[])")
(def ^:private sql:restore-files
"UPDATE file SET deleted_at = null, has_media_trimmed = false
WHERE id = ANY(?::uuid[])")
(def ^:private sql:restore-file-media-objects
"UPDATE file_media_object SET deleted_at = null
WHERE file_id = ANY(?::uuid[])")
(def ^:private sql:restore-file-changes
"UPDATE file_change SET deleted_at = null
WHERE file_id = ANY(?::uuid[])")
(def ^:private sql:restore-file-data
"UPDATE file_data SET deleted_at = null
WHERE file_id = ANY(?::uuid[])")
(def ^:private sql:restore-file-thumbnails
"UPDATE file_thumbnail SET deleted_at = null
WHERE file_id = ANY(?::uuid[])")
(def ^:private sql:restore-file-tagged-object-thumbnails
"UPDATE file_tagged_object_thumbnail SET deleted_at = null
WHERE file_id = ANY(?::uuid[])")
(defn- restore-files
[conn file-ids]
(let [file-ids (db/create-array conn "uuid" file-ids)]
(db/exec-one! conn [sql:restore-files file-ids])
(db/exec-one! conn [sql:restore-file-media-objects file-ids])
(db/exec-one! conn [sql:restore-file-changes file-ids])
(db/exec-one! conn [sql:restore-file-data file-ids])
(db/exec-one! conn [sql:restore-file-thumbnails file-ids])
(db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids])))
(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})
(-> result
(update :files conj id)
(update :projects conj project-id))))
{:files #{} :projects #{}}
(db/plan conn [sql:resolve-editable-files team-id
(db/create-array conn "uuid" ids)]))]
(when (seq files)
(restore-files conn files))
(when (seq projects)
(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)))