feat: добавить новый backend и cabinet

- добавлен новый Nest backend для auth, projects и project access tokens
- добавлена control-plane схема БД и миграция Drizzle
- перенесён старый backend в old-backend
- добавлен React/Vite cabinet с auth-only входом и Mantine layout
- обновлены workspace scripts и lockfile
This commit is contained in:
2026-05-12 09:22:04 +03:00
parent d49449c30c
commit 98295d0569
113 changed files with 3426 additions and 169 deletions

View File

@@ -22,7 +22,7 @@
После определения роли агент обязан открыть соответствующую инструкцию: После определения роли агент обязан открыть соответствующую инструкцию:
- `developer` → [DEVELOP.md](./ai/nextjs-style-guide/DEVELOP.md) - `developer` → [DEVELOP.md](./ai/DEVELOP.md)
Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия. Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия.

View File

@@ -19,7 +19,7 @@ export const MainLayout = (props: MainLayoutProps) => {
<AppShell {...rootAttrs} className={cl(styles.root, className)} header={{ height: 72 }} padding="md"> <AppShell {...rootAttrs} className={cl(styles.root, className)} header={{ height: 72 }} padding="md">
<AppShell.Header className={styles.header}> <AppShell.Header className={styles.header}>
<Group h="100%" justify="space-between" px={{ base: "md", md: "xl" }}> <Group h="100%" justify="space-between" px={{ base: "md", md: "xl" }}>
<Link className={styles.brand} to="/" aria-label="Админка платформы изображений"> <Link className={styles.brand} to="/" aria-label="Платформа изображений">
<ThemeIcon className={styles.brandMark} radius="xl" size={42} variant="light"> <ThemeIcon className={styles.brandMark} radius="xl" size={42} variant="light">
IP IP
</ThemeIcon> </ThemeIcon>
@@ -27,8 +27,6 @@ export const MainLayout = (props: MainLayoutProps) => {
Платформа изображений Платформа изображений
</Text> </Text>
</Link> </Link>
<Text className={styles.sectionLabel}>Админка</Text>
</Group> </Group>
</AppShell.Header> </AppShell.Header>

View File

@@ -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 { .main {
background: transparent; background: transparent;
} }

View File

@@ -10,14 +10,10 @@
}, },
"dependencies": { "dependencies": {
"@image-platform/database": "workspace:*", "@image-platform/database": "workspace:*",
"@image-platform/image-config": "workspace:*",
"@image-platform/queue": "workspace:*",
"@image-platform/storage": "workspace:*",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.0.0", "@nestjs/swagger": "^11.0.0",
"amqplib": "^1.0.4",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@@ -26,7 +22,6 @@
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@types/amqplib": "^0.10.8",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",

View File

@@ -1,19 +1,11 @@
import { Module } from "@nestjs/common" import { Module } from "@nestjs/common"
import { AssetsController } from "./assets/assets.controller" import { AuthModule } from "./auth/auth.module"
import { AssetsService } from "./assets/assets.service" import { HealthModule } from "./health/health.module"
import { HealthController } from "./health/health.controller" import { ProjectAccessTokensModule } from "./project-access-tokens/project-access-tokens.module"
import { DatabaseService } from "./infra/database.service" import { ProjectsModule } from "./projects/projects.module"
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({ @Module({
controllers: [HealthController, AssetsController, InternalImagesController, PresetsController, ProjectsController], imports: [AuthModule, HealthModule, ProjectsModule, ProjectAccessTokensModule],
providers: [AssetsService, DatabaseService, InternalImagesService, ProjectsService, QueueService, StorageService],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Get, Post, UseGuards } from "@nestjs/common"
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger"
import { AuthService } from "./auth.service"
import { CurrentAdmin } from "./decorators/current-admin.decorator"
import { LoginRequestDto, LoginResponseDto } from "./dto/login.dto"
import { AdminSessionResponseDto } from "./dto/session.dto"
import { AdminAuthGuard } from "./guards/admin-auth.guard"
import type { AdminSession } from "./types/authenticated-request.type"
@ApiTags("auth")
@Controller("auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("login")
@ApiOperation({
summary: "войти как администратор",
description: "Проверяет фиксированную пару логин/пароль. По умолчанию используется admin/admin без публичной регистрации.",
})
@ApiOkResponse({ description: "Администратор авторизован, сессионный токен выдан.", type: LoginResponseDto })
@ApiUnauthorizedResponse({ description: "Логин или пароль неверны." })
login(@Body() request: LoginRequestDto): LoginResponseDto {
return this.auth.login(request)
}
@Get("me")
@UseGuards(AdminAuthGuard)
@ApiBearerAuth("adminAuth")
@ApiOperation({
summary: "получить текущую admin-сессию",
description: "Возвращает данные администратора из Bearer token.",
})
@ApiOkResponse({ description: "Admin-сессия активна.", type: AdminSessionResponseDto })
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
getMe(@CurrentAdmin() admin: AdminSession): AdminSessionResponseDto {
return {
username: admin.username,
}
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common"
import { DatabaseModule } from "../infra/database.module"
import { AuthController } from "./auth.controller"
import { AuthService } from "./auth.service"
import { AdminAuthGuard } from "./guards/admin-auth.guard"
@Module({
controllers: [AuthController],
exports: [AdminAuthGuard, AuthService],
imports: [DatabaseModule],
providers: [AdminAuthGuard, AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,132 @@
import { Injectable, UnauthorizedException } from "@nestjs/common"
import { createHmac, timingSafeEqual } from "node:crypto"
import type { AdminSession } from "./types/authenticated-request.type"
type AdminTokenPayload = {
exp: number
username: string
}
@Injectable()
export class AuthService {
private readonly adminPassword = process.env.ADMIN_PASSWORD ?? "admin"
private readonly adminTokenTtlSeconds = parsePositiveInteger(process.env.ADMIN_TOKEN_TTL_SECONDS, 86_400)
private readonly adminUsername = process.env.ADMIN_USERNAME ?? "admin"
private readonly secret = process.env.ADMIN_AUTH_SECRET ?? "development-admin-auth-secret"
login(input: { password: string; username: string }) {
if (
!input ||
typeof input.username !== "string" ||
typeof input.password !== "string" ||
input.username !== this.adminUsername ||
input.password !== this.adminPassword
) {
throw new UnauthorizedException("invalid admin credentials")
}
const expiresAt = new Date(Date.now() + this.adminTokenTtlSeconds * 1000)
const accessToken = this.signAdminToken({
exp: Math.floor(expiresAt.getTime() / 1000),
username: input.username,
})
return {
accessToken,
expiresAt: expiresAt.toISOString(),
tokenType: "Bearer" as const,
}
}
verifyAdminToken(token: string): AdminSession {
const parts = token.split(".")
if (parts.length !== 3 || parts[0] !== "admin") {
throw new UnauthorizedException("invalid admin token")
}
const [, payloadPart, signature] = parts
const expectedSignature = this.sign(payloadPart)
if (!safeEqual(signature, expectedSignature)) {
throw new UnauthorizedException("invalid admin token")
}
const payload = parsePayload(payloadPart)
if (payload.exp <= Math.floor(Date.now() / 1000)) {
throw new UnauthorizedException("admin token expired")
}
return {
username: payload.username,
}
}
private signAdminToken(payload: AdminTokenPayload) {
const payloadPart = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")
const signature = this.sign(payloadPart)
return `admin.${payloadPart}.${signature}`
}
private sign(payloadPart: string) {
return createHmac("sha256", this.secret).update(payloadPart).digest("base64url")
}
}
function parsePayload(payloadPart: string): AdminTokenPayload {
try {
const value = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8")) as unknown
if (!isPayload(value)) {
throw new Error("invalid payload")
}
return value
} catch {
throw new UnauthorizedException("invalid admin token")
}
}
function isPayload(value: unknown): value is AdminTokenPayload {
return (
typeof value === "object" &&
value !== null &&
"exp" in value &&
"username" in value &&
typeof value.exp === "number" &&
typeof value.username === "string" &&
value.username.length > 0
)
}
function safeEqual(left: string | undefined, right: string) {
if (!left) {
return false
}
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) {
return false
}
return timingSafeEqual(leftBuffer, rightBuffer)
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
if (!value) {
return fallback
}
const parsed = Number.parseInt(value, 10)
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
return fallback
}
return parsed
}

View File

@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
import type { AdminSession, AuthenticatedRequest } from "../types/authenticated-request.type"
export const CurrentAdmin = createParamDecorator((_data: unknown, context: ExecutionContext): AdminSession => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
if (!request.adminSession) {
throw new Error("admin session is missing in request context")
}
return request.adminSession
})

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger"
export class LoginRequestDto {
@ApiProperty({ description: "Логин администратора.", example: "admin" })
username!: string
@ApiProperty({ description: "Пароль администратора.", example: "admin" })
password!: string
}
export class LoginResponseDto {
@ApiProperty({ description: "Bearer token сессии администратора.", example: "admin.eyJ1c2VybmFtZSI6ImFkbWluIn0.signature" })
accessToken!: string
@ApiProperty({ description: "Тип токена для HTTP Authorization header.", example: "Bearer" })
tokenType!: "Bearer"
@ApiProperty({ description: "ISO-дата истечения сессии.", example: "2026-05-13T10:00:00.000Z" })
expiresAt!: string
}

View File

@@ -0,0 +1,6 @@
import { ApiProperty } from "@nestjs/swagger"
export class AdminSessionResponseDto {
@ApiProperty({ description: "Логин текущего администратора.", example: "admin" })
username!: string
}

View File

@@ -0,0 +1,32 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"
import { AuthService } from "../auth.service"
import type { AuthenticatedRequest } from "../types/authenticated-request.type"
@Injectable()
export class AdminAuthGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
const token = readBearerToken(request.headers.authorization)
request.adminSession = this.auth.verifyAdminToken(token)
return true
}
}
export function readBearerToken(header: string | undefined): string {
if (!header) {
throw new UnauthorizedException("authorization header is required")
}
const [type, token] = header.split(" ")
if (type !== "Bearer" || !token) {
throw new UnauthorizedException("bearer token is required")
}
return token
}

View File

@@ -0,0 +1,17 @@
import type { ProjectAccessTokenScope } from "@image-platform/database"
import type { Request } from "express"
export type AdminSession = {
username: string
}
export type ProjectAccess = {
projectId: string
scopes: ProjectAccessTokenScope[]
tokenId: string
}
export type AuthenticatedRequest = Request & {
adminSession?: AdminSession
projectAccess?: ProjectAccess
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common"
import { HealthController } from "./health.controller"
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,10 @@
import { Global, Module } from "@nestjs/common"
import { DatabaseService } from "./database.service"
@Global()
@Module({
exports: [DatabaseService],
providers: [DatabaseService],
})
export class DatabaseModule {}

View File

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

View File

@@ -10,17 +10,33 @@ async function bootstrap() {
app.enableShutdownHooks() app.enableShutdownHooks()
const openApiConfig = new DocumentBuilder() const openApiConfig = new DocumentBuilder()
.setTitle("Image Platform API") .setTitle("Assets Delivery Platform API")
.setDescription( .setDescription(
"Backend API для управления image assets, metadata в PostgreSQL, S3 variants, RabbitMQ jobs и генерацией через imgproxy.", "Control plane API для авторизации, проектов и токенов доступа. Image-модуль будет переноситься из old-backend отдельным vertical slice.",
) )
.setVersion("0.1.0") .setVersion("0.1.0")
.addTag("system", "Системные endpoints для проверки состояния сервиса.") .addBearerAuth(
.addTag("assets", "Регистрация и управление исходными изображениями.") {
.addTag("variants", "Будущие endpoints для управления производными версиями изображений.") bearerFormat: "Admin session token",
.addTag("allowed-hosts", "Будущие endpoints для управления разрешёнными source hosts.") description: "Токен сессии администратора, полученный через /api/auth/login.",
.addTag("internal-images", "Внутренние endpoints, которые вызывает Gateway на cache miss.") scheme: "bearer",
.addTag("presets", "Статические presets, custom limits и mock allowlist source hosts.") 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() .build()
const openApiDocument = SwaggerModule.createDocument(app, openApiConfig) const openApiDocument = SwaggerModule.createDocument(app, openApiConfig)

View File

@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
import type { AuthenticatedRequest, ProjectAccess } from "../../auth/types/authenticated-request.type"
export const CurrentProjectAccess = createParamDecorator((_data: unknown, context: ExecutionContext): ProjectAccess => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
if (!request.projectAccess) {
throw new Error("project access context is missing in request")
}
return request.projectAccess
})

View File

@@ -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)
}

View File

@@ -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[]
}

View File

@@ -0,0 +1,34 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"
import { Reflector } from "@nestjs/core"
import { readBearerToken } from "../../auth/guards/admin-auth.guard"
import type { AuthenticatedRequest } from "../../auth/types/authenticated-request.type"
import { REQUIRED_PROJECT_SCOPES_KEY } from "../decorators/required-project-scopes.decorator"
import { ProjectAccessTokensService } from "../project-access-tokens.service"
import type { ProjectAccessTokenScope } from "@image-platform/database"
@Injectable()
export class ProjectAccessTokenGuard implements CanActivate {
constructor(
private readonly projectAccessTokens: ProjectAccessTokensService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
const token = readBearerToken(request.headers.authorization)
const projectAccess = await this.projectAccessTokens.authenticate(token)
const requiredScopes = this.reflector.getAllAndOverride<ProjectAccessTokenScope[]>(REQUIRED_PROJECT_SCOPES_KEY, [
context.getHandler(),
context.getClass(),
])
if (requiredScopes?.some((scope) => !projectAccess.scopes.includes(scope))) {
throw new ForbiddenException("project access token does not have required scopes")
}
request.projectAccess = projectAccess
return true
}
}

View File

@@ -0,0 +1,106 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common"
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiConflictResponse,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger"
import { AdminAuthGuard } from "../auth/guards/admin-auth.guard"
import { CurrentProjectAccess } from "./decorators/current-project-access.decorator"
import { RequiredProjectScopes } from "./decorators/required-project-scopes.decorator"
import {
CreateProjectAccessTokenRequestDto,
CreateProjectAccessTokenResponseDto,
ProjectAccessContextResponseDto,
ProjectAccessTokenResponseDto,
ProjectAccessTokensListResponseDto,
} from "./dto/project-access-token.dto"
import { ProjectAccessTokenGuard } from "./guards/project-access-token.guard"
import { ProjectAccessTokensService } from "./project-access-tokens.service"
import type { ProjectAccess } from "../auth/types/authenticated-request.type"
@ApiTags("project-access-tokens")
@Controller()
export class ProjectAccessTokensController {
constructor(private readonly projectAccessTokens: ProjectAccessTokensService) {}
@Post("projects/:projectId/access-tokens")
@UseGuards(AdminAuthGuard)
@ApiBearerAuth("adminAuth")
@ApiOperation({
summary: "создать токен доступа к проекту",
description:
"Создаёт server-side токен для headless API. Секрет возвращается только в этом ответе, в базе хранится только hash.",
})
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
@ApiCreatedResponse({ description: "Токен создан, секрет показан один раз.", type: CreateProjectAccessTokenResponseDto })
@ApiBadRequestResponse({ description: "Некорректные name, scopes или projectId." })
@ApiConflictResponse({ description: "Сгенерированный prefix токена уже существует." })
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
createToken(
@Param("projectId") projectId: string,
@Body() request: CreateProjectAccessTokenRequestDto,
): Promise<CreateProjectAccessTokenResponseDto> {
return this.projectAccessTokens.createToken(projectId, request)
}
@Get("projects/:projectId/access-tokens")
@UseGuards(AdminAuthGuard)
@ApiBearerAuth("adminAuth")
@ApiOperation({
summary: "получить токены доступа проекта",
description: "Возвращает список токенов проекта без секретов. Для UI показывается только prefix и metadata.",
})
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
@ApiOkResponse({ description: "Список токенов проекта возвращён.", type: ProjectAccessTokensListResponseDto })
@ApiBadRequestResponse({ description: "projectId не является UUID." })
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
listTokens(@Param("projectId") projectId: string): Promise<ProjectAccessTokensListResponseDto> {
return this.projectAccessTokens.listTokens(projectId)
}
@Delete("projects/:projectId/access-tokens/:tokenId")
@UseGuards(AdminAuthGuard)
@ApiBearerAuth("adminAuth")
@ApiOperation({
summary: "отозвать токен доступа проекта",
description: "Переводит токен в revoked. После отзыва он больше не даёт доступ к project-scoped API.",
})
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
@ApiParam({ description: "UUID токена доступа.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991", name: "tokenId" })
@ApiOkResponse({ description: "Токен отозван.", type: ProjectAccessTokenResponseDto })
@ApiBadRequestResponse({ description: "projectId или tokenId не является UUID." })
@ApiNotFoundResponse({ description: "Проект или токен не найден." })
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
revokeToken(
@Param("projectId") projectId: string,
@Param("tokenId") tokenId: string,
): Promise<ProjectAccessTokenResponseDto> {
return this.projectAccessTokens.revokeToken(projectId, tokenId)
}
@Get("project-access/me")
@UseGuards(ProjectAccessTokenGuard)
@RequiredProjectScopes("assets:read")
@ApiBearerAuth("projectAccessToken")
@ApiOperation({
summary: "проверить токен доступа к проекту",
description: "Диагностический endpoint для headless-клиентов. Возвращает projectId и scopes текущего project access token.",
})
@ApiOkResponse({ description: "Project access token активен и имеет scope assets:read.", type: ProjectAccessContextResponseDto })
@ApiUnauthorizedResponse({ description: "Project access token отсутствует, отозван или некорректен." })
@ApiForbiddenResponse({ description: "Project access token не имеет требуемого scope." })
getProjectAccess(@CurrentProjectAccess() projectAccess: ProjectAccess): ProjectAccessContextResponseDto {
return projectAccess
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1,219 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"
import { projectAccessTokens, projects, type ProjectAccessTokenScope } from "@image-platform/database"
import { and, desc, eq } from "drizzle-orm"
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
import { DatabaseService } from "../infra/database.service"
import { ProjectsService } from "../projects/projects.service"
import { PROJECT_ACCESS_TOKEN_SCOPES, type ProjectAccessTokenScopeDto } from "./dto/project-access-token.dto"
import type {
CreateProjectAccessTokenRequestDto,
CreateProjectAccessTokenResponseDto,
ProjectAccessTokenResponseDto,
ProjectAccessTokensListResponseDto,
} from "./dto/project-access-token.dto"
@Injectable()
export class ProjectAccessTokensService {
constructor(
private readonly database: DatabaseService,
private readonly projectsService: ProjectsService,
) {}
async createToken(projectId: string, request: CreateProjectAccessTokenRequestDto): Promise<CreateProjectAccessTokenResponseDto> {
await this.projectsService.loadActiveProject(projectId)
if (!request) {
throw new BadRequestException("request body is required")
}
const name = normalizeTokenName(request.name)
const scopes = normalizeScopes(request.scopes)
const generated = generateToken()
try {
const [token] = await this.database.db
.insert(projectAccessTokens)
.values({
name,
projectId,
scopes,
tokenHash: hashToken(generated.secret),
tokenPrefix: generated.prefix,
})
.returning()
if (!token) {
throw new Error("failed to create project access token")
}
return {
...mapTokenResponse(token),
secret: generated.secret,
}
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException("project access token prefix already exists")
}
throw error
}
}
async listTokens(projectId: string): Promise<ProjectAccessTokensListResponseDto> {
await this.projectsService.loadActiveProject(projectId)
const rows = await this.database.db
.select()
.from(projectAccessTokens)
.where(eq(projectAccessTokens.projectId, projectId))
.orderBy(desc(projectAccessTokens.createdAt))
return {
tokens: rows.map(mapTokenResponse),
}
}
async revokeToken(projectId: string, tokenId: string): Promise<ProjectAccessTokenResponseDto> {
await this.projectsService.loadActiveProject(projectId)
assertUuid(tokenId, "tokenId")
const [token] = await this.database.db
.update(projectAccessTokens)
.set({ revokedAt: new Date(), status: "revoked", updatedAt: new Date() })
.where(and(eq(projectAccessTokens.id, tokenId), eq(projectAccessTokens.projectId, projectId)))
.returning()
if (!token) {
throw new NotFoundException("project access token not found")
}
return mapTokenResponse(token)
}
async authenticate(secret: string) {
const prefix = parseTokenPrefix(secret)
const [row] = await this.database.db
.select({
projectId: projectAccessTokens.projectId,
projectStatus: projects.status,
scopes: projectAccessTokens.scopes,
status: projectAccessTokens.status,
tokenHash: projectAccessTokens.tokenHash,
tokenId: projectAccessTokens.id,
})
.from(projectAccessTokens)
.innerJoin(projects, eq(projects.id, projectAccessTokens.projectId))
.where(eq(projectAccessTokens.tokenPrefix, prefix))
.limit(1)
if (!row || row.status !== "active" || row.projectStatus !== "active") {
throw new UnauthorizedException("project access token is invalid")
}
if (!safeEqual(hashToken(secret), row.tokenHash)) {
throw new UnauthorizedException("project access token is invalid")
}
await this.database.db
.update(projectAccessTokens)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(eq(projectAccessTokens.id, row.tokenId))
return {
projectId: row.projectId,
scopes: row.scopes,
tokenId: row.tokenId,
}
}
}
function mapTokenResponse(row: typeof projectAccessTokens.$inferSelect): ProjectAccessTokenResponseDto {
return {
createdAt: row.createdAt.toISOString(),
id: row.id,
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
name: row.name,
projectId: row.projectId,
revokedAt: row.revokedAt?.toISOString() ?? null,
scopes: row.scopes,
status: row.status,
tokenPrefix: row.tokenPrefix,
updatedAt: row.updatedAt.toISOString(),
}
}
function generateToken() {
const prefix = randomBytes(6).toString("hex")
const entropy = randomBytes(32).toString("base64url")
return {
prefix,
secret: `ip_prj_${prefix}_${entropy}`,
}
}
function hashToken(secret: string) {
return createHash("sha256").update(secret).digest("hex")
}
function parseTokenPrefix(secret: string) {
const match = /^ip_prj_([a-f0-9]{12})_[A-Za-z0-9_-]{32,}$/.exec(secret)
if (!match?.[1]) {
throw new UnauthorizedException("project access token is invalid")
}
return match[1]
}
function normalizeTokenName(value: string) {
if (typeof value !== "string") {
throw new BadRequestException("token name must be a string")
}
const normalized = value.trim()
if (!normalized || normalized.length > 120) {
throw new BadRequestException("token name must be 1-120 chars")
}
return normalized
}
function normalizeScopes(scopes: ProjectAccessTokenScopeDto[]): ProjectAccessTokenScope[] {
if (!Array.isArray(scopes) || scopes.length === 0) {
throw new BadRequestException("token scopes must not be empty")
}
const uniqueScopes = [...new Set(scopes)]
for (const scope of uniqueScopes) {
if (!PROJECT_ACCESS_TOKEN_SCOPES.includes(scope)) {
throw new BadRequestException(`unsupported token scope: ${scope}`)
}
}
return uniqueScopes
}
function assertUuid(value: string, name: string) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
throw new BadRequestException(`${name} must be a uuid`)
}
}
function safeEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) {
return false
}
return timingSafeEqual(leftBuffer, rightBuffer)
}
function isUniqueViolation(error: unknown) {
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
}

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from "@nestjs/swagger"
export class CreateProjectRequestDto {
@ApiProperty({ description: "Человекочитаемое название проекта.", example: "Demo Shop" })
name!: string
@ApiProperty({ description: "Опциональный slug проекта. Если не передан, backend создаст его из имени.", example: "demo-shop", required: false })
slug?: string
}
export class ProjectResponseDto {
@ApiProperty({ description: "Внутренний UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
id!: string
@ApiProperty({ description: "Публичный slug проекта для UI и человекочитаемых ссылок.", example: "demo-shop" })
slug!: string
@ApiProperty({ description: "Название проекта.", example: "Demo Shop" })
name!: string
@ApiProperty({ description: "Статус проекта.", enum: ["active", "disabled"], example: "active" })
status!: "active" | "disabled"
@ApiProperty({ description: "ISO-дата создания проекта.", example: "2026-05-12T10:00:00.000Z" })
createdAt!: string
@ApiProperty({ description: "ISO-дата последнего обновления проекта.", example: "2026-05-12T10:00:00.000Z" })
updatedAt!: string
}
export class ProjectsListResponseDto {
@ApiProperty({ description: "Список проектов control plane.", type: [ProjectResponseDto] })
projects!: ProjectResponseDto[]
}

View File

@@ -1,21 +1,30 @@
import { BadRequestException } from "@nestjs/common" import { BadRequestException } from "@nestjs/common"
import { createHash } from "node:crypto"
export function normalizeProjectSlug(value: string) { export function normalizeProjectSlug(value: string) {
if (typeof value !== "string") {
throw new BadRequestException("project slug must be a string")
}
const normalized = value.trim().toLowerCase() const normalized = value.trim().toLowerCase()
if (!/^[a-z0-9][a-z0-9_-]{2,63}$/.test(normalized)) { 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, digits, _ or -") throw new BadRequestException("project slug must be 3-64 chars and contain lowercase letters, numbers or hyphens")
} }
return normalized return normalized
} }
export function createProjectSlug(name: string) { export function createProjectSlug(name: string) {
const slug = name const base = name
.trim() .trim()
.toLowerCase() .toLowerCase()
.replaceAll(/[^a-z0-9_-]+/g, "-") .replace(/[^a-z0-9]+/gi, "-")
.replaceAll(/^-+|-+$/g, "") .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}`)
} }

View File

@@ -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 { import {
ApiBadRequestResponse, ApiBadRequestResponse,
ApiBearerAuth,
ApiConflictResponse, ApiConflictResponse,
ApiCreatedResponse, ApiCreatedResponse,
ApiNotFoundResponse, ApiNotFoundResponse,
ApiOkResponse, ApiOkResponse,
ApiOperation, ApiOperation,
ApiParam, ApiParam,
ApiQuery,
ApiTags, ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger" } from "@nestjs/swagger"
import { AssetsListResponseDto } from "../assets/asset-response.dto" import { AdminAuthGuard } from "../auth/guards/admin-auth.guard"
import { AssetsService } from "../assets/assets.service" import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./dto/projects.dto"
import { CreateAssetRequestDto, CreateAssetResponseDto } from "../assets/create-asset.dto"
import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto"
import { ProjectsService } from "./projects.service" import { ProjectsService } from "./projects.service"
@ApiTags("projects") @ApiTags("projects")
@ApiBearerAuth("adminAuth")
@UseGuards(AdminAuthGuard)
@Controller("projects") @Controller("projects")
export class ProjectsController { export class ProjectsController {
constructor( constructor(private readonly projects: ProjectsService) {}
private readonly assets: AssetsService,
private readonly projects: ProjectsService,
) {}
@Get() @Get()
@ApiOperation({ @ApiOperation({
summary: "получить список проектов", summary: "получить список проектов",
description: "Возвращает проекты верхнего уровня для главной страницы admin.", description: "Возвращает проекты control plane. Проект является областью изоляции assets, presets, builds и access tokens.",
}) })
@ApiOkResponse({ description: "Список проектов возвращён.", type: ProjectsListResponseDto }) @ApiOkResponse({ description: "Список проектов возвращён.", type: ProjectsListResponseDto })
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
listProjects(): Promise<ProjectsListResponseDto> { listProjects(): Promise<ProjectsListResponseDto> {
return this.projects.listProjects() return this.projects.listProjects()
} }
@@ -38,59 +37,27 @@ export class ProjectsController {
@Post() @Post()
@ApiOperation({ @ApiOperation({
summary: "создать проект", summary: "создать проект",
description: "Создаёт проект, внутри которого admin управляет assets и source versions.", description: "Создаёт проект как верхнеуровневую область изоляции. Проект не является image-сущностью.",
}) })
@ApiCreatedResponse({ description: "Проект создан.", type: ProjectResponseDto }) @ApiCreatedResponse({ description: "Проект создан.", type: ProjectResponseDto })
@ApiBadRequestResponse({ description: "Некорректные name или slug." }) @ApiBadRequestResponse({ description: "Некорректные name или slug." })
@ApiConflictResponse({ description: "Проект с таким slug уже существует." }) @ApiConflictResponse({ description: "Проект с таким slug уже существует." })
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
createProject(@Body() request: CreateProjectRequestDto): Promise<ProjectResponseDto> { createProject(@Body() request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
return this.projects.createProject(request) return this.projects.createProject(request)
} }
@Get(":projectSlug") @Get(":projectId")
@ApiOperation({ @ApiOperation({
summary: "получить проект по slug", summary: "получить проект по id",
description: "Возвращает metadata проекта для project-level страницы admin.", 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 }) @ApiOkResponse({ description: "Проект найден.", type: ProjectResponseDto })
@ApiNotFoundResponse({ description: "Проект не найден." }) @ApiBadRequestResponse({ description: "projectId не является UUID." })
getProject(@Param("projectSlug") projectSlug: string): Promise<ProjectResponseDto> { @ApiNotFoundResponse({ description: "Проект не найден или отключён." })
return this.projects.getProject(projectSlug) @ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
} getProject(@Param("projectId") projectId: string): Promise<ProjectResponseDto> {
return this.projects.getProject(projectId)
@Get(":projectSlug/assets")
@ApiOperation({
summary: "получить assets проекта",
description: "Возвращает assets, созданные внутри выбранного проекта.",
})
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
@ApiQuery({ description: "Максимальное количество assets в ответе.", example: 50, name: "limit", required: false })
@ApiQuery({ description: "Смещение для простого paging.", example: 0, name: "offset", required: false })
@ApiOkResponse({ description: "Список assets проекта возвращён.", type: AssetsListResponseDto })
@ApiNotFoundResponse({ description: "Проект не найден." })
listProjectAssets(
@Param("projectSlug") projectSlug: string,
@Query("limit") limit?: string,
@Query("offset") offset?: string,
): Promise<AssetsListResponseDto> {
return this.assets.listAssets({ limit, offset, projectSlug })
}
@Post(":projectSlug/assets")
@ApiOperation({
summary: "создать asset в проекте",
description: "Создаёт asset и первую source version внутри выбранного проекта.",
})
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
@ApiCreatedResponse({ description: "Asset проекта создан.", type: CreateAssetResponseDto })
@ApiBadRequestResponse({ description: "Некорректный sourceUrl, publicId или source host запрещён настройками." })
@ApiConflictResponse({ description: "Asset с таким publicId уже существует." })
@ApiNotFoundResponse({ description: "Проект не найден." })
createProjectAsset(
@Param("projectSlug") projectSlug: string,
@Body() request: CreateAssetRequestDto,
): Promise<CreateAssetResponseDto> {
return this.assets.createAsset(request, projectSlug)
} }
} }

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common"
import { AuthModule } from "../auth/auth.module"
import { DatabaseModule } from "../infra/database.module"
import { ProjectsController } from "./projects.controller"
import { ProjectsService } from "./projects.service"
@Module({
controllers: [ProjectsController],
exports: [ProjectsService],
imports: [AuthModule, DatabaseModule],
providers: [ProjectsService],
})
export class ProjectsModule {}

View File

@@ -1,36 +1,39 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common" import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
import { imageAssets, imageProjects } from "@image-platform/database" import { projects } from "@image-platform/database"
import { count, desc, eq, inArray } from "drizzle-orm" import { desc, eq } from "drizzle-orm"
import { DatabaseService } from "../infra/database.service" import { DatabaseService } from "../infra/database.service"
import { createProjectSlug, normalizeProjectSlug } from "./project-slug" import { createProjectSlug, normalizeProjectSlug } from "./project-slug"
import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto" import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./dto/projects.dto"
@Injectable() @Injectable()
export class ProjectsService { export class ProjectsService {
constructor(private readonly database: DatabaseService) {} constructor(private readonly database: DatabaseService) {}
async listProjects(): Promise<ProjectsListResponseDto> { async listProjects(): Promise<ProjectsListResponseDto> {
const projects = await this.database.db.select().from(imageProjects).orderBy(desc(imageProjects.createdAt)) const rows = await this.database.db.select().from(projects).orderBy(desc(projects.createdAt))
const assetsCountByProjectId = await this.countAssetsByProjectIds(projects.map((project) => project.id))
return { return {
projects: projects.map((project) => mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)), projects: rows.map(mapProjectResponse),
} }
} }
async createProject(request: CreateProjectRequestDto): Promise<ProjectResponseDto> { async createProject(request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
if (!request) {
throw new BadRequestException("request body is required")
}
const name = normalizeProjectName(request.name) const name = normalizeProjectName(request.name)
const slug = request.slug ? normalizeProjectSlug(request.slug) : createProjectSlug(name) const slug = request.slug ? normalizeProjectSlug(request.slug) : createProjectSlug(name)
try { 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) { if (!project) {
throw new Error("failed to create project") throw new Error("failed to create project")
} }
return mapProjectResponse(project, 0) return mapProjectResponse(project)
} catch (error) { } catch (error) {
if (isUniqueViolation(error)) { if (isUniqueViolation(error)) {
throw new ConflictException("project slug already exists") throw new ConflictException("project slug already exists")
@@ -40,20 +43,16 @@ export class ProjectsService {
} }
} }
async getProject(slug: string): Promise<ProjectResponseDto> { async getProject(projectId: string): Promise<ProjectResponseDto> {
const project = await this.loadProject(slug) const project = await this.loadActiveProject(projectId)
const assetsCountByProjectId = await this.countAssetsByProjectIds([project.id])
return mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0) return mapProjectResponse(project)
} }
async loadProject(slug: string) { async loadActiveProject(projectId: string) {
const normalizedSlug = normalizeProjectSlug(slug) assertUuid(projectId, "projectId")
const [project] = await this.database.db
.select() const [project] = await this.database.db.select().from(projects).where(eq(projects.id, projectId)).limit(1)
.from(imageProjects)
.where(eq(imageProjects.slug, normalizedSlug))
.limit(1)
if (!project || project.status !== "active") { if (!project || project.status !== "active") {
throw new NotFoundException("project not found") throw new NotFoundException("project not found")
@@ -61,28 +60,10 @@ export class ProjectsService {
return project return project
} }
private async countAssetsByProjectIds(projectIds: string[]) {
if (projectIds.length === 0) {
return new Map<string, number>()
}
const rows = await this.database.db
.select({
assetsCount: count(imageAssets.id),
projectId: imageAssets.projectId,
})
.from(imageAssets)
.where(inArray(imageAssets.projectId, projectIds))
.groupBy(imageAssets.projectId)
return new Map(rows.flatMap((row) => (row.projectId ? [[row.projectId, row.assetsCount]] : [])))
}
} }
function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount: number): ProjectResponseDto { function mapProjectResponse(row: typeof projects.$inferSelect): ProjectResponseDto {
return { return {
assetsCount,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -93,6 +74,10 @@ function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount:
} }
function normalizeProjectName(value: string) { function normalizeProjectName(value: string) {
if (typeof value !== "string") {
throw new BadRequestException("project name must be a string")
}
const normalized = value.trim() const normalized = value.trim()
if (!normalized || normalized.length > 120) { if (!normalized || normalized.length > 120) {
@@ -102,6 +87,12 @@ function normalizeProjectName(value: string) {
return normalized 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) { function isUniqueViolation(error: unknown) {
return typeof error === "object" && error !== null && "code" in error && error.code === "23505" return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
} }

12
apps/cabinet/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Assets Cabinet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

36
apps/cabinet/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,10 @@
export default {
plugins: {
'@csstools/postcss-global-data': {
files: ['src/shared/styles/media.css'],
},
'postcss-custom-media': {},
'postcss-nesting': {},
autoprefixer: {},
},
}

View File

@@ -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 = () => (
<Routes>
<Route element={<LoginScreen />} path="/login" />
<Route
element={
<ProtectedRoute>
<AuthenticatedShell>
<SessionScreen />
</AuthenticatedShell>
</ProtectedRoute>
}
path="/"
/>
<Route element={<Navigate replace to="/" />} path="*" />
</Routes>
)

View File

@@ -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 (
<BrowserRouter>
<SWRConfig value={{ shouldRetryOnError: false }}>
<ThemeProvider>
<AppRouter />
</ThemeProvider>
</SWRConfig>
</BrowserRouter>
)
}

View File

@@ -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 (
<Center h="100vh">
<Loader />
</Center>
)
}
return (
<MainLayout username={session.session.username} onLogout={handleLogout}>
{children}
</MainLayout>
)
}

View File

@@ -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(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -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 (
<Center h="100vh">
<Loader />
</Center>
)
}
if (!session.isAuthenticated) {
return <Navigate replace to="/login" />
}
return children
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
/**
* Пропсы AuthenticatedShell.
*/
export type AuthenticatedShellProps = {
/** Контент защищённого маршрута. */
children: ReactNode
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
/**
* Пропсы ProtectedRoute.
*/
export type ProtectedRouteProps = {
/** Защищённый route element. */
children: ReactNode
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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<Error | null>(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,
}
}

View File

@@ -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,
}
}

View File

@@ -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'

View File

@@ -0,0 +1,7 @@
export const toError = (value: unknown) => {
if (value instanceof Error) {
return value
}
return new Error(String(value))
}

View File

@@ -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<LoginResponseDto>
}
export type LogoutAction = {
logout: () => Promise<void>
}
/**
* Публичный runtime API бизнес-модуля Auth.
*/
export type AuthApi = {
useAdminSession: () => AdminSession
useLogin: () => LoginAction
useLogout: () => LogoutAction
}

View File

@@ -0,0 +1,3 @@
import type { AuthApi } from './auth-api.type'
export type AuthFactory = () => AuthApi

View File

@@ -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<LoginResponseDto>('/auth/login', { body, method: 'POST' })
},
me: () => {
return request<AdminSessionResponseDto>('/auth/me', { isAuthorized: true })
},
},
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
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<T>
}
function buildHeaders(options: RequestOptions) {
const headers: Record<string, string> = {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

View File

@@ -0,0 +1 @@
export { getAdminSessionKey, useGetAdminSession } from './use-get-admin-session.hook'

View File

@@ -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<AdminSessionResponseDto>) => {
const key = getAdminToken() ? getAdminSessionKey() : null
const fetcher = () => backendApi.auth.me()
return useSWR<AdminSessionResponseDto>(key, fetcher, config)
}

View File

@@ -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'

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -0,0 +1,2 @@
export { ThemeProvider } from './theme-provider'
export type { ThemeProviderProps } from './types/theme-provider-props.type'

View File

@@ -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 (
<MantineProvider defaultColorScheme="light">
<Notifications position="top-right" />
{children}
</MantineProvider>
)
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
/**
* Пропсы ThemeProvider.
*/
export type ThemeProviderProps = {
/** Контент приложения. */
children: ReactNode
}

View File

@@ -0,0 +1,2 @@
export { MainLayout } from './main.layout'
export type { MainLayoutProps } from './types/main-layout-props.type'

View File

@@ -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 (
<AppShell
{...rootAttrs}
className={cl(styles.root, className)}
footer={{ height: 48 }}
header={{ height: 72 }}
navbar={{ breakpoint: 'md', collapsed: { mobile: !isNavbarOpened }, width: 280 }}
padding="md"
>
<AppShell.Header>
<Group h="100%" justify="space-between" px={{ base: 'md', md: 'xl' }}>
<Group gap="md">
<Burger hiddenFrom="md" opened={isNavbarOpened} size="sm" onClick={navbar.toggle} />
<Group gap="sm" aria-label="Assets Delivery Platform">
<ThemeIcon radius="xl" size={42} variant="light">
AD
</ThemeIcon>
<Text fw={700} visibleFrom="sm">
Assets Delivery
</Text>
</Group>
</Group>
<Group gap="sm">
<Text c="dimmed" size="sm">
{username}
</Text>
<Button color="gray" variant="light" onClick={onLogout}>
Выйти
</Button>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<Stack gap="sm">
<Text c="dimmed" fw={700} size="xs" tt="uppercase">
Навигация
</Text>
<NavLink active label="Вход выполнен" />
<NavLink disabled label="Проекты появятся позже" />
</Stack>
</AppShell.Navbar>
<AppShell.Main>
<Container py="xl" size="lg">
{children}
</Container>
</AppShell.Main>
<AppShell.Footer>
<Group h="100%" justify="space-between" px={{ base: 'md', md: 'xl' }}>
<Text c="dimmed" size="sm">
Control plane
</Text>
<Text c="dimmed" size="sm">
REST + SWR
</Text>
</Group>
</AppShell.Footer>
</AppShell>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-height: 100vh;
}

View File

@@ -0,0 +1,18 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
/**
* Параметры MainLayout.
*/
export type MainLayoutParams = {
/** Контент текущего маршрута. */
children: ReactNode
/** Имя авторизованного администратора. */
username: string
/** Обработчик выхода из кабинета. */
onLogout: () => void
}
/** Атрибуты корневого элемента без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
export type MainLayoutProps = RootAttrs & MainLayoutParams

View File

@@ -0,0 +1,2 @@
export { LoginScreen } from './login.screen'
export type { LoginScreenProps } from './types/login-screen-props.type'

View File

@@ -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 <Navigate replace to="/" />
}
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 (
<section {...rootAttrs} className={cl(styles.root, className)}>
<Center mih="100vh" p="md">
<Box w="100%" maw={460}>
<Stack gap="lg">
<Stack align="center" gap="xs">
<ThemeIcon radius="xl" size={52} variant="light">
AD
</ThemeIcon>
<Title order={1} ta="center">
Assets Delivery
</Title>
<Text c="dimmed" ta="center">
Вход в кабинет управления платформой.
</Text>
</Stack>
<Paper p="xl" radius="lg" shadow="sm" withBorder>
<form onSubmit={handleSubmit}>
<Stack gap="md">
{loginAction.error ? (
<Alert color="red" radius="lg" title="Вход не выполнен">
{loginAction.error.message}
</Alert>
) : null}
<TextInput label="Логин" placeholder="admin" required {...form.getInputProps('username')} />
<PasswordInput label="Пароль" placeholder="admin" required {...form.getInputProps('password')} />
<Button loading={loginAction.isLoggingIn} type="submit">
Войти
</Button>
</Stack>
</form>
</Paper>
</Stack>
</Box>
</Center>
</section>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-height: 100vh;
}

View File

@@ -0,0 +1,11 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры LoginScreen.
*/
export type LoginScreenParams = Record<string, never>
/** Атрибуты корневого элемента без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
export type LoginScreenProps = RootAttrs & LoginScreenParams

View File

@@ -0,0 +1,2 @@
export { SessionScreen } from './session.screen'
export type { SessionScreenProps } from './types/session-screen-props.type'

View File

@@ -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 (
<section {...rootAttrs} className={cl(styles.root, className)}>
<Paper p="xl" radius="lg" shadow="sm" withBorder>
<Stack gap="lg">
<Group justify="space-between">
<Group gap="md">
<ThemeIcon color="green" radius="xl" size={48} variant="light">
OK
</ThemeIcon>
<div>
<Title order={1}>Вход выполнен</Title>
<Text c="dimmed">Admin-сессия получена через новый backend.</Text>
</div>
</Group>
<Badge color="green" variant="light">
active
</Badge>
</Group>
<Text>
Пользователь: <strong>{session.session?.username ?? 'admin'}</strong>
</Text>
<Text c="dimmed">
Продуктовые экраны не подключены. Следующим шагом сюда можно добавить проекты и токены доступа к проектам.
</Text>
</Stack>
</Paper>
</section>
)
}

View File

@@ -0,0 +1,3 @@
.root {
display: block;
}

View File

@@ -0,0 +1,11 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры SessionScreen.
*/
export type SessionScreenParams = Record<string, never>
/** Атрибуты корневого элемента без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
export type SessionScreenProps = RootAttrs & SessionScreenParams

View File

@@ -0,0 +1,3 @@
@import "@mantine/core/styles.css";
@import "@mantine/notifications/styles.css";
@import "./variables.css";

View File

@@ -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);

View File

@@ -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;
}

1
apps/cabinet/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -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"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@@ -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"]
}

View File

@@ -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,
},
},
},
})

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -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"
}
}

View File

@@ -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 {}

View File

@@ -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
}

View File

@@ -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",
}
}
}

View File

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

View File

@@ -0,0 +1,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()

Some files were not shown because too many files have changed in this diff Show More