Add presigned S3 media URLs
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-25 17:38:08 +03:00
الأصل 6752ee5fbe
التزام fc7c93ed3e
9 ملفات معدلة مع 463 إضافات و198 حذوفات

256
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",

عرض الملف

@@ -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",

عرض الملف

@@ -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 {}

عرض الملف

@@ -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');
});
});

عرض الملف

@@ -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<unknown> {
if (context.getType() !== 'http' || !this.mediaStorageService.shouldSignResponseUrls()) {
return next.handle();
}
return next
.handle()
.pipe(switchMap((data) => from(this.resolveValue(data, new WeakMap<object, unknown>()))));
}
private async resolveValue(value: unknown, seen: WeakMap<object, unknown>): Promise<unknown> {
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<string, unknown> = {};
seen.set(value, result);
await Promise.all(
Object.entries(value).map(async ([key, entryValue]) => {
result[key] = await this.resolveValue(entryValue, seen);
}),
);
return result;
}
}

عرض الملف

@@ -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<string, unknown> = {
'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',
);
});
});

عرض الملف

@@ -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<string, { url: string; refreshAfterEpochMs: number }>();
constructor(private readonly configService: ConfigService) {}
constructor(
private readonly configService: ConfigService,
private readonly appLogger: AppLoggerService,
) {}
async uploadFile(file: MediaStorageUploadFile, folder: string): Promise<MediaStorageUploadResult> {
async uploadFile(
file: MediaStorageUploadFile,
folder: string,
): Promise<MediaStorageUploadResult> {
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<string>('storage.mediaAccessMode', { infer: true }) === 'signed'
);
}
async resolveResponseUrl(fileUrl: string): Promise<string> {
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<string> {
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<string>('storage.provider', { infer: true }) as
| 'local'
| 's3'
| undefined) ?? 'local'
);
}
private getBucket(): string {
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
if (!bucket) {
@@ -185,6 +269,53 @@ export class MediaStorageService implements OnModuleDestroy {
return this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
}
private getSignedUrlExpiresSeconds(): number {
const configured =
this.configService.get<number>('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<string>('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 (

عرض الملف

@@ -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: {

عرض الملف

@@ -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(),