From aeedb9626087ead44c6639a6ceb7451568772f9b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 09:41:48 +0200 Subject: [PATCH] :sparkles: Add media-processor service for image and font processing (#10767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add media-processor service for image and font processing Externalizes ImageMagick and FontForge subprocess invocations into a separate Node.js HTTP service (media-processor/). Backend dispatches via feature flag :use-remote-media-processing. Key changes: - media-processor module (TypeScript, Express 5, Sharp, FontForge/woff) - POST /api/image/info, /api/image/thumbnail, /api/font/generate - Resource limits: 128MP rejection, prlimit (512MB + 30s CPU) - Streaming multipart via SequenceInputStream - app.media split into validation (leaf), local (shell impls), remote (HTTP) - Schema enforcement: :upload and :input schemas in validation namespace - Configurable timeout (PENPOT_MEDIA_PROCESSING_SERVICE_TIMEOUT) - 78 tests across 4 files (image, font, middleware, config) - FontForge path escaping for command injection prevention - Parallel font variant conversions with Promise.all AI-assisted-by: mimo-v2.5-pro * :whale: Revert docker-compose changes from media-processor commit Remove docker-compose.yaml modifications that were part of the media-processor service commit. The media-processor service definition, flags, and environment variables are reverted to their previous state. AI-assisted-by: qwen3.7-plus * :arrow_up: Update dependencies * :bug: Fix PR review issues in media-processor - Font path bug: sfntToWoff and woff2ToSfnt now copy input to temp dir when input is a file path, ensuring output lands in expected location - Error preservation: execCommand preserves killed/signal/code properties from child process errors for OOM detection - Content-Length: service-multipart-request calculates and includes Content-Length header for streaming multipart requests AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor - Rename PENPOT_MEDIA_PROCESSOR_SECRET_KEY to PENPOT_MEDIA_PROCESSOR_SHARED_KEY in devenv to match backend config key - Fix timeout middleware to destroy request AFTER response finishes, preventing truncated 504 responses - Fix quality=0 parsing to preserve explicit zero (was silently overridden to 85) - Replace require('fs') with proper ES module import in upload-storage.ts - Refactor font conversion temp-dir boilerplate into withTempInput helper - Document FontForge escaping limitations (single quotes only) - Fix misleading comment in image.ts about sharp metadata decoding AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 2) - Fix queue middleware to skip next() when response already ended, preventing orphaned work after timeout - Fix hybrid storage to use disk when Content-Length is absent (chunked transfer), preventing unbounded memory allocation - Add source image format validation in generateThumbnail to reject unsupported formats (TIFF, BMP, etc.) with 400 instead of 500 - Remove dead code in convertFont for unreachable woff→woff path - Remove unused isEnabled() method from LokiLogTransport - Fix sfntToWoff to use correct extension (.ttf/.otf) based on source type - Extract queue middleware to separate file for testability - Add comprehensive tests for queue middleware and upload storage AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 3) - Fix disk-backed upload cleanup after successful requests by adding cleanup middleware that removes temp files on response finish/close - Wrap sharp metadata/decoding errors as 400 validation errors instead of 500 internal errors - Only apply flatten() for JPEG output to preserve alpha channel in PNG and WebP outputs AI-assisted-by: qwen3.7-plus * :sparkles: Add comprehensive tests for media-processor Phase 1 - Cleanup verification: - Add cleanup middleware unit tests (6 tests) - Add HTTP upload cleanup integration tests (5 tests) Phase 2 - Error handling & alpha preservation: - Add sharp error wrapping tests (4 tests) - Add HTTP malformed image tests (2 tests) - Add alpha preservation tests (3 tests) Phase 3 - Edge cases: - Add upload storage edge case tests (3 tests) - Add queue middleware edge case tests (4 tests) Phase 4 - Backend mock verification: - Fix backend mocks to include :mtype field in image info responses - Verify all error codes match actual service behavior Total: 27 new tests added (160 tests passing) AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 4) - Add Zod validation constraints for config values (int, positive, min) - Fix auth middleware to compare Buffer byte lengths instead of string lengths - Validate requested output dimensions in generateThumbnail (crop mode) - Change queue middleware to release slot via callback in finally block - Add comprehensive tests for all fixes AI-assisted-by: qwen3.7-plus * :bug: Close HTTP response streams in backend media remote - Wrap stream consumption in try/finally with .close() calls - Add tests to verify stream closure for info, font-convert, and thumbnail AI-assisted-by: qwen3.7-plus * :bug: Fix queue slot leak on upload failures Make releaseQueue idempotent and attach fallback listener to release slot when response finishes. This covers Multer errors that bypass the route handler's finally block, preventing permanent queue stall. AI-assisted-by: qwen3.7-plus * :bug: Cancel processing on timeout Create AbortController in timeout middleware and abort signal when timeout fires. Pass signal to Sharp and FontForge to cancel ongoing processing and release resources when request is cancelled. AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 6) - Error handler: check headersSent before writing response to prevent ERR_HTTP_HEADERS_SENT when timeout already sent 504 - Timeout config: increase default requestTimeout from 60s to 180s to match font processing timeout (120s) and backend request timeout - Image processing: check abort signal before starting Sharp operations to cancel processing when timeout fires - Queue lifecycle: remove res.on('close', release) fallback to hold queue slot until processing completes, preventing concurrency limit violation when client disconnects AI-assisted-by: qwen3.7-plus * :bug: Close HTTP response stream in download-image Wrap response body in with-open to ensure stream is closed after writing to temp file, preventing HTTP connection leaks on repeated URL imports. AI-assisted-by: qwen3.7-plus * :bug: Close HTTP response stream on validation errors in download-image Move with-open to wrap the entire validation and processing block, ensuring the response body stream is closed even when validation fails (non-2xx status, missing size, invalid media type). This prevents HTTP connection leaks on repeated failed downloads. Add test to verify stream closure on validation errors. AI-assisted-by: qwen3.7-plus * :bug: Pass abort signal to Sharp toBuffer for timeout cancellation Wrap Sharp's toBuffer() with Promise.race to check abort signal during processing. This ensures large thumbnails stop processing when the request times out, preventing wasted CPU/memory and queue capacity. Add test to verify abort during toBuffer operation. AI-assisted-by: qwen3.7-plus * :bug: Hold queue slot until Sharp completes and handle client disconnect - Remove Promise.race from generateThumbnail — Sharp processing now completes fully before queue slot is released, preventing concurrency limit violations under timeout conditions - Remove res.on("finish", release) fallback from queue middleware — error handler now explicitly calls releaseQueue in all error paths - Add res.on("close") handler in timeout middleware to abort signal when client disconnects, ensuring processing stops early - Add tests for client disconnect handling and queue slot lifecycle AI-assisted-by: qwen3.7-plus * :bug: Address round 9 review findings - Document Sharp 0.35.3 cancellation limitation in image.ts - Add integration test for timeout cleanup with large images - Fix font tools (sfntToWoff, woffToSfnt, woff2ToSfnt) to throw ProcessingError on resource limit kills instead of returning null - Validate font signatures for same-format conversions to prevent arbitrary files from being persisted as valid fonts - Fix concurrent mkdtemp race in upload-storage by using shared initialization promise AI-assisted-by: qwen3.7-plus * :bug: Address round 10 review findings - Add tmpdir assertion in font.ts to prevent path injection - Preserve original error in queue middleware catch handler - Change auth middleware response type from "internal" to "authorization" - Add cleanup flag to prevent double cleanup in cleanup middleware - Move quality clamping into parseQuality function for consistency - Add integration tests for quality parameter clamping at route level - Update existing tests to match new auth response type AI-assisted-by: qwen3.7-plus * :bug: Address round 11 review findings - Extract releaseSlot helper in error-handler to reduce duplication - Remove redundant try/catch in font.ts withTempDir cleanup - Improve font path validation error message for clarity - Move path validation before try/catch to prevent swallowing - Add debug logging for cleanup failures in cleanup middleware - Inline TransportTargetSpec type alias in logger.ts - Extract logging middleware to separate file for consistency - Remove duplicate MIME validation in image thumbnail route - Add test for font path validation (outside tmpdir rejection) - Add tests for error handler queue release across all branches AI-assisted-by: qwen3.7-plus * :bug: Remove Content-Length header from multipart requests The JDK's HttpClient rejects Content-Length as a restricted header, causing IllegalArgumentException when sending multipart requests to the media-processor. Remove the explicit Content-Length header and let the JDK use chunked transfer encoding. The media-processor will use disk storage for all multipart requests (safe default behavior). Remove unused size computations (file-size, header-bytes, footer-bytes, total-size) that were only used for Content-Length. Update test to verify Content-Length is not present in request headers. AI-assisted-by: qwen3.7-plus * :bug: Fix pino ESM bundling for media-processor Mark pino and its transports (pino-pretty, pino-loki) as external to avoid bundling issues with worker thread modules that reference __dirname (not available in ES modules). AI-assisted-by: qwen3.7-plus --- .gitignore | 1 + .serena/memories/critical-info.md | 1 + .serena/memories/media-processor/core.md | 100 + backend/scripts/_env | 4 + backend/src/app/config.clj | 4 + backend/src/app/main.clj | 10 +- backend/src/app/media.clj | 517 +--- backend/src/app/media/local.clj | 426 +++ backend/src/app/media/remote.clj | 264 ++ backend/src/app/media/validation.clj | 68 + backend/src/app/rpc/commands/binfile.clj | 4 +- .../src/app/rpc/commands/files_thumbnails.clj | 14 +- backend/src/app/rpc/commands/fonts.clj | 78 +- backend/src/app/rpc/commands/media.clj | 15 +- backend/src/app/rpc/commands/profile.clj | 7 +- backend/src/app/rpc/commands/teams.clj | 8 +- backend/src/app/rpc/management/exporter.clj | 2 +- backend/src/app/rpc/management/nitrate.clj | 4 +- backend/src/app/setup.clj | 3 +- .../test/backend_tests/media_remote_test.clj | 593 ++++ backend/test/backend_tests/rpc_font_test.clj | 738 ++--- backend/test/backend_tests/rpc_media_test.clj | 37 +- backend/test/backend_tests/storage_test.clj | 29 +- common/src/app/common/flags.cljc | 3 +- docker/images/Dockerfile.media-processor | 86 + media-processor/.prettierignore | 3 + media-processor/.prettierrc | 9 + media-processor/esbuild.config.mjs | 17 + media-processor/package.json | 40 + media-processor/pnpm-lock.yaml | 2649 +++++++++++++++++ media-processor/pnpm-workspace.yaml | 2 + media-processor/scripts/build | 4 + media-processor/scripts/setup | 6 + media-processor/src/config.ts | 64 + media-processor/src/index.ts | 59 + media-processor/src/logger.ts | 135 + media-processor/src/middleware/auth.ts | 27 + media-processor/src/middleware/cleanup.ts | 25 + .../src/middleware/error-handler.ts | 55 + media-processor/src/middleware/logging.ts | 21 + media-processor/src/middleware/queue.ts | 34 + media-processor/src/middleware/timeout.ts | 35 + media-processor/src/routes/font.ts | 55 + media-processor/src/routes/health.ts | 5 + media-processor/src/routes/image.ts | 95 + media-processor/src/services/errors.ts | 26 + media-processor/src/services/font.ts | 313 ++ media-processor/src/services/image.ts | 204 ++ media-processor/src/types.ts | 44 + media-processor/src/upload-storage.ts | 89 + media-processor/src/upload.ts | 50 + media-processor/test/config.test.ts | 130 + media-processor/test/fixtures/font-1.otf | Bin 0 -> 83280 bytes media-processor/test/fixtures/font-1.ttf | Bin 0 -> 68640 bytes media-processor/test/fixtures/font-1.woff | Bin 0 -> 29836 bytes media-processor/test/fixtures/font-1.woff2 | Bin 0 -> 75876 bytes media-processor/test/font.test.ts | 310 ++ media-processor/test/image.test.ts | 909 ++++++ media-processor/test/middleware.test.ts | 681 +++++ media-processor/test/queue.test.ts | 377 +++ .../test/routes-integration.test.ts | 296 ++ media-processor/test/setup.ts | 1 + media-processor/test/upload-storage.test.ts | 212 ++ media-processor/tsconfig.json | 19 + media-processor/vitest.config.ts | 8 + 65 files changed, 8922 insertions(+), 1103 deletions(-) create mode 100644 .serena/memories/media-processor/core.md create mode 100644 backend/src/app/media/local.clj create mode 100644 backend/src/app/media/remote.clj create mode 100644 backend/src/app/media/validation.clj create mode 100644 backend/test/backend_tests/media_remote_test.clj create mode 100644 docker/images/Dockerfile.media-processor create mode 100644 media-processor/.prettierignore create mode 100644 media-processor/.prettierrc create mode 100644 media-processor/esbuild.config.mjs create mode 100644 media-processor/package.json create mode 100644 media-processor/pnpm-lock.yaml create mode 100644 media-processor/pnpm-workspace.yaml create mode 100755 media-processor/scripts/build create mode 100755 media-processor/scripts/setup create mode 100644 media-processor/src/config.ts create mode 100644 media-processor/src/index.ts create mode 100644 media-processor/src/logger.ts create mode 100644 media-processor/src/middleware/auth.ts create mode 100644 media-processor/src/middleware/cleanup.ts create mode 100644 media-processor/src/middleware/error-handler.ts create mode 100644 media-processor/src/middleware/logging.ts create mode 100644 media-processor/src/middleware/queue.ts create mode 100644 media-processor/src/middleware/timeout.ts create mode 100644 media-processor/src/routes/font.ts create mode 100644 media-processor/src/routes/health.ts create mode 100644 media-processor/src/routes/image.ts create mode 100644 media-processor/src/services/errors.ts create mode 100644 media-processor/src/services/font.ts create mode 100644 media-processor/src/services/image.ts create mode 100644 media-processor/src/types.ts create mode 100644 media-processor/src/upload-storage.ts create mode 100644 media-processor/src/upload.ts create mode 100644 media-processor/test/config.test.ts create mode 100644 media-processor/test/fixtures/font-1.otf create mode 100644 media-processor/test/fixtures/font-1.ttf create mode 100644 media-processor/test/fixtures/font-1.woff create mode 100644 media-processor/test/fixtures/font-1.woff2 create mode 100644 media-processor/test/font.test.ts create mode 100644 media-processor/test/image.test.ts create mode 100644 media-processor/test/middleware.test.ts create mode 100644 media-processor/test/queue.test.ts create mode 100644 media-processor/test/routes-integration.test.ts create mode 100644 media-processor/test/setup.ts create mode 100644 media-processor/test/upload-storage.test.ts create mode 100644 media-processor/tsconfig.json create mode 100644 media-processor/vitest.config.ts 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 0000000000000000000000000000000000000000..9326ec78445b2edab3c105cee4666ad6c8f43606 GIT binary patch literal 83280 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 literal 0 HcmV?d00001 diff --git a/media-processor/test/fixtures/font-1.woff2 b/media-processor/test/fixtures/font-1.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..492d463d90e29712d342073f84832c91b834f976 GIT binary patch literal 75876 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 literal 0 HcmV?d00001 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"], + }, +});