diff --git a/package-lock.json b/package-lock.json index 4620429..2f1d8b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "maskan-pim-backend", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1111.0", "bcrypt": "^6.0.0", "compression": "^1.7.5", "cookie-parser": "^1.4.7", @@ -86,6 +87,314 @@ "openapi-types": ">=7" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz", + "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz", + "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.28", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/middleware-sdk-s3": "^3.972.74", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz", + "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.4", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.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/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -156,6 +465,87 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@smithy/core": { + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz", + "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.2.tgz", + "integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.2.tgz", + "integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -269,7 +659,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -463,6 +852,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -1207,7 +1602,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -2414,7 +2808,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -3526,6 +3919,12 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index f2c7403..cf71d04 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "db:seed:undo:test": "cross-env NODE_ENV=test sequelize-cli db:seed:undo:all --config src/shared/config/database.config.cjs --seeders-path src/seeders --models-path src/shared/database" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1111.0", "bcrypt": "^6.0.0", "compression": "^1.7.5", "cookie-parser": "^1.4.7", diff --git a/src/features/media/assets/asset.controller.js b/src/features/media/assets/asset.controller.js index 380aaff..88c3e88 100644 --- a/src/features/media/assets/asset.controller.js +++ b/src/features/media/assets/asset.controller.js @@ -1,4 +1,10 @@ import service from './asset.service.js'; +import fs from 'fs'; +import path from 'path'; +import { models } from '../../../shared/database/models.js'; +import { S3Service } from '../../../shared/services/s3.service.js'; +import { extractMetadata } from '../../../shared/utils/metadataExtractor.js'; + export class AssetController { getAll = async (req, res, next) => { @@ -58,13 +64,47 @@ export class AssetController { if (!req.file) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } + + let fileUrl = `/uploads/${req.file.filename}`; + let metadata = {}; + let isS3 = false; + + if (req.context && req.context.tenantId) { + const storageConfig = await models.FileServerConfig.findOne({ + where: { tenant_id: req.context.tenantId, status: 'active' } + }); + + if (storageConfig) { + isS3 = true; + const localPath = req.file.path; + const s3Key = `uploads/${req.file.filename}`; + + // 1. Extract metadata from local file before upload + metadata = extractMetadata(localPath); + + // 2. Upload to S3 + fileUrl = await S3Service.uploadFile(storageConfig, localPath, s3Key, req.file.mimetype); + + // 3. Delete local file + await fs.promises.unlink(localPath).catch(err => { + console.error('Failed to delete temp file:', localPath, err); + }); + } + } + return res.status(200).json({ success: true, data: { name: req.file.originalname, - file_url: `/uploads/${req.file.filename}`, + file_url: fileUrl, file_size: req.file.size, - mime_type: req.file.mimetype + mime_type: req.file.mimetype, + ...(isS3 ? { + checksum: metadata.checksum, + width: metadata.width, + height: metadata.height, + extension: metadata.extension + } : {}) } }); } catch (error) { @@ -77,9 +117,50 @@ export class AssetController { if (!req.file) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } + + let fileUrl = `/uploads/${req.file.filename}`; + let metadata = {}; + let isS3 = false; + + if (req.context && req.context.tenantId) { + const storageConfig = await models.FileServerConfig.findOne({ + where: { tenant_id: req.context.tenantId, status: 'active' } + }); + + if (storageConfig) { + isS3 = true; + const localPath = req.file.path; + const s3Key = `uploads/${req.file.filename}`; + + // 1. Extract metadata from local file before upload + metadata = extractMetadata(localPath); + + // 2. Upload to S3 + fileUrl = await S3Service.uploadFile(storageConfig, localPath, s3Key, req.file.mimetype); + + // 3. Delete local file + await fs.promises.unlink(localPath).catch(err => { + console.error('Failed to delete temp file:', localPath, err); + }); + } + } + + const fileData = { + file_url: fileUrl, + file_name: req.file.originalname, + file_size: req.file.size, + mime_type: req.file.mimetype, + ...(isS3 ? { + checksum: metadata.checksum, + width: metadata.width, + height: metadata.height, + extension: metadata.extension + } : {}) + }; + const record = await service.replaceFile( req.params.id, - { file_url: `/uploads/${req.file.filename}` }, + fileData, req.context ); return res.status(200).json({ success: true, data: record }); @@ -88,6 +169,7 @@ export class AssetController { } } + archive = async (req, res, next) => { try { const record = await service.archive(req.params.id, req.context); diff --git a/src/features/media/assets/asset.service.js b/src/features/media/assets/asset.service.js index d4170ac..db8e08f 100644 --- a/src/features/media/assets/asset.service.js +++ b/src/features/media/assets/asset.service.js @@ -78,21 +78,29 @@ export class AssetService { // 2. Set file-based fields if file is being uploaded let metadata = {}; if (data.file_url) { - // Handle path resolution safely for any absolute or relative URLs - let cleanPath = data.file_url; - if (cleanPath.includes('/uploads/')) { - cleanPath = 'uploads/' + cleanPath.split('/uploads/')[1]; + if (data.file_url.startsWith('http://') || data.file_url.startsWith('https://')) { + // Remote S3 file - use fields passed directly in data + data.file_name = data.file_name || path.basename(data.file_url) || 'asset_file'; + data.extension = data.extension || path.extname(data.file_url).replace('.', '') || 'bin'; + data.file_size = data.file_size || 0; + data.checksum = data.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`; + } else { + // Handle path resolution safely for any absolute or relative URLs + let cleanPath = data.file_url; + if (cleanPath.includes('/uploads/')) { + cleanPath = 'uploads/' + cleanPath.split('/uploads/')[1]; + } + const absolutePath = path.resolve(cleanPath); + + metadata = extractMetadata(absolutePath); + data.file_name = data.file_name || path.basename(absolutePath) || 'asset_file'; + data.extension = metadata.extension || path.extname(absolutePath).replace('.', '') || 'bin'; + data.file_size = metadata.file_size || data.file_size || 0; + data.checksum = metadata.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`; + data.width = metadata.width || null; + data.height = metadata.height || null; + data.page_count = metadata.page_count || null; } - const absolutePath = path.resolve(cleanPath); - - metadata = extractMetadata(absolutePath); - data.file_name = data.file_name || path.basename(absolutePath) || 'asset_file'; - data.extension = metadata.extension || path.extname(absolutePath).replace('.', '') || 'bin'; - data.file_size = metadata.file_size || data.file_size || 0; - data.checksum = metadata.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`; - data.width = metadata.width || null; - data.height = metadata.height || null; - data.page_count = metadata.page_count || null; // Check duplicates by checksum if (data.checksum) { @@ -106,6 +114,7 @@ export class AssetService { } } + // Enforce non-nullable fallback defaults data.file_name = data.file_name || 'asset_file'; data.file_url = data.file_url || '/uploads/placeholder'; @@ -232,10 +241,28 @@ export class AssetService { } // Check duplicates of new file checksum - const relativePath = fileData.file_url.replace(/^\/uploads\//, 'uploads/'); - const absolutePath = path.resolve(relativePath); - const metadata = extractMetadata(absolutePath); - const newChecksum = metadata.checksum; + let metadata = {}; + let newChecksum; + let absolutePath = ''; + + if (fileData.file_url.startsWith('http://') || fileData.file_url.startsWith('https://')) { + // Remote S3 file - use fields pre-calculated and passed in fileData + metadata = { + file_size: fileData.file_size || 0, + extension: fileData.extension || path.extname(fileData.file_url).replace('.', '') || 'bin', + checksum: fileData.checksum || `CHK-${Date.now()}`, + width: fileData.width || null, + height: fileData.height || null, + page_count: fileData.extension === 'pdf' ? 1 : null + }; + newChecksum = metadata.checksum; + } else { + // Local file path + const relativePath = fileData.file_url.replace(/^\/uploads\//, 'uploads/'); + absolutePath = path.resolve(relativePath); + metadata = extractMetadata(absolutePath); + newChecksum = metadata.checksum; + } const duplicate = await models.Asset.findOne({ where: { checksum: newChecksum, status: 'active', id: { [Op.ne]: id } }, @@ -257,7 +284,7 @@ export class AssetService { // 2. Update asset with new file details const updateData = { file_url: fileData.file_url, - file_name: path.basename(absolutePath), + file_name: fileData.file_name || (absolutePath ? path.basename(absolutePath) : path.basename(fileData.file_url)), extension: metadata.extension, file_size: metadata.file_size, checksum: newChecksum, diff --git a/src/features/settings/fileServer/controllers/fileServerConfig.controller.js b/src/features/settings/fileServer/controllers/fileServerConfig.controller.js new file mode 100644 index 0000000..68436ff --- /dev/null +++ b/src/features/settings/fileServer/controllers/fileServerConfig.controller.js @@ -0,0 +1,90 @@ +import service from '../services/fileServerConfig.service.js'; + +export class FileServerConfigController { + get = async (req, res, next) => { + try { + if (!req.context.tenantId) { + return res.status(200).json({ success: true, data: null }); + } + const config = await service.getByTenantId(req.context.tenantId); + if (config) { + // Strip or mask keys for response safety + config.secret_key = config.secret_key ? '********************' : null; + config.access_key = config.access_key ? '********************' : null; + } + return res.status(200).json({ success: true, data: config }); + } catch (error) { + next(error); + } + } + + save = async (req, res, next) => { + try { + if (!req.context.tenantId) { + return res.status(400).json({ success: false, message: 'Platform administrators cannot configure a tenant-scoped file server. Please log in as a tenant administrator.' }); + } + let configData = { ...req.body }; + const currentConfig = await service.getByTenantId(req.context.tenantId); + + // If access or secret key is submitted as masked, preserve the existing value + if (currentConfig) { + if (configData.access_key === '********************') { + configData.access_key = currentConfig.access_key; + } + if (configData.secret_key === '********************') { + configData.secret_key = currentConfig.secret_key; + } + } + + const config = await service.saveConfig(req.context.tenantId, configData, req.context); + + // Return masked values + if (config) { + config.secret_key = '********************'; + config.access_key = '********************'; + } + + return res.status(200).json({ success: true, data: config }); + } catch (error) { + next(error); + } + } + + test = async (req, res, next) => { + try { + let configData = { ...req.body }; + + if (req.context.tenantId) { + const currentConfig = await service.getByTenantId(req.context.tenantId); + // If access or secret key is submitted as masked, preserve the existing value for testing + if (currentConfig) { + if (configData.access_key === '********************') { + configData.access_key = currentConfig.access_key; + } + if (configData.secret_key === '********************') { + configData.secret_key = currentConfig.secret_key; + } + } + } + + const result = await service.testConnection(configData); + return res.status(200).json(result); + } catch (error) { + next(error); + } + } + + delete = async (req, res, next) => { + try { + if (!req.context.tenantId) { + return res.status(400).json({ success: false, message: 'No tenant context to delete config' }); + } + await service.deleteConfig(req.context.tenantId, req.context); + return res.status(200).json({ success: true, message: 'File server configuration deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new FileServerConfigController(); diff --git a/src/features/settings/fileServer/models/fileServerConfig.model.js b/src/features/settings/fileServer/models/fileServerConfig.model.js new file mode 100644 index 0000000..99d390c --- /dev/null +++ b/src/features/settings/fileServer/models/fileServerConfig.model.js @@ -0,0 +1,65 @@ +import { Model, DataTypes } from 'sequelize'; + +export class FileServerConfig extends Model { + static associate(models) { + FileServerConfig.belongsTo(models.Tenant, { + foreignKey: 'tenant_id', + as: 'tenant', + onDelete: 'CASCADE' + }); + } +} + +export default (sequelize) => { + FileServerConfig.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + tenant_id: { + type: DataTypes.INTEGER, + allowNull: false, + unique: true + }, + provider: { + type: DataTypes.STRING(50), + allowNull: false, + defaultValue: 's3' + }, + endpoint: { + type: DataTypes.STRING(255), + allowNull: false + }, + access_key: { + type: DataTypes.STRING(255), + allowNull: false + }, + secret_key: { + type: DataTypes.STRING(255), + allowNull: false + }, + bucket_name: { + type: DataTypes.STRING(150), + allowNull: false + }, + region: { + type: DataTypes.STRING(50), + allowNull: true + }, + status: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: 'active' + } + }, { + sequelize, + modelName: 'FileServerConfig', + tableName: 'file_server_configs', + timestamps: true, + underscored: true + }); + + return FileServerConfig; +}; diff --git a/src/features/settings/fileServer/repositories/fileServerConfig.repository.js b/src/features/settings/fileServer/repositories/fileServerConfig.repository.js new file mode 100644 index 0000000..0380c6d --- /dev/null +++ b/src/features/settings/fileServer/repositories/fileServerConfig.repository.js @@ -0,0 +1,29 @@ +import { models } from '../../../../shared/database/models.js'; + +export class FileServerConfigRepository { + async findByTenantId(tenantId, options = {}) { + return await models.FileServerConfig.findOne({ + where: { tenant_id: tenantId }, + ...options + }); + } + + async create(data, options = {}) { + return await models.FileServerConfig.create(data, options); + } + + async update(id, data, options = {}) { + const record = await models.FileServerConfig.findByPk(id, options); + if (!record) return null; + return await record.update(data, options); + } + + async delete(id, options = {}) { + const record = await models.FileServerConfig.findByPk(id, options); + if (!record) return false; + await record.destroy(options); + return true; + } +} + +export default new FileServerConfigRepository(); diff --git a/src/features/settings/fileServer/routes/fileServerConfig.routes.js b/src/features/settings/fileServer/routes/fileServerConfig.routes.js new file mode 100644 index 0000000..b4248ff --- /dev/null +++ b/src/features/settings/fileServer/routes/fileServerConfig.routes.js @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import controller from '../controllers/fileServerConfig.controller.js'; +import { authenticate } from '../../../../shared/middleware/auth.middleware.js'; +import { validate } from '../../../../shared/middleware/validation.middleware.js'; +import { authorize } from '../../../../shared/middleware/permission.middleware.js'; +import { saveValidation, testValidation } from '../validators/fileServerConfig.validation.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['settings.file_server']), + controller.get +); + +router.post( + '/', + authenticate, + authorize(['settings.file_server']), + saveValidation, + validate, + controller.save +); + +router.post( + '/test', + authenticate, + authorize(['settings.file_server']), + testValidation, + validate, + controller.test +); + +router.delete( + '/', + authenticate, + authorize(['settings.file_server']), + controller.delete +); + +export default router; diff --git a/src/features/settings/fileServer/services/fileServerConfig.service.js b/src/features/settings/fileServer/services/fileServerConfig.service.js new file mode 100644 index 0000000..1118aa8 --- /dev/null +++ b/src/features/settings/fileServer/services/fileServerConfig.service.js @@ -0,0 +1,97 @@ +import repository from '../repositories/fileServerConfig.repository.js'; +import { S3Service } from '../../../../shared/services/s3.service.js'; +import { AuditService } from '../../../../shared/services/audit.service.js'; +import { ApiError } from '../../../../utils/helpers/ApiError.utils.js'; + +export class FileServerConfigService { + async getByTenantId(tenantId) { + if (!tenantId) { + throw new ApiError(400, 'Tenant context is missing'); + } + const record = await repository.findByTenantId(tenantId); + return record; + } + + async saveConfig(tenantId, data, userContext = {}) { + if (!tenantId) { + throw new ApiError(400, 'Tenant context is missing'); + } + + let record = await repository.findByTenantId(tenantId); + + if (record) { + // Update existing config + record = await repository.update(record.id, { + provider: data.provider, + endpoint: data.endpoint, + access_key: data.access_key, + secret_key: data.secret_key, + bucket_name: data.bucket_name, + region: data.region || null, + status: data.status || 'active' + }); + + await AuditService.log({ + action: 'UPDATE_FILE_SERVER', + resource: 'FileServerConfig', + resourceId: record.id, + userId: userContext.userId || 'system', + details: { provider: data.provider, endpoint: data.endpoint, bucket_name: data.bucket_name } + }); + } else { + // Create new config + record = await repository.create({ + tenant_id: tenantId, + provider: data.provider, + endpoint: data.endpoint, + access_key: data.access_key, + secret_key: data.secret_key, + bucket_name: data.bucket_name, + region: data.region || null, + status: data.status || 'active' + }); + + await AuditService.log({ + action: 'CREATE_FILE_SERVER', + resource: 'FileServerConfig', + resourceId: record.id, + userId: userContext.userId || 'system', + details: { provider: data.provider, endpoint: data.endpoint, bucket_name: data.bucket_name } + }); + } + + return record; + } + + async testConnection(data) { + try { + await S3Service.testConnection(data); + return { success: true, message: 'Connection to file server was successful' }; + } catch (error) { + throw new ApiError(400, `Connection test failed: ${error.message}`); + } + } + + async deleteConfig(tenantId, userContext = {}) { + if (!tenantId) { + throw new ApiError(400, 'Tenant context is missing'); + } + const record = await repository.findByTenantId(tenantId); + if (!record) { + throw new ApiError(404, 'No file server configuration found'); + } + + await repository.delete(record.id); + + await AuditService.log({ + action: 'DELETE_FILE_SERVER', + resource: 'FileServerConfig', + resourceId: record.id, + userId: userContext.userId || 'system' + }); + + return true; + } +} + +export default new FileServerConfigService(); diff --git a/src/features/settings/fileServer/validators/fileServerConfig.validation.js b/src/features/settings/fileServer/validators/fileServerConfig.validation.js new file mode 100644 index 0000000..127b538 --- /dev/null +++ b/src/features/settings/fileServer/validators/fileServerConfig.validation.js @@ -0,0 +1,61 @@ +import { body } from 'express-validator'; + +export const saveValidation = [ + body('provider') + .isIn(['s3', 'minio']) + .withMessage('Provider must be either s3 or minio'), + body('endpoint') + .isString() + .trim() + .notEmpty() + .withMessage('Endpoint is required'), + body('access_key') + .isString() + .trim() + .notEmpty() + .withMessage('Access Key is required'), + body('secret_key') + .isString() + .trim() + .notEmpty() + .withMessage('Secret Key is required'), + body('bucket_name') + .isString() + .trim() + .notEmpty() + .withMessage('Bucket Name is required'), + body('region') + .optional() + .isString() + .trim() +]; + +export const testValidation = [ + body('provider') + .isIn(['s3', 'minio']) + .withMessage('Provider must be either s3 or minio'), + body('endpoint') + .isString() + .trim() + .notEmpty() + .withMessage('Endpoint is required'), + body('access_key') + .isString() + .trim() + .notEmpty() + .withMessage('Access Key is required'), + body('secret_key') + .isString() + .trim() + .notEmpty() + .withMessage('Secret Key is required'), + body('bucket_name') + .isString() + .trim() + .notEmpty() + .withMessage('Bucket Name is required'), + body('region') + .optional() + .isString() + .trim() +]; diff --git a/src/features/settings/index.js b/src/features/settings/index.js index 296acf9..9733516 100644 --- a/src/features/settings/index.js +++ b/src/features/settings/index.js @@ -1,10 +1,12 @@ import { Router } from 'express'; import settingsRouter from './settings/setting.routes.js'; import themeRouter from './theme/routes/theme.routes.js'; +import fileServerRouter from './fileServer/routes/fileServerConfig.routes.js'; const router = Router(); router.use('/settings/theme', themeRouter); +router.use('/settings/file-server', fileServerRouter); router.use('/settings', settingsRouter); export default router; diff --git a/src/migrations/20260817130000-create-file-server-configs.cjs b/src/migrations/20260817130000-create-file-server-configs.cjs new file mode 100644 index 0000000..28a091e --- /dev/null +++ b/src/migrations/20260817130000-create-file-server-configs.cjs @@ -0,0 +1,115 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // 1. Create file_server_configs table + await queryInterface.createTable('file_server_configs', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + tenant_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'tenants', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + provider: { + type: Sequelize.STRING(50), + allowNull: false, + defaultValue: 's3' + }, + endpoint: { + type: Sequelize.STRING(255), + allowNull: false + }, + access_key: { + type: Sequelize.STRING(255), + allowNull: false + }, + secret_key: { + type: Sequelize.STRING(255), + allowNull: false + }, + bucket_name: { + type: Sequelize.STRING(150), + allowNull: false + }, + region: { + type: Sequelize.STRING(50), + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'active' + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') + } + }); + + // Add unique index on tenant_id to allow only one file server configuration per tenant + await queryInterface.addIndex('file_server_configs', ['tenant_id'], { + unique: true, + name: 'idx_file_server_configs_tenant_id' + }); + + // 2. Insert permission node for settings.file_server + await queryInterface.sequelize.query(` + INSERT INTO permission_nodes (node_code, node_name, node_type, parent_id, node_level, display_order, + can_view, can_create, can_edit, can_delete, can_alter, can_export, can_import, created_at, updated_at) + VALUES ( + 'settings.file_server', + 'File Server Settings', + 'feature', + (SELECT id FROM permission_nodes WHERE node_code = 'settings' LIMIT 1), + 2, + 36, + true, true, true, true, false, false, false, + NOW(), NOW() + ) + ON CONFLICT (node_code) DO NOTHING; + `); + + // 3. Map permission to SUPER_ADMIN role (full access) + await queryInterface.sequelize.query(` + INSERT INTO role_permissions (role_id, node_id, can_view, can_create, can_edit, can_delete, can_alter, can_export, can_import, created_at, updated_at) + SELECT + (SELECT id FROM roles WHERE role_code = 'SUPER_ADMIN' LIMIT 1), + (SELECT id FROM permission_nodes WHERE node_code = 'settings.file_server' LIMIT 1), + true, true, true, true, true, true, true, + NOW(), NOW() + ON CONFLICT DO NOTHING; + `); + }, + + down: async (queryInterface, Sequelize) => { + // Remove role_permissions assignments + await queryInterface.sequelize.query(` + DELETE FROM role_permissions + WHERE node_id = (SELECT id FROM permission_nodes WHERE node_code = 'settings.file_server' LIMIT 1); + `); + + // Remove permission node + await queryInterface.sequelize.query(` + DELETE FROM permission_nodes WHERE node_code = 'settings.file_server'; + `); + + // Drop table + await queryInterface.dropTable('file_server_configs'); + } +}; diff --git a/src/shared/database/models.js b/src/shared/database/models.js index ec2ca8f..c0e24e3 100644 --- a/src/shared/database/models.js +++ b/src/shared/database/models.js @@ -30,6 +30,11 @@ export const associateModels = () => { models.Tenant.hasMany(models.Role, { foreignKey: 'tenant_id', as: 'roles' }); } + if (models.Tenant && models.FileServerConfig) { + models.Tenant.hasOne(models.FileServerConfig, { foreignKey: 'tenant_id', as: 'fileServerConfig' }); + } + + if (models.Role && models.PermissionNode && models.RolePermission) { models.Role.belongsToMany(models.PermissionNode, { through: models.RolePermission, @@ -134,6 +139,8 @@ import unitModelInit from '../../features/brands/units/unit.model.js'; import settingModelInit from '../../features/settings/settings/setting.model.js'; import auditLogModelInit from '../../features/auditLogs/auditLogs/auditLog.model.js'; import userThemeModelInit from '../../features/settings/theme/models/userTheme.model.js'; +import fileServerConfigModelInit from '../../features/settings/fileServer/models/fileServerConfig.model.js'; + // Attribute Management Models import attributeModelInit from '../../features/attributes/attributes/attribute.model.js'; @@ -199,6 +206,8 @@ export const initializeDatabaseModels = () => { registerModel('Setting', settingModelInit); registerModel('AuditLog', auditLogModelInit); registerModel('UserTheme', userThemeModelInit); + registerModel('FileServerConfig', fileServerConfigModelInit); + // Attribute Management registerModel('Attribute', attributeModelInit); diff --git a/src/shared/services/s3.service.js b/src/shared/services/s3.service.js new file mode 100644 index 0000000..558a409 --- /dev/null +++ b/src/shared/services/s3.service.js @@ -0,0 +1,62 @@ +import { S3Client, PutObjectCommand, HeadBucketCommand } from '@aws-sdk/client-s3'; +import fs from 'fs'; + +export class S3Service { + /** + * Uploads a file from local disk to an S3/MinIO bucket. + * @param {Object} config - The file server credentials (endpoint, access_key, secret_key, bucket_name, region) + * @param {string} localFilePath - Path to the local file + * @param {string} s3Key - Key/path under which the file should be stored in S3 + * @param {string} mimeType - The MIME type of the file + * @returns {Promise} The public access URL of the uploaded file + */ + static async uploadFile(config, localFilePath, s3Key, mimeType) { + const client = new S3Client({ + endpoint: config.endpoint, + credentials: { + accessKeyId: config.access_key, + secretAccessKey: config.secret_key + }, + region: config.region || 'us-east-1', + forcePathStyle: true + }); + + const fileStream = fs.createReadStream(localFilePath); + const command = new PutObjectCommand({ + Bucket: config.bucket_name, + Key: s3Key, + Body: fileStream, + ContentType: mimeType + }); + + await client.send(command); + + // Format the returned URL + const baseEndpoint = config.endpoint.endsWith('/') ? config.endpoint.slice(0, -1) : config.endpoint; + return `${baseEndpoint}/${config.bucket_name}/${s3Key}`; + } + + /** + * Tests a connection to S3/MinIO using the provided credentials. + * @param {Object} config - The credentials to test + * @returns {Promise} True if connection succeeded + */ + static async testConnection(config) { + const client = new S3Client({ + endpoint: config.endpoint, + credentials: { + accessKeyId: config.access_key, + secretAccessKey: config.secret_key + }, + region: config.region || 'us-east-1', + forcePathStyle: true + }); + + const command = new HeadBucketCommand({ + Bucket: config.bucket_name + }); + + await client.send(command); + return true; + } +} diff --git a/uploads/file-1786964194850-105282287.jpeg b/uploads/file-1786964194850-105282287.jpeg new file mode 100644 index 0000000..0cd3945 Binary files /dev/null and b/uploads/file-1786964194850-105282287.jpeg differ diff --git a/uploads/file-1786964218911-698867700.jpg b/uploads/file-1786964218911-698867700.jpg new file mode 100644 index 0000000..2f73d71 Binary files /dev/null and b/uploads/file-1786964218911-698867700.jpg differ diff --git a/uploads/file-1786964245570-74795151.jpg b/uploads/file-1786964245570-74795151.jpg new file mode 100644 index 0000000..2f73d71 Binary files /dev/null and b/uploads/file-1786964245570-74795151.jpg differ diff --git a/uploads/file-1786964272449-591392574.jpg b/uploads/file-1786964272449-591392574.jpg new file mode 100644 index 0000000..00d395b Binary files /dev/null and b/uploads/file-1786964272449-591392574.jpg differ diff --git a/uploads/file-1786964311091-569349152.pdf b/uploads/file-1786964311091-569349152.pdf new file mode 100644 index 0000000..dfae37c Binary files /dev/null and b/uploads/file-1786964311091-569349152.pdf differ