diff --git a/.gitignore b/.gitignore index 76da22b35f..382b89c92f 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ opencode.json /blob-report/ /playwright/.cache/ /render-wasm/target/ +/media-processor/dist/ /**/node_modules /**/.yarn/* /.pnpm-store diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index b032c97180..117e8ff464 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply - `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`. - `library/`: design library workflows; core conventions: `mem:library/core`. - `docs/`: documentation site; core workflow and conventions: `mem:docs/core`. +- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`. The memory is structured in a way that you can get the critical information about the module. You can read it from `mem:/core` diff --git a/.serena/memories/media-processor/core.md b/.serena/memories/media-processor/core.md new file mode 100644 index 0000000000..209d4b1a37 --- /dev/null +++ b/.serena/memories/media-processor/core.md @@ -0,0 +1,100 @@ +# Media Processor + +Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools). + +## Tech Stack + +- Language: TypeScript +- Runtime: Node.js +- Framework: Express +- Image processing: sharp (libvips) +- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress +- Upload handling: multer (hybrid storage: memory for small, disk for large) +- Logging: pino (with optional Loki transport) +- Config validation: Zod +- Testing: Vitest +- Package Manager: pnpm + +## Project Structure + +``` +media-processor/ +├── src/ +│ ├── index.ts # Express app setup, routes, middleware +│ ├── config.ts # Zod-validated env config, HKDF key derivation +│ ├── types.ts # TypeScript type definitions +│ ├── upload.ts # Multer configuration, getFileBuffer helper +│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold) +│ ├── logger.ts # Pino logger setup +│ ├── middleware/ +│ │ ├── auth.ts # Timing-safe shared key authentication +│ │ ├── error-handler.ts # ProcessingError class, centralized error handling +│ │ └── timeout.ts # Request timeout middleware +│ ├── routes/ +│ │ ├── health.ts # GET /api/health +│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail +│ │ └── font.ts # POST /api/font/convert +│ └── services/ +│ ├── image.ts # sharp-based image info/thumbnail generation +│ ├── font.ts # FontForge/woff-tools font conversion +│ └── errors.ts # throwValidation, throwRestriction, throwProcessing +├── test/ # Vitest test files +├── vitest.config.ts # Test configuration +├── tsconfig.json # TypeScript configuration +├── esbuild.config.mjs # Build configuration +└── package.json # Dependencies and scripts +``` + +## Key Conventions + +### Auth +- Requests authenticated via `x-shared-key` header using timing-safe comparison +- When no key configured, all requests rejected with 403 +- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY` + +### Resource Limits +- Image: max pixels, max width/height enforced before processing +- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits +- Concurrency: p-queue limits concurrent requests (default 10) +- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD` +- Max file size: configurable (default 350MB) + +### Error Handling +- `throwValidation(code, hint)` — 400 errors for invalid input +- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded +- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills) + +### Image Processing +- EXIF orientation applied before dimension validation and thumbnail generation +- sharp caching disabled to prevent unbounded memory growth +- `withoutEnlargement: true` prevents upscaling small images + +### Font Conversion +- Supported formats: TTF, OTF, WOFF, WOFF2 +- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF) +- Temp files cleaned up in finally blocks (best-effort) + +## Commands + +All commands run from `media-processor/` directory: + +- `pnpm run test` — Run Vitest test suite +- `pnpm run types:check` — TypeScript type checking (tsc --noEmit) +- `pnpm run fmt` — Format code with Prettier +- `pnpm run fmt:check` — Check formatting without modifying +- `pnpm run build` — Build for production (esbuild) +- `pnpm run start:dev` — Start development server (tsx) + +## Docker + +- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`) +- Must be deployed on internal Docker network only (not public-facing) +- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI` + +## Testing Principles + +Cross-cutting testing principles and anti-patterns: `mem:testing`. + +- Run `pnpm run test` after changes +- Run `pnpm run types:check` after TypeScript changes +- Run `pnpm run fmt:check` before commits diff --git a/backend/scripts/_env b/backend/scripts/_env index 120bb648bc..04dcf1a724 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -4,6 +4,7 @@ export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key export PENPOT_SECRET_KEY=super-secret-devenv-key +export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key # DEPRECATED: only used for subscriptions export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key @@ -21,6 +22,8 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then __worker_flag="enable-backend-worker" fi +export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065 + export PENPOT_FLAGS="\ $PENPOT_FLAGS \ enable-login-with-password \ @@ -36,6 +39,7 @@ export PENPOT_FLAGS="\ enable-feature-fdata-objects-map \ enable-audit-log \ enable-transit-readable-response \ + disable-remote-media-processing \ enable-demo-users \ enable-user-feedback \ disable-secure-session-cookies \ diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 27ca224a88..bebd5db826 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -121,6 +121,7 @@ [:exporter-shared-key {:optional true} :string] [:admin-console-shared-key {:optional true} :string] [:nexus-shared-key {:optional true} :string] + [:media-processor-shared-key {:optional true} :string] [:management-api-key {:optional true} :string] [:telemetry-uri {:optional true} :string] @@ -147,6 +148,9 @@ [:imagemagick-width-limit {:optional true} :string] [:imagemagick-height-limit {:optional true} :string] + [:media-processing-service-uri {:optional true} ::sm/uri] + [:media-processing-service-timeout {:optional true} ::sm/int] + [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 9f90d0b72f..743a17804b 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -335,6 +335,7 @@ ::rpc/rlimit (ig/ref ::rpc/rlimit) ::setup/templates (ig/ref ::setup/templates) ::setup/props (ig/ref ::setup/props) + ::setup/shared-keys (ig/ref ::setup/shared-keys) ::email/blacklist (ig/ref ::email/blacklist) ::email/whitelist (ig/ref ::email/whitelist) @@ -467,10 +468,11 @@ ::migrations (ig/ref :app.migrations/migrations)} ::setup/shared-keys - {::setup/props (ig/ref ::setup/props) - :nexus (cf/get :nexus-shared-key) - :admin-console (cf/get :admin-console-shared-key) - :exporter (cf/get :exporter-shared-key)} + {::setup/props (ig/ref ::setup/props) + :nexus (cf/get :nexus-shared-key) + :admin-console (cf/get :admin-console-shared-key) + :exporter (cf/get :exporter-shared-key) + :media-processor (cf/get :media-processor-shared-key)} ::setup/clock {} diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 30527857ad..dc23e3483c 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -5,316 +5,37 @@ ;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns app.media - "Media & Font postprocessing." + "Media & Font postprocessing. + + This namespace is the dispatch layer only. Processing implementations + live in two separate namespaces, each owning their own defmulti: + + app.media.local — shell/ImageMagick/FontForge implementations + app.media.remote — HTTP delegation to media-processor service + + Validation and schemas live in app.media.validation (leaf namespace, + no circular dep). When adding a new :cmd type, add defmethods in + BOTH local and remote." (:require [app.common.data :as d] - [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.schema.openapi :as-alias oapi] - [app.common.time :as ct] [app.config :as cf] [app.db :as-alias db] [app.http.client :as http] + [app.media.local :as media.local] + [app.media.remote :as media.remote] [app.media.sanitize :as sanitize] + [app.media.validation :as validation] [app.storage :as-alias sto] [app.storage.tmp :as tmp] - [app.util.shell :as shell] - [buddy.core.bytes :as bb] - [buddy.core.codecs :as bc] - [clojure.string] - [clojure.xml :as xml] [cuerdas.core :as str] - [datoteka.fs :as fs] - [datoteka.io :as io]) - (:import - clojure.lang.XMLHandler - java.io.InputStream - javax.xml.parsers.SAXParserFactory - javax.xml.XMLConstants - org.apache.commons.io.IOUtils)) - -(def schema:upload - [:map {:title "Upload"} - [:filename :string] - [:size ::sm/int] - [:path ::fs/path] - [:mtype {:optional true} :string] - [:headers {:optional true} - [:map-of :string :string]]]) - -(def ^:private schema:input - [:map {:title "Input"} - [:path ::fs/path] - [:mtype {:optional true} ::sm/text]]) - -(def check-input - (sm/check-fn schema:input)) - -(defn validate-media-type! - ([upload] (validate-media-type! upload cm/image-types)) - ([upload allowed] - (when-not (contains? allowed (:mtype upload)) - (ex/raise :type :validation - :code :media-type-not-allowed - :hint "Seems like you are uploading an invalid media object")) - - upload)) - -(defn validate-media-size! - [upload] - (let [max-size (cf/get :media-max-file-size)] - (when (> (:size upload) max-size) - (ex/raise :type :restriction - :code :media-max-file-size-reached - :hint (str/ffmt "the uploaded file size % is greater than the maximum %" - (:size upload) - max-size))) - upload)) - -(defn validate-font-size! - "Validates that the font file `upload` does not exceed the configured - `:font-max-file-size` limit. Accepts the same map shape as - `validate-media-size!` — requires a `:size` key in bytes." - [upload] - (let [max-size (cf/get :font-max-file-size)] - (when (> (:size upload) max-size) - (ex/raise :type :restriction - :code :font-max-file-size-reached - :hint (str/ffmt "the uploaded font size % is greater than the maximum %" - (:size upload) - max-size))) - upload)) - -(defmulti process (fn [_system params] (:cmd params))) - -(defmethod process :default - [_system {:keys [cmd] :as params}] - (ex/raise :type :internal - :code :not-implemented - :hint (str/fmt "No impl found for process cmd: %s" cmd))) + [datoteka.io :as io])) (defn run [system params] - (process system params)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SVG PARSING -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- secure-parser-factory - [^InputStream input ^XMLHandler handler] - (.. (doto (SAXParserFactory/newInstance) - (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) - (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) - (newSAXParser) - (parse input handler))) - -(defn- strip-doctype - [data] - (cond-> data - (str/includes? data "]*>" ""))) - -(defn- parse-svg - [text] - (let [text (strip-doctype text)] - (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] - (xml/parse istream secure-parser-factory)))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; IMAGE THUMBNAILS -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(def ^:private schema:thumbnail-params - [:map {:title "ThumbnailParams"} - [:input schema:input] - [:format [:enum :jpeg :webp :png]] - [:quality [:int {:min 1 :max 100}]] - [:width :int] - [:height :int]]) - -(def ^:private check-thumbnail-params - (sm/check-fn schema:thumbnail-params)) - -;; Related info on how thumbnails generation -;; http://www.imagemagick.org/Usage/thumbnails/ - -(def ^:private imagemagick-default-env - "Default environment variables for ImageMagick resource limits. - These are the soft ceiling — policy.xml is the hard ceiling." - {"MAGICK_THREAD_LIMIT" "2" - "MAGICK_MEMORY_LIMIT" "256MiB" - "MAGICK_MAP_LIMIT" "512MiB" - "MAGICK_AREA_LIMIT" "128MP" - "MAGICK_DISK_LIMIT" "1GiB" - "MAGICK_TIME_LIMIT" "30"}) - -(defn- get-imagemagick-env - "Returns environment variables for ImageMagick commands. - Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults." - [] - (let [thread (cf/get :imagemagick-thread-limit) - memory (cf/get :imagemagick-memory-limit) - map-l (cf/get :imagemagick-map-limit) - area (cf/get :imagemagick-area-limit) - disk (cf/get :imagemagick-disk-limit) - time (cf/get :imagemagick-time-limit) - width (cf/get :imagemagick-width-limit) - height (cf/get :imagemagick-height-limit)] - (cond-> imagemagick-default-env - thread (assoc "MAGICK_THREAD_LIMIT" thread) - memory (assoc "MAGICK_MEMORY_LIMIT" memory) - map-l (assoc "MAGICK_MAP_LIMIT" map-l) - area (assoc "MAGICK_AREA_LIMIT" area) - disk (assoc "MAGICK_DISK_LIMIT" disk) - time (assoc "MAGICK_TIME_LIMIT" time) - width (assoc "MAGICK_WIDTH_LIMIT" width) - height (assoc "MAGICK_HEIGHT_LIMIT" height)))) - -(defn- exec-magick! - "Execute an ImageMagick command with resource limits. - `args` is a vector of string arguments to pass to `magick`." - [system args] - (let [cmd (into ["magick"] args) - result (shell/exec! system - :cmd cmd - :env (get-imagemagick-env) - :timeout 60)] - (when (not= 0 (:exit result)) - (ex/raise :type :validation - :code :invalid-image - :hint (str "ImageMagick command failed: " (:err result)) - :cmd cmd - :exit (:exit result))) - result)) - -(defn- generic-process - [system {:keys [input format convert-args] :as params}] - (let [{:keys [path mtype]} input - format (or format (cm/mtype->format mtype)) - ext (cm/format->extension format) - tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) - args (into [(str path)] (conj (vec convert-args) (str tmp)))] - (exec-magick! system args) - (assoc params - :format format - :mtype (cm/format->mtype format) - :size (fs/size tmp) - :data tmp))) - -(defmethod process :generic-thumbnail - [system params] - (let [{:keys [quality width height] :as params} - (check-thumbnail-params params)] - (generic-process system - (assoc params - :convert-args ["-auto-orient" "-strip" - "-thumbnail" (str width "x" height ">") - "-quality" (str quality)])))) - -(defmethod process :profile-thumbnail - [system params] - (let [{:keys [quality width height] :as params} - (check-thumbnail-params params)] - (generic-process system - (assoc params - :convert-args ["-auto-orient" "-strip" - "-thumbnail" (str width "x" height "^") - "-gravity" "center" - "-extent" (str width "x" height) - "-quality" (str quality)])))) - -(defn get-basic-info-from-svg - [{:keys [tag attrs] :as data}] - (when (not= tag :svg) - (ex/raise :type :validation - :code :unable-to-parse-svg - :hint "uploaded svg has invalid content")) - (reduce (fn [default f] - (if-let [res (f attrs)] - (reduced res) - default)) - {:width 100 :height 100} - [(fn parse-width-and-height - [{:keys [width height]}] - (when (and (string? width) - (string? height)) - (let [width (d/parse-double width) - height (d/parse-double height)] - (when (and width height) - {:width (int width) - :height (int height)})))) - (fn parse-viewbox - [{:keys [viewBox]}] - (let [[x y width height] (->> (str/split viewBox #"\s+" 4) - (map d/parse-double))] - (when (and x y width height) - {:width (int width) - :height (int height)})))])) - -(defn- get-dimensions-with-orientation [system ^String path] - ;; Image magick doesn't give info about exif rotation so we use the identify command - ;; If we are processing an animated gif we use the first frame with -scene 0 - (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path]) - orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])] - (when (= 0 (:exit dim-result)) - (let [[w h] (-> (:out dim-result) - str/trim - (clojure.string/split #"\s+") - (->> (mapv #(Integer/parseInt %)))) - orientation-exit (:exit orient-result) - orientation (-> orient-result :out str/trim)] - (if (= 0 orientation-exit) - (case orientation - ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees - {:width w :height h}) ; Normal or unknown orientation - {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is - -(defmethod process :info - [system {:keys [input] :as params}] - (let [{:keys [path mtype] :as input} (check-input input)] - (if (= mtype "image/svg+xml") - (let [info (some-> path slurp parse-svg get-basic-info-from-svg)] - (when-not info - (ex/raise :type :validation - :code :invalid-svg-file - :hint "uploaded svg does not provides dimensions")) - (merge input info {:ts (ct/now) :size (fs/size path)})) - - (let [path-str (str path) - identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str]) - ;; identify prints one line per frame (animated GIFs, etc.); we take the first one - mtype' (if (zero? (:exit identify-res)) - (-> identify-res - :out - str/trim - (str/split #"\s+" 2) - first - str/lower) - (ex/raise :type :validation - :code :invalid-image - :hint "invalid image")) - {:keys [width height]} - (or (get-dimensions-with-orientation system path-str) - (do - (l/warn "Failed to read image dimensions with orientation" {:path path}) - (ex/raise :type :validation - :code :invalid-image - :hint "invalid image")))] - (when (and (string? mtype) - (not= (str/lower mtype) mtype')) - (ex/raise :type :validation - :code :media-type-mismatch - :hint (str "Seems like you are uploading a file whose content does not match the extension." - "Expected: " mtype ". Got: " mtype'))) - (assoc input - :width width - :height height - :size (fs/size path) - :ts (ct/now)))))) + (if (contains? cf/flags :remote-media-processing) + (media.remote/process system params) + (media.local/process system params))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; IMAGE HELPERS @@ -338,8 +59,8 @@ :hint "seems like the url points to resource with unknown size")) (-> {:size size :mtype mtype} - (validate-media-type!) - (validate-media-size!))))] + (validation/validate-media-type!) + (validation/validate-media-size!))))] (let [{:keys [body] :as response} (try @@ -367,188 +88,24 @@ (ex/raise :type :validation :code :unable-to-download-image :hint (str/ffmt "unable to download image from '%': I/O error" uri) - :cause cause))) + :cause cause)))] - {:keys [size mtype]} (parse-and-validate response) - path (tmp/tempfile :prefix "penpot.media.download.") - written (io/write* path body :size size)] + (if body + (with-open [body body] + (let [{:keys [size mtype]} (parse-and-validate response) + path (tmp/tempfile :prefix "penpot.media.download.") + written (io/write* path body :size size)] - (when (not= written size) - (ex/raise :type :internal - :code :mismatch-write-size - :hint "unexpected state: unable to write to file")) + (when (not= written size) + (ex/raise :type :internal + :code :mismatch-write-size + :hint "unexpected state: unable to write to file")) - ;; Sanitize: strip trailing data after image EOF markers - (let [new-size (sanitize/truncate-after-eof path mtype)] - {:path path - :mtype mtype - :size new-size})))) + ;; Sanitize: strip trailing data after image EOF markers + (let [new-size (sanitize/truncate-after-eof path mtype)] + {:path path + :mtype mtype + :size new-size}))) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; FONTS -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- get-font-prlimit - "Returns resource limits for font processing tools, read from config." - [] - {:mem (cf/get :font-process-mem) - :cpu (cf/get :font-process-cpu)}) - -(defn- get-font-timeout - "Returns the wall-clock timeout for font processing, read from config." - [] - (cf/get :font-process-timeout)) - -(defn- exec-font! - "Execute a font processing command with resource limits. - `args` is a vector of string arguments." - [system args] - (shell/exec! system - :cmd args - :prlimit (get-font-prlimit) - :timeout (get-font-timeout))) - -(defmethod process :generate-fonts - [system {:keys [input] :as params}] - (letfn [(ttf->otf [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".otf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" - (str/fmt "Open('%s'); Generate('%s')" - (str finput) - (str foutput))])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (otf->ttf [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".ttf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" - (str/fmt "Open('%s'); Generate('%s')" - (str finput) - (str foutput))])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (ttf-or-otf->woff [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".woff"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["sfnt2woff" (str finput)])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (woff->sfnt [data] - (let [finput (tmp/tempfile :prefix "penpot" :suffix "")] - (try - (io/write* finput data) - (let [res (shell/exec! system - :cmd ["woff2sfnt" (str finput)] - :out-enc :bytes - :prlimit (get-font-prlimit) - :timeout (get-font-timeout))] - (when (zero? (:exit res)) - (:out res))) - (finally - (fs/delete finput))))) - - (woff2->sfnt [data] - ;; woff2_decompress outputs to same directory with .ttf extension - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") - foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["woff2_decompress" (str finput)])] - (if (zero? (:exit res)) - foutput - (do - (when (fs/exists? foutput) - (fs/delete foutput)) - nil))) - (finally - (fs/delete finput))))) - - ;; Documented here: - ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory - (get-sfnt-type [data] - (let [buff (bb/slice data 0 4) - type (bc/bytes->hex buff)] - (case type - "4f54544f" :otf - "00010000" :ttf - (ex/raise :type :internal - :code :unexpected-data - :hint "unexpected font data")))) - - (gen-if-nil [val factory] - (if (nil? val) - (factory) - val))] - - (let [current (into #{} (keys input))] - (cond - (contains? current "font/ttf") - (let [data (get input "font/ttf")] - (-> input - (update "font/otf" gen-if-nil #(ttf->otf data)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)))) - - (contains? current "font/otf") - (let [data (get input "font/otf")] - (-> input - (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)) - (assoc "font/ttf" (otf->ttf data)))) - - (contains? current "font/woff") - (let [data (get input "font/woff") - sfnt (woff->sfnt data)] - (when-not sfnt - (ex/raise :type :validation - :code :invalid-woff-file - :hint "invalid woff file")) - (let [stype (get-sfnt-type sfnt)] - (cond-> input - true - (-> (assoc "font/woff" data)) - - (= stype :otf) - (-> (assoc "font/otf" sfnt) - (assoc "font/ttf" (otf->ttf sfnt))) - - (= stype :ttf) - (-> (assoc "font/otf" (ttf->otf sfnt)) - (assoc "font/ttf" sfnt))))) - - (contains? current "font/woff2") - (let [data (get input "font/woff2") - foutput (woff2->sfnt data)] - (when-not foutput - (ex/raise :type :validation - :code :invalid-woff2-file - :hint "invalid woff2 file")) - (try - (let [sfnt (io/read* foutput) - type (get-sfnt-type sfnt)] - (cond-> input - (= type :otf) - (-> (assoc "font/otf" sfnt) - (assoc "font/ttf" (otf->ttf sfnt)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) - - (= type :ttf) - (-> (assoc "font/ttf" sfnt) - (assoc "font/otf" (ttf->otf sfnt)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) - (finally - (fs/delete foutput)))))))) + ;; No body - validation will raise appropriate error + (parse-and-validate response))))) diff --git a/backend/src/app/media/local.clj b/backend/src/app/media/local.clj new file mode 100644 index 0000000000..b53c5a5f6d --- /dev/null +++ b/backend/src/app/media/local.clj @@ -0,0 +1,426 @@ +;; 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.local + "Local media processing via ImageMagick and FontForge shell commands." + (:require + [app.common.data :as d] + [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.config :as cf] + [app.media.validation :as validation] + [app.storage.tmp :as tmp] + [app.util.shell :as shell] + [buddy.core.bytes :as bb] + [buddy.core.codecs :as bc] + [clojure.string] + [clojure.xml :as xml] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io]) + (:import + clojure.lang.XMLHandler + java.io.InputStream + javax.xml.parsers.SAXParserFactory + javax.xml.XMLConstants + org.apache.commons.io.IOUtils)) + +(defmulti process (fn [_system params] (:cmd params))) + +(defmethod process :default + [_system {:keys [cmd] :as params}] + (ex/raise :type :internal + :code :not-implemented + :hint (str/fmt "No impl found for local process cmd: %s" cmd))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG PARSING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- secure-parser-factory + [^InputStream input ^XMLHandler handler] + (.. (doto (SAXParserFactory/newInstance) + (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) + (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) + (newSAXParser) + (parse input handler))) + +(defn- strip-doctype + [data] + (cond-> data + (str/includes? data "]*>" ""))) + +(defn parse-svg + [text] + (let [text (strip-doctype text)] + (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] + (xml/parse istream secure-parser-factory)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMAGE THUMBNAILS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:thumbnail-params + [:map {:title "ThumbnailParams"} + [:input validation/schema:input] + [:format [:enum :jpeg :webp :png]] + [:quality [:int {:min 1 :max 100}]] + [:width :int] + [:height :int]]) + +(def ^:private check-thumbnail-params + (sm/check-fn schema:thumbnail-params)) + +;; Related info on how thumbnails generation +;; http://www.imagemagick.org/Usage/thumbnails/ + +(def ^:private imagemagick-default-env + "Default environment variables for ImageMagick resource limits. + These are the soft ceiling — policy.xml is the hard ceiling." + {"MAGICK_THREAD_LIMIT" "2" + "MAGICK_MEMORY_LIMIT" "256MiB" + "MAGICK_MAP_LIMIT" "512MiB" + "MAGICK_AREA_LIMIT" "128MP" + "MAGICK_DISK_LIMIT" "1GiB" + "MAGICK_TIME_LIMIT" "30"}) + +(defn- get-imagemagick-env + "Returns environment variables for ImageMagick commands. + Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults." + [] + (let [thread (cf/get :imagemagick-thread-limit) + memory (cf/get :imagemagick-memory-limit) + map-l (cf/get :imagemagick-map-limit) + area (cf/get :imagemagick-area-limit) + disk (cf/get :imagemagick-disk-limit) + time (cf/get :imagemagick-time-limit) + width (cf/get :imagemagick-width-limit) + height (cf/get :imagemagick-height-limit)] + (cond-> imagemagick-default-env + thread (assoc "MAGICK_THREAD_LIMIT" thread) + memory (assoc "MAGICK_MEMORY_LIMIT" memory) + map-l (assoc "MAGICK_MAP_LIMIT" map-l) + area (assoc "MAGICK_AREA_LIMIT" area) + disk (assoc "MAGICK_DISK_LIMIT" disk) + time (assoc "MAGICK_TIME_LIMIT" time) + width (assoc "MAGICK_WIDTH_LIMIT" width) + height (assoc "MAGICK_HEIGHT_LIMIT" height)))) + +(defn- exec-magick! + "Execute an ImageMagick command with resource limits. + `args` is a vector of string arguments to pass to `magick`." + [system args] + (let [cmd (into ["magick"] args) + result (shell/exec! system + :cmd cmd + :env (get-imagemagick-env) + :timeout 60)] + (when (not= 0 (:exit result)) + (ex/raise :type :validation + :code :invalid-image + :hint (str "ImageMagick command failed: " (:err result)) + :cmd cmd + :exit (:exit result))) + result)) + +(defn- generic-process + [system {:keys [input format convert-args] :as params}] + (let [{:keys [path mtype]} input + format (or format (cm/mtype->format mtype)) + ext (cm/format->extension format) + tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) + args (into [(str path)] (conj (vec convert-args) (str tmp)))] + (exec-magick! system args) + (assoc params + :format format + :mtype (cm/format->mtype format) + :size (fs/size tmp) + :data tmp))) + +(defmethod process :generic-thumbnail + [system params] + (let [{:keys [quality width height] :as params} + (check-thumbnail-params params)] + (generic-process system + (assoc params + :convert-args ["-auto-orient" "-strip" + "-thumbnail" (str width "x" height ">") + "-quality" (str quality)])))) + +(defmethod process :profile-thumbnail + [system params] + (let [{:keys [quality width height] :as params} + (check-thumbnail-params params)] + (generic-process system + (assoc params + :convert-args ["-auto-orient" "-strip" + "-thumbnail" (str width "x" height "^") + "-gravity" "center" + "-extent" (str width "x" height) + "-quality" (str quality)])))) + +(defn get-basic-info-from-svg + [{:keys [tag attrs] :as data}] + (when (not= tag :svg) + (ex/raise :type :validation + :code :unable-to-parse-svg + :hint "uploaded svg has invalid content")) + (reduce (fn [default f] + (if-let [res (f attrs)] + (reduced res) + default)) + {:width 100 :height 100} + [(fn parse-width-and-height + [{:keys [width height]}] + (when (and (string? width) + (string? height)) + (let [width (d/parse-double width) + height (d/parse-double height)] + (when (and width height) + {:width (int width) + :height (int height)})))) + (fn parse-viewbox + [{:keys [viewBox]}] + (let [[x y width height] (->> (str/split viewBox #"\s+" 4) + (map d/parse-double))] + (when (and x y width height) + {:width (int width) + :height (int height)})))])) + +(defn- get-dimensions-with-orientation [system ^String path] + ;; Image magick doesn't give info about exif rotation so we use the identify command + ;; If we are processing an animated gif we use the first frame with -scene 0 + (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path]) + orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])] + (when (= 0 (:exit dim-result)) + (let [[w h] (-> (:out dim-result) + str/trim + (clojure.string/split #"\s+") + (->> (mapv #(Integer/parseInt %)))) + orientation-exit (:exit orient-result) + orientation (-> orient-result :out str/trim)] + (if (= 0 orientation-exit) + (case orientation + ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees + {:width w :height h}) ; Normal or unknown orientation + {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is + +(defmethod process :info + [system {:keys [input] :as params}] + (let [{:keys [path mtype] :as input} (validation/check-input input)] + (if (= mtype "image/svg+xml") + (let [info (some-> path slurp parse-svg get-basic-info-from-svg)] + (when-not info + (ex/raise :type :validation + :code :invalid-svg-file + :hint "uploaded svg does not provides dimensions")) + (merge input info {:ts (ct/now) :size (fs/size path)})) + + (let [path-str (str path) + identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str]) + ;; identify prints one line per frame (animated GIFs, etc.); we take the first one + mtype' (if (zero? (:exit identify-res)) + (-> identify-res + :out + str/trim + (str/split #"\s+" 2) + first + str/lower) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")) + {:keys [width height]} + (or (get-dimensions-with-orientation system path-str) + (do + (l/warn "Failed to read image dimensions with orientation" {:path path}) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")))] + (when (and (string? mtype) + (not= (str/lower mtype) mtype')) + (ex/raise :type :validation + :code :media-type-mismatch + :hint (str "Seems like you are uploading a file whose content does not match the extension." + "Expected: " mtype ". Got: " mtype'))) + (assoc input + :width width + :height height + :size (fs/size path) + :ts (ct/now)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FONTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- get-font-prlimit + "Returns resource limits for font processing tools, read from config." + [] + {:mem (cf/get :font-process-mem) + :cpu (cf/get :font-process-cpu)}) + +(defn- get-font-timeout + "Returns the wall-clock timeout for font processing, read from config." + [] + (cf/get :font-process-timeout)) + +(defn- exec-font! + "Execute a font processing command with resource limits. + `args` is a vector of string arguments." + [system args] + (shell/exec! system + :cmd args + :prlimit (get-font-prlimit) + :timeout (get-font-timeout))) + +(defmethod process :generate-fonts + [system {:keys [input] :as params}] + (letfn [(ttf->otf [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".otf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" + (str/fmt "Open('%s'); Generate('%s')" + (str finput) + (str foutput))])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (otf->ttf [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".ttf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" + (str/fmt "Open('%s'); Generate('%s')" + (str finput) + (str foutput))])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (ttf-or-otf->woff [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".woff"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["sfnt2woff" (str finput)])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (woff->sfnt [data] + (let [finput (tmp/tempfile :prefix "penpot" :suffix "")] + (try + (io/write* finput data) + (let [res (shell/exec! system + :cmd ["woff2sfnt" (str finput)] + :out-enc :bytes + :prlimit (get-font-prlimit) + :timeout (get-font-timeout))] + (when (zero? (:exit res)) + (:out res))) + (finally + (fs/delete finput))))) + + (woff2->sfnt [data] + ;; woff2_decompress outputs to same directory with .ttf extension + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") + foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["woff2_decompress" (str finput)])] + (if (zero? (:exit res)) + foutput + (do + (when (fs/exists? foutput) + (fs/delete foutput)) + nil))) + (finally + (fs/delete finput))))) + + ;; Documented here: + ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory + (get-sfnt-type [data] + (let [buff (bb/slice data 0 4) + type (bc/bytes->hex buff)] + (case type + "4f54544f" :otf + "00010000" :ttf + (ex/raise :type :internal + :code :unexpected-data + :hint "unexpected font data")))) + + (gen-if-nil [val factory] + (if (nil? val) + (factory) + val))] + + (let [current (into #{} (keys input))] + (cond + (contains? current "font/ttf") + (let [data (get input "font/ttf")] + (-> input + (update "font/otf" gen-if-nil #(ttf->otf data)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)))) + + (contains? current "font/otf") + (let [data (get input "font/otf")] + (-> input + (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)) + (assoc "font/ttf" (otf->ttf data)))) + + (contains? current "font/woff") + (let [data (get input "font/woff") + sfnt (woff->sfnt data)] + (when-not sfnt + (ex/raise :type :validation + :code :invalid-woff-file + :hint "invalid woff file")) + (let [stype (get-sfnt-type sfnt)] + (cond-> input + true + (-> (assoc "font/woff" data)) + + (= stype :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt))) + + (= stype :ttf) + (-> (assoc "font/otf" (ttf->otf sfnt)) + (assoc "font/ttf" sfnt))))) + + (contains? current "font/woff2") + (let [data (get input "font/woff2") + foutput (woff2->sfnt data)] + (when-not foutput + (ex/raise :type :validation + :code :invalid-woff2-file + :hint "invalid woff2 file")) + (try + (let [sfnt (io/read* foutput) + type (get-sfnt-type sfnt)] + (cond-> input + (= type :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) + + (= type :ttf) + (-> (assoc "font/ttf" sfnt) + (assoc "font/otf" (ttf->otf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) + (finally + (fs/delete foutput)))))))) diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj new file mode 100644 index 0000000000..447d5f2e55 --- /dev/null +++ b/backend/src/app/media/remote.clj @@ -0,0 +1,264 @@ +;; 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.local :as local] + [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 local/parse-svg local/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))) + diff --git a/backend/src/app/media/validation.clj b/backend/src/app/media/validation.clj new file mode 100644 index 0000000000..17dbd80e71 --- /dev/null +++ b/backend/src/app/media/validation.clj @@ -0,0 +1,68 @@ +;; 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.validation + "Schemas and validation functions for media uploads. + Leaf namespace — depends on app.common.* and app.config only." + (:require + [app.common.exceptions :as ex] + [app.common.media :as cm] + [app.common.schema :as sm] + [app.config :as cf] + [cuerdas.core :as str] + [datoteka.fs :as fs])) + +(def schema:upload + [:map {:title "Upload"} + [:filename :string] + [:size ::sm/int] + [:path ::fs/path] + [:mtype {:optional true} :string] + [:headers {:optional true} + [:map-of :string :string]]]) + +(def schema:input + [:map {:title "Input"} + [:path ::fs/path] + [:mtype {:optional true} ::sm/text]]) + +(def check-input + (sm/check-fn schema:input)) + +(defn validate-media-type! + ([upload] (validate-media-type! upload cm/image-types)) + ([upload allowed] + (when-not (contains? allowed (:mtype upload)) + (ex/raise :type :validation + :code :media-type-not-allowed + :hint "Seems like you are uploading an invalid media object")) + + upload)) + +(defn validate-media-size! + [upload] + (let [max-size (cf/get :media-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :media-max-file-size-reached + :hint (str/ffmt "the uploaded file size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) + +(defn validate-font-size! + "Validates that the font file `upload` does not exceed the configured + `:font-max-file-size` limit. Accepts the same map shape as + `validate-media-size!` — requires a `:size` key in bytes." + [upload] + (let [max-size (cf/get :font-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :font-max-file-size-reached + :hint (str/ffmt "the uploaded font size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 79b0bf7cf9..74101eadbc 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -19,7 +19,7 @@ [app.http.sse :as sse] [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.commands.files :as files] [app.rpc.commands.media :as media-cmd] @@ -124,7 +124,7 @@ [:project-id ::sm/uuid] [:file-id {:optional true} ::sm/uuid] [:version {:optional true} ::sm/int] - [:file {:optional true} media/schema:upload] + [:file {:optional true} media.v/schema:upload] [:upload-id {:optional true} ::sm/uuid]] [:fn {:error/message "one of :file or :upload-id is required"} (fn [{:keys [file upload-id]}] diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 46a4bc04ac..f4d9b538cb 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -21,7 +21,7 @@ [app.db.sql :as-alias sql] [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] @@ -275,7 +275,7 @@ [:map {:title "create-file-object-thumbnail"} [:file-id ::sm/uuid] [:object-id [:string {:max 250}]] - [:media media/schema:upload] + [:media media.v/schema:upload] [:tag {:optional true} [:string {:max 50}]]]) (sv/defmethod ::create-file-object-thumbnail @@ -289,8 +289,8 @@ ::sm/params schema:create-file-object-thumbnail} [cfg {:keys [::rpc/profile-id file-id object-id media tag]}] - (media/validate-media-type! media) - (media/validate-media-size! media) + (media.v/validate-media-type! media) + (media.v/validate-media-size! media) (db/run! cfg files/check-edition-permissions! profile-id file-id) (when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})] @@ -379,7 +379,7 @@ [:map {:title "create-file-thumbnail"} [:file-id ::sm/uuid] [:revn ::sm/int] - [:media media/schema:upload]]) + [:media media.v/schema:upload]]) (sv/defmethod ::create-file-thumbnail "Creates or updates the file thumbnail. Mainly used for paint the @@ -394,8 +394,8 @@ ::sm/params schema:create-file-thumbnail} [cfg {:keys [::rpc/profile-id file-id] :as params}] - (media/validate-media-type! (:media params)) - (media/validate-media-size! (:media params)) + (media.v/validate-media-type! (:media params)) + (media.v/validate-media-size! (:media params)) (db/run! cfg files/check-edition-permissions! profile-id file-id) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 4d9eb77636..0ca38ae7fd 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -21,6 +21,7 @@ [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] @@ -38,10 +39,7 @@ [datoteka.fs :as fs] [datoteka.io :as io]) (:import - java.io.InputStream java.io.OutputStream - java.io.SequenceInputStream - java.util.Collections java.util.zip.ZipEntry java.util.zip.ZipOutputStream)) @@ -96,18 +94,13 @@ (declare create-font-variant) (def ^:private schema:create-font-variant - [:and - [: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]] - [:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]] - [:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]] - [:fn {:error/message "one of :data or :uploads is required"} - (fn [{:keys [data uploads]}] - (or (seq data) (seq uploads)))]]) + [: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 → @@ -118,8 +111,8 @@ (fn [acc mtype session-id] (let [assembled (assemble-chunks cfg session-id)] (-> {:mtype mtype :size (:size assembled)} - (media/validate-media-type! cm/font-types) - (media/validate-font-size!)) + (media.v/validate-media-type! cm/font-types) + (media.v/validate-font-size!)) (assoc acc mtype (:path assembled)))) {} uploads)] @@ -128,54 +121,23 @@ (assoc :data data) (dissoc :uploads)))) -(defn- prepare-font-data-from-legacy - "Validates the media type and size of every entry in the legacy - `:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every - entry to a tempfile. Returns params with a normalised - `{mtype → path}` data map." - [{:keys [data] :as params}] - (let [data (reduce-kv - (fn [acc mtype content] - (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") - chunks (if (vector? content) content [content]) - streams (map io/input-stream chunks) - streams (Collections/enumeration streams)] - - ;; Generate the tempfile from all chunks - (with-open [^OutputStream output (io/output-stream tmp) - ^InputStream input (SequenceInputStream. streams)] - (io/copy input output)) - - ;; Validate - (-> {:mtype mtype :size (fs/size tmp)} - (media/validate-media-type! cm/font-types) - (media/validate-font-size!)) - - (assoc acc mtype tmp))) - {} - data)] - (assoc params :data data))) - (sv/defmethod ::create-font-variant - "Upload a font variant. Font data may be provided either as a - Transit-encoded `:data` map (keyed by mime-type) for small fonts, or - as an `:uploads` map (keyed by mime-type, values are upload-session - UUIDs from the chunked-upload API) for large fonts. Exactly one of - the two must be present." + "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"] + ::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 uploads] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] (teams/check-edition-permissions! pool profile-id team-id) (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team ::quotes/profile-id profile-id ::quotes/team-id team-id}) - (let [params (if (some? uploads) - (db/tx-run! cfg prepare-font-data-from-uploads params) - (prepare-font-data-from-legacy params))] + (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 @@ -229,9 +191,7 @@ (let [tpoint (ct/tpoint) mtypes (vec (keys data)) total-size (reduce-kv (fn [acc _ content] - (+ acc (if (bytes? content) - (alength ^bytes content) - (fs/size content)))) + (+ acc (fs/size content))) 0 data)] @@ -370,7 +330,7 @@ (defn- make-temporal-storage-object [cfg profile-id content] (let [storage (sto/resolve cfg) - content (media/check-input content) + content (media.v/check-input content) hash (sto/calculate-hash (:path content)) data (-> (sto/content (:path content)) (sto/wrap-with-hash hash)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index ff8add456a..383cd5d115 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -16,6 +16,7 @@ [app.db :as db] [app.loggers.audit :as-alias audit] [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] [app.rpc.commands.files :as files] @@ -44,7 +45,7 @@ [:file-id ::sm/uuid] [:is-local ::sm/boolean] [:name [:string {:max 250}]] - [:content media/schema:upload]]) + [:content media.v/schema:upload]]) (sv/defmethod ::upload-file-media-object {::doc/added "1.17" @@ -53,8 +54,8 @@ [:process-image/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] (files/check-edition-permissions! pool profile-id file-id) - (media/validate-media-type! content) - (media/validate-media-size! content) + (media.v/validate-media-type! content) + (media.v/validate-media-size! content) (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if @@ -315,7 +316,7 @@ [:map {:title "upload-chunk"} [:session-id ::sm/uuid] [:index ::sm/int] - [:content media/schema:upload]]) + [:content media.v/schema:upload]]) (def ^:private schema:upload-chunk-result [:map {:title "upload-chunk-result"} @@ -386,7 +387,7 @@ (defn assemble-chunks "Validates that all expected chunks are present for `session-id` and concatenates them into a single temporary file. Returns a map - conforming to `media/schema:upload` with `:filename`, `:path` and + conforming to `media.v/schema:upload` with `:filename`, `:path` and `:size`. Raises a :validation/:missing-chunks error when the number of stored @@ -440,8 +441,8 @@ content (-> content (assoc :filename (str "upload:" name)) (assoc :mtype mtype) - (media/validate-media-type!) - (media/validate-media-size!)) + (media.v/validate-media-type!) + (media.v/validate-media-size!)) mobj (create-file-media-object cfg (assoc params :id id :from-chunks? true diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index a26fc9ea9e..ed4d22f445 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -21,6 +21,7 @@ [app.loggers.audit :as audit] [app.main :as-alias main] [app.media :as media] + [app.media.validation :as media.v] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] @@ -280,7 +281,7 @@ (def ^:private schema:update-profile-photo [:map {:title "update-profile-photo"} - [:file media/schema:upload]]) + [:file media.v/schema:upload]]) (sv/defmethod ::update-profile-photo {:doc/added "1.1" @@ -288,8 +289,8 @@ ::sm/result :nil} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media/validate-media-size! file) + (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/validate-media-size! file) (update-profile-photo cfg (assoc params :profile-id profile-id))) (defn update-profile-photo diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 76d9b162c5..9277a803c1 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -22,7 +22,7 @@ [app.features.logical-deletion :as ldel] [app.loggers.audit :as audit] [app.main :as-alias main] - [app.media :as media] + [app.media.validation :as media.v] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -979,7 +979,7 @@ (def ^:private schema:update-team-photo [:map {:title "update-team-photo"} [:team-id ::sm/uuid] - [:file media/schema:upload]]) + [:file media.v/schema:upload]]) (sv/defmethod ::update-team-photo {::doc/added "1.17" @@ -987,8 +987,8 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media/validate-media-size! file) + (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/validate-media-size! file) (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index 20e791e7d0..aac508669d 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -10,7 +10,7 @@ [app.common.time :as ct] [app.common.uri :as u] [app.config :as cf] - [app.media :refer [schema:upload]] + [app.media.validation :refer [schema:upload]] [app.rpc :as-alias rpc] [app.rpc.doc :as doc] [app.storage :as sto] diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index e36b04fbb0..a87dd74ccb 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -24,7 +24,7 @@ [app.http :as-alias http] [app.http.session :as session] [app.loggers.audit :as audit] - [app.media :as media] + [app.media.validation :as media.v] [app.nitrate :as nitrate] [app.rpc :as rpc] [app.rpc.commands.auth :as auth] @@ -119,7 +119,7 @@ (def ^:private schema:upload-organization-logo [:map - [:content media/schema:upload] + [:content media.v/schema:upload] [:organization-id ::sm/uuid] [:previous-id {:optional true} ::sm/uuid]]) diff --git a/backend/src/app/setup.clj b/backend/src/app/setup.clj index ed3a3364f0..4a6ef8ec62 100644 --- a/backend/src/app/setup.clj +++ b/backend/src/app/setup.clj @@ -116,7 +116,8 @@ {} [:exporter :admin-console - :nexus]))) + :nexus + :media-processor]))) (sm/register! ::props [:map-of :keyword ::sm/any]) (sm/register! ::shared-keys [:map-of :keyword ::sm/text]) diff --git a/backend/test/backend_tests/media_remote_test.clj b/backend/test/backend_tests/media_remote_test.clj new file mode 100644 index 0000000000..dbaf8cb889 --- /dev/null +++ b/backend/test/backend_tests/media_remote_test.clj @@ -0,0 +1,593 @@ +;; 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 backend-tests.media-remote-test + (:require + [app.common.exceptions :as ex] + [app.config :as cf] + [app.media.remote :as media.remote] + [app.setup :as-alias setup] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io] + [mockery.core :refer [with-mocks]]) + (:import + java.io.ByteArrayInputStream)) + +(defn- mk-system + "Minimal system map for media.remote/process tests." + [] + {::setup/shared-keys {:media-processor "test-shared-key"}}) + +(defn- json-stream + "Create an InputStream from a Clojure data structure (JSON-encoded)." + [data] + (ByteArrayInputStream. + (json/encode data))) + +(def config-mock + "Standard config mock for media-processor service." + {:media-processing-service-uri "http://localhost:6065" + :media-processing-service-timeout 5000}) + +(defn- write-font-tmp + "Write font bytes to a tempfile and return the Path. Caller is responsible for cleanup." + [bytes suffix] + (let [tmp (fs/create-tempfile :prefix "penpot-test-font-" :suffix suffix)] + (io/write* tmp bytes) + tmp)) + +;; --------------------------------------------------------------------------- +;; :info +;; --------------------------------------------------------------------------- + +(t/deftest info-happy-path + (t/testing "info returns dimensions and merges into input" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 800 :height 600 :mtype "image/jpeg" :size 12345 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}})] + (t/is (= 800 (:width result))) + (t/is (= 600 (:height result))) + (t/is (= (fs/size path) (:size result))) + (t/is (some? (:ts result))) + (t/is (= path (:path result))) + (t/is (= "image/jpeg" (:mtype result))) + (t/is (= 1 (:call-count @mock)))))))) + +(t/deftest info-verifies-request-params + (t/testing "info sends correct endpoint, method, and x-shared-key header" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}) + (let [[system req-map] (:call-args @mock)] + ;; System passed through + (t/is (some? (::setup/shared-keys system))) + ;; Request structure + (t/is (= :post (:method req-map))) + (t/is (str/includes? (str (:uri req-map)) "api/image/info")) + (t/is (= "test-shared-key" (get-in req-map [:headers "x-shared-key"]))) + (t/is (str/starts-with? + (get-in req-map [:headers "Content-Type"]) + "multipart/form-data")))))))) + +(t/deftest info-no-content-length-header + (t/testing "info does not send Content-Length header (JDK uses chunked encoding)" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (nil? (get-in req-map [:headers "Content-Length"]))))))))) + +(t/deftest info-service-uri-not-configured + (t/testing "info throws when service URI is not configured" + (with-redefs [cf/get (th/config-get-mock {})] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-not-configured (:code (ex-data err)))))))) + +(t/deftest info-service-unavailable + (t/testing "info throws when service-request raises unavailable" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +(t/deftest info-service-timeout + (t/testing "info throws when service-request raises timeout" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "media-processor service request timed out" + {:type :internal + :code :media-processor-timeout})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-timeout (:code (ex-data err))))))))) + +(t/deftest info-mtype-mismatch + (t/testing "info raises :media-type-mismatch when detected mtype differs from declared" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :size 100 + :mtype "image/png"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :media-type-mismatch (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :generic-thumbnail +;; --------------------------------------------------------------------------- + +(t/deftest generic-thumbnail-happy-path + (t/testing "generic-thumbnail returns tempfile with correct format" + (let [thumb-bytes (.getBytes "fake-jpeg-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. thumb-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 80 + :width 200 + :height 200})] + (t/is (= :jpeg (:format result))) + (t/is (= "image/jpeg" (:mtype result))) + (t/is (pos? (:size result))) + (t/is (fs/exists? (:data result))))))))) + +(t/deftest generic-thumbnail-verifies-query-params + (t/testing "generic-thumbnail sends correct query params with mode=fit" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. (.getBytes "data" "UTF-8"))}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 300 + :height 400}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "width=300")) + (t/is (str/includes? (str (:uri req-map)) "height=400")) + (t/is (str/includes? (str (:uri req-map)) "quality=85")) + (t/is (str/includes? (str (:uri req-map)) "format=jpeg")) + (t/is (str/includes? (str (:uri req-map)) "mode=fit")))))))) + +(t/deftest generic-thumbnail-service-unavailable + (t/testing "generic-thumbnail throws on service error" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 200 + :height 200}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :profile-thumbnail +;; --------------------------------------------------------------------------- + +(t/deftest profile-thumbnail-happy-path + (t/testing "profile-thumbnail returns tempfile and uses mode=crop" + (let [thumb-bytes (.getBytes "fake-png-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. thumb-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :profile-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 128 + :height 128})] + (t/is (some? (:data result))) + (t/is (fs/exists? (:data result))) + ;; Verify mode=crop in URI + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "mode=crop"))))))))) + +(t/deftest profile-thumbnail-service-unavailable + (t/testing "profile-thumbnail throws on service error" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :profile-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 128 + :height 128}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :generate-fonts +;; --------------------------------------------------------------------------- + +(t/deftest generate-fonts-ttf-happy-path + (t/testing "generate-fonts with TTF path makes per-variant calls" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" ttfpath}})] + ;; Original path preserved + (t/is (= ttfpath (get result "font/ttf"))) + ;; Variants written to tempfiles + (t/is (fs/exists? (get result "font/otf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Two calls: one for otf, one for woff + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-ttf-as-path + (t/testing "generate-fonts with TTF as tempfile Path works" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + tmp-path (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" tmp-path}})] + ;; Path preserved + (t/is (= tmp-path (get result "font/ttf"))) + ;; Variant written + (t/is (fs/exists? (get result "font/otf")))) + (finally + (fs/delete tmp-path)))))))) + +(t/deftest generate-fonts-otf-happy-path + (t/testing "generate-fonts with OTF path" + (let [otfbytes (io/read* (io/resource "backend_tests/test_files/font-1.otf")) + otfpath (write-font-tmp otfbytes ".otf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/otf" otfpath}})] + (t/is (= otfpath (get result "font/otf"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Two calls: one for ttf, one for woff + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete otfpath)))))))) + +(t/deftest generate-fonts-woff-happy-path + (t/testing "generate-fonts with WOFF path" + (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) + woffpath (write-font-tmp woffbytes ".woff") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/woff" woffpath}})] + (t/is (= woffpath (get result "font/woff"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/otf"))) + ;; Two calls: one for ttf, one for otf + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete woffpath))))))) + + (t/deftest generate-fonts-woff2-happy-path + (t/testing "generate-fonts with WOFF2 path" + (let [woff2bytes (io/read* (io/resource "backend_tests/test_files/font-1.woff2")) + woff2path (write-font-tmp woff2bytes ".woff2") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/woff2" woff2path}})] + (t/is (= woff2path (get result "font/woff2"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/otf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Three calls: one for ttf, one for otf, one for woff + (t/is (= 3 (:call-count @mock)))) + (finally + (fs/delete woff2path))))))))) + +(t/deftest generate-fonts-verifies-query-params + (t/testing "generate-fonts sends target-type query param with 180s timeout" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "target-type=")) + (t/is (= 180000 (:timeout req-map)))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-woff-verifies-target-types + (t/testing "generate-fonts with WOFF sends target-type query param" + (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) + woffpath (write-font-tmp woffbytes ".woff") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/woff" woffpath}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "target-type="))) + (finally + (fs/delete woffpath)))))))) + +(t/deftest generate-fonts-no-recognized-variant + (t/testing "generate-fonts throws when no recognized font variant" + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/unknown" (.getBytes "data" "UTF-8")}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :invalid-font (:code (ex-data err)))))))) + +(t/deftest generate-fonts-connection-error + (t/testing "generate-fonts throws on service error" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-timeout-error + (t/testing "generate-fonts throws on service timeout" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "media-processor service request timed out" + {:type :internal + :code :media-processor-timeout})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-timeout (:code (ex-data err))))) + (finally + (fs/delete ttfpath)))))))) + +;; --------------------------------------------------------------------------- +;; Status code handling (service-request) +;; --------------------------------------------------------------------------- + +(t/deftest service-request-raises-on-400 + (t/testing "service-request raises :validation on status 400" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 400 + :body (json-stream {:type "validation" + :code "invalid-image" + :hint "bad input"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :invalid-image (:code (ex-data err))))))))) + +(t/deftest service-request-raises-on-500 + (t/testing "service-request raises :internal on status 500" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 500 + :body (json-stream {:type "internal" + :code "processing-error" + :hint "Internal server error"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :processing-error (:code (ex-data err))))))))) + +(t/deftest service-request-passes-on-200 + (t/testing "service-request returns response on status 200" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 200 + :body (json-stream {:width 100 :height 100})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [resp (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}})] + (t/is (= 200 (:status resp)))))))) + +;; --------------------------------------------------------------------------- +;; Shared key +;; --------------------------------------------------------------------------- + +(t/deftest shared-key-sent-correctly + (t/testing "x-shared-key header matches the system's shared key" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 1 :height 1 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [system {::setup/shared-keys {:media-processor "my-secret-key-123"}}] + (media.remote/process system + {:cmd :info + :input {:path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg"}}) + (let [[system-arg _] (:call-args @mock)] + ;; System passed through correctly + (t/is (= "my-secret-key-123" + (-> system-arg ::setup/shared-keys :media-processor))))))))) + +;; --------------------------------------------------------------------------- +;; Stream closure +;; --------------------------------------------------------------------------- + +(defn- tracking-stream + "Create an InputStream that tracks whether it was closed. + Returns a map with :stream (the InputStream) and :closed (an atom)." + [^bytes data] + (let [closed (atom false) + delegate (ByteArrayInputStream. data) + stream (proxy [java.io.InputStream] [] + (read + ([] (.read delegate)) + ([^bytes b] (.read delegate b)) + ([^bytes b off len] (.read delegate b off len))) + (close [] + (reset! closed true) + (.close delegate)))] + {:stream stream :closed closed})) + +(t/deftest info-closes-response-stream + (t/testing "info closes the response stream after parsing JSON" + (let [json-str "{\"width\":100,\"height\":100,\"mtype\":\"image/jpeg\",\"size\":1,\"orientation\":1}" + json-data (.getBytes json-str "UTF-8") + {:keys [stream closed]} (tracking-stream json-data)] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}}) + ;; Stream should be closed after processing + (t/is @closed))))))) + +(t/deftest font-convert-closes-response-stream + (t/testing "font-convert closes the response stream after writing" + (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-font-data" "UTF-8"))] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" ttfpath}}) + ;; Stream should be closed after processing + (t/is @closed) + (finally + (fs/delete ttfpath))))))))) + +(t/deftest thumbnail-closes-response-stream + (t/testing "thumbnail closes the response stream after writing" + (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-thumbnail-data" "UTF-8"))] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 200 + :height 200}) + ;; Stream should be closed after processing + (t/is @closed))))))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 234bcba89e..4f4b5378f7 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -24,312 +24,6 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) -(t/deftest ttf-font-upload-1 - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" ttfdata}} - out (th/command! params)] - - (t/is (= 1 (:call-count @mock))) - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style))))) - -(t/deftest ttf-font-upload-2 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data}} - out (th/command! params)] - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style)))) - -(t/deftest woff2-font-upload-1 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data (-> (io/resource "backend_tests/test_files/font-1.woff2") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff2" data}} - out (th/command! params)] - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/is (uuid? (:woff2-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style)))) - -(t/deftest font-deletion-1 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") - (io/read*))] - - ;; Create front variant - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data1}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 500 - :font-style "normal" - :data {"font/woff" data2}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) - - (let [params {::th/type :delete-font - ::rpc/profile-id (:id prof) - :team-id team-id - :id font-id} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 0 (:freeze res))) - (t/is (= 0 (:delete res)))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] - (let [res (th/run-task! :objects-gc {})] - (t/is (= 2 (:processed res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] - (let [res (th/run-task! :storage-gc-touched {})] - (t/is (= 0 (:freeze res))) - (t/is (= 6 (:delete res))))))) - -(t/deftest font-deletion-2 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") - (io/read*))] - - ;; Create front variant - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data1}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id (uuid/custom 10 2) - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data2}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) - - (let [params {::th/type :delete-font - ::rpc/profile-id (:id prof) - :team-id team-id - :id font-id} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 0 (:freeze res))) - (t/is (= 0 (:delete res)))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] - (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] - (let [res (th/run-task! :storage-gc-touched {})] - (t/is (= 0 (:freeze res))) - (t/is (= 3 (:delete res))))))) - -(t/deftest font-deletion-3 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*)) - params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof) - :team-id team-id :font-id font-id :font-family "somefont" - :font-weight 400 :font-style "normal" :data {"font/woff" data1}} - params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) - :team-id team-id :font-id font-id :font-family "somefont" - :font-weight 500 :font-style "normal" :data {"font/woff" data2}} - out1 (th/command! params1) - out2 (th/command! params2)] - (t/is (nil? (:error out1))) - (t/is (nil? (:error out2))) - - ;; freeze with hours 3 clock - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) - - (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) - :team-id team-id :id (-> out1 :result :id)} - out (th/command! params)] - (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) - - ;; no-op with hours 3 clock (nothing touched yet) - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 0 (:freeze res))) - (t/is (= 0 (:delete res)))) - - ;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] - (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] - (let [res (th/run-task! :storage-gc-touched {})] - (t/is (= 0 (:freeze res))) - (t/is (= 3 (:delete res))))))) - -(t/deftest input-sanitization-1 - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" "/etc/passwd"}} - out (th/command! params)] - - (t/is (= 0 (:call-count @mock))) - ;; (th/print-result! out) - - (let [error (:error out) - error-data (ex-data error)] - (t/is (th/ex-info? error)))))) - ;; ----------------------------------------------------------------------- ;; Helpers for chunked-upload font tests ;; ----------------------------------------------------------------------- @@ -399,119 +93,211 @@ :font-weight :font-style)) -;; ----------------------------------------------------------------------- -;; Path 1 – Normal (direct :data bytes) -;; ----------------------------------------------------------------------- +(t/deftest font-deletion-1 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) -(t/deftest create-font-variant-normal-ttf + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") + (io/read*)) + + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") + (io/read*))] + + ;; Create font variant + (let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "somefont" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "somefont" + :font-weight 500 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 6 (:freeze res)))) + + (let [params {::th/type :delete-font + ::rpc/profile-id (:id prof) + :team-id team-id + :id font-id} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res)))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (let [res (th/run-task! :objects-gc {})] + (t/is (= 2 (:processed res))))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 6 (:delete res))))))) + +(t/deftest font-deletion-2 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) + + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") + (io/read*)) + + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") + (io/read*))] + + ;; Create font variant + (let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "somefont" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id (uuid/custom 10 2) + :font-family "somefont" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 6 (:freeze res)))) + + (let [params {::th/type :delete-font + ::rpc/profile-id (:id prof) + :team-id team-id + :id font-id} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res)))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (let [res (th/run-task! :objects-gc {})] + (t/is (= 1 (:processed res))))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 3 (:delete res))))))) + +(t/deftest font-deletion-3 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*)) + sid1 (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + sid2 (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 400 :font-style "normal" :uploads {"font/woff" sid1}} + params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 500 :font-style "normal" :uploads {"font/woff" sid2}} + out1 (th/command! params1) + out2 (th/command! params2)] + (t/is (nil? (:error out1))) + (t/is (nil? (:error out2))) + + ;; freeze with hours 3 clock + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 6 (:freeze res)))) + + (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :id (-> out1 :result :id)} + out (th/command! params)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; no-op with hours 3 clock (nothing touched yet) + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res)))) + + ;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (let [res (th/run-task! :objects-gc {})] + (t/is (= 1 (:processed res))))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 3 (:delete res))))))) + +(t/deftest input-sanitization-1 (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) - font-id (uuid/custom 10 10) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" data}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) -(t/deftest create-font-variant-normal-otf - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 11) - data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" - :font-weight 400 - :font-style "normal" - :data {"font/otf" data}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) + ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") + (io/read*)) -(t/deftest create-font-variant-normal-woff - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 12) - data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - params {::th/type :create-font-variant + session-id (upload-font-chunked! prof ttfdata "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" + :team-id team-id + :font-id font-id + :font-family "somefont" :font-weight 400 - :font-style "normal" - :data {"font/woff" data}} + :font-style "normal" + :uploads {"font/ttf" session-id}} out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) + + ;; (th/print-result! out) + (t/is (nil? (:error out)))))) ;; ----------------------------------------------------------------------- -;; Path 2 – Legacy chunking (:data with vector of byte-arrays per mtype) -;; ----------------------------------------------------------------------- - -(t/deftest create-font-variant-legacy-chunked-ttf - "Upload a TTF via the legacy :data path where each mtype value is a - vector of byte-array chunks (4 MiB each) instead of a single byte-array." - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 20) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - ;; Simulate 4 MiB legacy chunks – font is small so a single chunk suffices - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "legacy-chunked" - :font-weight 700 - :font-style "italic" - :data {"font/ttf" (vec chunks)}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -(t/deftest create-font-variant-legacy-chunked-woff - "Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks - to exercise the SequenceInputStream concatenation path." - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 21) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - ;; Split into small chunks to exercise the SequenceInputStream path - chunks (split-bytes-into-chunks full-bytes 512) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "legacy-chunked-woff" - :font-weight 400 - :font-style "normal" - :data {"font/woff" (vec chunks)}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -;; ----------------------------------------------------------------------- -;; Path 3 – New standardized chunked upload (:uploads map) +;; Chunked upload (:uploads map) ;; ----------------------------------------------------------------------- (t/deftest create-font-variant-chunked-upload-ttf @@ -606,8 +392,8 @@ ;; Error cases ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-missing-data-and-uploads - "Neither :data nor :uploads is present — schema validation must reject it." +(t/deftest create-font-variant-missing-uploads + "Missing :uploads — schema validation must reject it." (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) font-id (uuid/custom 10 40) @@ -674,49 +460,6 @@ ;; Font size validation tests ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-size-exceeded-normal - "Direct :data upload exceeding font-max-file-size must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 50) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-exceeded" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" data}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :restriction (-> out :error ex-data :type))) - (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) - -(t/deftest create-font-variant-size-exceeded-legacy-chunked - "Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 51) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-exceeded-legacy" - :font-weight 400 - :font-style "normal" - :data {"font/woff" (vec chunks)}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :restriction (-> out :error ex-data :type))) - (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) - (t/deftest create-font-variant-size-exceeded-chunked-upload "New :uploads path exceeding font-max-file-size must be rejected after assembly." (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] @@ -738,72 +481,10 @@ (t/is (= :restriction (-> out :error ex-data :type))) (t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))) -(t/deftest create-font-variant-size-within-limit - "Upload exactly at the limit must succeed." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 53) - font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - font-size (alength ^bytes font-bytes)] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)] - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-at-limit" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" font-bytes}} - out (th/command! params)] - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))))) - ;; ----------------------------------------------------------------------- -;; Font media-type validation tests +;; Font media-type validation ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-invalid-type-normal - "Direct :data upload with a disallowed mtype must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 60) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "invalid-type" - :font-weight 400 - :font-style "normal" - :data {"application/octet-stream" data}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :validation (-> out :error ex-data :type))) - (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) - -(t/deftest create-font-variant-invalid-type-legacy-chunked - "Legacy :data chunk-vector upload with a disallowed mtype must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 61) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "invalid-type-legacy" - :font-weight 400 - :font-style "normal" - :data {"image/png" (vec chunks)}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :validation (-> out :error ex-data :type))) - (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) - (t/deftest create-font-variant-invalid-type-chunked-upload "New :uploads path with a disallowed mtype must be rejected after assembly." (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] @@ -836,46 +517,50 @@ data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] ;; name with < should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation))) ;; name with ' should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil'name" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation))) ;; name with } should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil}name" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation))) ;; valid name should succeed - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id (uuid/custom 10 101) :font-family "Source Sans Pro" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (th/success? out)))))) @@ -887,12 +572,13 @@ data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] ;; Create a valid font first - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "ValidFont" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (th/success? out))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index ff38aee470..4669ad929d 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -380,8 +380,41 @@ (t/is (= :validation (:type (ex-data err)))) (t/is (= :unable-to-download-image (:code (ex-data err)))))))) -;; -------------------------------------------------------------------- -;; Helpers for chunked-upload tests + +(t/deftest download-image-closes-stream + (t/testing "response body stream is closed on success" + (let [closed? (atom false) + ;; Minimal valid PNG (1x1 pixel, red) + png-data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE 0x00 0x00 0x00 0x0C 0x49 0x44 0x41 0x54 0x08 0xD7 0x63 0xF8 0xCF 0xC0 0x00 0x00 0x00 0x02 0x00 0x01 0xE2 0x21 0xBC 0x33 0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82]) + body (proxy [java.io.ByteArrayInputStream] [png-data] + (close [] (reset! closed? true)))] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 200 + :headers {"content-type" "image/png" + "content-length" (str (alength png-data))} + :body body}}] + (let [cfg {::http/client :mock-client} + result (media/download-image cfg "https://example.com/image.png")] + (t/is (some? result)) + (t/is @closed? "body stream should be closed after successful download"))))) + + (t/testing "response body stream is closed on validation error" + (let [closed? (atom false) + body (proxy [java.io.ByteArrayInputStream] [(byte-array 100)] + (close [] (reset! closed? true)))] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 404 + :headers {"content-type" "text/html" + "content-length" "100"} + :body body}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "https://example.com/not-found.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :unable-to-download-image (:code (ex-data err)))) + (t/is @closed? "body stream should be closed even on validation error")))))) ;; -------------------------------------------------------------------- (defn- split-file-into-chunks diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index 348a978fc2..2aca502e9f 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -199,6 +199,25 @@ (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])] (t/is (= 0 (:count res))))))) +(defn- upload-font-chunked! + "Splits `font-bytes` into a single chunk, creates an upload session, + uploads the chunk, and returns the session-id UUID." + [prof ^bytes font-bytes mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-") + _ (io/write* tmp font-bytes) + mfile {:filename "chunk" :path tmp :mtype mtype :size (alength font-bytes)} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] + (assert (nil? (:error out))) + session-id)) + (t/deftest touched-gc-task-2 (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) @@ -229,6 +248,8 @@ :name "testfile" :content mfile} + session-id (upload-font-chunked! prof ttfdata "font/ttf") + params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id @@ -236,7 +257,7 @@ :font-family "somefont" :font-weight 400 :font-style "normal" - :data {"font/ttf" ttfdata}} + :uploads {"font/ttf" session-id}} out1 (th/command! params1) out2 (th/command! params2)] @@ -250,7 +271,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 5 (:freeze res))) - (t/is (= 0 (:delete res))) + (t/is (= 1 (:delete res))) (let [result-1 (:result out1) result-2 (:result out2)] @@ -271,7 +292,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) - (t/is (= 3 (:delete res)))) + (t/is (= 4 (:delete res)))) ;; now check that there are no touched objects (let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])] @@ -279,7 +300,7 @@ ;; now check that all objects are marked to be deleted (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])] - (t/is (= 3 (:count res)))))))) + (t/is (= 4 (:count res)))))))) (t/deftest touched-gc-task-3 (let [storage (-> (:app.storage/storage th/*system*) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index ddfa667165..9988c1a9f8 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -178,7 +178,8 @@ :stroke-path :stroke-per-side - :custom-shortcuts}) + :custom-shortcuts + :remote-media-processing}) (def all-flags (set/union email login varia)) diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor new file mode 100644 index 0000000000..bc83e9e5a5 --- /dev/null +++ b/docker/images/Dockerfile.media-processor @@ -0,0 +1,86 @@ +FROM ubuntu:26.04 +LABEL maintainer="Penpot " + +ENV LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + NODE_VERSION=v24.18.0 \ + DEBIAN_FRONTEND=noninteractive \ + PATH=/opt/node/bin:$PATH + +RUN set -ex; \ + useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ + mkdir -p /etc/resolvconf/resolv.conf.d; \ + echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \ + apt-get -qq update; \ + apt-get -qq dist-upgrade; \ + apt-get -qqy --no-install-recommends install \ + curl \ + tzdata \ + locales \ + ca-certificates \ + ; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ + locale-gen; \ + find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete; + +RUN set -ex; \ + apt-get -qq update; \ + apt-get -qqy --no-install-recommends install \ + fontforge \ + woff-tools \ + woff2 \ + \ + libgomp1 \ + libheif1 \ + libjpeg-turbo8 \ + liblcms2-2 \ + libopenexr-3-1-30 \ + libopenjp2-7 \ + libpng16-16 \ + librsvg2-2 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ + libxml2-16 \ + libzip5 \ + libzstd1 \ + ; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; + +RUN set -eux; \ + ARCH="$(dpkg --print-architecture)"; \ + case "${ARCH}" in \ + aarch64|arm64) \ + BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ + ;; \ + amd64|x86_64) \ + BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ + ;; \ + *) \ + echo "Unsupported arch: ${ARCH}"; \ + exit 1; \ + ;; \ + esac; \ + curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ + mkdir -p /opt/node; \ + cd /opt/node; \ + tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ + chown -R root /opt/node; \ + rm -rf /tmp/nodejs.tar.gz; \ + corepack enable; \ + mkdir -p /opt/penpot; \ + chown -R penpot:penpot /opt/penpot; + +ARG BUNDLE_PATH="./bundle-media-processor/" +COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/media-processor/ + +WORKDIR /opt/penpot/media-processor +USER penpot:penpot + +RUN ./setup + +CMD ["node", "dist/index.js"] diff --git a/media-processor/.prettierignore b/media-processor/.prettierignore new file mode 100644 index 0000000000..2d0c064480 --- /dev/null +++ b/media-processor/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +coverage/ diff --git a/media-processor/.prettierrc b/media-processor/.prettierrc new file mode 100644 index 0000000000..5ebd5018e8 --- /dev/null +++ b/media-processor/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 120, + "endOfLine": "lf" +} diff --git a/media-processor/esbuild.config.mjs b/media-processor/esbuild.config.mjs new file mode 100644 index 0000000000..698edc38d4 --- /dev/null +++ b/media-processor/esbuild.config.mjs @@ -0,0 +1,17 @@ +import { build } from "esbuild"; + +await build({ + entryPoints: ["src/index.ts"], + bundle: true, + platform: "node", + target: "node24", + format: "esm", + outfile: "dist/index.js", + external: ["sharp", "pino", "pino-pretty", "pino-loki"], + banner: { + js: ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +`, + }, +}); diff --git a/media-processor/package.json b/media-processor/package.json new file mode 100644 index 0000000000..7c08c70c0f --- /dev/null +++ b/media-processor/package.json @@ -0,0 +1,40 @@ +{ + "name": "media-processor", + "version": "1.0.0", + "description": "Stateless HTTP service for Penpot image and font processing", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "node esbuild.config.mjs", + "start": "node dist/index.js", + "start:dev": "tsx --env-file=../backend/scripts/_env src/index.ts", + "types:check": "tsc --noEmit", + "test": "vitest run", + "fmt": "prettier --write src/ test/", + "fmt:check": "prettier --check src/ test/", + "clean": "rm -rf dist/" + }, + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "dependencies": { + "express": "^5.2.1", + "multer": "^2.2.0", + "p-queue": "^9.3.3", + "pino": "^10.3.1", + "pino-loki": "^3.0.0", + "pino-pretty": "^13.1.3", + "sharp": "^0.35.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/multer": "^2.0.0", + "@types/node": "^26.1.2", + "@types/supertest": "^7.2.1", + "esbuild": "^0.28.1", + "prettier": "^3.6.2", + "supertest": "^7.2.2", + "tsx": "^4.22.4", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } +} diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml new file mode 100644 index 0000000000..be8c8eec4e --- /dev/null +++ b/media-processor/pnpm-lock.yaml @@ -0,0 +1,2649 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + multer: + specifier: ^2.2.0 + version: 2.2.0 + p-queue: + specifier: ^9.3.3 + version: 9.3.3 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-loki: + specifier: ^3.0.0 + version: 3.0.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@26.1.2) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/multer': + specifier: ^2.0.0 + version: 2.2.0 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 + prettier: + specifier: ^3.6.2 + version: 3.9.6 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + tsx: + specifier: ^4.22.4 + version: 4.23.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)) + +packages: + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.2': + resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-copy@4.0.4: + resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-loki@3.0.0: + resolution: {integrity: sha512-9TyUW5syTjp2nT70QcijJtIWUzdYUj+olQ7+fWNfm1/HrDGEWt86Q4ACzClH6DM6GBwtQimRDgneNczP+p4ypA==} + engines: {node: '>=20'} + hasBin: true + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pinojs/redact@0.4.0': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 26.1.2 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.1.2 + + '@types/cookiejar@2.1.5': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.2': + dependencies: + '@types/node': 26.1.2 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.2 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/methods@1.1.4': {} + + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 26.1.2 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 26.1.2 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 26.1.2 + form-data: 4.0.6 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.1.2)(tsx@4.23.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + append-field@1.0.0: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.2.2: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + dateformat@4.6.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-copy@4.0.4: {} + + fast-safe-stringify@2.1.1: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + help-me@5.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + joycon@3.1.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + minimist@1.2.8: {} + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + nanoid@3.3.16: {} + + negotiator@1.0.0: {} + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-loki@3.0.0: + dependencies: + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.4 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.20: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + process-warning@5.0.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + quick-format-unescaped@4.0.4: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.35.3(@types/node@26.1.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 26.1.2 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@5.0.3: {} + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + toidentifier@1.0.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.20 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + fsevents: 2.3.3 + tsx: 4.23.1 + + vitest@4.1.10(@types/node@26.1.2)(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.6(@types/node@26.1.2)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + zod@4.4.3: {} diff --git a/media-processor/pnpm-workspace.yaml b/media-processor/pnpm-workspace.yaml new file mode 100644 index 0000000000..5ed0b5af0d --- /dev/null +++ b/media-processor/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/media-processor/scripts/build b/media-processor/scripts/build new file mode 100755 index 0000000000..94ec8e856c --- /dev/null +++ b/media-processor/scripts/build @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +pnpm run build diff --git a/media-processor/scripts/setup b/media-processor/scripts/setup new file mode 100755 index 0000000000..c7be37d33d --- /dev/null +++ b/media-processor/scripts/setup @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +corepack enable +corepack install +pnpm install diff --git a/media-processor/src/config.ts b/media-processor/src/config.ts new file mode 100644 index 0000000000..c6229ae343 --- /dev/null +++ b/media-processor/src/config.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { hkdfSync } from "node:crypto"; +import type { AppConfig } from "./types.js"; + +const envSchema = z.object({ + PENPOT_MEDIA_PROCESSOR_PORT: z.coerce.number().int().positive().default(6065), + PENPOT_MEDIA_PROCESSOR_HOST: z.string().default("0.0.0.0"), + PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS: z.coerce.number().int().min(1).default(10), + PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT: z.coerce.number().int().nonnegative().default(180000), + PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE: z.coerce.number().int().positive().default(367001600), // 350 MB + PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD: z.coerce.number().int().positive().default(10485760), // 10 MB — uploads below this use memory storage; above use disk storage + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS: z.coerce.number().int().positive().default(128_000_000), + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH: z.coerce.number().int().positive().default(16384), + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT: z.coerce.number().int().positive().default(16384), + PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM: z.coerce.number().int().positive().default(512), + PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME: z.coerce.number().int().positive().default(30), + PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT: z.coerce.number().int().positive().default(120000), + PENPOT_MEDIA_PROCESSOR_SHARED_KEY: z.string().optional(), + PENPOT_SECRET_KEY: z.string().optional(), + PENPOT_MEDIA_PROCESSOR_LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + PENPOT_LOGGERS_LOKI_URI: z.string().optional(), + PENPOT_LOGGERS_LOKI_JOB: z.string().default("media-processor"), + PENPOT_LOGGERS_LOKI_ENVIRONMENT: z.string().optional(), + PENPOT_LOGGERS_LOKI_INSTANCE: z.string().optional(), +}); + +function deriveSharedKey(secret: string): string { + const key = hkdfSync("blake2b512", secret, Buffer.from("media-processor"), "", 32); + return Buffer.from(key).toString("base64url"); +} + +export function loadConfig(): AppConfig { + const parsed = envSchema.parse(process.env); + + let sharedKey: string | null = null; + if (parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY) { + sharedKey = parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY; + } else if (parsed.PENPOT_SECRET_KEY) { + sharedKey = deriveSharedKey(parsed.PENPOT_SECRET_KEY); + } + + return { + port: parsed.PENPOT_MEDIA_PROCESSOR_PORT, + host: parsed.PENPOT_MEDIA_PROCESSOR_HOST, + maxConcurrentRequests: parsed.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS, + requestTimeout: parsed.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT, + maxFileSize: parsed.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE, + memoryThreshold: parsed.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD, + imageMaxPixels: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS, + imageMaxWidth: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH, + imageMaxHeight: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT, + fontProcessMem: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM, + fontProcessCpuTime: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME, + fontTimeout: parsed.PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT, + sharedKey, + logLevel: parsed.PENPOT_MEDIA_PROCESSOR_LOG_LEVEL, + lokiUri: parsed.PENPOT_LOGGERS_LOKI_URI || null, + lokiJob: parsed.PENPOT_LOGGERS_LOKI_JOB, + lokiEnvironment: parsed.PENPOT_LOGGERS_LOKI_ENVIRONMENT || null, + lokiInstance: parsed.PENPOT_LOGGERS_LOKI_INSTANCE || null, + }; +} diff --git a/media-processor/src/index.ts b/media-processor/src/index.ts new file mode 100644 index 0000000000..ac05201659 --- /dev/null +++ b/media-processor/src/index.ts @@ -0,0 +1,59 @@ +import express, { type Express } from "express"; +import { loadConfig } from "./config.js"; +import { initLogger, logger, logActiveTransports } from "./logger.js"; +import { healthRoutes } from "./routes/health.js"; +import { createImageRoutes } from "./routes/image.js"; +import { createFontRoutes } from "./routes/font.js"; +import { errorHandler } from "./middleware/error-handler.js"; +import { timeoutMiddleware } from "./middleware/timeout.js"; +import { sharedKeyAuth } from "./middleware/auth.js"; +import { createQueueMiddleware } from "./middleware/queue.js"; +import { loggingMiddleware } from "./middleware/logging.js"; +import { configureImageLimits } from "./services/image.js"; +import { configureFontLimits } from "./services/font.js"; +import { configureUploadLimits } from "./upload.js"; +import sharp from "sharp"; + +// Auth is enforced via x-shared-key header (sharedKeyAuth middleware). +// When no key is configured, all requests are rejected (403). +// This service MUST be deployed on an internal Docker network only +// — do NOT expose to the public internet. + +// Disable sharp/libvips caching to prevent unbounded memory growth +sharp.cache(false); + +const config = loadConfig(); +initLogger(config); +const app: Express = express(); + +// Configure resource limits +configureImageLimits({ + maxPixels: config.imageMaxPixels, + maxWidth: config.imageMaxWidth, + maxHeight: config.imageMaxHeight, +}); + +configureFontLimits({ + mem: config.fontProcessMem, + cpuTime: config.fontProcessCpuTime, + timeout: config.fontTimeout, +}); + +configureUploadLimits({ maxFileSize: config.maxFileSize, memoryThreshold: config.memoryThreshold }); + +const queueMiddleware = createQueueMiddleware(config.maxConcurrentRequests); + +app.use(timeoutMiddleware(config.requestTimeout)); +app.use(loggingMiddleware); + +app.get("/api/health", healthRoutes); +app.use("/api/image", sharedKeyAuth(config.sharedKey), queueMiddleware, createImageRoutes()); +app.use("/api/font", sharedKeyAuth(config.sharedKey), queueMiddleware, createFontRoutes()); +app.use(errorHandler); + +app.listen(config.port, config.host, () => { + logActiveTransports(logger); + logger.info(`media-processor listening on ${config.host}:${config.port}`); +}); + +export { app }; diff --git a/media-processor/src/logger.ts b/media-processor/src/logger.ts new file mode 100644 index 0000000000..aede1219d2 --- /dev/null +++ b/media-processor/src/logger.ts @@ -0,0 +1,135 @@ +import pino, { type TransportTargetOptions } from "pino"; +import { loadConfig } from "./config.js"; +import type { AppConfig } from "./types.js"; + +interface LogTransportProvider { + getTarget(): TransportTargetOptions | null; + getStartupMessage(): string | null; +} + +class ConsoleLogTransport implements LogTransportProvider { + public constructor(private readonly config: AppConfig) {} + + public getTarget(): TransportTargetOptions { + return { + target: "pino-pretty", + level: this.config.logLevel, + options: { + colorize: true, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + messageFormat: "{msg}", + levelFirst: true, + }, + }; + } + + public getStartupMessage(): string { + return "Logging to console"; + } +} + +class LokiLogTransport implements LogTransportProvider { + private readonly host: string | null; + + public constructor( + private readonly config: AppConfig, + lokiUri: string | null + ) { + this.host = lokiUri; + } + + public getTarget(): TransportTargetOptions | null { + if (this.host === null) { + return null; + } + return { + target: "pino-loki", + level: this.config.logLevel, + options: { + host: this.host, + json: false, + batching: true, + interval: 5, + replaceTimestamp: true, + labels: this.buildLabels(), + messageFormat: "{msg}", + ignore: "pid,hostname", + }, + }; + } + + private buildLabels(): Record { + const labels: Record = { + job: this.config.lokiJob, + }; + if (this.config.lokiEnvironment) { + labels.environment = this.config.lokiEnvironment; + } + if (this.config.lokiInstance) { + labels.instance = this.config.lokiInstance; + } + return labels; + } + + public getStartupMessage(): string | null { + return this.host !== null ? `Logging to Loki: ${this.host}` : null; + } +} + +function buildLogger(config: AppConfig) { + const consoleTransport = new ConsoleLogTransport(config); + const lokiTransport = new LokiLogTransport(config, config.lokiUri); + const transports: LogTransportProvider[] = [consoleTransport, lokiTransport]; + + const instance = pino({ + level: config.logLevel, + timestamp: pino.stdTimeFunctions.isoTime, + transport: { + targets: transports + .map((t) => t.getTarget()) + .filter((target): target is TransportTargetOptions => target !== null), + }, + }); + + return { instance, transports }; +} + +let _instance: pino.Logger | null = null; +let _transports: LogTransportProvider[] = []; + +export function initLogger(config: AppConfig): pino.Logger { + const result = buildLogger(config); + _instance = result.instance; + _transports = result.transports; + return _instance; +} + +function getInstance(): pino.Logger { + if (_instance === null) { + return initLogger(loadConfig()); + } + return _instance; +} + +// Export as a getter so consumers see the lazily-initialized instance. +export const logger: pino.Logger = new Proxy({} as pino.Logger, { + get(_, prop) { + const inst = getInstance(); + const value = (inst as unknown as Record)[prop]; + return typeof value === "function" ? value.bind(inst) : value; + }, +}); + +export function logActiveTransports(log: pino.Logger): void { + for (const t of _transports) { + const msg = t.getStartupMessage(); + if (msg !== null) { + log.info(msg); + } + } +} + +export function createLogger(name: string) { + return logger.child({ name }); +} diff --git a/media-processor/src/middleware/auth.ts b/media-processor/src/middleware/auth.ts new file mode 100644 index 0000000000..72e1ca743b --- /dev/null +++ b/media-processor/src/middleware/auth.ts @@ -0,0 +1,27 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; + +export function sharedKeyAuth(expectedKey: string | null) { + if (expectedKey === null) { + return (_req: Request, res: Response, _next: NextFunction): void => { + res.status(403).json({ type: "authorization", code: "forbidden", hint: "Shared key not configured" }); + }; + } + + return (req: Request, res: Response, next: NextFunction): void => { + const provided = req.headers["x-shared-key"]; + if (typeof provided !== "string") { + res.status(403).json({ type: "authorization", code: "forbidden" }); + return; + } + + const providedBuf = Buffer.from(provided); + const expectedBuf = Buffer.from(expectedKey); + + if (providedBuf.length === expectedBuf.length && timingSafeEqual(providedBuf, expectedBuf)) { + next(); + } else { + res.status(403).json({ type: "authorization", code: "forbidden" }); + } + }; +} diff --git a/media-processor/src/middleware/cleanup.ts b/media-processor/src/middleware/cleanup.ts new file mode 100644 index 0000000000..ec5796154a --- /dev/null +++ b/media-processor/src/middleware/cleanup.ts @@ -0,0 +1,25 @@ +import { rm } from "node:fs/promises"; +import type { Request, Response, NextFunction } from "express"; +import { createLogger } from "../logger.js"; + +const logger = createLogger("cleanup"); + +export function cleanupMiddleware(req: Request, _res: Response, next: NextFunction): void { + let cleaned = false; + + _res.on("finish", cleanup); + _res.on("close", cleanup); + + async function cleanup() { + if (cleaned) return; + cleaned = true; + const file = req.file as (Express.Multer.File & { path?: string }) | undefined; + if (file?.path) { + await rm(file.path, { force: true }).catch((err) => { + logger.debug({ err, path: file.path }, "Failed to cleanup uploaded file"); + }); + } + } + + next(); +} diff --git a/media-processor/src/middleware/error-handler.ts b/media-processor/src/middleware/error-handler.ts new file mode 100644 index 0000000000..f7834c59fe --- /dev/null +++ b/media-processor/src/middleware/error-handler.ts @@ -0,0 +1,55 @@ +import type { Request, Response, NextFunction } from "express"; +import type { AppError } from "../types.js"; +import { createLogger } from "../logger.js"; +import multer from "multer"; + +const logger = createLogger("error-handler"); + +export class ProcessingError extends Error { + public readonly statusCode: number; + public readonly errorBody: AppError; + + constructor(statusCode: number, body: AppError) { + super(body.hint ?? body.code); + this.statusCode = statusCode; + this.errorBody = body; + } +} + +function releaseSlot(res: Response): void { + const releaseQueue = (res as any).locals?.releaseQueue; + if (releaseQueue) releaseQueue(); +} + +export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { + if (res.headersSent) { + return; + } + + if (err instanceof ProcessingError) { + logger.warn({ err, statusCode: err.statusCode }, "Processing error"); + res.status(err.statusCode).json(err.errorBody); + releaseSlot(res); + return; + } + + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + logger.warn({ err }, "Upload size limit exceeded"); + res.status(413).json({ + type: "restriction", + code: "payload-too-large", + }); + releaseSlot(res); + return; + } + } + + logger.error({ err }, "Unhandled error"); + res.status(500).json({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + releaseSlot(res); +} diff --git a/media-processor/src/middleware/logging.ts b/media-processor/src/middleware/logging.ts new file mode 100644 index 0000000000..8c7ecc6948 --- /dev/null +++ b/media-processor/src/middleware/logging.ts @@ -0,0 +1,21 @@ +import type { Request, Response, NextFunction } from "express"; +import { logger } from "../logger.js"; + +const OP_NAMES: Record = { + "POST /api/image/info": "image/info", + "POST /api/image/thumbnail": "image/thumbnail", + "POST /api/font/convert": "font/convert", +}; + +export function loggingMiddleware(req: Request, res: Response, next: NextFunction): void { + const start = Date.now(); + res.on("finish", () => { + const path = req.originalUrl?.split("?")[0]; + const op = OP_NAMES[`${req.method} ${path}`]; + if (op) { + const meta = res.locals.opMeta ? `, ${res.locals.opMeta}` : ""; + logger.info(`op=${op}${meta}, status=${res.statusCode}, elapsed=${Date.now() - start}ms`); + } + }); + next(); +} diff --git a/media-processor/src/middleware/queue.ts b/media-processor/src/middleware/queue.ts new file mode 100644 index 0000000000..f6e6485548 --- /dev/null +++ b/media-processor/src/middleware/queue.ts @@ -0,0 +1,34 @@ +import type { Request, Response, NextFunction } from "express"; +import PQueue from "p-queue"; + +export function createQueueMiddleware(concurrency: number) { + const queue = new PQueue({ concurrency }); + + return function queueMiddleware(_req: Request, res: Response, next: NextFunction): void { + queue + .add( + () => + new Promise((resolve) => { + if (res.writableEnded) { + resolve(); + return; + } + + let released = false; + const release = () => { + if (!released) { + released = true; + resolve(); + } + }; + + // Store releaseQueue callback on res.locals so route handlers and error handler can call it + (res as any).locals = (res as any).locals || {}; + (res as any).locals.releaseQueue = release; + + next(); + }) + ) + .catch((err) => next(err instanceof Error ? err : new Error("Request processing failed"))); + }; +} diff --git a/media-processor/src/middleware/timeout.ts b/media-processor/src/middleware/timeout.ts new file mode 100644 index 0000000000..3b261a4082 --- /dev/null +++ b/media-processor/src/middleware/timeout.ts @@ -0,0 +1,35 @@ +import type { Request, Response, NextFunction } from "express"; + +export function timeoutMiddleware(timeout: number) { + return (req: Request, res: Response, next: NextFunction): void => { + // Create AbortController for request cancellation + const abortController = new AbortController(); + (req as any).abortController = abortController; + + const timer = setTimeout(() => { + if (!res.headersSent) { + res.status(504).json({ + type: "internal", + code: "processing-timeout", + hint: "Request timed out", + }); + // Abort the signal to cancel ongoing processing + abortController.abort(); + res.on("finish", () => req.destroy()); + } + }, timeout); + + // Clear timer on finish (successful completion) + res.on("finish", () => clearTimeout(timer)); + + // Clear timer and abort signal on close (client disconnect) + res.on("close", () => { + clearTimeout(timer); + if (!abortController.signal.aborted) { + abortController.abort(); + } + }); + + next(); + }; +} diff --git a/media-processor/src/routes/font.ts b/media-processor/src/routes/font.ts new file mode 100644 index 0000000000..885b6192c3 --- /dev/null +++ b/media-processor/src/routes/font.ts @@ -0,0 +1,55 @@ +import { Router, type IRouter, type Request, type Response, type NextFunction } from "express"; +import { getUpload, getFileInput } from "../upload.js"; +import { convertFont } from "../services/font.js"; +import { throwValidation } from "../services/errors.js"; +import { cleanupMiddleware } from "../middleware/cleanup.js"; + +const VALID_TARGET_MTYPES = new Set(["font/ttf", "font/otf", "font/woff"]); +const VALID_SOURCE_MTYPES = new Set(["font/ttf", "font/otf", "font/woff", "font/woff2"]); + +export function createFontRoutes(): IRouter { + const router: IRouter = Router(); + const upload = getUpload(); + + router.post( + "/convert", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-font", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const sourceMtype = req.file!.mimetype; + if (!VALID_SOURCE_MTYPES.has(sourceMtype)) { + throwValidation("invalid-font", `Unrecognized font mime-type: ${sourceMtype}`); + } + + const targetMtype = req.query["target-type"] as string; + if (!targetMtype || !VALID_TARGET_MTYPES.has(targetMtype)) { + throwValidation("invalid-font", `Invalid target-type. Must be one of: font/ttf, font/otf, font/woff`); + } + + res.locals.opMeta = `src=${sourceMtype}, dest=${targetMtype}`; + const result = await convertFont(input, sourceMtype, targetMtype, signal); + + if (!result) { + throwValidation("invalid-font", `Conversion from ${sourceMtype} to ${targetMtype} is not supported`); + } + + res.setHeader("Content-Type", targetMtype); + res.send(result); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + return router; +} diff --git a/media-processor/src/routes/health.ts b/media-processor/src/routes/health.ts new file mode 100644 index 0000000000..e598130a69 --- /dev/null +++ b/media-processor/src/routes/health.ts @@ -0,0 +1,5 @@ +import type { Request, Response } from "express"; + +export function healthRoutes(_req: Request, res: Response): void { + res.json({ status: "ok" }); +} diff --git a/media-processor/src/routes/image.ts b/media-processor/src/routes/image.ts new file mode 100644 index 0000000000..ac28c55b4d --- /dev/null +++ b/media-processor/src/routes/image.ts @@ -0,0 +1,95 @@ +import { Router, type IRouter, type Request, type Response, type NextFunction } from "express"; +import { getUpload, getFileInput } from "../upload.js"; +import { getImageInfo, generateThumbnail } from "../services/image.js"; +import { throwValidation } from "../services/errors.js"; +import { cleanupMiddleware } from "../middleware/cleanup.js"; +import type { ThumbnailParams } from "../types.js"; + +export function parseQuality(value: string | undefined, defaultValue = 85): number { + if (value === undefined) return defaultValue; + const parsed = parseInt(value, 10); + if (isNaN(parsed)) return defaultValue; + return Math.min(100, Math.max(1, parsed)); +} + +export function createImageRoutes(): IRouter { + const router: IRouter = Router(); + const upload = getUpload(); + + router.post( + "/info", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-image", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const info = await getImageInfo(input, req.file!.size, signal); + res.locals.opMeta = `mtype=${info.mtype}, size=${info.width}x${info.height}`; + res.json(info); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + router.post( + "/thumbnail", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-image", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const width = parseInt(req.query.width as string, 10); + const height = parseInt(req.query.height as string, 10); + const quality = parseQuality(req.query.quality as string); + const format = (req.query.format as string) || "jpeg"; + const mode = (req.query.mode as string) || "fit"; + + if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { + throwValidation("invalid-image", "width and height must be positive integers"); + } + + if (!["jpeg", "webp", "png"].includes(format)) { + throwValidation("invalid-image", `Unsupported format: ${format}`); + } + + if (!["fit", "crop"].includes(mode)) { + throwValidation("invalid-image", `Unsupported mode: ${mode}`); + } + + const params: ThumbnailParams = { + width, + height, + quality, + format: format as "jpeg" | "webp" | "png", + mode: mode as "fit" | "crop", + }; + + res.locals.opMeta = `size=${width}x${height}, fmt=${format}, mode=${mode}, q=${params.quality}`; + const { data, mtype } = await generateThumbnail(input, params, signal); + res.setHeader("Content-Type", mtype); + res.send(data); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + return router; +} diff --git a/media-processor/src/services/errors.ts b/media-processor/src/services/errors.ts new file mode 100644 index 0000000000..7afdf93e37 --- /dev/null +++ b/media-processor/src/services/errors.ts @@ -0,0 +1,26 @@ +import { ProcessingError } from "../middleware/error-handler.js"; +import type { AppError } from "../types.js"; + +export function throwValidation(code: string, hint?: string): never { + throw new ProcessingError(400, { + type: "validation", + code, + hint, + } satisfies AppError); +} + +export function throwRestriction(code: string, hint?: string): never { + throw new ProcessingError(413, { + type: "restriction", + code, + hint, + } satisfies AppError); +} + +export function throwProcessing(code: string, hint?: string): never { + throw new ProcessingError(503, { + type: "internal", + code, + hint, + } satisfies AppError); +} diff --git a/media-processor/src/services/font.ts b/media-processor/src/services/font.ts new file mode 100644 index 0000000000..16e4a0a091 --- /dev/null +++ b/media-processor/src/services/font.ts @@ -0,0 +1,313 @@ +import { execFile } from "node:child_process"; +import { writeFile, readFile, mkdtemp, rm, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { throwValidation, throwProcessing } from "./errors.js"; +import { createLogger } from "../logger.js"; +import type { FileInput } from "../types.js"; + +const logger = createLogger("font"); + +let fontProcessMem = 512; +let fontProcessCpuTime = 30; +let fontTimeout = 120000; + +export function configureFontLimits(opts: { mem: number; cpuTime: number; timeout: number }): void { + fontProcessMem = opts.mem; + fontProcessCpuTime = opts.cpuTime; + fontTimeout = opts.timeout; +} + +export function execCommand( + cmd: string, + args: string[], + timeout?: number, + options?: { encoding?: BufferEncoding | "buffer"; signal?: AbortSignal } +): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { + const effectiveTimeout = timeout ?? fontTimeout; + const encoding = options?.encoding ?? "utf8"; + + // Use prlimit on Linux for memory + CPU resource limits + // Matches backend's prlimit-based font processing protection + const isLinux = process.platform === "linux"; + let finalCmd = cmd; + let finalArgs = args; + + if (isLinux && cmd !== "prlimit") { + // Wrap with prlimit: address space ceiling + CPU time limit + const prlimitArgs = [ + `--as=${fontProcessMem * 1024 * 1024}`, // address space (memory) + `--cpu=${fontProcessCpuTime}`, // CPU seconds + "--", + cmd, + ...args, + ]; + finalCmd = "prlimit"; + finalArgs = prlimitArgs; + } + + return new Promise((resolve, reject) => { + execFile( + finalCmd, + finalArgs, + { + timeout: effectiveTimeout, + encoding: encoding === "buffer" ? null : encoding, + signal: options?.signal, + }, + (err, stdout, stderr) => { + if (err) { + const error = new Error(`Command failed: ${finalCmd} ${finalArgs.join(" ")}\n${stderr}`); + if (err.killed) (error as any).killed = err.killed; + if (err.signal) (error as any).signal = err.signal; + if (err.code !== null && err.code !== undefined) (error as any).code = err.code; + reject(error); + } else { + resolve({ stdout, stderr }); + } + } + ); + }); +} + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "penpot.font.")); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function withTempInput( + ext: string, + input: FileInput, + fn: (dir: string, inputPath: string) => Promise +): Promise { + return withTempDir(async (dir) => { + const inputPath = join(dir, `input${ext}`); + if (typeof input === "string") { + await copyFile(input, inputPath); + } else { + await writeFile(inputPath, input); + } + return fn(dir, inputPath); + }); +} + +async function fontConvert( + inputExt: string, + outputExt: string, + input: FileInput, + signal?: AbortSignal +): Promise { + return withTempDir(async (dir) => { + let inputPath: string; + if (typeof input === "string") { + inputPath = input; // Use path directly — avoids reading file into heap + } else { + inputPath = join(dir, `input${inputExt}`); + await writeFile(inputPath, input); // Write buffer to temp file + } + + // Ensure input path is from tmpdir to prevent injection + if (!inputPath.startsWith(tmpdir())) { + throw new Error("Font processing denied: input path is outside expected directory"); + } + + const outputPath = join(dir, `input${outputExt}`); + try { + // Escape single quotes for FontForge's string parser (not shell). + // execFile passes args as an array — no shell injection vector. + // FontForge's own lexer uses doubled single quotes for escaping. + const escInput = inputPath.replace(/'/g, "''"); + const escOutput = outputPath.replace(/'/g, "''"); + await execCommand("fontforge", ["-lang=ff", "-c", `Open('${escInput}'); Generate('${escOutput}')`], undefined, { + signal, + }); + return await readFile(outputPath); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + // Detect resource limit kills from prlimit (SIGKILL = OOM, SIGXCPU = CPU time exceeded) + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err, inputExt, outputExt }, "FontForge killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err, inputExt, outputExt }, "FontForge conversion failed"); + return null; + } + }); +} + +async function ttfToOtf(input: FileInput, signal?: AbortSignal): Promise { + return fontConvert(".ttf", ".otf", input, signal); +} + +async function otfToTtf(input: FileInput, signal?: AbortSignal): Promise { + return fontConvert(".otf", ".ttf", input, signal); +} + +async function sfntToWoff(input: FileInput, ext: string = ".ttf", signal?: AbortSignal): Promise { + return withTempInput(ext, input, async (dir, inputPath) => { + try { + await execCommand("sfnt2woff", [inputPath], undefined, { signal }); + const output = join(dir, "input.woff"); + return await readFile(output); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "sfnt2woff killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "sfnt2woff conversion failed"); + return null; + } + }); +} + +async function woffToSfnt(input: FileInput, signal?: AbortSignal): Promise { + return withTempInput(".woff", input, async (_dir, inputPath) => { + try { + const { stdout } = await execCommand("woff2sfnt", [inputPath], undefined, { encoding: "buffer", signal }); + return stdout as Buffer; + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "woff2sfnt killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "woff2sfnt conversion failed"); + return null; + } + }); +} + +async function woff2ToSfnt(input: FileInput, signal?: AbortSignal): Promise { + return withTempInput(".woff2", input, async (dir, inputPath) => { + const output = join(dir, "input.ttf"); + try { + await execCommand("woff2_decompress", [inputPath], undefined, { signal }); + return await readFile(output); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "woff2_decompress killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "woff2_decompress failed"); + return null; + } + }); +} + +function getSfntType(data: Buffer): "ttf" | "otf" { + const magic = data.subarray(0, 4).toString("hex"); + switch (magic) { + case "4f54544f": + return "otf"; + case "00010000": + return "ttf"; + default: + throwValidation("invalid-font", "Unrecognized font format"); + } +} + +async function convertFromSfnt(sfnt: Buffer, targetType: string, signal?: AbortSignal): Promise { + if (targetType === "ttf") { + const stype = getSfntType(sfnt); + if (stype === "ttf") return sfnt; + return otfToTtf(sfnt, signal); + } + if (targetType === "otf") { + const stype = getSfntType(sfnt); + if (stype === "otf") return sfnt; + return ttfToOtf(sfnt, signal); + } + if (targetType === "woff") { + return sfntToWoff(sfnt, ".ttf", signal); + } + return null; +} + +function validateFontSignature(data: Buffer, expectedType: string): void { + if (data.length < 4) { + throwValidation("invalid-font", "Font data too short"); + } + + const magic = data.subarray(0, 4).toString("hex"); + + switch (expectedType) { + case "ttf": + if (magic !== "00010000") { + throwValidation("invalid-font", "Invalid TTF signature"); + } + break; + case "otf": + if (magic !== "4f54544f") { + throwValidation("invalid-font", "Invalid OTF signature"); + } + break; + case "woff": + if (magic !== "774f4646") { + throwValidation("invalid-font", "Invalid WOFF signature"); + } + break; + case "woff2": + if (magic !== "774f4632") { + throwValidation("invalid-font", "Invalid WOFF2 signature"); + } + break; + } +} + +export async function convertFont( + input: FileInput, + sourceMtype: string, + targetMtype: string, + signal?: AbortSignal +): Promise { + const sourceType = sourceMtype.replace("font/", ""); + const targetType = targetMtype.replace("font/", ""); + + // Same type: validate signature and return data as-is + if (sourceType === targetType) { + let data: Buffer; + if (typeof input === "string") { + data = await readFile(input); + } else { + data = input; + } + validateFontSignature(data, sourceType); + return data; + } + + // Source is TTF + if (sourceType === "ttf") { + if (targetType === "otf") return ttfToOtf(input, signal); + if (targetType === "woff") return sfntToWoff(input, ".ttf", signal); + return null; + } + + // Source is OTF + if (sourceType === "otf") { + if (targetType === "ttf") return otfToTtf(input, signal); + if (targetType === "woff") return sfntToWoff(input, ".otf", signal); + return null; + } + + // Source is WOFF: extract sfnt first, then convert + if (sourceType === "woff") { + const sfnt = await woffToSfnt(input, signal); + if (!sfnt) { + throwValidation("invalid-font", "Could not extract SFNT from WOFF"); + } + return convertFromSfnt(sfnt, targetType, signal); + } + + // Source is WOFF2: decompress to sfnt, then convert + const sfnt = await woff2ToSfnt(input, signal); + if (!sfnt) { + throwValidation("invalid-font", "Could not decompress WOFF2"); + } + return convertFromSfnt(sfnt, targetType, signal); +} diff --git a/media-processor/src/services/image.ts b/media-processor/src/services/image.ts new file mode 100644 index 0000000000..302f41ddf5 --- /dev/null +++ b/media-processor/src/services/image.ts @@ -0,0 +1,204 @@ +import sharp from "sharp"; +import type { FileInput, ImageInfo, ThumbnailParams } from "../types.js"; +import { throwValidation, throwRestriction } from "./errors.js"; +import { createLogger } from "../logger.js"; + +const logger = createLogger("image"); + +const SUPPORTED_MIMES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +function orientationSwapDimensions( + width: number, + height: number, + orientation: number +): { width: number; height: number } { + if (orientation === 6 || orientation === 8) { + return { width: height, height: width }; + } + return { width, height }; +} + +let imageMaxPixels = 128_000_000; +let imageMaxWidth = 16384; +let imageMaxHeight = 16384; + +export function configureImageLimits(opts: { maxPixels: number; maxWidth: number; maxHeight: number }): void { + imageMaxPixels = opts.maxPixels; + imageMaxWidth = opts.maxWidth; + imageMaxHeight = opts.maxHeight; +} + +function validateImageDimensions(width: number, height: number): void { + if (width > imageMaxWidth || height > imageMaxHeight) { + throwRestriction( + "image-dimensions-exceeded", + `Image dimensions ${width}x${height} exceed maximum ${imageMaxWidth}x${imageMaxHeight}` + ); + } + const pixels = width * height; + if (pixels > imageMaxPixels) { + throwRestriction("image-pixel-count-exceeded", `Image pixel count ${pixels} exceeds maximum ${imageMaxPixels}`); + } +} + +function validateOutputDimensions(width: number, height: number): void { + if (width > imageMaxWidth || height > imageMaxHeight) { + throwRestriction( + "output-dimensions-exceeded", + `Requested output dimensions ${width}x${height} exceed maximum ${imageMaxWidth}x${imageMaxHeight}` + ); + } + const pixels = width * height; + if (pixels > imageMaxPixels) { + throwRestriction( + "output-pixel-count-exceeded", + `Requested output pixel count ${pixels} exceeds maximum ${imageMaxPixels}` + ); + } +} + +export async function getImageInfo(input: FileInput, size: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + let metadata; + try { + metadata = await sharp(input).metadata(); + } catch (err) { + throwValidation("invalid-image", `Failed to decode image: ${(err as Error).message}`); + } + + if (!metadata.width || !metadata.height) { + throwValidation("invalid-image", "Could not read image dimensions"); + } + + const mtype = metadata.format ? `image/${metadata.format}` : undefined; + if (!mtype || !SUPPORTED_MIMES.has(mtype)) { + throwValidation("invalid-image", `Unsupported image format: ${metadata.format}`); + } + + const orientation = metadata.orientation ?? 1; + const { width, height } = orientationSwapDimensions(metadata.width!, metadata.height!, orientation); + + validateImageDimensions(width, height); + + logger.debug({ width, height, mtype: mtype!, size }, "Image info extracted"); + + return { + width, + height, + mtype: mtype!, + size, + orientation, + }; +} + +const FORMAT_MIMES: Record = { + jpeg: "image/jpeg", + webp: "image/webp", + png: "image/png", +}; + +export async function generateThumbnail( + input: FileInput, + params: ThumbnailParams, + signal?: AbortSignal +): Promise<{ data: Buffer; mtype: string }> { + // Check if request was cancelled before starting + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + // Pre-validate source image dimensions using the same sharp instance + // that will be used for the resize pipeline. Sharp reads metadata + // (dimensions, orientation) from the image header without fully decoding + // the pixel data, then reuses the instance for the resize operations. + const source = sharp(input); + let srcMeta; + try { + srcMeta = await source.metadata(); + } catch (err) { + throwValidation("invalid-image", `Failed to decode image: ${(err as Error).message}`); + } + + // Check again after metadata read + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + if (srcMeta.width == null || srcMeta.height == null) { + throwValidation("invalid-image", "Could not read source image dimensions"); + } + + // Validate source image format + if (srcMeta.format && !SUPPORTED_MIMES.has(`image/${srcMeta.format}`)) { + throwValidation("unsupported-image-format", `Unsupported image format: ${srcMeta.format}`); + } + + const orientation = srcMeta.orientation ?? 1; + const { width: displayWidth, height: displayHeight } = orientationSwapDimensions( + srcMeta.width, + srcMeta.height, + orientation + ); + validateImageDimensions(displayWidth, displayHeight); + + // Validate requested output dimensions (important for crop mode which can enlarge) + validateOutputDimensions(params.width, params.height); + + logger.debug( + { width: params.width, height: params.height, format: params.format, mode: params.mode }, + "Generating thumbnail" + ); + + let pipeline = source.rotate(); + + // Only flatten for JPEG output (which doesn't support transparency). + // PNG and WebP support alpha, so preserve it. + if (params.format === "jpeg") { + pipeline = pipeline.flatten({ background: { r: 255, g: 255, b: 255 } }); + } + + if (params.mode === "fit") { + pipeline = pipeline.resize(params.width, params.height, { + fit: "inside", + withoutEnlargement: true, + }); + } else { + pipeline = pipeline.resize(params.width, params.height, { + fit: "cover", + position: "center", + }); + } + + switch (params.format) { + case "jpeg": + pipeline = pipeline.jpeg({ quality: params.quality }); + break; + case "webp": + pipeline = pipeline.webp({ quality: params.quality }); + break; + case "png": + pipeline = pipeline.png(); + break; + } + + let data: Buffer; + try { + // Sharp 0.35.3 does not support cancellation of native libvips operations. + // toBuffer() only accepts { resolveWithObject: boolean }, no AbortSignal. + // We hold the queue slot until Sharp completes fully, then check signal + // to throw if the request was cancelled during processing. This prevents + // concurrency limit violations and handles timeouts gracefully. + data = await pipeline.toBuffer(); + } catch (err) { + throwValidation("invalid-image", `Failed to process image: ${(err as Error).message}`); + } + + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + return { data, mtype: FORMAT_MIMES[params.format] }; +} diff --git a/media-processor/src/types.ts b/media-processor/src/types.ts new file mode 100644 index 0000000000..22c702f5f7 --- /dev/null +++ b/media-processor/src/types.ts @@ -0,0 +1,44 @@ +export type FileInput = Buffer | string; + +export interface AppConfig { + port: number; + host: string; + maxConcurrentRequests: number; + requestTimeout: number; + maxFileSize: number; + memoryThreshold: number; + imageMaxPixels: number; + imageMaxWidth: number; + imageMaxHeight: number; + fontProcessMem: number; + fontProcessCpuTime: number; + fontTimeout: number; + sharedKey: string | null; + logLevel: string; + lokiUri: string | null; + lokiJob: string; + lokiEnvironment: string | null; + lokiInstance: string | null; +} + +export interface ImageInfo { + width: number; + height: number; + mtype: string; + size: number; + orientation: number; +} + +export interface ThumbnailParams { + width: number; + height: number; + quality: number; + format: "jpeg" | "webp" | "png"; + mode: "fit" | "crop"; +} + +export interface AppError { + type: "validation" | "restriction" | "internal"; + code: string; + hint?: string; +} diff --git a/media-processor/src/upload-storage.ts b/media-processor/src/upload-storage.ts new file mode 100644 index 0000000000..1677e3efe7 --- /dev/null +++ b/media-processor/src/upload-storage.ts @@ -0,0 +1,89 @@ +import multer from "multer"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { Request } from "express"; + +interface HybridStorageOptions { + memoryThreshold: number; +} + +interface FileInfo { + destination: string; + filename: string; + path: string; + size: number; +} + +function getContentLength(req: Request): number { + const cl = req.headers["content-length"]; + if (!cl) return -1; + const parsed = parseInt(cl, 10); + return isNaN(parsed) ? -1 : parsed; +} + +export function createHybridStorage(opts: HybridStorageOptions): multer.StorageEngine { + const memoryStorage = multer.memoryStorage(); + + let tempDirPromise: Promise | null = null; + + async function ensureTempDir(): Promise { + if (!tempDirPromise) { + tempDirPromise = mkdtemp(join(tmpdir(), "penpot.upload.")); + } + return tempDirPromise; + } + + return { + _handleFile(req: Request, file: Express.Multer.File, cb: (error?: any, info?: Partial) => void): void { + const contentLength = getContentLength(req); + const useDisk = contentLength < 0 || contentLength >= opts.memoryThreshold; + + if (!useDisk) { + memoryStorage._handleFile(req, file, cb); + return; + } + + ensureTempDir() + .then((dir) => { + const filename = `${randomBytes(16).toString("hex")}${getExt(file.originalname)}`; + const filepath = join(dir, filename); + + const ws = createWriteStream(filepath); + + file.stream.pipe(ws); + + ws.on("error", (err: Error) => { + cb(err); + }); + + ws.on("finish", () => { + cb(null, { + destination: dir, + filename, + path: filepath, + size: ws.bytesWritten, + }); + }); + }) + .catch(cb); + }, + + _removeFile(req: Request, file: Express.Multer.File & { path?: string }, cb: (error: Error | null) => void): void { + if (file.path) { + rm(file.path, { force: true }) + .then(() => cb(null)) + .catch(() => cb(null)); + } else { + cb(null); + } + }, + }; +} + +function getExt(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot >= 0 ? filename.substring(dot) : ""; +} diff --git a/media-processor/src/upload.ts b/media-processor/src/upload.ts new file mode 100644 index 0000000000..3ba22fb899 --- /dev/null +++ b/media-processor/src/upload.ts @@ -0,0 +1,50 @@ +import multer from "multer"; +import { readFile } from "node:fs/promises"; +import { createHybridStorage } from "./upload-storage.js"; +import type { Request } from "express"; +import type { FileInput } from "./types.js"; + +let _upload: multer.Multer | null = null; + +// Hybrid storage: small uploads (< memoryThreshold) buffered in RAM for speed; +// large uploads streamed to disk to avoid heap pressure. +// Default threshold is 10MB. Disk files are cleaned up after response finishes. +export function configureUploadLimits(opts: { maxFileSize: number; memoryThreshold: number }): void { + const storage = createHybridStorage({ memoryThreshold: opts.memoryThreshold }); + + _upload = multer({ + storage, + limits: { fileSize: opts.maxFileSize }, + }); +} + +export function getUpload(): multer.Multer { + if (!_upload) { + throw new Error("Upload not configured — call configureUploadLimits first"); + } + return _upload; +} + +// Returns file input suitable for sharp and font processing. +// For disk-stored files, returns the file path (libvips uses mmap). +// For memory-stored files, returns the buffer. +export function getFileInput(file: Express.Multer.File): FileInput { + if (file.path) { + return file.path; + } + if (file.buffer) { + return file.buffer; + } + throw new Error("File has no buffer or path"); +} + +// Returns file contents as Buffer regardless of storage backend (memory or disk). +export async function getFileBuffer(file: Express.Multer.File): Promise { + if (file.buffer) { + return file.buffer; + } + if (file.path) { + return readFile(file.path); + } + throw new Error("File has no buffer or path"); +} diff --git a/media-processor/test/config.test.ts b/media-processor/test/config.test.ts new file mode 100644 index 0000000000..019cbd4947 --- /dev/null +++ b/media-processor/test/config.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("loadConfig", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("uses defaults when env vars not set", () => { + const config = loadConfig(); + expect(config.port).toBe(6065); + expect(config.host).toBe("0.0.0.0"); + expect(config.maxConcurrentRequests).toBe(10); + expect(config.requestTimeout).toBe(180000); + expect(config.maxFileSize).toBe(367001600); + expect(config.memoryThreshold).toBe(10485760); + }); + + it("accepts valid config with all fields set", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "8080"; + process.env.PENPOT_MEDIA_PROCESSOR_HOST = "127.0.0.1"; + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "20"; + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "30000"; + process.env.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE = "104857600"; + process.env.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD = "5242880"; + process.env.PENPOT_MEDIA_PROCESSOR_SHARED_KEY = "test-key"; + + const config = loadConfig(); + expect(config.port).toBe(8080); + expect(config.host).toBe("127.0.0.1"); + expect(config.maxConcurrentRequests).toBe(20); + expect(config.requestTimeout).toBe(30000); + expect(config.maxFileSize).toBe(104857600); + expect(config.memoryThreshold).toBe(5242880); + expect(config.sharedKey).toBe("test-key"); + }); + + it("rejects concurrency=0", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "0"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative concurrency", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "-5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional concurrency", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "2.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "-1000"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "1000.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional port", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "8080.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative port", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "-8080"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative max file size", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative memory threshold", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative image max pixels", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional image max width", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH = "100.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional image max height", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT = "100.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font process mem", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM = "-512"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font process cpu time", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME = "-30"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT = "-120000"; + expect(() => loadConfig()).toThrow(); + }); + + it("accepts concurrency=1 (minimum valid)", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "1"; + const config = loadConfig(); + expect(config.maxConcurrentRequests).toBe(1); + }); + + it("accepts timeout=0 (edge case, might be valid for testing)", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "0"; + const config = loadConfig(); + expect(config.requestTimeout).toBe(0); + }); +}); diff --git a/media-processor/test/fixtures/font-1.otf b/media-processor/test/fixtures/font-1.otf new file mode 100644 index 0000000000..9326ec7844 Binary files /dev/null and b/media-processor/test/fixtures/font-1.otf differ diff --git a/media-processor/test/fixtures/font-1.ttf b/media-processor/test/fixtures/font-1.ttf new file mode 100644 index 0000000000..cb2f335971 Binary files /dev/null and b/media-processor/test/fixtures/font-1.ttf differ diff --git a/media-processor/test/fixtures/font-1.woff b/media-processor/test/fixtures/font-1.woff new file mode 100644 index 0000000000..9607e1e194 Binary files /dev/null and b/media-processor/test/fixtures/font-1.woff differ diff --git a/media-processor/test/fixtures/font-1.woff2 b/media-processor/test/fixtures/font-1.woff2 new file mode 100644 index 0000000000..492d463d90 Binary files /dev/null and b/media-processor/test/fixtures/font-1.woff2 differ diff --git a/media-processor/test/font.test.ts b/media-processor/test/font.test.ts new file mode 100644 index 0000000000..cb0a462801 --- /dev/null +++ b/media-processor/test/font.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { convertFont, execCommand } from "../src/services/font.js"; +import { ProcessingError } from "../src/middleware/error-handler.js"; + +const FIXTURES = join(import.meta.dirname, "fixtures"); + +let ttfData: Buffer; +let otfData: Buffer; +let woffData: Buffer; +let woff2Data: Buffer; + +beforeAll(async () => { + [ttfData, otfData, woffData, woff2Data] = await Promise.all([ + readFile(join(FIXTURES, "font-1.ttf")), + readFile(join(FIXTURES, "font-1.otf")), + readFile(join(FIXTURES, "font-1.woff")), + readFile(join(FIXTURES, "font-1.woff2")), + ]); +}); + +describe("convertFont", () => { + describe("sourceType=ttf", () => { + it("ttf→ttf returns the input buffer unchanged", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/ttf"); + expect(result).toBe(ttfData); + }); + + it("ttf→otf returns non-null Buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("ttf→woff returns non-null Buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("sourceType=otf", () => { + it("otf→otf returns the input buffer unchanged", async () => { + const result = await convertFont(otfData, "font/otf", "font/otf"); + expect(result).toBe(otfData); + }); + + it("otf→ttf returns non-null Buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("otf→woff returns non-null Buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("sourceType=woff", () => { + it("woff→woff returns the input buffer unchanged", async () => { + const result = await convertFont(woffData, "font/woff", "font/woff"); + expect(result).toBe(woffData); + }); + + it("woff→ttf returns non-null Buffer", async () => { + const result = await convertFont(woffData, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff→otf returns Buffer or null (FontForge limitation)", async () => { + const result = await convertFont(woffData, "font/woff", "font/otf"); + // FontForge may fail to convert TTF-based WOFF to OTF for some fonts. + // The backend handles null gracefully (variant is just absent). + if (result !== null) { + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + } + }); + }); + + describe("sourceType=woff2", () => { + it("woff2→ttf returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff2→otf returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff2→woff returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("invalid input", () => { + it("woff with garbage data throws ProcessingError", async () => { + const garbage = Buffer.from("not a font at all"); + await expect(convertFont(garbage, "font/woff", "font/ttf")).rejects.toThrow(ProcessingError); + }); + + it("woff2 with garbage data throws ProcessingError", async () => { + const garbage = Buffer.from("not a font at all"); + await expect(convertFont(garbage, "font/woff2", "font/ttf")).rejects.toThrow(ProcessingError); + }); + + it("sfnt with garbage data throws validation error", async () => { + const garbage = Buffer.from("not a font at all"); + try { + await convertFont(garbage, "font/woff", "font/ttf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("ttf→ttf with invalid magic bytes throws validation error", async () => { + const invalidTtf = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidTtf, "font/ttf", "font/ttf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("otf→otf with invalid magic bytes throws validation error", async () => { + const invalidOtf = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidOtf, "font/otf", "font/otf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("woff→woff with invalid magic bytes throws validation error", async () => { + const invalidWoff = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidWoff, "font/woff", "font/woff"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("woff2→woff2 with invalid magic bytes throws validation error", async () => { + const invalidWoff2 = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidWoff2, "font/woff2", "font/woff2"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + }); + + describe("data integrity", () => { + it("ttf→otf produces valid font buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("otf→ttf produces valid font buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff→ttf produces valid SFNT with correct magic bytes", async () => { + const result = await convertFont(woffData, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + // SFNT magic: 00010000 (TTF) or 4f54544f (OTF/CFF) + const magic = result!.subarray(0, 4).toString("hex"); + expect(["00010000", "4f54544f"]).toContain(magic); + }); + + it("woff2→ttf produces valid font buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("file path input", () => { + it("ttf→otf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("ttf→ttf with file path returns file contents as Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBe(ttfData.length); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("woff→ttf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.woff`); + try { + await writeFile(tempPath, woffData); + const result = await convertFont(tempPath, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("ttf→woff with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("woff2→ttf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.woff2`); + try { + await writeFile(tempPath, woff2Data); + const result = await convertFont(tempPath, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + }); + + describe("path validation", () => { + it("rejects string input path outside tmpdir", async () => { + const outsidePath = "/etc/passwd"; + try { + await convertFont(outsidePath, "font/ttf", "font/otf"); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error; + expect(error.message).toContain("Font processing denied: input path is outside expected directory"); + } + }); + }); +}); + +describe("execCommand", () => { + it("preserves killed and signal properties from child process errors", async () => { + try { + await execCommand("false", []); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error & { killed?: boolean; signal?: string; code?: number }; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("Command failed"); + } + }); + + it("preserves error properties when command is killed by signal", async () => { + try { + await execCommand("sh", ["-c", "kill -KILL $$"], 5000); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error & { killed?: boolean; signal?: string }; + expect(error).toBeInstanceOf(Error); + expect(error.signal).toBe("SIGKILL"); + } + }); +}); diff --git a/media-processor/test/image.test.ts b/media-processor/test/image.test.ts new file mode 100644 index 0000000000..b80d8ec3f5 --- /dev/null +++ b/media-processor/test/image.test.ts @@ -0,0 +1,909 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { getImageInfo, generateThumbnail, configureImageLimits } from "../src/services/image.js"; +import { parseQuality } from "../src/routes/image.js"; +import { ProcessingError } from "../src/middleware/error-handler.js"; +import sharp from "sharp"; +import { writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const DEFAULT_LIMITS = { + maxPixels: 128_000_000, + maxWidth: 16384, + maxHeight: 16384, +}; + +afterEach(() => { + configureImageLimits(DEFAULT_LIMITS); +}); + +describe("getImageInfo", () => { + it("returns correct info for a PNG", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } }, + }) + .png() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(80); + expect(info.mtype).toBe("image/png"); + expect(info.size).toBe(buffer.length); + expect(info.orientation).toBe(1); + }); + + it("returns correct info for a JPEG", async () => { + const buffer = await sharp({ + create: { width: 200, height: 150, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(200); + expect(info.height).toBe(150); + expect(info.mtype).toBe("image/jpeg"); + }); + + it("returns correct info for a WebP", async () => { + const buffer = await sharp({ + create: { width: 300, height: 250, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }) + .webp() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(300); + expect(info.height).toBe(250); + expect(info.mtype).toBe("image/webp"); + }); + + it("throws on invalid image data", async () => { + const buffer = Buffer.from("not an image"); + await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow(); + }); + + it("returns correct info for a GIF", async () => { + // sharp create doesn't support GIF directly, so create PNG then convert + const pngBuffer = await sharp({ + create: { width: 120, height: 90, channels: 3, background: { r: 200, g: 100, b: 50 } }, + }) + .png() + .toBuffer(); + + const gifBuffer = await sharp(pngBuffer).gif().toBuffer(); + const info = await getImageInfo(gifBuffer, gifBuffer.length); + expect(info.width).toBe(120); + expect(info.height).toBe(90); + expect(info.mtype).toBe("image/gif"); + }); + + it("returns size equal to buffer length", async () => { + const buffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.size).toBe(buffer.length); + }); + + it("defaults orientation to 1 when no EXIF data", async () => { + const buffer = await sharp({ + create: { width: 60, height: 40, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.orientation).toBe(1); + }); + + it("throws on garbage data (sharp unsupported format)", async () => { + const buffer = Buffer.alloc(100, 0xff); + await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow(); + }); + + it("accepts file path input and returns correct info", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 128, b: 255 } }, + }) + .jpeg() + .toBuffer(); + + const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`); + try { + await writeFile(tempPath, buffer); + const info = await getImageInfo(tempPath, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(80); + expect(info.mtype).toBe("image/jpeg"); + expect(info.size).toBe(buffer.length); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("throws restriction when width exceeds limit", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 200, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-dimensions-exceeded"); + } + }); + + it("throws restriction when height exceeds limit", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 }); + const buffer = await sharp({ + create: { width: 50, height: 200, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-dimensions-exceeded"); + } + }); + + it("throws restriction when pixel count exceeds limit", async () => { + configureImageLimits({ maxPixels: 1000, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-pixel-count-exceeded"); + } + }); + + it("passes when dimensions are exactly at the limit", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 }); + const buffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(100); + }); + + it("throws when pixel count is exactly 1 over limit", async () => { + configureImageLimits({ maxPixels: 9999, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.errorBody.code).toBe("image-pixel-count-exceeded"); + } + }); + + it("throws when signal is aborted before processing", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const controller = new AbortController(); + controller.abort(); + + try { + await getImageInfo(buffer, buffer.length, controller.signal); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); +}); + +describe("generateThumbnail", () => { + const createImage = (w: number, h: number) => + sharp({ + create: { width: w, height: h, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + it("mode=fit produces thumbnail fitting within dimensions (no upscale)", async () => { + const buffer = await createImage(1000, 800); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(200); + expect(meta.height).toBeLessThanOrEqual(200); + expect(mtype).toBe("image/jpeg"); + }); + + it("mode=crop produces center-cropped thumbnail at exact dimensions", async () => { + const buffer = await createImage(1000, 800); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + expect(mtype).toBe("image/jpeg"); + }); + + it("supports webp output", async () => { + const buffer = await createImage(500, 400); + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 80, + format: "webp", + mode: "fit", + }); + expect(mtype).toBe("image/webp"); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(100); + }); + + it("supports png output", async () => { + const buffer = await createImage(500, 400); + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 80, + format: "png", + mode: "fit", + }); + expect(mtype).toBe("image/png"); + }); + + it("fit mode does not upscale small source", async () => { + const buffer = await createImage(50, 40); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(50); + expect(meta.height).toBe(40); + }); + + it("crop mode with non-square target", async () => { + const buffer = await createImage(1000, 500); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 100, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(100); + expect(mtype).toBe("image/jpeg"); + }); + + it("png output with crop mode", async () => { + const buffer = await createImage(800, 600); + const { data, mtype } = await generateThumbnail(buffer, { + width: 150, + height: 150, + quality: 80, + format: "png", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(150); + expect(meta.height).toBe(150); + expect(mtype).toBe("image/png"); + }); + + it("webp output with crop mode", async () => { + const buffer = await createImage(800, 600); + const { data, mtype } = await generateThumbnail(buffer, { + width: 150, + height: 150, + quality: 80, + format: "webp", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(150); + expect(meta.height).toBe(150); + expect(mtype).toBe("image/webp"); + }); + + it("source at exact target dimensions (fit mode) returns same size", async () => { + const buffer = await createImage(200, 200); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + }); + + it("source at exact target dimensions (crop mode) returns same size", async () => { + const buffer = await createImage(200, 200); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + }); + + it("very small source (1x1) with fit mode returns 1x1", async () => { + const buffer = await createImage(1, 1); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(1); + expect(meta.height).toBe(1); + }); + + it("throws restriction when source exceeds dimension limits", async () => { + configureImageLimits({ maxPixels: 1000, maxWidth: 50, maxHeight: 50 }); + const buffer = await createImage(200, 200); + + try { + await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + } + }); + + it("removes alpha channel from PNG source", async () => { + const pngBuffer = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data } = await generateThumbnail(pngBuffer, { + width: 50, + height: 50, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + // JPEG output should not have alpha + expect(meta.channels).toBe(3); + }); + + it("composites transparent PNG onto white background for JPEG", async () => { + // Create a fully transparent PNG — removeAlpha() would produce black, + // but the local ImageMagick path composites onto white. + const pngBuffer = await sharp({ + create: { width: 10, height: 10, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .png() + .toBuffer(); + + const { data } = await generateThumbnail(pngBuffer, { + width: 10, + height: 10, + quality: 85, + format: "jpeg", + mode: "fit", + }); + + // Sample a pixel — should be white (255,255,255), not black (0,0,0) + const pixel = await sharp(data).raw().toBuffer(); + const r = pixel[0]; + const g = pixel[1]; + const b = pixel[2]; + expect(r).toBe(255); + expect(g).toBe(255); + expect(b).toBe(255); + }); + + it("GIF source works with thumbnail generation", async () => { + const pngBuffer = await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 100, g: 100, b: 100 } }, + }) + .png() + .toBuffer(); + + const gifBuffer = await sharp(pngBuffer).gif().toBuffer(); + const { data, mtype } = await generateThumbnail(gifBuffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(100); + expect(meta.height).toBeLessThanOrEqual(100); + expect(mtype).toBe("image/jpeg"); + }); + + it("JPEG quality affects output file size", async () => { + // Create an image with actual detail (gradient) so quality matters + const width = 200; + const height = 200; + const channels = 3; + const rawBuffer = Buffer.alloc(width * height * channels); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * channels; + rawBuffer[idx] = (x * 255) / width; + rawBuffer[idx + 1] = (y * 255) / height; + rawBuffer[idx + 2] = ((x + y) * 255) / (width + height); + } + } + const buffer = await sharp(rawBuffer, { raw: { width, height, channels } }).jpeg().toBuffer(); + + const low = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 10, + format: "jpeg", + mode: "fit", + }); + const high = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 100, + format: "jpeg", + mode: "fit", + }); + expect(low.data.length).toBeLessThan(high.data.length); + }); + + it("accepts quality=1 (minimum valid quality)", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(buffer, { + width: 50, + height: 40, + quality: 1, + format: "jpeg", + mode: "fit", + }); + expect(data).toBeInstanceOf(Buffer); + expect(data.length).toBeGreaterThan(0); + expect(mtype).toBe("image/jpeg"); + }); + + it("accepts file path input for thumbnail generation", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`); + try { + await writeFile(tempPath, buffer); + const { data, mtype } = await generateThumbnail(tempPath, { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(50); + expect(meta.height).toBeLessThanOrEqual(40); + expect(mtype).toBe("image/jpeg"); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("throws validation error for unsupported source format (TIFF)", async () => { + const pngBuffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .png() + .toBuffer(); + + const tiffBuffer = await sharp(pngBuffer).tiff().toBuffer(); + + try { + await generateThumbnail(tiffBuffer, { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("unsupported-image-format"); + } + }); + + it("throws invalid-image for corrupted image data", async () => { + // Create corrupted image by truncating valid image + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + // Truncate to create corrupted data + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + try { + await getImageInfo(corruptedBuffer, corruptedBuffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-image"); + } + }); + + it("throws invalid-image for truncated image data in generateThumbnail", async () => { + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + // Truncate to create corrupted data + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + try { + await generateThumbnail(corruptedBuffer, { + width: 50, + height: 50, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-image"); + } + }); + + it("preserves alpha channel in PNG output from transparent PNG", async () => { + // Create a transparent PNG with alpha < 1 + const transparentPng = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentPng, { + width: 50, + height: 50, + quality: 85, + format: "png", + mode: "fit", + }); + + expect(mtype).toBe("image/png"); + const meta = await sharp(data).metadata(); + // PNG should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("preserves alpha channel in WebP output from transparent PNG", async () => { + const transparentPng = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 255, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentPng, { + width: 50, + height: 50, + quality: 85, + format: "webp", + mode: "fit", + }); + + expect(mtype).toBe("image/webp"); + const meta = await sharp(data).metadata(); + // WebP should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("preserves alpha channel in PNG output from transparent WebP", async () => { + // Create a transparent WebP + const transparentWebp = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 0.5 } }, + }) + .webp() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentWebp, { + width: 50, + height: 50, + quality: 85, + format: "png", + mode: "fit", + }); + + expect(mtype).toBe("image/png"); + const meta = await sharp(data).metadata(); + // PNG should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("throws restriction when requested width exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 200, + height: 50, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-dimensions-exceeded"); + } + }); + + it("throws restriction when requested height exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 50, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-dimensions-exceeded"); + } + }); + + it("throws restriction when requested pixel count exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-pixel-count-exceeded"); + } + }); + + it("accepts requested dimensions at the limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 }); + const buffer = await createImage(50, 50); + + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "crop", + }); + + expect(mtype).toBe("image/jpeg"); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(100); + expect(meta.height).toBe(100); + }); + + it("throws when signal is aborted before processing", async () => { + const buffer = await createImage(100, 80); + + const controller = new AbortController(); + controller.abort(); + + try { + await generateThumbnail( + buffer, + { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }, + controller.signal + ); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); + + it("aborts during toBuffer when signal fires", async () => { + const buffer = await createImage(2000, 2000); + + const controller = new AbortController(); + const signal = controller.signal; + + const promise = generateThumbnail( + buffer, + { + width: 1000, + height: 1000, + quality: 85, + format: "jpeg", + mode: "fit", + }, + signal + ); + + setTimeout(() => controller.abort(), 10); + + try { + await promise; + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); + + it("waits for Sharp to complete before checking signal", async () => { + const buffer = await createImage(100, 80); + + const controller = new AbortController(); + const signal = controller.signal; + + const promise = generateThumbnail( + buffer, + { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }, + signal + ); + + controller.abort(); + + try { + await promise; + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); +}); + +describe("parseQuality", () => { + it("returns default when value is undefined", () => { + expect(parseQuality(undefined)).toBe(85); + }); + + it("returns default when value is empty string", () => { + expect(parseQuality("")).toBe(85); + }); + + it("returns default when value is not a number", () => { + expect(parseQuality("abc")).toBe(85); + }); + + it("returns parsed value when valid", () => { + expect(parseQuality("50")).toBe(50); + }); + + it("clamps quality=0 to 1 (minimum valid)", () => { + expect(parseQuality("0")).toBe(1); + }); + + it("preserves quality=1 (minimum valid)", () => { + expect(parseQuality("1")).toBe(1); + }); + + it("clamps quality=101 to 100 (maximum valid)", () => { + expect(parseQuality("101")).toBe(100); + }); + + it("preserves quality=100 (maximum valid)", () => { + expect(parseQuality("100")).toBe(100); + }); + + it("uses custom default value", () => { + expect(parseQuality(undefined, 75)).toBe(75); + expect(parseQuality("abc", 75)).toBe(75); + }); +}); diff --git a/media-processor/test/middleware.test.ts b/media-processor/test/middleware.test.ts new file mode 100644 index 0000000000..f7b33c74f3 --- /dev/null +++ b/media-processor/test/middleware.test.ts @@ -0,0 +1,681 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ProcessingError, errorHandler } from "../src/middleware/error-handler.js"; +import { sharedKeyAuth } from "../src/middleware/auth.js"; +import { timeoutMiddleware } from "../src/middleware/timeout.js"; +import { cleanupMiddleware } from "../src/middleware/cleanup.js"; +import { throwValidation, throwRestriction } from "../src/services/errors.js"; +import multer from "multer"; +import type { Request, Response, NextFunction } from "express"; +import { EventEmitter } from "node:events"; + +function mockRes() { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + headersSent: false, + }; + return res as unknown as Response; +} + +function mockReq() { + return {} as Request; +} + +describe("ProcessingError", () => { + it("stores statusCode", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + }); + expect(err.statusCode).toBe(400); + }); + + it("stores errorBody", () => { + const body = { type: "validation" as const, code: "test-error", hint: "details" }; + const err = new ProcessingError(400, body); + expect(err.errorBody).toEqual(body); + }); + + it("message defaults to code when no hint", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + }); + expect(err.message).toBe("test-error"); + }); + + it("message uses hint when provided", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + hint: "something went wrong", + }); + expect(err.message).toBe("something went wrong"); + }); + + it("is an instance of Error", () => { + const err = new ProcessingError(500, { + type: "internal", + code: "internal-error", + }); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("throwValidation", () => { + it("throws ProcessingError with status 400", () => { + try { + throwValidation("bad-input", "invalid value"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.type).toBe("validation"); + expect(pe.errorBody.code).toBe("bad-input"); + expect(pe.errorBody.hint).toBe("invalid value"); + } + }); + + it("works without hint", () => { + try { + throwValidation("bad-input"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.errorBody.hint).toBeUndefined(); + } + }); +}); + +describe("throwRestriction", () => { + it("throws ProcessingError with status 413", () => { + try { + throwRestriction("too-large", "file exceeds limit"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.type).toBe("restriction"); + expect(pe.errorBody.code).toBe("too-large"); + expect(pe.errorBody.hint).toBe("file exceeds limit"); + } + }); + + it("works without hint", () => { + try { + throwRestriction("too-large"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.errorBody.hint).toBeUndefined(); + } + }); +}); + +describe("errorHandler", () => { + let res: ReturnType; + let next: NextFunction; + + beforeEach(() => { + res = mockRes(); + next = vi.fn(); + }); + + it("handles ProcessingError (400 validation)", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + }); + + it("handles ProcessingError (413 restriction)", () => { + const err = new ProcessingError(413, { + type: "restriction", + code: "payload-too-large", + }); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith({ + type: "restriction", + code: "payload-too-large", + }); + }); + + it("handles MulterError LIMIT_FILE_SIZE as 413", () => { + const err = new multer.MulterError("LIMIT_FILE_SIZE"); + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith({ + type: "restriction", + code: "payload-too-large", + }); + }); + + it("handles generic Error as 500", () => { + const err = new Error("something broke"); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + }); + + it("handles Error with empty message", () => { + const err = new Error(""); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + }); + + it("does not write response if headers already sent", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + + const resWithHeadersSent = { + ...res, + headersSent: true, + }; + + errorHandler(err, mockReq(), resWithHeadersSent, next); + + expect(resWithHeadersSent.status).not.toHaveBeenCalled(); + expect(resWithHeadersSent.json).not.toHaveBeenCalled(); + }); + + it("calls releaseQueue for ProcessingError", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new ProcessingError(400, { type: "validation", code: "test" }); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("calls releaseQueue for MulterError LIMIT_FILE_SIZE", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new multer.MulterError("LIMIT_FILE_SIZE"); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("calls releaseQueue for generic Error", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new Error("something broke"); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("does not throw when releaseQueue is not set", () => { + const resWithNoLocals = { ...res, locals: {} } as any; + const err = new ProcessingError(400, { type: "validation", code: "test" }); + + expect(() => errorHandler(err, mockReq(), resWithNoLocals, next)).not.toThrow(); + }); +}); + +describe("sharedKeyAuth", () => { + let res: ReturnType; + let next: NextFunction; + + beforeEach(() => { + res = mockRes(); + next = vi.fn(); + }); + + it("returns 403 when expectedKey is null", () => { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + type: "authorization", + code: "forbidden", + hint: "Shared key not configured", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 when expectedKey is null regardless of NODE_ENV", () => { + const originalEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("passes through with correct key", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-key" } } as unknown as Request; + middleware(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("returns 403 with wrong key", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "wrong-key" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ type: "authorization", code: "forbidden" }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 with missing header", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ type: "authorization", code: "forbidden" }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 with undefined header value", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": undefined } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 when key is null and NODE_ENV is production", () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + type: "authorization", + code: "forbidden", + hint: "Shared key not configured", + }); + expect(next).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("returns 403 for multibyte Unicode with same string length but different byte length", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-ké" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 for emoji input (multibyte)", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-k🔑" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 for accented characters with same string length", () => { + const middleware = sharedKeyAuth("abcdefgh"); + const req = { headers: { "x-shared-key": "ábcdefgh" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe("timeoutMiddleware", () => { + it("calls next() immediately", () => { + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("clears timer when response finishes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + res.emit("finish"); + + // Advance past timeout - should not throw + vi.advanceTimersByTime(2000); + vi.useRealTimers(); + }); + + it("clears timer when response closes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + res.emit("close"); + + // Advance past timeout - should not throw + vi.advanceTimersByTime(2000); + vi.useRealTimers(); + }); + + it("sends 504 response when timeout expires before response", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + vi.advanceTimersByTime(150); + + expect(res.status).toHaveBeenCalledWith(504); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-timeout", + hint: "Request timed out", + }); + vi.useRealTimers(); + }); + + it("does not send response if headers already sent", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = true; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + vi.advanceTimersByTime(150); + + expect(res.status).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("destroys request AFTER response finishes (not before)", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + + // Advance to timeout - this triggers the 504 response + vi.advanceTimersByTime(100); + + // req.destroy should NOT be called yet (response not finished) + expect(req.destroy).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(504); + + // Now simulate response finishing + res.emit("finish"); + + // Now req.destroy should be called + expect(req.destroy).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("creates AbortController and attaches to request", () => { + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + expect((req as any).abortController).toBeDefined(); + expect((req as any).abortController.signal).toBeDefined(); + expect((req as any).abortController.signal.aborted).toBe(false); + }); + + it("aborts signal when timeout fires", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + + // Signal should not be aborted yet + expect((req as any).abortController.signal.aborted).toBe(false); + + // Advance to timeout + vi.advanceTimersByTime(150); + + // Signal should now be aborted + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); + + it("does not abort signal when response finishes before timeout", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + // Response finishes before timeout + res.emit("finish"); + + // Advance past timeout + vi.advanceTimersByTime(2000); + + // Signal should NOT be aborted (timer was cleared) + expect((req as any).abortController.signal.aborted).toBe(false); + vi.useRealTimers(); + }); + + it("aborts signal when response closes (client disconnect)", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + // Signal should not be aborted initially + expect((req as any).abortController.signal.aborted).toBe(false); + + // Simulate client disconnect (response closes) + res.emit("close"); + + // Signal should now be aborted + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); + + it("does not abort signal again if already aborted when response closes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnValue({ json: vi.fn() }); + const next = vi.fn(); + + middleware(req, res, next); + + // Advance past timeout to trigger abort + vi.advanceTimersByTime(200); + + // Signal should be aborted from timeout + expect((req as any).abortController.signal.aborted).toBe(true); + + // Simulate client disconnect (response closes) + res.emit("close"); + + // Signal should still be aborted (no error thrown) + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); +}); + +describe("cleanupMiddleware", () => { + it("calls next() immediately", () => { + const req = {} as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("removes file on response finish", async () => { + const req = { + file: { + path: "/tmp/test-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + // Mock the rm function by spying on the cleanup behavior + // We'll verify the middleware registers the finish handler + cleanupMiddleware(req, res, next); + + // Emit finish event - this should trigger cleanup + // The actual rm is mocked internally, so we just verify no errors + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("removes file on response close", async () => { + const req = { + file: { + path: "/tmp/test-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit close event - this should trigger cleanup + res.emit("close"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("does nothing when req.file is undefined", async () => { + const req = {} as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("does nothing when req.file.path is undefined", async () => { + const req = { + file: { + buffer: Buffer.from("test"), + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("doesn't throw when file doesn't exist", async () => { + const req = { + file: { + path: "/tmp/nonexistent-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw even if file doesn't exist + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +}); diff --git a/media-processor/test/queue.test.ts b/media-processor/test/queue.test.ts new file mode 100644 index 0000000000..cd6d26cc94 --- /dev/null +++ b/media-processor/test/queue.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createQueueMiddleware } from "../src/middleware/queue.js"; +import type { Request, Response, NextFunction } from "express"; +import { EventEmitter } from "node:events"; + +function mockRes() { + const res = new EventEmitter() as any; + res.status = vi.fn().mockReturnThis(); + res.json = vi.fn().mockReturnThis(); + res.send = vi.fn().mockReturnThis(); + res.headersSent = false; + res.writableEnded = false; + return res as Response; +} + +function mockReq() { + return {} as Request; +} + +describe("queueMiddleware", () => { + it("calls next() when queue has capacity", async () => { + const middleware = createQueueMiddleware(1); + const req = mockReq(); + const res = mockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("skips next() when res.writableEnded is true (timeout already sent)", async () => { + const middleware = createQueueMiddleware(1); + const req = mockReq(); + const res = mockRes(); + (res as any).writableEnded = true; + const next = vi.fn(); + + middleware(req, res, next); + + // next() should NOT be called because response already ended + expect(next).not.toHaveBeenCalled(); + }); + + it("queues requests when concurrency limit reached", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + // First request takes the slot + middleware(req1, res1, next1); + expect(next1).toHaveBeenCalled(); + + // Second request should queue + middleware(req2, res2, next2); + expect(next2).not.toHaveBeenCalled(); + + // Release first request's queue slot (simulating processing completion) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + // Now second request should proceed + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next2).toHaveBeenCalled(); + }); + + it("resolves promise when releaseQueue is called", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Release first request's queue slot + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next2).toHaveBeenCalled(); + }); + + it("processes requests sequentially with concurrency 1", async () => { + const middleware = createQueueMiddleware(1); + const order: number[] = []; + + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(() => order.push(1)); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(() => order.push(2)); + + const req3 = mockReq(); + const res3 = mockRes(); + const next3 = vi.fn(() => order.push(3)); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + middleware(req3, res3, next3); + + // Only first should be called immediately + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Release first request's queue slot + const releaseQueue1 = (res1 as any).locals?.releaseQueue; + expect(releaseQueue1).toBeDefined(); + releaseQueue1(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second should be called + expect(next2).toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Release second request's queue slot + const releaseQueue2 = (res2 as any).locals?.releaseQueue; + expect(releaseQueue2).toBeDefined(); + releaseQueue2(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now third should be called + expect(next3).toHaveBeenCalled(); + + // Verify sequential order + expect(order).toEqual([1, 2, 3]); + }); + + it("processes requests in parallel with concurrency 10", async () => { + const middleware = createQueueMiddleware(10); + const calls: number[] = []; + + // Create 5 requests (less than concurrency limit) + const requests = Array.from({ length: 5 }, (_, i) => { + const req = mockReq(); + const res = mockRes(); + const next = vi.fn(() => calls.push(i)); + return { req, res, next }; + }); + + // All should be called immediately + requests.forEach(({ req, res, next }) => { + middleware(req, res, next); + }); + + // All 5 should be called immediately since concurrency is 10 + expect(calls.length).toBe(5); + expect(calls).toEqual([0, 1, 2, 3, 4]); + }); + + it("handles request errors gracefully", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Simulate error by releasing queue slot (as would happen in finally block) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should still proceed even after first "error" + expect(next2).toHaveBeenCalled(); + }); + + it("doesn't block on slow requests within concurrency limit", async () => { + const middleware = createQueueMiddleware(2); + const calls: number[] = []; + + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(() => calls.push(1)); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(() => calls.push(2)); + + const req3 = mockReq(); + const res3 = mockRes(); + const next3 = vi.fn(() => calls.push(3)); + + // Start first two requests (concurrency is 2) + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Both should be called immediately + expect(next1).toHaveBeenCalled(); + expect(next2).toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Third request should wait + middleware(req3, res3, next3); + expect(next3).not.toHaveBeenCalled(); + + // Release first request's queue slot + const releaseQueue1 = (res1 as any).locals?.releaseQueue; + expect(releaseQueue1).toBeDefined(); + releaseQueue1(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now third should proceed + expect(next3).toHaveBeenCalled(); + }); + + it("releases slot when error handler calls releaseQueue (covers Multer error path)", async () => { + // This test verifies that the error handler releases the queue slot + // by calling releaseQueue from res.locals. This covers the Multer error + // case where the route handler never runs. + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate error handler calling releaseQueue (e.g., Multer error) + const releaseQueue = (res1 as any).locals.releaseQueue; + releaseQueue(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed because slot was released + expect(next2).toHaveBeenCalled(); + }); + + it("releases slot when releaseQueue callback is called", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate processing completing by calling releaseQueue + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second request should proceed + expect(next2).toHaveBeenCalled(); + }); + + it("releases slot on processing error (via finally block)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate processing error and release in finally block + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed even after error + expect(next2).toHaveBeenCalled(); + }); + + it("releaseQueue is idempotent (can be called multiple times)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Call releaseQueue multiple times + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + releaseQueue(); // Should not throw or cause issues + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed + expect(next2).toHaveBeenCalled(); + }); + + it("holds queue slot when client disconnects (close event)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate client disconnect (close event) + res1.emit("close"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should NOT proceed because slot is still held + expect(next2).not.toHaveBeenCalled(); + + // Now release the slot (simulating processing completion) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second request should proceed + expect(next2).toHaveBeenCalled(); + }); +}); diff --git a/media-processor/test/routes-integration.test.ts b/media-processor/test/routes-integration.test.ts new file mode 100644 index 0000000000..26fa0b36de --- /dev/null +++ b/media-processor/test/routes-integration.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import request from "supertest"; +import express from "express"; +import { createImageRoutes } from "../src/routes/image.js"; +import { createFontRoutes } from "../src/routes/font.js"; +import { errorHandler } from "../src/middleware/error-handler.js"; +import { timeoutMiddleware } from "../src/middleware/timeout.js"; +import { sharedKeyAuth } from "../src/middleware/auth.js"; +import { createQueueMiddleware } from "../src/middleware/queue.js"; +import { configureImageLimits } from "../src/services/image.js"; +import { configureFontLimits } from "../src/services/font.js"; +import { configureUploadLimits } from "../src/upload.js"; +import sharp from "sharp"; +import { readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Test app with low memoryThreshold to force disk storage +function createTestApp() { + const app = express(); + + // Configure with low threshold to force disk storage + configureImageLimits({ + maxPixels: 128_000_000, + maxWidth: 16384, + maxHeight: 16384, + }); + + configureFontLimits({ + mem: 1024 * 1024 * 512, + cpuTime: 30, + timeout: 30, + }); + + // Very low threshold to force disk storage for small files + configureUploadLimits({ maxFileSize: 10 * 1024 * 1024, memoryThreshold: 10 }); + + const queueMiddleware = createQueueMiddleware(10); + + app.use(timeoutMiddleware(5000)); + app.use("/api/image", sharedKeyAuth("test-key"), queueMiddleware, createImageRoutes()); + app.use("/api/font", sharedKeyAuth("test-key"), queueMiddleware, createFontRoutes()); + app.use(errorHandler); + + return app; +} + +describe("HTTP upload cleanup", () => { + let app: ReturnType; + + beforeAll(() => { + app = createTestApp(); + }); + + async function getTempFiles(): Promise { + const tmp = tmpdir(); + const files = await readdir(tmp); + const uploadDirs = files.filter((f) => f.startsWith("penpot.upload.")); + + // Get all files inside upload directories + const allFiles: string[] = []; + for (const dir of uploadDirs) { + try { + const dirPath = join(tmp, dir); + const dirFiles = await readdir(dirPath); + allFiles.push(...dirFiles.map((f) => join(dir, f))); + } catch { + // Directory might not exist or be inaccessible + } + } + return allFiles; + } + + it("removes disk-backed file after successful image/info request", async () => { + const beforeFiles = await getTempFiles(); + + // Create a small image + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.body.width).toBe(100); + expect(response.body.height).toBe(100); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + + // No new temp files should remain + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after successful image/thumbnail request", async () => { + const beforeFiles = await getTempFiles(); + + const imageBuffer = await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .png() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=100&height=100&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.png", contentType: "image/png" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after successful font/convert request", async () => { + const beforeFiles = await getTempFiles(); + + // Create a minimal TTF font (this is a simplified test - in reality you'd use a real font) + // For this test, we'll just verify the cleanup happens even if the conversion fails + const fontBuffer = Buffer.from("not a real font"); + + const response = await request(app) + .post("/api/font/convert?target-type=font/woff") + .set("x-shared-key", "test-key") + .attach("file", fontBuffer, { filename: "test.ttf", contentType: "font/ttf" }); + + // The conversion will fail, but cleanup should still happen + // We expect either 400 (invalid font) or 500 (processing error) + expect([400, 500]).toContain(response.status); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after failed request", async () => { + const beforeFiles = await getTempFiles(); + + // Send invalid image data + const invalidBuffer = Buffer.from("not an image"); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", invalidBuffer, { filename: "invalid.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after timeout", async () => { + // Create a test app with very short timeout + const timeoutApp = express(); + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 16384 }); + configureUploadLimits({ maxFileSize: 10 * 1024 * 1024, memoryThreshold: 10 }); + const queueMiddleware = createQueueMiddleware(10); + timeoutApp.use(timeoutMiddleware(10)); // 10ms timeout - very aggressive + timeoutApp.use("/api/image", sharedKeyAuth("test-key"), queueMiddleware, createImageRoutes()); + timeoutApp.use(errorHandler); + + const beforeFiles = await getTempFiles(); + + // Create a large image that will take time to process + const imageBuffer = await sharp({ + create: { width: 4000, height: 4000, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg({ quality: 100 }) + .toBuffer(); + + const response = await request(timeoutApp) + .post("/api/image/thumbnail?width=2000&height=2000&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "large.jpg", contentType: "image/jpeg" }); + + // Should timeout + expect(response.status).toBe(504); + expect(response.body.type).toBe("internal"); + expect(response.body.code).toBe("processing-timeout"); + + // Wait for processing to settle (Sharp may still be working in background) + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); +}); + +describe("HTTP malformed image handling", () => { + let app: ReturnType; + + beforeAll(() => { + app = createTestApp(); + }); + + it("returns 400 for corrupted image in /api/image/info", async () => { + // Create a valid image then truncate it + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", corruptedBuffer, { filename: "corrupted.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + expect(response.body.type).toBe("validation"); + expect(response.body.code).toBe("invalid-image"); + }); + + it("returns 400 for corrupted image in /api/image/thumbnail", async () => { + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", corruptedBuffer, { filename: "corrupted.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + expect(response.body.type).toBe("validation"); + expect(response.body.code).toBe("invalid-image"); + }); +}); + +describe("HTTP quality parameter clamping", () => { + let app: ReturnType; + + beforeAll(() => { + app = createTestApp(); + }); + + it("clamps quality=0 to 1 at route level", async () => { + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&quality=0&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + }); + + it("clamps quality=101 to 100 at route level", async () => { + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&quality=101&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + }); +}); diff --git a/media-processor/test/setup.ts b/media-processor/test/setup.ts new file mode 100644 index 0000000000..b4c413ce0a --- /dev/null +++ b/media-processor/test/setup.ts @@ -0,0 +1 @@ +process.env.PENPOT_MEDIA_PROCESSOR_LOG_LEVEL = "silent"; diff --git a/media-processor/test/upload-storage.test.ts b/media-processor/test/upload-storage.test.ts new file mode 100644 index 0000000000..b2613e533a --- /dev/null +++ b/media-processor/test/upload-storage.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createHybridStorage } from "../src/upload-storage.js"; +import type { Request } from "express"; +import { Readable } from "node:stream"; +import { rm } from "node:fs/promises"; + +function mockReq(contentLength?: string): Request { + const headers: Record = {}; + if (contentLength !== undefined) { + headers["content-length"] = contentLength; + } + return { headers } as Request; +} + +function mockFile(content: string = "test content") { + const stream = Readable.from([content]); + return { + fieldname: "file", + originalname: "test.txt", + encoding: "7bit", + mimetype: "text/plain", + stream, + } as Express.Multer.File; +} + +describe("createHybridStorage", () => { + let storage: ReturnType; + let tempDirs: string[] = []; + + beforeEach(() => { + storage = createHybridStorage({ memoryThreshold: 1024 }); + }); + + afterEach(async () => { + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } + tempDirs = []; + }); + + it("uses memory storage when Content-Length is below threshold", async () => { + const req = mockReq("100"); + const file = mockFile("small content"); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeUndefined(); + expect((info as any).buffer).toBeDefined(); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is above threshold", async () => { + const req = mockReq("2048"); + const file = mockFile("x".repeat(2048)); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is absent (chunked transfer)", async () => { + const req = mockReq(); + const file = mockFile("chunked content"); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is invalid", async () => { + const req = mockReq("not-a-number"); + const file = mockFile("content"); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("removes file from disk", async () => { + const req = mockReq("2048"); + const file = mockFile("x".repeat(2048)); + + const info = await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }); + + tempDirs.push(info.destination); + + await new Promise((resolve, reject) => { + storage._removeFile(req, { ...file, path: info.path } as any, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }); + + it("uses memory storage when Content-Length is 0", async () => { + const req = mockReq("0"); + const file = mockFile(""); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeUndefined(); + expect((info as any).buffer).toBeDefined(); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length equals threshold", async () => { + const req = mockReq("1024"); + const file = mockFile("x".repeat(1024)); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is very large", async () => { + const req = mockReq("1073741824"); // 1GB + const file = mockFile("x"); // Small actual content, but large Content-Length + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("reuses the same temp directory for concurrent uploads", async () => { + const req1 = mockReq("2048"); + const file1 = mockFile("x".repeat(2048)); + const req2 = mockReq("2048"); + const file2 = mockFile("y".repeat(2048)); + + const [info1, info2] = await Promise.all([ + new Promise((resolve, reject) => { + storage._handleFile(req1, file1, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }), + new Promise((resolve, reject) => { + storage._handleFile(req2, file2, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }), + ]); + + tempDirs.push(info1.destination); + tempDirs.push(info2.destination); + + // Both uploads should use the same temp directory + expect(info1.destination).toBe(info2.destination); + }); +}); diff --git a/media-processor/tsconfig.json b/media-processor/tsconfig.json new file mode 100644 index 0000000000..b5edd7fa16 --- /dev/null +++ b/media-processor/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/media-processor/vitest.config.ts b/media-processor/vitest.config.ts new file mode 100644 index 0000000000..ad177edacb --- /dev/null +++ b/media-processor/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + envPrefix: [], + test: { + setupFiles: ["./test/setup.ts"], + }, +});