From 7f9358e637179f7e64da9ee5f113b6ddd6834d54 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Mon, 6 Jul 2026 14:17:20 +0330 Subject: [PATCH] uploader --- .env.example | 8 + package-lock.json | 470 ++++++++++++++++++++++++ package.json | 3 + src/app.module.ts | 2 + src/config/configuration.ts | 7 + src/uploader/dto/upload-response.dto.ts | 23 ++ src/uploader/uploader.controller.ts | 100 +++++ src/uploader/uploader.module.ts | 12 + src/uploader/uploader.service.ts | 131 +++++++ 9 files changed, 756 insertions(+) create mode 100644 src/uploader/dto/upload-response.dto.ts create mode 100644 src/uploader/uploader.controller.ts create mode 100644 src/uploader/uploader.module.ts create mode 100644 src/uploader/uploader.service.ts diff --git a/.env.example b/.env.example index e6f4137..0faf84f 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,11 @@ ZARINPAL_MERCHANT_ID=8bf4bd49-df53-44b3-a58d-b068d4c1ec98 ZARINPAL_SANDBOX=true ZARINPAL_CALLBACK_URL=http://localhost:4000/api/payments/callback ZARINPAL_RETURN_URL=http://localhost:3000/payment/result + +# Object Storage (S3-compatible / Parspack) +S3_ENDPOINT=https://c274938.parspack.net +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_BUCKET=c274938 +S3_REGION=us-east-1 +# S3_PUBLIC_BASE_URL=https://c274938.parspack.net diff --git a/package-lock.json b/package-lock.json index 24dcaaf..298c240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@aws-sdk/client-s3": "^3.1079.0", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", @@ -22,6 +23,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "multer": "^2.2.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.22.0", @@ -38,6 +40,7 @@ "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.2.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", @@ -201,6 +204,314 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.12.tgz", + "integrity": "sha512-RgNDWfhNRIlNEzePIRrYTNi/6q+wwRMMapojn8YVzw4ZcJRa/gxVMtUbeZARR1gmopuv6oIhMbY7J66qIQ0ynw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1079.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1079.0.tgz", + "integrity": "sha512-di9U/7Po7qlVYb2dq58ULsbBAE1pBIk53rux+50LQCvH1X+/l1Ys+BIk/QLBtdaK1nADk0xRNEBbA1QWVnMccw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.12", + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/credential-provider-node": "^3.972.62", + "@aws-sdk/middleware-sdk-s3": "^3.972.58", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.27.tgz", + "integrity": "sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@aws-sdk/xml-builder": "^3.972.33", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.0", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.53.tgz", + "integrity": "sha512-+KDA3uc/HZ1vIneGu5QMQb0gAXDYrm2vOE60+BJ7lS0YinMQ5i2oV4PR1A16XkF6K1IbSwjEHd1hQIIgMsK48w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.55.tgz", + "integrity": "sha512-1gBfkWY3RWeBlCoB9lIJjXMx45/54wxcgfzv6BY9otTmMrZPcNPi1v+MwZxxaCUg441NV3jsr1efnFNCXiW70g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.60.tgz", + "integrity": "sha512-CV2md+PXvABwRjApWGhQ0wACy9WSFIhnUGrovLcjnjBCd/46TbuivLADtkF8IWNjtCQmQ+2IagSaxqBYqXBNAQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/credential-provider-env": "^3.972.53", + "@aws-sdk/credential-provider-http": "^3.972.55", + "@aws-sdk/credential-provider-login": "^3.972.59", + "@aws-sdk/credential-provider-process": "^3.972.53", + "@aws-sdk/credential-provider-sso": "^3.972.59", + "@aws-sdk/credential-provider-web-identity": "^3.972.59", + "@aws-sdk/nested-clients": "^3.997.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.59.tgz", + "integrity": "sha512-JG4S9yyA1GFzJdJXqLKrUzZbyK+VDp2QIsJD7YOicJHAhqymfHpDJIok2dLnhOdVB0I37RjdC53uOwCMVS00gw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/nested-clients": "^3.997.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.62.tgz", + "integrity": "sha512-S6Slq3Tx7bvFk5yc34XNADyZYTX2HUXvaFAnowGRQnhjBO8J/mP62Fn7lxvJwjaDyYm/7gh9h6HEHaltRyMFXw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.53", + "@aws-sdk/credential-provider-http": "^3.972.55", + "@aws-sdk/credential-provider-ini": "^3.972.60", + "@aws-sdk/credential-provider-process": "^3.972.53", + "@aws-sdk/credential-provider-sso": "^3.972.59", + "@aws-sdk/credential-provider-web-identity": "^3.972.59", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.53.tgz", + "integrity": "sha512-EhfH+MQlqOMCkXIVa8MMObPzAQqwTTtxA7KhEJiyPeuNVA8PLOOUpgK7nBrgaDaGiIDLN/9LpGdaHuDjomeRTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.59.tgz", + "integrity": "sha512-h8793pOjcImx0SB+VcLONcaQQ57VAvKVuqyewQMRKqqH+CSXsG2dwOeLMUJPMxLdNvL7dXOM0ueTukyNUnu5mA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/nested-clients": "^3.997.27", + "@aws-sdk/token-providers": "3.1079.0", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.59.tgz", + "integrity": "sha512-VoyO9+vl3XVmpZwn4obskrWIkrA/Jf3lSe1E3ZERlaN9u0D4YZ6+HywC3+L98QOXqZesEfedk67gRER8tK8+8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/nested-clients": "^3.997.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.58.tgz", + "integrity": "sha512-6uaWRRYJGhOqc9EoTSbLDf9nI/doSAb5vAwGshs5/Hlv5Ce25b246lBkbRd/77fLAi+uMI1a70mJzVyLyCEufQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.27.tgz", + "integrity": "sha512-A8PIePF9NIIOJ/4Lg1rl9xm/+QaKkHGetq+Z9wb5B+3Da31YYXRo8n7IDMh5C+HQI5eyEmjrwkGWVdYtnLtbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", + "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1079.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1079.0.tgz", + "integrity": "sha512-cbietrLlHPhhmbnMPTuDS4Zj/KNGhY+3vVhn6dwjO6Dqzrwothzg2srtcY34T9mlICsTXn34avDoWLHSntP54A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.27", + "@aws-sdk/nested-clients": "^3.997.27", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", + "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", + "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2770,6 +3081,87 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.1.tgz", + "integrity": "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz", + "integrity": "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz", + "integrity": "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.3.tgz", + "integrity": "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", + "integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", + "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -3050,6 +3442,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -4472,6 +4874,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -8026,6 +8434,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", diff --git a/package.json b/package.json index faf81df..dd1329e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1079.0", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", @@ -37,6 +38,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "multer": "^2.2.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.22.0", @@ -53,6 +55,7 @@ "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.2.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 44ef29b..a40a9e5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { StylistsModule } from './stylists/stylists.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { UsersModule } from './users/users.module'; import { PaymentsModule } from './payments/payments.module'; +import { UploaderModule } from './uploader/uploader.module'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { PaymentsModule } from './payments/payments.module'; SentMessagesModule, SubscriptionsModule, PaymentsModule, + UploaderModule, ], controllers: [], providers: [], diff --git a/src/config/configuration.ts b/src/config/configuration.ts index c6afd5a..acefbeb 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -31,5 +31,12 @@ export default () => { 'http://localhost:4000/api/payments/callback', ZARINPAL_RETURN_URL: process.env.ZARINPAL_RETURN_URL ?? 'http://localhost:3000/payment/result', + S3_ENDPOINT: process.env.S3_ENDPOINT ?? 'https://c274938.parspack.net', + S3_ACCESS_KEY: process.env.S3_ACCESS_KEY ?? '', + S3_SECRET_KEY: process.env.S3_SECRET_KEY ?? '', + S3_BUCKET: process.env.S3_BUCKET ?? 'c274938', + S3_REGION: process.env.S3_REGION ?? 'us-east-1', + S3_PUBLIC_BASE_URL: + process.env.S3_PUBLIC_BASE_URL ?? process.env.S3_ENDPOINT ?? 'https://c274938.parspack.net', }; }; diff --git a/src/uploader/dto/upload-response.dto.ts b/src/uploader/dto/upload-response.dto.ts new file mode 100644 index 0000000..faf08f1 --- /dev/null +++ b/src/uploader/dto/upload-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UploadFileResponseDto { + @ApiProperty({ example: 'uploads/2026/07/uuid-photo.jpg' }) + key: string; + + @ApiProperty({ example: 'https://c274938.parspack.net/uploads/2026/07/uuid-photo.jpg' }) + url: string; + + @ApiProperty({ example: 'photo.jpg' }) + originalName: string; + + @ApiProperty({ example: 'image/jpeg' }) + mimeType: string; + + @ApiProperty({ example: 102400 }) + size: number; +} + +export class MultiUploadResponseDto { + @ApiProperty({ type: [UploadFileResponseDto] }) + files: UploadFileResponseDto[]; +} diff --git a/src/uploader/uploader.controller.ts b/src/uploader/uploader.controller.ts new file mode 100644 index 0000000..eb5c527 --- /dev/null +++ b/src/uploader/uploader.controller.ts @@ -0,0 +1,100 @@ +import { + Controller, + Post, + UploadedFile, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiTags, +} from '@nestjs/swagger'; +import { memoryStorage } from 'multer'; +import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; +import { + ApiStandardController, + ApiStandardOkResponse, +} from '../swagger/decorators/swagger-response.decorator'; +import { + MultiUploadResponseDto, + UploadFileResponseDto, +} from './dto/upload-response.dto'; +import { UploaderService } from './uploader.service'; + +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; + +@ApiTags('Uploader') +@ApiBearerAuth(SWAGGER_JWT_AUTH) +@ApiStandardController() +@Controller('uploader') +@UseGuards(RolesGuard, PermissionsGuard) +export class UploaderController { + constructor(private readonly uploaderService: UploaderService) {} + + @Roles(UserRole.ADMIN, UserRole.SALON) + @AllowWithoutSubscription() + @Post('single-upload') + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file'], + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + @ApiStandardOkResponse(UploadFileResponseDto) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: MAX_FILE_SIZE_BYTES }, + }), + ) + singleUpload(@UploadedFile() file: Express.Multer.File) { + return this.uploaderService.uploadSingle(file); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @AllowWithoutSubscription() + @Post('multi-upload') + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['files'], + properties: { + files: { + type: 'array', + items: { + type: 'string', + format: 'binary', + }, + }, + }, + }, + }) + @ApiStandardOkResponse(MultiUploadResponseDto) + @UseInterceptors( + FilesInterceptor('files', 10, { + storage: memoryStorage(), + limits: { fileSize: MAX_FILE_SIZE_BYTES }, + }), + ) + async multiUpload(@UploadedFiles() files: Express.Multer.File[]) { + const uploadedFiles = await this.uploaderService.uploadMultiple(files); + return { files: uploadedFiles }; + } +} diff --git a/src/uploader/uploader.module.ts b/src/uploader/uploader.module.ts new file mode 100644 index 0000000..48da668 --- /dev/null +++ b/src/uploader/uploader.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { UploaderController } from './uploader.controller'; +import { UploaderService } from './uploader.service'; + +@Module({ + imports: [AuthGuardsModule], + controllers: [UploaderController], + providers: [UploaderService], + exports: [UploaderService], +}) +export class UploaderModule {} diff --git a/src/uploader/uploader.service.ts b/src/uploader/uploader.service.ts new file mode 100644 index 0000000..9ac1b12 --- /dev/null +++ b/src/uploader/uploader.service.ts @@ -0,0 +1,131 @@ +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import { extname } from 'path'; +import { UploadFileResponseDto } from './dto/upload-response.dto'; + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'image/svg+xml', + 'application/pdf', +]); + +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; + +@Injectable() +export class UploaderService { + private readonly s3Client: S3Client; + private readonly bucket: string; + private readonly publicBaseUrl: string; + + constructor(private readonly configService: ConfigService) { + const endpoint = this.configService.getOrThrow('S3_ENDPOINT'); + const accessKeyId = this.configService.getOrThrow('S3_ACCESS_KEY'); + const secretAccessKey = + this.configService.getOrThrow('S3_SECRET_KEY'); + + this.bucket = this.configService.getOrThrow('S3_BUCKET'); + this.publicBaseUrl = ( + this.configService.get('S3_PUBLIC_BASE_URL') ?? endpoint + ).replace(/\/$/, ''); + + this.s3Client = new S3Client({ + endpoint, + region: this.configService.get('S3_REGION') ?? 'us-east-1', + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle: true, + }); + } + + async uploadSingle(file: Express.Multer.File): Promise { + this.validateFile(file); + return this.uploadFile(file); + } + + async uploadMultiple( + files: Express.Multer.File[], + ): Promise { + if (!files?.length) { + throw new BadRequestException('At least one file is required'); + } + + for (const file of files) { + this.validateFile(file); + } + + return Promise.all(files.map((file) => this.uploadFile(file))); + } + + private validateFile(file: Express.Multer.File): void { + if (!file) { + throw new BadRequestException('File is required'); + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + `File size must not exceed ${MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB`, + ); + } + + if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { + throw new BadRequestException( + `Unsupported file type: ${file.mimetype}. Allowed: ${[...ALLOWED_MIME_TYPES].join(', ')}`, + ); + } + } + + private async uploadFile( + file: Express.Multer.File, + ): Promise { + const key = this.buildObjectKey(file.originalname); + + try { + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + ACL: 'public-read', + }), + ); + } catch (error) { + throw new InternalServerErrorException( + `Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + + return { + key, + url: this.buildPublicUrl(key), + originalName: file.originalname, + mimeType: file.mimetype, + size: file.size, + }; + } + + private buildPublicUrl(key: string): string { + return `${this.publicBaseUrl}/${this.bucket}/${key}`; + } + + private buildObjectKey(originalName: string): string { + const now = new Date(); + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const extension = extname(originalName).toLowerCase(); + const safeName = originalName + .replace(extname(originalName), '') + .replace(/[^a-zA-Z0-9._-]/g, '-') + .slice(0, 80); + + return `uploads/${year}/${month}/${randomUUID()}-${safeName || 'file'}${extension}`; + } +}