diff --git a/AGENTS.md b/AGENTS.md index 45e527c..373bdee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ После определения роли агент обязан открыть соответствующую инструкцию: -- `developer` → [DEVELOP.md](./ai/nextjs-style-guide/DEVELOP.md) +- `developer` → [DEVELOP.md](./ai/DEVELOP.md) Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия. diff --git a/ai/nextjs-style-guide/DEVELOP.md b/ai/DEVELOP.md similarity index 100% rename from ai/nextjs-style-guide/DEVELOP.md rename to ai/DEVELOP.md diff --git a/apps/admin/src/layouts/main/main.layout.tsx b/apps/admin/src/layouts/main/main.layout.tsx index 0bad17a..d177d44 100644 --- a/apps/admin/src/layouts/main/main.layout.tsx +++ b/apps/admin/src/layouts/main/main.layout.tsx @@ -19,7 +19,7 @@ export const MainLayout = (props: MainLayoutProps) => { - + IP @@ -27,8 +27,6 @@ export const MainLayout = (props: MainLayoutProps) => { Платформа изображений - - Админка diff --git a/apps/admin/src/layouts/main/styles/main.module.css b/apps/admin/src/layouts/main/styles/main.module.css index 0673dd0..92a8b4b 100644 --- a/apps/admin/src/layouts/main/styles/main.module.css +++ b/apps/admin/src/layouts/main/styles/main.module.css @@ -34,14 +34,6 @@ } } -.sectionLabel { - color: var(--color-text-subtle); - font-size: 0.75rem; - font-weight: 760; - letter-spacing: 0.12em; - text-transform: uppercase; -} - .main { background: transparent; } diff --git a/apps/backend/package.json b/apps/backend/package.json index cd531d9..4afa909 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index b5710d5..1b39ccb 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..b40c9ca --- /dev/null +++ b/apps/backend/src/auth/auth.controller.ts @@ -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, + } + } +} diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..3883459 --- /dev/null +++ b/apps/backend/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..90b5962 --- /dev/null +++ b/apps/backend/src/auth/auth.service.ts @@ -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 +} diff --git a/apps/backend/src/auth/decorators/current-admin.decorator.ts b/apps/backend/src/auth/decorators/current-admin.decorator.ts new file mode 100644 index 0000000..4656c44 --- /dev/null +++ b/apps/backend/src/auth/decorators/current-admin.decorator.ts @@ -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() + + if (!request.adminSession) { + throw new Error("admin session is missing in request context") + } + + return request.adminSession +}) diff --git a/apps/backend/src/auth/dto/login.dto.ts b/apps/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..ebd1e05 --- /dev/null +++ b/apps/backend/src/auth/dto/login.dto.ts @@ -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 +} diff --git a/apps/backend/src/auth/dto/session.dto.ts b/apps/backend/src/auth/dto/session.dto.ts new file mode 100644 index 0000000..7ad33a4 --- /dev/null +++ b/apps/backend/src/auth/dto/session.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger" + +export class AdminSessionResponseDto { + @ApiProperty({ description: "Логин текущего администратора.", example: "admin" }) + username!: string +} diff --git a/apps/backend/src/auth/guards/admin-auth.guard.ts b/apps/backend/src/auth/guards/admin-auth.guard.ts new file mode 100644 index 0000000..1907f1c --- /dev/null +++ b/apps/backend/src/auth/guards/admin-auth.guard.ts @@ -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() + 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 +} diff --git a/apps/backend/src/auth/types/authenticated-request.type.ts b/apps/backend/src/auth/types/authenticated-request.type.ts new file mode 100644 index 0000000..d81df50 --- /dev/null +++ b/apps/backend/src/auth/types/authenticated-request.type.ts @@ -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 +} diff --git a/apps/backend/src/health/health-response.dto.ts b/apps/backend/src/health/health-response.dto.ts index 38f07e1..9d20ffd 100644 --- a/apps/backend/src/health/health-response.dto.ts +++ b/apps/backend/src/health/health-response.dto.ts @@ -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 } diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts index 45701f5..9b3fa56 100644 --- a/apps/backend/src/health/health.controller.ts +++ b/apps/backend/src/health/health.controller.ts @@ -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", } } diff --git a/apps/backend/src/health/health.module.ts b/apps/backend/src/health/health.module.ts new file mode 100644 index 0000000..1e986e0 --- /dev/null +++ b/apps/backend/src/health/health.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common" + +import { HealthController } from "./health.controller" + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/backend/src/infra/database.module.ts b/apps/backend/src/infra/database.module.ts new file mode 100644 index 0000000..c59c1f9 --- /dev/null +++ b/apps/backend/src/infra/database.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from "@nestjs/common" + +import { DatabaseService } from "./database.service" + +@Global() +@Module({ + exports: [DatabaseService], + providers: [DatabaseService], +}) +export class DatabaseModule {} diff --git a/apps/backend/src/infra/database.service.ts b/apps/backend/src/infra/database.service.ts index 398c69a..e4451a6 100644 --- a/apps/backend/src/infra/database.service.ts +++ b/apps/backend/src/infra/database.service.ts @@ -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() { diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 8878bcb..ff57645 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -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) diff --git a/apps/backend/src/project-access-tokens/decorators/current-project-access.decorator.ts b/apps/backend/src/project-access-tokens/decorators/current-project-access.decorator.ts new file mode 100644 index 0000000..b5c643b --- /dev/null +++ b/apps/backend/src/project-access-tokens/decorators/current-project-access.decorator.ts @@ -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() + + if (!request.projectAccess) { + throw new Error("project access context is missing in request") + } + + return request.projectAccess +}) diff --git a/apps/backend/src/project-access-tokens/decorators/required-project-scopes.decorator.ts b/apps/backend/src/project-access-tokens/decorators/required-project-scopes.decorator.ts new file mode 100644 index 0000000..78d3a37 --- /dev/null +++ b/apps/backend/src/project-access-tokens/decorators/required-project-scopes.decorator.ts @@ -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) +} diff --git a/apps/backend/src/project-access-tokens/dto/project-access-token.dto.ts b/apps/backend/src/project-access-tokens/dto/project-access-token.dto.ts new file mode 100644 index 0000000..eb87d6c --- /dev/null +++ b/apps/backend/src/project-access-tokens/dto/project-access-token.dto.ts @@ -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[] +} diff --git a/apps/backend/src/project-access-tokens/guards/project-access-token.guard.ts b/apps/backend/src/project-access-tokens/guards/project-access-token.guard.ts new file mode 100644 index 0000000..170b332 --- /dev/null +++ b/apps/backend/src/project-access-tokens/guards/project-access-token.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest() + const token = readBearerToken(request.headers.authorization) + const projectAccess = await this.projectAccessTokens.authenticate(token) + const requiredScopes = this.reflector.getAllAndOverride(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 + } +} diff --git a/apps/backend/src/project-access-tokens/project-access-tokens.controller.ts b/apps/backend/src/project-access-tokens/project-access-tokens.controller.ts new file mode 100644 index 0000000..2f6f917 --- /dev/null +++ b/apps/backend/src/project-access-tokens/project-access-tokens.controller.ts @@ -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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/backend/src/project-access-tokens/project-access-tokens.module.ts b/apps/backend/src/project-access-tokens/project-access-tokens.module.ts new file mode 100644 index 0000000..609d911 --- /dev/null +++ b/apps/backend/src/project-access-tokens/project-access-tokens.module.ts @@ -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 {} diff --git a/apps/backend/src/project-access-tokens/project-access-tokens.service.ts b/apps/backend/src/project-access-tokens/project-access-tokens.service.ts new file mode 100644 index 0000000..6bd7dba --- /dev/null +++ b/apps/backend/src/project-access-tokens/project-access-tokens.service.ts @@ -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 { + 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 { + 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 { + 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" +} diff --git a/apps/backend/src/projects/dto/projects.dto.ts b/apps/backend/src/projects/dto/projects.dto.ts new file mode 100644 index 0000000..df199ca --- /dev/null +++ b/apps/backend/src/projects/dto/projects.dto.ts @@ -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[] +} diff --git a/apps/backend/src/projects/project-slug.ts b/apps/backend/src/projects/project-slug.ts index 2f4b6a6..bcdb657 100644 --- a/apps/backend/src/projects/project-slug.ts +++ b/apps/backend/src/projects/project-slug.ts @@ -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}`) } diff --git a/apps/backend/src/projects/projects.controller.ts b/apps/backend/src/projects/projects.controller.ts index 6ebce2a..9dfcc94 100644 --- a/apps/backend/src/projects/projects.controller.ts +++ b/apps/backend/src/projects/projects.controller.ts @@ -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 { 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 { 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 { - 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 { - 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 { - return this.assets.createAsset(request, projectSlug) + @ApiBadRequestResponse({ description: "projectId не является UUID." }) + @ApiNotFoundResponse({ description: "Проект не найден или отключён." }) + @ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." }) + getProject(@Param("projectId") projectId: string): Promise { + return this.projects.getProject(projectId) } } diff --git a/apps/backend/src/projects/projects.module.ts b/apps/backend/src/projects/projects.module.ts new file mode 100644 index 0000000..9e9d3a4 --- /dev/null +++ b/apps/backend/src/projects/projects.module.ts @@ -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 {} diff --git a/apps/backend/src/projects/projects.service.ts b/apps/backend/src/projects/projects.service.ts index 0be82cf..50a570b 100644 --- a/apps/backend/src/projects/projects.service.ts +++ b/apps/backend/src/projects/projects.service.ts @@ -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 { - 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 { + 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 { - const project = await this.loadProject(slug) - const assetsCountByProjectId = await this.countAssetsByProjectIds([project.id]) + async getProject(projectId: string): Promise { + 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() - } - - 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" } diff --git a/apps/cabinet/index.html b/apps/cabinet/index.html new file mode 100644 index 0000000..588c3cc --- /dev/null +++ b/apps/cabinet/index.html @@ -0,0 +1,12 @@ + + + + + + Assets Cabinet + + +
+ + + diff --git a/apps/cabinet/package.json b/apps/cabinet/package.json new file mode 100644 index 0000000..f78a828 --- /dev/null +++ b/apps/cabinet/package.json @@ -0,0 +1,36 @@ +{ + "name": "@image-platform/cabinet", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "codegen:backend-api": "npx @gromlab/api-codegen@latest -i http://localhost:3001/docs-json -o src/infra/backend-api/generated -n backend-api.generated", + "dev": "vite --host 0.0.0.0 --port 5174", + "preview": "vite preview --host 0.0.0.0 --port 5174", + "typecheck": "tsc -b" + }, + "dependencies": { + "@mantine/core": "^9.1.1", + "@mantine/form": "^9.1.1", + "@mantine/hooks": "^9.1.1", + "@mantine/notifications": "^9.1.1", + "clsx": "^2.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-router-dom": "^7.15.0", + "swr": "^2.4.1" + }, + "devDependencies": { + "@csstools/postcss-global-data": "^4.0.0", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "postcss-custom-media": "^12.0.1", + "postcss-nesting": "^14.0.0", + "typescript": "^5.9.3", + "vite": "^8.0.10" + } +} diff --git a/apps/cabinet/postcss.config.mjs b/apps/cabinet/postcss.config.mjs new file mode 100644 index 0000000..f1ba12b --- /dev/null +++ b/apps/cabinet/postcss.config.mjs @@ -0,0 +1,10 @@ +export default { + plugins: { + '@csstools/postcss-global-data': { + files: ['src/shared/styles/media.css'], + }, + 'postcss-custom-media': {}, + 'postcss-nesting': {}, + autoprefixer: {}, + }, +} diff --git a/apps/cabinet/src/app/app-router.tsx b/apps/cabinet/src/app/app-router.tsx new file mode 100644 index 0000000..46feebe --- /dev/null +++ b/apps/cabinet/src/app/app-router.tsx @@ -0,0 +1,23 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { LoginScreen } from 'screens/login' +import { SessionScreen } from 'screens/session' + +import { AuthenticatedShell } from './authenticated-shell' +import { ProtectedRoute } from './protected-route' + +export const AppRouter = () => ( + + } path="/login" /> + + + + + + } + path="/" + /> + } path="*" /> + +) diff --git a/apps/cabinet/src/app/app.tsx b/apps/cabinet/src/app/app.tsx new file mode 100644 index 0000000..e6414d0 --- /dev/null +++ b/apps/cabinet/src/app/app.tsx @@ -0,0 +1,17 @@ +import { BrowserRouter } from 'react-router-dom' +import { SWRConfig } from 'swr' +import { ThemeProvider } from 'infra/theme' + +import { AppRouter } from './app-router' + +export const App = () => { + return ( + + + + + + + + ) +} diff --git a/apps/cabinet/src/app/authenticated-shell.tsx b/apps/cabinet/src/app/authenticated-shell.tsx new file mode 100644 index 0000000..0dab330 --- /dev/null +++ b/apps/cabinet/src/app/authenticated-shell.tsx @@ -0,0 +1,40 @@ +import { Loader, Center } from '@mantine/core' +import { useNavigate } from 'react-router-dom' +import { authFactory } from 'business/auth' +import { MainLayout } from 'layouts/main' + +import type { AuthenticatedShellProps } from './types/authenticated-shell-props.type' + +const auth = authFactory() + +/** + * Обёртка защищённого layout кабинета. + * + * Используется для: + * - передачи admin-сессии в MainLayout + * - выполнения выхода из кабинета + */ +export const AuthenticatedShell = (props: AuthenticatedShellProps) => { + const { children } = props + const navigate = useNavigate() + const session = auth.useAdminSession() + const logoutAction = auth.useLogout() + + const handleLogout = () => { + void logoutAction.logout().then(() => navigate('/login', { replace: true })) + } + + if (!session.session) { + return ( +
+ +
+ ) + } + + return ( + + {children} + + ) +} diff --git a/apps/cabinet/src/app/main.tsx b/apps/cabinet/src/app/main.tsx new file mode 100644 index 0000000..6619622 --- /dev/null +++ b/apps/cabinet/src/app/main.tsx @@ -0,0 +1,18 @@ +import 'shared/styles/global.css' + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { App } from './app' + +const root = document.getElementById('root') + +if (!root) { + throw new Error('Root element #root not found') +} + +createRoot(root).render( + + + , +) diff --git a/apps/cabinet/src/app/protected-route.tsx b/apps/cabinet/src/app/protected-route.tsx new file mode 100644 index 0000000..ba9e756 --- /dev/null +++ b/apps/cabinet/src/app/protected-route.tsx @@ -0,0 +1,33 @@ +import { Loader, Center } from '@mantine/core' +import { Navigate } from 'react-router-dom' +import { authFactory } from 'business/auth' + +import type { ProtectedRouteProps } from './types/protected-route-props.type' + +const auth = authFactory() + +/** + * Guard защищённых маршрутов кабинета. + * + * Используется для: + * - проверки admin-сессии через SWR + * - перенаправления неавторизованного пользователя на вход + */ +export const ProtectedRoute = (props: ProtectedRouteProps) => { + const { children } = props + const session = auth.useAdminSession() + + if (session.isLoading) { + return ( +
+ +
+ ) + } + + if (!session.isAuthenticated) { + return + } + + return children +} diff --git a/apps/cabinet/src/app/types/authenticated-shell-props.type.ts b/apps/cabinet/src/app/types/authenticated-shell-props.type.ts new file mode 100644 index 0000000..1a6102b --- /dev/null +++ b/apps/cabinet/src/app/types/authenticated-shell-props.type.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +/** + * Пропсы AuthenticatedShell. + */ +export type AuthenticatedShellProps = { + /** Контент защищённого маршрута. */ + children: ReactNode +} diff --git a/apps/cabinet/src/app/types/protected-route-props.type.ts b/apps/cabinet/src/app/types/protected-route-props.type.ts new file mode 100644 index 0000000..2f119b8 --- /dev/null +++ b/apps/cabinet/src/app/types/protected-route-props.type.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +/** + * Пропсы ProtectedRoute. + */ +export type ProtectedRouteProps = { + /** Защищённый route element. */ + children: ReactNode +} diff --git a/apps/cabinet/src/business/auth/auth.factory.ts b/apps/cabinet/src/business/auth/auth.factory.ts new file mode 100644 index 0000000..457bfe7 --- /dev/null +++ b/apps/cabinet/src/business/auth/auth.factory.ts @@ -0,0 +1,15 @@ +import { useAdminSession } from './hooks/use-admin-session.hook' +import { useLogin } from './hooks/use-login.hook' +import { useLogout } from './hooks/use-logout.hook' +import type { AuthFactory } from './types/auth-factory.type' + +/** + * Создаёт runtime API бизнес-модуля Auth. + */ +export const authFactory: AuthFactory = () => { + return { + useAdminSession, + useLogin, + useLogout, + } +} diff --git a/apps/cabinet/src/business/auth/hooks/use-admin-session.hook.ts b/apps/cabinet/src/business/auth/hooks/use-admin-session.hook.ts new file mode 100644 index 0000000..62ef775 --- /dev/null +++ b/apps/cabinet/src/business/auth/hooks/use-admin-session.hook.ts @@ -0,0 +1,20 @@ +import { getAdminToken, useGetAdminSession } from 'infra/backend-api' + +import type { AdminSession } from '../types/auth-api.type' + +/** + * Состояние текущей admin-сессии. + */ +export const useAdminSession = (): AdminSession => { + const hasToken = Boolean(getAdminToken()) + const sessionQuery = useGetAdminSession({ + shouldRetryOnError: false, + }) + + return { + error: sessionQuery.error, + isAuthenticated: Boolean(sessionQuery.data), + isLoading: hasToken && sessionQuery.isLoading, + session: sessionQuery.data ?? null, + } +} diff --git a/apps/cabinet/src/business/auth/hooks/use-login.hook.ts b/apps/cabinet/src/business/auth/hooks/use-login.hook.ts new file mode 100644 index 0000000..be1405d --- /dev/null +++ b/apps/cabinet/src/business/auth/hooks/use-login.hook.ts @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { useSWRConfig } from 'swr' +import { backendApi, getAdminSessionKey, setAdminToken } from 'infra/backend-api' + +import { toError } from '../lib/to-error' +import type { LoginAction, LoginInput } from '../types/auth-api.type' + +/** + * Сценарий входа администратора. + */ +export const useLogin = (): LoginAction => { + const { mutate } = useSWRConfig() + const [error, setError] = useState(null) + const [isLoggingIn, setIsLoggingIn] = useState(false) + + const login = async (input: LoginInput) => { + setError(null) + setIsLoggingIn(true) + + try { + const session = await backendApi.auth.login(input) + setAdminToken(session.accessToken) + await mutate(getAdminSessionKey()) + + return session + } catch (caughtError) { + const nextError = toError(caughtError) + setError(nextError) + throw nextError + } finally { + setIsLoggingIn(false) + } + } + + return { + error, + isLoggingIn, + login, + } +} diff --git a/apps/cabinet/src/business/auth/hooks/use-logout.hook.ts b/apps/cabinet/src/business/auth/hooks/use-logout.hook.ts new file mode 100644 index 0000000..1f80cd3 --- /dev/null +++ b/apps/cabinet/src/business/auth/hooks/use-logout.hook.ts @@ -0,0 +1,20 @@ +import { useSWRConfig } from 'swr' +import { clearAdminToken, getAdminSessionKey } from 'infra/backend-api' + +import type { LogoutAction } from '../types/auth-api.type' + +/** + * Сценарий выхода администратора. + */ +export const useLogout = (): LogoutAction => { + const { mutate } = useSWRConfig() + + const logout = async () => { + clearAdminToken() + await mutate(getAdminSessionKey(), undefined, { revalidate: false }) + } + + return { + logout, + } +} diff --git a/apps/cabinet/src/business/auth/index.ts b/apps/cabinet/src/business/auth/index.ts new file mode 100644 index 0000000..e93e0be --- /dev/null +++ b/apps/cabinet/src/business/auth/index.ts @@ -0,0 +1,3 @@ +export { authFactory } from './auth.factory' +export type { AdminSession, AuthApi, LoginAction, LoginInput, LogoutAction } from './types/auth-api.type' +export type { AuthFactory } from './types/auth-factory.type' diff --git a/apps/cabinet/src/business/auth/lib/to-error.ts b/apps/cabinet/src/business/auth/lib/to-error.ts new file mode 100644 index 0000000..93e9dcc --- /dev/null +++ b/apps/cabinet/src/business/auth/lib/to-error.ts @@ -0,0 +1,7 @@ +export const toError = (value: unknown) => { + if (value instanceof Error) { + return value + } + + return new Error(String(value)) +} diff --git a/apps/cabinet/src/business/auth/types/auth-api.type.ts b/apps/cabinet/src/business/auth/types/auth-api.type.ts new file mode 100644 index 0000000..1345462 --- /dev/null +++ b/apps/cabinet/src/business/auth/types/auth-api.type.ts @@ -0,0 +1,29 @@ +import type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from 'infra/backend-api' + +export type LoginInput = LoginRequestDto + +export type AdminSession = { + error?: Error + isAuthenticated: boolean + isLoading: boolean + session: AdminSessionResponseDto | null +} + +export type LoginAction = { + error: Error | null + isLoggingIn: boolean + login: (input: LoginInput) => Promise +} + +export type LogoutAction = { + logout: () => Promise +} + +/** + * Публичный runtime API бизнес-модуля Auth. + */ +export type AuthApi = { + useAdminSession: () => AdminSession + useLogin: () => LoginAction + useLogout: () => LogoutAction +} diff --git a/apps/cabinet/src/business/auth/types/auth-factory.type.ts b/apps/cabinet/src/business/auth/types/auth-factory.type.ts new file mode 100644 index 0000000..108f0ff --- /dev/null +++ b/apps/cabinet/src/business/auth/types/auth-factory.type.ts @@ -0,0 +1,3 @@ +import type { AuthApi } from './auth-api.type' + +export type AuthFactory = () => AuthApi diff --git a/apps/cabinet/src/infra/backend-api/client.ts b/apps/cabinet/src/infra/backend-api/client.ts new file mode 100644 index 0000000..3e4c939 --- /dev/null +++ b/apps/cabinet/src/infra/backend-api/client.ts @@ -0,0 +1,77 @@ +import { getAdminToken } from './token-storage' +import type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from './types/backend-api.type' + +type RequestOptions = { + body?: unknown + isAuthorized?: boolean + method?: 'GET' | 'POST' +} + +const API_BASE_URL = import.meta.env.VITE_BACKEND_API_BASE_URL ?? '/api' + +export const backendApi = { + auth: { + login: (body: LoginRequestDto) => { + return request('/auth/login', { body, method: 'POST' }) + }, + me: () => { + return request('/auth/me', { isAuthorized: true }) + }, + }, +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + body: options.body ? JSON.stringify(options.body) : undefined, + headers: buildHeaders(options), + method: options.method ?? 'GET', + }) + + if (!response.ok) { + throw new Error(await readErrorMessage(response)) + } + + return response.json() as Promise +} + +function buildHeaders(options: RequestOptions) { + const headers: Record = { + Accept: 'application/json', + } + + if (options.body) { + headers['Content-Type'] = 'application/json' + } + + if (options.isAuthorized) { + const token = getAdminToken() + + if (token) { + headers.Authorization = `Bearer ${token}` + } + } + + return headers +} + +async function readErrorMessage(response: Response) { + try { + const value = (await response.json()) as unknown + + if (isRecord(value) && typeof value.message === 'string') { + return value.message + } + + if (isRecord(value) && Array.isArray(value.message)) { + return value.message.join(', ') + } + } catch { + return `request failed with status ${response.status}` + } + + return `request failed with status ${response.status}` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/apps/cabinet/src/infra/backend-api/hooks/index.ts b/apps/cabinet/src/infra/backend-api/hooks/index.ts new file mode 100644 index 0000000..c031e4d --- /dev/null +++ b/apps/cabinet/src/infra/backend-api/hooks/index.ts @@ -0,0 +1 @@ +export { getAdminSessionKey, useGetAdminSession } from './use-get-admin-session.hook' diff --git a/apps/cabinet/src/infra/backend-api/hooks/use-get-admin-session.hook.ts b/apps/cabinet/src/infra/backend-api/hooks/use-get-admin-session.hook.ts new file mode 100644 index 0000000..80a5677 --- /dev/null +++ b/apps/cabinet/src/infra/backend-api/hooks/use-get-admin-session.hook.ts @@ -0,0 +1,18 @@ +import useSWR from 'swr' +import type { SWRConfiguration } from 'swr' + +import { backendApi } from '../client' +import { getAdminToken } from '../token-storage' +import type { AdminSessionResponseDto } from '../types/backend-api.type' + +export const getAdminSessionKey = () => ['backend-api', 'auth', 'me'] as const + +/** + * Получение текущей admin-сессии. + */ +export const useGetAdminSession = (config?: SWRConfiguration) => { + const key = getAdminToken() ? getAdminSessionKey() : null + const fetcher = () => backendApi.auth.me() + + return useSWR(key, fetcher, config) +} diff --git a/apps/cabinet/src/infra/backend-api/index.ts b/apps/cabinet/src/infra/backend-api/index.ts new file mode 100644 index 0000000..0c09798 --- /dev/null +++ b/apps/cabinet/src/infra/backend-api/index.ts @@ -0,0 +1,4 @@ +export { backendApi } from './client' +export { clearAdminToken, getAdminToken, setAdminToken } from './token-storage' +export * from './hooks' +export type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from './types/backend-api.type' diff --git a/apps/cabinet/src/infra/backend-api/token-storage.ts b/apps/cabinet/src/infra/backend-api/token-storage.ts new file mode 100644 index 0000000..5c93da9 --- /dev/null +++ b/apps/cabinet/src/infra/backend-api/token-storage.ts @@ -0,0 +1,13 @@ +const ADMIN_TOKEN_KEY = 'image-platform.cabinet.admin-token' + +export const getAdminToken = () => { + return window.localStorage.getItem(ADMIN_TOKEN_KEY) +} + +export const setAdminToken = (token: string) => { + window.localStorage.setItem(ADMIN_TOKEN_KEY, token) +} + +export const clearAdminToken = () => { + window.localStorage.removeItem(ADMIN_TOKEN_KEY) +} diff --git a/apps/cabinet/src/infra/backend-api/types/backend-api.type.ts b/apps/cabinet/src/infra/backend-api/types/backend-api.type.ts new file mode 100644 index 0000000..415eb17 --- /dev/null +++ b/apps/cabinet/src/infra/backend-api/types/backend-api.type.ts @@ -0,0 +1,14 @@ +export type LoginRequestDto = { + username: string + password: string +} + +export type LoginResponseDto = { + accessToken: string + tokenType: 'Bearer' + expiresAt: string +} + +export type AdminSessionResponseDto = { + username: string +} diff --git a/apps/cabinet/src/infra/theme/index.ts b/apps/cabinet/src/infra/theme/index.ts new file mode 100644 index 0000000..c4a50dc --- /dev/null +++ b/apps/cabinet/src/infra/theme/index.ts @@ -0,0 +1,2 @@ +export { ThemeProvider } from './theme-provider' +export type { ThemeProviderProps } from './types/theme-provider-props.type' diff --git a/apps/cabinet/src/infra/theme/theme-provider.tsx b/apps/cabinet/src/infra/theme/theme-provider.tsx new file mode 100644 index 0000000..54b3f66 --- /dev/null +++ b/apps/cabinet/src/infra/theme/theme-provider.tsx @@ -0,0 +1,22 @@ +import { MantineProvider } from '@mantine/core' +import { Notifications } from '@mantine/notifications' + +import type { ThemeProviderProps } from './types/theme-provider-props.type' + +/** + * Провайдер визуальной темы кабинета. + * + * Используется для: + * - подключения Mantine theme + * - подключения контейнера уведомлений + */ +export const ThemeProvider = (props: ThemeProviderProps) => { + const { children } = props + + return ( + + + {children} + + ) +} diff --git a/apps/cabinet/src/infra/theme/types/theme-provider-props.type.ts b/apps/cabinet/src/infra/theme/types/theme-provider-props.type.ts new file mode 100644 index 0000000..2352fdc --- /dev/null +++ b/apps/cabinet/src/infra/theme/types/theme-provider-props.type.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +/** + * Пропсы ThemeProvider. + */ +export type ThemeProviderProps = { + /** Контент приложения. */ + children: ReactNode +} diff --git a/apps/cabinet/src/layouts/main/index.ts b/apps/cabinet/src/layouts/main/index.ts new file mode 100644 index 0000000..5472739 --- /dev/null +++ b/apps/cabinet/src/layouts/main/index.ts @@ -0,0 +1,2 @@ +export { MainLayout } from './main.layout' +export type { MainLayoutProps } from './types/main-layout-props.type' diff --git a/apps/cabinet/src/layouts/main/main.layout.tsx b/apps/cabinet/src/layouts/main/main.layout.tsx new file mode 100644 index 0000000..1be7907 --- /dev/null +++ b/apps/cabinet/src/layouts/main/main.layout.tsx @@ -0,0 +1,82 @@ +import { AppShell, Burger, Button, Container, Group, NavLink, Stack, Text, ThemeIcon } from '@mantine/core' +import { useDisclosure } from '@mantine/hooks' +import cl from 'clsx' + +import styles from './styles/main-layout.module.css' +import type { MainLayoutProps } from './types/main-layout-props.type' + +/** + * Основной layout кабинета с Mantine AppShell. + * + * Используется для: + * - оборачивания защищённых маршрутов кабинета + * - отображения шапки, сайдбара и футера + */ +export const MainLayout = (props: MainLayoutProps) => { + const { children, className, onLogout, username, ...rootAttrs } = props + const [isNavbarOpened, navbar] = useDisclosure(false) + + return ( + + + + + + + + + AD + + + Assets Delivery + + + + + + + {username} + + + + + + + + + + Навигация + + + + + + + + + {children} + + + + + + + Control plane + + + REST + SWR + + + + + ) +} diff --git a/apps/cabinet/src/layouts/main/styles/main-layout.module.css b/apps/cabinet/src/layouts/main/styles/main-layout.module.css new file mode 100644 index 0000000..bb1b4d3 --- /dev/null +++ b/apps/cabinet/src/layouts/main/styles/main-layout.module.css @@ -0,0 +1,3 @@ +.root { + min-height: 100vh; +} diff --git a/apps/cabinet/src/layouts/main/types/main-layout-props.type.ts b/apps/cabinet/src/layouts/main/types/main-layout-props.type.ts new file mode 100644 index 0000000..30ecd7a --- /dev/null +++ b/apps/cabinet/src/layouts/main/types/main-layout-props.type.ts @@ -0,0 +1,18 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react' + +/** + * Параметры MainLayout. + */ +export type MainLayoutParams = { + /** Контент текущего маршрута. */ + children: ReactNode + /** Имя авторизованного администратора. */ + username: string + /** Обработчик выхода из кабинета. */ + onLogout: () => void +} + +/** Атрибуты корневого элемента без children. */ +type RootAttrs = Omit, 'children'> + +export type MainLayoutProps = RootAttrs & MainLayoutParams diff --git a/apps/cabinet/src/screens/login/index.ts b/apps/cabinet/src/screens/login/index.ts new file mode 100644 index 0000000..1fc37d1 --- /dev/null +++ b/apps/cabinet/src/screens/login/index.ts @@ -0,0 +1,2 @@ +export { LoginScreen } from './login.screen' +export type { LoginScreenProps } from './types/login-screen-props.type' diff --git a/apps/cabinet/src/screens/login/login.screen.tsx b/apps/cabinet/src/screens/login/login.screen.tsx new file mode 100644 index 0000000..84ea4a8 --- /dev/null +++ b/apps/cabinet/src/screens/login/login.screen.tsx @@ -0,0 +1,106 @@ +import { + Alert, + Box, + Button, + Center, + Paper, + PasswordInput, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from '@mantine/core' +import { useForm } from '@mantine/form' +import { notifications } from '@mantine/notifications' +import cl from 'clsx' +import { Navigate, useNavigate } from 'react-router-dom' +import { authFactory } from 'business/auth' + +import styles from './styles/login.module.css' +import type { LoginScreenProps } from './types/login-screen-props.type' + +const auth = authFactory() + +/** + * Экран входа администратора. + * + * Используется для: + * - получения admin Bearer token + * - входа в защищенный layout кабинета + */ +export const LoginScreen = (props: LoginScreenProps) => { + const { className, ...rootAttrs } = props + const navigate = useNavigate() + const session = auth.useAdminSession() + const loginAction = auth.useLogin() + const form = useForm({ + initialValues: { + password: 'admin', + username: 'admin', + }, + }) + + if (session.isAuthenticated) { + return + } + + const handleSubmit = form.onSubmit(async (values) => { + try { + await loginAction.login(values) + notifications.show({ + color: 'green', + message: 'Admin-сессия активна.', + title: 'Вход выполнен', + }) + navigate('/', { replace: true }) + } catch (error) { + notifications.show({ + color: 'red', + message: error instanceof Error ? error.message : 'Не удалось войти.', + title: 'Ошибка входа', + }) + } + }) + + return ( +
+
+ + + + + AD + + + Assets Delivery + + + Вход в кабинет управления платформой. + + + + +
+ + {loginAction.error ? ( + + {loginAction.error.message} + + ) : null} + + + + + + +
+
+
+
+
+
+ ) +} diff --git a/apps/cabinet/src/screens/login/styles/login.module.css b/apps/cabinet/src/screens/login/styles/login.module.css new file mode 100644 index 0000000..bb1b4d3 --- /dev/null +++ b/apps/cabinet/src/screens/login/styles/login.module.css @@ -0,0 +1,3 @@ +.root { + min-height: 100vh; +} diff --git a/apps/cabinet/src/screens/login/types/login-screen-props.type.ts b/apps/cabinet/src/screens/login/types/login-screen-props.type.ts new file mode 100644 index 0000000..b17d6dd --- /dev/null +++ b/apps/cabinet/src/screens/login/types/login-screen-props.type.ts @@ -0,0 +1,11 @@ +import type { ComponentPropsWithoutRef } from 'react' + +/** + * Параметры LoginScreen. + */ +export type LoginScreenParams = Record + +/** Атрибуты корневого элемента без children. */ +type RootAttrs = Omit, 'children'> + +export type LoginScreenProps = RootAttrs & LoginScreenParams diff --git a/apps/cabinet/src/screens/session/index.ts b/apps/cabinet/src/screens/session/index.ts new file mode 100644 index 0000000..bdc20e6 --- /dev/null +++ b/apps/cabinet/src/screens/session/index.ts @@ -0,0 +1,2 @@ +export { SessionScreen } from './session.screen' +export type { SessionScreenProps } from './types/session-screen-props.type' diff --git a/apps/cabinet/src/screens/session/session.screen.tsx b/apps/cabinet/src/screens/session/session.screen.tsx new file mode 100644 index 0000000..70dcba3 --- /dev/null +++ b/apps/cabinet/src/screens/session/session.screen.tsx @@ -0,0 +1,52 @@ +import { Badge, Group, Paper, Stack, Text, ThemeIcon, Title } from '@mantine/core' +import cl from 'clsx' +import { authFactory } from 'business/auth' + +import styles from './styles/session.module.css' +import type { SessionScreenProps } from './types/session-screen-props.type' + +const auth = authFactory() + +/** + * Экран активной admin-сессии. + * + * Используется для: + * - подтверждения успешного входа + * - отображения базового состояния кабинета до продуктовых экранов + */ +export const SessionScreen = (props: SessionScreenProps) => { + const { className, ...rootAttrs } = props + const session = auth.useAdminSession() + + return ( +
+ + + + + + OK + +
+ Вход выполнен + Admin-сессия получена через новый backend. +
+
+ + + active + +
+ + + Пользователь: {session.session?.username ?? 'admin'} + + + + Продуктовые экраны не подключены. Следующим шагом сюда можно добавить проекты и токены доступа к проектам. + +
+
+
+ ) +} diff --git a/apps/cabinet/src/screens/session/styles/session.module.css b/apps/cabinet/src/screens/session/styles/session.module.css new file mode 100644 index 0000000..63d08ec --- /dev/null +++ b/apps/cabinet/src/screens/session/styles/session.module.css @@ -0,0 +1,3 @@ +.root { + display: block; +} diff --git a/apps/cabinet/src/screens/session/types/session-screen-props.type.ts b/apps/cabinet/src/screens/session/types/session-screen-props.type.ts new file mode 100644 index 0000000..271c482 --- /dev/null +++ b/apps/cabinet/src/screens/session/types/session-screen-props.type.ts @@ -0,0 +1,11 @@ +import type { ComponentPropsWithoutRef } from 'react' + +/** + * Параметры SessionScreen. + */ +export type SessionScreenParams = Record + +/** Атрибуты корневого элемента без children. */ +type RootAttrs = Omit, 'children'> + +export type SessionScreenProps = RootAttrs & SessionScreenParams diff --git a/apps/cabinet/src/shared/styles/global.css b/apps/cabinet/src/shared/styles/global.css new file mode 100644 index 0000000..8719051 --- /dev/null +++ b/apps/cabinet/src/shared/styles/global.css @@ -0,0 +1,3 @@ +@import "@mantine/core/styles.css"; +@import "@mantine/notifications/styles.css"; +@import "./variables.css"; diff --git a/apps/cabinet/src/shared/styles/media.css b/apps/cabinet/src/shared/styles/media.css new file mode 100644 index 0000000..87f37af --- /dev/null +++ b/apps/cabinet/src/shared/styles/media.css @@ -0,0 +1,17 @@ +/* Ширина - Mobile First (min-width), кроме --xs (max-width). */ +@custom-media --xs (max-width: 35.9375rem); +@custom-media --sm (min-width: 36rem); +@custom-media --md (min-width: 48rem); +@custom-media --lg (min-width: 62rem); +@custom-media --xl (min-width: 75rem); +@custom-media --2xl (min-width: 88rem); +@custom-media --3xl (min-width: 120rem); + +/* Высота - min-height. */ +@custom-media --h-xs (min-height: 41.6875rem); +@custom-media --h-sm (min-height: 43.875rem); +@custom-media --h-md (min-height: 50.625rem); +@custom-media --h-lg (min-height: 56.25rem); +@custom-media --h-xl (min-height: 62.5rem); +@custom-media --h-2xl (min-height: 68.75rem); +@custom-media --h-3xl (min-height: 75rem); diff --git a/apps/cabinet/src/shared/styles/variables.css b/apps/cabinet/src/shared/styles/variables.css new file mode 100644 index 0000000..e6fa2cf --- /dev/null +++ b/apps/cabinet/src/shared/styles/variables.css @@ -0,0 +1,12 @@ +:root { + --color-primary: #228be6; + --color-bg: #ffffff; + --color-bg-hover: #f8f9fa; + --color-text: #1a1b1e; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --radius-1: 4px; + --radius-2: 8px; +} diff --git a/apps/cabinet/src/vite-env.d.ts b/apps/cabinet/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/cabinet/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/cabinet/tsconfig.app.json b/apps/cabinet/tsconfig.app.json new file mode 100644 index 0000000..185e86a --- /dev/null +++ b/apps/cabinet/tsconfig.app.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "app/*": ["src/app/*"], + "layouts/*": ["src/layouts/*"], + "screens/*": ["src/screens/*"], + "widgets/*": ["src/widgets/*"], + "business/*": ["src/business/*"], + "infra/*": ["src/infra/*"], + "ui/*": ["src/ui/*"], + "shared/*": ["src/shared/*"] + } + }, + "include": ["src"] +} diff --git a/apps/cabinet/tsconfig.json b/apps/cabinet/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/apps/cabinet/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/apps/cabinet/tsconfig.node.json b/apps/cabinet/tsconfig.node.json new file mode 100644 index 0000000..636c3dd --- /dev/null +++ b/apps/cabinet/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/apps/cabinet/vite.config.ts b/apps/cabinet/vite.config.ts new file mode 100644 index 0000000..4d1fc7c --- /dev/null +++ b/apps/cabinet/vite.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath, URL } from 'node:url' + +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +const srcPath = (path: string) => fileURLToPath(new URL(`./src/${path}`, import.meta.url)) +const backendProxyTarget = process.env.CABINET_BACKEND_PROXY_TARGET ?? 'http://localhost:3001' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + app: srcPath('app'), + layouts: srcPath('layouts'), + screens: srcPath('screens'), + widgets: srcPath('widgets'), + business: srcPath('business'), + infra: srcPath('infra'), + ui: srcPath('ui'), + shared: srcPath('shared'), + }, + }, + server: { + proxy: { + '/api': { + changeOrigin: true, + target: backendProxyTarget, + }, + }, + }, +}) diff --git a/apps/old-backend/nest-cli.json b/apps/old-backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/old-backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/old-backend/package.json b/apps/old-backend/package.json new file mode 100644 index 0000000..f736f6c --- /dev/null +++ b/apps/old-backend/package.json @@ -0,0 +1,35 @@ +{ + "name": "@image-platform/old-backend", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "dev": "node --env-file-if-exists=../../.env ./node_modules/@nestjs/cli/bin/nest.js start --watch", + "start": "node --env-file-if-exists=../../.env dist/main.js", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@image-platform/database": "workspace:*", + "@image-platform/image-config": "workspace:*", + "@image-platform/queue": "workspace:*", + "@image-platform/storage": "workspace:*", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.0.0", + "amqplib": "^1.0.4", + "drizzle-orm": "^0.45.2", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/amqplib": "^0.10.8", + "@types/express": "^5.0.6", + "@types/node": "^24.0.0", + "@types/swagger-ui-express": "^4.1.8", + "typescript": "^5.9.0" + } +} diff --git a/apps/old-backend/src/app.module.ts b/apps/old-backend/src/app.module.ts new file mode 100644 index 0000000..b5710d5 --- /dev/null +++ b/apps/old-backend/src/app.module.ts @@ -0,0 +1,19 @@ +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" + +@Module({ + controllers: [HealthController, AssetsController, InternalImagesController, PresetsController, ProjectsController], + providers: [AssetsService, DatabaseService, InternalImagesService, ProjectsService, QueueService, StorageService], +}) +export class AppModule {} diff --git a/apps/backend/src/assets/asset-picture-response.dto.ts b/apps/old-backend/src/assets/asset-picture-response.dto.ts similarity index 100% rename from apps/backend/src/assets/asset-picture-response.dto.ts rename to apps/old-backend/src/assets/asset-picture-response.dto.ts diff --git a/apps/backend/src/assets/asset-response.dto.ts b/apps/old-backend/src/assets/asset-response.dto.ts similarity index 100% rename from apps/backend/src/assets/asset-response.dto.ts rename to apps/old-backend/src/assets/asset-response.dto.ts diff --git a/apps/backend/src/assets/assets.controller.ts b/apps/old-backend/src/assets/assets.controller.ts similarity index 100% rename from apps/backend/src/assets/assets.controller.ts rename to apps/old-backend/src/assets/assets.controller.ts diff --git a/apps/backend/src/assets/assets.service.ts b/apps/old-backend/src/assets/assets.service.ts similarity index 100% rename from apps/backend/src/assets/assets.service.ts rename to apps/old-backend/src/assets/assets.service.ts diff --git a/apps/backend/src/assets/create-asset-variants.dto.ts b/apps/old-backend/src/assets/create-asset-variants.dto.ts similarity index 100% rename from apps/backend/src/assets/create-asset-variants.dto.ts rename to apps/old-backend/src/assets/create-asset-variants.dto.ts diff --git a/apps/backend/src/assets/create-asset-version.dto.ts b/apps/old-backend/src/assets/create-asset-version.dto.ts similarity index 100% rename from apps/backend/src/assets/create-asset-version.dto.ts rename to apps/old-backend/src/assets/create-asset-version.dto.ts diff --git a/apps/backend/src/assets/create-asset.dto.ts b/apps/old-backend/src/assets/create-asset.dto.ts similarity index 100% rename from apps/backend/src/assets/create-asset.dto.ts rename to apps/old-backend/src/assets/create-asset.dto.ts diff --git a/apps/backend/src/assets/source-url.ts b/apps/old-backend/src/assets/source-url.ts similarity index 100% rename from apps/backend/src/assets/source-url.ts rename to apps/old-backend/src/assets/source-url.ts diff --git a/apps/old-backend/src/health/health-response.dto.ts b/apps/old-backend/src/health/health-response.dto.ts new file mode 100644 index 0000000..38f07e1 --- /dev/null +++ b/apps/old-backend/src/health/health-response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger" + +export class HealthResponseDto { + @ApiProperty({ description: "Имя сервиса, который вернул health-check ответ.", example: "image-platform-api" }) + service!: string + + @ApiProperty({ description: "Текущее состояние сервиса.", example: "ok" }) + status!: string +} diff --git a/apps/old-backend/src/health/health.controller.ts b/apps/old-backend/src/health/health.controller.ts new file mode 100644 index 0000000..45701f5 --- /dev/null +++ b/apps/old-backend/src/health/health.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get } from "@nestjs/common" +import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger" + +import { HealthResponseDto } from "./health-response.dto" + +@ApiTags("system") +@Controller("health") +export class HealthController { + @Get() + @ApiOperation({ + summary: "проверить состояние Backend API", + description: "Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests.", + }) + @ApiOkResponse({ description: "Backend API доступен и отвечает.", type: HealthResponseDto }) + getHealth(): HealthResponseDto { + return { + service: "image-platform-api", + status: "ok", + } + } +} diff --git a/apps/old-backend/src/infra/database.service.ts b/apps/old-backend/src/infra/database.service.ts new file mode 100644 index 0000000..398c69a --- /dev/null +++ b/apps/old-backend/src/infra/database.service.ts @@ -0,0 +1,14 @@ +import { Injectable, OnModuleDestroy } from "@nestjs/common" +import { createDatabase, createDatabasePool } from "@image-platform/database" +import type { Database } from "@image-platform/database" + +@Injectable() +export class DatabaseService implements OnModuleDestroy { + private readonly pool = createDatabasePool() + + readonly db: Database = createDatabase(this.pool) + + async onModuleDestroy() { + await this.pool.end() + } +} diff --git a/apps/backend/src/infra/queue.service.ts b/apps/old-backend/src/infra/queue.service.ts similarity index 100% rename from apps/backend/src/infra/queue.service.ts rename to apps/old-backend/src/infra/queue.service.ts diff --git a/apps/backend/src/infra/storage.service.ts b/apps/old-backend/src/infra/storage.service.ts similarity index 100% rename from apps/backend/src/infra/storage.service.ts rename to apps/old-backend/src/infra/storage.service.ts diff --git a/apps/backend/src/internal-images/ensure-image-variant.dto.ts b/apps/old-backend/src/internal-images/ensure-image-variant.dto.ts similarity index 100% rename from apps/backend/src/internal-images/ensure-image-variant.dto.ts rename to apps/old-backend/src/internal-images/ensure-image-variant.dto.ts diff --git a/apps/backend/src/internal-images/internal-images.controller.ts b/apps/old-backend/src/internal-images/internal-images.controller.ts similarity index 100% rename from apps/backend/src/internal-images/internal-images.controller.ts rename to apps/old-backend/src/internal-images/internal-images.controller.ts diff --git a/apps/backend/src/internal-images/internal-images.service.ts b/apps/old-backend/src/internal-images/internal-images.service.ts similarity index 100% rename from apps/backend/src/internal-images/internal-images.service.ts rename to apps/old-backend/src/internal-images/internal-images.service.ts diff --git a/apps/old-backend/src/main.ts b/apps/old-backend/src/main.ts new file mode 100644 index 0000000..8878bcb --- /dev/null +++ b/apps/old-backend/src/main.ts @@ -0,0 +1,40 @@ +import { NestFactory } from "@nestjs/core" +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger" + +import { AppModule } from "./app.module" + +async function bootstrap() { + const app = await NestFactory.create(AppModule) + + app.setGlobalPrefix("api") + app.enableShutdownHooks() + + const openApiConfig = new DocumentBuilder() + .setTitle("Image Platform API") + .setDescription( + "Backend API для управления image assets, metadata в PostgreSQL, S3 variants, RabbitMQ jobs и генерацией через imgproxy.", + ) + .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.") + .build() + + const openApiDocument = SwaggerModule.createDocument(app, openApiConfig) + + SwaggerModule.setup("docs", app, openApiDocument, { + jsonDocumentUrl: "docs-json", + swaggerOptions: { + persistAuthorization: true, + }, + }) + + const port = Number.parseInt(process.env.BACKEND_PORT ?? process.env.API_PORT ?? "3001", 10) + + await app.listen(port) +} + +void bootstrap() diff --git a/apps/backend/src/presets/presets.controller.ts b/apps/old-backend/src/presets/presets.controller.ts similarity index 100% rename from apps/backend/src/presets/presets.controller.ts rename to apps/old-backend/src/presets/presets.controller.ts diff --git a/apps/backend/src/presets/presets.dto.ts b/apps/old-backend/src/presets/presets.dto.ts similarity index 100% rename from apps/backend/src/presets/presets.dto.ts rename to apps/old-backend/src/presets/presets.dto.ts diff --git a/apps/old-backend/src/projects/project-slug.ts b/apps/old-backend/src/projects/project-slug.ts new file mode 100644 index 0000000..2f4b6a6 --- /dev/null +++ b/apps/old-backend/src/projects/project-slug.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from "@nestjs/common" + +export function normalizeProjectSlug(value: 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 -") + } + + return normalized +} + +export function createProjectSlug(name: string) { + const slug = name + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9_-]+/g, "-") + .replaceAll(/^-+|-+$/g, "") + + return normalizeProjectSlug(slug || "project") +} diff --git a/apps/old-backend/src/projects/projects.controller.ts b/apps/old-backend/src/projects/projects.controller.ts new file mode 100644 index 0000000..6ebce2a --- /dev/null +++ b/apps/old-backend/src/projects/projects.controller.ts @@ -0,0 +1,96 @@ +import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common" +import { + ApiBadRequestResponse, + ApiConflictResponse, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} 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 { ProjectsService } from "./projects.service" + +@ApiTags("projects") +@Controller("projects") +export class ProjectsController { + constructor( + private readonly assets: AssetsService, + private readonly projects: ProjectsService, + ) {} + + @Get() + @ApiOperation({ + summary: "получить список проектов", + description: "Возвращает проекты верхнего уровня для главной страницы admin.", + }) + @ApiOkResponse({ description: "Список проектов возвращён.", type: ProjectsListResponseDto }) + listProjects(): Promise { + return this.projects.listProjects() + } + + @Post() + @ApiOperation({ + summary: "создать проект", + description: "Создаёт проект, внутри которого admin управляет assets и source versions.", + }) + @ApiCreatedResponse({ description: "Проект создан.", type: ProjectResponseDto }) + @ApiBadRequestResponse({ description: "Некорректные name или slug." }) + @ApiConflictResponse({ description: "Проект с таким slug уже существует." }) + createProject(@Body() request: CreateProjectRequestDto): Promise { + return this.projects.createProject(request) + } + + @Get(":projectSlug") + @ApiOperation({ + summary: "получить проект по slug", + description: "Возвращает metadata проекта для project-level страницы admin.", + }) + @ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" }) + @ApiOkResponse({ description: "Проект найден.", type: ProjectResponseDto }) + @ApiNotFoundResponse({ description: "Проект не найден." }) + getProject(@Param("projectSlug") projectSlug: string): Promise { + 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 { + 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 { + return this.assets.createAsset(request, projectSlug) + } +} diff --git a/apps/backend/src/projects/projects.dto.ts b/apps/old-backend/src/projects/projects.dto.ts similarity index 100% rename from apps/backend/src/projects/projects.dto.ts rename to apps/old-backend/src/projects/projects.dto.ts diff --git a/apps/old-backend/src/projects/projects.service.ts b/apps/old-backend/src/projects/projects.service.ts new file mode 100644 index 0000000..0be82cf --- /dev/null +++ b/apps/old-backend/src/projects/projects.service.ts @@ -0,0 +1,107 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common" +import { imageAssets, imageProjects } from "@image-platform/database" +import { count, desc, eq, inArray } from "drizzle-orm" + +import { DatabaseService } from "../infra/database.service" +import { createProjectSlug, normalizeProjectSlug } from "./project-slug" +import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto" + +@Injectable() +export class ProjectsService { + constructor(private readonly database: DatabaseService) {} + + async listProjects(): Promise { + const projects = await this.database.db.select().from(imageProjects).orderBy(desc(imageProjects.createdAt)) + const assetsCountByProjectId = await this.countAssetsByProjectIds(projects.map((project) => project.id)) + + return { + projects: projects.map((project) => mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)), + } + } + + async createProject(request: CreateProjectRequestDto): Promise { + 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() + + if (!project) { + throw new Error("failed to create project") + } + + return mapProjectResponse(project, 0) + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException("project slug already exists") + } + + throw error + } + } + + async getProject(slug: string): Promise { + const project = await this.loadProject(slug) + const assetsCountByProjectId = await this.countAssetsByProjectIds([project.id]) + + return mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0) + } + + async loadProject(slug: string) { + const normalizedSlug = normalizeProjectSlug(slug) + const [project] = await this.database.db + .select() + .from(imageProjects) + .where(eq(imageProjects.slug, normalizedSlug)) + .limit(1) + + if (!project || project.status !== "active") { + throw new NotFoundException("project not found") + } + + return project + } + + private async countAssetsByProjectIds(projectIds: string[]) { + if (projectIds.length === 0) { + return new Map() + } + + 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 { + return { + assetsCount, + createdAt: row.createdAt.toISOString(), + id: row.id, + name: row.name, + slug: row.slug, + status: row.status, + updatedAt: row.updatedAt.toISOString(), + } +} + +function normalizeProjectName(value: string) { + const normalized = value.trim() + + if (!normalized || normalized.length > 120) { + throw new BadRequestException("project name must be 1-120 chars") + } + + return normalized +} + +function isUniqueViolation(error: unknown) { + return typeof error === "object" && error !== null && "code" in error && error.code === "23505" +} diff --git a/apps/old-backend/tsconfig.build.json b/apps/old-backend/tsconfig.build.json new file mode 100644 index 0000000..e79b11e --- /dev/null +++ b/apps/old-backend/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "noEmit": false + }, + "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"] +} diff --git a/apps/old-backend/tsconfig.json b/apps/old-backend/tsconfig.json new file mode 100644 index 0000000..c60455f --- /dev/null +++ b/apps/old-backend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "baseUrl": "./", + "declaration": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "incremental": true, + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./dist", + "removeComments": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "strictPropertyInitialization": false, + "target": "ES2023", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index fc024f5..cae4679 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,11 @@ "backend:dev": "pnpm image-config:build && pnpm db:build && pnpm queue:build && pnpm storage:build && pnpm --filter @image-platform/backend dev", "backend:start": "pnpm image-config:build && pnpm db:build && pnpm queue:build && pnpm storage:build && pnpm --filter @image-platform/backend start", "backend:typecheck": "pnpm --filter @image-platform/backend typecheck", + "cabinet:build": "pnpm --filter @image-platform/cabinet build", + "cabinet:codegen": "pnpm --filter @image-platform/cabinet codegen:backend-api", + "cabinet:dev": "pnpm --filter @image-platform/cabinet dev", + "cabinet:preview": "pnpm --filter @image-platform/cabinet preview", + "cabinet:typecheck": "pnpm --filter @image-platform/cabinet typecheck", "client:build": "pnpm --filter @image-platform/client build", "client:typecheck": "pnpm --filter @image-platform/client typecheck", "db:build": "pnpm --filter @image-platform/database build", @@ -43,6 +48,6 @@ "infra:up": "docker compose -f infra/compose.dev.yml up -d", "infra:down": "docker compose -f infra/compose.dev.yml down", "infra:logs": "docker compose -f infra/compose.dev.yml logs -f", - "check": "pnpm infra:config && pnpm image-config:typecheck && pnpm client:typecheck && pnpm db:typecheck && pnpm queue:typecheck && pnpm storage:typecheck && pnpm backend:typecheck && pnpm admin:typecheck && pnpm gateway:typecheck && pnpm worker:typecheck" + "check": "pnpm infra:config && pnpm image-config:typecheck && pnpm client:typecheck && pnpm db:typecheck && pnpm queue:typecheck && pnpm storage:typecheck && pnpm backend:typecheck && pnpm admin:typecheck && pnpm cabinet:typecheck && pnpm gateway:typecheck && pnpm worker:typecheck" } } diff --git a/packages/database/drizzle/0004_complex_yellowjacket.sql b/packages/database/drizzle/0004_complex_yellowjacket.sql new file mode 100644 index 0000000..5ddbf9f --- /dev/null +++ b/packages/database/drizzle/0004_complex_yellowjacket.sql @@ -0,0 +1,29 @@ +CREATE TYPE "public"."project_access_token_status" AS ENUM('active', 'revoked');--> statement-breakpoint +CREATE TABLE "project_access_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "name" text NOT NULL, + "token_prefix" text NOT NULL, + "token_hash" text NOT NULL, + "scopes" jsonb NOT NULL, + "status" "project_access_token_status" DEFAULT 'active' NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "status" "project_status" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "project_access_tokens" ADD CONSTRAINT "project_access_tokens_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "project_access_tokens_project_id_idx" ON "project_access_tokens" USING btree ("project_id");--> statement-breakpoint +CREATE UNIQUE INDEX "project_access_tokens_prefix_idx" ON "project_access_tokens" USING btree ("token_prefix");--> statement-breakpoint +CREATE UNIQUE INDEX "project_access_tokens_hash_idx" ON "project_access_tokens" USING btree ("token_hash");--> statement-breakpoint +CREATE UNIQUE INDEX "projects_slug_idx" ON "projects" USING btree ("slug"); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0004_snapshot.json b/packages/database/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..6bbd0fb --- /dev/null +++ b/packages/database/drizzle/meta/0004_snapshot.json @@ -0,0 +1,967 @@ +{ + "id": "611b84db-81ef-47a6-bc62-fa7e797d88da", + "prevId": "f407b041-197f-4277-8c7d-2b5ce920adc5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.allowed_image_hosts": { + "name": "allowed_image_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "allowed_image_hosts_hostname_idx": { + "name": "allowed_image_hosts_hostname_idx", + "columns": [ + { + "expression": "hostname", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_asset_versions": { + "name": "image_asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_host": { + "name": "source_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_s3_key": { + "name": "original_s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_asset_versions_asset_version_idx": { + "name": "image_asset_versions_asset_version_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_asset_versions_source_hash_idx": { + "name": "image_asset_versions_source_hash_idx", + "columns": [ + { + "expression": "source_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "image_asset_versions_asset_id_image_assets_id_fk": { + "name": "image_asset_versions_asset_id_image_assets_id_fk", + "tableFrom": "image_asset_versions", + "tableTo": "image_assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_assets": { + "name": "image_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_assets_public_id_idx": { + "name": "image_assets_public_id_idx", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_assets_project_id_idx": { + "name": "image_assets_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "image_assets_project_id_image_projects_id_fk": { + "name": "image_assets_project_id_image_projects_id_fk", + "tableFrom": "image_assets", + "tableTo": "image_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_projects": { + "name": "image_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_projects_slug_idx": { + "name": "image_projects_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_variants": { + "name": "image_variants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_version_id": { + "name": "asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_version": { + "name": "asset_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_hash": { + "name": "variant_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_format": { + "name": "requested_format", + "type": "requested_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "format": { + "name": "format", + "type": "variant_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "resize_mode": { + "name": "resize_mode", + "type": "resize_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fit'" + }, + "quality": { + "name": "quality", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "variant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_variants_lookup_idx": { + "name": "image_variants_lookup_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "preset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "width", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "height", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resize_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quality", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "format", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_variants_s3_key_idx": { + "name": "image_variants_s3_key_idx", + "columns": [ + { + "expression": "s3_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_variants_variant_hash_idx": { + "name": "image_variants_variant_hash_idx", + "columns": [ + { + "expression": "variant_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_variants_status_idx": { + "name": "image_variants_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "image_variants_asset_id_image_assets_id_fk": { + "name": "image_variants_asset_id_image_assets_id_fk", + "tableFrom": "image_variants", + "tableTo": "image_assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_variants_asset_version_id_image_asset_versions_id_fk": { + "name": "image_variants_asset_version_id_image_asset_versions_id_fk", + "tableFrom": "image_variants", + "tableTo": "image_asset_versions", + "columnsFrom": [ + "asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_access_tokens": { + "name": "project_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "project_access_token_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_access_tokens_project_id_idx": { + "name": "project_access_tokens_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_access_tokens_prefix_idx": { + "name": "project_access_tokens_prefix_idx", + "columns": [ + { + "expression": "token_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_access_tokens_hash_idx": { + "name": "project_access_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_tokens_project_id_projects_id_fk": { + "name": "project_access_tokens_project_id_projects_id_fk", + "tableFrom": "project_access_tokens", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_slug_idx": { + "name": "projects_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "active", + "disabled", + "deleted" + ] + }, + "public.project_access_token_status": { + "name": "project_access_token_status", + "schema": "public", + "values": [ + "active", + "revoked" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.requested_format": { + "name": "requested_format", + "schema": "public", + "values": [ + "auto", + "avif", + "webp", + "jpg", + "png" + ] + }, + "public.resize_mode": { + "name": "resize_mode", + "schema": "public", + "values": [ + "fit", + "fill" + ] + }, + "public.variant_format": { + "name": "variant_format", + "schema": "public", + "values": [ + "avif", + "webp", + "jpg", + "png" + ] + }, + "public.variant_status": { + "name": "variant_status", + "schema": "public", + "values": [ + "pending", + "processing", + "ready", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 9b3ea42..07b1e69 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1778007484095, "tag": "0002_grey_ser_duncan", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1778563561633, + "tag": "0004_complex_yellowjacket", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index f84c13b..e35d64b 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -3,6 +3,7 @@ import { boolean, index, integer, + jsonb, pgEnum, pgTable, text, @@ -12,12 +13,22 @@ import { } from "drizzle-orm/pg-core" export const assetStatusEnum = pgEnum("asset_status", ["active", "disabled", "deleted"]) +export const projectAccessTokenStatusEnum = pgEnum("project_access_token_status", ["active", "revoked"]) export const projectStatusEnum = pgEnum("project_status", ["active", "disabled"]) export const variantFormatEnum = pgEnum("variant_format", ["avif", "webp", "jpg", "png"]) export const requestedFormatEnum = pgEnum("requested_format", ["auto", "avif", "webp", "jpg", "png"]) export const resizeModeEnum = pgEnum("resize_mode", ["fit", "fill"]) export const variantStatusEnum = pgEnum("variant_status", ["pending", "processing", "ready", "failed"]) +export type ProjectAccessTokenScope = + | "assets:read" + | "assets:write" + | "assets:delete" + | "presets:read" + | "presets:write" + | "builds:read" + | "builds:write" + const timestamps = { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), @@ -35,6 +46,41 @@ export const allowedImageHosts = pgTable( (table) => [uniqueIndex("allowed_image_hosts_hostname_idx").on(table.hostname)], ) +export const projects = pgTable( + "projects", + { + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug").notNull(), + name: text("name").notNull(), + status: projectStatusEnum("status").notNull().default("active"), + ...timestamps, + }, + (table) => [uniqueIndex("projects_slug_idx").on(table.slug)], +) + +export const projectAccessTokens = pgTable( + "project_access_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + tokenPrefix: text("token_prefix").notNull(), + tokenHash: text("token_hash").notNull(), + scopes: jsonb("scopes").$type().notNull(), + status: projectAccessTokenStatusEnum("status").notNull().default("active"), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + ...timestamps, + }, + (table) => [ + index("project_access_tokens_project_id_idx").on(table.projectId), + uniqueIndex("project_access_tokens_prefix_idx").on(table.tokenPrefix), + uniqueIndex("project_access_tokens_hash_idx").on(table.tokenHash), + ], +) + export const imageProjects = pgTable( "image_projects", { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af6c9c5..65fac92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,135 @@ importers: version: 8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(terser@5.46.2)(tsx@4.21.0) apps/backend: + dependencies: + '@image-platform/database': + specifier: workspace:* + version: link:../../packages/database + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.0.0 + version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/swagger': + specifier: ^11.0.0 + version: 11.4.2(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2) + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/pg@8.20.0)(pg@8.20.0) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.2.1) + devDependencies: + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.21(@types/node@24.12.2) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@4.0.3)(typescript@5.9.3) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^24.0.0 + version: 24.12.2 + '@types/swagger-ui-express': + specifier: ^4.1.8 + version: 4.1.8 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + + apps/cabinet: + dependencies: + '@mantine/core': + specifier: ^9.1.1 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/form': + specifier: ^9.1.1 + version: 9.1.1(react@19.2.5) + '@mantine/hooks': + specifier: ^9.1.1 + version: 9.1.1(react@19.2.5) + '@mantine/notifications': + specifier: ^9.1.1 + version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + react-router-dom: + specifier: ^7.15.0 + version: 7.15.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + swr: + specifier: ^2.4.1 + version: 2.4.1(react@19.2.5) + devDependencies: + '@csstools/postcss-global-data': + specifier: ^4.0.0 + version: 4.0.0(postcss@8.5.14) + '@types/node': + specifier: ^24.12.2 + version: 24.12.2 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(terser@5.46.2)(tsx@4.21.0)) + autoprefixer: + specifier: ^10.5.0 + version: 10.5.0(postcss@8.5.14) + postcss-custom-media: + specifier: ^12.0.1 + version: 12.0.1(postcss@8.5.14) + postcss-nesting: + specifier: ^14.0.0 + version: 14.0.0(postcss@8.5.14) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.0.10 + version: 8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(terser@5.46.2)(tsx@4.21.0) + + apps/gateway: + dependencies: + '@image-platform/image-config': + specifier: workspace:* + version: link:../../packages/image-config + fastify: + specifier: ^5.8.5 + version: 5.8.5 + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + apps/old-backend: dependencies: '@image-platform/database': specifier: workspace:* @@ -133,25 +262,6 @@ importers: specifier: ^5.9.0 version: 5.9.3 - apps/gateway: - dependencies: - '@image-platform/image-config': - specifier: workspace:* - version: link:../../packages/image-config - fastify: - specifier: ^5.8.5 - version: 5.8.5 - devDependencies: - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - apps/worker: dependencies: '@image-platform/database':