From d565422be49cc7f3833b70388dd752348104eae3 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Fri, 3 Jan 2025 01:59:01 +0100 Subject: [PATCH 1/8] feat(upload): file upload endpoint Adds an upload endpoint to our API. This has been implemented using multer as a middleware for file validations and restrictions on file size. Basic happy/unhappy flow tests pass. --- package.json | 2 + pnpm-lock.yaml | 102 +++++++++++++++++- src/controllers/UploadController.ts | 161 ++++++++++++++++++++++++++++ src/index.ts | 4 +- src/middleware/upload.ts | 9 ++ src/types/api.ts | 18 ++++ test/api/v1/upload.test.ts | 136 +++++++++++++++++++++++ test/test-utils/mockFile.ts | 31 ++++++ tsoa.json | 9 +- 9 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 src/controllers/UploadController.ts create mode 100644 src/middleware/upload.ts create mode 100644 test/api/v1/upload.test.ts create mode 100644 test/test-utils/mockFile.ts diff --git a/package.json b/package.json index 36245d8..e803159 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "kysely": "^0.27.4", "lodash": "^4.17.21", "lru-cache": "^11.0.0", + "multer": "1.4.5-lts.1", "node-cron": "^3.0.3", "pg": "^8.12.0", "reflect-metadata": "^0.2.2", @@ -95,6 +96,7 @@ "@swc/cli": "^0.3.12", "@swc/core": "^1.4.15", "@types/body-parser": "^1.19.5", + "@types/multer": "^1.4.12", "@types/node-cron": "^3.0.11", "@types/pg": "^8.11.6", "@types/sinon": "^17.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad5800..58fe191 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: lru-cache: specifier: ^11.0.0 version: 11.0.0 + multer: + specifier: 1.4.5-lts.1 + version: 1.4.5-lts.1 node-cron: specifier: ^3.0.3 version: 3.0.3 @@ -204,6 +207,9 @@ importers: '@types/body-parser': specifier: ^1.19.5 version: 1.19.5 + '@types/multer': + specifier: ^1.4.12 + version: 1.4.12 '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 @@ -339,6 +345,7 @@ packages: '@ardatan/relay-compiler@12.0.0': resolution: {integrity: sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==} + hasBin: true peerDependencies: graphql: '*' @@ -1062,6 +1069,7 @@ packages: '@graphql-codegen/cli@5.0.2': resolution: {integrity: sha512-MBIaFqDiLKuO4ojN6xxG9/xL9wmfD3ZjZ7RsPjwQnSHBCUXnEkdKvX+JVpx87Pq29Ycn8wTJUguXnTZ7Di0Mlw==} + hasBin: true peerDependencies: '@parcel/watcher': ^2.1.0 graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -2037,6 +2045,7 @@ packages: '@openzeppelin/hardhat-upgrades@3.2.1': resolution: {integrity: sha512-Zy5M3QhkzwGdpzQmk+xbWdYOGJWjoTvwbBKYLhctu9B91DoprlhDRaZUwCtunwTdynkTDGdVfGr0kIkvycyKjw==} + hasBin: true peerDependencies: '@nomicfoundation/hardhat-ethers': ^3.0.0 '@nomicfoundation/hardhat-verify': ^2.0.0 @@ -2389,6 +2398,7 @@ packages: '@snaplet/seed@0.97.20': resolution: {integrity: sha512-+lnqESgwP92O1266vsTyoRgrg4hMCUTybBUxDT1ICMBFcvdjgwcOaUt8Xjj81YvxYkZlu5+TTBIjyNQT4nP4jQ==} engines: {node: '>=18.5.0'} + hasBin: true peerDependencies: '@prisma/client': '>=5' '@snaplet/copycat': '>=2' @@ -2447,6 +2457,7 @@ packages: '@swc/cli@0.3.12': resolution: {integrity: sha512-h7bvxT+4+UDrLWJLFHt6V+vNAcUNii2G4aGSSotKz1ECEk4MyEh5CWxmeSscwuz5K3i+4DWTgm4+4EyMCQKn+g==} engines: {node: '>= 16.14.0'} + hasBin: true peerDependencies: '@swc/core': ^1.2.66 chokidar: ^3.5.1 @@ -2744,8 +2755,8 @@ packages: '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} - '@types/multer@1.4.11': - resolution: {integrity: sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==} + '@types/multer@1.4.12': + resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==} '@types/mute-stream@0.0.4': resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} @@ -3212,6 +3223,9 @@ packages: peerDependencies: graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arch@2.2.0: resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} @@ -3724,6 +3738,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + concurrently@8.2.2: resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} engines: {node: ^14.13.0 || >=16.0.0} @@ -3776,6 +3794,9 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -4637,6 +4658,7 @@ packages: gql.tada@1.8.3: resolution: {integrity: sha512-0H81I3M54jKTDHbnNWhXDf57Ie2d2raxnFCc93zdYjXHnrXe522jrio9AAFwqBlGx/xtaP3ILSSUw7J9H31LAA==} + hasBin: true peerDependencies: typescript: ^5.0.0 @@ -4718,6 +4740,7 @@ packages: hardhat@2.22.17: resolution: {integrity: sha512-tDlI475ccz4d/dajnADUTRc1OJ3H8fpP9sWhXhBPpYsQOg8JHq5xrDimo53UhWPl7KJmAeDCm1bFG74xvpGRpg==} + hasBin: true peerDependencies: ts-node: '*' typescript: '*' @@ -5637,14 +5660,17 @@ packages: mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} + hasBin: true mkdirp@2.1.6: resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} engines: {node: '>=10'} + hasBin: true mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} @@ -5681,6 +5707,10 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multer@1.4.5-lts.1: + resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} + engines: {node: '>= 6.0.0'} + multiformats@11.0.2: resolution: {integrity: sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} @@ -6241,6 +6271,9 @@ packages: resolution: {integrity: sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==} engines: {node: '>=14'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -6338,6 +6371,9 @@ packages: resolution: {integrity: sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -6501,6 +6537,9 @@ packages: resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -6764,6 +6803,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -6997,6 +7039,7 @@ packages: ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true peerDependencies: '@swc/core': '>=1.2.50' '@swc/wasm': '>=1.2.50' @@ -7011,6 +7054,7 @@ packages: tsconfck@3.1.4: resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} engines: {node: ^18 || >=20} + hasBin: true peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -7118,9 +7162,13 @@ packages: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typedoc@0.26.5: resolution: {integrity: sha512-Vn9YKdjKtDZqSk+by7beZ+xzkkr8T8CYoiasqyt4TTRFy5+UHzL/mF/o4wGBjRF+rlWQHDb0t6xCpA3JNL5phg==} engines: {node: '>= 18'} + hasBin: true peerDependencies: typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x @@ -7219,6 +7267,7 @@ packages: update-browserslist-db@1.0.13: resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -7315,6 +7364,7 @@ packages: vite@5.0.11: resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==} engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true peerDependencies: '@types/node': ^18.0.0 || >=20.0.0 less: '*' @@ -7348,6 +7398,7 @@ packages: vitest@2.1.8: resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 @@ -10926,7 +10977,7 @@ snapshots: '@tsoa/cli@6.2.1': dependencies: '@tsoa/runtime': 6.2.1 - '@types/multer': 1.4.11 + '@types/multer': 1.4.12 fs-extra: 11.2.0 glob: 10.3.10 handlebars: 4.7.8 @@ -10945,7 +10996,7 @@ snapshots: '@hapi/boom': 10.0.1 '@hapi/hapi': 21.3.9 '@types/koa': 2.15.0 - '@types/multer': 1.4.11 + '@types/multer': 1.4.12 express: 4.19.2 reflect-metadata: 0.2.2 validator: 13.12.0 @@ -11079,7 +11130,7 @@ snapshots: '@types/minimatch@3.0.5': {} - '@types/multer@1.4.11': + '@types/multer@1.4.12': dependencies: '@types/express': 4.17.21 @@ -11744,6 +11795,8 @@ snapshots: ts-invariant: 0.4.4 tslib: 1.14.1 + append-field@1.0.0: {} + arch@2.2.0: {} arg@4.1.3: {} @@ -12380,6 +12433,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + concurrently@8.2.2: dependencies: chalk: 4.1.2 @@ -12442,6 +12502,8 @@ snapshots: cookie@0.6.0: {} + core-util-is@1.0.3: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -14679,6 +14741,16 @@ snapshots: muggle-string@0.4.1: {} + multer@1.4.5-lts.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + multiformats@11.0.2: {} multiformats@12.1.3: {} @@ -15248,6 +15320,8 @@ snapshots: prettier@3.3.2: {} + process-nextick-args@2.0.1: {} + process@0.11.10: {} promise@7.3.1: @@ -15369,6 +15443,16 @@ snapshots: read-cmd-shim@4.0.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -15552,6 +15636,8 @@ snapshots: has-symbols: 1.0.3 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-regex-test@1.0.3: @@ -15849,6 +15935,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -16269,6 +16359,8 @@ snapshots: is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 + typedarray@0.0.6: {} + typedoc@0.26.5(typescript@5.5.3): dependencies: lunr: 2.3.9 diff --git a/src/controllers/UploadController.ts b/src/controllers/UploadController.ts new file mode 100644 index 0000000..ee695f1 --- /dev/null +++ b/src/controllers/UploadController.ts @@ -0,0 +1,161 @@ +import { + Controller, + FormField, + Post, + Route, + SuccessResponse, + Tags, + UploadedFiles, +} from "tsoa"; +import { StorageService } from "../services/StorageService.js"; +import type { UploadResponse } from "../types/api.js"; + +/** + * Controller handling file uploads to IPFS storage + * @class UploadController + */ +@Route("v1/upload") +@Tags("Upload") +export class UploadController extends Controller { + /** + * Upload one or more files to IPFS storage. + * + * @summary Upload files to IPFS + * @param files - Array of files to upload (max 5 files, 10MB each) + * @returns Promise containing upload results with CIDs and any failed uploads + * + * @example + * Request: + * ``` + * POST /v1/upload + * Content-Type: multipart/form-data + * + * files: [File, File, ...] + * ``` + * + * Success Response: + * ```json + * { + * "success": true, + * "message": "Upload successful", + * "data": { + * "results": [ + * { "cid": "Qm...", "fileName": "example.txt" } + * ], + * "failed": [] + * } + * } + * ``` + * + * Partial Success Response: + * ```json + * { + * "success": false, + * "message": "Some uploads failed", + * "data": { + * "results": [ + * { "cid": "Qm...", "fileName": "success.txt" } + * ], + * "failed": [ + * { "fileName": "failed.txt", "error": "Upload failed" } + * ] + * } + * } + */ + @Post() + @SuccessResponse(201, "Upload successful") + public async upload( + @UploadedFiles("files") files?: Express.Multer.File[], + @FormField() jsonData?: string, + ): Promise { + const storage = await StorageService.init(); + + try { + if (jsonData) { + console.debug("Got JSON data for future use"); + } + + const blobs = files?.map( + (file) => new Blob([file.buffer], { type: file.mimetype }), + ); + + if (!files || !blobs) { + this.setStatus(400); + return { + success: false, + message: "No files uploaded", + errors: { upload: "No files uploaded" }, + }; + } + + const uploadResults = await Promise.allSettled( + files.map(async (file, index) => { + try { + const result = await storage.uploadFile({ + file: blobs[index], + }); + return { + cid: result.cid, + fileName: file.originalname, + }; + } catch (error) { + throw { + fileName: file.originalname, + error: (error as Error).message, + }; + } + }), + ); + + const successful = uploadResults + .filter( + ( + result, + ): result is PromiseFulfilledResult<{ + cid: string; + fileName: string; + }> => result.status === "fulfilled", + ) + .map((result) => result.value); + + const failed = uploadResults + .filter( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ) + .map((result) => result.reason as { fileName: string; error: string }); + + if (failed.length > 0) { + this.setStatus(failed.length === uploadResults.length ? 500 : 207); + return { + success: false, + message: "Some uploads failed", + data: { + results: successful, + failed: failed.map((f) => ({ + fileName: f.fileName, + error: f.error, + })), + }, + }; + } + + this.setStatus(201); + return { + success: true, + message: "Upload successful", + data: { + results: successful, + failed: [], + }, + }; + } catch (error) { + this.setStatus(400); + return { + success: false, + message: "Upload failed", + errors: { upload: (error as Error).message }, + }; + } + } +} diff --git a/src/index.ts b/src/index.ts index 786c721..977f94e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,8 +25,8 @@ const PORT = assertExists(process.env.PORT, "PORT"); const app: Express = express(); -app.use(express.urlencoded({ extended: true, limit: "1mb" })); -app.use(express.json({ limit: "1mb" })); +app.use(express.urlencoded({ extended: true, limit: "10mb" })); +app.use(express.json({ limit: "10mb" })); app.use(cors()); app.get("/health", (req, res) => { diff --git a/src/middleware/upload.ts b/src/middleware/upload.ts new file mode 100644 index 0000000..6426ecb --- /dev/null +++ b/src/middleware/upload.ts @@ -0,0 +1,9 @@ +import multer from "multer"; + +export const upload = multer({ + limits: { + fileSize: 10 * 1024 * 1024, // 10MB + files: 5, + }, + storage: multer.memoryStorage(), +}); diff --git a/src/types/api.ts b/src/types/api.ts index 9d3fa56..d295a16 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -26,6 +26,24 @@ export interface ValidationResponse extends BaseResponse { // Storage-related interfaces export interface StorageResponse extends DataResponse<{ cid: string }> {} +export interface UploadResponse extends BaseResponse { + data?: { + results: Array<{ + cid: string; + fileName: string; + }>; + failed: Array<{ + fileName: string; + error: string; + }>; + }; +} + +// Data-related interfaces +export interface StoreDataRequest { + data: unknown; +} + export interface StoreMetadataRequest { metadata: HypercertMetadata; } diff --git a/test/api/v1/upload.test.ts b/test/api/v1/upload.test.ts new file mode 100644 index 0000000..39f7105 --- /dev/null +++ b/test/api/v1/upload.test.ts @@ -0,0 +1,136 @@ +import { describe, test, vi } from "vitest"; +import { expect } from "chai"; +import { mock } from "vitest-mock-extended"; +import { UploadController } from "../../../src/controllers/UploadController.js"; +import { StorageService } from "../../../src/services/StorageService.js"; +import { createMockFile, mockTextFile } from "../../test-utils/mockFile.js"; + +const mocks = vi.hoisted(() => { + return { + init: vi.fn(), + }; +}); + +vi.mock("../../../src/services/StorageService", async () => { + return { + StorageService: { init: mocks.init }, + }; +}); + +describe("File upload at v1/upload", async () => { + const controller = new UploadController(); + const mockStorage = mock(); + + test("Successfully uploads a single file and returns CID", async () => { + mocks.init.mockResolvedValue(mockStorage); + mockStorage.uploadFile.mockResolvedValue({ cid: "TEST_CID" }); + + const response = await controller.upload([mockTextFile]); + + expect(response.success).to.be.true; + expect(response.data).to.not.be.undefined; + expect(response.data?.results).to.have.lengthOf(1); + expect(response.data?.results[0]).to.deep.equal({ + cid: "TEST_CID", + fileName: "test.txt", + }); + expect(response.data?.failed).to.have.lengthOf(0); + }); + + test("Successfully uploads multiple files and returns CIDs", async () => { + mocks.init.mockResolvedValue(mockStorage); + mockStorage.uploadFile + .mockResolvedValueOnce({ cid: "TEST_CID_1" }) + .mockResolvedValueOnce({ cid: "TEST_CID_2" }); + + const mockFiles = [ + createMockFile("content 1", "test1.txt"), + createMockFile("content 2", "test2.txt"), + ]; + + const response = await controller.upload(mockFiles); + + expect(response.success).to.be.true; + expect(response.data?.results).to.have.lengthOf(2); + expect(response.data?.results).to.deep.equal([ + { cid: "TEST_CID_1", fileName: "test1.txt" }, + { cid: "TEST_CID_2", fileName: "test2.txt" }, + ]); + expect(response.data?.failed).to.have.lengthOf(0); + }); + + test("Handles partial upload failures", async () => { + mocks.init.mockResolvedValue(mockStorage); + mockStorage.uploadFile + .mockResolvedValueOnce({ cid: "TEST_CID_1" }) + .mockRejectedValueOnce(new Error("Upload failed")); + + const mockFiles = [ + createMockFile("content 1", "test1.txt"), + createMockFile("content 2", "test2.txt"), + ]; + + const response = await controller.upload(mockFiles); + + expect(response.success).to.be.false; + expect(response.data?.results).to.have.lengthOf(1); + expect(response.data?.results[0]).to.deep.equal({ + cid: "TEST_CID_1", + fileName: "test1.txt", + }); + expect(response.data?.failed).to.have.lengthOf(1); + expect(response.data?.failed[0]).to.deep.equal({ + fileName: "test2.txt", + error: "Upload failed", + }); + }); + + test("Handles no files provided", async () => { + mocks.init.mockResolvedValue(mockStorage); + + const response = await controller.upload(undefined); + + expect(response.success).to.be.false; + expect(response.message).to.equal("No files uploaded"); + expect(response.errors).to.deep.equal({ + upload: "No files uploaded", + }); + }); + + test("Handles complete upload failure", async () => { + mocks.init.mockResolvedValue(mockStorage); + const mockError = new Error("Storage service unavailable"); + mockStorage.uploadFile.mockRejectedValue(mockError); + + const response = await controller.upload([mockTextFile]); + + expect(response.success).to.be.false; + expect(response.data?.results).to.have.lengthOf(0); + expect(response.data?.failed).to.have.lengthOf(1); + expect(response.data?.failed[0]).to.deep.equal({ + fileName: "test.txt", + error: "Storage service unavailable", + }); + }); + + test("Handles different file types", async () => { + mocks.init.mockResolvedValue(mockStorage); + mockStorage.uploadFile + .mockResolvedValueOnce({ cid: "TEST_CID_1" }) + .mockResolvedValueOnce({ cid: "TEST_CID_2" }); + + const mockFiles = [ + createMockFile("text content", "doc.txt", "text/plain"), + createMockFile("", "page.html", "text/html"), + ]; + + const response = await controller.upload(mockFiles); + + expect(response.success).to.be.true; + expect(response.data?.results).to.have.lengthOf(2); + expect(response.data?.results).to.deep.equal([ + { cid: "TEST_CID_1", fileName: "doc.txt" }, + { cid: "TEST_CID_2", fileName: "page.html" }, + ]); + }); +}); diff --git a/test/test-utils/mockFile.ts b/test/test-utils/mockFile.ts new file mode 100644 index 0000000..8b09301 --- /dev/null +++ b/test/test-utils/mockFile.ts @@ -0,0 +1,31 @@ +import { Buffer } from "buffer"; +import { Readable } from "node:stream"; + +export const createMockFile = ( + content: string = "test content", + filename: string = "test.txt", + mimetype: string = "text/plain", +): Express.Multer.File => ({ + buffer: Buffer.from(content), + mimetype, + originalname: filename, + fieldname: "files", + encoding: "7bit", + size: Buffer.from(content).length, + stream: null as unknown as Readable, + destination: "", + filename: filename, + path: "", +}); + +export const mockTextFile = createMockFile( + "test content", + "test.txt", + "text/plain", +); + +export const mockJsonFile = createMockFile( + JSON.stringify({ test: "content" }), + "test.json", + "application/json", +); diff --git a/tsoa.json b/tsoa.json index cbcaf4a..158dc00 100644 --- a/tsoa.json +++ b/tsoa.json @@ -1,8 +1,6 @@ { "entryFile": "src/index.ts", - "controllerPathGlobs": [ - "src/controllers/*Controller.ts" - ], + "controllerPathGlobs": ["src/controllers/*Controller.ts"], "noImplicitAdditionalProperties": "throw-on-extras", "spec": { "outputDirectory": "src/__generated__", @@ -16,6 +14,9 @@ }, "routes": { "routesDir": "src/__generated__/routes", - "esm": true + "esm": true, + "middleware": { + "v1/upload": [{ "name": "upload.array", "args": ["files", 5] }] + } } } From de00bf53d8cff07705cb7f5ac2445edcb04802d2 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Fri, 3 Jan 2025 02:11:00 +0100 Subject: [PATCH 2/8] chore(cleanup): types in lib and coverage threshold Increases coverage threshold based on updated test suite Updates the types used in lib/users to match updated API types Generated updated Swagger --- src/__generated__/routes/routes.ts | 47 ++++++++++ src/__generated__/swagger.json | 110 ++++++++++++++++++++++++ src/lib/users/EOAUpsertStrategy.ts | 4 +- src/lib/users/MultisigUpsertStrategy.ts | 5 +- src/lib/users/UserUpsertStrategy.ts | 4 +- src/lib/users/errors.ts | 4 +- vitest.config.ts | 9 +- 7 files changed, 170 insertions(+), 13 deletions(-) diff --git a/src/__generated__/routes/routes.ts b/src/__generated__/routes/routes.ts index 386b8a3..c08bcc1 100644 --- a/src/__generated__/routes/routes.ts +++ b/src/__generated__/routes/routes.ts @@ -5,6 +5,8 @@ import { TsoaRoute, fetchMiddlewares, ExpressTemplateService } from '@tsoa/runti // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { UserController } from './../../controllers/UserController.js'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { UploadController } from './../../controllers/UploadController.js'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { SignatureRequestController } from './../../controllers/SignatureRequestController.js'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { MetadataController } from './../../controllers/MetadataController.js'; @@ -17,6 +19,8 @@ import { BlueprintController } from './../../controllers/BlueprintController.js' // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { AllowListController } from './../../controllers/AllowListController.js'; import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express'; +import multer from 'multer'; +const upload = multer({"limits":{"fileSize":8388608}}); @@ -76,6 +80,17 @@ const models: TsoaRoute.Models = { "type": {"dataType":"union","subSchemas":[{"ref":"EOAUserUpsertRequest"},{"ref":"MultisigUserUpsertRequest"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "UploadResponse": { + "dataType": "refObject", + "properties": { + "success": {"dataType":"boolean","required":true}, + "message": {"dataType":"string"}, + "errors": {"ref":"Record_string.string-or-string-Array_"}, + "data": {"dataType":"nestedObjectLiteral","nestedProperties":{"failed":{"dataType":"array","array":{"dataType":"nestedObjectLiteral","nestedProperties":{"error":{"dataType":"string","required":true},"fileName":{"dataType":"string","required":true}}},"required":true},"results":{"dataType":"array","array":{"dataType":"nestedObjectLiteral","nestedProperties":{"fileName":{"dataType":"string","required":true},"cid":{"dataType":"string","required":true}}},"required":true}}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "CancelSignatureRequest": { "dataType": "refObject", "properties": { @@ -364,6 +379,38 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/v1/upload', + upload.array('files'), + ...(fetchMiddlewares(UploadController)), + ...(fetchMiddlewares(UploadController.prototype.upload)), + + async function UploadController_upload(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + files: {"in":"formData","name":"files","dataType":"array","array":{"dataType":"file"}}, + jsonData: {"in":"formData","name":"jsonData","dataType":"string"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new UploadController(); + + await templateService.apiHandler({ + methodName: 'upload', + controller, + response, + next, + validatedArgs, + successStatus: 201, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/v1/signature-requests/:safe_address-:message_hash/cancel', ...(fetchMiddlewares(SignatureRequestController)), ...(fetchMiddlewares(SignatureRequestController.prototype.cancelSignatureRequest)), diff --git a/src/__generated__/swagger.json b/src/__generated__/swagger.json index f7a901c..02f7f52 100644 --- a/src/__generated__/swagger.json +++ b/src/__generated__/swagger.json @@ -138,6 +138,69 @@ } ] }, + "UploadResponse": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "errors": { + "$ref": "#/components/schemas/Record_string.string-or-string-Array_" + }, + "data": { + "properties": { + "failed": { + "items": { + "properties": { + "error": { + "type": "string" + }, + "fileName": { + "type": "string" + } + }, + "required": [ + "error", + "fileName" + ], + "type": "object" + }, + "type": "array" + }, + "results": { + "items": { + "properties": { + "fileName": { + "type": "string" + }, + "cid": { + "type": "string" + } + }, + "required": [ + "fileName", + "cid" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "failed", + "results" + ], + "type": "object" + } + }, + "required": [ + "success" + ], + "type": "object", + "additionalProperties": false + }, "CancelSignatureRequest": { "properties": { "signature": { @@ -1096,6 +1159,53 @@ } } }, + "/v1/upload": { + "post": { + "operationId": "Upload", + "responses": { + "201": { + "description": "Upload successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadResponse" + } + } + } + } + }, + "description": "Upload one or more files to IPFS storage.", + "summary": "Upload files to IPFS", + "tags": [ + "Upload" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": false, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files": { + "items": { + "type": "string", + "format": "binary" + }, + "type": "array", + "description": "- Array of files to upload (max 5 files, 10MB each)" + }, + "jsonData": { + "type": "string" + } + } + } + } + } + } + } + }, "/v1/signature-requests/{safe_address}-{message_hash}/cancel": { "post": { "operationId": "CancelSignatureRequest", diff --git a/src/lib/users/EOAUpsertStrategy.ts b/src/lib/users/EOAUpsertStrategy.ts index be99274..a21ac9a 100644 --- a/src/lib/users/EOAUpsertStrategy.ts +++ b/src/lib/users/EOAUpsertStrategy.ts @@ -1,6 +1,6 @@ import { verifyAuthSignedData } from "../../utils/verifyAuthSignedData.js"; import { SupabaseDataService } from "../../services/SupabaseDataService.js"; -import type { AddOrUpdateUserResponse } from "../../types/api.js"; +import type { UserResponse } from "../../types/api.js"; import type { EOAUpdateRequest } from "./schemas.js"; import type { UserUpsertStrategy } from "./UserUpsertStrategy.js"; @@ -16,7 +16,7 @@ export default class EOAUpdateStrategy implements UserUpsertStrategy { this.dataService = new SupabaseDataService(); } - async execute(): Promise { + async execute(): Promise { await this.throwIfInvalidSignature(); const user = await this.upsertUser(); return { diff --git a/src/lib/users/MultisigUpsertStrategy.ts b/src/lib/users/MultisigUpsertStrategy.ts index b824fce..03a940f 100644 --- a/src/lib/users/MultisigUpsertStrategy.ts +++ b/src/lib/users/MultisigUpsertStrategy.ts @@ -3,7 +3,7 @@ import SafeApiKit, { type SafeApiKitConfig } from "@safe-global/api-kit"; import { SignatureRequestPurpose } from "../../graphql/schemas/typeDefs/signatureRequestTypeDefs.js"; import { SupabaseDataService } from "../../services/SupabaseDataService.js"; -import { AddOrUpdateUserResponse } from "../../types/api.js"; +import { UserResponse } from "../../types/api.js"; import { isTypedMessage } from "../../utils/signatures.js"; import type { UserUpsertStrategy } from "./UserUpsertStrategy.js"; @@ -45,7 +45,7 @@ export default class MultisigUpdateStrategy implements UserUpsertStrategy { } // We could check if it's a 1 of 1 and execute right away - async execute(): Promise { + async execute(): Promise { const { message } = await this.safeApiKit.getMessage( this.request.messageHash, ); @@ -76,7 +76,6 @@ export default class MultisigUpdateStrategy implements UserUpsertStrategy { return { success: true, message: "Signature request created successfully", - data: null, }; } } diff --git a/src/lib/users/UserUpsertStrategy.ts b/src/lib/users/UserUpsertStrategy.ts index 419f04d..772baa4 100644 --- a/src/lib/users/UserUpsertStrategy.ts +++ b/src/lib/users/UserUpsertStrategy.ts @@ -1,11 +1,11 @@ -import { AddOrUpdateUserResponse } from "../../types/api.js"; +import { UserResponse } from "../../types/api.js"; import MultisigUpsertStrategy from "./MultisigUpsertStrategy.js"; import EOAUpsertStrategy from "./EOAUpsertStrategy.js"; import { EOAUpdateRequest, MultisigUpdateRequest } from "./schemas.js"; export interface UserUpsertStrategy { - execute(): Promise; + execute(): Promise; } export function createStrategy( diff --git a/src/lib/users/errors.ts b/src/lib/users/errors.ts index 702f519..b83009c 100644 --- a/src/lib/users/errors.ts +++ b/src/lib/users/errors.ts @@ -1,8 +1,8 @@ -import { AddOrUpdateUserResponse } from "../../types/api.js"; +import { UserResponse } from "../../types/api.js"; export class UserUpsertError extends Error { code: number; - public errors: AddOrUpdateUserResponse["errors"]; + public errors: UserResponse["errors"]; constructor(code: number, message: string) { super(message); this.name = "UserUpdateError"; diff --git a/vitest.config.ts b/vitest.config.ts index 68171d6..e5730e2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,10 +11,10 @@ export default defineConfig({ // If you want a coverage reports even if your tests are failing, include the reportOnFailure option reportOnFailure: true, thresholds: { - lines: 15, - branches: 54, - functions: 49, - statements: 15, + lines: 17, + branches: 60, + functions: 54, + statements: 17, }, include: ["src/**/*.ts"], exclude: [ @@ -24,6 +24,7 @@ export default defineConfig({ "src/__generated__/**/*", "src/graphql/**/*", "src/types/**/*", + "src/abis/**/*", "./lib/**/*", ], }, From a57f65aecb277428834bcfb1e9af0ed65922b0f1 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Fri, 3 Jan 2025 02:38:06 +0100 Subject: [PATCH 3/8] feat(upload.ts): add file validations Add validations on file type and size --- package.json | 3 ++ pnpm-lock.yaml | 75 ++++++++++++++++++++++++++ src/middleware/upload.ts | 53 +++++++++++++++++++ test/middleware/upload.test.ts | 96 ++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 test/middleware/upload.test.ts diff --git a/package.json b/package.json index e803159..5b18005 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "cors": "^2.8.5", "ethers": "^6.12.2", "express": "^4.19.2", + "file-type": "^19.6.0", "gql.tada": "^1.2.1", "graphql": "^16.8.1", "graphql-filter": "^1.1.5", @@ -63,6 +64,7 @@ "kysely": "^0.27.4", "lodash": "^4.17.21", "lru-cache": "^11.0.0", + "mime-types": "^2.1.35", "multer": "1.4.5-lts.1", "node-cron": "^3.0.3", "pg": "^8.12.0", @@ -96,6 +98,7 @@ "@swc/cli": "^0.3.12", "@swc/core": "^1.4.15", "@types/body-parser": "^1.19.5", + "@types/mime-types": "^2.1.4", "@types/multer": "^1.4.12", "@types/node-cron": "^3.0.11", "@types/pg": "^8.11.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58fe191..35af945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: express: specifier: ^4.19.2 version: 4.19.2 + file-type: + specifier: ^19.6.0 + version: 19.6.0 gql.tada: specifier: ^1.2.1 version: 1.2.1(graphql@16.8.1) @@ -122,6 +125,9 @@ importers: lru-cache: specifier: ^11.0.0 version: 11.0.0 + mime-types: + specifier: ^2.1.35 + version: 2.1.35 multer: specifier: 1.4.5-lts.1 version: 1.4.5-lts.1 @@ -207,6 +213,9 @@ importers: '@types/body-parser': specifier: ^1.19.5 version: 1.19.5 + '@types/mime-types': + specifier: ^2.1.4 + version: 2.1.4 '@types/multer': specifier: ^1.4.12 version: 1.4.12 @@ -2289,6 +2298,9 @@ packages: '@scure/bip39@1.4.0': resolution: {integrity: sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sentry/core@5.30.0': resolution: {integrity: sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==} engines: {node: '>=6'} @@ -2746,6 +2758,9 @@ packages: '@types/lru-cache@5.1.1': resolution: {integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==} + '@types/mime-types@2.1.4': + resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -4413,6 +4428,10 @@ packages: resolution: {integrity: sha512-hlDw5Ev+9e883s0pwUsuuYNu4tD7GgpUnOvykjv1Gya0ZIjuKumthDRua90VUn6/nlRKAjcxLUnHNTIUWwWIiw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + file-type@19.6.0: + resolution: {integrity: sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==} + engines: {node: '>=18'} + filename-reserved-regex@3.0.0: resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4585,6 +4604,10 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-symbol-description@1.0.2: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} @@ -5075,6 +5098,10 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -6129,6 +6156,10 @@ packages: resolution: {integrity: sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==} engines: {node: '>=14.16'} + peek-readable@5.3.1: + resolution: {integrity: sha512-GVlENSDW6KHaXcd9zkZltB7tCLosKB/4Hg0fqBJkAoBgYG2Tn1xtMgXtSUuMU9AK/gCm/tTdT8mgAeF4YNeeqw==} + engines: {node: '>=14.16'} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -6849,6 +6880,10 @@ packages: resolution: {integrity: sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ==} engines: {node: '>=14.16'} + strtok3@9.1.1: + resolution: {integrity: sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==} + engines: {node: '>=16'} + stubborn-fs@1.2.5: resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} @@ -6993,6 +7028,10 @@ packages: resolution: {integrity: sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==} engines: {node: '>=14.16'} + token-types@6.0.0: + resolution: {integrity: sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==} + engines: {node: '>=14.16'} + touch@3.1.0: resolution: {integrity: sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==} @@ -7206,6 +7245,10 @@ packages: resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} engines: {node: '>=0.8.0'} + uint8array-extras@1.4.0: + resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==} + engines: {node: '>=18'} + uint8arraylist@2.4.8: resolution: {integrity: sha512-vc1PlGOzglLF0eae1M8mLRTBivsvrGsdmJ5RbK3e+QRvRLOZfZhQROTwH/OfyF3+ZVUg9/8hE8bmKP2CvP9quQ==} @@ -10505,6 +10548,8 @@ snapshots: '@noble/hashes': 1.5.0 '@scure/base': 1.1.9 + '@sec-ant/readable-stream@0.4.1': {} + '@sentry/core@5.30.0': dependencies: '@sentry/hub': 5.30.0 @@ -11124,6 +11169,8 @@ snapshots: '@types/lru-cache@5.1.1': {} + '@types/mime-types@2.1.4': {} + '@types/mime@1.3.5': {} '@types/mime@3.0.4': {} @@ -13305,6 +13352,13 @@ snapshots: strtok3: 7.0.0 token-types: 5.0.1 + file-type@19.6.0: + dependencies: + get-stream: 9.0.1 + strtok3: 9.1.1 + token-types: 6.0.0 + uint8array-extras: 1.4.0 + filename-reserved-regex@3.0.0: {} filenamify@5.1.1: @@ -13474,6 +13528,11 @@ snapshots: get-stream@8.0.1: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-symbol-description@1.0.2: dependencies: call-bind: 1.0.7 @@ -14120,6 +14179,8 @@ snapshots: is-stream@3.0.0: {} + is-stream@4.0.1: {} + is-string@1.0.7: dependencies: has-tostringtag: 1.0.2 @@ -15186,6 +15247,8 @@ snapshots: peek-readable@5.0.0: {} + peek-readable@5.3.1: {} + perfect-debounce@1.0.0: {} periscopic@3.1.0: @@ -15972,6 +16035,11 @@ snapshots: '@tokenizer/token': 0.3.0 peek-readable: 5.0.0 + strtok3@9.1.1: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 5.3.1 + stubborn-fs@1.2.5: {} supabase@1.191.3: @@ -16156,6 +16224,11 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + token-types@6.0.0: + dependencies: + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + touch@3.1.0: dependencies: nopt: 1.0.10 @@ -16396,6 +16469,8 @@ snapshots: uglify-js@3.17.4: optional: true + uint8array-extras@1.4.0: {} + uint8arraylist@2.4.8: dependencies: uint8arrays: 5.1.0 diff --git a/src/middleware/upload.ts b/src/middleware/upload.ts index 6426ecb..cc31486 100644 --- a/src/middleware/upload.ts +++ b/src/middleware/upload.ts @@ -1,9 +1,62 @@ +import { Request } from "express"; +import { fileTypeFromBuffer } from "file-type"; +import { lookup } from "mime-types"; import multer from "multer"; +// Allowed file types +const ALLOWED_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "application/pdf", + "text/plain", + "application/json", +]); + +// File validation error types +export enum FileValidationError { + INVALID_TYPE = "INVALID_TYPE", + CONTENT_MISMATCH = "CONTENT_MISMATCH", + SIZE_EXCEEDED = "SIZE_EXCEEDED", +} + +// File validation middleware +export const validateFile = async ( + req: Request, + file: Express.Multer.File, +): Promise => { + // 1. Check file size + if (file.size > 10 * 1024 * 1024) { + throw new Error(FileValidationError.SIZE_EXCEEDED); + } + + // 2. Verify mime type + const detectedType = await fileTypeFromBuffer(file.buffer); + const declaredMime = lookup(file.originalname) || file.mimetype; + + if (!ALLOWED_MIMES.has(declaredMime)) { + throw new Error(FileValidationError.INVALID_TYPE); + } + + // 3. Check if declared type matches actual content + if (detectedType && detectedType.mime !== declaredMime) { + throw new Error(FileValidationError.CONTENT_MISMATCH); + } +}; + +// Configure multer with validation export const upload = multer({ limits: { fileSize: 10 * 1024 * 1024, // 10MB files: 5, }, storage: multer.memoryStorage(), + fileFilter: async (req, file, cb) => { + try { + await validateFile(req, file); + cb(null, true); + } catch (error) { + cb(error as Error); + } + }, }); diff --git a/test/middleware/upload.test.ts b/test/middleware/upload.test.ts new file mode 100644 index 0000000..8976bdb --- /dev/null +++ b/test/middleware/upload.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect, vi } from "vitest"; +import { createMockFile } from "../test-utils/mockFile"; +import { FileValidationError, validateFile } from "../../src/middleware/upload"; +import { fileTypeFromBuffer, type FileTypeResult } from "file-type"; +import { Request } from "express"; +import { mock } from "vitest-mock-extended"; + +// Mock file-type +vi.mock("file-type", () => ({ + fileTypeFromBuffer: vi.fn(), +})); + +describe("Upload Middleware", () => { + const mockReq = mock(); + + test("accepts valid file", async () => { + const mockFile = createMockFile("test content", "test.txt", "text/plain"); + vi.mocked(fileTypeFromBuffer).mockResolvedValue({ + mime: "text/plain" as const, + ext: "txt", + } satisfies FileTypeResult); + + await expect(validateFile(mockReq, mockFile)).resolves.not.toThrow(); + }); + + test("rejects oversized file", async () => { + const largeContent = "x".repeat(11 * 1024 * 1024); // 11MB + const mockFile = createMockFile(largeContent, "large.txt", "text/plain"); + + await expect(validateFile(mockReq, mockFile)).rejects.toThrow( + FileValidationError.SIZE_EXCEEDED, + ); + }); + + test("rejects file with invalid mime type", async () => { + const mockFile = createMockFile( + "test", + "test.exe", + "application/x-msdownload", + ); + + await expect(validateFile(mockReq, mockFile)).rejects.toThrow( + FileValidationError.INVALID_TYPE, + ); + }); + + test("rejects file with mismatched content type", async () => { + const mockFile = createMockFile("", "fake.txt", "text/plain"); + vi.mocked(fileTypeFromBuffer).mockResolvedValue({ + mime: "text/html" as const, + ext: "html", + } satisfies FileTypeResult); + + await expect(validateFile(mockReq, mockFile)).rejects.toThrow( + FileValidationError.CONTENT_MISMATCH, + ); + }); + + test("handles different allowed file types", async () => { + const testCases = [ + { content: "test", name: "test.txt", type: "text/plain" as const }, + { content: "{}", name: "test.json", type: "application/json" as const }, + { content: "PDF", name: "test.pdf", type: "application/pdf" as const }, + ]; + + for (const testCase of testCases) { + const mockFile = createMockFile( + testCase.content, + testCase.name, + testCase.type, + ); + vi.mocked(fileTypeFromBuffer).mockResolvedValue({ + mime: testCase.type, + ext: testCase.name.split(".")[1], + } satisfies FileTypeResult); + + await expect(validateFile(mockReq, mockFile)).resolves.not.toThrow(); + } + }); + + test("handles null file type detection", async () => { + const mockFile = createMockFile("test", "test.txt", "text/plain"); + vi.mocked(fileTypeFromBuffer).mockResolvedValue(null); + + await expect(validateFile(mockReq, mockFile)).resolves.not.toThrow(); + }); + + test("handles file type detection errors", async () => { + const mockFile = createMockFile("test", "test.txt", "text/plain"); + vi.mocked(fileTypeFromBuffer).mockRejectedValue( + new Error("Detection failed"), + ); + + await expect(validateFile(mockReq, mockFile)).rejects.toThrow(); + }); +}); From 73c2e4b9cb48728d1b056f87d86b226871fb9b45 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Fri, 3 Jan 2025 03:44:41 +0100 Subject: [PATCH 4/8] chore(lib): allowlist helpers to allowlists lib To gradually clean up the repo, we agreed on restructuring helpers from utils to more cleanly scoped libs. Additionally, added some tests --- pnpm-lock.yaml | 16 --- src/controllers/AllowListController.ts | 2 +- src/controllers/MetadataController.ts | 2 +- src/lib/allowlists/isParsableToMerkleTree.ts | 21 ++++ .../parseAndValidateMerkleTreeDump.ts | 28 ++--- src/lib/allowlists/parseMerkleTree.ts | 15 +++ src/utils/isParsableToMerkleTree.ts | 22 ---- src/utils/parseMerkleTree.ts | 22 ---- src/utils/validateRemoteAllowList.ts | 25 +++-- .../allowlists/isParsableToMerkleTree.test.ts | 90 ++++++++++++++++ test/lib/allowlists/parseMerkleTree.test.ts | 100 ++++++++++++++++++ 11 files changed, 256 insertions(+), 87 deletions(-) create mode 100644 src/lib/allowlists/isParsableToMerkleTree.ts rename src/{utils => lib/allowlists}/parseAndValidateMerkleTreeDump.ts (72%) create mode 100644 src/lib/allowlists/parseMerkleTree.ts delete mode 100644 src/utils/isParsableToMerkleTree.ts delete mode 100644 src/utils/parseMerkleTree.ts create mode 100644 test/lib/allowlists/isParsableToMerkleTree.test.ts create mode 100644 test/lib/allowlists/parseMerkleTree.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35af945..650eb87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,7 +354,6 @@ packages: '@ardatan/relay-compiler@12.0.0': resolution: {integrity: sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==} - hasBin: true peerDependencies: graphql: '*' @@ -1078,7 +1077,6 @@ packages: '@graphql-codegen/cli@5.0.2': resolution: {integrity: sha512-MBIaFqDiLKuO4ojN6xxG9/xL9wmfD3ZjZ7RsPjwQnSHBCUXnEkdKvX+JVpx87Pq29Ycn8wTJUguXnTZ7Di0Mlw==} - hasBin: true peerDependencies: '@parcel/watcher': ^2.1.0 graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -2054,7 +2052,6 @@ packages: '@openzeppelin/hardhat-upgrades@3.2.1': resolution: {integrity: sha512-Zy5M3QhkzwGdpzQmk+xbWdYOGJWjoTvwbBKYLhctu9B91DoprlhDRaZUwCtunwTdynkTDGdVfGr0kIkvycyKjw==} - hasBin: true peerDependencies: '@nomicfoundation/hardhat-ethers': ^3.0.0 '@nomicfoundation/hardhat-verify': ^2.0.0 @@ -2410,7 +2407,6 @@ packages: '@snaplet/seed@0.97.20': resolution: {integrity: sha512-+lnqESgwP92O1266vsTyoRgrg4hMCUTybBUxDT1ICMBFcvdjgwcOaUt8Xjj81YvxYkZlu5+TTBIjyNQT4nP4jQ==} engines: {node: '>=18.5.0'} - hasBin: true peerDependencies: '@prisma/client': '>=5' '@snaplet/copycat': '>=2' @@ -2469,7 +2465,6 @@ packages: '@swc/cli@0.3.12': resolution: {integrity: sha512-h7bvxT+4+UDrLWJLFHt6V+vNAcUNii2G4aGSSotKz1ECEk4MyEh5CWxmeSscwuz5K3i+4DWTgm4+4EyMCQKn+g==} engines: {node: '>= 16.14.0'} - hasBin: true peerDependencies: '@swc/core': ^1.2.66 chokidar: ^3.5.1 @@ -4681,7 +4676,6 @@ packages: gql.tada@1.8.3: resolution: {integrity: sha512-0H81I3M54jKTDHbnNWhXDf57Ie2d2raxnFCc93zdYjXHnrXe522jrio9AAFwqBlGx/xtaP3ILSSUw7J9H31LAA==} - hasBin: true peerDependencies: typescript: ^5.0.0 @@ -4763,7 +4757,6 @@ packages: hardhat@2.22.17: resolution: {integrity: sha512-tDlI475ccz4d/dajnADUTRc1OJ3H8fpP9sWhXhBPpYsQOg8JHq5xrDimo53UhWPl7KJmAeDCm1bFG74xvpGRpg==} - hasBin: true peerDependencies: ts-node: '*' typescript: '*' @@ -5687,17 +5680,14 @@ packages: mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} - hasBin: true mkdirp@2.1.6: resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} engines: {node: '>=10'} - hasBin: true mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} @@ -7078,7 +7068,6 @@ packages: ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true peerDependencies: '@swc/core': '>=1.2.50' '@swc/wasm': '>=1.2.50' @@ -7093,7 +7082,6 @@ packages: tsconfck@3.1.4: resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} engines: {node: ^18 || >=20} - hasBin: true peerDependencies: typescript: ^5.0.0 peerDependenciesMeta: @@ -7207,7 +7195,6 @@ packages: typedoc@0.26.5: resolution: {integrity: sha512-Vn9YKdjKtDZqSk+by7beZ+xzkkr8T8CYoiasqyt4TTRFy5+UHzL/mF/o4wGBjRF+rlWQHDb0t6xCpA3JNL5phg==} engines: {node: '>= 18'} - hasBin: true peerDependencies: typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x @@ -7310,7 +7297,6 @@ packages: update-browserslist-db@1.0.13: resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} - hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -7407,7 +7393,6 @@ packages: vite@5.0.11: resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==} engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true peerDependencies: '@types/node': ^18.0.0 || >=20.0.0 less: '*' @@ -7441,7 +7426,6 @@ packages: vitest@2.1.8: resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 diff --git a/src/controllers/AllowListController.ts b/src/controllers/AllowListController.ts index fd08548..5810d37 100644 --- a/src/controllers/AllowListController.ts +++ b/src/controllers/AllowListController.ts @@ -9,7 +9,7 @@ import { Tags, } from "tsoa"; import { StorageService } from "../services/StorageService.js"; -import { parseAndValidateMerkleTree } from "../utils/parseAndValidateMerkleTreeDump.js"; +import { parseAndValidateMerkleTree } from "../lib/allowlists/parseAndValidateMerkleTreeDump.js"; import type { StorageResponse, StoreAllowListRequest, diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index e668864..03cbb84 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -19,7 +19,7 @@ import type { } from "../types/api.js"; import { validateMetadataAndClaimdata } from "../utils/validateMetadataAndClaimdata.js"; import { validateRemoteAllowList } from "../utils/validateRemoteAllowList.js"; -import { parseAndValidateMerkleTree } from "../utils/parseAndValidateMerkleTreeDump.js"; +import { parseAndValidateMerkleTree } from "../lib/allowlists/parseAndValidateMerkleTreeDump.js"; @Route("v1/metadata") @Tags("Metadata") diff --git a/src/lib/allowlists/isParsableToMerkleTree.ts b/src/lib/allowlists/isParsableToMerkleTree.ts new file mode 100644 index 0000000..d61fe2f --- /dev/null +++ b/src/lib/allowlists/isParsableToMerkleTree.ts @@ -0,0 +1,21 @@ +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; + +export const tryParseMerkleTree = (allowList: unknown) => { + try { + StandardMerkleTree.load(JSON.parse(allowList as string)); + + return true; + } catch (e) { + console.warn("Error loading merkle tree as JSON", e); + } + + try { + StandardMerkleTree.load(allowList as never); + + return true; + } catch (e) { + console.warn("Error loading merkle tree from dump", e); + } + + return false; +}; diff --git a/src/utils/parseAndValidateMerkleTreeDump.ts b/src/lib/allowlists/parseAndValidateMerkleTreeDump.ts similarity index 72% rename from src/utils/parseAndValidateMerkleTreeDump.ts rename to src/lib/allowlists/parseAndValidateMerkleTreeDump.ts index b082e0a..83aa0ee 100644 --- a/src/utils/parseAndValidateMerkleTreeDump.ts +++ b/src/lib/allowlists/parseAndValidateMerkleTreeDump.ts @@ -1,9 +1,10 @@ import { validateAllowlist } from "@hypercerts-org/sdk"; import { parseMerkleTree } from "./parseMerkleTree.js"; -import { parseEther } from "viem"; -import { ValidateAllowListRequest } from "../types/api.js"; +import { ValidateAllowListRequest } from "../../types/api.js"; -export const parseAndValidateMerkleTree = (request: ValidateAllowListRequest) => { +export const parseAndValidateMerkleTree = ( + request: ValidateAllowListRequest, +) => { const { allowList, totalUnits } = request; const _merkleTree = parseMerkleTree(allowList); @@ -12,17 +13,20 @@ export const parseAndValidateMerkleTree = (request: ValidateAllowListRequest) => data: _merkleTree, valid: false, errors: { - allowListData: "Data could not be parsed to OpenZeppelin MerkleTree" - } + allowListData: "Data could not be parsed to OpenZeppelin MerkleTree", + }, }; } - const allowListEntries = Array.from(_merkleTree.entries()).map(entry => ({ + const allowListEntries = Array.from(_merkleTree.entries()).map((entry) => ({ address: entry[1][0], - units: BigInt(entry[1][1]) + units: BigInt(entry[1][1]), })); - const totalUnitsInEntries = allowListEntries.reduce((acc, entry) => acc + entry.units, BigInt(0)); + const totalUnitsInEntries = allowListEntries.reduce( + (acc, entry) => acc + entry.units, + BigInt(0), + ); if (totalUnits) { if (totalUnitsInEntries !== BigInt(totalUnits)) { @@ -31,8 +35,8 @@ export const parseAndValidateMerkleTree = (request: ValidateAllowListRequest) => valid: false, errors: { totalUnits: - "Total units do not match the sum of units in the allowlist" - } + "Total units do not match the sum of units in the allowlist", + }, }; } @@ -41,8 +45,8 @@ export const parseAndValidateMerkleTree = (request: ValidateAllowListRequest) => data: _merkleTree, valid: false, errors: { - totalUnits: "Total units should amount to 100M (100_000_000) units" - } + totalUnits: "Total units should amount to 100M (100_000_000) units", + }, }; } } diff --git a/src/lib/allowlists/parseMerkleTree.ts b/src/lib/allowlists/parseMerkleTree.ts new file mode 100644 index 0000000..be8b8ab --- /dev/null +++ b/src/lib/allowlists/parseMerkleTree.ts @@ -0,0 +1,15 @@ +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; + +export const parseMerkleTree = (allowList: string) => { + try { + return StandardMerkleTree.load(JSON.parse(allowList)); + } catch (e) { + console.warn("Error loading merkle tree as JSON", e); + } + + try { + return StandardMerkleTree.load(allowList as never); + } catch (e) { + console.warn("Error loading merkle tree from dump", e); + } +}; diff --git a/src/utils/isParsableToMerkleTree.ts b/src/utils/isParsableToMerkleTree.ts deleted file mode 100644 index 6c9dadb..0000000 --- a/src/utils/isParsableToMerkleTree.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {StandardMerkleTree} from "@openzeppelin/merkle-tree"; - - -export const tryParseMerkleTree = (allowList: string) => { - try { - StandardMerkleTree.load(JSON.parse(allowList)); - - return true - } catch (e) { - console.warn("Error loading merkle tree as JSON", e); - } - - try { - StandardMerkleTree.load(allowList as never); - - return true; - } catch (e) { - console.warn("Error loading merkle tree from dump", e); - } - - return false; -} diff --git a/src/utils/parseMerkleTree.ts b/src/utils/parseMerkleTree.ts deleted file mode 100644 index 8c9b5e0..0000000 --- a/src/utils/parseMerkleTree.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {StandardMerkleTree} from "@openzeppelin/merkle-tree"; - - -export const parseMerkleTree = (allowList: string) => { - - try { - return StandardMerkleTree.load( - JSON.parse(allowList) - ); - - } catch (e) { - console.warn("Error loading merkle tree as JSON", e); - } - - try { - return StandardMerkleTree.load( - allowList as never - ); - } catch (e) { - console.warn("Error loading merkle tree from dump", e); - } -} \ No newline at end of file diff --git a/src/utils/validateRemoteAllowList.ts b/src/utils/validateRemoteAllowList.ts index e753e12..58d1c81 100644 --- a/src/utils/validateRemoteAllowList.ts +++ b/src/utils/validateRemoteAllowList.ts @@ -1,8 +1,10 @@ import { getFromIPFS } from "@hypercerts-org/sdk"; -import { tryParseMerkleTree } from "./isParsableToMerkleTree.js"; +import { tryParseMerkleTree } from "../lib/allowlists/isParsableToMerkleTree.js"; import { ValidationResult } from "../types/api.js"; -export const validateRemoteAllowList = async (uri: string): Promise> => { +export const validateRemoteAllowList = async ( + uri: string, +): Promise> => { try { const allowList = await getFromIPFS(uri, 30000); @@ -10,22 +12,22 @@ export const validateRemoteAllowList = async (uri: string): Promise { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const actual = await vi.importActual("@openzeppelin/merkle-tree"); + return { + ...actual, + StandardMerkleTree: { + of: (actual.StandardMerkleTree as any).of, + load: vi.fn().mockImplementation((actual as any).StandardMerkleTree.load), + }, + }; + /* eslint-enable @typescript-eslint/no-explicit-any */ +}); + +describe("tryParseMerkleTree", () => { + const validTree = StandardMerkleTree.of( + [ + ["0x1234567890123456789012345678901234567890", "100"], + ["0x2345678901234567890123456789012345678901", "200"], + ], + ["address", "uint256"], + ); + + test("returns true for valid merkle tree JSON", () => { + const treeJson = JSON.stringify(validTree.dump()); + const result = tryParseMerkleTree(treeJson); + expect(result).toBe(true); + }); + + test("returns true for valid merkle tree object", () => { + const result = tryParseMerkleTree(JSON.stringify(validTree.dump())); + expect(result).toBe(true); + }); + + test("returns false for invalid JSON", () => { + const result = tryParseMerkleTree("{invalid json}"); + expect(result).toBe(false); + }); + + test("returns false when StandardMerkleTree.load fails", () => { + vi.mocked(StandardMerkleTree.load).mockImplementationOnce(() => { + throw new Error("Invalid merkle tree"); + }); + + const result = tryParseMerkleTree(JSON.stringify(validTree.dump())); + expect(result).toBe(false); + }); + + test("returns false for non-object input", () => { + const result = tryParseMerkleTree("not an object or json"); + expect(result).toBe(false); + }); + + test("handles null input", () => { + const result = tryParseMerkleTree(null); + expect(result).toBe(false); + }); + + test("handles undefined input", () => { + const result = tryParseMerkleTree(undefined); + expect(result).toBe(false); + }); + + test("returns false for array input", () => { + const result = tryParseMerkleTree(JSON.stringify([])); + expect(result).toBe(false); + }); + + test("returns false for invalid tree format", () => { + const invalidTree = { + ...validTree.dump(), + format: "invalid-format", + }; + const result = tryParseMerkleTree(JSON.stringify(invalidTree)); + expect(result).toBe(false); + }); + + test("returns false for missing values", () => { + const invalidTree = { + ...validTree.dump(), + values: undefined, + }; + const result = tryParseMerkleTree(JSON.stringify(invalidTree)); + expect(result).toBe(false); + }); +}); diff --git a/test/lib/allowlists/parseMerkleTree.test.ts b/test/lib/allowlists/parseMerkleTree.test.ts new file mode 100644 index 0000000..a1b703e --- /dev/null +++ b/test/lib/allowlists/parseMerkleTree.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, vi } from "vitest"; +import { parseMerkleTree } from "../../../src/lib/allowlists/parseMerkleTree"; +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; + +// Only mock for error cases +vi.mock("@openzeppelin/merkle-tree", async () => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + + const actual = await vi.importActual("@openzeppelin/merkle-tree"); + return { + ...actual, + StandardMerkleTree: { + ...(actual as any).StandardMerkleTree, + of: (actual as any).StandardMerkleTree.of, + load: vi.fn().mockImplementation((actual as any).StandardMerkleTree.load), + }, + }; + /* eslint-enable @typescript-eslint/no-explicit-any */ +}); + +describe("parseMerkleTree", () => { + const validTree = StandardMerkleTree.of( + [ + ["0x1234567890123456789012345678901234567890", "100"], + ["0x2345678901234567890123456789012345678901", "200"], + ], + ["address", "uint256"], + ); + + test("successfully parses valid merkle tree JSON", () => { + const treeJson = JSON.stringify(validTree.dump()); + const result = parseMerkleTree(treeJson); + expect(result).toBeDefined(); + expect(result?.root).toBe(validTree.root); + }); + + test("successfully parses valid merkle tree object", () => { + const result = parseMerkleTree(JSON.stringify(validTree.dump())); + expect(result).toBeDefined(); + expect(result?.root).toBe(validTree.root); + }); + + test("returns undefined for invalid JSON", () => { + const result = parseMerkleTree("{invalid json}"); + expect(result).toBeUndefined(); + }); + + test("returns undefined when StandardMerkleTree.load fails", () => { + vi.mocked(StandardMerkleTree.load).mockImplementationOnce(() => { + throw new Error("Invalid merkle tree"); + }); + + const result = parseMerkleTree(JSON.stringify(validTree.dump())); + expect(result).toBeUndefined(); + }); + + test("returns undefined for invalid tree format", () => { + const invalidTree = { + ...validTree.dump(), + format: "invalid-format", + }; + const result = parseMerkleTree(JSON.stringify(invalidTree)); + expect(result).toBeUndefined(); + }); + + test("returns undefined for missing values", () => { + const invalidTree = { + ...validTree.dump(), + values: undefined, + }; + const result = parseMerkleTree(JSON.stringify(invalidTree)); + expect(result).toBeUndefined(); + }); + + test("verifies tree properties are accessible", () => { + const result = parseMerkleTree(JSON.stringify(validTree.dump())); + expect(result).toBeDefined(); + if (result) { + expect(result.root).toBe(validTree.root); + expect(result.entries()).toBeDefined(); + const [[index, value]] = result.entries(); + expect(index).toBe(0); + expect(value).toBeDefined(); + expect(value[0]).toBe("0x1234567890123456789012345678901234567890"); + expect(value[1]).toBe("100"); + } + }); + + test("handles proof verification", () => { + const result = parseMerkleTree(JSON.stringify(validTree.dump())); + expect(result).toBeDefined(); + if (result) { + expect(result.entries()).toBeDefined(); + const [[index, value]] = result.entries(); + const proof = result.getProof(index); + expect(Array.isArray(proof)).toBe(true); + expect(result.verify(value, proof)).toBe(true); + } + }); +}); From 4e63e187a170fe08489206893e2b3bdd38a57f26 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 20 Jan 2025 14:45:52 +0100 Subject: [PATCH 5/8] fix(errors): add custom errors Updates the UploadController to use custom errors. The custom errors are defined in a uploads lib directory and handles all cases found in the controller. --- src/controllers/UploadController.ts | 82 ++++++++++++++++++----------- src/lib/uploads/errors.ts | 42 +++++++++++++++ 2 files changed, 94 insertions(+), 30 deletions(-) create mode 100644 src/lib/uploads/errors.ts diff --git a/src/controllers/UploadController.ts b/src/controllers/UploadController.ts index ee695f1..0900048 100644 --- a/src/controllers/UploadController.ts +++ b/src/controllers/UploadController.ts @@ -9,6 +9,12 @@ import { } from "tsoa"; import { StorageService } from "../services/StorageService.js"; import type { UploadResponse } from "../types/api.js"; +import { + FileUploadError, + NoFilesUploadedError, + PartialUploadError, + UploadFailedError, +} from "../lib/uploads/errors.js"; /** * Controller handling file uploads to IPFS storage @@ -68,9 +74,9 @@ export class UploadController extends Controller { @UploadedFiles("files") files?: Express.Multer.File[], @FormField() jsonData?: string, ): Promise { - const storage = await StorageService.init(); - try { + const storage = await StorageService.init(); + if (jsonData) { console.debug("Got JSON data for future use"); } @@ -80,12 +86,7 @@ export class UploadController extends Controller { ); if (!files || !blobs) { - this.setStatus(400); - return { - success: false, - message: "No files uploaded", - errors: { upload: "No files uploaded" }, - }; + throw new NoFilesUploadedError(); } const uploadResults = await Promise.allSettled( @@ -99,10 +100,11 @@ export class UploadController extends Controller { fileName: file.originalname, }; } catch (error) { - throw { - fileName: file.originalname, - error: (error as Error).message, - }; + throw new UploadFailedError( + `Failed to upload file`, + file.originalname, + (error as Error).message, + ); } }), ); @@ -123,21 +125,32 @@ export class UploadController extends Controller { (result): result is PromiseRejectedResult => result.status === "rejected", ) - .map((result) => result.reason as { fileName: string; error: string }); + .map((result) => { + const error = result.reason as UploadFailedError; + return { + fileName: error.fileName, + error: error.errorDetail, + }; + }); if (failed.length > 0) { - this.setStatus(failed.length === uploadResults.length ? 500 : 207); - return { - success: false, - message: "Some uploads failed", - data: { - results: successful, - failed: failed.map((f) => ({ - fileName: f.fileName, - error: f.error, - })), - }, + const data = { + results: successful, + failed: failed.map((f) => ({ + fileName: f.fileName, + error: f.error, + })), }; + + if (failed.length === uploadResults.length) { + throw new UploadFailedError( + "All uploads failed", + "multiple files", + "None of the files could be uploaded", + ); + } + + throw new PartialUploadError("Some uploads failed", data); } this.setStatus(201); @@ -150,12 +163,21 @@ export class UploadController extends Controller { }, }; } catch (error) { - this.setStatus(400); - return { - success: false, - message: "Upload failed", - errors: { upload: (error as Error).message }, - }; + if (error instanceof FileUploadError) { + this.setStatus(error.code); + return { + success: false, + message: error.message, + ...(error.errors && { errors: error.errors }), + ...(error instanceof PartialUploadError && { data: error.results }), + }; + } + + throw new UploadFailedError( + "Upload failed", + "unknown file", + (error as Error).message, + ); } } } diff --git a/src/lib/uploads/errors.ts b/src/lib/uploads/errors.ts new file mode 100644 index 0000000..3328e99 --- /dev/null +++ b/src/lib/uploads/errors.ts @@ -0,0 +1,42 @@ +import { UploadResponse } from "../../types/api.js"; + +export class FileUploadError extends Error { + code: number; + public errors: UploadResponse["errors"]; + + constructor(code: number, message: string) { + super(message); + this.name = "FileUploadError"; + this.code = code; + } +} + +export class NoFilesUploadedError extends FileUploadError { + constructor() { + super(400, "No files uploaded"); + this.name = "NoFilesUploadedError"; + this.errors = { upload: "No files uploaded" }; + } +} + +export class PartialUploadError extends FileUploadError { + constructor( + message: string, + public results: UploadResponse["data"], + ) { + super(207, message); + this.name = "PartialUploadError"; + } +} + +export class UploadFailedError extends FileUploadError { + constructor( + message: string, + public fileName: string, + public errorDetail: string, + ) { + super(500, message); + this.name = "UploadFailedError"; + this.errors = { upload: message }; + } +} From a03832ab784011e3dee91afe8d2b4daa68ff4100 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 20 Jan 2025 14:49:13 +0100 Subject: [PATCH 6/8] chore(docs): update typedoc example UploadController Updates the UploadController typedoc example to document different upload tools and the expected responses --- src/controllers/UploadController.ts | 58 ++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/src/controllers/UploadController.ts b/src/controllers/UploadController.ts index 0900048..4a75e65 100644 --- a/src/controllers/UploadController.ts +++ b/src/controllers/UploadController.ts @@ -28,18 +28,41 @@ export class UploadController extends Controller { * * @summary Upload files to IPFS * @param files - Array of files to upload (max 5 files, 10MB each) + * @param jsonData - Optional JSON string with additional metadata * @returns Promise containing upload results with CIDs and any failed uploads * * @example - * Request: + * Using curl: + * ```bash + * curl -X POST http://api.example.com/v1/upload \ + * -F "files=@/path/to/file1.txt" \ + * -F "files=@/path/to/file2.txt" \ + * -F "jsonData={\"key\":\"value\"}" * ``` - * POST /v1/upload - * Content-Type: multipart/form-data * - * files: [File, File, ...] + * Using HTML Form: + * ```html + *
+ * + * + * + *
* ``` * - * Success Response: + * Using Fetch API: + * ```javascript + * const formData = new FormData(); + * formData.append('files', fileInput.files[0]); + * formData.append('files', fileInput.files[1]); + * formData.append('jsonData', JSON.stringify({key: 'value'})); + * + * fetch('/v1/upload', { + * method: 'POST', + * body: formData + * }); + * ``` + * + * Success Response (201): * ```json * { * "success": true, @@ -53,7 +76,7 @@ export class UploadController extends Controller { * } * ``` * - * Partial Success Response: + * Partial Success Response (207): * ```json * { * "success": false, @@ -67,6 +90,29 @@ export class UploadController extends Controller { * ] * } * } + * ``` + * + * No Files Error Response (400): + * ```json + * { + * "success": false, + * "message": "No files uploaded", + * "errors": { + * "upload": "No files uploaded" + * } + * } + * ``` + * + * Upload Failed Error Response (500): + * ```json + * { + * "success": false, + * "message": "Upload failed", + * "errors": { + * "upload": "Failed to upload file" + * } + * } + * ``` */ @Post() @SuccessResponse(201, "Upload successful") From 21375d37a32bc079821a6746298c0a615f018082 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 20 Jan 2025 14:53:50 +0100 Subject: [PATCH 7/8] fix(typing): add typeguards to upload result parsing Adds typeguards and updates the parsing of failed and successful file uploads for readability --- src/controllers/UploadController.ts | 46 ++++++++++++++++------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/controllers/UploadController.ts b/src/controllers/UploadController.ts index 4a75e65..169da87 100644 --- a/src/controllers/UploadController.ts +++ b/src/controllers/UploadController.ts @@ -16,6 +16,24 @@ import { UploadFailedError, } from "../lib/uploads/errors.js"; +// Type definitions and guards at module scope +type UploadResult = { + cid: string; + fileName: string; +}; + +function isSuccessfulUpload( + result: PromiseSettledResult, +): result is PromiseFulfilledResult { + return result.status === "fulfilled"; +} + +function isFailedUpload( + result: PromiseSettledResult, +): result is PromiseRejectedResult { + return result.status === "rejected"; +} + /** * Controller handling file uploads to IPFS storage * @class UploadController @@ -156,28 +174,16 @@ export class UploadController extends Controller { ); const successful = uploadResults - .filter( - ( - result, - ): result is PromiseFulfilledResult<{ - cid: string; - fileName: string; - }> => result.status === "fulfilled", - ) + .filter(isSuccessfulUpload) .map((result) => result.value); - const failed = uploadResults - .filter( - (result): result is PromiseRejectedResult => - result.status === "rejected", - ) - .map((result) => { - const error = result.reason as UploadFailedError; - return { - fileName: error.fileName, - error: error.errorDetail, - }; - }); + const failed = uploadResults.filter(isFailedUpload).map((result) => { + const error = result.reason as UploadFailedError; + return { + fileName: error.fileName, + error: error.errorDetail, + }; + }); if (failed.length > 0) { const data = { From 25c613c631ec3172912572a4c131233c5342ccc4 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 20 Jan 2025 15:20:54 +0100 Subject: [PATCH 8/8] fix(UploadController): tests typedoc and error types Updates the test cases to test on the expanded upload result types. Additionally typedoc documentation was updated to document the error and the controller more accurately. --- src/controllers/UploadController.ts | 83 +++++++++++----- src/lib/uploads/errors.ts | 148 +++++++++++++++++++++++++++- src/types/api.ts | 8 +- test/api/v1/upload.test.ts | 17 ++++ 4 files changed, 231 insertions(+), 25 deletions(-) diff --git a/src/controllers/UploadController.ts b/src/controllers/UploadController.ts index 169da87..d6b069e 100644 --- a/src/controllers/UploadController.ts +++ b/src/controllers/UploadController.ts @@ -14,6 +14,7 @@ import { NoFilesUploadedError, PartialUploadError, UploadFailedError, + SingleUploadFailedError, } from "../lib/uploads/errors.js"; // Type definitions and guards at module scope @@ -80,31 +81,37 @@ export class UploadController extends Controller { * }); * ``` * - * Success Response (201): + * Full Success Response (201): * ```json * { * "success": true, * "message": "Upload successful", + * "uploadStatus": "all", * "data": { * "results": [ - * { "cid": "Qm...", "fileName": "example.txt" } + * { "cid": "Qm...", "fileName": "example1.txt" }, + * { "cid": "Qm...", "fileName": "example2.txt" } * ], * "failed": [] * } * } * ``` * - * Partial Success Response (207): + * Multi-Status Response (207): * ```json * { * "success": false, * "message": "Some uploads failed", + * "uploadStatus": "some", * "data": { * "results": [ * { "cid": "Qm...", "fileName": "success.txt" } * ], * "failed": [ - * { "fileName": "failed.txt", "error": "Upload failed" } + * { + * "fileName": "failed.txt", + * "error": "File exceeds size limit" + * } * ] * } * } @@ -115,6 +122,7 @@ export class UploadController extends Controller { * { * "success": false, * "message": "No files uploaded", + * "uploadStatus": "none", * "errors": { * "upload": "No files uploaded" * } @@ -164,8 +172,7 @@ export class UploadController extends Controller { fileName: file.originalname, }; } catch (error) { - throw new UploadFailedError( - `Failed to upload file`, + throw new SingleUploadFailedError( file.originalname, (error as Error).message, ); @@ -178,7 +185,7 @@ export class UploadController extends Controller { .map((result) => result.value); const failed = uploadResults.filter(isFailedUpload).map((result) => { - const error = result.reason as UploadFailedError; + const error = result.reason as SingleUploadFailedError; return { fileName: error.fileName, error: error.errorDetail, @@ -188,18 +195,14 @@ export class UploadController extends Controller { if (failed.length > 0) { const data = { results: successful, - failed: failed.map((f) => ({ - fileName: f.fileName, - error: f.error, - })), + failed: failed, }; if (failed.length === uploadResults.length) { - throw new UploadFailedError( - "All uploads failed", - "multiple files", - "None of the files could be uploaded", - ); + throw new UploadFailedError("All uploads failed", { + results: [], + failed: failed, // Preserve all failed upload details + }); } throw new PartialUploadError("Some uploads failed", data); @@ -209,6 +212,7 @@ export class UploadController extends Controller { return { success: true, message: "Upload successful", + uploadStatus: "all", data: { results: successful, failed: [], @@ -220,16 +224,51 @@ export class UploadController extends Controller { return { success: false, message: error.message, - ...(error.errors && { errors: error.errors }), - ...(error instanceof PartialUploadError && { data: error.results }), + uploadStatus: error instanceof PartialUploadError ? "some" : "none", + errors: error.errors, + data: + error instanceof PartialUploadError || + error instanceof UploadFailedError + ? error.results + : { + results: [], + failed: [ + { + fileName: + error instanceof SingleUploadFailedError + ? error.fileName + : "unknown", + error: + error instanceof SingleUploadFailedError + ? error.errorDetail + : error.message, + }, + ], + }, }; } - throw new UploadFailedError( - "Upload failed", - "unknown file", - (error as Error).message, + const uploadError = new UploadFailedError( + `Upload failed: ${(error as Error).message}`, + { + results: [], + failed: [ + { + fileName: "unknown", + error: (error as Error).message, + }, + ], + }, ); + + this.setStatus(uploadError.code); + return { + success: false, + message: uploadError.message, + uploadStatus: "none", + errors: uploadError.errors, + data: uploadError.results, + }; } } } diff --git a/src/lib/uploads/errors.ts b/src/lib/uploads/errors.ts index 3328e99..2c37ce3 100644 --- a/src/lib/uploads/errors.ts +++ b/src/lib/uploads/errors.ts @@ -1,9 +1,32 @@ import { UploadResponse } from "../../types/api.js"; +/** + * Base class for file upload errors + * @class FileUploadError + * @extends Error + * + * @example + * ```typescript + * throw new FileUploadError(500, "Upload failed"); + * ``` + * + * Response: + * ```json + * { + * "success": false, + * "message": "Upload failed", + * "uploadStatus": "none" + * } + * ``` + */ export class FileUploadError extends Error { code: number; public errors: UploadResponse["errors"]; + /** + * @param code - HTTP status code + * @param message - Error message + */ constructor(code: number, message: string) { super(message); this.name = "FileUploadError"; @@ -11,6 +34,28 @@ export class FileUploadError extends Error { } } +/** + * Error thrown when no files are provided in the upload request + * @class NoFilesUploadedError + * @extends FileUploadError + * + * @example + * ```typescript + * throw new NoFilesUploadedError(); + * ``` + * + * Response: + * ```json + * { + * "success": false, + * "message": "No files uploaded", + * "uploadStatus": "none", + * "errors": { + * "upload": "No files uploaded" + * } + * } + * ``` + */ export class NoFilesUploadedError extends FileUploadError { constructor() { super(400, "No files uploaded"); @@ -19,6 +64,36 @@ export class NoFilesUploadedError extends FileUploadError { } } +/** + * Error thrown when some files uploaded successfully but others failed + * @class PartialUploadError + * @extends FileUploadError + * + * @example + * ```typescript + * throw new PartialUploadError("Some uploads failed", { + * results: [{ cid: "Qm...", fileName: "success.txt" }], + * failed: [{ fileName: "failed.txt", error: "Upload failed" }] + * }); + * ``` + * + * Response: + * ```json + * { + * "success": false, + * "message": "Some uploads failed", + * "uploadStatus": "some", + * "data": { + * "results": [ + * { "cid": "Qm...", "fileName": "success.txt" } + * ], + * "failed": [ + * { "fileName": "failed.txt", "error": "Upload failed" } + * ] + * } + * } + * ``` + */ export class PartialUploadError extends FileUploadError { constructor( message: string, @@ -29,11 +104,80 @@ export class PartialUploadError extends FileUploadError { } } -export class UploadFailedError extends FileUploadError { +/** + * Error thrown when a single file upload fails + * @class SingleUploadFailedError + * @extends FileUploadError + * + * @example + * ```typescript + * throw new SingleUploadFailedError("example.txt", "File too large"); + * ``` + * + * Response: + * ```json + * { + * "success": false, + * "message": "Failed to upload example.txt", + * "uploadStatus": "none", + * "data": { + * "results": [], + * "failed": [ + * { "fileName": "example.txt", "error": "File too large" } + * ] + * } + * } + * ``` + */ +export class SingleUploadFailedError extends FileUploadError { constructor( - message: string, public fileName: string, public errorDetail: string, + ) { + super(422, `Failed to upload ${fileName}`); + this.name = "SingleUploadFailedError"; + } +} + +/** + * Error thrown when the upload service is unavailable or all uploads fail due to service issues + * @class UploadFailedError + * @extends FileUploadError + * + * @example + * ```typescript + * throw new UploadFailedError("Upload service unavailable", { + * results: [], + * failed: [ + * { fileName: "file1.txt", error: "IPFS service unavailable" }, + * { fileName: "file2.txt", error: "Storage service not responding" } + * ] + * }); + * ``` + * + * Response: + * ```json + * { + * "success": false, + * "message": "Upload service unavailable", + * "uploadStatus": "none", + * "errors": { + * "upload": "Upload service unavailable" + * }, + * "data": { + * "results": [], + * "failed": [ + * { "fileName": "file1.txt", "error": "IPFS service unavailable" }, + * { "fileName": "file2.txt", "error": "Storage service not responding" } + * ] + * } + * } + * ``` + */ +export class UploadFailedError extends FileUploadError { + constructor( + message: string, + public results: UploadResponse["data"], ) { super(500, message); this.name = "UploadFailedError"; diff --git a/src/types/api.ts b/src/types/api.ts index d295a16..2f3628b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -26,7 +26,12 @@ export interface ValidationResponse extends BaseResponse { // Storage-related interfaces export interface StorageResponse extends DataResponse<{ cid: string }> {} -export interface UploadResponse extends BaseResponse { +export type UploadStatus = "all" | "some" | "none"; + +export interface UploadResponse { + success: boolean; + message: string; + uploadStatus: UploadStatus; data?: { results: Array<{ cid: string; @@ -37,6 +42,7 @@ export interface UploadResponse extends BaseResponse { error: string; }>; }; + errors?: Record; } // Data-related interfaces diff --git a/test/api/v1/upload.test.ts b/test/api/v1/upload.test.ts index 39f7105..c9f7ad9 100644 --- a/test/api/v1/upload.test.ts +++ b/test/api/v1/upload.test.ts @@ -28,6 +28,7 @@ describe("File upload at v1/upload", async () => { const response = await controller.upload([mockTextFile]); expect(response.success).to.be.true; + expect(response.uploadStatus).to.equal("all"); expect(response.data).to.not.be.undefined; expect(response.data?.results).to.have.lengthOf(1); expect(response.data?.results[0]).to.deep.equal({ @@ -51,6 +52,7 @@ describe("File upload at v1/upload", async () => { const response = await controller.upload(mockFiles); expect(response.success).to.be.true; + expect(response.uploadStatus).to.equal("all"); expect(response.data?.results).to.have.lengthOf(2); expect(response.data?.results).to.deep.equal([ { cid: "TEST_CID_1", fileName: "test1.txt" }, @@ -73,6 +75,7 @@ describe("File upload at v1/upload", async () => { const response = await controller.upload(mockFiles); expect(response.success).to.be.false; + expect(response.uploadStatus).to.equal("some"); expect(response.data?.results).to.have.lengthOf(1); expect(response.data?.results[0]).to.deep.equal({ cid: "TEST_CID_1", @@ -91,6 +94,7 @@ describe("File upload at v1/upload", async () => { const response = await controller.upload(undefined); expect(response.success).to.be.false; + expect(response.uploadStatus).to.equal("none"); expect(response.message).to.equal("No files uploaded"); expect(response.errors).to.deep.equal({ upload: "No files uploaded", @@ -105,6 +109,7 @@ describe("File upload at v1/upload", async () => { const response = await controller.upload([mockTextFile]); expect(response.success).to.be.false; + expect(response.uploadStatus).to.equal("none"); expect(response.data?.results).to.have.lengthOf(0); expect(response.data?.failed).to.have.lengthOf(1); expect(response.data?.failed[0]).to.deep.equal({ @@ -113,6 +118,17 @@ describe("File upload at v1/upload", async () => { }); }); + test("Handles catastrophic failure", async () => { + mocks.init.mockRejectedValue(new Error("Service initialization failed")); + + const response = await controller.upload([mockTextFile]); + + expect(response.success).to.be.false; + expect(response.uploadStatus).to.equal("none"); + expect(response.message).to.include("Upload failed"); + expect(response.errors?.upload).to.include("Service initialization failed"); + }); + test("Handles different file types", async () => { mocks.init.mockResolvedValue(mockStorage); mockStorage.uploadFile @@ -127,6 +143,7 @@ describe("File upload at v1/upload", async () => { const response = await controller.upload(mockFiles); expect(response.success).to.be.true; + expect(response.uploadStatus).to.equal("all"); expect(response.data?.results).to.have.lengthOf(2); expect(response.data?.results).to.deep.equal([ { cid: "TEST_CID_1", fileName: "doc.txt" },