├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── .markdownlintignore ├── .prettierrc ├── .commitlintrc.json ├── .markdownlint.json ├── test ├── test-files │ └── znaem.png ├── jest-e2e.json └── optimize.e2e-spec.ts ├── tsconfig.build.json ├── src ├── enums │ └── formats.ts ├── utils │ └── enumFromStringValue.ts ├── controllers │ ├── metrics │ │ ├── metrics.controller.ts │ │ └── metrics.controller.spec.ts │ └── optimize-controller │ │ ├── optimize.controller.ts │ │ └── optimize.controller.spec.ts ├── main.ts ├── services │ ├── optimize.service.ts │ ├── allow.service.ts │ ├── json-logger.service.ts │ └── img-loader.service.ts ├── app.module.ts └── middleware │ └── RequestLoggerMiddleware.ts ├── nest-cli.json ├── .editorconfig ├── tsconfig.json ├── Dockerfile ├── eslint.config.mjs ├── LICENSE ├── .github └── workflows │ └── docker-publish.yml ├── README.md ├── package.json ├── CHANGELOG.md ├── cliff.toml ├── CODE_OF_CONDUCT.md └── .gitignore /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | npx lint-staged --quiet -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | npx commitlint --edit $1 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "all", 4 | "tabWidth": 4 5 | } -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD013": { 4 | "line_length": 120 5 | } 6 | } -------------------------------------------------------------------------------- /test/test-files/znaem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileTeleSystems/image-optimize/HEAD/test/test-files/znaem.png -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/enums/formats.ts: -------------------------------------------------------------------------------- 1 | export enum Formats { 2 | Jpeg = "jpeg", 3 | Png = "png", 4 | Webp = "webp", 5 | Avif = "avif", 6 | } 7 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/enumFromStringValue.ts: -------------------------------------------------------------------------------- 1 | export const enumFromStringValue = ( 2 | enm: { [s: string]: T }, 3 | value: string, 4 | ): T | undefined => { 5 | return (Object.values(enm) as unknown as string[]).includes(value) 6 | ? (value as unknown as T) 7 | : undefined; 8 | }; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | charset = utf-8 4 | 5 | # Code files 6 | [*.cs,*.csx,*.js,*.jsx,*.ts,*,tsx,*.css,*.scss] 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | trim_trailing_whitespace = true 11 | max_line_length = 140 12 | quote_type = double 13 | curly_bracket_next_line = true 14 | spaces_around_operators = true 15 | spaces_around_brackets = true 16 | indent_brace_style = Allman 17 | continuation_indent_size = 4 18 | 19 | [*.xml] 20 | indent_style = space 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2023", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/controllers/metrics/metrics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Response, Get } from "@nestjs/common"; 2 | import { Response as EResponse } from "express"; 3 | import { collectDefaultMetrics, Registry } from "prom-client"; 4 | 5 | const register = new Registry(); 6 | collectDefaultMetrics({ 7 | register: register, 8 | eventLoopMonitoringPrecision: 100, 9 | }); 10 | 11 | @Controller("metrics") 12 | export class MetricsController { 13 | @Get() 14 | public async getMetrics(@Response() response: EResponse) { 15 | return response 16 | .set("Content-Type", register.contentType) 17 | .send(await register.metrics()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-alpine AS development 2 | 3 | WORKDIR /app 4 | COPY package*.json tsconfig*.json nest-cli.json eslint.config.mjs ./ 5 | RUN npm ci 6 | COPY ./src ./src 7 | COPY ./test ./test 8 | 9 | RUN npm run test 10 | RUN npm run test:e2e 11 | RUN npm run build 12 | 13 | 14 | FROM node:24-alpine as production 15 | 16 | ARG NODE_ENV=production 17 | ENV NODE_ENV=${NODE_ENV} 18 | 19 | WORKDIR /app 20 | 21 | RUN addgroup -g 1001 -S nodejs 22 | RUN adduser -S nestjs -u 1001 23 | 24 | COPY package*.json ./ 25 | RUN sed -i 's/husky install//g' ./package.json 26 | RUN npm ci --omit=dev 27 | COPY --from=development /app/dist ./dist 28 | 29 | USER nestjs 30 | 31 | CMD ["node", "dist/main"] -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // init variables 2 | process.env.PORT ||= "3000"; 3 | process.env.ALLOW_SIZES ||= "100-1920"; 4 | process.env.ALLOW_SOURCES ||= "*"; 5 | process.env.BASIC_AUTHS ||= void 0; // array of basic auths in format encodeURIComponent("url"):login:password, use comma as separator 6 | process.env.SHARP_CONCURRENCY ||= "0"; // https://sharp.pixelplumbing.com/api-utility#concurrency 7 | 8 | import { NestFactory } from "@nestjs/core"; 9 | import { AppModule } from "./app.module"; 10 | import { JsonLogger } from "./services/json-logger.service"; 11 | 12 | // init app 13 | async function bootstrap() { 14 | const app = await NestFactory.create(AppModule, { 15 | logger: new JsonLogger(), 16 | }); 17 | 18 | await app.listen(process.env.PORT as string, "0.0.0.0"); 19 | } 20 | bootstrap(); 21 | -------------------------------------------------------------------------------- /src/services/optimize.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import * as sharp from "sharp"; // http://sharp.pixelplumbing.com/en/stable/api-constructor/ , https://developers.google.com/speed/webp/docs/cwebp 3 | import { Formats } from "../enums/formats"; 4 | 5 | sharp.concurrency(Number.parseInt(process.env.SHARP_CONCURRENCY as string, 10)); 6 | 7 | @Injectable() 8 | export class OptimizeService { 9 | public async getOptimizedImage( 10 | imgBuffer: Buffer, 11 | width: number, 12 | format: Formats, 13 | quality?: number, 14 | ): Promise { 15 | const img = sharp(imgBuffer); 16 | const { width: sourceWidth } = await img.metadata(); 17 | 18 | // Math.min prevent grow image to biggest width 19 | img.resize({ width: Math.min(sourceWidth, width) }); 20 | 21 | return await img[format]({ quality }).toBuffer(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MetricsController } from "./controllers/metrics/metrics.controller"; 2 | import { Module } from "@nestjs/common"; 3 | import { OptimizeController } from "./controllers/optimize-controller/optimize.controller"; 4 | import { OptimizeService } from "./services/optimize.service"; 5 | import { AllowService } from "./services/allow.service"; 6 | import { NestModule, MiddlewareConsumer } from "@nestjs/common"; 7 | import { RequestLoggerMiddleware } from "./middleware/RequestLoggerMiddleware"; 8 | import { ImgLoaderService } from "./services/img-loader.service"; 9 | 10 | @Module({ 11 | imports: [], 12 | controllers: [OptimizeController, MetricsController], 13 | providers: [OptimizeService, AllowService, ImgLoaderService], 14 | }) 15 | export class AppModule implements NestModule { 16 | configure(consumer: MiddlewareConsumer): void { 17 | consumer.apply(RequestLoggerMiddleware).forRoutes("*"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from '@eslint/js'; 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { 9 | ignores: ['eslint.config.mjs'], 10 | }, 11 | eslint.configs.recommended, 12 | ...tseslint.configs.recommendedTypeChecked, 13 | eslintPluginPrettierRecommended, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.node, 18 | ...globals.jest, 19 | }, 20 | sourceType: 'commonjs', 21 | parserOptions: { 22 | projectService: true, 23 | tsconfigRootDir: import.meta.dirname, 24 | }, 25 | }, 26 | }, 27 | { 28 | rules: { 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | '@typescript-eslint/no-floating-promises': 'warn', 31 | '@typescript-eslint/no-unsafe-argument': 'warn' 32 | }, 33 | }, 34 | ); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 MTS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/services/allow.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | @Injectable() 4 | export class AllowService { 5 | public isAllowedSizes(intSize: number): boolean { 6 | if (!process.env.ALLOW_SIZES || process.env.ALLOW_SIZES === "*") { 7 | return true; 8 | } 9 | 10 | /** 11 | * Variants: 12 | * 300 13 | * 600,900 14 | * 100-1920 15 | */ 16 | const allowSizes = process.env.ALLOW_SIZES.split(","); 17 | 18 | return allowSizes.some((size) => { 19 | if (size.includes("-")) { 20 | const range = size.split("-"); 21 | 22 | const min = Number(range[0].trim()) || Number.MIN_SAFE_INTEGER; 23 | const max = Number(range[1].trim()) || Number.MAX_SAFE_INTEGER; 24 | 25 | return min <= intSize && intSize <= max; 26 | } else { 27 | return intSize === Number(size.trim()); 28 | } 29 | }); 30 | } 31 | 32 | public isAllowedSources(src: string): boolean { 33 | if (!process.env.ALLOW_SOURCES || process.env.ALLOW_SOURCES === "*") { 34 | return true; 35 | } 36 | 37 | const allowSources = process.env.ALLOW_SOURCES.split(",").map( 38 | (souce: string) => decodeURIComponent(souce.trim()), 39 | ); 40 | 41 | return allowSources.some((souce) => src.startsWith(souce)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/middleware/RequestLoggerMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from "@nestjs/common"; 2 | 3 | import { Request, Response, NextFunction } from "express"; 4 | import { JsonLogger, LogLevels } from "../services/json-logger.service"; 5 | 6 | @Injectable() 7 | export class RequestLoggerMiddleware implements NestMiddleware { 8 | private logger = new JsonLogger(); 9 | 10 | use(request: Request, response: Response, next: NextFunction): void { 11 | const { ip, method, originalUrl, query } = request; 12 | const userAgent = request.get("user-agent") || "not set"; 13 | const startTime = performance.now(); 14 | const traceId = request.get("x-trace-id") || void 0; 15 | const realIp = request.get("x-real-ip") || void 0; 16 | 17 | response.on("close", () => { 18 | const { statusCode } = response; 19 | const contentLength = response.get("content-length"); 20 | 21 | let level = LogLevels.WARN; // for statuses 100, 300, 400, 600 22 | if (200 <= statusCode && statusCode < 300) { 23 | level = LogLevels.INFO; 24 | } else if (500 <= statusCode && statusCode < 600) { 25 | level = LogLevels.FATAL; 26 | } 27 | 28 | this.logger.extraLogs("Request", level, { 29 | method: method, 30 | url: originalUrl, 31 | query: query, 32 | statusCode: statusCode, 33 | contentLength: contentLength, 34 | userAgent: userAgent, 35 | userIp: realIp || ip, 36 | processTime: performance.now() - startTime, 37 | traceId: traceId, 38 | }); 39 | }); 40 | 41 | next(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/services/json-logger.service.ts: -------------------------------------------------------------------------------- 1 | import { LoggerService } from "@nestjs/common"; 2 | import { hostname } from "os"; 3 | 4 | export enum LogLevels { 5 | FATAL = 60, 6 | ERROR = 50, 7 | WARN = 40, 8 | INFO = 30, 9 | DEBUG = 20, 10 | TRACE = 10, 11 | } 12 | 13 | export class JsonLogger implements LoggerService { 14 | protected hostname: string = hostname(); 15 | 16 | /** 17 | * Write a 'log' level log. 18 | */ 19 | public log(message: any) { 20 | this.writeJson(message, LogLevels.INFO); 21 | } 22 | 23 | /** 24 | * Write an 'error' level log. 25 | */ 26 | public error(message: any) { 27 | this.writeJson(message, LogLevels.ERROR); 28 | } 29 | 30 | /** 31 | * Write a 'warn' level log. 32 | */ 33 | public warn(message: any) { 34 | this.writeJson(message, LogLevels.WARN); 35 | } 36 | 37 | /** 38 | * Write a 'debug' level log. 39 | */ 40 | public debug(message: any) { 41 | this.writeJson(message, LogLevels.DEBUG); 42 | } 43 | 44 | /** 45 | * Write a 'verbose' level log. 46 | */ 47 | public verbose(message: any) { 48 | this.writeJson(message, LogLevels.TRACE); 49 | } 50 | 51 | public extraLogs( 52 | message: any, 53 | level: number, 54 | extraProps: object = {}, 55 | ): void { 56 | this.writeJson(message, level, extraProps); 57 | } 58 | 59 | protected writeJson( 60 | message: any, 61 | level: number, 62 | extraProps: object = {}, 63 | ): void { 64 | console.log( 65 | JSON.stringify({ 66 | message: String(message), 67 | ...extraProps, 68 | time: Date.now(), 69 | level: level, 70 | hostname: this.hostname, 71 | service: "image-optimize", 72 | }), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ main ] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | with: 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | fetch-depth: 0 27 | 28 | - name: Log into registry docker.io 29 | uses: docker/login-action@v3 30 | with: 31 | registry: docker.io 32 | username: ${{ secrets.DOCKERHUB_USER }} 33 | password: ${{ secrets.DOCKERHUB_TOKEN }} 34 | 35 | - name: Build and publish package 36 | uses: actions/setup-node@v3 37 | with: 38 | node-version: 24 39 | registry-url: https://registry.npmjs.org/ 40 | 41 | - run: git config --global user.email "elabutin@mts.ru" 42 | - run: git config --global user.name "Eugene Labutin" 43 | - run: npm ci 44 | - run: npm run release 45 | - run: git push && git push --tags 46 | 47 | - name: Get version from package.json 48 | run: | 49 | VERSION=$(node -p "require('./package.json').version") 50 | echo "VERSION=$VERSION" >> $GITHUB_ENV 51 | echo "MAJOR=$(echo $VERSION | cut -d. -f1)" >> $GITHUB_ENV 52 | echo "MINOR=$(echo $VERSION | cut -d. -f2)" >> $GITHUB_ENV 53 | echo "PATCH=$(echo $VERSION | cut -d. -f3)" >> $GITHUB_ENV 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@v3 57 | with: 58 | push: true 59 | tags: | 60 | mtsrus/image-optimize:latest 61 | mtsrus/image-optimize:${{ env.MAJOR }} 62 | mtsrus/image-optimize:${{ env.MAJOR }}.${{ env.MINOR }} 63 | mtsrus/image-optimize:${{ env.MAJOR }}.${{ env.MINOR }}.${{ env.PATCH }} 64 | -------------------------------------------------------------------------------- /src/services/img-loader.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | } from "@nestjs/common"; 6 | 7 | @Injectable() 8 | export class ImgLoaderService { 9 | protected basicAuths: [string, string, string][] = []; // Url, login, password 10 | 11 | constructor() { 12 | this.extractBasicAuth(); 13 | } 14 | 15 | public async getImage(src: string): Promise { 16 | const fetchResponse = await fetch(src, { 17 | headers: this.prepareHeaders(src), 18 | }); 19 | 20 | if (fetchResponse.ok) { 21 | const arrayBuffer = await fetchResponse.arrayBuffer(); 22 | return Buffer.from(arrayBuffer); 23 | } 24 | 25 | if (fetchResponse.status === 404) { 26 | throw new NotFoundException( 27 | `Error on fetch image by src: ${src}`, 28 | `${fetchResponse.status} - ${fetchResponse.statusText}`, 29 | ); 30 | } 31 | 32 | throw new BadRequestException( 33 | `Error on fetch image by src: ${src}`, 34 | `${fetchResponse.status} - ${fetchResponse.statusText}`, 35 | ); 36 | } 37 | 38 | protected prepareHeaders(src: string): Headers { 39 | const headers = new Headers(); 40 | 41 | for (const auth of this.basicAuths) { 42 | if (src.startsWith(auth[0])) { 43 | const basic = Buffer.from( 44 | `${auth[1]}:${auth[2]}`, 45 | "utf8", 46 | ).toString("base64"); 47 | 48 | headers.set("Authorization", "Basic " + basic); 49 | break; 50 | } 51 | } 52 | 53 | return headers; 54 | } 55 | 56 | protected extractBasicAuth(): void { 57 | if (process.env.BASIC_AUTHS) { 58 | this.basicAuths = process.env.BASIC_AUTHS.split(",").map( 59 | (auth: string) => { 60 | const parts = auth.trim().split(":"); 61 | 62 | return [ 63 | decodeURIComponent(parts[0]), 64 | decodeURIComponent(parts[1]), 65 | decodeURIComponent(parts[2]), 66 | ]; 67 | }, 68 | ); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/controllers/metrics/metrics.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { MetricsController } from "./metrics.controller"; 3 | import { Response } from "express"; 4 | 5 | describe("MetricsController", () => { 6 | let appController: MetricsController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [MetricsController], 11 | providers: [], 12 | }).compile(); 13 | 14 | appController = app.get(MetricsController); 15 | }); 16 | 17 | describe("getMetrics", () => { 18 | it("should return metrics with correct content type and response", async () => { 19 | const mockSet = jest.fn().mockReturnThis(); 20 | const mockSend = jest.fn().mockReturnThis(); 21 | const mockResponse = { 22 | set: mockSet, 23 | send: mockSend, 24 | } as unknown as Response; 25 | 26 | const result = await appController.getMetrics(mockResponse); 27 | 28 | // Check that response methods are called correctly 29 | expect(mockSet).toHaveBeenCalledTimes(1); 30 | expect(mockSet).toHaveBeenCalledWith( 31 | "Content-Type", 32 | expect.stringContaining("text/plain"), 33 | ); 34 | 35 | expect(mockSend).toHaveBeenCalledTimes(1); 36 | 37 | // Check that result contains Prometheus metrics 38 | expect(mockSend).toHaveBeenCalledWith( 39 | expect.stringContaining("# HELP"), 40 | ); 41 | expect(mockSend).toHaveBeenCalledWith( 42 | expect.stringContaining("# TYPE"), 43 | ); 44 | 45 | // Check that metrics contain CPU and RAM consumption values 46 | expect(mockSend).toHaveBeenCalledWith( 47 | expect.stringContaining("process_cpu_user_seconds_total"), 48 | ); 49 | expect(mockSend).toHaveBeenCalledWith( 50 | expect.stringContaining("process_cpu_system_seconds_total"), 51 | ); 52 | expect(mockSend).toHaveBeenCalledWith( 53 | expect.stringContaining("process_resident_memory_bytes"), 54 | ); 55 | expect(mockSend).toHaveBeenCalledWith( 56 | expect.stringContaining("nodejs_heap_size_total_bytes"), 57 | ); 58 | expect(mockSend).toHaveBeenCalledWith( 59 | expect.stringContaining("nodejs_heap_size_used_bytes"), 60 | ); 61 | 62 | // Check method result 63 | expect(result).toBe(mockResponse); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/optimize.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { INestApplication } from "@nestjs/common"; 3 | import * as request from "supertest"; 4 | import { AppModule } from "../src/app.module"; 5 | import { default as sFastify } from "fastify"; 6 | import * as fs from "fs"; 7 | import * as path from "path"; 8 | 9 | describe("OptimizeController (e2e)", () => { 10 | let app: INestApplication; 11 | const fastify = sFastify(); 12 | 13 | beforeAll(async () => { 14 | const imagePath = path.join(__dirname, "test-files", "znaem.png"); 15 | const imageBuffer = fs.readFileSync(imagePath); 16 | 17 | fastify.get("/image", (_req, reply) => 18 | reply 19 | .code(200) 20 | .header("Content-Type", "image/png") 21 | .send(imageBuffer), 22 | ); 23 | 24 | await fastify.listen({ port: 3001 }); 25 | await fastify.ready(); 26 | }); 27 | 28 | afterAll(async () => { 29 | await fastify.close(); 30 | }); 31 | 32 | beforeEach(async () => { 33 | const moduleFixture: TestingModule = await Test.createTestingModule({ 34 | imports: [AppModule], 35 | }).compile(); 36 | 37 | app = moduleFixture.createNestApplication(); 38 | await app.init(); 39 | }); 40 | 41 | it("should return resized and optimized jpeg", () => { 42 | const search = new URLSearchParams(); 43 | search.set("src", "http://localhost:3001/image"); 44 | search.set("size", "512"); 45 | search.set("format", "jpeg"); 46 | 47 | return request(app.getHttpServer()) 48 | .get(`/optimize?${search.toString()}`) 49 | .expect("Content-Type", "image/jpeg") 50 | .expect("Content-Length", "17481") 51 | .expect(200); 52 | }); 53 | 54 | it("should return resized and optimized png", () => { 55 | const search = new URLSearchParams(); 56 | search.set("src", "http://localhost:3001/image"); 57 | search.set("size", "512"); 58 | search.set("format", "png"); 59 | 60 | return request(app.getHttpServer()) 61 | .get(`/optimize?${search.toString()}`) 62 | .expect("Content-Type", "image/png") 63 | .expect("Content-Length", "203623") 64 | .expect(200); 65 | }); 66 | 67 | it("should return resized and optimized webp", () => { 68 | const search = new URLSearchParams(); 69 | search.set("src", "http://localhost:3001/image"); 70 | search.set("size", "512"); 71 | search.set("format", "webp"); 72 | 73 | return request(app.getHttpServer()) 74 | .get(`/optimize?${search.toString()}`) 75 | .expect("Content-Type", "image/webp") 76 | .expect("Content-Length", "12052") 77 | .expect(200); 78 | }); 79 | 80 | it("should return resized and optimized avif", () => { 81 | const search = new URLSearchParams(); 82 | search.set("src", "http://localhost:3001/image"); 83 | search.set("size", "512"); 84 | search.set("format", "avif"); 85 | 86 | return request(app.getHttpServer()) 87 | .get(`/optimize?${search.toString()}`) 88 | .expect("Content-Type", "image/avif") 89 | .expect("Content-Length", "6985") 90 | .expect(200); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microservice for responsive resize, compression and optimization of images on the fly for web pages 2 | 3 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/042786e7f0304d1ea29d83f8c1522a55)](https://www.codacy.com/gh/MobileTeleSystems/image-optimize/dashboard?utm_source=github.com&utm_medium=referral&utm_content=MobileTeleSystems/image-optimize&utm_campaign=Badge_Grade) 4 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) 5 | [![GitHub license](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/MobileTeleSystems/image-optimize/blob/main/LICENSE) 6 | 7 | Optimizing images helps reduce image weight and increases website loading speed, 8 | which is very important for both users and search engines. For these purposes, 9 | we have created a microservice that perfectly copes with this task. 10 | 11 | Features: 12 | - Resize images for the user's screen size, 13 | - Image compression to reduce traffic, 14 | - Converting images to modern formats such as webp and avif, 15 | - Works with dynamic content, compression occurs on the fly, 16 | - High compression speed, an average picture is processed in just 200 ms, 17 | - Includes exporter of metrics for Prometheus, 18 | - Supports basic authorization for multiple domains and endpoints, 19 | - Supports security restrictions for allowed addresses. 20 | 21 | ## Try 22 | 23 | To try the microservice features, run the container with the command: 24 | 25 | ```sh 26 | docker run -it --rm -p 3000:3000 mtsrus/image-optimize 27 | ``` 28 | 29 | Now you can open the browser and check the work with the command: 30 | 31 | ```sh 32 | http://localhost:3000/optimize?size=1060&format=webp&src=https://mtscdn.ru/upload/iblock/75d/cmn5ki0o5dyk5laamf0idch2n77qf8gd.png 33 | ``` 34 | 35 | By changing the src, size, format parameters, 36 | you can choose the path to the image, 37 | the final size and the image format. 38 | 39 | ## Use 40 | 41 | To start the microservice in production, use the command: 42 | 43 | ```sh 44 | docker run -d --restart always -p 3000:3000 mtsrus/image-optimize 45 | ``` 46 | 47 | ## Container parameters 48 | 49 | - `-e PORT=3000` - the port on which the microservice will be launched, default 3000. 50 | - `-e ALLOW_SIZES="100,200,1024-1920"` - an array of allowed sizes for the resulting images, 51 | default 100-1920. Use specific values to prevent heavy loads on the server. 52 | 53 | - `-e ALLOW_SOURCES="https%3A%2F%2Ftb.mts.ru%2F"` - URL array of allowed addresses for image sources, default * (any). 54 | Use comma as separator. It is recommended to apply encodeURIComponent to url. 55 | 56 | - `-e BASIC_AUTHS="https%3A%2F%2Ftb.mts.ru%2F"` - an array of endpoints with basic authorization parameters, default empty. 57 | Has format encodeURIComponent("url"):encodeURIComponent("login"):encodeURIComponent("password"). Use comma as separator. 58 | 59 | - `-e SHARP_CONCURRENCY=0` - number of threads libvips' should create to process each image, 60 | default 0 (will reset to the number of CPU cores). 61 | 62 | ## Components for web 63 | 64 | To optimize images in the browser, there is a component for React. You can find it 65 | [by following the link](https://github.com/MobileTeleSystems/image-optimize-react). 66 | The component itself determines the most suitable image parameters and requests it from this microservice. 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-optimize", 3 | "version": "1.6.4", 4 | "description": "", 5 | "author": "MobileTeleSystems", 6 | "homepage": "https://github.com/MobileTeleSystems/image-optimize", 7 | "bugs": "https://github.com/MobileTeleSystems/image-optimize/issues", 8 | "private": true, 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/MobileTeleSystems/image-optimize.git" 13 | }, 14 | "scripts": { 15 | "prebuild": "rimraf dist", 16 | "build": "nest build", 17 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 18 | "start": "nest start", 19 | "start:dev": "nest start --watch", 20 | "start:debug": "nest start --debug --watch", 21 | "start:prod": "node dist/main", 22 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 23 | "lint:md": "markdownlint --fix **/*.md --ignore node_modules", 24 | "test": "jest", 25 | "test:watch": "jest --watch", 26 | "test:cov": "jest --coverage", 27 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 28 | "test:e2e": "jest --config ./test/jest-e2e.json", 29 | "release": "cliff-jumper --name 'image-optimize' --package-path '.' --no-skip-changelog --no-skip-tag", 30 | "prepare": "husky" 31 | }, 32 | "lint-staged": { 33 | "./(src|test)/**/*.(ts|tsx|js|jsx)": [ 34 | "eslint --fix -c eslint.config.mjs --ext .tsx,.ts,.jsx,.js" 35 | ], 36 | "*.md": "markdownlint --fix" 37 | }, 38 | "dependencies": { 39 | "@nestjs/common": "^11.1.9", 40 | "@nestjs/core": "^11.1.9", 41 | "@nestjs/platform-express": "^11.1.9", 42 | "prom-client": "^15.1.3", 43 | "reflect-metadata": "^0.2.2", 44 | "rimraf": "^6.1.2", 45 | "rxjs": "^7.8.2", 46 | "sharp": "^0.34.5" 47 | }, 48 | "devDependencies": { 49 | "@commitlint/cli": "^20.1.0", 50 | "@commitlint/config-conventional": "^20.0.0", 51 | "@favware/cliff-jumper": "^6.0.0", 52 | "@eslint/eslintrc": "^3.3.1", 53 | "@eslint/js": "^9.39.1", 54 | "@nestjs/cli": "^11.0.12", 55 | "@nestjs/schematics": "^11.0.9", 56 | "@nestjs/testing": "^11.1.9", 57 | "@swc/cli": "^0.7.9", 58 | "@swc/core": "^1.15.3", 59 | "fastify": "^5.6.2", 60 | "@types/express": "^5.0.5", 61 | "@types/jest": "^30.0.0", 62 | "@types/node": "^24.10.1", 63 | "@types/node-fetch": "^3.0.2", 64 | "@types/sharp": "^0.32.0", 65 | "@types/supertest": "^6.0.3", 66 | "eslint": "^9.39.1", 67 | "eslint-config-prettier": "^10.1.8", 68 | "eslint-plugin-prettier": "^5.5.4", 69 | "globals": "^16.5.0", 70 | "husky": "^9.1.7", 71 | "jest": "^30.2.0", 72 | "lint-staged": "^16.2.7", 73 | "markdownlint": "^0.39.0", 74 | "markdownlint-cli": "^0.46.0", 75 | "prettier": "^3.6.2", 76 | "source-map-support": "^0.5.21", 77 | "supertest": "^7.1.4", 78 | "ts-jest": "^29.4.5", 79 | "ts-loader": "^9.5.4", 80 | "ts-node": "^10.9.2", 81 | "tsconfig-paths": "^4.2.0", 82 | "typescript": "^5.9.3", 83 | "typescript-eslint": "^8.47.0" 84 | }, 85 | "jest": { 86 | "moduleFileExtensions": [ 87 | "js", 88 | "json", 89 | "ts" 90 | ], 91 | "rootDir": "src", 92 | "testRegex": ".*\\.spec\\.ts$", 93 | "transform": { 94 | "^.+\\.(t|j)s$": "ts-jest" 95 | }, 96 | "collectCoverageFrom": [ 97 | "**/*.(t|j)s" 98 | ], 99 | "coverageDirectory": "../coverage", 100 | "testEnvironment": "node" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.6.4] - 2025-11-21 6 | 7 | ### ⚙️ Miscellaneous Tasks 8 | 9 | - Update dependencies and devDependencies to latest versions 10 | 11 | # Changelog 12 | 13 | All notable changes to this project will be documented in this file. 14 | 15 | ## [1.6.3] - 2025-07-20 16 | 17 | ### ⚙️ Miscellaneous Tasks 18 | 19 | - Update Docker image tags to use dynamic versioning from package.json 20 | 21 | ## [1.6.2] - 2025-07-20 22 | 23 | ### ⚙️ Miscellaneous Tasks 24 | 25 | - Update Docker image tags to version 1.6.1 26 | 27 | ## [1.6.1] - 2025-07-19 28 | 29 | ### ⚙️ Miscellaneous Tasks 30 | 31 | - Update release script name to 'image-optimize' in package.json 32 | 33 | ## [1.6.0] - 2025-07-19 34 | 35 | ### 🚀 Features 36 | 37 | - Throw error 400 on image download error 38 | - Update packages versions 39 | - Update sharp library version 40 | - Willsoto/nestjs-prometheus by prom-client, use originalUrl for logs 41 | - Update dependencies versions 42 | - Update dependecies version 43 | - Add Fastify server for testing image optimization 44 | 45 | ### 🐛 Bug Fixes 46 | 47 | - Throw error 404 if image src return 404 48 | - Update ESLint configuration file name in Dockerfile and README example URL 49 | - Correct typos and improve clarity in README.md 50 | 51 | ### 🧪 Testing 52 | 53 | - Fix metrics test 54 | - Update e2e controll summ 55 | - Enhance OptimizeController tests with comprehensive validation scenarios 56 | 57 | ### ⚙️ Miscellaneous Tasks 58 | 59 | - Update github action versions 60 | - Add husky, minor code style fixes 61 | - *(release)* 1.4.0 62 | - Update docker image version 63 | - Remove husky from production container 64 | - Add badges to readme 65 | - Update code style for md files 66 | - Make correct license 67 | - Update markdown styles 68 | - Fix badges reositories 69 | - Add markdown lint 70 | - Add badge for sharp js library 71 | - Update code style for readme 72 | - Clean package-lock 73 | - Update npm link url 74 | - Remove npm badge 75 | - Code style for code of conduct 76 | - *(release)* 1.5.0 77 | - Update dependencies and devDependencies to latest versions 78 | - Remove pull request trigger from Docker publish workflow 79 | - Update Docker workflow and add git-cliff configuration for changelog management 80 | - Update package-lock.json 81 | - Add permissions section for contents write access in Docker workflow 82 | - Update permissions in Docker workflow to allow write access for contents 83 | 84 | ## [1.5.0](https://github.com/MobileTeleSystems/image-optimize/compare/v1.4.0...v1.5.0) (2023-10-02) 85 | 86 | 87 | ### Features 88 | 89 | * update dependencies versions ([aee780c](https://github.com/MobileTeleSystems/image-optimize/commit/aee780c48a203ebba767bb33ed78aa0e63516199)) 90 | * update sharp library version ([2ff333d](https://github.com/MobileTeleSystems/image-optimize/commit/2ff333d2b54bbe2c0366a5d9569c4cd4e0044a57)) 91 | * willsoto/nestjs-prometheus by prom-client, use originalUrl for logs ([f4aca04](https://github.com/MobileTeleSystems/image-optimize/commit/f4aca0411b92bbb7df01d07cdc858ed0e422ffb8)) 92 | 93 | ## 1.4.0 (2022-12-30) 94 | 95 | ### Features 96 | 97 | * throw error 400 on image download error ([f9af9c0](https://github.com/MobileTeleSystems/image-optimize/commit/f9af9c04879573dbbf1301e1323ddc7d2059ddd8)) 98 | * update packages versions ([11e314e](https://github.com/MobileTeleSystems/image-optimize/commit/11e314ec7a68b3981a37399b621cabac26333a90)) 99 | 100 | ### Bug Fixes 101 | 102 | * throw error 404 if image src return 404 ([dc4bf2a](https://github.com/MobileTeleSystems/image-optimize/commit/dc4bf2aea308f88a18b8427004ff0281e55ce0c5)) 103 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = """ 11 | # Changelog\n 12 | All notable changes to this project will be documented in this file.\n 13 | """ 14 | # template for the changelog body 15 | # https://keats.github.io/tera/docs/#introduction 16 | body = """ 17 | {% if version %}\ 18 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 19 | {% else %}\ 20 | ## [unreleased] 21 | {% endif %}\ 22 | {% for group, commits in commits | group_by(attribute="group") %} 23 | ### {{ group | striptags | trim | upper_first }} 24 | {% for commit in commits %} 25 | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 26 | {% if commit.breaking %}[**breaking**] {% endif %}\ 27 | {{ commit.message | upper_first }}\ 28 | {% endfor %} 29 | {% endfor %}\n 30 | """ 31 | # template for the changelog footer 32 | footer = """ 33 | 34 | """ 35 | # remove the leading and trailing s 36 | trim = true 37 | # postprocessors 38 | postprocessors = [ 39 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 40 | ] 41 | 42 | [git] 43 | # parse the commits based on https://www.conventionalcommits.org 44 | conventional_commits = true 45 | # filter out the commits that are not conventional 46 | filter_unconventional = true 47 | # process each line of a commit as an individual commit 48 | split_commits = false 49 | # regex for preprocessing the commit messages 50 | commit_preprocessors = [ 51 | # Replace issue numbers 52 | #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, 53 | # Check spelling of the commit with https://github.com/crate-ci/typos 54 | # If the spelling is incorrect, it will be automatically fixed. 55 | #{ pattern = '.*', replace_command = 'typos --write-changes -' }, 56 | ] 57 | # regex for parsing and grouping commits 58 | commit_parsers = [ 59 | { message = "^feat", group = "🚀 Features" }, 60 | { message = "^fix", group = "🐛 Bug Fixes" }, 61 | { message = "^doc", group = "📚 Documentation" }, 62 | { message = "^perf", group = "⚡ Performance" }, 63 | { message = "^refactor", group = "🚜 Refactor" }, 64 | { message = "^style", group = "🎨 Styling" }, 65 | { message = "^test", group = "🧪 Testing" }, 66 | { message = "^chore\\(release\\): prepare for", skip = true }, 67 | { message = "^chore\\(deps.*\\)", skip = true }, 68 | { message = "^chore\\(pr\\)", skip = true }, 69 | { message = "^chore\\(pull\\)", skip = true }, 70 | { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, 71 | { body = ".*security", group = "🛡️ Security" }, 72 | { message = "^revert", group = "◀️ Revert" }, 73 | ] 74 | # protect breaking changes from being skipped due to matching a skipping commit_parser 75 | protect_breaking_commits = false 76 | # filter out the commits that are not matched by commit parsers 77 | filter_commits = false 78 | # regex for matching git tags 79 | # tag_pattern = "v[0-9].*" 80 | # regex for skipping tags 81 | # skip_tags = "" 82 | # regex for ignoring tags 83 | # ignore_tags = "" 84 | # sort the tags topologically 85 | topo_order = false 86 | # sort the commits inside sections by oldest/newest order 87 | sort_commits = "oldest" 88 | # limit the number of commits included in the changelog. 89 | # limit_commits = 42 90 | -------------------------------------------------------------------------------- /src/controllers/optimize-controller/optimize.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpStatus, Query, Res } from "@nestjs/common"; 2 | import { BadRequestException } from "@nestjs/common"; 3 | import { OptimizeService } from "../../services/optimize.service"; 4 | import { AllowService } from "../../services/allow.service"; 5 | import { Formats } from "../../enums/formats"; 6 | import { enumFromStringValue } from "../../utils/enumFromStringValue"; 7 | import { Response } from "express"; 8 | import { ImgLoaderService } from "../../services/img-loader.service"; 9 | 10 | @Controller("optimize") 11 | export class OptimizeController { 12 | constructor( 13 | private readonly optimizeService: OptimizeService, 14 | private readonly imgLoaderService: ImgLoaderService, 15 | private readonly allowService: AllowService, 16 | ) {} 17 | 18 | /** 19 | * Sample: 20 | * http://localhost:3000/optimize?src=https%3A%2F%2Ftb.mts.ru%2Fstatic%2Flanding%2Fimages-index2%2Fbanner%2Fslider%2Fznaem2.png&size=1920&quality=80&format=avif 21 | * https://tb.mts.ru/optimizer/optimize?src=https%3A%2F%2Ftb.mts.ru%2Fstatic%2Flanding%2Fimages-index2%2Fbanner%2Fslider%2Fznaem2.png&size=1920&quality=80&format=avif 22 | * 23 | * @param {string} src - Url to image for optimization; 24 | * @param {string} size - Size of result image; 25 | * @param {string} format - Jpeg, png, webp, avif; 26 | * @param response 27 | */ 28 | @Get("") 29 | public async getPreview( 30 | @Query("src") src: string, 31 | @Query("size") size: string, 32 | @Query("format") format: string, 33 | @Query("quality") quality: string | void, 34 | @Res() response: Response, 35 | ): Promise { 36 | if (!src) { 37 | throw new BadRequestException("Parameter 'src' is required."); 38 | } 39 | 40 | if (!size) { 41 | throw new BadRequestException("Parameter 'size' is required."); 42 | } 43 | 44 | const intSize = Number.parseInt(size); 45 | if (intSize <= 0) { 46 | throw new BadRequestException( 47 | "Parameter 'size' should be a positive integer.", 48 | ); 49 | } 50 | 51 | if (!format) { 52 | throw new BadRequestException("Parameter 'format' is required."); 53 | } 54 | 55 | const enumFormat = enumFromStringValue(Formats, format); 56 | if (enumFormat === void 0) { 57 | throw new BadRequestException( 58 | "Parameter 'format' is not supported.", 59 | ); 60 | } 61 | 62 | const intQuality = quality ? Number.parseInt(quality) : void 0; 63 | if (intQuality !== void 0) { 64 | if (isNaN(intQuality)) { 65 | throw new BadRequestException( 66 | "Parameter 'quality' is not a number.", 67 | ); 68 | } 69 | 70 | if (!(1 <= intQuality && intQuality <= 100)) { 71 | throw new BadRequestException( 72 | "Parameter 'quality' must be in range 1-100.", 73 | ); 74 | } 75 | } 76 | 77 | const decodedSrc = decodeURIComponent(src); 78 | if (!this.allowService.isAllowedSources(decodedSrc)) { 79 | throw new BadRequestException( 80 | "Parameter 'src' has an not allowed value.", 81 | ); 82 | } 83 | 84 | if (!this.allowService.isAllowedSizes(intSize)) { 85 | throw new BadRequestException( 86 | "Parameter 'size' has an not allowed value.", 87 | ); 88 | } 89 | 90 | const imgBuffer = await this.imgLoaderService.getImage(decodedSrc); 91 | 92 | const result = await this.optimizeService.getOptimizedImage( 93 | imgBuffer, 94 | intSize, 95 | enumFormat, 96 | intQuality, 97 | ); 98 | 99 | response 100 | .setHeader("Content-Type", `image/${enumFormat}`) 101 | .status(HttpStatus.OK) 102 | .send(result); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 24 | * Focusing on what is best not just for us as individuals, but for the overall community 25 | 26 | Examples of unacceptable behavior include: 27 | 28 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 29 | * Trolling, insulting or derogatory comments, and personal or political attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or email address, without their explicit permission 32 | * Other conduct which could reasonably be considered inappropriate in a professional setting 33 | 34 | ## Enforcement Responsibilities 35 | 36 | Community leaders are responsible for clarifying and enforcing our standards of 37 | acceptable behavior and will take appropriate and fair corrective action in 38 | response to any behavior that they deem inappropriate, threatening, offensive, 39 | or harmful. 40 | 41 | Community leaders have the right and responsibility to remove, edit, or reject 42 | comments, commits, code, wiki edits, issues, and other contributions that are 43 | not aligned to this Code of Conduct, and will communicate reasons for moderation 44 | decisions when appropriate. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all community spaces, and also applies when 49 | an individual is officially representing the community in public spaces. 50 | Examples of representing our community include using an official e-mail address, 51 | posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. 53 | 54 | ## Enforcement 55 | 56 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 57 | reported to the community leaders responsible for enforcement at 58 | . 59 | All complaints will be reviewed and investigated promptly and fairly. 60 | 61 | All community leaders are obligated to respect the privacy and security of the 62 | reporter of any incident. 63 | 64 | ## Enforcement Guidelines 65 | 66 | Community leaders will follow these Community Impact Guidelines in determining 67 | the consequences for any action they deem in violation of this Code of Conduct: 68 | 69 | ### 1. Correction 70 | 71 | **Community Impact**: Use of inappropriate language or other behavior deemed 72 | unprofessional or unwelcome in the community. 73 | 74 | **Consequence**: A private, written warning from community leaders, providing 75 | clarity around the nature of the violation and an explanation of why the 76 | behavior was inappropriate. A public apology may be requested. 77 | 78 | ### 2. Warning 79 | 80 | **Community Impact**: A violation through a single incident or series of 81 | actions. 82 | 83 | **Consequence**: A warning with consequences for continued behavior. No 84 | interaction with the people involved, including unsolicited interaction with 85 | those enforcing the Code of Conduct, for a specified period of time. This 86 | includes avoiding interactions in community spaces as well as external channels 87 | like social media. Violating these terms may lead to a temporary or permanent 88 | ban. 89 | 90 | ### 3. Temporary Ban 91 | 92 | **Community Impact**: A serious violation of community standards, including 93 | sustained inappropriate behavior. 94 | 95 | **Consequence**: A temporary ban from any sort of interaction or public 96 | communication with the community for a specified period of time. No public or 97 | private interaction with the people involved, including unsolicited interaction 98 | with those enforcing the Code of Conduct, is allowed during this period. 99 | Violating these terms may lead to a permanent ban. 100 | 101 | ### 4. Permanent Ban 102 | 103 | **Community Impact**: Demonstrating a pattern of violation of community 104 | standards, including sustained inappropriate behavior, harassment of an 105 | individual, or aggression toward or disparagement of classes of individuals. 106 | 107 | **Consequence**: A permanent ban from any sort of public interaction within the 108 | community. 109 | 110 | ## Attribution 111 | 112 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 113 | version 2.1, available at 114 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 115 | 116 | Community Impact Guidelines were inspired by 117 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 118 | 119 | For answers to common questions about this code of conduct, see the FAQ at 120 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 121 | [https://www.contributor-covenant.org/translations][translations]. 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 125 | [Mozilla CoC]: https://github.com/mozilla/diversity 126 | [FAQ]: https://www.contributor-covenant.org/faq 127 | [translations]: https://www.contributor-covenant.org/translations 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/dictionaries 10 | 11 | # Sensitive or high-churn files: 12 | .idea/**/dataSources/ 13 | .idea/**/dataSources.ids 14 | .idea/**/dataSources.xml 15 | .idea/**/dataSources.local.xml 16 | .idea/**/sqlDataSources.xml 17 | .idea/**/dynamic.xml 18 | .idea/**/uiDesigner.xml 19 | 20 | # Gradle: 21 | .idea/**/gradle.xml 22 | .idea/**/libraries 23 | 24 | # CMake 25 | cmake-build-debug/ 26 | 27 | # Mongo Explorer plugin: 28 | .idea/**/mongoSettings.xml 29 | 30 | ## File-based project format: 31 | *.iws 32 | 33 | ## Plugin-specific files: 34 | 35 | # IntelliJ 36 | out/ 37 | 38 | # mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Cursive Clojure plugin 45 | .idea/replstate.xml 46 | 47 | # Crashlytics plugin (for Android Studio and IntelliJ) 48 | com_crashlytics_export_strings.xml 49 | crashlytics.properties 50 | crashlytics-build.properties 51 | fabric.properties 52 | ### VisualStudio template 53 | ## Ignore Visual Studio temporary files, build results, and 54 | ## files generated by popular Visual Studio add-ons. 55 | ## 56 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 57 | 58 | # User-specific files 59 | *.suo 60 | *.user 61 | *.userosscache 62 | *.sln.docstates 63 | 64 | # User-specific files (MonoDevelop/Xamarin Studio) 65 | *.userprefs 66 | 67 | # Build results 68 | [Dd]ebug/ 69 | [Dd]ebugPublic/ 70 | [Rr]elease/ 71 | [Rr]eleases/ 72 | x64/ 73 | x86/ 74 | bld/ 75 | [Bb]in/ 76 | [Oo]bj/ 77 | [Ll]og/ 78 | 79 | # Visual Studio 2015 cache/options directory 80 | .vs/ 81 | # Uncomment if you have tasks that create the project's static files in wwwroot 82 | #wwwroot/ 83 | 84 | # MSTest test Results 85 | [Tt]est[Rr]esult*/ 86 | [Bb]uild[Ll]og.* 87 | 88 | # NUNIT 89 | *.VisualState.xml 90 | TestResult.xml 91 | 92 | # Build Results of an ATL Project 93 | [Dd]ebugPS/ 94 | [Rr]eleasePS/ 95 | dlldata.c 96 | 97 | # Benchmark Results 98 | BenchmarkDotNet.Artifacts/ 99 | 100 | # .NET Core 101 | project.lock.json 102 | project.fragment.lock.json 103 | artifacts/ 104 | **/Properties/launchSettings.json 105 | 106 | *_i.c 107 | *_p.c 108 | *_i.h 109 | *.ilk 110 | *.meta 111 | *.obj 112 | *.pch 113 | *.pdb 114 | *.pgc 115 | *.pgd 116 | *.rsp 117 | *.sbr 118 | *.tlb 119 | *.tli 120 | *.tlh 121 | *.tmp 122 | *.tmp_proj 123 | *.log 124 | *.vspscc 125 | *.vssscc 126 | .builds 127 | *.pidb 128 | *.svclog 129 | *.scc 130 | 131 | # Chutzpah Test files 132 | _Chutzpah* 133 | 134 | # Visual C++ cache files 135 | ipch/ 136 | *.aps 137 | *.ncb 138 | *.opendb 139 | *.opensdf 140 | *.sdf 141 | *.cachefile 142 | *.VC.db 143 | *.VC.VC.opendb 144 | 145 | # Visual Studio profiler 146 | *.psess 147 | *.vsp 148 | *.vspx 149 | *.sap 150 | 151 | # Visual Studio Trace Files 152 | *.e2e 153 | 154 | # TFS 2012 Local Workspace 155 | $tf/ 156 | 157 | # Guidance Automation Toolkit 158 | *.gpState 159 | 160 | # ReSharper is a .NET coding add-in 161 | _ReSharper*/ 162 | *.[Rr]e[Ss]harper 163 | *.DotSettings.user 164 | 165 | # JustCode is a .NET coding add-in 166 | .JustCode 167 | 168 | # TeamCity is a build add-in 169 | _TeamCity* 170 | 171 | # DotCover is a Code Coverage Tool 172 | *.dotCover 173 | 174 | # AxoCover is a Code Coverage Tool 175 | .axoCover/* 176 | !.axoCover/settings.json 177 | 178 | # Visual Studio code coverage results 179 | *.coverage 180 | *.coveragexml 181 | 182 | # NCrunch 183 | _NCrunch_* 184 | .*crunch*.local.xml 185 | nCrunchTemp_* 186 | 187 | # MightyMoose 188 | *.mm.* 189 | AutoTest.Net/ 190 | 191 | # Web workbench (sass) 192 | .sass-cache/ 193 | 194 | # Installshield output folder 195 | [Ee]xpress/ 196 | 197 | # DocProject is a documentation generator add-in 198 | DocProject/buildhelp/ 199 | DocProject/Help/*.HxT 200 | DocProject/Help/*.HxC 201 | DocProject/Help/*.hhc 202 | DocProject/Help/*.hhk 203 | DocProject/Help/*.hhp 204 | DocProject/Help/Html2 205 | DocProject/Help/html 206 | 207 | # Click-Once directory 208 | publish/ 209 | 210 | # Publish Web Output 211 | *.[Pp]ublish.xml 212 | *.azurePubxml 213 | # Note: Comment the next line if you want to checkin your web deploy settings, 214 | # but database connection strings (with potential passwords) will be unencrypted 215 | *.pubxml 216 | *.publishproj 217 | 218 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 219 | # checkin your Azure Web App publish settings, but sensitive information contained 220 | # in these scripts will be unencrypted 221 | PublishScripts/ 222 | 223 | # NuGet Packages 224 | *.nupkg 225 | # The packages folder can be ignored because of Package Restore 226 | **/[Pp]ackages/* 227 | # except build/, which is used as an MSBuild target. 228 | !**/[Pp]ackages/build/ 229 | # Uncomment if necessary however generally it will be regenerated when needed 230 | #!**/[Pp]ackages/repositories.config 231 | # NuGet v3's project.json files produces more ignorable files 232 | *.nuget.props 233 | *.nuget.targets 234 | 235 | # Microsoft Azure Build Output 236 | csx/ 237 | *.build.csdef 238 | 239 | # Microsoft Azure Emulator 240 | ecf/ 241 | rcf/ 242 | 243 | # Windows Store app package directories and files 244 | AppPackages/ 245 | BundleArtifacts/ 246 | Package.StoreAssociation.xml 247 | _pkginfo.txt 248 | *.appx 249 | 250 | # Visual Studio cache files 251 | # files ending in .cache can be ignored 252 | *.[Cc]ache 253 | # but keep track of directories ending in .cache 254 | !*.[Cc]ache/ 255 | 256 | # Others 257 | ClientBin/ 258 | ~$* 259 | *~ 260 | *.dbmdl 261 | *.dbproj.schemaview 262 | *.jfm 263 | *.pfx 264 | *.publishsettings 265 | orleans.codegen.cs 266 | 267 | # Since there are multiple workflows, uncomment next line to ignore bower_components 268 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 269 | #bower_components/ 270 | 271 | # RIA/Silverlight projects 272 | Generated_Code/ 273 | 274 | # Backup & report files from converting an old project file 275 | # to a newer Visual Studio version. Backup files are not needed, 276 | # because we have git ;-) 277 | _UpgradeReport_Files/ 278 | Backup*/ 279 | UpgradeLog*.XML 280 | UpgradeLog*.htm 281 | 282 | # SQL Server files 283 | *.mdf 284 | *.ldf 285 | *.ndf 286 | 287 | # Business Intelligence projects 288 | *.rdl.data 289 | *.bim.layout 290 | *.bim_*.settings 291 | 292 | # Microsoft Fakes 293 | FakesAssemblies/ 294 | 295 | # GhostDoc plugin setting file 296 | *.GhostDoc.xml 297 | 298 | # Node.js Tools for Visual Studio 299 | .ntvs_analysis.dat 300 | node_modules/ 301 | 302 | # Typescript v1 declaration files 303 | typings/ 304 | 305 | # Visual Studio 6 build log 306 | *.plg 307 | 308 | # Visual Studio 6 workspace options file 309 | *.opt 310 | 311 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 312 | *.vbw 313 | 314 | # Visual Studio LightSwitch build output 315 | **/*.HTMLClient/GeneratedArtifacts 316 | **/*.DesktopClient/GeneratedArtifacts 317 | **/*.DesktopClient/ModelManifest.xml 318 | **/*.Server/GeneratedArtifacts 319 | **/*.Server/ModelManifest.xml 320 | _Pvt_Extensions 321 | 322 | # Paket dependency manager 323 | .paket/paket.exe 324 | paket-files/ 325 | 326 | # FAKE - F# Make 327 | .fake/ 328 | 329 | # JetBrains Rider 330 | .idea/ 331 | *.sln.iml 332 | 333 | # IDE - VSCode 334 | .vscode/* 335 | !.vscode/settings.json 336 | !.vscode/tasks.json 337 | !.vscode/launch.json 338 | !.vscode/extensions.json 339 | 340 | # CodeRush 341 | .cr/ 342 | 343 | # Python Tools for Visual Studio (PTVS) 344 | __pycache__/ 345 | *.pyc 346 | 347 | # Cake - Uncomment if you are using it 348 | # tools/** 349 | # !tools/packages.config 350 | 351 | # Tabs Studio 352 | *.tss 353 | 354 | # Telerik's JustMock configuration file 355 | *.jmconfig 356 | 357 | # BizTalk build output 358 | *.btp.cs 359 | *.btm.cs 360 | *.odx.cs 361 | *.xsd.cs 362 | 363 | # OpenCover UI analysis results 364 | OpenCover/ 365 | coverage/ 366 | 367 | ### macOS template 368 | # General 369 | .DS_Store 370 | .AppleDouble 371 | .LSOverride 372 | 373 | # Icon must end with two \r 374 | Icon 375 | 376 | # Thumbnails 377 | ._* 378 | 379 | # Files that might appear in the root of a volume 380 | .DocumentRevisions-V100 381 | .fseventsd 382 | .Spotlight-V100 383 | .TemporaryItems 384 | .Trashes 385 | .VolumeIcon.icns 386 | .com.apple.timemachine.donotpresent 387 | 388 | # Directories potentially created on remote AFP share 389 | .AppleDB 390 | .AppleDesktop 391 | Network Trash Folder 392 | Temporary Items 393 | .apdisk 394 | 395 | ======= 396 | # Local 397 | .env 398 | dist 399 | -------------------------------------------------------------------------------- /src/controllers/optimize-controller/optimize.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from "@nestjs/testing"; 2 | import { OptimizeController } from "./optimize.controller"; 3 | import { OptimizeService } from "../../services/optimize.service"; 4 | import { AllowService } from "../../services/allow.service"; 5 | import { ImgLoaderService } from "../../services/img-loader.service"; 6 | import { BadRequestException } from "@nestjs/common"; 7 | import { Response } from "express"; 8 | 9 | describe("OptimizeController", () => { 10 | let optimizeController: OptimizeController; 11 | let allowService: AllowService; 12 | 13 | beforeEach(async () => { 14 | const mockAllowService = { 15 | isAllowedSources: jest.fn(), 16 | isAllowedSizes: jest.fn(), 17 | }; 18 | 19 | const mockImgLoaderService = { 20 | getImage: jest.fn(), 21 | }; 22 | 23 | const mockOptimizeService = { 24 | getOptimizedImage: jest.fn(), 25 | }; 26 | 27 | const app: TestingModule = await Test.createTestingModule({ 28 | controllers: [OptimizeController], 29 | providers: [ 30 | { 31 | provide: OptimizeService, 32 | useValue: mockOptimizeService, 33 | }, 34 | { 35 | provide: AllowService, 36 | useValue: mockAllowService, 37 | }, 38 | { 39 | provide: ImgLoaderService, 40 | useValue: mockImgLoaderService, 41 | }, 42 | ], 43 | }).compile(); 44 | 45 | optimizeController = app.get(OptimizeController); 46 | allowService = app.get(AllowService); 47 | }); 48 | 49 | describe("getPreview - negative scenarios for query parameters", () => { 50 | let mockResponse: Partial; 51 | 52 | beforeEach(() => { 53 | mockResponse = { 54 | setHeader: jest.fn().mockReturnThis(), 55 | status: jest.fn().mockReturnThis(), 56 | send: jest.fn(), 57 | }; 58 | }); 59 | 60 | it("should throw BadRequestException if src parameter is missing", async () => { 61 | await expect( 62 | optimizeController.getPreview( 63 | "", 64 | "1920", 65 | "jpeg", 66 | "80", 67 | mockResponse as Response, 68 | ), 69 | ).rejects.toThrow( 70 | new BadRequestException("Parameter 'src' is required."), 71 | ); 72 | }); 73 | 74 | it("should throw BadRequestException if size parameter is missing", async () => { 75 | await expect( 76 | optimizeController.getPreview( 77 | "https://example.com/image.jpg", 78 | "", 79 | "jpeg", 80 | "80", 81 | mockResponse as Response, 82 | ), 83 | ).rejects.toThrow( 84 | new BadRequestException("Parameter 'size' is required."), 85 | ); 86 | }); 87 | 88 | it("should throw BadRequestException if size is zero", async () => { 89 | await expect( 90 | optimizeController.getPreview( 91 | "https://example.com/image.jpg", 92 | "0", 93 | "jpeg", 94 | "80", 95 | mockResponse as Response, 96 | ), 97 | ).rejects.toThrow( 98 | new BadRequestException( 99 | "Parameter 'size' should be a positive integer.", 100 | ), 101 | ); 102 | }); 103 | 104 | it("should throw BadRequestException if size is negative", async () => { 105 | await expect( 106 | optimizeController.getPreview( 107 | "https://example.com/image.jpg", 108 | "-100", 109 | "jpeg", 110 | "80", 111 | mockResponse as Response, 112 | ), 113 | ).rejects.toThrow( 114 | new BadRequestException( 115 | "Parameter 'size' should be a positive integer.", 116 | ), 117 | ); 118 | }); 119 | 120 | it("should throw BadRequestException if format parameter is missing", async () => { 121 | await expect( 122 | optimizeController.getPreview( 123 | "https://example.com/image.jpg", 124 | "1920", 125 | "", 126 | "80", 127 | mockResponse as Response, 128 | ), 129 | ).rejects.toThrow( 130 | new BadRequestException("Parameter 'format' is required."), 131 | ); 132 | }); 133 | 134 | it("should throw BadRequestException if format is not supported", async () => { 135 | await expect( 136 | optimizeController.getPreview( 137 | "https://example.com/image.jpg", 138 | "1920", 139 | "gif", 140 | "80", 141 | mockResponse as Response, 142 | ), 143 | ).rejects.toThrow( 144 | new BadRequestException("Parameter 'format' is not supported."), 145 | ); 146 | }); 147 | 148 | it("should throw BadRequestException if quality is not a number", async () => { 149 | await expect( 150 | optimizeController.getPreview( 151 | "https://example.com/image.jpg", 152 | "1920", 153 | "jpeg", 154 | "abc", 155 | mockResponse as Response, 156 | ), 157 | ).rejects.toThrow( 158 | new BadRequestException("Parameter 'quality' is not a number."), 159 | ); 160 | }); 161 | 162 | it("should throw BadRequestException if quality is less than 1", async () => { 163 | await expect( 164 | optimizeController.getPreview( 165 | "https://example.com/image.jpg", 166 | "1920", 167 | "jpeg", 168 | "0", 169 | mockResponse as Response, 170 | ), 171 | ).rejects.toThrow( 172 | new BadRequestException( 173 | "Parameter 'quality' must be in range 1-100.", 174 | ), 175 | ); 176 | }); 177 | 178 | it("should throw BadRequestException if quality is greater than 100", async () => { 179 | await expect( 180 | optimizeController.getPreview( 181 | "https://example.com/image.jpg", 182 | "1920", 183 | "jpeg", 184 | "101", 185 | mockResponse as Response, 186 | ), 187 | ).rejects.toThrow( 188 | new BadRequestException( 189 | "Parameter 'quality' must be in range 1-100.", 190 | ), 191 | ); 192 | }); 193 | 194 | it("should throw BadRequestException if src is not allowed", async () => { 195 | (allowService.isAllowedSources as jest.Mock).mockReturnValue(false); 196 | 197 | await expect( 198 | optimizeController.getPreview( 199 | "https://malicious.com/image.jpg", 200 | "1920", 201 | "jpeg", 202 | "80", 203 | mockResponse as Response, 204 | ), 205 | ).rejects.toThrow( 206 | new BadRequestException( 207 | "Parameter 'src' has an not allowed value.", 208 | ), 209 | ); 210 | }); 211 | 212 | it("should throw BadRequestException if size is not allowed", async () => { 213 | (allowService.isAllowedSources as jest.Mock).mockReturnValue(true); 214 | (allowService.isAllowedSizes as jest.Mock).mockReturnValue(false); 215 | 216 | await expect( 217 | optimizeController.getPreview( 218 | "https://example.com/image.jpg", 219 | "5000", 220 | "jpeg", 221 | "80", 222 | mockResponse as Response, 223 | ), 224 | ).rejects.toThrow( 225 | new BadRequestException( 226 | "Parameter 'size' has an not allowed value.", 227 | ), 228 | ); 229 | }); 230 | }); 231 | }); 232 | --------------------------------------------------------------------------------