diff --git a/package-lock.json b/package-lock.json index be25494..2790d9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1041.0", + "@aws-sdk/s3-request-presigner": "^3.1041.0", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.0", @@ -431,7 +432,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1041.0.tgz", "integrity": "sha512-sQV14bIqslnBHuSlLMD+fc3pH+ajop6vnrFlJ4wM4JDqcYwVik4O+9srnZUrkesFw5y+CN0GfOQ06CAgtC4mjQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -494,24 +494,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", - "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", + "version": "3.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", + "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.22", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.31", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -962,6 +956,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1041.0.tgz", + "integrity": "sha512-DlKsPQ8Z75wgeDSHbjUPNDQCYUF0OLBkqllZqFei61KIoQDqEeKUCwuCf6RhNLjaP4b8oSpBA9+FmUS+zm3xUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-format-url": "^3.972.10", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.25", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", @@ -998,12 +1011,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1038,6 +1051,19 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.25.tgz", + "integrity": "sha512-irTY6K0UaXfR9wionq3yJkxjF6r/4WV9YxaJT5RxLqmp8n2mohErhYJiEH+7lvIQmIt00xL3aGUmavqFe6yFcw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", @@ -1088,14 +1114,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", + "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", "license": "Apache-2.0", "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1142,7 +1166,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2900,7 +2923,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -2947,7 +2969,6 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -3040,7 +3061,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -3207,7 +3227,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -3239,18 +3258,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -3401,20 +3408,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.17", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", - "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", + "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -3797,18 +3797,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", - "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz", + "integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -3834,9 +3829,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4303,7 +4298,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4439,7 +4433,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4836,7 +4829,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4850,6 +4842,7 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" }, @@ -4921,7 +4914,6 @@ "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -5411,7 +5403,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5725,15 +5716,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6492,7 +6481,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -6557,7 +6547,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6618,7 +6607,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7009,42 +6997,6 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.1.3" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -7507,36 +7459,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8434,7 +8356,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9781,7 +9702,6 @@ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", "license": "MIT", - "peer": true, "dependencies": { "bson": "^6.10.4", "kareem": "2.6.3", @@ -10333,7 +10253,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10407,21 +10326,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -10618,7 +10522,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10834,8 +10737,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/repeat-string": { "version": "1.6.1", @@ -11004,7 +10906,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11060,7 +10961,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11330,7 +11230,6 @@ "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" @@ -11633,18 +11532,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -12194,7 +12081,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12341,7 +12227,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12643,6 +12528,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12657,6 +12543,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -12667,6 +12554,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/package.json b/package.json index b851fa9..3ce5fc9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1041.0", "@aws-sdk/lib-storage": "^3.1041.0", + "@aws-sdk/s3-request-presigner": "^3.1041.0", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.0", diff --git a/src/app.module.ts b/src/app.module.ts index 9e97118..bdd28e9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -34,6 +34,7 @@ import { SuperAdminModule } from './modules/superadmin/superadmin.module'; import { SupportModule } from './modules/support/support.module'; import { UsersModule } from './modules/users/users.module'; import { ThrottleGuard } from './common/guards/throttle.guard'; +import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor'; @Module({ imports: [ @@ -79,6 +80,10 @@ import { ThrottleGuard } from './common/guards/throttle.guard'; provide: APP_GUARD, useClass: ThrottleGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: MediaUrlInterceptor, + }, ], }) export class AppModule {} diff --git a/src/common/interceptors/media-url.interceptor.spec.ts b/src/common/interceptors/media-url.interceptor.spec.ts new file mode 100644 index 0000000..a1ffad8 --- /dev/null +++ b/src/common/interceptors/media-url.interceptor.spec.ts @@ -0,0 +1,51 @@ +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { lastValueFrom, of } from 'rxjs'; +import { MediaStorageService } from '../media/media-storage.service'; +import { MediaUrlInterceptor } from './media-url.interceptor'; + +describe('MediaUrlInterceptor', () => { + const storageService = { + shouldSignResponseUrls: jest.fn(() => true), + resolveResponseUrl: jest.fn(async (url: string) => + url.includes('s3.cumin.dev') ? `${url}?signed=true` : url, + ), + } as unknown as MediaStorageService; + const interceptor = new MediaUrlInterceptor(storageService); + const context = { + getType: () => 'http', + } as ExecutionContext; + + beforeEach(() => { + (storageService.resolveResponseUrl as jest.Mock).mockClear(); + }); + + it('signs nested media URLs and variants throughout a response', async () => { + const response = { + imageUrls: ['https://s3.cumin.dev/uploads/image.jpg'], + media: { + displayUrl: 'https://s3.cumin.dev/uploads/display.jpg', + images: [ + { + url: 'https://s3.cumin.dev/uploads/medium.jpg', + originalUrl: 'https://s3.cumin.dev/uploads/original.jpg', + variants: { + lowUrl: 'https://s3.cumin.dev/uploads/low.jpg', + }, + }, + ], + preferredPlaybackUrl: 'https://s3.cumin.dev/uploads/audio.mp3', + }, + externalUrl: 'https://cdn.example.com/image.jpg', + }; + const next = { handle: () => of(response) } as CallHandler; + + const result = (await lastValueFrom(interceptor.intercept(context, next))) as typeof response; + + expect(result.imageUrls[0]).toContain('?signed=true'); + expect(result.media.displayUrl).toContain('?signed=true'); + expect(result.media.images[0].originalUrl).toContain('?signed=true'); + expect(result.media.images[0].variants.lowUrl).toContain('?signed=true'); + expect(result.media.preferredPlaybackUrl).toContain('?signed=true'); + expect(result.externalUrl).toBe('https://cdn.example.com/image.jpg'); + }); +}); diff --git a/src/common/interceptors/media-url.interceptor.ts b/src/common/interceptors/media-url.interceptor.ts new file mode 100644 index 0000000..7d1f566 --- /dev/null +++ b/src/common/interceptors/media-url.interceptor.ts @@ -0,0 +1,63 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { from, Observable, switchMap } from 'rxjs'; +import { MediaStorageService } from '../media/media-storage.service'; + +@Injectable() +export class MediaUrlInterceptor implements NestInterceptor { + constructor(private readonly mediaStorageService: MediaStorageService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'http' || !this.mediaStorageService.shouldSignResponseUrls()) { + return next.handle(); + } + + return next + .handle() + .pipe(switchMap((data) => from(this.resolveValue(data, new WeakMap())))); + } + + private async resolveValue(value: unknown, seen: WeakMap): Promise { + if (typeof value === 'string') { + return this.mediaStorageService.resolveResponseUrl(value); + } + + if ( + value === null || + typeof value !== 'object' || + value instanceof Date || + Buffer.isBuffer(value) + ) { + return value; + } + + const existing = seen.get(value); + if (existing) { + return existing; + } + + if (Array.isArray(value)) { + const result: unknown[] = []; + seen.set(value, result); + const entries = await Promise.all(value.map((entry) => this.resolveValue(entry, seen))); + result.push(...entries); + return result; + } + + const serializable = + typeof (value as { toJSON?: unknown }).toJSON === 'function' + ? (value as { toJSON: () => unknown }).toJSON() + : value; + if (serializable !== value) { + return this.resolveValue(serializable, seen); + } + + const result: Record = {}; + seen.set(value, result); + await Promise.all( + Object.entries(value).map(async ([key, entryValue]) => { + result[key] = await this.resolveValue(entryValue, seen); + }), + ); + return result; + } +} diff --git a/src/common/media/media-storage.service.spec.ts b/src/common/media/media-storage.service.spec.ts new file mode 100644 index 0000000..1b6cf09 --- /dev/null +++ b/src/common/media/media-storage.service.spec.ts @@ -0,0 +1,122 @@ +import { ConfigService } from '@nestjs/config'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { MediaStorageService } from './media-storage.service'; + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(), +})); + +const mockedGetSignedUrl = jest.mocked(getSignedUrl); + +describe('MediaStorageService response URLs', () => { + const config: Record = { + 'storage.provider': 's3', + 'storage.mediaAccessMode': 'signed', + 'storage.signedUrlExpiresSeconds': 86400, + 'storage.basePath': 'uploads', + 'storage.publicBaseUrl': 'https://s3.cumin.dev', + 'storage.s3.bucket': 'oudelaa', + 'storage.s3.region': 'garage', + 'storage.s3.endpoint': 'https://s3.cumin.dev', + 'storage.s3.accessKeyId': 'test-access-key', + 'storage.s3.secretAccessKey': 'test-secret-key', + 'storage.s3.forcePathStyle': true, + publicBaseUrl: 'https://api.oudelaa.test', + }; + const configService = { + get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue), + } as unknown as ConfigService; + const appLogger = { + error: jest.fn(), + } as unknown as AppLoggerService; + + beforeEach(() => { + mockedGetSignedUrl.mockReset(); + (appLogger.error as jest.Mock).mockReset(); + }); + + it('signs a managed S3 URL with the configured expiry', async () => { + mockedGetSignedUrl.mockResolvedValue( + 'https://s3.cumin.dev/oudelaa/uploads/posts/image.jpg?X-Amz-Signature=signed', + ); + const service = new MediaStorageService(configService, appLogger); + + await expect( + service.resolveResponseUrl('https://s3.cumin.dev/uploads/posts/image.jpg'), + ).resolves.toContain('X-Amz-Signature=signed'); + + expect(mockedGetSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + input: { + Bucket: 'oudelaa', + Key: 'uploads/posts/image.jpg', + }, + }), + { expiresIn: 86400 }, + ); + }); + + it('leaves external URLs unchanged', async () => { + const service = new MediaStorageService(configService, appLogger); + + await expect(service.resolveResponseUrl('https://cdn.example.com/external.jpg')).resolves.toBe( + 'https://cdn.example.com/external.jpg', + ); + expect(mockedGetSignedUrl).not.toHaveBeenCalled(); + }); + + it('extracts the key from a path-style URL that includes the bucket', async () => { + config['storage.publicBaseUrl'] = ''; + mockedGetSignedUrl.mockResolvedValue( + 'https://s3.cumin.dev/oudelaa/uploads/posts/video.mp4?X-Amz-Signature=signed', + ); + const service = new MediaStorageService(configService, appLogger); + + await service.resolveResponseUrl('https://s3.cumin.dev/oudelaa/uploads/posts/video.mp4'); + + expect(mockedGetSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + input: { + Bucket: 'oudelaa', + Key: 'uploads/posts/video.mp4', + }, + }), + { expiresIn: 86400 }, + ); + config['storage.publicBaseUrl'] = 'https://s3.cumin.dev'; + }); + + it('leaves local storage URLs unchanged', async () => { + config['storage.provider'] = 'local'; + const service = new MediaStorageService(configService, appLogger); + + await expect(service.resolveResponseUrl('/uploads/posts/image.jpg')).resolves.toBe( + '/uploads/posts/image.jpg', + ); + expect(mockedGetSignedUrl).not.toHaveBeenCalled(); + config['storage.provider'] = 's3'; + }); + + it('logs key, bucket and error details without returning a direct URL', async () => { + mockedGetSignedUrl.mockRejectedValue(new Error('signing failed')); + const service = new MediaStorageService(configService, appLogger); + + await expect( + service.resolveResponseUrl('https://s3.cumin.dev/uploads/posts/image.jpg'), + ).resolves.toBe(''); + expect(appLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + event: 's3_presigned_url_failed', + key: 'uploads/posts/image.jpg', + bucket: 'oudelaa', + errorName: 'Error', + errorMessage: 'signing failed', + }), + expect.any(String), + 'MediaStorageService', + ); + }); +}); diff --git a/src/common/media/media-storage.service.ts b/src/common/media/media-storage.service.ts index e9d94fa..d633328 100644 --- a/src/common/media/media-storage.service.ts +++ b/src/common/media/media-storage.service.ts @@ -1,8 +1,15 @@ import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { randomUUID } from 'crypto'; import { posix } from 'path'; +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; import { getFileExtension } from './allowed-media'; export type MediaStorageUploadFile = { @@ -23,10 +30,17 @@ export type MediaStorageUploadResult = { @Injectable() export class MediaStorageService implements OnModuleDestroy { private s3Client: S3Client | null = null; + private readonly signedUrlCache = new Map(); - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly appLogger: AppLoggerService, + ) {} - async uploadFile(file: MediaStorageUploadFile, folder: string): Promise { + async uploadFile( + file: MediaStorageUploadFile, + folder: string, + ): Promise { const extension = getFileExtension(file.originalname); const fileName = `media-${randomUUID()}${extension}`; const key = posix.join(this.getBasePath(), this.normalizeFolder(folder), fileName); @@ -98,31 +112,92 @@ export class MediaStorageService implements OnModuleDestroy { resolveKeyFromUrl(fileUrl: string): string | null { const normalizedUrl = fileUrl.split('?')[0].split('#')[0]; + const managedPathPrefix = `/${this.getBasePath()}/`; + if (normalizedUrl.startsWith(managedPathPrefix)) { + return this.normalizeKey(normalizedUrl.slice(1)); + } + const publicBaseUrl = this.getPublicBaseUrl(); if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) { - return this.normalizeKey(normalizedUrl.slice(publicBaseUrl.length + 1)); + return this.normalizeS3UrlPath(normalizedUrl.slice(publicBaseUrl.length + 1)); } const endpoint = this.getEndpoint(); - const bucket = this.getBucket(); if (!endpoint || !normalizedUrl.startsWith(`${endpoint}/`)) { - return null; + return this.resolveKeyFromApplicationUrl(normalizedUrl, managedPathPrefix); } - const pathPart = normalizedUrl.slice(endpoint.length + 1); - if (this.getForcePathStyle()) { - const expectedPrefix = `${bucket}/`; - return pathPart.startsWith(expectedPrefix) - ? this.normalizeKey(pathPart.slice(expectedPrefix.length)) - : null; + return this.normalizeS3UrlPath(normalizedUrl.slice(endpoint.length + 1)); + } + + shouldSignResponseUrls(): boolean { + return ( + this.getProvider() === 's3' && + this.configService.get('storage.mediaAccessMode', { infer: true }) === 'signed' + ); + } + + async resolveResponseUrl(fileUrl: string): Promise { + if (!this.shouldSignResponseUrls() || !fileUrl.trim()) { + return fileUrl; } - return this.normalizeKey(pathPart); + const key = this.resolveKeyFromUrl(fileUrl); + if (!key) { + return fileUrl; + } + + return this.getSignedObjectUrl(key); + } + + async getSignedObjectUrl(key: string): Promise { + const normalizedKey = this.normalizeKey(key); + if (!normalizedKey) { + return ''; + } + + const cached = this.signedUrlCache.get(normalizedKey); + if (cached && cached.refreshAfterEpochMs > Date.now()) { + return cached.url; + } + + const bucket = this.getBucket(); + const expiresIn = this.getSignedUrlExpiresSeconds(); + + try { + const url = await getSignedUrl( + this.getS3Client(), + new GetObjectCommand({ + Bucket: bucket, + Key: normalizedKey, + }), + { expiresIn }, + ); + const refreshBufferSeconds = Math.min(60, Math.max(1, Math.floor(expiresIn / 10))); + this.cacheSignedUrl(normalizedKey, url, expiresIn - refreshBufferSeconds); + return url; + } catch (error) { + const errorName = error instanceof Error ? error.name : 'UnknownError'; + const errorMessage = error instanceof Error ? error.message : String(error); + this.appLogger.error( + { + event: 's3_presigned_url_failed', + key: normalizedKey, + bucket, + errorName, + errorMessage, + }, + error instanceof Error ? error.stack : undefined, + 'MediaStorageService', + ); + return ''; + } } onModuleDestroy(): void { this.s3Client = null; + this.signedUrlCache.clear(); } private getS3Client(): S3Client { @@ -159,6 +234,15 @@ export class MediaStorageService implements OnModuleDestroy { .replace(/^\/+|\/+$/g, ''); } + private getProvider(): 'local' | 's3' { + return ( + (this.configService.get('storage.provider', { infer: true }) as + | 'local' + | 's3' + | undefined) ?? 'local' + ); + } + private getBucket(): string { const bucket = this.configService.get('storage.s3.bucket', { infer: true }) ?? ''; if (!bucket) { @@ -185,6 +269,53 @@ export class MediaStorageService implements OnModuleDestroy { return this.configService.get('storage.s3.forcePathStyle', { infer: true }) ?? false; } + private getSignedUrlExpiresSeconds(): number { + const configured = + this.configService.get('storage.signedUrlExpiresSeconds', { infer: true }) ?? 86400; + return Math.min(604800, Math.max(1, Math.floor(configured))); + } + + private resolveKeyFromApplicationUrl(fileUrl: string, managedPathPrefix: string): string | null { + const applicationBaseUrl = ( + this.configService.get('publicBaseUrl', { infer: true }) ?? '' + ).replace(/\/$/, ''); + if (!applicationBaseUrl || !fileUrl.startsWith(`${applicationBaseUrl}/`)) { + return null; + } + + try { + const pathname = new URL(fileUrl).pathname; + return pathname.startsWith(managedPathPrefix) ? this.normalizeKey(pathname.slice(1)) : null; + } catch { + return null; + } + } + + private normalizeS3UrlPath(pathPart: string): string | null { + if (!this.getForcePathStyle()) { + return this.normalizeKey(pathPart); + } + + const bucketPrefix = `${this.getBucket()}/`; + return this.normalizeKey( + pathPart.startsWith(bucketPrefix) ? pathPart.slice(bucketPrefix.length) : pathPart, + ); + } + + private cacheSignedUrl(key: string, url: string, refreshAfterSeconds: number): void { + if (this.signedUrlCache.size >= 10000) { + const oldestKey = this.signedUrlCache.keys().next().value as string | undefined; + if (oldestKey) { + this.signedUrlCache.delete(oldestKey); + } + } + + this.signedUrlCache.set(key, { + url, + refreshAfterEpochMs: Date.now() + Math.max(1, refreshAfterSeconds) * 1000, + }); + } + private normalizeFolder(folder: string): string { const normalized = folder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); if ( diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 05cfd34..85cbe5e 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -82,6 +82,8 @@ export default () => ({ }, storage: { provider: process.env.STORAGE_PROVIDER ?? 'local', + mediaAccessMode: process.env.MEDIA_ACCESS_MODE ?? 'direct', + signedUrlExpiresSeconds: Number(process.env.SIGNED_URL_EXPIRES_SECONDS ?? 86400), basePath: process.env.STORAGE_BASE_PATH ?? 'uploads', publicBaseUrl: process.env.S3_PUBLIC_BASE_URL ?? process.env.STORAGE_PUBLIC_BASE_URL ?? '', s3: { diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index 28bfa65..c68fca6 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -54,6 +54,8 @@ export const validationSchema = Joi.object({ QUEUE_REMOVE_ON_COMPLETE: Joi.boolean().truthy('true').falsy('false').default(true), QUEUE_WORKER_CONCURRENCY: Joi.number().min(1).max(100).default(5), STORAGE_PROVIDER: Joi.string().valid('local', 's3').default('local'), + MEDIA_ACCESS_MODE: Joi.string().valid('direct', 'signed').default('direct'), + SIGNED_URL_EXPIRES_SECONDS: Joi.number().integer().min(1).max(604800).default(86400), STORAGE_BASE_PATH: Joi.string().default('uploads'), STORAGE_PUBLIC_BASE_URL: Joi.string().allow('').optional(), S3_BUCKET: Joi.string().allow('').optional(),