feat: добавить новый backend и cabinet
- добавлен новый Nest backend для auth, projects и project access tokens - добавлена control-plane схема БД и миграция Drizzle - перенесён старый backend в old-backend - добавлен React/Vite cabinet с auth-only входом и Mantine layout - обновлены workspace scripts и lockfile
This commit is contained in:
@@ -10,14 +10,10 @@
|
||||
},
|
||||
"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",
|
||||
@@ -26,7 +22,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
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"
|
||||
import { ProjectsController } from "./projects/projects.controller"
|
||||
import { ProjectsService } from "./projects/projects.service"
|
||||
import { AuthModule } from "./auth/auth.module"
|
||||
import { HealthModule } from "./health/health.module"
|
||||
import { ProjectAccessTokensModule } from "./project-access-tokens/project-access-tokens.module"
|
||||
import { ProjectsModule } from "./projects/projects.module"
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController, AssetsController, InternalImagesController, PresetsController, ProjectsController],
|
||||
providers: [AssetsService, DatabaseService, InternalImagesService, ProjectsService, QueueService, StorageService],
|
||||
imports: [AuthModule, HealthModule, ProjectsModule, ProjectAccessTokensModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class AssetPictureImageResponseDto {
|
||||
@ApiProperty({ description: "Fallback image URL для `<img src>`.", example: "http://localhost:8888/images/asset_demo/v1/card?w=960&q=80&f=jpg" })
|
||||
src!: string
|
||||
|
||||
@ApiProperty({ description: "Fallback image format.", enum: ["avif", "webp", "jpg", "png"], example: "jpg" })
|
||||
format!: string
|
||||
|
||||
@ApiProperty({ description: "Fallback image Content-Type.", example: "image/jpeg" })
|
||||
type!: string
|
||||
|
||||
@ApiProperty({ description: "Fallback image width.", example: 960 })
|
||||
width!: number
|
||||
|
||||
@ApiProperty({ description: "Fallback image height. `0` означает auto height.", example: 0 })
|
||||
height!: number
|
||||
}
|
||||
|
||||
export class AssetPictureSourceResponseDto {
|
||||
@ApiProperty({ description: "Source format.", enum: ["avif", "webp", "jpg", "png"], example: "webp" })
|
||||
format!: string
|
||||
|
||||
@ApiProperty({ description: "Source MIME type для `<source type>`.", example: "image/webp" })
|
||||
type!: string
|
||||
|
||||
@ApiProperty({ description: "Готовая строка srcset с width descriptors." })
|
||||
srcSet!: string
|
||||
}
|
||||
|
||||
export class AssetPictureResponseDto {
|
||||
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
|
||||
publicId!: string
|
||||
|
||||
@ApiProperty({ description: "Preset, для которого построен picture contract.", example: "card" })
|
||||
preset!: string
|
||||
|
||||
@ApiProperty({ description: "Версия source image.", example: 1 })
|
||||
version!: number
|
||||
|
||||
@ApiProperty({ description: "Quality для всех URL.", example: 80 })
|
||||
quality!: number
|
||||
|
||||
@ApiProperty({ description: "Значение sizes для `<img sizes>`.", example: "100vw" })
|
||||
sizes!: string
|
||||
|
||||
@ApiProperty({ description: "Width descriptors, вошедшие в srcset.", example: [320, 640, 960] })
|
||||
widths!: number[]
|
||||
|
||||
@ApiProperty({ description: "Fallback image для `<img>`.", type: AssetPictureImageResponseDto })
|
||||
image!: AssetPictureImageResponseDto
|
||||
|
||||
@ApiProperty({ description: "Sources для `<picture>`.", type: [AssetPictureSourceResponseDto] })
|
||||
sources!: AssetPictureSourceResponseDto[]
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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 AssetVersionResponseDto {
|
||||
@ApiProperty({ description: "Внутренний UUID версии source image.", example: "3b5da974-bb7f-4d73-b172-d6ad9c244528" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "Номер версии source image.", example: 2 })
|
||||
version!: number
|
||||
|
||||
@ApiProperty({ description: "Является ли версия текущей для asset.", example: true })
|
||||
isCurrent!: boolean
|
||||
|
||||
@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: "Базовый Gateway path для версии.", example: "/images/asset_demo/v2/card" })
|
||||
imageBasePath!: string
|
||||
|
||||
@ApiPropertyOptional({ description: "Ширина оригинального изображения, если уже определена Worker.", example: 1200, nullable: true, type: Number })
|
||||
width!: number | null
|
||||
|
||||
@ApiPropertyOptional({ description: "Высота оригинального изображения, если уже определена Worker.", example: 800, nullable: true, type: Number })
|
||||
height!: number | null
|
||||
|
||||
@ApiPropertyOptional({ description: "Content-Type оригинального изображения, если уже определён Worker.", example: "image/jpeg", nullable: true, type: String })
|
||||
contentType!: string | null
|
||||
|
||||
@ApiPropertyOptional({ description: "Размер оригинального изображения в bytes, если уже определён Worker.", example: 245760, nullable: true, type: Number })
|
||||
sizeBytes!: number | null
|
||||
|
||||
@ApiProperty({ description: "Дата создания версии.", example: "2026-05-05T12:00:00.000Z" })
|
||||
createdAt!: string
|
||||
}
|
||||
|
||||
export class AssetVersionsResponseDto {
|
||||
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
|
||||
publicId!: string
|
||||
|
||||
@ApiProperty({ description: "Текущая версия source image.", example: 2 })
|
||||
currentVersion!: number
|
||||
|
||||
@ApiProperty({ description: "История версий source image.", type: [AssetVersionResponseDto] })
|
||||
versions!: AssetVersionResponseDto[]
|
||||
}
|
||||
|
||||
export class AssetsListResponseDto {
|
||||
@ApiProperty({ description: "Список assets.", type: [AssetResponseDto] })
|
||||
assets!: AssetResponseDto[]
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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 { AssetPictureResponseDto } from "./asset-picture-response.dto"
|
||||
import { AssetResponseDto, AssetVariantsResponseDto, AssetVersionsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
|
||||
import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.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/versions")
|
||||
@ApiOperation({
|
||||
summary: "получить историю версий source image",
|
||||
description:
|
||||
"Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
|
||||
@ApiOkResponse({ description: "История версий source image возвращена.", type: AssetVersionsResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Asset не найден." })
|
||||
listAssetVersions(@Param("publicId") publicId: string): Promise<AssetVersionsResponseDto> {
|
||||
return this.assets.listAssetVersions(publicId)
|
||||
}
|
||||
|
||||
@Post(":publicId/versions")
|
||||
@ApiOperation({
|
||||
summary: "создать новую версию source image",
|
||||
description:
|
||||
"Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
|
||||
@ApiCreatedResponse({ description: "Новая версия source image создана и стала текущей.", type: CreateAssetVersionResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректный sourceUrl или source host запрещён настройками." })
|
||||
@ApiConflictResponse({ description: "Версия asset изменилась конкурентно." })
|
||||
@ApiNotFoundResponse({ description: "Asset не найден." })
|
||||
createAssetVersion(
|
||||
@Param("publicId") publicId: string,
|
||||
@Body() request: CreateAssetVersionRequestDto,
|
||||
): Promise<CreateAssetVersionResponseDto> {
|
||||
return this.assets.createAssetVersion(publicId, request)
|
||||
}
|
||||
|
||||
@Get(":publicId/picture")
|
||||
@ApiOperation({
|
||||
summary: "получить picture/srcset URLs",
|
||||
description:
|
||||
"Возвращает готовый контракт для `<picture>` и `<img>` по static preset: sources, srcset, fallback src, sizes и versioned Gateway URLs. Endpoint не ставит generation jobs: Gateway сгенерирует bytes lazy или отдаст cache.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
|
||||
@ApiQuery({ description: "Static preset для picture contract.", example: "card", name: "preset", required: true })
|
||||
@ApiQuery({ description: "Версия source image. Если не передана, используется currentVersion asset.", example: 1, name: "version", required: false })
|
||||
@ApiQuery({ description: "Quality. Если не передано, берётся default quality preset.", example: 80, name: "quality", required: false })
|
||||
@ApiQuery({ description: "Значение для HTML `sizes`.", example: "(min-width: 768px) 50vw, 100vw", name: "sizes", required: false })
|
||||
@ApiOkResponse({ description: "Picture/srcset contract возвращён.", type: AssetPictureResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректный preset, version, quality или sizes." })
|
||||
@ApiNotFoundResponse({ description: "Asset или version не найдены." })
|
||||
getAssetPicture(
|
||||
@Param("publicId") publicId: string,
|
||||
@Query("preset") preset?: string,
|
||||
@Query("version") version?: string,
|
||||
@Query("quality") quality?: string,
|
||||
@Query("sizes") sizes?: string,
|
||||
): Promise<AssetPictureResponseDto> {
|
||||
return this.assets.getAssetPicture(publicId, { preset, quality, sizes, version })
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -1,966 +0,0 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
|
||||
import { imageAssets, imageAssetVersions, imageProjects, 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 { normalizeProjectSlug } from "../projects/project-slug"
|
||||
import type { AssetPictureResponseDto } from "./asset-picture-response.dto"
|
||||
import type {
|
||||
AssetResponseDto,
|
||||
AssetVariantResponseDto,
|
||||
AssetVariantsResponseDto,
|
||||
AssetVersionsResponseDto,
|
||||
AssetsListResponseDto,
|
||||
} from "./asset-response.dto"
|
||||
import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.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
|
||||
}
|
||||
|
||||
type PublicImageUrlInput = {
|
||||
assetVersion: number
|
||||
format: ActualImageFormat
|
||||
height: number | null
|
||||
preset: string
|
||||
quality: number
|
||||
resizeMode: "fill" | "fit"
|
||||
width: 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; projectSlug?: string }): Promise<AssetsListResponseDto> {
|
||||
const limit = parsePaginationInteger(input.limit, 50, 1, 100)
|
||||
const offset = parsePaginationInteger(input.offset, 0, 0, 10_000)
|
||||
|
||||
if (input.projectSlug) {
|
||||
const project = await this.loadProject(input.projectSlug)
|
||||
|
||||
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)),
|
||||
)
|
||||
.where(eq(imageAssets.projectId, project.id))
|
||||
.orderBy(desc(imageAssets.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return {
|
||||
assets: rows.map(mapAssetResponse),
|
||||
}
|
||||
}
|
||||
|
||||
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, projectSlug?: string): Promise<CreateAssetResponseDto> {
|
||||
const source = normalizeSourceUrl(request.sourceUrl)
|
||||
await this.assertAllowedHost(source.hostname)
|
||||
|
||||
const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId()
|
||||
const sourceHash = createSourceHash(source.sourceUrl)
|
||||
const project = projectSlug ? await this.loadProject(projectSlug) : null
|
||||
|
||||
try {
|
||||
const result = await this.database.db.transaction(async (tx) => {
|
||||
const [asset] = await tx
|
||||
.insert(imageAssets)
|
||||
.values(project ? { projectId: project.id, publicId } : { 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 listAssetVersions(publicId: string): Promise<AssetVersionsResponseDto> {
|
||||
const normalizedPublicId = normalizePublicId(publicId)
|
||||
const [asset] = await this.database.db
|
||||
.select({
|
||||
currentVersion: imageAssets.currentVersion,
|
||||
id: imageAssets.id,
|
||||
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 versions = await this.database.db
|
||||
.select({
|
||||
contentType: imageAssetVersions.contentType,
|
||||
createdAt: imageAssetVersions.createdAt,
|
||||
height: imageAssetVersions.height,
|
||||
id: imageAssetVersions.id,
|
||||
sizeBytes: imageAssetVersions.sizeBytes,
|
||||
sourceHost: imageAssetVersions.sourceHost,
|
||||
sourceUrl: imageAssetVersions.sourceUrl,
|
||||
version: imageAssetVersions.version,
|
||||
width: imageAssetVersions.width,
|
||||
})
|
||||
.from(imageAssetVersions)
|
||||
.where(eq(imageAssetVersions.assetId, asset.id))
|
||||
.orderBy(desc(imageAssetVersions.version))
|
||||
|
||||
return {
|
||||
currentVersion: asset.currentVersion,
|
||||
publicId: asset.publicId,
|
||||
versions: versions.map((version) => ({
|
||||
contentType: version.contentType,
|
||||
createdAt: version.createdAt.toISOString(),
|
||||
height: version.height,
|
||||
id: version.id,
|
||||
imageBasePath: `/images/${asset.publicId}/v${version.version}/card`,
|
||||
isCurrent: version.version === asset.currentVersion,
|
||||
sizeBytes: version.sizeBytes,
|
||||
sourceHost: version.sourceHost,
|
||||
sourceUrl: version.sourceUrl,
|
||||
version: version.version,
|
||||
width: version.width,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async createAssetVersion(
|
||||
publicId: string,
|
||||
request: CreateAssetVersionRequestDto,
|
||||
): Promise<CreateAssetVersionResponseDto> {
|
||||
const normalizedPublicId = normalizePublicId(publicId)
|
||||
const source = normalizeSourceUrl(request.sourceUrl)
|
||||
await this.assertAllowedHost(source.hostname)
|
||||
|
||||
try {
|
||||
const result = await this.database.db.transaction(async (tx) => {
|
||||
const [asset] = await tx
|
||||
.select({
|
||||
currentVersion: imageAssets.currentVersion,
|
||||
id: imageAssets.id,
|
||||
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 previousVersion = asset.currentVersion
|
||||
const nextVersion = previousVersion + 1
|
||||
const [version] = await tx
|
||||
.insert(imageAssetVersions)
|
||||
.values({
|
||||
assetId: asset.id,
|
||||
sourceHash: createSourceHash(source.sourceUrl),
|
||||
sourceHost: source.hostname,
|
||||
sourceUrl: source.sourceUrl,
|
||||
version: nextVersion,
|
||||
})
|
||||
.returning({
|
||||
createdAt: imageAssetVersions.createdAt,
|
||||
id: imageAssetVersions.id,
|
||||
sourceHost: imageAssetVersions.sourceHost,
|
||||
sourceUrl: imageAssetVersions.sourceUrl,
|
||||
version: imageAssetVersions.version,
|
||||
})
|
||||
|
||||
if (!version) {
|
||||
throw new Error("failed to create image asset version")
|
||||
}
|
||||
|
||||
const [updatedAsset] = await tx
|
||||
.update(imageAssets)
|
||||
.set({ currentVersion: nextVersion, updatedAt: new Date() })
|
||||
.where(and(eq(imageAssets.id, asset.id), eq(imageAssets.currentVersion, previousVersion)))
|
||||
.returning({ currentVersion: imageAssets.currentVersion })
|
||||
|
||||
if (!updatedAsset) {
|
||||
throw new ConflictException("asset version changed concurrently")
|
||||
}
|
||||
|
||||
return { asset, previousVersion, version }
|
||||
})
|
||||
|
||||
return {
|
||||
assetId: result.asset.id,
|
||||
createdAt: result.version.createdAt.toISOString(),
|
||||
imageBasePath: `/images/${result.asset.publicId}/v${result.version.version}/card`,
|
||||
previousVersion: result.previousVersion,
|
||||
publicId: result.asset.publicId,
|
||||
sourceHost: result.version.sourceHost,
|
||||
sourceUrl: result.version.sourceUrl,
|
||||
version: result.version.version,
|
||||
versionId: result.version.id,
|
||||
}
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException("asset version already exists")
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetPicture(
|
||||
publicId: string,
|
||||
input: { preset?: string; quality?: string; sizes?: string; version?: string },
|
||||
): Promise<AssetPictureResponseDto> {
|
||||
if (!input.preset) {
|
||||
throw new BadRequestException("preset is required")
|
||||
}
|
||||
|
||||
const preset = getImagePreset(input.preset)
|
||||
|
||||
if (!preset || input.preset === CUSTOM_PRESET_NAME) {
|
||||
throw new BadRequestException("picture endpoint supports only static presets")
|
||||
}
|
||||
|
||||
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(input.version))
|
||||
const quality = parseOptionalQuality(input.quality)
|
||||
const sizes = normalizeSizes(input.sizes)
|
||||
const widths = getPresetWidths(input.preset, preset)
|
||||
const sources = preset.formats.map((format) => {
|
||||
const srcSet = widths
|
||||
.map((width) => {
|
||||
const transform = this.normalizeTransform({
|
||||
format,
|
||||
preset: input.preset!,
|
||||
quality,
|
||||
version: asset.version,
|
||||
width: preset.mode === "fixed" ? undefined : width,
|
||||
})
|
||||
|
||||
return `${buildPublicImageUrl(this.publicImageBaseUrl, asset.publicId, transformToPublicUrlInput(transform))} ${transform.width}w`
|
||||
})
|
||||
.join(", ")
|
||||
|
||||
return {
|
||||
format,
|
||||
srcSet,
|
||||
type: contentTypeForFormat(format),
|
||||
}
|
||||
})
|
||||
const fallbackFormat = selectFallbackFormat(preset.formats)
|
||||
const fallbackWidth = widths[widths.length - 1]
|
||||
|
||||
if (fallbackWidth === undefined) {
|
||||
throw new BadRequestException(`preset ${input.preset} has no widths configured`)
|
||||
}
|
||||
|
||||
const fallbackTransform = this.normalizeTransform({
|
||||
format: fallbackFormat,
|
||||
preset: input.preset,
|
||||
quality,
|
||||
version: asset.version,
|
||||
width: preset.mode === "fixed" ? undefined : fallbackWidth,
|
||||
})
|
||||
|
||||
return {
|
||||
image: {
|
||||
format: fallbackTransform.format,
|
||||
height: fallbackTransform.height,
|
||||
src: buildPublicImageUrl(this.publicImageBaseUrl, asset.publicId, transformToPublicUrlInput(fallbackTransform)),
|
||||
type: contentTypeForFormat(fallbackTransform.format),
|
||||
width: fallbackTransform.width,
|
||||
},
|
||||
preset: input.preset,
|
||||
publicId: asset.publicId,
|
||||
quality: fallbackTransform.quality,
|
||||
sizes,
|
||||
sources,
|
||||
version: asset.version,
|
||||
widths,
|
||||
}
|
||||
}
|
||||
|
||||
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 loadProject(projectSlug: string) {
|
||||
const normalizedSlug = normalizeProjectSlug(projectSlug)
|
||||
const [project] = await this.database.db
|
||||
.select({ id: imageProjects.id, status: imageProjects.status })
|
||||
.from(imageProjects)
|
||||
.where(eq(imageProjects.slug, normalizedSlug))
|
||||
.limit(1)
|
||||
|
||||
if (!project || project.status !== "active") {
|
||||
throw new NotFoundException("project not found")
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
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 createSourceHash(sourceUrl: string) {
|
||||
return createHash("sha256").update(sourceUrl).digest("hex")
|
||||
}
|
||||
|
||||
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 parseOptionalQuality(value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new BadRequestException("quality must be a positive integer")
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 100) {
|
||||
throw new BadRequestException("quality must be between 1 and 100")
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeSizes(value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
return "100vw"
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
|
||||
if (!normalized || normalized.length > 256 || /[<>]/.test(normalized)) {
|
||||
throw new BadRequestException("sizes is invalid")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
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 getPresetWidths(presetName: string, preset: NonNullable<ReturnType<typeof getImagePreset>>) {
|
||||
if (preset.mode === "fixed") {
|
||||
if (!preset.width) {
|
||||
throw new BadRequestException(`preset ${presetName} has no width configured`)
|
||||
}
|
||||
|
||||
return [preset.width]
|
||||
}
|
||||
|
||||
if (!preset.widths?.length) {
|
||||
throw new BadRequestException(`preset ${presetName} has no widths configured`)
|
||||
}
|
||||
|
||||
return [...preset.widths]
|
||||
}
|
||||
|
||||
function selectFallbackFormat(formats: readonly ActualImageFormat[]) {
|
||||
if (formats.includes("jpg")) {
|
||||
return "jpg"
|
||||
}
|
||||
|
||||
const format = formats[formats.length - 1]
|
||||
|
||||
if (!format) {
|
||||
throw new BadRequestException("preset has no formats configured")
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
function transformToPublicUrlInput(transform: VariantTransform): PublicImageUrlInput {
|
||||
return {
|
||||
assetVersion: transform.version,
|
||||
format: transform.format,
|
||||
height: transform.height,
|
||||
preset: transform.preset,
|
||||
quality: transform.quality,
|
||||
resizeMode: transform.resize,
|
||||
width: transform.width,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPublicImageUrl(baseUrl: string, publicId: string, variant: PublicImageUrlInput) {
|
||||
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()
|
||||
}
|
||||
|
||||
function contentTypeForFormat(format: ActualImageFormat) {
|
||||
if (format === "jpg") {
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
return `image/${format}`
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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[]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class CreateAssetVersionRequestDto {
|
||||
@ApiProperty({
|
||||
description: "Постоянная ссылка на новую версию исходного изображения.",
|
||||
example: "https://storage.yandexcloud.net/shared1318/img/1.jpg",
|
||||
})
|
||||
sourceUrl!: string
|
||||
}
|
||||
|
||||
export class CreateAssetVersionResponseDto {
|
||||
@ApiProperty({ description: "Внутренний UUID asset.", example: "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" })
|
||||
assetId!: string
|
||||
|
||||
@ApiProperty({ description: "Внутренний UUID новой версии source image.", example: "3b5da974-bb7f-4d73-b172-d6ad9c244528" })
|
||||
versionId!: string
|
||||
|
||||
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
|
||||
publicId!: string
|
||||
|
||||
@ApiProperty({ description: "Предыдущая активная версия source image.", example: 1 })
|
||||
previousVersion!: number
|
||||
|
||||
@ApiProperty({ description: "Новая активная версия source image.", example: 2 })
|
||||
version!: number
|
||||
|
||||
@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: "Базовый Gateway path для новой версии.", example: "/images/asset_demo/v2/card" })
|
||||
imageBasePath!: string
|
||||
|
||||
@ApiProperty({ description: "Дата создания версии.", example: "2026-05-05T12:00:00.000Z" })
|
||||
createdAt!: string
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
41
apps/backend/src/auth/auth.controller.ts
Normal file
41
apps/backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from "@nestjs/common"
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger"
|
||||
|
||||
import { AuthService } from "./auth.service"
|
||||
import { CurrentAdmin } from "./decorators/current-admin.decorator"
|
||||
import { LoginRequestDto, LoginResponseDto } from "./dto/login.dto"
|
||||
import { AdminSessionResponseDto } from "./dto/session.dto"
|
||||
import { AdminAuthGuard } from "./guards/admin-auth.guard"
|
||||
import type { AdminSession } from "./types/authenticated-request.type"
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@ApiOperation({
|
||||
summary: "войти как администратор",
|
||||
description: "Проверяет фиксированную пару логин/пароль. По умолчанию используется admin/admin без публичной регистрации.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Администратор авторизован, сессионный токен выдан.", type: LoginResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Логин или пароль неверны." })
|
||||
login(@Body() request: LoginRequestDto): LoginResponseDto {
|
||||
return this.auth.login(request)
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "получить текущую admin-сессию",
|
||||
description: "Возвращает данные администратора из Bearer token.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Admin-сессия активна.", type: AdminSessionResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
getMe(@CurrentAdmin() admin: AdminSession): AdminSessionResponseDto {
|
||||
return {
|
||||
username: admin.username,
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/backend/src/auth/auth.module.ts
Normal file
14
apps/backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { DatabaseModule } from "../infra/database.module"
|
||||
import { AuthController } from "./auth.controller"
|
||||
import { AuthService } from "./auth.service"
|
||||
import { AdminAuthGuard } from "./guards/admin-auth.guard"
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
exports: [AdminAuthGuard, AuthService],
|
||||
imports: [DatabaseModule],
|
||||
providers: [AdminAuthGuard, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
132
apps/backend/src/auth/auth.service.ts
Normal file
132
apps/backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common"
|
||||
import { createHmac, timingSafeEqual } from "node:crypto"
|
||||
|
||||
import type { AdminSession } from "./types/authenticated-request.type"
|
||||
|
||||
type AdminTokenPayload = {
|
||||
exp: number
|
||||
username: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly adminPassword = process.env.ADMIN_PASSWORD ?? "admin"
|
||||
private readonly adminTokenTtlSeconds = parsePositiveInteger(process.env.ADMIN_TOKEN_TTL_SECONDS, 86_400)
|
||||
private readonly adminUsername = process.env.ADMIN_USERNAME ?? "admin"
|
||||
private readonly secret = process.env.ADMIN_AUTH_SECRET ?? "development-admin-auth-secret"
|
||||
|
||||
login(input: { password: string; username: string }) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input.username !== "string" ||
|
||||
typeof input.password !== "string" ||
|
||||
input.username !== this.adminUsername ||
|
||||
input.password !== this.adminPassword
|
||||
) {
|
||||
throw new UnauthorizedException("invalid admin credentials")
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + this.adminTokenTtlSeconds * 1000)
|
||||
const accessToken = this.signAdminToken({
|
||||
exp: Math.floor(expiresAt.getTime() / 1000),
|
||||
username: input.username,
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
tokenType: "Bearer" as const,
|
||||
}
|
||||
}
|
||||
|
||||
verifyAdminToken(token: string): AdminSession {
|
||||
const parts = token.split(".")
|
||||
|
||||
if (parts.length !== 3 || parts[0] !== "admin") {
|
||||
throw new UnauthorizedException("invalid admin token")
|
||||
}
|
||||
|
||||
const [, payloadPart, signature] = parts
|
||||
const expectedSignature = this.sign(payloadPart)
|
||||
|
||||
if (!safeEqual(signature, expectedSignature)) {
|
||||
throw new UnauthorizedException("invalid admin token")
|
||||
}
|
||||
|
||||
const payload = parsePayload(payloadPart)
|
||||
|
||||
if (payload.exp <= Math.floor(Date.now() / 1000)) {
|
||||
throw new UnauthorizedException("admin token expired")
|
||||
}
|
||||
|
||||
return {
|
||||
username: payload.username,
|
||||
}
|
||||
}
|
||||
|
||||
private signAdminToken(payload: AdminTokenPayload) {
|
||||
const payloadPart = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")
|
||||
const signature = this.sign(payloadPart)
|
||||
|
||||
return `admin.${payloadPart}.${signature}`
|
||||
}
|
||||
|
||||
private sign(payloadPart: string) {
|
||||
return createHmac("sha256", this.secret).update(payloadPart).digest("base64url")
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayload(payloadPart: string): AdminTokenPayload {
|
||||
try {
|
||||
const value = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8")) as unknown
|
||||
|
||||
if (!isPayload(value)) {
|
||||
throw new Error("invalid payload")
|
||||
}
|
||||
|
||||
return value
|
||||
} catch {
|
||||
throw new UnauthorizedException("invalid admin token")
|
||||
}
|
||||
}
|
||||
|
||||
function isPayload(value: unknown): value is AdminTokenPayload {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"exp" in value &&
|
||||
"username" in value &&
|
||||
typeof value.exp === "number" &&
|
||||
typeof value.username === "string" &&
|
||||
value.username.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function safeEqual(left: string | undefined, right: string) {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
13
apps/backend/src/auth/decorators/current-admin.decorator.ts
Normal file
13
apps/backend/src/auth/decorators/current-admin.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
|
||||
|
||||
import type { AdminSession, AuthenticatedRequest } from "../types/authenticated-request.type"
|
||||
|
||||
export const CurrentAdmin = createParamDecorator((_data: unknown, context: ExecutionContext): AdminSession => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
|
||||
if (!request.adminSession) {
|
||||
throw new Error("admin session is missing in request context")
|
||||
}
|
||||
|
||||
return request.adminSession
|
||||
})
|
||||
20
apps/backend/src/auth/dto/login.dto.ts
Normal file
20
apps/backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class LoginRequestDto {
|
||||
@ApiProperty({ description: "Логин администратора.", example: "admin" })
|
||||
username!: string
|
||||
|
||||
@ApiProperty({ description: "Пароль администратора.", example: "admin" })
|
||||
password!: string
|
||||
}
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty({ description: "Bearer token сессии администратора.", example: "admin.eyJ1c2VybmFtZSI6ImFkbWluIn0.signature" })
|
||||
accessToken!: string
|
||||
|
||||
@ApiProperty({ description: "Тип токена для HTTP Authorization header.", example: "Bearer" })
|
||||
tokenType!: "Bearer"
|
||||
|
||||
@ApiProperty({ description: "ISO-дата истечения сессии.", example: "2026-05-13T10:00:00.000Z" })
|
||||
expiresAt!: string
|
||||
}
|
||||
6
apps/backend/src/auth/dto/session.dto.ts
Normal file
6
apps/backend/src/auth/dto/session.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class AdminSessionResponseDto {
|
||||
@ApiProperty({ description: "Логин текущего администратора.", example: "admin" })
|
||||
username!: string
|
||||
}
|
||||
32
apps/backend/src/auth/guards/admin-auth.guard.ts
Normal file
32
apps/backend/src/auth/guards/admin-auth.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"
|
||||
|
||||
import { AuthService } from "../auth.service"
|
||||
import type { AuthenticatedRequest } from "../types/authenticated-request.type"
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
const token = readBearerToken(request.headers.authorization)
|
||||
|
||||
request.adminSession = this.auth.verifyAdminToken(token)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function readBearerToken(header: string | undefined): string {
|
||||
if (!header) {
|
||||
throw new UnauthorizedException("authorization header is required")
|
||||
}
|
||||
|
||||
const [type, token] = header.split(" ")
|
||||
|
||||
if (type !== "Bearer" || !token) {
|
||||
throw new UnauthorizedException("bearer token is required")
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
17
apps/backend/src/auth/types/authenticated-request.type.ts
Normal file
17
apps/backend/src/auth/types/authenticated-request.type.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||
import type { Request } from "express"
|
||||
|
||||
export type AdminSession = {
|
||||
username: string
|
||||
}
|
||||
|
||||
export type ProjectAccess = {
|
||||
projectId: string
|
||||
scopes: ProjectAccessTokenScope[]
|
||||
tokenId: string
|
||||
}
|
||||
|
||||
export type AuthenticatedRequest = Request & {
|
||||
adminSession?: AdminSession
|
||||
projectAccess?: ProjectAccess
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class HealthResponseDto {
|
||||
@ApiProperty({ description: "Имя сервиса, который вернул health-check ответ.", example: "image-platform-api" })
|
||||
service!: string
|
||||
@ApiProperty({ description: "Текущий статус backend.", example: "ok" })
|
||||
status!: "ok"
|
||||
|
||||
@ApiProperty({ description: "Текущее состояние сервиса.", example: "ok" })
|
||||
status!: string
|
||||
@ApiProperty({ description: "Название сервиса.", example: "backend" })
|
||||
service!: string
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import { HealthResponseDto } from "./health-response.dto"
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "проверить состояние Backend API",
|
||||
description: "Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests.",
|
||||
summary: "проверить состояние backend",
|
||||
description: "Возвращает простой health-check для runtime и инфраструктурных проверок.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Backend API доступен и отвечает.", type: HealthResponseDto })
|
||||
@ApiOkResponse({ description: "Backend отвечает на запросы.", type: HealthResponseDto })
|
||||
getHealth(): HealthResponseDto {
|
||||
return {
|
||||
service: "image-platform-api",
|
||||
service: "backend",
|
||||
status: "ok",
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/backend/src/health/health.module.ts
Normal file
8
apps/backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { HealthController } from "./health.controller"
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
10
apps/backend/src/infra/database.module.ts
Normal file
10
apps/backend/src/infra/database.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from "@nestjs/common"
|
||||
|
||||
import { DatabaseService } from "./database.service"
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
exports: [DatabaseService],
|
||||
providers: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common"
|
||||
import { createDatabase, createDatabasePool } from "@image-platform/database"
|
||||
import type { Database } from "@image-platform/database"
|
||||
import { createDatabase, createDatabasePool, type Database, type DatabasePool } from "@image-platform/database"
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleDestroy {
|
||||
private readonly pool = createDatabasePool()
|
||||
|
||||
readonly pool: DatabasePool = createDatabasePool()
|
||||
readonly db: Database = createDatabase(this.pool)
|
||||
|
||||
async onModuleDestroy() {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
|
||||
import type { ActualImageFormat, RequestedImageFormat, ResizeMode } from "@image-platform/image-config"
|
||||
|
||||
export class EnsureImageVariantRequestDto {
|
||||
@ApiProperty({ description: "Публичный идентификатор asset из Gateway URL.", example: "asset_123" })
|
||||
assetId!: string
|
||||
|
||||
@ApiProperty({ description: "Версия source image из Gateway URL `v{version}`.", example: 4, minimum: 1 })
|
||||
version!: number
|
||||
|
||||
@ApiProperty({ description: "Имя preset трансформации. Сейчас используется как часть variant key.", example: "card" })
|
||||
preset!: string
|
||||
|
||||
@ApiPropertyOptional({ description: "Целевая ширина variant в пикселях. Обязательна для responsive presets и custom.", example: 640, minimum: 1 })
|
||||
width?: number
|
||||
|
||||
@ApiPropertyOptional({ description: "Целевая высота variant в пикселях. `0` или отсутствие означает auto height.", example: 420, minimum: 0 })
|
||||
height?: number
|
||||
|
||||
@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
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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: "подготовить 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)
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -10,17 +10,33 @@ async function bootstrap() {
|
||||
app.enableShutdownHooks()
|
||||
|
||||
const openApiConfig = new DocumentBuilder()
|
||||
.setTitle("Image Platform API")
|
||||
.setTitle("Assets Delivery Platform API")
|
||||
.setDescription(
|
||||
"Backend API для управления image assets, metadata в PostgreSQL, S3 variants, RabbitMQ jobs и генерацией через imgproxy.",
|
||||
"Control plane API для авторизации, проектов и токенов доступа. Image-модуль будет переноситься из old-backend отдельным vertical slice.",
|
||||
)
|
||||
.setVersion("0.1.0")
|
||||
.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.")
|
||||
.addBearerAuth(
|
||||
{
|
||||
bearerFormat: "Admin session token",
|
||||
description: "Токен сессии администратора, полученный через /api/auth/login.",
|
||||
scheme: "bearer",
|
||||
type: "http",
|
||||
},
|
||||
"adminAuth",
|
||||
)
|
||||
.addBearerAuth(
|
||||
{
|
||||
bearerFormat: "Project access token",
|
||||
description: "Токен доступа к проекту для headless API.",
|
||||
scheme: "bearer",
|
||||
type: "http",
|
||||
},
|
||||
"projectAccessToken",
|
||||
)
|
||||
.addTag("system", "Системные endpoints для проверки состояния backend.")
|
||||
.addTag("auth", "Авторизация администратора без публичной регистрации.")
|
||||
.addTag("projects", "Control plane для управления проектами.")
|
||||
.addTag("project-access-tokens", "Токены доступа к проектам для server-side интеграций.")
|
||||
.build()
|
||||
|
||||
const openApiDocument = SwaggerModule.createDocument(app, openApiConfig)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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[]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
|
||||
|
||||
import type { AuthenticatedRequest, ProjectAccess } from "../../auth/types/authenticated-request.type"
|
||||
|
||||
export const CurrentProjectAccess = createParamDecorator((_data: unknown, context: ExecutionContext): ProjectAccess => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
|
||||
if (!request.projectAccess) {
|
||||
throw new Error("project access context is missing in request")
|
||||
}
|
||||
|
||||
return request.projectAccess
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from "@nestjs/common"
|
||||
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||
|
||||
export const REQUIRED_PROJECT_SCOPES_KEY = "requiredProjectScopes"
|
||||
|
||||
export const RequiredProjectScopes = (...scopes: ProjectAccessTokenScope[]) => {
|
||||
return SetMetadata(REQUIRED_PROJECT_SCOPES_KEY, scopes)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export const PROJECT_ACCESS_TOKEN_SCOPES = [
|
||||
"assets:read",
|
||||
"assets:write",
|
||||
"assets:delete",
|
||||
"presets:read",
|
||||
"presets:write",
|
||||
"builds:read",
|
||||
"builds:write",
|
||||
] as const
|
||||
|
||||
export type ProjectAccessTokenScopeDto = (typeof PROJECT_ACCESS_TOKEN_SCOPES)[number]
|
||||
|
||||
export class CreateProjectAccessTokenRequestDto {
|
||||
@ApiProperty({ description: "Название токена для отображения в кабинете.", example: "Production backend" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({
|
||||
description: "Scopes, которые разрешены этому токену проекта.",
|
||||
enum: PROJECT_ACCESS_TOKEN_SCOPES,
|
||||
example: ["assets:read", "assets:write"],
|
||||
isArray: true,
|
||||
})
|
||||
scopes!: ProjectAccessTokenScopeDto[]
|
||||
}
|
||||
|
||||
export class ProjectAccessTokenResponseDto {
|
||||
@ApiProperty({ description: "UUID токена доступа.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "UUID проекта, к которому привязан токен.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||
projectId!: string
|
||||
|
||||
@ApiProperty({ description: "Название токена.", example: "Production backend" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Безопасный prefix токена для отображения и поиска.", example: "a1b2c3d4e5f6" })
|
||||
tokenPrefix!: string
|
||||
|
||||
@ApiProperty({ description: "Scopes токена.", enum: PROJECT_ACCESS_TOKEN_SCOPES, isArray: true })
|
||||
scopes!: ProjectAccessTokenScopeDto[]
|
||||
|
||||
@ApiProperty({ description: "Статус токена.", enum: ["active", "revoked"], example: "active" })
|
||||
status!: "active" | "revoked"
|
||||
|
||||
@ApiProperty({ description: "ISO-дата последнего использования токена.", example: "2026-05-12T10:00:00.000Z", nullable: true })
|
||||
lastUsedAt!: string | null
|
||||
|
||||
@ApiProperty({ description: "ISO-дата отзыва токена.", example: null, nullable: true })
|
||||
revokedAt!: string | null
|
||||
|
||||
@ApiProperty({ description: "ISO-дата создания токена.", example: "2026-05-12T10:00:00.000Z" })
|
||||
createdAt!: string
|
||||
|
||||
@ApiProperty({ description: "ISO-дата последнего обновления токена.", example: "2026-05-12T10:00:00.000Z" })
|
||||
updatedAt!: string
|
||||
}
|
||||
|
||||
export class CreateProjectAccessTokenResponseDto extends ProjectAccessTokenResponseDto {
|
||||
@ApiProperty({
|
||||
description: "Секрет токена. Показывается только один раз при создании и не хранится в базе в открытом виде.",
|
||||
example: "ip_prj_a1b2c3d4e5f6_secretValue",
|
||||
})
|
||||
secret!: string
|
||||
}
|
||||
|
||||
export class ProjectAccessTokensListResponseDto {
|
||||
@ApiProperty({ description: "Список токенов доступа проекта без секретов.", type: [ProjectAccessTokenResponseDto] })
|
||||
tokens!: ProjectAccessTokenResponseDto[]
|
||||
}
|
||||
|
||||
export class ProjectAccessContextResponseDto {
|
||||
@ApiProperty({ description: "UUID проекта, определённый по Bearer token.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||
projectId!: string
|
||||
|
||||
@ApiProperty({ description: "UUID использованного токена.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991" })
|
||||
tokenId!: string
|
||||
|
||||
@ApiProperty({ description: "Scopes текущего токена.", enum: PROJECT_ACCESS_TOKEN_SCOPES, isArray: true })
|
||||
scopes!: ProjectAccessTokenScopeDto[]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"
|
||||
import { Reflector } from "@nestjs/core"
|
||||
|
||||
import { readBearerToken } from "../../auth/guards/admin-auth.guard"
|
||||
import type { AuthenticatedRequest } from "../../auth/types/authenticated-request.type"
|
||||
import { REQUIRED_PROJECT_SCOPES_KEY } from "../decorators/required-project-scopes.decorator"
|
||||
import { ProjectAccessTokensService } from "../project-access-tokens.service"
|
||||
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||
|
||||
@Injectable()
|
||||
export class ProjectAccessTokenGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly projectAccessTokens: ProjectAccessTokensService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
const token = readBearerToken(request.headers.authorization)
|
||||
const projectAccess = await this.projectAccessTokens.authenticate(token)
|
||||
const requiredScopes = this.reflector.getAllAndOverride<ProjectAccessTokenScope[]>(REQUIRED_PROJECT_SCOPES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
])
|
||||
|
||||
if (requiredScopes?.some((scope) => !projectAccess.scopes.includes(scope))) {
|
||||
throw new ForbiddenException("project access token does not have required scopes")
|
||||
}
|
||||
|
||||
request.projectAccess = projectAccess
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common"
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger"
|
||||
|
||||
import { AdminAuthGuard } from "../auth/guards/admin-auth.guard"
|
||||
import { CurrentProjectAccess } from "./decorators/current-project-access.decorator"
|
||||
import { RequiredProjectScopes } from "./decorators/required-project-scopes.decorator"
|
||||
import {
|
||||
CreateProjectAccessTokenRequestDto,
|
||||
CreateProjectAccessTokenResponseDto,
|
||||
ProjectAccessContextResponseDto,
|
||||
ProjectAccessTokenResponseDto,
|
||||
ProjectAccessTokensListResponseDto,
|
||||
} from "./dto/project-access-token.dto"
|
||||
import { ProjectAccessTokenGuard } from "./guards/project-access-token.guard"
|
||||
import { ProjectAccessTokensService } from "./project-access-tokens.service"
|
||||
import type { ProjectAccess } from "../auth/types/authenticated-request.type"
|
||||
|
||||
@ApiTags("project-access-tokens")
|
||||
@Controller()
|
||||
export class ProjectAccessTokensController {
|
||||
constructor(private readonly projectAccessTokens: ProjectAccessTokensService) {}
|
||||
|
||||
@Post("projects/:projectId/access-tokens")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "создать токен доступа к проекту",
|
||||
description:
|
||||
"Создаёт server-side токен для headless API. Секрет возвращается только в этом ответе, в базе хранится только hash.",
|
||||
})
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiCreatedResponse({ description: "Токен создан, секрет показан один раз.", type: CreateProjectAccessTokenResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректные name, scopes или projectId." })
|
||||
@ApiConflictResponse({ description: "Сгенерированный prefix токена уже существует." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
createToken(
|
||||
@Param("projectId") projectId: string,
|
||||
@Body() request: CreateProjectAccessTokenRequestDto,
|
||||
): Promise<CreateProjectAccessTokenResponseDto> {
|
||||
return this.projectAccessTokens.createToken(projectId, request)
|
||||
}
|
||||
|
||||
@Get("projects/:projectId/access-tokens")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "получить токены доступа проекта",
|
||||
description: "Возвращает список токенов проекта без секретов. Для UI показывается только prefix и metadata.",
|
||||
})
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiOkResponse({ description: "Список токенов проекта возвращён.", type: ProjectAccessTokensListResponseDto })
|
||||
@ApiBadRequestResponse({ description: "projectId не является UUID." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
listTokens(@Param("projectId") projectId: string): Promise<ProjectAccessTokensListResponseDto> {
|
||||
return this.projectAccessTokens.listTokens(projectId)
|
||||
}
|
||||
|
||||
@Delete("projects/:projectId/access-tokens/:tokenId")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "отозвать токен доступа проекта",
|
||||
description: "Переводит токен в revoked. После отзыва он больше не даёт доступ к project-scoped API.",
|
||||
})
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiParam({ description: "UUID токена доступа.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991", name: "tokenId" })
|
||||
@ApiOkResponse({ description: "Токен отозван.", type: ProjectAccessTokenResponseDto })
|
||||
@ApiBadRequestResponse({ description: "projectId или tokenId не является UUID." })
|
||||
@ApiNotFoundResponse({ description: "Проект или токен не найден." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
revokeToken(
|
||||
@Param("projectId") projectId: string,
|
||||
@Param("tokenId") tokenId: string,
|
||||
): Promise<ProjectAccessTokenResponseDto> {
|
||||
return this.projectAccessTokens.revokeToken(projectId, tokenId)
|
||||
}
|
||||
|
||||
@Get("project-access/me")
|
||||
@UseGuards(ProjectAccessTokenGuard)
|
||||
@RequiredProjectScopes("assets:read")
|
||||
@ApiBearerAuth("projectAccessToken")
|
||||
@ApiOperation({
|
||||
summary: "проверить токен доступа к проекту",
|
||||
description: "Диагностический endpoint для headless-клиентов. Возвращает projectId и scopes текущего project access token.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Project access token активен и имеет scope assets:read.", type: ProjectAccessContextResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Project access token отсутствует, отозван или некорректен." })
|
||||
@ApiForbiddenResponse({ description: "Project access token не имеет требуемого scope." })
|
||||
getProjectAccess(@CurrentProjectAccess() projectAccess: ProjectAccess): ProjectAccessContextResponseDto {
|
||||
return projectAccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { AuthModule } from "../auth/auth.module"
|
||||
import { DatabaseModule } from "../infra/database.module"
|
||||
import { ProjectsModule } from "../projects/projects.module"
|
||||
import { ProjectAccessTokenGuard } from "./guards/project-access-token.guard"
|
||||
import { ProjectAccessTokensController } from "./project-access-tokens.controller"
|
||||
import { ProjectAccessTokensService } from "./project-access-tokens.service"
|
||||
|
||||
@Module({
|
||||
controllers: [ProjectAccessTokensController],
|
||||
exports: [ProjectAccessTokenGuard, ProjectAccessTokensService],
|
||||
imports: [AuthModule, DatabaseModule, ProjectsModule],
|
||||
providers: [ProjectAccessTokenGuard, ProjectAccessTokensService],
|
||||
})
|
||||
export class ProjectAccessTokensModule {}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"
|
||||
import { projectAccessTokens, projects, type ProjectAccessTokenScope } from "@image-platform/database"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
|
||||
import { DatabaseService } from "../infra/database.service"
|
||||
import { ProjectsService } from "../projects/projects.service"
|
||||
import { PROJECT_ACCESS_TOKEN_SCOPES, type ProjectAccessTokenScopeDto } from "./dto/project-access-token.dto"
|
||||
import type {
|
||||
CreateProjectAccessTokenRequestDto,
|
||||
CreateProjectAccessTokenResponseDto,
|
||||
ProjectAccessTokenResponseDto,
|
||||
ProjectAccessTokensListResponseDto,
|
||||
} from "./dto/project-access-token.dto"
|
||||
|
||||
@Injectable()
|
||||
export class ProjectAccessTokensService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly projectsService: ProjectsService,
|
||||
) {}
|
||||
|
||||
async createToken(projectId: string, request: CreateProjectAccessTokenRequestDto): Promise<CreateProjectAccessTokenResponseDto> {
|
||||
await this.projectsService.loadActiveProject(projectId)
|
||||
|
||||
if (!request) {
|
||||
throw new BadRequestException("request body is required")
|
||||
}
|
||||
|
||||
const name = normalizeTokenName(request.name)
|
||||
const scopes = normalizeScopes(request.scopes)
|
||||
const generated = generateToken()
|
||||
|
||||
try {
|
||||
const [token] = await this.database.db
|
||||
.insert(projectAccessTokens)
|
||||
.values({
|
||||
name,
|
||||
projectId,
|
||||
scopes,
|
||||
tokenHash: hashToken(generated.secret),
|
||||
tokenPrefix: generated.prefix,
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (!token) {
|
||||
throw new Error("failed to create project access token")
|
||||
}
|
||||
|
||||
return {
|
||||
...mapTokenResponse(token),
|
||||
secret: generated.secret,
|
||||
}
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException("project access token prefix already exists")
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async listTokens(projectId: string): Promise<ProjectAccessTokensListResponseDto> {
|
||||
await this.projectsService.loadActiveProject(projectId)
|
||||
|
||||
const rows = await this.database.db
|
||||
.select()
|
||||
.from(projectAccessTokens)
|
||||
.where(eq(projectAccessTokens.projectId, projectId))
|
||||
.orderBy(desc(projectAccessTokens.createdAt))
|
||||
|
||||
return {
|
||||
tokens: rows.map(mapTokenResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async revokeToken(projectId: string, tokenId: string): Promise<ProjectAccessTokenResponseDto> {
|
||||
await this.projectsService.loadActiveProject(projectId)
|
||||
assertUuid(tokenId, "tokenId")
|
||||
|
||||
const [token] = await this.database.db
|
||||
.update(projectAccessTokens)
|
||||
.set({ revokedAt: new Date(), status: "revoked", updatedAt: new Date() })
|
||||
.where(and(eq(projectAccessTokens.id, tokenId), eq(projectAccessTokens.projectId, projectId)))
|
||||
.returning()
|
||||
|
||||
if (!token) {
|
||||
throw new NotFoundException("project access token not found")
|
||||
}
|
||||
|
||||
return mapTokenResponse(token)
|
||||
}
|
||||
|
||||
async authenticate(secret: string) {
|
||||
const prefix = parseTokenPrefix(secret)
|
||||
const [row] = await this.database.db
|
||||
.select({
|
||||
projectId: projectAccessTokens.projectId,
|
||||
projectStatus: projects.status,
|
||||
scopes: projectAccessTokens.scopes,
|
||||
status: projectAccessTokens.status,
|
||||
tokenHash: projectAccessTokens.tokenHash,
|
||||
tokenId: projectAccessTokens.id,
|
||||
})
|
||||
.from(projectAccessTokens)
|
||||
.innerJoin(projects, eq(projects.id, projectAccessTokens.projectId))
|
||||
.where(eq(projectAccessTokens.tokenPrefix, prefix))
|
||||
.limit(1)
|
||||
|
||||
if (!row || row.status !== "active" || row.projectStatus !== "active") {
|
||||
throw new UnauthorizedException("project access token is invalid")
|
||||
}
|
||||
|
||||
if (!safeEqual(hashToken(secret), row.tokenHash)) {
|
||||
throw new UnauthorizedException("project access token is invalid")
|
||||
}
|
||||
|
||||
await this.database.db
|
||||
.update(projectAccessTokens)
|
||||
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(projectAccessTokens.id, row.tokenId))
|
||||
|
||||
return {
|
||||
projectId: row.projectId,
|
||||
scopes: row.scopes,
|
||||
tokenId: row.tokenId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapTokenResponse(row: typeof projectAccessTokens.$inferSelect): ProjectAccessTokenResponseDto {
|
||||
return {
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
id: row.id,
|
||||
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
||||
name: row.name,
|
||||
projectId: row.projectId,
|
||||
revokedAt: row.revokedAt?.toISOString() ?? null,
|
||||
scopes: row.scopes,
|
||||
status: row.status,
|
||||
tokenPrefix: row.tokenPrefix,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
const prefix = randomBytes(6).toString("hex")
|
||||
const entropy = randomBytes(32).toString("base64url")
|
||||
|
||||
return {
|
||||
prefix,
|
||||
secret: `ip_prj_${prefix}_${entropy}`,
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(secret: string) {
|
||||
return createHash("sha256").update(secret).digest("hex")
|
||||
}
|
||||
|
||||
function parseTokenPrefix(secret: string) {
|
||||
const match = /^ip_prj_([a-f0-9]{12})_[A-Za-z0-9_-]{32,}$/.exec(secret)
|
||||
|
||||
if (!match?.[1]) {
|
||||
throw new UnauthorizedException("project access token is invalid")
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function normalizeTokenName(value: string) {
|
||||
if (typeof value !== "string") {
|
||||
throw new BadRequestException("token name must be a string")
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
|
||||
if (!normalized || normalized.length > 120) {
|
||||
throw new BadRequestException("token name must be 1-120 chars")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: ProjectAccessTokenScopeDto[]): ProjectAccessTokenScope[] {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||
throw new BadRequestException("token scopes must not be empty")
|
||||
}
|
||||
|
||||
const uniqueScopes = [...new Set(scopes)]
|
||||
|
||||
for (const scope of uniqueScopes) {
|
||||
if (!PROJECT_ACCESS_TOKEN_SCOPES.includes(scope)) {
|
||||
throw new BadRequestException(`unsupported token scope: ${scope}`)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueScopes
|
||||
}
|
||||
|
||||
function assertUuid(value: string, name: string) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new BadRequestException(`${name} must be a uuid`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
|
||||
}
|
||||
34
apps/backend/src/projects/dto/projects.dto.ts
Normal file
34
apps/backend/src/projects/dto/projects.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class CreateProjectRequestDto {
|
||||
@ApiProperty({ description: "Человекочитаемое название проекта.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Опциональный slug проекта. Если не передан, backend создаст его из имени.", example: "demo-shop", required: false })
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export class ProjectResponseDto {
|
||||
@ApiProperty({ description: "Внутренний UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "Публичный slug проекта для UI и человекочитаемых ссылок.", example: "demo-shop" })
|
||||
slug!: string
|
||||
|
||||
@ApiProperty({ description: "Название проекта.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Статус проекта.", enum: ["active", "disabled"], example: "active" })
|
||||
status!: "active" | "disabled"
|
||||
|
||||
@ApiProperty({ description: "ISO-дата создания проекта.", example: "2026-05-12T10:00:00.000Z" })
|
||||
createdAt!: string
|
||||
|
||||
@ApiProperty({ description: "ISO-дата последнего обновления проекта.", example: "2026-05-12T10:00:00.000Z" })
|
||||
updatedAt!: string
|
||||
}
|
||||
|
||||
export class ProjectsListResponseDto {
|
||||
@ApiProperty({ description: "Список проектов control plane.", type: [ProjectResponseDto] })
|
||||
projects!: ProjectResponseDto[]
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import { BadRequestException } from "@nestjs/common"
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
export function normalizeProjectSlug(value: string) {
|
||||
if (typeof value !== "string") {
|
||||
throw new BadRequestException("project slug must be a string")
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9_-]{2,63}$/.test(normalized)) {
|
||||
throw new BadRequestException("project slug must be 3-64 chars and contain lowercase letters, digits, _ or -")
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(normalized)) {
|
||||
throw new BadRequestException("project slug must be 3-64 chars and contain lowercase letters, numbers or hyphens")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function createProjectSlug(name: string) {
|
||||
const slug = name
|
||||
const base = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9_-]+/g, "-")
|
||||
.replaceAll(/^-+|-+$/g, "")
|
||||
.replace(/[^a-z0-9]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
|
||||
return normalizeProjectSlug(slug || "project")
|
||||
const safeBase = base || "project"
|
||||
const suffix = createHash("sha1").update(name).digest("hex").slice(0, 8)
|
||||
|
||||
return normalizeProjectSlug(`${safeBase}-${suffix}`)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from "@nestjs/common"
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger"
|
||||
|
||||
import { AssetsListResponseDto } from "../assets/asset-response.dto"
|
||||
import { AssetsService } from "../assets/assets.service"
|
||||
import { CreateAssetRequestDto, CreateAssetResponseDto } from "../assets/create-asset.dto"
|
||||
import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto"
|
||||
import { AdminAuthGuard } from "../auth/guards/admin-auth.guard"
|
||||
import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./dto/projects.dto"
|
||||
import { ProjectsService } from "./projects.service"
|
||||
|
||||
@ApiTags("projects")
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller("projects")
|
||||
export class ProjectsController {
|
||||
constructor(
|
||||
private readonly assets: AssetsService,
|
||||
private readonly projects: ProjectsService,
|
||||
) {}
|
||||
constructor(private readonly projects: ProjectsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "получить список проектов",
|
||||
description: "Возвращает проекты верхнего уровня для главной страницы admin.",
|
||||
description: "Возвращает проекты control plane. Проект является областью изоляции assets, presets, builds и access tokens.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Список проектов возвращён.", type: ProjectsListResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
listProjects(): Promise<ProjectsListResponseDto> {
|
||||
return this.projects.listProjects()
|
||||
}
|
||||
@@ -38,59 +37,27 @@ export class ProjectsController {
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: "создать проект",
|
||||
description: "Создаёт проект, внутри которого admin управляет assets и source versions.",
|
||||
description: "Создаёт проект как верхнеуровневую область изоляции. Проект не является image-сущностью.",
|
||||
})
|
||||
@ApiCreatedResponse({ description: "Проект создан.", type: ProjectResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректные name или slug." })
|
||||
@ApiConflictResponse({ description: "Проект с таким slug уже существует." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
createProject(@Body() request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
|
||||
return this.projects.createProject(request)
|
||||
}
|
||||
|
||||
@Get(":projectSlug")
|
||||
@Get(":projectId")
|
||||
@ApiOperation({
|
||||
summary: "получить проект по slug",
|
||||
description: "Возвращает metadata проекта для project-level страницы admin.",
|
||||
summary: "получить проект по id",
|
||||
description: "Возвращает metadata активного проекта по внутреннему UUID.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiOkResponse({ description: "Проект найден.", type: ProjectResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
getProject(@Param("projectSlug") projectSlug: string): Promise<ProjectResponseDto> {
|
||||
return this.projects.getProject(projectSlug)
|
||||
}
|
||||
|
||||
@Get(":projectSlug/assets")
|
||||
@ApiOperation({
|
||||
summary: "получить assets проекта",
|
||||
description: "Возвращает assets, созданные внутри выбранного проекта.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiQuery({ description: "Максимальное количество assets в ответе.", example: 50, name: "limit", required: false })
|
||||
@ApiQuery({ description: "Смещение для простого paging.", example: 0, name: "offset", required: false })
|
||||
@ApiOkResponse({ description: "Список assets проекта возвращён.", type: AssetsListResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
listProjectAssets(
|
||||
@Param("projectSlug") projectSlug: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("offset") offset?: string,
|
||||
): Promise<AssetsListResponseDto> {
|
||||
return this.assets.listAssets({ limit, offset, projectSlug })
|
||||
}
|
||||
|
||||
@Post(":projectSlug/assets")
|
||||
@ApiOperation({
|
||||
summary: "создать asset в проекте",
|
||||
description: "Создаёт asset и первую source version внутри выбранного проекта.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiCreatedResponse({ description: "Asset проекта создан.", type: CreateAssetResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректный sourceUrl, publicId или source host запрещён настройками." })
|
||||
@ApiConflictResponse({ description: "Asset с таким publicId уже существует." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
createProjectAsset(
|
||||
@Param("projectSlug") projectSlug: string,
|
||||
@Body() request: CreateAssetRequestDto,
|
||||
): Promise<CreateAssetResponseDto> {
|
||||
return this.assets.createAsset(request, projectSlug)
|
||||
@ApiBadRequestResponse({ description: "projectId не является UUID." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
getProject(@Param("projectId") projectId: string): Promise<ProjectResponseDto> {
|
||||
return this.projects.getProject(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
|
||||
|
||||
export class CreateProjectRequestDto {
|
||||
@ApiProperty({ description: "Название проекта в admin UI.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiPropertyOptional({ description: "Публичный slug проекта для URL и SDK.", example: "demo-shop" })
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export class ProjectResponseDto {
|
||||
@ApiProperty({ description: "Внутренний UUID проекта.", example: "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "Публичный slug проекта.", example: "demo-shop" })
|
||||
slug!: string
|
||||
|
||||
@ApiProperty({ description: "Название проекта.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Статус проекта.", enum: ["active", "disabled"], example: "active" })
|
||||
status!: string
|
||||
|
||||
@ApiProperty({ description: "Количество assets в проекте.", example: 12 })
|
||||
assetsCount!: number
|
||||
|
||||
@ApiProperty({ description: "Дата создания проекта.", example: "2026-05-05T12:00:00.000Z" })
|
||||
createdAt!: string
|
||||
|
||||
@ApiProperty({ description: "Дата обновления проекта.", example: "2026-05-05T12:00:00.000Z" })
|
||||
updatedAt!: string
|
||||
}
|
||||
|
||||
export class ProjectsListResponseDto {
|
||||
@ApiProperty({ description: "Список проектов.", type: [ProjectResponseDto] })
|
||||
projects!: ProjectResponseDto[]
|
||||
}
|
||||
14
apps/backend/src/projects/projects.module.ts
Normal file
14
apps/backend/src/projects/projects.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { AuthModule } from "../auth/auth.module"
|
||||
import { DatabaseModule } from "../infra/database.module"
|
||||
import { ProjectsController } from "./projects.controller"
|
||||
import { ProjectsService } from "./projects.service"
|
||||
|
||||
@Module({
|
||||
controllers: [ProjectsController],
|
||||
exports: [ProjectsService],
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
providers: [ProjectsService],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
@@ -1,36 +1,39 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
|
||||
import { imageAssets, imageProjects } from "@image-platform/database"
|
||||
import { count, desc, eq, inArray } from "drizzle-orm"
|
||||
import { projects } from "@image-platform/database"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
|
||||
import { DatabaseService } from "../infra/database.service"
|
||||
import { createProjectSlug, normalizeProjectSlug } from "./project-slug"
|
||||
import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto"
|
||||
import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./dto/projects.dto"
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async listProjects(): Promise<ProjectsListResponseDto> {
|
||||
const projects = await this.database.db.select().from(imageProjects).orderBy(desc(imageProjects.createdAt))
|
||||
const assetsCountByProjectId = await this.countAssetsByProjectIds(projects.map((project) => project.id))
|
||||
const rows = await this.database.db.select().from(projects).orderBy(desc(projects.createdAt))
|
||||
|
||||
return {
|
||||
projects: projects.map((project) => mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)),
|
||||
projects: rows.map(mapProjectResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async createProject(request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
|
||||
if (!request) {
|
||||
throw new BadRequestException("request body is required")
|
||||
}
|
||||
|
||||
const name = normalizeProjectName(request.name)
|
||||
const slug = request.slug ? normalizeProjectSlug(request.slug) : createProjectSlug(name)
|
||||
|
||||
try {
|
||||
const [project] = await this.database.db.insert(imageProjects).values({ name, slug }).returning()
|
||||
const [project] = await this.database.db.insert(projects).values({ name, slug }).returning()
|
||||
|
||||
if (!project) {
|
||||
throw new Error("failed to create project")
|
||||
}
|
||||
|
||||
return mapProjectResponse(project, 0)
|
||||
return mapProjectResponse(project)
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException("project slug already exists")
|
||||
@@ -40,20 +43,16 @@ export class ProjectsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getProject(slug: string): Promise<ProjectResponseDto> {
|
||||
const project = await this.loadProject(slug)
|
||||
const assetsCountByProjectId = await this.countAssetsByProjectIds([project.id])
|
||||
async getProject(projectId: string): Promise<ProjectResponseDto> {
|
||||
const project = await this.loadActiveProject(projectId)
|
||||
|
||||
return mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)
|
||||
return mapProjectResponse(project)
|
||||
}
|
||||
|
||||
async loadProject(slug: string) {
|
||||
const normalizedSlug = normalizeProjectSlug(slug)
|
||||
const [project] = await this.database.db
|
||||
.select()
|
||||
.from(imageProjects)
|
||||
.where(eq(imageProjects.slug, normalizedSlug))
|
||||
.limit(1)
|
||||
async loadActiveProject(projectId: string) {
|
||||
assertUuid(projectId, "projectId")
|
||||
|
||||
const [project] = await this.database.db.select().from(projects).where(eq(projects.id, projectId)).limit(1)
|
||||
|
||||
if (!project || project.status !== "active") {
|
||||
throw new NotFoundException("project not found")
|
||||
@@ -61,28 +60,10 @@ export class ProjectsService {
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
private async countAssetsByProjectIds(projectIds: string[]) {
|
||||
if (projectIds.length === 0) {
|
||||
return new Map<string, number>()
|
||||
}
|
||||
|
||||
const rows = await this.database.db
|
||||
.select({
|
||||
assetsCount: count(imageAssets.id),
|
||||
projectId: imageAssets.projectId,
|
||||
})
|
||||
.from(imageAssets)
|
||||
.where(inArray(imageAssets.projectId, projectIds))
|
||||
.groupBy(imageAssets.projectId)
|
||||
|
||||
return new Map(rows.flatMap((row) => (row.projectId ? [[row.projectId, row.assetsCount]] : [])))
|
||||
}
|
||||
}
|
||||
|
||||
function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount: number): ProjectResponseDto {
|
||||
function mapProjectResponse(row: typeof projects.$inferSelect): ProjectResponseDto {
|
||||
return {
|
||||
assetsCount,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -93,6 +74,10 @@ function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount:
|
||||
}
|
||||
|
||||
function normalizeProjectName(value: string) {
|
||||
if (typeof value !== "string") {
|
||||
throw new BadRequestException("project name must be a string")
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
|
||||
if (!normalized || normalized.length > 120) {
|
||||
@@ -102,6 +87,12 @@ function normalizeProjectName(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function assertUuid(value: string, name: string) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new BadRequestException(`${name} must be a uuid`)
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user