Andrey Antukh 367e4d534c
🐛 Scope assemble-chunks session lookup to profile-id (#11012)
Prevent BOLA in chunked upload assembly by verifying session
ownership. The assemble-chunks function now requires a profile-id
parameter and scopes the upload_session lookup accordingly, matching
the pattern already used by upload-chunk.

All three callers (assemble-file-media-object, create-font-variant,
import-binfile) updated to pass the authenticated profile-id.

AI-assisted-by: mimo-v2.5-pro
2026-08-18 14:36:29 +02:00

435 lines
16 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.fonts
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.font :as types.font]
[app.common.uuid :as uuid]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.features.logical-deletion :as ldel]
[app.http :as-alias http]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.media :as media]
[app.media.validation :as media.v]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
[app.rpc.commands.media :refer [assemble-chunks]]
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
java.io.OutputStream
java.util.zip.ZipEntry
java.util.zip.ZipOutputStream))
(set! *warn-on-reflection* true)
(def valid-weight #{100 200 300 400 500 600 700 800 900 950})
(def valid-style #{"normal" "italic"})
;; --- QUERY: Get font variants
(def ^:private
schema:get-font-variants
[:and
[:map {:title "get-font-variants"}
[:team-id {:optional true} ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:project-id {:optional true} ::sm/uuid]
[:share-id {:optional true} ::sm/uuid]]
[::sm/contains-any #{:team-id :file-id :project-id}]])
(sv/defmethod ::get-font-variants
{::doc/added "1.18"
::sm/params schema:get-font-variants}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id file-id project-id share-id] :as params}]
(dm/with-open [conn (db/open pool)]
(cond
(uuid? team-id)
(do
(teams/check-read-permissions! cfg profile-id team-id)
(db/query conn :team-font-variant
{:team-id team-id
:deleted-at nil}))
(uuid? project-id)
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})]
(projects/check-read-permissions! cfg profile-id project-id)
(db/query conn :team-font-variant
{:team-id (:team-id project)
:deleted-at nil}))
(uuid? file-id)
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]})
project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]})
perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
(files/check-read-permissions! perms)
(db/query conn :team-font-variant
{:team-id (:team-id project)
:deleted-at nil})))))
(declare create-font-variant)
(defn- check-font-team-ownership!
"When font-id already has variants belonging to a different team,
raises :not-found to prevent cross-team font injection."
[conn team-id font-id]
(let [row (db/get* conn :team-font-variant
{:font-id font-id}
{::db/columns [:team-id]})]
(when (and row (not= (:team-id row) team-id))
(ex/raise :type :not-found
:code :object-not-found
:hint "font does not belong to this team"))))
(def ^:private schema:create-font-variant
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:font-id ::sm/uuid]
[:font-family types.font/schema:font-family]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:uploads [:map-of ::sm/text ::sm/uuid]]])
(defn- prepare-font-data-from-uploads
"Assembles each chunked-upload session in `uploads` (a `{mtype →
session-id}` map) into a temp file, validates the media type and
size of every entry, and returns a `{mtype → path}` data map."
[cfg {:keys [::rpc/profile-id uploads] :as params}]
(let [data (reduce-kv
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg profile-id session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media.v/validate-media-type! cm/font-types)
(media.v/validate-font-size!))
(assoc acc mtype (:path assembled))))
{}
uploads)]
(-> params
(assoc :data data)
(dissoc :uploads))))
(sv/defmethod ::create-font-variant
"Upload a font variant. Font data must be provided as an `:uploads`
map (keyed by mime-type, values are upload-session UUIDs from the
chunked-upload API)."
{::doc/added "1.18"
::doc/changes [["2.16" "Add :uploads param for chunked upload support"]
["2.18" "Remove :data param, use :uploads exclusively"]]
::climit/id [[:process-font/by-profile ::rpc/profile-id]
[:process-font/global]]
::webhooks/event? true
::sm/params schema:create-font-variant}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id] :as params}]
(teams/check-edition-permissions! pool profile-id team-id)
(check-font-team-ownership! pool team-id font-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [params (db/tx-run! cfg prepare-font-data-from-uploads params)]
(create-font-variant cfg (assoc params :profile-id profile-id))))
(defn create-font-variant
[{:keys [::sto/storage] :as cfg} {:keys [data] :as params}]
(letfn [(generate-missing [data]
(let [data (media/run cfg {:cmd :generate-fonts :input data})]
(when (and (not (contains? data "font/otf"))
(not (contains? data "font/ttf"))
(not (contains? data "font/woff"))
(not (contains? data "font/woff2")))
(ex/raise :type :validation
:code :invalid-font-upload
:hint "invalid font upload, unable to generate missing font assets"))
data))
(prepare-font [data mtype]
(when-let [resource (get data mtype)]
(let [hash (sto/calculate-hash resource)
content (-> (sto/content resource)
(sto/wrap-with-hash hash))]
{::sto/content content
::sto/touched-at (ct/now)
::sto/deduplicate? true
:content-type mtype
:bucket "team-font-variant"})))
(persist-fonts-files! [data]
(into {} (keep (fn [[kind mtype]]
(when-let [params (prepare-font data mtype)]
[kind (sto/put-object! storage params)])))
[[:otf "font/otf"]
[:ttf "font/ttf"]
[:woff1 "font/woff"]
[:woff2 "font/woff2"]]))
(insert-font-variant! [conn {:keys [woff1 woff2 otf ttf]}]
(db/insert! conn :team-font-variant
{:id (uuid/next)
:team-id (:team-id params)
:font-id (:font-id params)
:font-family (:font-family params)
:font-weight (:font-weight params)
:font-style (:font-style params)
:variant-name (:variant-name params)
:woff1-file-id (:id woff1)
:woff2-file-id (:id woff2)
:otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))]
(let [tpoint (ct/tpoint)
mtypes (vec (keys data))
total-size (reduce-kv (fn [acc _ content]
(+ acc (fs/size content)))
0
data)]
(l/dbg :hint "create-font-variant"
:step "init"
:font-family (:font-family params)
:font-weight (:font-weight params)
:font-style (:font-style params)
:mtypes (str/join mtypes ",")
:size total-size)
(let [data (generate-missing data)
assets (persist-fonts-files! data)
result (db/tx-run! cfg #(insert-font-variant! (::db/conn %) assets))
elapsed (tpoint)]
(l/dbg :hint "create-font-variant"
:step "end"
:font-family (:font-family params)
:font-weight (:font-weight params)
:font-style (:font-style params)
:mtypes (str/join mtypes ",")
:size total-size
:elapsed (ct/format-duration elapsed))
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))))
;; --- UPDATE FONT FAMILY
(def ^:private
schema:update-font
[:map {:title "update-font"}
[:team-id ::sm/uuid]
[:id ::sm/uuid]
[:name types.font/schema:font-family]])
(sv/defmethod ::update-font
{::doc/added "1.18"
::webhooks/event? true
::sm/params schema:update-font}
[cfg {:keys [::rpc/profile-id team-id id name]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(teams/check-edition-permissions! conn profile-id team-id)
(db/update! conn :team-font-variant
{:font-family name}
{:font-id id
:team-id team-id})
(rph/with-meta (rph/wrap nil)
{::audit/replace-props {:id id
:name name
:team-id team-id
:profile-id profile-id}}))))
;; --- DELETE FONT
(def ^:private
schema:delete-font
[:map {:title "delete-font"}
[:team-id ::sm/uuid]
[:id ::sm/uuid]])
(sv/defmethod ::delete-font
{::doc/added "1.18"
::webhooks/event? true
::sm/params schema:delete-font
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id]}]
(let [team (teams/get-team conn
:profile-id profile-id
:team-id team-id)
fonts (db/query conn :team-font-variant
{:team-id team-id
:font-id id
:deleted-at nil}
{::sql/for-update true})
delay (ldel/get-deletion-delay team)
tnow (ct/in-future delay)]
(teams/check-edition-permissions! (:permissions team))
(when-not (seq fonts)
(ex/raise :type :not-found
:code :object-not-found))
(doseq [font fonts]
(db/update! conn :team-font-variant
{:deleted-at tnow}
{:id (:id font)}
{::db/return-keys false}))
(rph/with-meta (rph/wrap)
{::audit/props {:id id
:team-id team-id
:name (:font-family (peek fonts))
:profile-id profile-id}})))
;; --- DELETE FONT VARIANT
(def ^:private schema:delete-font-variant
[:map {:title "delete-font-variant"}
[:team-id ::sm/uuid]
[:id ::sm/uuid]])
(sv/defmethod ::delete-font-variant
{::doc/added "1.18"
::webhooks/event? true
::sm/params schema:delete-font-variant
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id]}]
(let [team (teams/get-team conn
:profile-id profile-id
:team-id team-id)
variant (db/get conn :team-font-variant
{:id id :team-id team-id}
{::sql/for-update true})
delay (ldel/get-deletion-delay team)]
(teams/check-edition-permissions! (:permissions team))
(db/update! conn :team-font-variant
{:deleted-at (ct/in-future delay)}
{:id (:id variant)}
{::db/return-keys false})
(rph/with-meta (rph/wrap)
{::audit/props {:font-family (:font-family variant)
:font-id (:font-id variant)}})))
;; --- DOWNLOAD FONT
(defn- make-temporal-storage-object
[cfg profile-id content]
(let [storage (sto/resolve cfg)
content (media.v/check-input content)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
mtype (:mtype content "application/octet-stream")
content {::sto/content data
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 30})
:profile-id profile-id
:content-type mtype
:bucket "tempfile"}]
(sto/put-object! storage content)))
(defn- make-variant-filename
[v mtype]
(str (:font-family v) "-" (:font-weight v)
(when-not (= "normal" (:font-style v)) (str "-" (:font-style v)))
(cm/mtype->extension mtype)))
(def ^:private schema:download-font
[:map {:title "download-font"}
[:id ::sm/uuid]])
(sv/defmethod ::download-font
"Download the font file. Returns a http redirect to the asset resource uri."
{::doc/added "2.15"
::sm/params schema:download-font}
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(let [variant (db/get pool :team-font-variant {:id id})]
(teams/check-read-permissions! cfg profile-id (:team-id variant))
;; Try to get the best available font format (prefer TTF for broader compatibility).
(let [media-id (or (:ttf-file-id variant)
(:otf-file-id variant)
(:woff2-file-id variant)
(:woff1-file-id variant))
sobj (sto/get-object storage media-id)
mtype (-> sobj meta :content-type)]
{:id (:id sobj)
:uri (files/resolve-public-uri (:id sobj))
:name (make-variant-filename variant mtype)})))
(def ^:private schema:download-font-family
[:map {:title "download-font-family"}
[:font-id ::sm/uuid]])
(sv/defmethod ::download-font-family
"Download the entire font family as a zip file. Returns the zip
bytes on the body, without encoding it on transit or json."
{::doc/added "2.15"
::sm/params schema:download-font-family}
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}]
(let [variants (db/query pool :team-font-variant
{:font-id font-id
:deleted-at nil})]
(when-not (seq variants)
(ex/raise :type :not-found
:code :object-not-found))
(teams/check-read-permissions! cfg profile-id (:team-id (first variants)))
(let [tempfile (tmp/tempfile :suffix ".zip")
ffamily (-> variants first :font-family)]
(with-open [^OutputStream output (io/output-stream tempfile)
^OutputStream output (ZipOutputStream. output)]
(doseq [v variants]
(let [media-id (or (:ttf-file-id v)
(:otf-file-id v)
(:woff2-file-id v)
(:woff1-file-id v))
sobj (sto/get-object storage media-id)
mtype (-> sobj meta :content-type)
name (make-variant-filename v mtype)]
(with-open [input (sto/get-object-data storage sobj)]
(.putNextEntry ^ZipOutputStream output (ZipEntry. ^String name))
(io/copy input output :size (:size sobj))
(.closeEntry ^ZipOutputStream output)))))
(let [{:keys [id] :as sobj} (make-temporal-storage-object cfg profile-id
{:mtype "application/zip"
:path tempfile})]
{:id id
:uri (files/resolve-public-uri id)
:name (str ffamily ".zip")}))))