penpot/backend/src/app/http/assets.clj
2026-05-29 11:24:58 +02:00

145 lines
4.9 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.http.assets
"Assets related handlers."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.common.uri :as u]
[app.db :as db]
[app.http.access-token :as actoken]
[app.http.session :as session]
[app.storage :as sto]
[integrant.core :as ig]
[yetti.response :as-alias yres]))
(def ^:private default-cache-max-age
(ct/duration {:hours 24}))
(def ^:private default-signature-max-age
(ct/duration {:hours 24 :minutes 15}))
;; Buckets that are legitimately public and do not require authentication.
;; These are used by public shared board viewing, profile photos in UI,
;; and embedded export/binfile flows.
(def ^:private public-buckets
#{"file-media-object"
"file-object-thumbnail"
"team-font-variant"
"file-data-fragment"})
(defn get-id
[{:keys [path-params]}]
(or (some-> path-params :id d/parse-uuid)
(ex/raise :type :not-found
:hint "object not found")))
(defn- get-file-media-object
[pool id]
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
(let [sig-max-age (or signature-max-age default-signature-max-age)
cch-max-age (or cache-max-age default-cache-max-age)
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
{::yres/status 307
::yres/headers {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
(defn- serve-object-from-fs
[{:keys [::path ::cache-max-age]} obj]
(let [cch-max-age (or cache-max-age default-cache-max-age)
purl (u/join (u/uri path)
(sto/object->relative-path obj))
mdata (meta obj)
headers {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
{::yres/status 204
::yres/headers headers}))
(defn- serve-object
"Helper function that returns the appropriate response depending on
the storage object backend type."
[cfg {:keys [backend] :as obj}]
(case backend
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
(defn- requires-auth?
"Check if the storage object requires authentication based on its bucket."
[obj]
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
(defn- authenticated?
"Check if the request has an authenticated profile, either via session
or access token."
[request]
(or (some? (::session/profile-id request))
(some? (::actoken/profile-id request))))
(defn objects-handler
"Handler that serves storage objects by id.
For non-public buckets (e.g. profile), requires authentication
via session cookie or access token."
[{:keys [::sto/storage] :as cfg} request]
(let [id (get-id request)
obj (sto/get-object storage id)]
(cond
(nil? obj)
{::yres/status 404}
(and (requires-auth? obj)
(not (authenticated? request)))
{::yres/status 401}
:else
(serve-object cfg obj))))
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
[{:keys [::sto/storage] :as cfg} request kf]
(let [pool (::db/pool storage)
id (get-id request)
mobj (get-file-media-object pool id)
sobj (sto/get-object storage (kf mobj))]
(if sobj
(serve-object cfg sobj)
{::yres/status 404})))
(defn file-objects-handler
"Handler that serves storage objects by file media id."
[cfg request]
(generic-handler cfg request :media-id))
(defn file-thumbnails-handler
"Handler that serves storage objects by thumbnail-id and quick
fallback to file-media-id if no thumbnail is available."
[cfg request]
(generic-handler cfg request #(or (:thumbnail-id %) (:media-id %))))
;; --- Initialization
(defmethod ig/assert-key ::routes
[_ params]
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
(assert (session/manager? (::session/manager params)) "expected valid session manager")
(assert (string? (::path params))))
(defmethod ig/init-key ::routes
[_ cfg]
["/assets" {:middleware [[session/authz cfg]
[actoken/authz cfg]]}
["/by-id/:id" {:handler (partial objects-handler cfg)}]
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])