mirror of
https://github.com/penpot/penpot.git
synced 2025-06-02 12:11:39 +02:00
456 lines
18 KiB
Clojure
456 lines
18 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.management
|
|
"A collection of RPC methods for manage the files, projects and team organization."
|
|
(:require
|
|
[app.binfile.common :as bfc]
|
|
[app.binfile.v1 :as bf.v1]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.features :as cfeat]
|
|
[app.common.schema :as sm]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.http.sse :as sse]
|
|
[app.loggers.audit :as audit]
|
|
[app.loggers.webhooks :as-alias webhooks]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.commands.files :as files]
|
|
[app.rpc.commands.projects :as proj]
|
|
[app.rpc.commands.teams :as teams]
|
|
[app.rpc.doc :as-alias doc]
|
|
[app.setup :as-alias setup]
|
|
[app.setup.templates :as tmpl]
|
|
[app.util.services :as sv]
|
|
[app.util.time :as dt]
|
|
[app.worker :as-alias wrk]
|
|
[promesa.exec :as px]))
|
|
|
|
;; --- COMMAND: Duplicate File
|
|
|
|
(defn duplicate-file
|
|
[{:keys [::db/conn ::bfc/timestamp] :as cfg} {:keys [profile-id file-id name reset-shared-flag] :as params}]
|
|
(let [;; We don't touch the original file on duplication
|
|
file (bfc/get-file cfg file-id)
|
|
project-id (:project-id file)
|
|
file (-> file
|
|
(update :id bfc/lookup-index)
|
|
(update :project-id bfc/lookup-index)
|
|
(cond-> (string? name)
|
|
(assoc :name name))
|
|
(cond-> (true? reset-shared-flag)
|
|
(assoc :is-shared false)))
|
|
|
|
flibs (bfc/get-files-rels cfg #{file-id})
|
|
fmeds (bfc/get-file-media cfg file)]
|
|
|
|
(when (uuid? profile-id)
|
|
(proj/check-edition-permissions! conn profile-id project-id))
|
|
|
|
(vswap! bfc/*state* update :index bfc/update-index fmeds :id)
|
|
|
|
;; Process and persist file
|
|
(let [file (->> (bfc/process-file file)
|
|
(bfc/persist-file! cfg))]
|
|
|
|
;; The file profile creation is optional, so when no profile is
|
|
;; present (when this function is called from profile less
|
|
;; environment: SREPL) we just omit the creation of the relation
|
|
(when (uuid? profile-id)
|
|
(db/insert! conn :file-profile-rel
|
|
{:file-id (:id file)
|
|
:profile-id profile-id
|
|
:is-owner true
|
|
:is-admin true
|
|
:can-edit true}
|
|
{::db/return-keys? false}))
|
|
|
|
(doseq [params (sequence (comp
|
|
(map #(bfc/remap-id % :file-id))
|
|
(map #(bfc/remap-id % :library-file-id))
|
|
(map #(assoc % :synced-at timestamp))
|
|
(map #(assoc % :created-at timestamp)))
|
|
flibs)]
|
|
(db/insert! conn :file-library-rel params ::db/return-keys false))
|
|
|
|
(doseq [params (sequence (comp
|
|
(map #(bfc/remap-id % :id))
|
|
(map #(assoc % :created-at timestamp))
|
|
(map #(bfc/remap-id % :file-id)))
|
|
fmeds)]
|
|
(db/insert! conn :file-media-object params ::db/return-keys false))
|
|
|
|
file)))
|
|
|
|
(def ^:private
|
|
schema:duplicate-file
|
|
(sm/define
|
|
[:map {:title "duplicate-file"}
|
|
[:file-id ::sm/uuid]
|
|
[:name {:optional true} [:string {:max 250}]]]))
|
|
|
|
(sv/defmethod ::duplicate-file
|
|
"Duplicate a single file in the same team."
|
|
{::doc/added "1.16"
|
|
::webhooks/event? true
|
|
::sm/params schema:duplicate-file}
|
|
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
|
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
|
|
|
|
(binding [bfc/*state* (volatile! {:index {file-id (uuid/next)}})]
|
|
(duplicate-file (assoc cfg ::bfc/timestamp (dt/now))
|
|
(-> params
|
|
(assoc :profile-id profile-id)
|
|
(assoc :reset-shared-flag true)))))))
|
|
|
|
;; --- COMMAND: Duplicate Project
|
|
|
|
(defn duplicate-project
|
|
[{:keys [::db/conn ::bfc/timestamp] :as cfg} {:keys [profile-id project-id name] :as params}]
|
|
(binding [bfc/*state* (volatile! {:index {project-id (uuid/next)}})]
|
|
(let [project (-> (db/get-by-id conn :project project-id)
|
|
(assoc :created-at timestamp)
|
|
(assoc :modified-at timestamp)
|
|
(assoc :is-pinned false)
|
|
(update :id bfc/lookup-index)
|
|
(cond-> (string? name)
|
|
(assoc :name name)))
|
|
|
|
files (bfc/get-project-files cfg project-id)]
|
|
|
|
;; Update index with the project files and the project-id
|
|
(vswap! bfc/*state* update :index bfc/update-index files)
|
|
|
|
|
|
;; Check if the source team-id allow creating new project for current user
|
|
(teams/check-edition-permissions! conn profile-id (:team-id project))
|
|
|
|
;; create the duplicated project and assign the current profile as
|
|
;; a project owner
|
|
(let [project (teams/create-project conn project)]
|
|
;; The project profile creation is optional, so when no profile is
|
|
;; present (when this function is called from profile less
|
|
;; environment: SREPL) we just omit the creation of the relation
|
|
(when (uuid? profile-id)
|
|
(teams/create-project-role conn profile-id (:id project) :owner))
|
|
|
|
(doseq [file-id files]
|
|
(let [params (-> params
|
|
(dissoc :name)
|
|
(assoc :file-id file-id)
|
|
(assoc :reset-shared-flag false))]
|
|
(duplicate-file cfg params)))
|
|
|
|
project))))
|
|
|
|
(def ^:private
|
|
schema:duplicate-project
|
|
(sm/define
|
|
[:map {:title "duplicate-project"}
|
|
[:project-id ::sm/uuid]
|
|
[:name {:optional true} [:string {:max 250}]]]))
|
|
|
|
(sv/defmethod ::duplicate-project
|
|
"Duplicate an entire project with all the files"
|
|
{::doc/added "1.16"
|
|
::webhooks/event? true
|
|
::sm/params schema:duplicate-project}
|
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
|
(db/tx-run! cfg (fn [cfg]
|
|
;; Defer all constraints
|
|
(db/exec-one! cfg ["SET CONSTRAINTS ALL DEFERRED"])
|
|
(-> (assoc cfg ::bfc/timestamp (dt/now))
|
|
(duplicate-project (assoc params :profile-id profile-id))))))
|
|
|
|
(defn duplicate-team
|
|
[{:keys [::db/conn ::bfc/timestamp] :as cfg} & {:keys [profile-id team-id name] :as params}]
|
|
|
|
;; Check if the source team-id allowed to be read by the user if
|
|
;; profile-id is present; it can be ommited if this function is
|
|
;; called from SREPL helpers where no profile is available
|
|
(when (uuid? profile-id)
|
|
(teams/check-read-permissions! conn profile-id team-id))
|
|
|
|
(binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})]
|
|
(let [projs (bfc/get-team-projects cfg team-id)
|
|
files (bfc/get-team-files cfg team-id)
|
|
frels (bfc/get-files-rels cfg files)
|
|
|
|
team (-> (db/get-by-id conn :team team-id)
|
|
(assoc :created-at timestamp)
|
|
(assoc :modified-at timestamp)
|
|
(update :id bfc/lookup-index)
|
|
(cond-> (string? name)
|
|
(assoc :name name)))
|
|
|
|
fonts (db/query conn :team-font-variant
|
|
{:team-id team-id})]
|
|
|
|
(vswap! bfc/*state* update :index
|
|
(fn [index]
|
|
(-> index
|
|
(bfc/update-index projs)
|
|
(bfc/update-index files)
|
|
(bfc/update-index fonts :id))))
|
|
|
|
;; FIXME: disallow clone default team
|
|
;; Create the new team in the database
|
|
(db/insert! conn :team team)
|
|
|
|
;; Duplicate team <-> profile relations
|
|
(doseq [params frels]
|
|
(let [params (-> params
|
|
(assoc :id (uuid/next))
|
|
(update :team-id bfc/lookup-index)
|
|
(assoc :created-at timestamp)
|
|
(assoc :modified-at timestamp))]
|
|
(db/insert! conn :team-profile-rel params
|
|
{::db/return-keys false})))
|
|
|
|
;; Duplicate team fonts
|
|
(doseq [font fonts]
|
|
(let [params (-> font
|
|
(update :id bfc/lookup-index)
|
|
(update :team-id bfc/lookup-index)
|
|
(assoc :created-at timestamp)
|
|
(assoc :modified-at timestamp))]
|
|
(db/insert! conn :team-font-variant params
|
|
{::db/return-keys false})))
|
|
|
|
;; Duplicate projects; We don't reuse the `duplicate-project`
|
|
;; here because we handle files duplication by whole team
|
|
;; instead of by project and we want to preserve some project
|
|
;; props which are reset on the `duplicate-project` impl
|
|
(doseq [project-id projs]
|
|
(let [project (db/get conn :project {:id project-id})
|
|
project (-> project
|
|
(assoc :created-at timestamp)
|
|
(assoc :modified-at timestamp)
|
|
(update :id bfc/lookup-index)
|
|
(update :team-id bfc/lookup-index))]
|
|
(teams/create-project conn project)
|
|
|
|
;; The project profile creation is optional, so when no profile is
|
|
;; present (when this function is called from profile less
|
|
;; environment: SREPL) we just omit the creation of the relation
|
|
(when (uuid? profile-id)
|
|
(teams/create-project-role conn profile-id (:id project) :owner))))
|
|
|
|
(doseq [file-id files]
|
|
(let [params (-> params
|
|
(dissoc :name)
|
|
(assoc :file-id file-id)
|
|
(assoc :reset-shared-flag false))]
|
|
(duplicate-file cfg params)))
|
|
|
|
team)))
|
|
|
|
;; --- COMMAND: Move file
|
|
|
|
(def sql:get-files
|
|
"select id, features, project_id from file where id = ANY(?)")
|
|
|
|
(def sql:move-files
|
|
"update file set project_id = ? where id = ANY(?)")
|
|
|
|
(def sql:delete-broken-relations
|
|
"with broken as (
|
|
(select * from file_library_rel as flr
|
|
inner join file as f on (flr.file_id = f.id)
|
|
inner join project as p on (f.project_id = p.id)
|
|
inner join file as lf on (flr.library_file_id = lf.id)
|
|
inner join project as lp on (lf.project_id = lp.id)
|
|
where p.id = ANY(?)
|
|
and lp.team_id != p.team_id)
|
|
)
|
|
delete from file_library_rel as rel
|
|
using broken as br
|
|
where rel.file_id = br.file_id
|
|
and rel.library_file_id = br.library_file_id")
|
|
|
|
(defn move-files
|
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id ids project-id] :as params}]
|
|
|
|
(let [fids (db/create-array conn "uuid" ids)
|
|
files (->> (db/exec! conn [sql:get-files fids])
|
|
(map files/decode-row))
|
|
source (into #{} (map :project-id) files)
|
|
pids (->> (conj source project-id)
|
|
(db/create-array conn "uuid"))]
|
|
|
|
(when (contains? source project-id)
|
|
(ex/raise :type :validation
|
|
:code :cant-move-to-same-project
|
|
:hint "Unable to move a file to the same project"))
|
|
|
|
;; Check if we have permissions on the destination project
|
|
(proj/check-edition-permissions! conn profile-id project-id)
|
|
|
|
;; Check if we have permissions on all source projects
|
|
(doseq [project-id source]
|
|
(proj/check-edition-permissions! conn profile-id project-id))
|
|
|
|
;; Check the team compatibility
|
|
(let [orig-team (teams/get-team conn :profile-id profile-id :project-id (first source))
|
|
dest-team (teams/get-team conn :profile-id profile-id :project-id project-id)]
|
|
(cfeat/check-teams-compatibility! orig-team dest-team)
|
|
|
|
;; Check if all pending to move files are compaib
|
|
(let [features (cfeat/get-team-enabled-features cf/flags dest-team)]
|
|
(doseq [file files]
|
|
(cfeat/check-file-features! features (:features file)))))
|
|
|
|
;; move all files to the project
|
|
(db/exec-one! conn [sql:move-files project-id fids])
|
|
|
|
;; delete possible broken relations on moved files
|
|
(db/exec-one! conn [sql:delete-broken-relations pids])
|
|
|
|
;; Update the modification date of the all affected projects
|
|
;; ensuring that the destination project is the most recent one.
|
|
(doseq [project-id (into (list project-id) source)]
|
|
|
|
;; NOTE: as this is executed on virtual thread, sleeping does
|
|
;; not causes major issues, and allows an easy way to set a
|
|
;; trully different modification date to each file.
|
|
(px/sleep 10)
|
|
(db/update! conn :project
|
|
{:modified-at (dt/now)}
|
|
{:id project-id}))
|
|
|
|
nil))
|
|
|
|
(def ^:private
|
|
schema:move-files
|
|
(sm/define
|
|
[:map {:title "move-files"}
|
|
[:ids ::sm/set-of-uuid]
|
|
[:project-id ::sm/uuid]]))
|
|
|
|
(sv/defmethod ::move-files
|
|
"Move a set of files from one project to other."
|
|
{::doc/added "1.16"
|
|
::webhooks/event? true
|
|
::sm/params schema:move-files}
|
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
|
(db/tx-run! cfg #(move-files % (assoc params :profile-id profile-id))))
|
|
|
|
;; --- COMMAND: Move project
|
|
|
|
(defn move-project
|
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id project-id] :as params}]
|
|
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})
|
|
pids (->> (db/query conn :project {:team-id (:team-id project)} {:columns [:id]})
|
|
(map :id)
|
|
(db/create-array conn "uuid"))]
|
|
|
|
(when (= team-id (:team-id project))
|
|
(ex/raise :type :validation
|
|
:code :cant-move-to-same-team
|
|
:hint "Unable to move a project to same team"))
|
|
|
|
(teams/check-edition-permissions! conn profile-id (:team-id project))
|
|
(teams/check-edition-permissions! conn profile-id team-id)
|
|
|
|
;; Check the teams compatibility
|
|
(let [orig-team (teams/get-team conn :profile-id profile-id :team-id (:team-id project))
|
|
dest-team (teams/get-team conn :profile-id profile-id :team-id team-id)]
|
|
(cfeat/check-teams-compatibility! orig-team dest-team)
|
|
|
|
;; Check if all pending to move files are compaib
|
|
(let [features (cfeat/get-team-enabled-features cf/flags dest-team)]
|
|
(doseq [file (->> (db/query conn :file
|
|
{:project-id project-id}
|
|
{:columns [:features]})
|
|
(map files/decode-row))]
|
|
(cfeat/check-file-features! features (:features file)))))
|
|
|
|
;; move project to the destination team
|
|
(db/update! conn :project
|
|
{:team-id team-id}
|
|
{:id project-id})
|
|
|
|
;; delete possible broken relations on moved files
|
|
(db/exec-one! conn [sql:delete-broken-relations pids])
|
|
|
|
nil))
|
|
|
|
(def ^:private
|
|
schema:move-project
|
|
[:map {:title "move-project"}
|
|
[:team-id ::sm/uuid]
|
|
[:project-id ::sm/uuid]])
|
|
|
|
(sv/defmethod ::move-project
|
|
"Move projects between teams"
|
|
{::doc/added "1.16"
|
|
::webhooks/event? true
|
|
::sm/params schema:move-project}
|
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
|
(db/tx-run! cfg #(move-project % (assoc params :profile-id profile-id))))
|
|
|
|
;; --- COMMAND: Clone Template
|
|
|
|
(defn clone-template
|
|
[cfg {:keys [project-id profile-id] :as params} template]
|
|
(db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}]
|
|
;; NOTE: the importation process performs some operations that
|
|
;; are not very friendly with virtual threads, and for avoid
|
|
;; unexpected blocking of other concurrent operations we
|
|
;; dispatch that operation to a dedicated executor.
|
|
(let [cfg (-> cfg
|
|
(assoc ::bf.v1/project-id project-id)
|
|
(assoc ::bf.v1/profile-id profile-id))
|
|
result (px/invoke! executor (partial bf.v1/import-files! cfg template))]
|
|
|
|
(db/update! conn :project
|
|
{:modified-at (dt/now)}
|
|
{:id project-id})
|
|
|
|
(let [props (audit/clean-props params)]
|
|
(doseq [file-id result]
|
|
(let [props (assoc props :id file-id)
|
|
event (-> (audit/event-from-rpc-params params)
|
|
(assoc ::audit/profile-id profile-id)
|
|
(assoc ::audit/name "create-file")
|
|
(assoc ::audit/props props))]
|
|
(audit/submit! cfg event))))
|
|
|
|
result))))
|
|
|
|
(def ^:private
|
|
schema:clone-template
|
|
[:map {:title "clone-template"}
|
|
[:project-id ::sm/uuid]
|
|
[:template-id ::sm/word-string]])
|
|
|
|
(sv/defmethod ::clone-template
|
|
"Clone into the specified project the template by its id."
|
|
{::doc/added "1.16"
|
|
::sse/stream? true
|
|
::webhooks/event? true
|
|
::sm/params schema:clone-template}
|
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}]
|
|
(let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]})
|
|
_ (teams/check-edition-permissions! pool profile-id (:team-id project))
|
|
template (tmpl/get-template-stream cfg template-id)
|
|
params (assoc params :profile-id profile-id)]
|
|
|
|
(when-not template
|
|
(ex/raise :type :not-found
|
|
:code :template-not-found
|
|
:hint "template not found"))
|
|
|
|
(sse/response #(clone-template cfg params template))))
|
|
|
|
;; --- COMMAND: Get list of builtin templates
|
|
|
|
(sv/defmethod ::get-builtin-templates
|
|
{::doc/added "1.19"}
|
|
[cfg _params]
|
|
(mapv #(select-keys % [:id :name]) (::setup/templates cfg)))
|