mirror of
https://github.com/penpot/penpot.git
synced 2026-08-23 21:28:38 +00:00
Add sanitize-svg function that removes dangerous elements and attributes: - script tags - foreignObject elements - Event handler attributes (onload, onmouseover, etc.) - javascript: URLs from href/xlink:href attributes Apply sanitization in process-main-image before storing SVG files. AI-assisted-by: mimo-v2.5-pro
265 lines
11 KiB
Clojure
265 lines
11 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.media.remote
|
|
"Remote media processing via the media-processor HTTP service."
|
|
(:require
|
|
[app.common.exceptions :as ex]
|
|
[app.common.media :as cm]
|
|
[app.common.time :as ct]
|
|
[app.common.uri :as uri]
|
|
[app.config :as cf]
|
|
[app.http.client :as http]
|
|
[app.media.svg :as svg]
|
|
[app.media.validation :as validation]
|
|
[app.setup :as-alias setup]
|
|
[app.storage.tmp :as tmp]
|
|
[app.util.json :as json]
|
|
[cuerdas.core :as str]
|
|
[datoteka.fs :as fs]
|
|
[datoteka.io :as io])
|
|
(:import
|
|
java.io.ByteArrayInputStream
|
|
java.io.InputStream
|
|
java.io.SequenceInputStream
|
|
java.net.ConnectException
|
|
java.net.http.HttpTimeoutException
|
|
java.util.Collections))
|
|
|
|
(defn- service-base-url
|
|
"Returns the base URL of the media-processor service."
|
|
[]
|
|
(or (cf/get :media-processing-service-uri)
|
|
(ex/raise :type :internal
|
|
:code :media-processor-not-configured
|
|
:hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured")))
|
|
|
|
(defn- service-timeout
|
|
"Returns the HTTP timeout (ms) for media-processor requests."
|
|
[]
|
|
(or (cf/get :media-processing-service-timeout)
|
|
120000))
|
|
|
|
(defn- get-shared-key
|
|
"Returns the shared key for authenticating with the media-processor."
|
|
[system]
|
|
(-> system ::setup/shared-keys :media-processor))
|
|
|
|
(defn- parse-json-response
|
|
"Parse a JSON response body."
|
|
[body]
|
|
(json/read! body))
|
|
|
|
(defn- translate-error
|
|
"Translate a media-processor error response into a Penpot exception."
|
|
[status body]
|
|
(let [code (or (:code body) "media-processor-error")
|
|
hint (or (:hint body) "media-processor request failed")]
|
|
(case status
|
|
400 {:type :validation :code (keyword code) :hint hint}
|
|
403 {:type :authorization :code :forbidden :hint hint}
|
|
413 {:type :restriction :code (keyword code) :hint hint}
|
|
504 {:type :internal :code :media-processor-timeout :hint hint}
|
|
{:type :internal :code (keyword code) :hint hint})))
|
|
|
|
(defn service-request
|
|
"Make an HTTP request to the media-processor service."
|
|
[system {:keys [method uri body headers timeout]}]
|
|
(let [client (::http/client system)
|
|
timeout (or timeout (service-timeout))]
|
|
(try
|
|
(let [resp (http/req client
|
|
{:method method
|
|
:uri uri
|
|
:body body
|
|
:headers headers}
|
|
{:response-type :input-stream
|
|
:skip-ssrf-check? true
|
|
:timeout timeout})
|
|
status (:status resp)]
|
|
(when (not (<= 200 status 299))
|
|
(let [body (:body resp)]
|
|
(try
|
|
(let [parsed (try (parse-json-response body) (catch Exception _ nil))
|
|
err (translate-error status parsed)]
|
|
(ex/raise :type (:type err) :code (:code err) :hint (:hint err)))
|
|
(finally
|
|
(.close body)))))
|
|
resp)
|
|
(catch ConnectException _cause
|
|
(ex/raise :type :internal
|
|
:code :media-processor-unavailable
|
|
:hint "Cannot connect to media-processor service"))
|
|
(catch HttpTimeoutException _cause
|
|
(ex/raise :type :internal
|
|
:code :media-processor-timeout
|
|
:hint "media-processor service request timed out")))))
|
|
|
|
(defn- multipart-boundary
|
|
[]
|
|
(str "----PenpotBoundary" (System/currentTimeMillis)))
|
|
|
|
(defn- build-multipart-stream
|
|
"Build a streaming multipart/form-data body with a single file field.
|
|
Returns an InputStream that lazily reads from the file on demand."
|
|
[^String boundary mtype ^InputStream file-stream]
|
|
(let [header (.getBytes (str "--" boundary "\r\n"
|
|
"Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
|
|
"Content-Type: " mtype "\r\n"
|
|
"\r\n")
|
|
"UTF-8")
|
|
footer (.getBytes (str "\r\n--" boundary "--\r\n")
|
|
"UTF-8")
|
|
parts (Collections/enumeration
|
|
[(ByteArrayInputStream. header)
|
|
file-stream
|
|
(ByteArrayInputStream. footer)])]
|
|
(SequenceInputStream. parts)))
|
|
|
|
(defn- service-multipart-request
|
|
"Send a multipart request to the media-processor service.
|
|
Accepts a file from disk via :path. The file stream is closed
|
|
after the HTTP request completes (success or failure)."
|
|
[system {:keys [endpoint path mtype query timeout]}]
|
|
(let [shared-key (get-shared-key system)
|
|
boundary (multipart-boundary)
|
|
ctype (or mtype "application/octet-stream")
|
|
base-url (service-base-url)
|
|
request-uri (cond-> (uri/join base-url endpoint)
|
|
(seq query)
|
|
(str "?" (uri/map->query-string query)))]
|
|
(with-open [file-stream (io/input-stream path)]
|
|
(let [body (build-multipart-stream boundary ctype file-stream)]
|
|
(service-request system
|
|
{:method :post
|
|
:uri request-uri
|
|
:body body
|
|
:headers {"Content-Type" (str "multipart/form-data; boundary=" boundary)
|
|
"x-shared-key" shared-key}
|
|
:timeout timeout})))))
|
|
|
|
(def ^:private known-font-types
|
|
"Priority-ordered list of font mime-types the system knows how to convert.
|
|
Order matters: when a font upload contains multiple variants, the first
|
|
match becomes the conversion source (ttf preferred for best coverage)."
|
|
["font/ttf" "font/otf" "font/woff" "font/woff2"])
|
|
|
|
(defn- font-convert
|
|
"Convert a font to the given target mime-type via the media-processor service.
|
|
Accepts source font data as a filesystem Path. Returns a tempfile Path."
|
|
[system source-mtype target-mtype data]
|
|
(let [resp (service-multipart-request system {:endpoint "api/font/convert"
|
|
:path data
|
|
:mtype source-mtype
|
|
:query {:target-type target-mtype}
|
|
:timeout 180000})
|
|
ext (cm/mtype->extension target-mtype)
|
|
tmp (tmp/tempfile :prefix "penpot.font." :suffix ext)
|
|
body (:body resp)]
|
|
(try
|
|
(io/write* tmp body)
|
|
(finally
|
|
(.close body)))
|
|
tmp))
|
|
|
|
(defn- font-missing-variants
|
|
"Return the set of target mime-types that should be generated for the given
|
|
source mime-type (excluding font/woff2, which is never generated)."
|
|
[source-mtype]
|
|
(case source-mtype
|
|
"font/ttf" #{"font/otf" "font/woff"}
|
|
"font/otf" #{"font/ttf" "font/woff"}
|
|
"font/woff" #{"font/ttf" "font/otf"}
|
|
"font/woff2" #{"font/ttf" "font/otf" "font/woff"}))
|
|
|
|
(defmulti process (fn [_system params] (:cmd params)))
|
|
|
|
(defmethod process :info
|
|
[system {:keys [input]}]
|
|
(let [{:keys [path mtype]} (validation/check-input input)]
|
|
(if (= mtype "image/svg+xml")
|
|
;; SVG: parse locally (Sharp doesn't support SVG)
|
|
(let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
|
|
(when-not info
|
|
(ex/raise :type :validation
|
|
:code :invalid-svg-file
|
|
:hint "uploaded svg does not provide dimensions"))
|
|
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
|
;; Raster: delegate to media-processor
|
|
(let [resp (service-multipart-request system {:endpoint "api/image/info"
|
|
:path path
|
|
:mtype mtype})
|
|
body (:body resp)]
|
|
(try
|
|
(let [info (parse-json-response body)
|
|
detected-mtype (:mtype info)]
|
|
(when (and (string? mtype)
|
|
(string? detected-mtype)
|
|
(not= (str/lower mtype) (str/lower detected-mtype)))
|
|
(ex/raise :type :validation
|
|
:code :media-type-mismatch
|
|
:hint (str "File content does not match the declared type. "
|
|
"Expected: " mtype ". Got: " detected-mtype)))
|
|
(assoc input
|
|
:width (:width info)
|
|
:height (:height info)
|
|
:size (fs/size path)
|
|
:ts (ct/now)))
|
|
(finally
|
|
(.close body)))))))
|
|
|
|
(defn- thumbnail-request
|
|
"Shared implementation for generic-thumbnail and profile-thumbnail."
|
|
[system params mode]
|
|
(let [{:keys [input format quality width height]} params
|
|
{:keys [path mtype]} (validation/check-input input)
|
|
fmt (name (or format (cm/mtype->format mtype) :jpeg))
|
|
resp (service-multipart-request system {:endpoint "api/image/thumbnail"
|
|
:path path
|
|
:mtype mtype
|
|
:query {:width width
|
|
:height height
|
|
:quality quality
|
|
:format fmt
|
|
:mode mode}})
|
|
out-format (or format (cm/mtype->format mtype) :jpeg)
|
|
ext (cm/format->extension out-format)
|
|
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
|
body (:body resp)]
|
|
(try
|
|
(io/write* tmp body)
|
|
(finally
|
|
(.close body)))
|
|
(assoc params
|
|
:format out-format
|
|
:mtype (cm/format->mtype out-format)
|
|
:size (fs/size tmp)
|
|
:data tmp)))
|
|
|
|
(defmethod process :generic-thumbnail
|
|
[system params]
|
|
(thumbnail-request system params "fit"))
|
|
|
|
(defmethod process :profile-thumbnail
|
|
[system params]
|
|
(thumbnail-request system params "crop"))
|
|
|
|
(defmethod process :generate-fonts
|
|
[system {:keys [input]}]
|
|
(let [source-mtype (or (some #(when (contains? input %) %) known-font-types)
|
|
(ex/raise :type :validation
|
|
:code :invalid-font
|
|
:hint "No recognized font variant in input"))
|
|
data (get input source-mtype)
|
|
present (set (keys input))
|
|
targets (remove present (font-missing-variants source-mtype))]
|
|
(reduce (fn [acc target-mtype]
|
|
(assoc acc target-mtype
|
|
(font-convert system source-mtype target-mtype data)))
|
|
input
|
|
targets)))
|
|
|