Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,7 @@ S3_BUCKET_NAME=''
S3_ENDPOINT=''
S3_REGION=''
S3_ACCESS_KEY=''
S3_SECRET_KEY=''
S3_SECRET_KEY=''

IMAGOR_SECRET=''
IMAGOR_URL=''
34 changes: 34 additions & 0 deletions infra/dev/compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,40 @@ services:
${S3_ACCESS_KEY} ${S3_SECRET_KEY}; mc mb myminio/${S3_BUCKET_NAME}
--ignore-existing; mc anonymous set download
myminio/${S3_BUCKET_NAME}; exit 0; "
imagor:
image: shumc/imagor:latest
container_name: imagor
restart: always
environment:
- IMAGOR_SECRET=${IMAGOR_SECRET:-supersecret}
- HTTP_LOADER_DISABLE=1

- AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY}
- AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY}
- AWS_REGION=${S3_REGION}

- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=1

- S3_LOADER_BUCKET=${S3_BUCKET_NAME}
- S3_LOADER_BASE_DIR=sources

- S3_STORAGE_BUCKET=${S3_BUCKET_NAME}
- S3_STORAGE_BASE_DIR=
- S3_STORAGE_ACL=private

- S3_RESULT_STORAGE_BUCKET=${S3_BUCKET_NAME}
- S3_RESULT_STORAGE_ACL=private

- VIPS_CONCURRENCY=1
- DEBUG=1
ports:
- '8000:8000'
depends_on:
- minio
networks:
- backend
profiles: ['infra']

volumes:
postgres_data:
Expand Down
2 changes: 2 additions & 0 deletions libs/bootstrap/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export async function bootstrapApp(options: BootstrapOptions) {
await app.register(fastifyMultipart, {
limits: {
fileSize: 5 * 1024 * 1024,
fieldNameSize: 100,
files: 5,
},
});

Expand Down
2 changes: 2 additions & 0 deletions libs/config/src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const ConfigSchema = z.object({
REDIS_HOST: z.string().default('redis'),
REDIS_PORT: z.coerce.number().optional().default(6379),
REDIS_PASSWORD: z.string().optional(),
IMAGOR_SECRET: z.string().optional(),
IMAGOR_URL: z.string().nonempty('Укажите адрес сервера Imagor'),
DOMAIN: z
.string()
.toLowerCase()
Expand Down
16 changes: 16 additions & 0 deletions libs/imagor/src/imagor.module-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import type { ImagorModuleOptions } from './interfaces';

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } =
new ConfigurableModuleBuilder<ImagorModuleOptions>()
.setClassMethodName('forRoot')
.setExtras(
{
global: true,
},
(definition, extras) => ({
...definition,
global: extras.global,
}),
)
.build();
9 changes: 9 additions & 0 deletions libs/imagor/src/imagor.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfigurableModuleClass } from './imagor.module-definition';
import { ImagorService } from './imagor.service';

@Module({
providers: [ImagorService],
exports: [ImagorService],
})
export class ImagorModule extends ConfigurableModuleClass {}
84 changes: 84 additions & 0 deletions libs/imagor/src/imagor.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { MODULE_OPTIONS_TOKEN } from './imagor.module-definition';
import type { ImagorModuleOptions, Filters } from './interfaces';
import { createHmac } from 'crypto';
import { HttpService } from '@nestjs/axios';
import { ImagorPathBuilder } from './utils';
import { catchError, firstValueFrom, throwError } from 'rxjs';
import { AxiosError } from 'axios';

@Injectable()
export class ImagorService {
private logger = new Logger(ImagorService.name);

constructor(
@Inject(MODULE_OPTIONS_TOKEN)
private options: ImagorModuleOptions,
private readonly http: HttpService,
) {}

/**
* Выполняет GET запрос к Imagor с применением фильтров и пресетов
* @param path Путь к исходному файлу в хранилище
* @param presetOrFilters Название пресета или объект с фильтрами (width, height, smart и т.д.)
*/
async get(path: string, presetOrFilters?: string | Filters): Promise<Buffer> {
const host = this.options.url.replace(/\/+$/, '');
const transformPath = this.buildTransformPath(path, presetOrFilters);
const signature = this.getFullSignedPath(transformPath);
const url = `${host}/${signature}`;

try {
this.logger.debug(url);
const response = await firstValueFrom(
this.http.get(url, { responseType: 'arraybuffer' }).pipe(
catchError((error: AxiosError) => {
console.error('Imagor Get Error:', error.response?.data || error.message);
return throwError(() => error);
}),
),
);

return Buffer.from(response.data);
} catch (error) {
throw error;
}
}

private buildTransformPath(path: string, presetOrFilters?: string | Filters): string {
const builder = new ImagorPathBuilder(path, this.options.storageRoot);

const globalFilters = this.options.filters || {};
let localFilters: Filters = {};

if (typeof presetOrFilters === 'string') {
localFilters = this.options.presets?.[presetOrFilters] || {};
} else if (presetOrFilters) {
localFilters = presetOrFilters;
}

const merged = { ...globalFilters, ...localFilters };

if (merged.width || merged.height) builder.resize(merged.width ?? 0, merged.height ?? 0);
if (merged.smart) builder.smart(true);
if (merged.fit) builder.fit(merged.fit);

builder.applyFilters(merged);

return builder.build();
}

private getFullSignedPath(path: string): string {
if (!this.options.secret) {
return `unsafe/${path}`;
}

const hash = createHmac('sha1', this.options.secret)
.update(path)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');

return `${hash}/${path}`;
}
}
2 changes: 2 additions & 0 deletions libs/imagor/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ImagorModule } from './imagor.module';
export { ImagorService } from './imagor.service';
173 changes: 173 additions & 0 deletions libs/imagor/src/interfaces/filters.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { Format } from './formats.interface';

/**
* Режимы вписывания изображения в заданные размеры.
* - 'fit-in': Вписывает изображение целиком, сохраняя пропорции (могут появиться пустые поля).
* - 'stretch': Растягивает изображение строго под размеры, игнорируя пропорции.
* - 'dashed': Специфический режим Imagor для обработки прозрачности или границ.
*/
type Fit = 'fit-in' | 'stretch' | 'dashed';

/**
* Набор фильтров и трансформаций Imagor.
* Порядок применения фильтров в URL обычно соответствует порядку их перечисления.
* @see https://github.com/cshum/imagor#filters
*/
export interface Filters {
/**
* Ширина выходного изображения в пикселях.
* Используйте 'orig', чтобы сохранить исходную ширину.
*/
width?: number | 'orig';

/**
* Высота выходного изображения в пикселях.
* Используйте 'orig', чтобы сохранить исходную высоту.
*/
height?: number | 'orig';

/**
* Включает умную обрезку (Smart Cropping).
* Imagor попытается найти наиболее важные области (лица, контрастные объекты) и сфокусироваться на них.
*/
smart?: boolean;

/**
* Режим вписывания.
* Если не указан, по умолчанию используется обрезка (Crop) для заполнения всей области.
*/
fit?: Fit;

/**
* Устанавливает качество выходного изображения.
* @param {number} quality Число от 0 до 100.
*/
quality?: number;

/**
* Принудительно устанавливает формат выходного изображения.
* WebP и AVIF рекомендуются для лучшего сжатия.
*/
format?: Format;

/**
* Если true, автоматически конвертирует изображения с прозрачностью в JPEG,
* заменяя прозрачные области фоном (белым по умолчанию).
*/
autojpg?: boolean;

/** Удаляет EXIF метаданные из выходного изображения. Полезно для приватности и уменьшения размера. */
strip_exif?: boolean;

/** Удаляет ICC профили цвета. */
strip_icc?: boolean;

/**
* Регулирует яркость изображения.
* @param {number} brightness Число от -100 до 100. Положительные — ярче, отрицательные — темнее.
*/
brightness?: number;

/**
* Регулирует контрастность изображения.
* @param {number} contrast Число от -100 до 100.
*/
contrast?: number;

/** Преобразует изображение в черно-белое (grayscale). */
grayscale?: boolean;

/**
* Настройка цветовых каналов RGB.
* @property {number} r Красный (-100 до 100)
* @property {number} g Зеленый (-100 до 100)
* @property {number} b Синий (-100 до 100)
*/
rgb?: { r: number; g: number; b: number };

/**
* Изменяет общую насыщенность цветов.
* @param {number} proportion Число от 0 до 100.
*/
proportion?: number;

/**
* Применяет размытие Гаусса.
* Можно передать число (радиус) или объект для более точной настройки сигмы.
*/
blur?: number | { radius: number; sigma?: number };

/**
* Повышает резкость изображения.
* @property {number} amount Степень резкости.
* @property {number} radius Радиус фильтра.
* @property {number} threshold Порог срабатывания.
*/
sharpen?: {
amount: number;
radius: number;
threshold: number;
};

/**
* Добавляет шум на изображение.
* @param {number} noise Уровень шума от 0 до 100.
*/
noise?: number;

/** Поворачивает изображение на заданный угол по часовой стрелке. */
rotate?: 90 | 180 | 270;

/**
* Определяет цвет заполнения пустых областей при использовании режима 'fit-in'.
* @example 'ff0000' (hex), 'white' (name) или 'auto' (главный цвет изображения).
*/
fill?: string;

/** Устанавливает цвет фона для прозрачных изображений (например, PNG). */
background_color?: string;

/**
* Наложение водяного знака поверх основного изображения.
*/
watermark?: {
/** Путь к файлу водяного знака в хранилище. */
image: string;
/** Позиция по горизонтали или смещение в пикселях. */
x?: number | 'center' | 'left' | 'right';
/** Позиция по вертикали или смещение в пикселях. */
y?: number | 'center' | 'top' | 'bottom';
/** Прозрачность водяного знака (0 - прозрачный, 100 - непрозрачный). */
alpha?: number;
/** Относительная ширина знака в процентах (0.0 - 1.0) от основного изображения. */
w_ratio?: number;
/** Относительная высота знака в процентах (0.0 - 1.0). */
h_ratio?: number;
};

/**
* Указывает точку фокуса для кропа.
* Полезно, если вы знаете координаты лица или важного объекта.
*/
focal?: { x: number; y: number };

/**
* Скругление углов изображения.
* @property {number} radius Радиус скругления в пикселях.
* @property {string} color Цвет заливки углов (например, 'transparent' или 'ffffff').
*/
round_corner?: {
radius: number;
color?: string;
};

/**
* Ограничивает размер файла (в байтах). Imagor будет снижать качество, пока не впишется в лимит.
*/
max_bytes?: number;

/**
* Запрещает увеличивать изображение, если его исходные размеры меньше запрошенных (width/height).
*/
no_upscale?: boolean;
}
10 changes: 10 additions & 0 deletions libs/imagor/src/interfaces/formats.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const FORMATS = {
JPEG: 'jpeg',
PNG: 'png',
WEBP: 'webp',
AVIF: 'avif',
JP2: 'jp2',
GIF: 'gif',
} as const;

export type Format = (typeof FORMATS)[keyof typeof FORMATS];
2 changes: 2 additions & 0 deletions libs/imagor/src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type * from './module.interface';
export type * from './filters.interface';
Loading
Loading