diff --git a/.env.example b/.env.example index 471ec10..2990091 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,6 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= AWS_BUCKET= + +#Generator File +GENERATOR_FILE_URL= diff --git a/package-lock.json b/package-lock.json index 4ded430..f4535c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3931,6 +3931,21 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "babel-jest": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.5.0.tgz", @@ -4307,6 +4322,14 @@ "text-hex": "1.0.x" } }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4481,6 +4504,11 @@ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4779,6 +4807,21 @@ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, + "follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6303,6 +6346,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/package.json b/package.json index 1771146..35cf956 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.310.0", + "axios": "^1.7.4", "body-parser": "^1.20.1", "compression": "^1.7.4", "cors": "^2.8.5", diff --git a/src/config/config.interface.ts b/src/config/config.interface.ts index de7b789..f7d2289 100644 --- a/src/config/config.interface.ts +++ b/src/config/config.interface.ts @@ -36,4 +36,7 @@ export interface Config { bucket: string region: string } + generator_file: { + url: string + } } diff --git a/src/config/config.schema.ts b/src/config/config.schema.ts index 1e34e07..04542a6 100644 --- a/src/config/config.schema.ts +++ b/src/config/config.schema.ts @@ -11,8 +11,8 @@ export default Joi.object({ FILE_URI: Joi.string().uri().optional(), FILE_TYPE: Joi.string() .optional() - .default('image/jpg,image/png,image/jpeg,image/svg+xml'), - FILE_MAX: Joi.number().optional().default(10), + .default('image/jpg,image/png,image/jpeg,image/svg+xml,image/webp'), + FILE_MAX: Joi.number().optional().default(50), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().required(), DB_USERNAME: Joi.string().required(), @@ -25,4 +25,5 @@ export default Joi.object({ AWS_REGION: Joi.string().optional(), JWT_ACCESS_SECRET: Joi.string().required(), JWT_ALGORITHM: Joi.string().default('HS256'), + GENERATOR_FILE_URL: Joi.string().uri().optional(), }) diff --git a/src/config/config.ts b/src/config/config.ts index 3bd5dc0..945cf89 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -44,6 +44,9 @@ const config: Config = { bucket: env.AWS_BUCKET, region: env.AWS_REGION, }, + generator_file: { + url: env.GENERATOR_FILE_URL, + }, } export default config diff --git a/src/external/fileGenerator.ts b/src/external/fileGenerator.ts new file mode 100644 index 0000000..5a84d35 --- /dev/null +++ b/src/external/fileGenerator.ts @@ -0,0 +1,67 @@ +import axios from 'axios' +import { Config } from '../config/config.interface' +import Logger from '../pkg/logger' +import S3 from './s3' +import error from '../pkg/error' + +class FileGenerator { + constructor( + private config: Config, + private s3: S3, + private logger: Logger + ) {} + + public async ImageCompression( + uri: string, + quality: number, + convertTo: string, + path: string + ) { + try { + const { url, size } = await this.send('convert-image', { + url: uri, + quality, + convertTo, + }) + + const { data, headers } = await axios.get(url, { + responseType: 'arraybuffer', + }) + + const contentType = headers['content-type'] || '' + + await this.s3.Upload(data, path, contentType) + this.logger.Info('process compress image success', { + category: 'compress-image', + }) + return size + } catch (error: any) { + this.logger.Error( + 'process compress image failed: ' + error.message, + { category: 'compress-image' } + ) + throw error + } + } + + private async send(path: string, body: object) { + try { + const { data } = await axios.post( + this.config.generator_file.url + '/' + path, + body + ) + + return data.data + } catch (err: any) { + const message = err.message + + this.logger.Error(message, { + category: FileGenerator.name, + }) + + throw new error(err.status, message) + } + } +} + +export default FileGenerator diff --git a/src/helpers/file.ts b/src/helpers/file.ts index 7ceb3ea..a7fdd95 100644 --- a/src/helpers/file.ts +++ b/src/helpers/file.ts @@ -1,7 +1,3 @@ -import path from 'path' - export const CustomPathFile = (newPath: string, file: any) => { - const ext = path.extname(file.filename) - if (!ext) file.filename = file.filename + path.extname(file.originalname) - return `${newPath}/${file.filename}` + return `${newPath}/${file.originalname}` } diff --git a/src/helpers/regex.ts b/src/helpers/regex.ts index 2a565a2..1bf30bf 100644 --- a/src/helpers/regex.ts +++ b/src/helpers/regex.ts @@ -4,3 +4,4 @@ export const RegexSanitize = /^[ a-zA-Z0-9_,.()'"&\?\-/]+$/ export const RegexObjectID = /^[0-9a-fA-F]{24}$/ export const RegexContentTypeImage = /^image\// export const RegexExtensionImage = /.png|.jpg|.jpeg|.svg|.webp/i +export const RegexContentTypeImageNotCompressed = /image\/svg\+xml|image\/webp/ diff --git a/src/helpers/requestParams.ts b/src/helpers/requestParams.ts index ef3110e..2d92ea9 100644 --- a/src/helpers/requestParams.ts +++ b/src/helpers/requestParams.ts @@ -18,7 +18,6 @@ export const GetRequestParams = (query: Record): RequestParams => { sort_order = 'asc' } - return { ...query, page, diff --git a/src/modules/images/delivery/http/handler.ts b/src/modules/images/delivery/http/handler.ts index bad3f59..0d56352 100644 --- a/src/modules/images/delivery/http/handler.ts +++ b/src/modules/images/delivery/http/handler.ts @@ -23,6 +23,9 @@ class Handler { category: req.body.category, tags: req.body.tags, file: req.file || {}, + compression: req.body.compression, + quality: req.body.quality, + convertTo: req.body.convertTo, }) } diff --git a/src/modules/images/entity/interface.ts b/src/modules/images/entity/interface.ts index c9b7009..4d26232 100644 --- a/src/modules/images/entity/interface.ts +++ b/src/modules/images/entity/interface.ts @@ -1,10 +1,10 @@ export interface Store { file: File - caption: string title: string - description: string category: string - tags: string[] + compression: boolean + quality: number + convertTo: string } export interface File { diff --git a/src/modules/images/entity/schema.ts b/src/modules/images/entity/schema.ts index 95ccd4b..7e0fd6a 100644 --- a/src/modules/images/entity/schema.ts +++ b/src/modules/images/entity/schema.ts @@ -13,14 +13,19 @@ const file = Joi.object({ }) export const Store = Joi.object({ - caption: Joi.string().regex(RegexSanitize).optional(), category: Joi.string().alphanum().required(), - tags: Joi.array() - .items(Joi.string().alphanum()) - .optional() - .default([]) - .unique((a, b) => a == b, { ignoreUndefined: true }), title: Joi.string().regex(RegexSanitize).optional().default(null), description: Joi.string().regex(RegexSanitize).optional().default(null), + compression: Joi.boolean().optional().default(false), + quality: Joi.number().min(1).max(100).when('compression', { + is: true, + then: Joi.required(), + otherwise: Joi.optional(), + }), + convertTo: Joi.string().valid('jpeg', 'webp').when('compression', { + is: true, + then: Joi.required(), + otherwise: Joi.optional(), + }), file, }) diff --git a/src/modules/images/images.ts b/src/modules/images/images.ts index 45b308e..eb2a402 100644 --- a/src/modules/images/images.ts +++ b/src/modules/images/images.ts @@ -5,6 +5,7 @@ import Handler from './delivery/http/handler' import Repository from './repository/mongo/repository' import { Config } from '../../config/config.interface' import S3 from '../../external/s3' +import FileGenerator from '../../external/fileGenerator' class Images { constructor( @@ -13,8 +14,9 @@ class Images { private config: Config ) { const s3 = new S3(config) + const fileGenerator = new FileGenerator(config, s3, logger) const repository = new Repository(logger) - const usecase = new Usecase(logger, repository, s3) + const usecase = new Usecase(logger, repository, s3, fileGenerator) this.loadHttp(usecase) } diff --git a/src/modules/images/repository/mongo/repository.ts b/src/modules/images/repository/mongo/repository.ts index e3acdc8..260ad94 100644 --- a/src/modules/images/repository/mongo/repository.ts +++ b/src/modules/images/repository/mongo/repository.ts @@ -53,6 +53,16 @@ class Repository { return schemaNew.save() } + + public async UpdateSize(id: string, size: number) { + return imageSchema.findByIdAndUpdate(id, { + 'file.size': size, + }) + } + + public async FindByPath(path: string) { + return imageSchema.findOne({ 'file.path': path }).exec() + } } export default Repository diff --git a/src/modules/images/usecase/usecase.ts b/src/modules/images/usecase/usecase.ts index 27d2e2b..80df204 100644 --- a/src/modules/images/usecase/usecase.ts +++ b/src/modules/images/usecase/usecase.ts @@ -1,18 +1,23 @@ import { readFileSync } from 'fs' import { RequestParams } from '../../../helpers/requestParams' import Logger from '../../../pkg/logger' -import { Store } from '../entity/interface' +import { File, Store } from '../entity/interface' import Repository from '../repository/mongo/repository' import S3 from '../../../external/s3' import { CustomPathFile } from '../../../helpers/file' import { getSlug } from '../../../helpers/slug' -import { RegexExtensionImage } from '../../../helpers/regex' +import { + RegexContentTypeImageNotCompressed, + RegexExtensionImage, +} from '../../../helpers/regex' +import FileGenerator from '../../../external/fileGenerator' class Usecase { constructor( private logger: Logger, private repository: Repository, - private s3: S3 + private s3: S3, + private fileGenerator: FileGenerator ) {} public async Fetch(request: RequestParams) { @@ -20,20 +25,74 @@ class Usecase { } public async Store(body: Store) { + const hasImageCompression = + body.compression && + !RegexContentTypeImageNotCompressed.test(body.file.mimetype) + + body.title = body.file.originalname.replace(RegexExtensionImage, '') + + if (hasImageCompression) + Object.assign( + body.file, + this.getFileImage(body.title, body.file, body.convertTo) + ) + const category = getSlug(body.category) const newPath = CustomPathFile(category, body.file) - body.title = - body.title ?? - body.file.originalname.replace(RegexExtensionImage, '') - body.tags.push(body.title, body.category) const source = readFileSync(body.file.path) await this.s3.Upload(source, newPath, body.file.mimetype) body.file.path = newPath - const result = await this.repository.Store(body) + + const existImage = await this.repository.FindByPath(newPath) + + const result = existImage + ? existImage + : await this.repository.Store(body) + + const { uri, path } = result.file + + if (hasImageCompression) + this.updateMetaImage( + uri, + path, + body.quality, + body.convertTo, + result.id + ) return result } + + private getFileImage(title: string, file: File, convertTo: string) { + const originalname = title + + file.filename = originalname + file.mimetype = `image/${convertTo}` + file.originalname = originalname + '.' + convertTo + + return file + } + + private async updateMetaImage( + uri: string, + path: string, + quality: number, + convertTo: string, + id: string + ) { + try { + const size = await this.fileGenerator.ImageCompression( + uri, + quality, + convertTo, + path + ) + await this.repository.UpdateSize(id, size) + } catch (error: any) { + this.logger.Error(error.message) + } + } } export default Usecase