mirror of
https://github.com/penpot/penpot.git
synced 2025-05-01 04:26:20 +02:00
* ♻️ Remove deprecated with-atomic and refactor tx-run! * ✨ Do not hold open connection for the whole clone-template operation
1091 lines
37 KiB
Clojure
1091 lines
37 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
|
|
(:require
|
|
[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.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.file-migrations :as feat.fmigr]
|
|
[app.loggers.audit :as-alias audit]
|
|
[app.loggers.webhooks :as-alias webhooks]
|
|
[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.pointer-map :as pmap]
|
|
[app.util.services :as sv]
|
|
[app.util.time :as dt]
|
|
[app.worker :as wrk]
|
|
[cuerdas.core :as str]
|
|
[promesa.exec :as px]))
|
|
|
|
;; --- 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
|
|
(dt/duration {:days 7}))
|
|
|
|
(defn decode-row
|
|
[{:keys [data changes features] :as row}]
|
|
(when row
|
|
(cond-> row
|
|
features (assoc :features (db/decode-pgarray features #{}))
|
|
changes (assoc :changes (blob/decode changes))
|
|
data (assoc :data (blob/decode data)))))
|
|
|
|
(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 ^:private sql:file-permissions
|
|
"select fpr.is_owner,
|
|
fpr.is_admin,
|
|
fpr.can_edit
|
|
from file_profile_rel as fpr
|
|
where fpr.file_id = ?
|
|
and fpr.profile_id = ?
|
|
union all
|
|
select tpr.is_owner,
|
|
tpr.is_admin,
|
|
tpr.can_edit
|
|
from team_profile_rel as tpr
|
|
inner join project as p on (p.team_id = tpr.team_id)
|
|
inner join file as f on (p.id = f.project_id)
|
|
where f.id = ?
|
|
and tpr.profile_id = ?
|
|
union all
|
|
select ppr.is_owner,
|
|
ppr.is_admin,
|
|
ppr.can_edit
|
|
from project_profile_rel as ppr
|
|
inner join file as f on (f.project_id = ppr.project_id)
|
|
where f.id = ?
|
|
and ppr.profile_id = ?")
|
|
|
|
(defn get-file-permissions
|
|
[conn profile-id file-id]
|
|
(when (and profile-id file-id)
|
|
(db/exec! conn [sql:file-permissions
|
|
file-id profile-id
|
|
file-id profile-id
|
|
file-id profile-id])))
|
|
|
|
(defn get-permissions
|
|
([conn profile-id file-id]
|
|
(let [rows (get-file-permissions conn profile-id file-id)
|
|
is-owner (boolean (some :is-owner rows))
|
|
is-admin (boolean (some :is-admin rows))
|
|
can-edit (boolean (some :can-edit rows))]
|
|
(when (seq rows)
|
|
{:type :membership
|
|
:is-owner is-owner
|
|
:is-admin (or is-owner is-admin)
|
|
:can-edit (or is-owner is-admin can-edit)
|
|
:can-read true
|
|
:is-logged (some? profile-id)})))
|
|
|
|
([conn profile-id file-id share-id]
|
|
(let [perms (get-permissions conn profile-id file-id)
|
|
ldata (some-> (db/get* conn :share-link {:id share-id :file-id file-id})
|
|
(dissoc :flags)
|
|
(update :pages db/decode-pgarray #{}))]
|
|
|
|
;; NOTE: in a future when share-link becomes more powerful and
|
|
;; will allow us specify which parts of the app is available, we
|
|
;; will probably need to tweak this function in order to expose
|
|
;; this flags to the frontend.
|
|
(cond
|
|
(some? perms) perms
|
|
(some? ldata) {:type :share-link
|
|
:can-read true
|
|
:pages (:pages ldata)
|
|
:is-logged (some? profile-id)
|
|
:who-comment (:who-comment ldata)
|
|
:who-inspect (:who-inspect ldata)}))))
|
|
|
|
(def has-edit-permissions?
|
|
(perms/make-edition-predicate-fn get-permissions))
|
|
|
|
(def has-read-permissions?
|
|
(perms/make-read-predicate-fn get-permissions))
|
|
|
|
(def has-comment-permissions?
|
|
(perms/make-comment-predicate-fn get-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 (get-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 ::dt/instant]
|
|
[:is-shared ::sm/boolean]
|
|
[:project-id ::sm/uuid]
|
|
[:created-at ::dt/instant]
|
|
[:data {:optional true} :any]])
|
|
|
|
(def schema:permissions-mixin
|
|
[:map {:title "PermissionsMixin"}
|
|
[:permissions ::perms/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]
|
|
[:project-id {:optional true} ::sm/uuid]])
|
|
|
|
(defn- migrate-file
|
|
[{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)
|
|
pmap/*tracked* (pmap/create-tracked)]
|
|
(let [;; For avoid unnecesary overhead of creating multiple pointers and
|
|
;; handly internally with objects map in their worst case (when
|
|
;; probably all shapes and all pointers will be readed in any
|
|
;; case), we just realize/resolve them before applying the
|
|
;; migration to the file
|
|
file (-> file
|
|
(update :data feat.fdata/process-pointers deref)
|
|
(update :data feat.fdata/process-objects (partial into {}))
|
|
(fmg/migrate-file))
|
|
|
|
;; When file is migrated, we break the rule of no perform
|
|
;; mutations on get operations and update the file with all
|
|
;; migrations applied
|
|
;;
|
|
;; WARN: he following code will not work on read-only mode,
|
|
;; it is a known issue; we keep is not implemented until we
|
|
;; really need this.
|
|
file (if (contains? (:features file) "fdata/objects-map")
|
|
(feat.fdata/enable-objects-map file)
|
|
file)
|
|
file (if (contains? (:features file) "fdata/pointer-map")
|
|
(feat.fdata/enable-pointer-map file)
|
|
file)]
|
|
|
|
(db/update! conn :file
|
|
{:data (blob/encode (:data file))
|
|
:version (:version file)
|
|
:features (db/create-array conn "text" (:features file))}
|
|
{:id id})
|
|
|
|
(when (contains? (:features file) "fdata/pointer-map")
|
|
(feat.fdata/persist-pointers! cfg id))
|
|
|
|
(feat.fmigr/upsert-migrations! conn file)
|
|
(feat.fmigr/resolve-applied-migrations cfg file))))
|
|
|
|
(defn get-file
|
|
[{:keys [::db/conn ::wrk/executor] :as cfg} id
|
|
& {:keys [project-id
|
|
migrate?
|
|
include-deleted?
|
|
lock-for-update?]
|
|
:or {include-deleted? false
|
|
lock-for-update? false
|
|
migrate? true}}]
|
|
|
|
(assert (db/connection? conn) "expected cfg with valid connection")
|
|
|
|
(let [params (merge {:id id}
|
|
(when (some? project-id)
|
|
{:project-id project-id}))
|
|
file (->> (db/get conn :file params
|
|
{::db/check-deleted (not include-deleted?)
|
|
::db/remove-deleted (not include-deleted?)
|
|
::sql/for-update lock-for-update?})
|
|
(feat.fmigr/resolve-applied-migrations cfg)
|
|
(feat.fdata/resolve-file-data cfg))
|
|
|
|
;; NOTE: we perform the file decoding in a separate thread
|
|
;; because it has heavy and synchronous operations for
|
|
;; decoding file body that are not very friendly with virtual
|
|
;; threads.
|
|
file (px/invoke! executor #(decode-row file))]
|
|
|
|
(if (and migrate? (fmg/need-migration? file))
|
|
(migrate-file cfg file)
|
|
file)))
|
|
|
|
(defn get-minimal-file
|
|
[cfg id & {:as opts}]
|
|
(let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :vern :data-ref-id :data-backend])]
|
|
(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 (get-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 "/"
|
|
(dt/format-instant 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))
|
|
(get-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 (-> (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) (:features params)))
|
|
|
|
;; 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")))
|
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
|
(update file :data feat.fdata/process-pointers deref))
|
|
file))))
|
|
|
|
;; --- COMMAND QUERY: get-file-fragment (by id)
|
|
|
|
(def schema:file-fragment
|
|
[:map {:title "FileFragment"}
|
|
[:id ::sm/uuid]
|
|
[:file-id ::sm/uuid]
|
|
[:created-at ::dt/instant]
|
|
[:content 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]
|
|
(let [resolve-file-data (partial feat.fdata/resolve-file-data cfg)]
|
|
(some-> (db/get cfg :file-data-fragment {:file-id file-id :id fragment-id})
|
|
(resolve-file-data)
|
|
(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 (get-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]}]
|
|
(dm/with-open [conn (db/open pool)]
|
|
(projects/check-read-permissions! conn profile-id project-id)
|
|
(get-project-files conn 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)))
|
|
|
|
|
|
;; --- 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] :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 [team (teams/get-team conn
|
|
:profile-id profile-id
|
|
:file-id file-id)
|
|
|
|
file (get-file cfg file-id)
|
|
|
|
_ (-> (cfeat/get-team-enabled-features cf/flags team)
|
|
(cfeat/check-client-features! (:features params))
|
|
(cfeat/check-file-features! (:features file) (:features params)))
|
|
|
|
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)))]
|
|
|
|
(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
|
|
|
|
(def ^:private sql:team-shared-files
|
|
"select f.id,
|
|
f.revn,
|
|
f.vern,
|
|
f.data,
|
|
f.project_id,
|
|
f.created_at,
|
|
f.modified_at,
|
|
f.name,
|
|
f.is_shared,
|
|
ft.media_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.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-library-summary
|
|
[cfg {:keys [id data] :as file}]
|
|
(letfn [(assets-sample [assets limit]
|
|
(let [sorted-assets (->> (vals assets)
|
|
(sort-by #(str/lower (:name %))))]
|
|
{:count (count sorted-assets)
|
|
:sample (into [] (take limit sorted-assets))}))]
|
|
|
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
|
(let [load-objects (fn [component]
|
|
(ctf/load-component-objects data component))
|
|
components-sample (-> (assets-sample (ctkl/components data) 4)
|
|
(update :sample #(mapv load-objects %)))]
|
|
{:components components-sample
|
|
:media (assets-sample (:media data) 3)
|
|
:colors (assets-sample (:colors data) 3)
|
|
:typographies (assets-sample (:typographies data) 3)}))))
|
|
|
|
(defn- get-team-shared-files
|
|
[{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}]
|
|
(teams/check-read-permissions! conn profile-id team-id)
|
|
(->> (db/exec! conn [sql:team-shared-files team-id])
|
|
(into #{} (comp
|
|
(map decode-row)
|
|
(map (fn [row]
|
|
(if-let [media-id (:media-id row)]
|
|
(-> row
|
|
(dissoc :media-id)
|
|
(assoc :thumbnail-id media-id))
|
|
(dissoc row :media-id))))
|
|
(map #(assoc % :library-summary (get-library-summary cfg %)))
|
|
(map #(dissoc % :data))))))
|
|
|
|
(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-libraries
|
|
|
|
(def ^:private sql:get-file-libraries
|
|
"WITH RECURSIVE libs AS (
|
|
SELECT fl.*, flr.synced_at
|
|
FROM file AS fl
|
|
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
|
|
WHERE flr.file_id = ?::uuid
|
|
UNION
|
|
SELECT fl.*, flr.synced_at
|
|
FROM file AS fl
|
|
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
|
|
JOIN libs AS l ON (flr.file_id = l.id)
|
|
)
|
|
SELECT l.id,
|
|
l.features,
|
|
l.project_id,
|
|
l.created_at,
|
|
l.modified_at,
|
|
l.deleted_at,
|
|
l.name,
|
|
l.revn,
|
|
l.vern,
|
|
l.synced_at,
|
|
l.is_shared
|
|
FROM libs AS l
|
|
WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
|
|
|
|
(defn get-file-libraries
|
|
[conn file-id]
|
|
(into []
|
|
(comp
|
|
;; FIXME: :is-indirect set to false to all rows looks
|
|
;; completly useless
|
|
(map #(assoc % :is-indirect false))
|
|
(map decode-row))
|
|
(db/exec! conn [sql:get-file-libraries file-id])))
|
|
|
|
(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}
|
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
|
|
(dm/with-open [conn (db/open pool)]
|
|
(check-read-permissions! conn profile-id file-id)
|
|
(get-file-libraries conn 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-file-summary
|
|
|
|
(defn- get-file-summary
|
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id id project-id] :as params}]
|
|
(check-read-permissions! conn profile-id id)
|
|
(let [team (teams/get-team conn
|
|
:profile-id profile-id
|
|
:project-id project-id
|
|
:file-id id)
|
|
|
|
file (get-file cfg id :project-id project-id)]
|
|
|
|
(-> (cfeat/get-team-enabled-features cf/flags team)
|
|
(cfeat/check-client-features! (:features params))
|
|
(cfeat/check-file-features! (:features file) (:features params)))
|
|
|
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
|
{:name (:name file)
|
|
:components-count (count (ctkl/components-seq (:data file)))
|
|
:graphics-count (count (get-in file [:data :media] []))
|
|
:colors-count (count (get-in file [:data :colors] []))
|
|
:typography-count (count (get-in file [:data :typographies] []))})))
|
|
|
|
(sv/defmethod ::get-file-summary
|
|
"Retrieve a file summary by its ID. Only authenticated users."
|
|
{::doc/added "1.20"
|
|
::sm/params schema:get-file}
|
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
|
(db/tx-run! cfg get-file-summary (assoc params :profile-id profile-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]}))
|
|
|
|
(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 (dt/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 ::dt/instant]
|
|
[:modified-at ::dt/instant]]
|
|
|
|
::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 ::dt/instant]
|
|
[:modified-at ::dt/instant]]
|
|
|
|
::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 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]
|
|
|
|
(dm/assert!
|
|
"expected cfg with valid connection"
|
|
(db/connection-map? cfg))
|
|
|
|
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)
|
|
pmap/*tracked* (pmap/create-tracked)]
|
|
(let [file (-> (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))
|
|
|
|
(db/update! cfg :file
|
|
{:revn (inc (:revn file))
|
|
:data (blob/encode (:data file))
|
|
:modified-at (dt/now)
|
|
:has-media-trimmed false}
|
|
{:id file-id})
|
|
|
|
(feat.fdata/persist-pointers! cfg file-id))))
|
|
|
|
(defn- absorb-library
|
|
"Find all files using a shared library, and absorb all library assets
|
|
into the file local libraries"
|
|
[cfg {:keys [id] :as library}]
|
|
|
|
(dm/assert!
|
|
"expected cfg with valid connection"
|
|
(db/connection-map? cfg))
|
|
|
|
(let [ldata (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
|
|
(-> library :data (feat.fdata/process-pointers deref)))
|
|
ids (->> (db/exec! cfg [sql:get-referenced-files id])
|
|
(map :id))]
|
|
|
|
(l/trc :hint "absorbing library"
|
|
:library-id (str id)
|
|
:files (str/join "," (map str ids)))
|
|
|
|
(run! (partial absorb-library-by-file! cfg ldata) ids)
|
|
library))
|
|
|
|
(defn absorb-library!
|
|
[{:keys [::db/conn] :as cfg} id]
|
|
(let [file (-> (get-file cfg id
|
|
: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 (dt/now)}
|
|
{:id id})
|
|
(select-keys file [:id :name :is-shared]))
|
|
|
|
(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 (dt/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 file-id]
|
|
(let [file (db/update! conn :file
|
|
{:deleted-at (dt/now)}
|
|
{:id file-id}
|
|
{::db/return-keys [:id :name :is-shared :deleted-at
|
|
:project-id :created-at :modified-at]})]
|
|
(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 [file (mark-file-deleted conn 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
|
|
{::doc/added "1.17"
|
|
::webhooks/event? true
|
|
::sm/params schema:link-file-to-library}
|
|
[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"))
|
|
|
|
(db/tx-run! cfg
|
|
(fn [{:keys [::db/conn]}]
|
|
(check-edition-permissions! conn profile-id file-id)
|
|
(check-edition-permissions! conn profile-id library-id)
|
|
(link-file-to-library conn params))))
|
|
|
|
;; --- 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] :as params}]
|
|
(check-edition-permissions! conn profile-id file-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 (dt/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] :as params}]
|
|
(check-edition-permissions! conn profile-id file-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 (dt/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 ::dt/instant]])
|
|
|
|
;; 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 #{})))
|