fix:uplodig of assets and configured file server

This commit is contained in:
Mohammed-Fardeen-02
2026-08-19 12:44:26 +05:30
parent 704339380d
commit bb055fa794
19 changed files with 1106 additions and 25 deletions
+402 -3
View File
@@ -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",
+1
View File
@@ -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",
+85 -3
View File
@@ -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);
+46 -19
View File
@@ -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,
@@ -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();
@@ -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;
};
@@ -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();
@@ -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;
@@ -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();
@@ -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()
];
+2
View File
@@ -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;
@@ -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');
}
};
+9
View File
@@ -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);
+62
View File
@@ -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<string>} 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<boolean>} 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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.