feat: добавить генерацию image variants

- добавлен shared config presets, custom transforms и allowlist hosts
- реализованы Backend endpoints для assets, presets и variants
- добавлена orchestration через PostgreSQL, RabbitMQ, S3 и worker
- обновлён Gateway read-through flow с L1 cache и корректным Vary: Accept
- добавлена миграция resize_mode для variants lookup
- обновлены dev scripts, env template, lockfile и документация
This commit is contained in:
2026-05-05 13:25:28 +03:00
parent bcadb85a83
commit 1c0e8277a3
59 changed files with 3526 additions and 143 deletions

View File

@@ -4,15 +4,21 @@
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"dev": "node --env-file-if-exists=../../.env ./node_modules/@nestjs/cli/bin/nest.js start --watch",
"start": "node --env-file-if-exists=../../.env dist/main.js",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@image-platform/database": "workspace:*",
"@image-platform/image-config": "workspace:*",
"@image-platform/queue": "workspace:*",
"@image-platform/storage": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.0.0",
"amqplib": "^1.0.4",
"drizzle-orm": "^0.45.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
@@ -20,6 +26,8 @@
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@types/amqplib": "^0.10.8",
"@types/express": "^5.0.6",
"@types/node": "^24.0.0",
"@types/swagger-ui-express": "^4.1.8",
"typescript": "^5.9.0"

View File

@@ -1,9 +1,17 @@
import { Module } from "@nestjs/common"
import { AssetsController } from "./assets/assets.controller"
import { AssetsService } from "./assets/assets.service"
import { HealthController } from "./health/health.controller"
import { DatabaseService } from "./infra/database.service"
import { QueueService } from "./infra/queue.service"
import { StorageService } from "./infra/storage.service"
import { InternalImagesController } from "./internal-images/internal-images.controller"
import { InternalImagesService } from "./internal-images/internal-images.service"
import { PresetsController } from "./presets/presets.controller"
@Module({
controllers: [HealthController, InternalImagesController],
controllers: [HealthController, AssetsController, InternalImagesController, PresetsController],
providers: [AssetsService, DatabaseService, InternalImagesService, QueueService, StorageService],
})
export class AppModule {}

View File

@@ -0,0 +1,93 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
export class AssetResponseDto {
@ApiProperty({ description: "Внутренний UUID asset.", example: "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" })
id!: string
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
publicId!: string
@ApiProperty({ description: "Текущая версия source image.", example: 1 })
currentVersion!: number
@ApiProperty({ description: "Статус asset.", enum: ["active", "disabled", "deleted"], example: "active" })
status!: string
@ApiProperty({ description: "Source URL текущей версии.", example: "https://storage.yandexcloud.net/shared1318/img/1.jpg" })
sourceUrl!: string
@ApiProperty({ description: "Hostname source URL текущей версии.", example: "storage.yandexcloud.net" })
sourceHost!: string
@ApiProperty({ description: "Дата создания asset.", example: "2026-05-05T12:00:00.000Z" })
createdAt!: string
@ApiProperty({ description: "Дата обновления asset.", example: "2026-05-05T12:00:00.000Z" })
updatedAt!: string
}
export class AssetVariantResponseDto {
@ApiProperty({ description: "Внутренний UUID variant.", example: "7748d24e-5f30-4064-8ee8-4745a4d2aef1" })
id!: string
@ApiProperty({ description: "Preset или `custom`.", example: "card" })
preset!: string
@ApiProperty({ description: "Версия source image, для которой создан variant.", example: 1 })
version!: number
@ApiProperty({ description: "Ширина variant.", example: 640 })
width!: number
@ApiProperty({ description: "Высота variant. `0` означает auto height.", example: 0 })
height!: number
@ApiProperty({ description: "Режим resize.", enum: ["fit", "fill"], example: "fit" })
resize!: string
@ApiProperty({ description: "Качество variant.", example: 80 })
quality!: number
@ApiProperty({ description: "Запрошенный формат.", enum: ["auto", "avif", "webp", "jpg", "png"], example: "webp" })
requestedFormat!: string
@ApiProperty({ description: "Фактический формат bytes.", enum: ["avif", "webp", "jpg", "png"], example: "webp" })
format!: string
@ApiProperty({ description: "Статус генерации.", enum: ["pending", "processing", "ready", "failed"], example: "ready" })
status!: string
@ApiProperty({ description: "Публичный Gateway URL для variant.", example: "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=webp" })
url!: string
@ApiProperty({ description: "S3 key variant object.", example: "variants/asset_demo/v1/abc.webp" })
s3Key!: string
@ApiPropertyOptional({ description: "Content-Type готового object.", example: "image/webp" })
contentType!: string | null
@ApiPropertyOptional({ description: "Размер готового object в bytes.", example: 71844 })
sizeBytes!: number | null
@ApiPropertyOptional({ description: "Ошибка последней генерации, если status=`failed`." })
error!: string | null
@ApiProperty({ description: "Дата создания variant.", example: "2026-05-05T12:00:00.000Z" })
createdAt!: string
@ApiProperty({ description: "Дата обновления variant.", example: "2026-05-05T12:00:00.000Z" })
updatedAt!: string
}
export class AssetVariantsResponseDto {
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
publicId!: string
@ApiProperty({ description: "Список variants.", type: [AssetVariantResponseDto] })
variants!: AssetVariantResponseDto[]
}
export class AssetsListResponseDto {
@ApiProperty({ description: "Список assets.", type: [AssetResponseDto] })
assets!: AssetResponseDto[]
}

View File

@@ -0,0 +1,93 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"
import {
ApiBadRequestResponse,
ApiConflictResponse,
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from "@nestjs/swagger"
import { AssetsService } from "./assets.service"
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
import { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto"
@ApiTags("assets")
@Controller("assets")
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
@Get()
@ApiOperation({
summary: "получить список assets",
description: "Возвращает последние зарегистрированные assets вместе с source URL текущей версии.",
})
@ApiQuery({ description: "Максимальное количество assets в ответе.", example: 50, name: "limit", required: false })
@ApiQuery({ description: "Смещение для простого paging.", example: 0, name: "offset", required: false })
@ApiOkResponse({ description: "Список assets возвращён.", type: AssetsListResponseDto })
listAssets(@Query("limit") limit?: string, @Query("offset") offset?: string): Promise<AssetsListResponseDto> {
return this.assets.listAssets({ limit, offset })
}
@Post()
@ApiOperation({
summary: "зарегистрировать исходное изображение",
description:
"Создаёт asset и первую версию source image. Source URL сохраняется в PostgreSQL, а публичный image URL строится через Gateway без раскрытия исходной ссылки клиенту.",
})
@ApiCreatedResponse({ description: "Asset создан, версия source image зарегистрирована.", type: CreateAssetResponseDto })
@ApiBadRequestResponse({ description: "Некорректный sourceUrl, publicId или source host запрещён настройками." })
@ApiConflictResponse({ description: "Asset с таким publicId уже существует." })
createAsset(@Body() request: CreateAssetRequestDto): Promise<CreateAssetResponseDto> {
return this.assets.createAsset(request)
}
@Get(":publicId")
@ApiOperation({
summary: "получить asset по publicId",
description: "Возвращает metadata asset и source URL текущей версии.",
})
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
@ApiOkResponse({ description: "Asset найден.", type: AssetResponseDto })
@ApiNotFoundResponse({ description: "Asset не найден." })
getAsset(@Param("publicId") publicId: string): Promise<AssetResponseDto> {
return this.assets.getAsset(publicId)
}
@Get(":publicId/variants")
@ApiOperation({
summary: "получить variants asset",
description: "Возвращает variants asset: preset/custom параметры, status, S3 key, public URL и ошибку генерации, если она была.",
})
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
@ApiQuery({ description: "Версия source image. Если не передана, возвращаются variants всех версий.", example: 1, name: "version", required: false })
@ApiOkResponse({ description: "Variants возвращены.", type: AssetVariantsResponseDto })
@ApiNotFoundResponse({ description: "Asset не найден." })
listAssetVariants(
@Param("publicId") publicId: string,
@Query("version") version?: string,
): Promise<AssetVariantsResponseDto> {
return this.assets.listAssetVariants(publicId, version)
}
@Post(":publicId/variants")
@ApiOperation({
summary: "поставить generation jobs для variants",
description:
"Business endpoint для явной подготовки variants. В режиме `single` создаёт один variant, в режиме `family` создаёт набор variants preset по всем разрешённым widths/formats. Endpoint не ждёт bytes, а возвращает созданные/переиспользованные rows и public URLs.",
})
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
@ApiCreatedResponse({ description: "Variants созданы или переиспользованы, jobs поставлены при необходимости.", type: CreateAssetVariantsResponseDto })
@ApiBadRequestResponse({ description: "Некорректный preset/custom transform config." })
@ApiNotFoundResponse({ description: "Asset или version не найдены." })
createAssetVariants(
@Param("publicId") publicId: string,
@Body() request: CreateAssetVariantsRequestDto,
): Promise<CreateAssetVariantsResponseDto> {
return this.assets.createAssetVariants(publicId, request)
}
}

View File

@@ -0,0 +1,611 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
import { imageAssets, imageAssetVersions, imageVariants } from "@image-platform/database"
import {
CUSTOM_PRESET_NAME,
ImageTransformConfigError,
getImagePreset,
isAllowedSourceHost,
loadAllowedSourceHostsFromEnv,
normalizeImageTransform,
parseBooleanFlag,
type ActualImageFormat,
type NormalizedImageTransform,
} from "@image-platform/image-config"
import { buildVariantImageKey } from "@image-platform/storage"
import { and, desc, eq } from "drizzle-orm"
import { createHash, randomUUID } from "node:crypto"
import { DatabaseService } from "../infra/database.service"
import { QueueService } from "../infra/queue.service"
import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
import type { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto"
import { normalizeSourceUrl } from "./source-url"
type AssetVersionRow = {
assetId: string
currentVersion: number
publicId: string
status: "active" | "deleted" | "disabled"
versionId: string
version: number
}
type VariantRow = typeof imageVariants.$inferSelect
type VariantTransform = NormalizedImageTransform & {
version: number
}
@Injectable()
export class AssetsService {
private readonly allowedHosts = loadAllowedSourceHostsFromEnv()
private readonly allowCustomTransforms = parseBooleanFlag(process.env.IMAGE_ALLOW_CUSTOM_TRANSFORMS, false)
private readonly allowUnregisteredHosts = parseBooleanFlag(process.env.SOURCE_HOST_ALLOW_ALL, false)
private readonly publicImageBaseUrl = process.env.PUBLIC_IMAGE_BASE_URL ?? "http://localhost:8888"
constructor(
private readonly database: DatabaseService,
private readonly queue: QueueService,
) {}
async listAssets(input: { limit?: string; offset?: string }): Promise<AssetsListResponseDto> {
const limit = parsePaginationInteger(input.limit, 50, 1, 100)
const offset = parsePaginationInteger(input.offset, 0, 0, 10_000)
const rows = await this.database.db
.select({
createdAt: imageAssets.createdAt,
currentVersion: imageAssets.currentVersion,
id: imageAssets.id,
publicId: imageAssets.publicId,
sourceHost: imageAssetVersions.sourceHost,
sourceUrl: imageAssetVersions.sourceUrl,
status: imageAssets.status,
updatedAt: imageAssets.updatedAt,
})
.from(imageAssets)
.innerJoin(
imageAssetVersions,
and(eq(imageAssetVersions.assetId, imageAssets.id), eq(imageAssetVersions.version, imageAssets.currentVersion)),
)
.orderBy(desc(imageAssets.createdAt))
.limit(limit)
.offset(offset)
return {
assets: rows.map(mapAssetResponse),
}
}
async createAsset(request: CreateAssetRequestDto): Promise<CreateAssetResponseDto> {
const source = normalizeSourceUrl(request.sourceUrl)
await this.assertAllowedHost(source.hostname)
const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId()
const sourceHash = createHash("sha256").update(source.sourceUrl).digest("hex")
try {
const result = await this.database.db.transaction(async (tx) => {
const [asset] = await tx
.insert(imageAssets)
.values({ publicId })
.returning({ id: imageAssets.id, publicId: imageAssets.publicId })
if (!asset) {
throw new Error("failed to create image asset")
}
const [version] = await tx
.insert(imageAssetVersions)
.values({
assetId: asset.id,
sourceHash,
sourceHost: source.hostname,
sourceUrl: source.sourceUrl,
version: 1,
})
.returning({ sourceHost: imageAssetVersions.sourceHost, version: imageAssetVersions.version })
if (!version) {
throw new Error("failed to create image asset version")
}
return { asset, version }
})
return {
id: result.asset.id,
imageBasePath: `/images/${result.asset.publicId}/v${result.version.version}/card`,
publicId: result.asset.publicId,
sourceHost: result.version.sourceHost,
version: result.version.version,
}
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException("publicId already exists")
}
throw error
}
}
async getAsset(publicId: string): Promise<AssetResponseDto> {
const asset = await this.loadAsset(publicId)
return mapAssetResponse(asset)
}
async listAssetVariants(publicId: string, versionInput?: string): Promise<AssetVariantsResponseDto> {
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput))
const conditions = [eq(imageVariants.assetId, asset.assetId)]
if (versionInput !== undefined) {
conditions.push(eq(imageVariants.assetVersion, asset.version))
}
const variants = await this.database.db
.select()
.from(imageVariants)
.where(and(...conditions))
.orderBy(desc(imageVariants.createdAt))
return {
publicId: asset.publicId,
variants: variants.map((variant) => this.mapVariantResponse(asset.publicId, variant)),
}
}
async createAssetVariants(
publicId: string,
request: CreateAssetVariantsRequestDto,
): Promise<CreateAssetVariantsResponseDto> {
const asset = await this.loadAssetVersion(publicId, request.version)
const transforms = this.buildVariantTransforms(request, asset.version)
const variants: AssetVariantResponseDto[] = []
for (const transform of transforms) {
const variant = await this.findOrCreateVariant(asset, transform)
if (variant.status === "failed") {
const pending = await this.markVariantPending(variant.id)
this.queue.publishGenerateVariant(pending.id)
variants.push(this.mapVariantResponse(asset.publicId, pending))
continue
}
if (variant.status === "pending") {
this.queue.publishGenerateVariant(variant.id)
}
variants.push(this.mapVariantResponse(asset.publicId, variant))
}
return {
publicId: asset.publicId,
variants,
version: asset.version,
}
}
private async loadAsset(publicId: string) {
const normalizedPublicId = normalizePublicId(publicId)
const [asset] = await this.database.db
.select({
createdAt: imageAssets.createdAt,
currentVersion: imageAssets.currentVersion,
id: imageAssets.id,
publicId: imageAssets.publicId,
sourceHost: imageAssetVersions.sourceHost,
sourceUrl: imageAssetVersions.sourceUrl,
status: imageAssets.status,
updatedAt: imageAssets.updatedAt,
})
.from(imageAssets)
.innerJoin(
imageAssetVersions,
and(eq(imageAssetVersions.assetId, imageAssets.id), eq(imageAssetVersions.version, imageAssets.currentVersion)),
)
.where(eq(imageAssets.publicId, normalizedPublicId))
.limit(1)
if (!asset) {
throw new NotFoundException("asset not found")
}
return asset
}
private async loadAssetVersion(publicId: string, versionInput: number | null | undefined): Promise<AssetVersionRow> {
const normalizedPublicId = normalizePublicId(publicId)
const [asset] = await this.database.db
.select({
assetId: imageAssets.id,
currentVersion: imageAssets.currentVersion,
publicId: imageAssets.publicId,
status: imageAssets.status,
})
.from(imageAssets)
.where(eq(imageAssets.publicId, normalizedPublicId))
.limit(1)
if (!asset || asset.status !== "active") {
throw new NotFoundException("asset not found")
}
const version = versionInput ?? asset.currentVersion
const [assetVersion] = await this.database.db
.select({ id: imageAssetVersions.id, version: imageAssetVersions.version })
.from(imageAssetVersions)
.where(and(eq(imageAssetVersions.assetId, asset.assetId), eq(imageAssetVersions.version, version)))
.limit(1)
if (!assetVersion) {
throw new NotFoundException("asset version not found")
}
return {
...asset,
version: assetVersion.version,
versionId: assetVersion.id,
}
}
private buildVariantTransforms(request: CreateAssetVariantsRequestDto, version: number): VariantTransform[] {
const mode = request.mode ?? "single"
if (mode !== "single" && mode !== "family") {
throw new BadRequestException("mode must be single or family")
}
if (mode === "family") {
return this.buildFamilyTransforms(request, version)
}
const format = request.format ?? selectDefaultFormat(request.preset, this.allowCustomTransforms)
return [
this.normalizeTransform({
format,
height: request.height,
preset: request.preset,
quality: request.quality,
resize: request.resize,
version,
width: request.width,
}),
]
}
private buildFamilyTransforms(request: CreateAssetVariantsRequestDto, version: number): VariantTransform[] {
if (request.preset === CUSTOM_PRESET_NAME) {
throw new BadRequestException("custom transforms do not support family mode")
}
const preset = getImagePreset(request.preset)
if (!preset) {
throw new BadRequestException(`unknown image preset: ${request.preset}`)
}
if (request.width !== undefined || request.height !== undefined || request.resize !== undefined) {
throw new BadRequestException("width, height and resize are not accepted in family mode")
}
const formats = [...new Set(request.formats?.length ? request.formats : preset.formats)]
for (const format of formats) {
if (!preset.formats.includes(format)) {
throw new BadRequestException(`format ${format} is not allowed for preset ${request.preset}`)
}
}
const widths = preset.mode === "fixed" ? [preset.width] : preset.widths
if (!widths?.length) {
throw new BadRequestException(`preset ${request.preset} has no widths configured`)
}
return widths.flatMap((width) =>
formats.map((format) =>
this.normalizeTransform({
format,
preset: request.preset,
quality: request.quality,
version,
width: preset.mode === "fixed" ? undefined : width,
}),
),
)
}
private normalizeTransform(input: {
format: ActualImageFormat
height?: number
preset: string
quality?: number
resize?: "fill" | "fit"
version: number
width?: number
}): VariantTransform {
try {
const transform = normalizeImageTransform({
allowCustomTransforms: this.allowCustomTransforms,
format: input.format,
height: input.height,
preset: input.preset,
quality: input.quality,
requestedFormat: input.format,
resize: input.resize,
width: input.width,
})
return {
...transform,
version: input.version,
}
} catch (error) {
if (error instanceof ImageTransformConfigError) {
throw new BadRequestException(error.message)
}
throw error
}
}
private async findOrCreateVariant(asset: AssetVersionRow, transform: VariantTransform): Promise<VariantRow> {
const existing = await this.findVariant(asset.assetId, transform)
if (existing) {
return existing
}
const variantHash = createVariantHash(asset.publicId, transform)
const s3Key = buildVariantImageKey({
assetId: asset.publicId,
format: transform.format,
variantHash,
version: asset.version,
})
const [created] = await this.database.db
.insert(imageVariants)
.values({
assetId: asset.assetId,
assetVersion: asset.version,
assetVersionId: asset.versionId,
format: transform.format,
height: transform.height,
preset: transform.preset,
quality: transform.quality,
requestedFormat: transform.requestedFormat,
resizeMode: transform.resize,
s3Key,
status: "pending",
variantHash,
width: transform.width,
})
.onConflictDoNothing({
target: [
imageVariants.assetId,
imageVariants.assetVersion,
imageVariants.preset,
imageVariants.width,
imageVariants.height,
imageVariants.resizeMode,
imageVariants.quality,
imageVariants.format,
],
})
.returning()
if (created) {
return created
}
const raced = await this.findVariant(asset.assetId, transform)
if (!raced) {
throw new Error("failed to create image variant")
}
return raced
}
private async findVariant(assetId: string, transform: VariantTransform): Promise<VariantRow | null> {
const [variant] = await this.database.db
.select()
.from(imageVariants)
.where(
and(
eq(imageVariants.assetId, assetId),
eq(imageVariants.assetVersion, transform.version),
eq(imageVariants.preset, transform.preset),
eq(imageVariants.width, transform.width),
eq(imageVariants.height, transform.height),
eq(imageVariants.resizeMode, transform.resize),
eq(imageVariants.quality, transform.quality),
eq(imageVariants.format, transform.format),
),
)
.limit(1)
return variant ?? null
}
private async markVariantPending(variantId: string): Promise<VariantRow> {
const [variant] = await this.database.db
.update(imageVariants)
.set({ error: null, status: "pending", updatedAt: new Date() })
.where(eq(imageVariants.id, variantId))
.returning()
if (!variant) {
throw new NotFoundException("variant not found")
}
return variant
}
private mapVariantResponse(publicId: string, variant: VariantRow): AssetVariantResponseDto {
return {
contentType: variant.contentType,
createdAt: variant.createdAt.toISOString(),
error: variant.error,
format: variant.format,
height: variant.height ?? 0,
id: variant.id,
preset: variant.preset,
quality: variant.quality,
requestedFormat: variant.requestedFormat,
resize: variant.resizeMode,
s3Key: variant.s3Key,
sizeBytes: variant.sizeBytes,
status: variant.status,
updatedAt: variant.updatedAt.toISOString(),
url: buildPublicImageUrl(this.publicImageBaseUrl, publicId, variant),
version: variant.assetVersion,
width: variant.width,
}
}
private async assertAllowedHost(hostname: string) {
if (this.allowUnregisteredHosts) {
return
}
if (!isAllowedSourceHost(hostname, this.allowedHosts)) {
throw new BadRequestException("sourceUrl host is not allowed")
}
}
}
function mapAssetResponse(row: {
createdAt: Date
currentVersion: number
id: string
publicId: string
sourceHost: string
sourceUrl: string
status: "active" | "deleted" | "disabled"
updatedAt: Date
}): AssetResponseDto {
return {
createdAt: row.createdAt.toISOString(),
currentVersion: row.currentVersion,
id: row.id,
publicId: row.publicId,
sourceHost: row.sourceHost,
sourceUrl: row.sourceUrl,
status: row.status,
updatedAt: row.updatedAt.toISOString(),
}
}
function generatePublicId() {
return `asset_${randomUUID().replaceAll("-", "").slice(0, 16)}`
}
function normalizePublicId(publicId: string) {
const normalized = publicId.trim()
if (!/^[a-zA-Z0-9_-]{3,128}$/.test(normalized)) {
throw new BadRequestException("publicId must be 3-128 chars and contain only letters, digits, _ or -")
}
return normalized
}
function isUniqueViolation(error: unknown) {
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
}
function parsePaginationInteger(value: string | undefined, fallback: number, min: number, max: number) {
if (value === undefined) {
return fallback
}
if (!/^\d+$/.test(value)) {
throw new BadRequestException("pagination params must be integers")
}
const parsed = Number.parseInt(value, 10)
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
throw new BadRequestException(`pagination param must be between ${min} and ${max}`)
}
return parsed
}
function parseOptionalVersion(value: string | undefined) {
if (value === undefined) {
return undefined
}
if (!/^\d+$/.test(value)) {
throw new BadRequestException("version must be a positive integer")
}
const parsed = Number.parseInt(value, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new BadRequestException("version must be a positive integer")
}
return parsed
}
function selectDefaultFormat(preset: string, allowCustomTransforms: boolean): ActualImageFormat {
const config = getImagePreset(preset)
if (config) {
return config.formats.includes("webp") ? "webp" : config.formats[0]
}
if (preset === CUSTOM_PRESET_NAME && allowCustomTransforms) {
return "webp"
}
throw new BadRequestException(`unknown image preset: ${preset}`)
}
function createVariantHash(publicId: string, transform: VariantTransform) {
return createHash("sha256")
.update(
[
publicId,
transform.version,
transform.preset,
transform.width,
transform.height,
transform.resize,
transform.quality,
transform.format,
].join(":"),
)
.digest("hex")
.slice(0, 32)
}
function buildPublicImageUrl(baseUrl: string, publicId: string, variant: VariantRow) {
const url = new URL(`/images/${publicId}/v${variant.assetVersion}/${variant.preset}`, baseUrl)
const isFixedPresetUrl = variant.preset !== CUSTOM_PRESET_NAME && variant.height && variant.height > 0
if (!isFixedPresetUrl || variant.preset === CUSTOM_PRESET_NAME) {
url.searchParams.set("w", variant.width.toString())
}
if (variant.preset === CUSTOM_PRESET_NAME && variant.height) {
url.searchParams.set("h", variant.height.toString())
url.searchParams.set("fit", variant.resizeMode)
}
if (!isFixedPresetUrl || variant.quality !== 80) {
url.searchParams.set("q", variant.quality.toString())
}
url.searchParams.set("f", variant.format)
return url.toString()
}

View File

@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
import { AssetVariantResponseDto } from "./asset-response.dto"
export class CreateAssetVariantsRequestDto {
@ApiProperty({ description: "Preset для генерации или `custom`.", example: "card" })
preset!: string
@ApiPropertyOptional({ description: "Режим генерации: один variant или вся family preset.", enum: ["single", "family"], example: "single" })
mode?: "family" | "single"
@ApiPropertyOptional({ description: "Версия source image. Если не передана, используется currentVersion asset.", example: 1 })
version?: number
@ApiPropertyOptional({ description: "Ширина variant. Обязательна для responsive preset в mode=`single` и custom.", example: 640 })
width?: number
@ApiPropertyOptional({ description: "Высота variant для custom. `0` или отсутствие означает auto height.", example: 333 })
height?: number
@ApiPropertyOptional({ description: "Качество. Если не передано, берётся из preset/custom config.", example: 80 })
quality?: number
@ApiPropertyOptional({ description: "Фактический формат для single generation.", enum: ["avif", "webp", "jpg", "png"], example: "webp" })
format?: "avif" | "jpg" | "png" | "webp"
@ApiPropertyOptional({ description: "Форматы для family generation. Если не переданы, используются все форматы preset.", enum: ["avif", "webp", "jpg", "png"], isArray: true })
formats?: Array<"avif" | "jpg" | "png" | "webp">
@ApiPropertyOptional({ description: "Resize mode для custom transforms.", enum: ["fit", "fill"], example: "fill" })
resize?: "fill" | "fit"
}
export class CreateAssetVariantsResponseDto {
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
publicId!: string
@ApiProperty({ description: "Версия source image, для которой поставлены jobs.", example: 1 })
version!: number
@ApiProperty({ description: "Созданные или переиспользованные variants.", type: [AssetVariantResponseDto] })
variants!: AssetVariantResponseDto[]
}

View File

@@ -0,0 +1,36 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
export class CreateAssetRequestDto {
@ApiPropertyOptional({
description:
"Публичный стабильный идентификатор asset. Если не передан, Backend сгенерирует идентификатор автоматически.",
example: "asset_123",
})
publicId?: string
@ApiProperty({
description: "Постоянная ссылка на исходное изображение. Сейчас поддерживаются только публичные http/https URL.",
example: "https://storage.yandexcloud.net/shared1318/img/1.jpg",
})
sourceUrl!: string
}
export class CreateAssetResponseDto {
@ApiProperty({ description: "Внутренний UUID asset в PostgreSQL.", example: "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" })
id!: string
@ApiProperty({ description: "Публичный идентификатор asset для Gateway URL.", example: "asset_123" })
publicId!: string
@ApiProperty({ description: "Номер версии source image. Используется в URL как `v{version}`.", example: 1 })
version!: number
@ApiProperty({ description: "Нормализованный hostname исходного изображения.", example: "storage.yandexcloud.net" })
sourceHost!: string
@ApiProperty({
description: "Базовый путь Gateway для запроса variant. Width, quality и format передаются query params.",
example: "/images/asset_123/v1/card",
})
imageBasePath!: string
}

View File

@@ -0,0 +1,65 @@
import { BadRequestException } from "@nestjs/common"
import { isIP } from "node:net"
const LOCAL_HOSTNAMES = new Set(["localhost", "localhost.localdomain"])
export type NormalizedSourceUrl = {
hostname: string
sourceUrl: string
}
export function normalizeSourceUrl(input: string): NormalizedSourceUrl {
let url: URL
try {
url = new URL(input)
} catch {
throw new BadRequestException("sourceUrl must be a valid URL")
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new BadRequestException("sourceUrl must use http or https")
}
const hostname = url.hostname.toLowerCase().replace(/\.$/, "")
if (isPrivateOrLocalHostname(hostname)) {
throw new BadRequestException("sourceUrl host must be public")
}
url.hostname = hostname
url.hash = ""
return {
hostname,
sourceUrl: url.toString(),
}
}
function isPrivateOrLocalHostname(hostname: string) {
if (LOCAL_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
return true
}
const ipVersion = isIP(hostname)
if (ipVersion === 0) {
return false
}
if (ipVersion === 6) {
return true
}
const [a = 0, b = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10))
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
)
}

View File

@@ -1,9 +1,9 @@
import { ApiProperty } from "@nestjs/swagger"
export class HealthResponseDto {
@ApiProperty({ example: "image-platform-api" })
@ApiProperty({ description: "Имя сервиса, который вернул health-check ответ.", example: "image-platform-api" })
service!: string
@ApiProperty({ example: "ok" })
@ApiProperty({ description: "Текущее состояние сервиса.", example: "ok" })
status!: string
}

View File

@@ -7,8 +7,11 @@ import { HealthResponseDto } from "./health-response.dto"
@Controller("health")
export class HealthController {
@Get()
@ApiOperation({ summary: "Проверить состояние API" })
@ApiOkResponse({ type: HealthResponseDto })
@ApiOperation({
summary: "проверить состояние Backend API",
description: "Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests.",
})
@ApiOkResponse({ description: "Backend API доступен и отвечает.", type: HealthResponseDto })
getHealth(): HealthResponseDto {
return {
service: "image-platform-api",

View File

@@ -0,0 +1,14 @@
import { Injectable, OnModuleDestroy } from "@nestjs/common"
import { createDatabase, createDatabasePool } from "@image-platform/database"
import type { Database } from "@image-platform/database"
@Injectable()
export class DatabaseService implements OnModuleDestroy {
private readonly pool = createDatabasePool()
readonly db: Database = createDatabase(this.pool)
async onModuleDestroy() {
await this.pool.end()
}
}

View File

@@ -0,0 +1,52 @@
import { Injectable, OnModuleDestroy, OnModuleInit, ServiceUnavailableException } from "@nestjs/common"
import amqp, { type Channel, type ChannelModel } from "amqplib"
import { assertQueueTopology, loadQueueTopologyFromEnv, publishGenerateVariantJob } from "@image-platform/queue"
import { randomUUID } from "node:crypto"
@Injectable()
export class QueueService implements OnModuleDestroy, OnModuleInit {
private readonly rabbitmqUrl = getRequiredEnv("RABBITMQ_URL")
private readonly topology = loadQueueTopologyFromEnv()
private channel: Channel | null = null
private connection: ChannelModel | null = null
async onModuleInit() {
const connection = await amqp.connect(this.rabbitmqUrl)
const channel = await connection.createChannel()
await assertQueueTopology(channel, this.topology)
this.connection = connection
this.channel = channel
}
async onModuleDestroy() {
await this.channel?.close().catch((error: unknown) => console.error("failed to close RabbitMQ channel", error))
await this.connection?.close().catch((error: unknown) => console.error("failed to close RabbitMQ connection", error))
}
publishGenerateVariant(variantId: string) {
if (!this.channel) {
throw new ServiceUnavailableException("RabbitMQ channel is not ready")
}
const published = publishGenerateVariantJob(this.channel, this.topology, {
jobId: randomUUID(),
variantId,
})
if (!published) {
throw new ServiceUnavailableException("RabbitMQ publish buffer is full")
}
}
}
function getRequiredEnv(name: string) {
const value = process.env[name]
if (!value) {
throw new Error(`${name} is required`)
}
return value
}

View File

@@ -0,0 +1,14 @@
import { Injectable } from "@nestjs/common"
import { createS3Client, getObjectBuffer, loadStorageConfigFromEnv, type StoredObject } from "@image-platform/storage"
@Injectable()
export class StorageService {
private readonly config = loadStorageConfigFromEnv()
private readonly client = createS3Client(this.config)
readonly bucket = this.config.bucket
async getObject(key: string): Promise<StoredObject | null> {
return getObjectBuffer(this.client, this.bucket, key)
}
}

View File

@@ -1,21 +1,43 @@
import { ApiProperty } from "@nestjs/swagger"
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
import type { ActualImageFormat, RequestedImageFormat, ResizeMode } from "@image-platform/image-config"
export class EnsureImageVariantRequestDto {
@ApiProperty({ example: "asset_123" })
@ApiProperty({ description: "Публичный идентификатор asset из Gateway URL.", example: "asset_123" })
assetId!: string
@ApiProperty({ example: 4, minimum: 1 })
@ApiProperty({ description: "Версия source image из Gateway URL `v{version}`.", example: 4, minimum: 1 })
version!: number
@ApiProperty({ example: "card" })
@ApiProperty({ description: "Имя preset трансформации. Сейчас используется как часть variant key.", example: "card" })
preset!: string
@ApiProperty({ example: 640, minimum: 1 })
width!: number
@ApiPropertyOptional({ description: "Целевая ширина variant в пикселях. Обязательна для responsive presets и custom.", example: 640, minimum: 1 })
width?: number
@ApiProperty({ example: 80, minimum: 1 })
quality!: number
@ApiPropertyOptional({ description: "Целевая высота variant в пикселях. `0` или отсутствие означает auto height.", example: 420, minimum: 0 })
height?: number
@ApiProperty({ enum: ["auto", "avif", "webp", "jpg", "png"], example: "auto" })
format!: "auto" | "avif" | "jpg" | "png" | "webp"
@ApiPropertyOptional({ description: "Качество сжатия для imgproxy. Если не передано, берётся из preset.", example: 80, minimum: 1 })
quality?: number
@ApiPropertyOptional({
description: "Формат, который запросил клиент. Для `auto` Gateway выбирает фактический формат по `Accept` header.",
enum: ["auto", "avif", "webp", "jpg", "png"],
example: "auto",
})
requestedFormat?: RequestedImageFormat
@ApiPropertyOptional({
description: "Режим resize для custom transforms. Для обычных presets берётся из preset config.",
enum: ["fit", "fill"],
example: "fit",
})
resize?: ResizeMode
@ApiProperty({
description: "Фактический output format после negotiation. Именно этот формат попадает в S3 key и L1 cache key.",
enum: ["avif", "webp", "jpg", "png"],
example: "webp",
})
format!: ActualImageFormat
}

View File

@@ -1,19 +1,63 @@
import { Body, Controller, NotImplementedException, Post } from "@nestjs/common"
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"
import { Body, Controller, Header, Post, Res, StreamableFile } from "@nestjs/common"
import {
ApiBadGatewayResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiResponse,
ApiTags,
} from "@nestjs/swagger"
import type { Response } from "express"
import { EnsureImageVariantRequestDto } from "./ensure-image-variant.dto"
import { InternalImagesService } from "./internal-images.service"
@ApiTags("internal-images")
@Controller("internal/images")
export class InternalImagesController {
constructor(private readonly internalImages: InternalImagesService) {}
@Post("ensure")
@ApiOperation({ summary: "Ensure image variant for Gateway L1 miss" })
@ApiResponse({ status: 501, description: "Read-through image pipeline is not implemented yet" })
ensureImageVariant(@Body() request: EnsureImageVariantRequestDto): never {
throw new NotImplementedException({
message: "image read-through pipeline is not implemented yet",
request,
status: "not_implemented",
})
@ApiOperation({
summary: "подготовить variant изображения для Gateway",
description:
"Внутренний endpoint для Gateway. На L1 cache miss Backend проверяет PostgreSQL и S3, создаёт variant при необходимости, публикует RabbitMQ job, ждёт генерацию worker и возвращает готовые image bytes.",
})
@ApiOkResponse({
description: "Variant уже был готов в S3 или был успешно сгенерирован worker.",
content: {
"image/*": {
schema: {
format: "binary",
type: "string",
},
},
},
})
@ApiBadRequestResponse({ description: "Некорректный assetId, version, preset, width, quality или format." })
@ApiNotFoundResponse({ description: "Asset или указанная версия source image не найдены." })
@ApiBadGatewayResponse({ description: "Worker/imgproxy/S3 не смогли подготовить или вернуть variant." })
@ApiResponse({ status: 504, description: "Variant не успел сгенерироваться до истечения IMAGE_ENSURE_WAIT_MS." })
@Header("Cache-Control", "public, max-age=31536000, immutable")
async ensureImageVariant(
@Body() request: EnsureImageVariantRequestDto,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
const result = await this.internalImages.ensureImageVariant(request)
response.setHeader("Cache-Control", result.cacheControl)
response.setHeader("Content-Length", result.contentLength.toString())
response.setHeader("Content-Type", result.contentType)
if (result.etag) {
response.setHeader("ETag", result.etag)
}
if (result.vary) {
response.setHeader("Vary", result.vary)
}
return new StreamableFile(result.body)
}
}

View File

@@ -0,0 +1,326 @@
import {
BadGatewayException,
BadRequestException,
GatewayTimeoutException,
Injectable,
NotFoundException,
} from "@nestjs/common"
import { imageAssetVersions, imageAssets, imageVariants } from "@image-platform/database"
import {
ImageTransformConfigError,
normalizeImageTransform,
parseBooleanFlag,
type ActualImageFormat,
type NormalizedImageTransform,
type RequestedImageFormat,
} from "@image-platform/image-config"
import { buildVariantImageKey } from "@image-platform/storage"
import { and, eq } from "drizzle-orm"
import { createHash } from "node:crypto"
import { DatabaseService } from "../infra/database.service"
import { QueueService } from "../infra/queue.service"
import { StorageService } from "../infra/storage.service"
import type { EnsureImageVariantRequestDto } from "./ensure-image-variant.dto"
type NormalizedEnsureRequest = NormalizedImageTransform & {
assetId: string
version: number
}
type VariantRow = typeof imageVariants.$inferSelect
export type EnsuredImageVariant = {
body: Buffer
cacheControl: string
contentLength: number
contentType: string
etag: string | null
vary: string | null
}
@Injectable()
export class InternalImagesService {
private readonly ensureWaitMs = parsePositiveInteger(process.env.IMAGE_ENSURE_WAIT_MS, 15_000)
constructor(
private readonly database: DatabaseService,
private readonly queue: QueueService,
private readonly storage: StorageService,
) {}
async ensureImageVariant(request: EnsureImageVariantRequestDto): Promise<EnsuredImageVariant> {
const normalized = normalizeRequest(request)
const assetVersion = await this.loadAssetVersion(normalized)
let variant = await this.findOrCreateVariant(normalized, assetVersion)
if (variant.status === "ready") {
const ready = await this.loadReadyObject(variant, normalized.requestedFormat)
if (ready) {
return ready
}
variant = await this.markVariantPending(variant.id)
}
if (variant.status === "failed") {
variant = await this.markVariantPending(variant.id)
}
this.queue.publishGenerateVariant(variant.id)
const readyVariant = await this.waitForReadyVariant(variant.id)
const ready = await this.loadReadyObject(readyVariant, normalized.requestedFormat)
if (!ready) {
throw new BadGatewayException("variant was marked ready but S3 object is missing")
}
return ready
}
private async loadAssetVersion(request: NormalizedEnsureRequest) {
const [row] = await this.database.db
.select({
assetId: imageAssets.id,
assetStatus: imageAssets.status,
assetVersionId: imageAssetVersions.id,
})
.from(imageAssets)
.innerJoin(imageAssetVersions, eq(imageAssetVersions.assetId, imageAssets.id))
.where(and(eq(imageAssets.publicId, request.assetId), eq(imageAssetVersions.version, request.version)))
.limit(1)
if (!row || row.assetStatus !== "active") {
throw new NotFoundException("image asset version not found")
}
return row
}
private async findOrCreateVariant(
request: NormalizedEnsureRequest,
assetVersion: { assetId: string; assetVersionId: string },
): Promise<VariantRow> {
const existing = await this.findVariant(request, assetVersion.assetId)
if (existing) {
return existing
}
const variantHash = createVariantHash(request)
const s3Key = buildVariantImageKey({
assetId: request.assetId,
format: request.format,
variantHash,
version: request.version,
})
const [created] = await this.database.db
.insert(imageVariants)
.values({
assetId: assetVersion.assetId,
assetVersion: request.version,
assetVersionId: assetVersion.assetVersionId,
format: request.format,
height: request.height,
preset: request.preset,
quality: request.quality,
requestedFormat: request.requestedFormat,
resizeMode: request.resize,
s3Key,
status: "pending",
variantHash,
width: request.width,
})
.onConflictDoNothing({
target: [
imageVariants.assetId,
imageVariants.assetVersion,
imageVariants.preset,
imageVariants.width,
imageVariants.height,
imageVariants.resizeMode,
imageVariants.quality,
imageVariants.format,
],
})
.returning()
if (created) {
return created
}
const raced = await this.findVariant(request, assetVersion.assetId)
if (!raced) {
throw new Error("failed to create image variant")
}
return raced
}
private async findVariant(request: NormalizedEnsureRequest, assetId: string) {
const [variant] = await this.database.db
.select()
.from(imageVariants)
.where(
and(
eq(imageVariants.assetId, assetId),
eq(imageVariants.assetVersion, request.version),
eq(imageVariants.preset, request.preset),
eq(imageVariants.width, request.width),
eq(imageVariants.height, request.height),
eq(imageVariants.resizeMode, request.resize),
eq(imageVariants.quality, request.quality),
eq(imageVariants.format, request.format),
),
)
.limit(1)
return variant ?? null
}
private async markVariantPending(variantId: string) {
const [variant] = await this.database.db
.update(imageVariants)
.set({ error: null, status: "pending", updatedAt: new Date() })
.where(eq(imageVariants.id, variantId))
.returning()
if (!variant) {
throw new NotFoundException("image variant not found")
}
return variant
}
private async loadReadyObject(
variant: VariantRow,
requestedFormat: RequestedImageFormat,
): Promise<EnsuredImageVariant | null> {
const object = await this.storage.getObject(variant.s3Key)
if (!object) {
return null
}
await this.database.db
.update(imageVariants)
.set({ lastAccessedAt: new Date(), updatedAt: new Date() })
.where(eq(imageVariants.id, variant.id))
return {
body: object.body,
cacheControl: "public, max-age=31536000, immutable",
contentLength: object.contentLength ?? object.body.length,
contentType: object.contentType ?? contentTypeForFormat(variant.format),
etag: object.etag,
vary: requestedFormat === "auto" ? "Accept" : null,
}
}
private async waitForReadyVariant(variantId: string) {
const deadline = Date.now() + this.ensureWaitMs
while (Date.now() <= deadline) {
const [variant] = await this.database.db.select().from(imageVariants).where(eq(imageVariants.id, variantId)).limit(1)
if (!variant) {
throw new NotFoundException("image variant not found")
}
if (variant.status === "ready") {
return variant
}
if (variant.status === "failed") {
throw new BadGatewayException(variant.error ?? "image variant generation failed")
}
await sleep(200)
}
throw new GatewayTimeoutException("image variant generation timed out")
}
}
function normalizeRequest(request: EnsureImageVariantRequestDto): NormalizedEnsureRequest {
if (!/^[a-zA-Z0-9_-]{3,128}$/.test(request.assetId)) {
throw new BadRequestException("assetId is invalid")
}
if (!isPositiveInteger(request.version)) {
throw new BadRequestException("version must be a positive integer")
}
try {
const transform = normalizeImageTransform({
allowCustomTransforms: parseBooleanFlag(process.env.IMAGE_ALLOW_CUSTOM_TRANSFORMS, false),
format: request.format,
height: request.height,
preset: request.preset,
quality: request.quality,
requestedFormat: request.requestedFormat,
resize: request.resize,
width: request.width,
})
return {
...transform,
assetId: request.assetId,
version: request.version,
}
} catch (error) {
if (error instanceof ImageTransformConfigError) {
throw new BadRequestException(error.message)
}
throw error
}
}
function createVariantHash(request: NormalizedEnsureRequest) {
return createHash("sha256")
.update(
[
request.assetId,
request.version,
request.preset,
request.width,
request.height,
request.resize,
request.quality,
request.format,
].join(":"),
)
.digest("hex")
.slice(0, 32)
}
function contentTypeForFormat(format: ActualImageFormat) {
if (format === "jpg") {
return "image/jpeg"
}
return `image/${format}`
}
function isPositiveInteger(value: number) {
return Number.isSafeInteger(value) && value > 0
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
if (!value) {
return fallback
}
const parsed = Number.parseInt(value, 10)
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -11,13 +11,16 @@ async function bootstrap() {
const openApiConfig = new DocumentBuilder()
.setTitle("Image Platform API")
.setDescription("Control plane for image assets, variants, S3 storage and external imgproxy.")
.setDescription(
"Backend API для управления image assets, metadata в PostgreSQL, S3 variants, RabbitMQ jobs и генерацией через imgproxy.",
)
.setVersion("0.1.0")
.addTag("system")
.addTag("assets")
.addTag("variants")
.addTag("allowed-hosts")
.addTag("internal-images")
.addTag("system", "Системные endpoints для проверки состояния сервиса.")
.addTag("assets", "Регистрация и управление исходными изображениями.")
.addTag("variants", "Будущие endpoints для управления производными версиями изображений.")
.addTag("allowed-hosts", "Будущие endpoints для управления разрешёнными source hosts.")
.addTag("internal-images", "Внутренние endpoints, которые вызывает Gateway на cache miss.")
.addTag("presets", "Статические presets, custom limits и mock allowlist source hosts.")
.build()
const openApiDocument = SwaggerModule.createDocument(app, openApiConfig)
@@ -29,7 +32,7 @@ async function bootstrap() {
},
})
const port = Number.parseInt(process.env.API_PORT ?? "3001", 10)
const port = Number.parseInt(process.env.BACKEND_PORT ?? process.env.API_PORT ?? "3001", 10)
await app.listen(port)
}

View File

@@ -0,0 +1,49 @@
import { Controller, Get } from "@nestjs/common"
import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"
import {
CUSTOM_TRANSFORM_CONFIG,
IMAGE_PRESETS,
loadAllowedSourceHostsFromEnv,
parseBooleanFlag,
type ImagePreset,
} from "@image-platform/image-config"
import { PresetsResponseDto } from "./presets.dto"
@ApiTags("presets")
@Controller("presets")
export class PresetsController {
@Get()
@ApiOperation({
summary: "получить доступные presets и custom config",
description: "Возвращает статический config presets, custom transform limits и mock allowlist source hosts.",
})
@ApiOkResponse({ description: "Конфигурация presets возвращена.", type: PresetsResponseDto })
getPresets(): PresetsResponseDto {
return {
allowedSourceHosts: [...loadAllowedSourceHostsFromEnv()],
custom: {
enabled: parseBooleanFlag(process.env.IMAGE_ALLOW_CUSTOM_TRANSFORMS, false),
formats: CUSTOM_TRANSFORM_CONFIG.formats,
maxHeight: CUSTOM_TRANSFORM_CONFIG.maxHeight,
maxWidth: CUSTOM_TRANSFORM_CONFIG.maxWidth,
quality: CUSTOM_TRANSFORM_CONFIG.quality,
},
presets: Object.entries(IMAGE_PRESETS).map(([name, preset]) => {
const config: ImagePreset = preset
return {
formats: config.formats,
height: config.height,
mode: config.mode,
name,
qualities: config.qualities,
quality: config.quality,
resize: config.resize,
width: config.width,
widths: config.widths,
}
}),
}
}
}

View File

@@ -0,0 +1,58 @@
import { ApiProperty } from "@nestjs/swagger"
export class PresetResponseDto {
@ApiProperty({ description: "Имя preset.", example: "card" })
name!: string
@ApiProperty({ description: "Режим preset.", enum: ["fixed", "responsive"], example: "responsive" })
mode!: string
@ApiProperty({ description: "Разрешённые форматы.", example: ["avif", "webp", "jpg"] })
formats!: readonly string[]
@ApiProperty({ description: "Разрешённые значения quality.", example: [75, 80] })
qualities!: readonly number[]
@ApiProperty({ description: "Quality по умолчанию.", example: 80 })
quality!: number
@ApiProperty({ description: "Resize mode preset.", enum: ["fit", "fill"], example: "fit" })
resize!: string
@ApiProperty({ description: "Фиксированная ширина для fixed preset.", example: 256, required: false })
width?: number
@ApiProperty({ description: "Фиксированная высота для fixed preset.", example: 256, required: false })
height?: number
@ApiProperty({ description: "Разрешённые ширины для responsive preset.", example: [320, 640, 960], required: false })
widths?: readonly number[]
}
export class CustomTransformConfigResponseDto {
@ApiProperty({ description: "Включены ли custom transforms.", example: true })
enabled!: boolean
@ApiProperty({ description: "Разрешённые форматы custom transforms.", example: ["avif", "webp", "jpg", "png"] })
formats!: readonly string[]
@ApiProperty({ description: "Максимальная ширина custom transform.", example: 4096 })
maxWidth!: number
@ApiProperty({ description: "Максимальная высота custom transform.", example: 4096 })
maxHeight!: number
@ApiProperty({ description: "Quality по умолчанию для custom transform.", example: 80 })
quality!: number
}
export class PresetsResponseDto {
@ApiProperty({ description: "Static presets.", type: [PresetResponseDto] })
presets!: PresetResponseDto[]
@ApiProperty({ description: "Custom transform config.", type: CustomTransformConfigResponseDto })
custom!: CustomTransformConfigResponseDto
@ApiProperty({ description: "Mock allowlist source hosts.", example: ["storage.yandexcloud.net"] })
allowedSourceHosts!: string[]
}