diff --git a/package.json b/package.json index 36245d8..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,8 @@ "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", "reflect-metadata": "^0.2.2", @@ -95,6 +98,8 @@ "@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", "@types/sinon": "^17.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad5800..650eb87 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,12 @@ 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 node-cron: specifier: ^3.0.3 version: 3.0.3 @@ -204,6 +213,12 @@ 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 '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 @@ -2280,6 +2295,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'} @@ -2735,6 +2753,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==} @@ -2744,8 +2765,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 +3233,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 +3748,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 +3804,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'} @@ -4392,6 +4423,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} @@ -4564,6 +4599,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'} @@ -5052,6 +5091,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'} @@ -5681,6 +5724,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'} @@ -6099,6 +6146,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==} @@ -6241,6 +6292,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 +6392,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 +6558,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 +6824,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==} @@ -6807,6 +6870,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==} @@ -6951,6 +7018,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==} @@ -7118,6 +7189,9 @@ 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'} @@ -7158,6 +7232,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==} @@ -10454,6 +10532,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 @@ -10926,7 +11006,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 +11025,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 @@ -11073,13 +11153,15 @@ snapshots: '@types/lru-cache@5.1.1': {} + '@types/mime-types@2.1.4': {} + '@types/mime@1.3.5': {} '@types/mime@3.0.4': {} '@types/minimatch@3.0.5': {} - '@types/multer@1.4.11': + '@types/multer@1.4.12': dependencies: '@types/express': 4.17.21 @@ -11744,6 +11826,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 +12464,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 +12533,8 @@ snapshots: cookie@0.6.0: {} + core-util-is@1.0.3: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -13243,6 +13336,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: @@ -13412,6 +13512,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 @@ -14058,6 +14163,8 @@ snapshots: is-stream@3.0.0: {} + is-stream@4.0.1: {} + is-string@1.0.7: dependencies: has-tostringtag: 1.0.2 @@ -14679,6 +14786,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: {} @@ -15114,6 +15231,8 @@ snapshots: peek-readable@5.0.0: {} + peek-readable@5.3.1: {} + perfect-debounce@1.0.0: {} periscopic@3.1.0: @@ -15248,6 +15367,8 @@ snapshots: prettier@3.3.2: {} + process-nextick-args@2.0.1: {} + process@0.11.10: {} promise@7.3.1: @@ -15369,6 +15490,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 +15683,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 +15982,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 @@ -15882,6 +16019,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: @@ -16066,6 +16208,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 @@ -16269,6 +16416,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 @@ -16304,6 +16453,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/__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/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/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/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/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/src/middleware/upload.ts b/src/middleware/upload.ts new file mode 100644 index 0000000..cc31486 --- /dev/null +++ b/src/middleware/upload.ts @@ -0,0 +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/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/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 { + 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/lib/allowlists/isParsableToMerkleTree.test.ts b/test/lib/allowlists/isParsableToMerkleTree.test.ts new file mode 100644 index 0000000..6fd82d9 --- /dev/null +++ b/test/lib/allowlists/isParsableToMerkleTree.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect, vi } from "vitest"; +import { tryParseMerkleTree } from "../../../src/lib/allowlists/isParsableToMerkleTree"; +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: { + 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); + } + }); +}); 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(); + }); +}); 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] }] + } } } 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/**/*", ], },