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:
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
После определения роли агент обязан открыть соответствующую инструкцию:
|
После определения роли агент обязан открыть соответствующую инструкцию:
|
||||||
|
|
||||||
- `developer` → [DEVELOP.md](./ai/nextjs-style-guide/DEVELOP.md)
|
- `developer` → [DEVELOP.md](./ai/DEVELOP.md)
|
||||||
|
|
||||||
Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия.
|
Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
41
apps/backend/src/auth/auth.controller.ts
Normal file
41
apps/backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from "@nestjs/common"
|
||||||
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger"
|
||||||
|
|
||||||
|
import { AuthService } from "./auth.service"
|
||||||
|
import { CurrentAdmin } from "./decorators/current-admin.decorator"
|
||||||
|
import { LoginRequestDto, LoginResponseDto } from "./dto/login.dto"
|
||||||
|
import { AdminSessionResponseDto } from "./dto/session.dto"
|
||||||
|
import { AdminAuthGuard } from "./guards/admin-auth.guard"
|
||||||
|
import type { AdminSession } from "./types/authenticated-request.type"
|
||||||
|
|
||||||
|
@ApiTags("auth")
|
||||||
|
@Controller("auth")
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
|
@Post("login")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "войти как администратор",
|
||||||
|
description: "Проверяет фиксированную пару логин/пароль. По умолчанию используется admin/admin без публичной регистрации.",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ description: "Администратор авторизован, сессионный токен выдан.", type: LoginResponseDto })
|
||||||
|
@ApiUnauthorizedResponse({ description: "Логин или пароль неверны." })
|
||||||
|
login(@Body() request: LoginRequestDto): LoginResponseDto {
|
||||||
|
return this.auth.login(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("me")
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth("adminAuth")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "получить текущую admin-сессию",
|
||||||
|
description: "Возвращает данные администратора из Bearer token.",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ description: "Admin-сессия активна.", type: AdminSessionResponseDto })
|
||||||
|
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||||
|
getMe(@CurrentAdmin() admin: AdminSession): AdminSessionResponseDto {
|
||||||
|
return {
|
||||||
|
username: admin.username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/backend/src/auth/auth.module.ts
Normal file
14
apps/backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common"
|
||||||
|
|
||||||
|
import { DatabaseModule } from "../infra/database.module"
|
||||||
|
import { AuthController } from "./auth.controller"
|
||||||
|
import { AuthService } from "./auth.service"
|
||||||
|
import { AdminAuthGuard } from "./guards/admin-auth.guard"
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController],
|
||||||
|
exports: [AdminAuthGuard, AuthService],
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
providers: [AdminAuthGuard, AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
132
apps/backend/src/auth/auth.service.ts
Normal file
132
apps/backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from "@nestjs/common"
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto"
|
||||||
|
|
||||||
|
import type { AdminSession } from "./types/authenticated-request.type"
|
||||||
|
|
||||||
|
type AdminTokenPayload = {
|
||||||
|
exp: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly adminPassword = process.env.ADMIN_PASSWORD ?? "admin"
|
||||||
|
private readonly adminTokenTtlSeconds = parsePositiveInteger(process.env.ADMIN_TOKEN_TTL_SECONDS, 86_400)
|
||||||
|
private readonly adminUsername = process.env.ADMIN_USERNAME ?? "admin"
|
||||||
|
private readonly secret = process.env.ADMIN_AUTH_SECRET ?? "development-admin-auth-secret"
|
||||||
|
|
||||||
|
login(input: { password: string; username: string }) {
|
||||||
|
if (
|
||||||
|
!input ||
|
||||||
|
typeof input.username !== "string" ||
|
||||||
|
typeof input.password !== "string" ||
|
||||||
|
input.username !== this.adminUsername ||
|
||||||
|
input.password !== this.adminPassword
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException("invalid admin credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + this.adminTokenTtlSeconds * 1000)
|
||||||
|
const accessToken = this.signAdminToken({
|
||||||
|
exp: Math.floor(expiresAt.getTime() / 1000),
|
||||||
|
username: input.username,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
tokenType: "Bearer" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyAdminToken(token: string): AdminSession {
|
||||||
|
const parts = token.split(".")
|
||||||
|
|
||||||
|
if (parts.length !== 3 || parts[0] !== "admin") {
|
||||||
|
throw new UnauthorizedException("invalid admin token")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, payloadPart, signature] = parts
|
||||||
|
const expectedSignature = this.sign(payloadPart)
|
||||||
|
|
||||||
|
if (!safeEqual(signature, expectedSignature)) {
|
||||||
|
throw new UnauthorizedException("invalid admin token")
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsePayload(payloadPart)
|
||||||
|
|
||||||
|
if (payload.exp <= Math.floor(Date.now() / 1000)) {
|
||||||
|
throw new UnauthorizedException("admin token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: payload.username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private signAdminToken(payload: AdminTokenPayload) {
|
||||||
|
const payloadPart = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")
|
||||||
|
const signature = this.sign(payloadPart)
|
||||||
|
|
||||||
|
return `admin.${payloadPart}.${signature}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private sign(payloadPart: string) {
|
||||||
|
return createHmac("sha256", this.secret).update(payloadPart).digest("base64url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload(payloadPart: string): AdminTokenPayload {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8")) as unknown
|
||||||
|
|
||||||
|
if (!isPayload(value)) {
|
||||||
|
throw new Error("invalid payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException("invalid admin token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPayload(value: unknown): value is AdminTokenPayload {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"exp" in value &&
|
||||||
|
"username" in value &&
|
||||||
|
typeof value.exp === "number" &&
|
||||||
|
typeof value.username === "string" &&
|
||||||
|
value.username.length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(left: string | undefined, right: string) {
|
||||||
|
if (!left) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftBuffer = Buffer.from(left)
|
||||||
|
const rightBuffer = Buffer.from(right)
|
||||||
|
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||||
|
if (!value) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
13
apps/backend/src/auth/decorators/current-admin.decorator.ts
Normal file
13
apps/backend/src/auth/decorators/current-admin.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
|
||||||
|
|
||||||
|
import type { AdminSession, AuthenticatedRequest } from "../types/authenticated-request.type"
|
||||||
|
|
||||||
|
export const CurrentAdmin = createParamDecorator((_data: unknown, context: ExecutionContext): AdminSession => {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||||
|
|
||||||
|
if (!request.adminSession) {
|
||||||
|
throw new Error("admin session is missing in request context")
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.adminSession
|
||||||
|
})
|
||||||
20
apps/backend/src/auth/dto/login.dto.ts
Normal file
20
apps/backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger"
|
||||||
|
|
||||||
|
export class LoginRequestDto {
|
||||||
|
@ApiProperty({ description: "Логин администратора.", example: "admin" })
|
||||||
|
username!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Пароль администратора.", example: "admin" })
|
||||||
|
password!: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoginResponseDto {
|
||||||
|
@ApiProperty({ description: "Bearer token сессии администратора.", example: "admin.eyJ1c2VybmFtZSI6ImFkbWluIn0.signature" })
|
||||||
|
accessToken!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Тип токена для HTTP Authorization header.", example: "Bearer" })
|
||||||
|
tokenType!: "Bearer"
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO-дата истечения сессии.", example: "2026-05-13T10:00:00.000Z" })
|
||||||
|
expiresAt!: string
|
||||||
|
}
|
||||||
6
apps/backend/src/auth/dto/session.dto.ts
Normal file
6
apps/backend/src/auth/dto/session.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger"
|
||||||
|
|
||||||
|
export class AdminSessionResponseDto {
|
||||||
|
@ApiProperty({ description: "Логин текущего администратора.", example: "admin" })
|
||||||
|
username!: string
|
||||||
|
}
|
||||||
32
apps/backend/src/auth/guards/admin-auth.guard.ts
Normal file
32
apps/backend/src/auth/guards/admin-auth.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"
|
||||||
|
|
||||||
|
import { AuthService } from "../auth.service"
|
||||||
|
import type { AuthenticatedRequest } from "../types/authenticated-request.type"
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminAuthGuard implements CanActivate {
|
||||||
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||||
|
const token = readBearerToken(request.headers.authorization)
|
||||||
|
|
||||||
|
request.adminSession = this.auth.verifyAdminToken(token)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBearerToken(header: string | undefined): string {
|
||||||
|
if (!header) {
|
||||||
|
throw new UnauthorizedException("authorization header is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, token] = header.split(" ")
|
||||||
|
|
||||||
|
if (type !== "Bearer" || !token) {
|
||||||
|
throw new UnauthorizedException("bearer token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
17
apps/backend/src/auth/types/authenticated-request.type.ts
Normal file
17
apps/backend/src/auth/types/authenticated-request.type.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||||
|
import type { Request } from "express"
|
||||||
|
|
||||||
|
export type AdminSession = {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectAccess = {
|
||||||
|
projectId: string
|
||||||
|
scopes: ProjectAccessTokenScope[]
|
||||||
|
tokenId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthenticatedRequest = Request & {
|
||||||
|
adminSession?: AdminSession
|
||||||
|
projectAccess?: ProjectAccess
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger"
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
apps/backend/src/health/health.module.ts
Normal file
8
apps/backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common"
|
||||||
|
|
||||||
|
import { HealthController } from "./health.controller"
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
10
apps/backend/src/infra/database.module.ts
Normal file
10
apps/backend/src/infra/database.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common"
|
||||||
|
|
||||||
|
import { DatabaseService } from "./database.service"
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
exports: [DatabaseService],
|
||||||
|
providers: [DatabaseService],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Injectable, OnModuleDestroy } from "@nestjs/common"
|
import { 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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
|
||||||
|
|
||||||
|
import type { AuthenticatedRequest, ProjectAccess } from "../../auth/types/authenticated-request.type"
|
||||||
|
|
||||||
|
export const CurrentProjectAccess = createParamDecorator((_data: unknown, context: ExecutionContext): ProjectAccess => {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||||
|
|
||||||
|
if (!request.projectAccess) {
|
||||||
|
throw new Error("project access context is missing in request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.projectAccess
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common"
|
||||||
|
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||||
|
|
||||||
|
export const REQUIRED_PROJECT_SCOPES_KEY = "requiredProjectScopes"
|
||||||
|
|
||||||
|
export const RequiredProjectScopes = (...scopes: ProjectAccessTokenScope[]) => {
|
||||||
|
return SetMetadata(REQUIRED_PROJECT_SCOPES_KEY, scopes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger"
|
||||||
|
|
||||||
|
export const PROJECT_ACCESS_TOKEN_SCOPES = [
|
||||||
|
"assets:read",
|
||||||
|
"assets:write",
|
||||||
|
"assets:delete",
|
||||||
|
"presets:read",
|
||||||
|
"presets:write",
|
||||||
|
"builds:read",
|
||||||
|
"builds:write",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ProjectAccessTokenScopeDto = (typeof PROJECT_ACCESS_TOKEN_SCOPES)[number]
|
||||||
|
|
||||||
|
export class CreateProjectAccessTokenRequestDto {
|
||||||
|
@ApiProperty({ description: "Название токена для отображения в кабинете.", example: "Production backend" })
|
||||||
|
name!: string
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Scopes, которые разрешены этому токену проекта.",
|
||||||
|
enum: PROJECT_ACCESS_TOKEN_SCOPES,
|
||||||
|
example: ["assets:read", "assets:write"],
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
|
scopes!: ProjectAccessTokenScopeDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectAccessTokenResponseDto {
|
||||||
|
@ApiProperty({ description: "UUID токена доступа.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991" })
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "UUID проекта, к которому привязан токен.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||||
|
projectId!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Название токена.", example: "Production backend" })
|
||||||
|
name!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Безопасный prefix токена для отображения и поиска.", example: "a1b2c3d4e5f6" })
|
||||||
|
tokenPrefix!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Scopes токена.", enum: PROJECT_ACCESS_TOKEN_SCOPES, isArray: true })
|
||||||
|
scopes!: ProjectAccessTokenScopeDto[]
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Статус токена.", enum: ["active", "revoked"], example: "active" })
|
||||||
|
status!: "active" | "revoked"
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO-дата последнего использования токена.", example: "2026-05-12T10:00:00.000Z", nullable: true })
|
||||||
|
lastUsedAt!: string | null
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO-дата отзыва токена.", example: null, nullable: true })
|
||||||
|
revokedAt!: string | null
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO-дата создания токена.", example: "2026-05-12T10:00:00.000Z" })
|
||||||
|
createdAt!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO-дата последнего обновления токена.", example: "2026-05-12T10:00:00.000Z" })
|
||||||
|
updatedAt!: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateProjectAccessTokenResponseDto extends ProjectAccessTokenResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Секрет токена. Показывается только один раз при создании и не хранится в базе в открытом виде.",
|
||||||
|
example: "ip_prj_a1b2c3d4e5f6_secretValue",
|
||||||
|
})
|
||||||
|
secret!: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectAccessTokensListResponseDto {
|
||||||
|
@ApiProperty({ description: "Список токенов доступа проекта без секретов.", type: [ProjectAccessTokenResponseDto] })
|
||||||
|
tokens!: ProjectAccessTokenResponseDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectAccessContextResponseDto {
|
||||||
|
@ApiProperty({ description: "UUID проекта, определённый по Bearer token.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||||
|
projectId!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "UUID использованного токена.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991" })
|
||||||
|
tokenId!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Scopes текущего токена.", enum: PROJECT_ACCESS_TOKEN_SCOPES, isArray: true })
|
||||||
|
scopes!: ProjectAccessTokenScopeDto[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"
|
||||||
|
import { Reflector } from "@nestjs/core"
|
||||||
|
|
||||||
|
import { readBearerToken } from "../../auth/guards/admin-auth.guard"
|
||||||
|
import type { AuthenticatedRequest } from "../../auth/types/authenticated-request.type"
|
||||||
|
import { REQUIRED_PROJECT_SCOPES_KEY } from "../decorators/required-project-scopes.decorator"
|
||||||
|
import { ProjectAccessTokensService } from "../project-access-tokens.service"
|
||||||
|
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectAccessTokenGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly projectAccessTokens: ProjectAccessTokensService,
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||||
|
const token = readBearerToken(request.headers.authorization)
|
||||||
|
const projectAccess = await this.projectAccessTokens.authenticate(token)
|
||||||
|
const requiredScopes = this.reflector.getAllAndOverride<ProjectAccessTokenScope[]>(REQUIRED_PROJECT_SCOPES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (requiredScopes?.some((scope) => !projectAccess.scopes.includes(scope))) {
|
||||||
|
throw new ForbiddenException("project access token does not have required scopes")
|
||||||
|
}
|
||||||
|
|
||||||
|
request.projectAccess = projectAccess
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common"
|
||||||
|
import {
|
||||||
|
ApiBadRequestResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiConflictResponse,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from "@nestjs/swagger"
|
||||||
|
|
||||||
|
import { AdminAuthGuard } from "../auth/guards/admin-auth.guard"
|
||||||
|
import { CurrentProjectAccess } from "./decorators/current-project-access.decorator"
|
||||||
|
import { RequiredProjectScopes } from "./decorators/required-project-scopes.decorator"
|
||||||
|
import {
|
||||||
|
CreateProjectAccessTokenRequestDto,
|
||||||
|
CreateProjectAccessTokenResponseDto,
|
||||||
|
ProjectAccessContextResponseDto,
|
||||||
|
ProjectAccessTokenResponseDto,
|
||||||
|
ProjectAccessTokensListResponseDto,
|
||||||
|
} from "./dto/project-access-token.dto"
|
||||||
|
import { ProjectAccessTokenGuard } from "./guards/project-access-token.guard"
|
||||||
|
import { ProjectAccessTokensService } from "./project-access-tokens.service"
|
||||||
|
import type { ProjectAccess } from "../auth/types/authenticated-request.type"
|
||||||
|
|
||||||
|
@ApiTags("project-access-tokens")
|
||||||
|
@Controller()
|
||||||
|
export class ProjectAccessTokensController {
|
||||||
|
constructor(private readonly projectAccessTokens: ProjectAccessTokensService) {}
|
||||||
|
|
||||||
|
@Post("projects/:projectId/access-tokens")
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth("adminAuth")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "создать токен доступа к проекту",
|
||||||
|
description:
|
||||||
|
"Создаёт server-side токен для headless API. Секрет возвращается только в этом ответе, в базе хранится только hash.",
|
||||||
|
})
|
||||||
|
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||||
|
@ApiCreatedResponse({ description: "Токен создан, секрет показан один раз.", type: CreateProjectAccessTokenResponseDto })
|
||||||
|
@ApiBadRequestResponse({ description: "Некорректные name, scopes или projectId." })
|
||||||
|
@ApiConflictResponse({ description: "Сгенерированный prefix токена уже существует." })
|
||||||
|
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||||
|
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||||
|
createToken(
|
||||||
|
@Param("projectId") projectId: string,
|
||||||
|
@Body() request: CreateProjectAccessTokenRequestDto,
|
||||||
|
): Promise<CreateProjectAccessTokenResponseDto> {
|
||||||
|
return this.projectAccessTokens.createToken(projectId, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("projects/:projectId/access-tokens")
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth("adminAuth")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "получить токены доступа проекта",
|
||||||
|
description: "Возвращает список токенов проекта без секретов. Для UI показывается только prefix и metadata.",
|
||||||
|
})
|
||||||
|
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||||
|
@ApiOkResponse({ description: "Список токенов проекта возвращён.", type: ProjectAccessTokensListResponseDto })
|
||||||
|
@ApiBadRequestResponse({ description: "projectId не является UUID." })
|
||||||
|
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||||
|
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||||
|
listTokens(@Param("projectId") projectId: string): Promise<ProjectAccessTokensListResponseDto> {
|
||||||
|
return this.projectAccessTokens.listTokens(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("projects/:projectId/access-tokens/:tokenId")
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth("adminAuth")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "отозвать токен доступа проекта",
|
||||||
|
description: "Переводит токен в revoked. После отзыва он больше не даёт доступ к project-scoped API.",
|
||||||
|
})
|
||||||
|
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||||
|
@ApiParam({ description: "UUID токена доступа.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991", name: "tokenId" })
|
||||||
|
@ApiOkResponse({ description: "Токен отозван.", type: ProjectAccessTokenResponseDto })
|
||||||
|
@ApiBadRequestResponse({ description: "projectId или tokenId не является UUID." })
|
||||||
|
@ApiNotFoundResponse({ description: "Проект или токен не найден." })
|
||||||
|
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||||
|
revokeToken(
|
||||||
|
@Param("projectId") projectId: string,
|
||||||
|
@Param("tokenId") tokenId: string,
|
||||||
|
): Promise<ProjectAccessTokenResponseDto> {
|
||||||
|
return this.projectAccessTokens.revokeToken(projectId, tokenId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("project-access/me")
|
||||||
|
@UseGuards(ProjectAccessTokenGuard)
|
||||||
|
@RequiredProjectScopes("assets:read")
|
||||||
|
@ApiBearerAuth("projectAccessToken")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "проверить токен доступа к проекту",
|
||||||
|
description: "Диагностический endpoint для headless-клиентов. Возвращает projectId и scopes текущего project access token.",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ description: "Project access token активен и имеет scope assets:read.", type: ProjectAccessContextResponseDto })
|
||||||
|
@ApiUnauthorizedResponse({ description: "Project access token отсутствует, отозван или некорректен." })
|
||||||
|
@ApiForbiddenResponse({ description: "Project access token не имеет требуемого scope." })
|
||||||
|
getProjectAccess(@CurrentProjectAccess() projectAccess: ProjectAccess): ProjectAccessContextResponseDto {
|
||||||
|
return projectAccess
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from "@nestjs/common"
|
||||||
|
|
||||||
|
import { AuthModule } from "../auth/auth.module"
|
||||||
|
import { DatabaseModule } from "../infra/database.module"
|
||||||
|
import { ProjectsModule } from "../projects/projects.module"
|
||||||
|
import { ProjectAccessTokenGuard } from "./guards/project-access-token.guard"
|
||||||
|
import { ProjectAccessTokensController } from "./project-access-tokens.controller"
|
||||||
|
import { ProjectAccessTokensService } from "./project-access-tokens.service"
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProjectAccessTokensController],
|
||||||
|
exports: [ProjectAccessTokenGuard, ProjectAccessTokensService],
|
||||||
|
imports: [AuthModule, DatabaseModule, ProjectsModule],
|
||||||
|
providers: [ProjectAccessTokenGuard, ProjectAccessTokensService],
|
||||||
|
})
|
||||||
|
export class ProjectAccessTokensModule {}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"
|
||||||
|
import { projectAccessTokens, projects, type ProjectAccessTokenScope } from "@image-platform/database"
|
||||||
|
import { and, desc, eq } from "drizzle-orm"
|
||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
||||||
|
|
||||||
|
import { DatabaseService } from "../infra/database.service"
|
||||||
|
import { ProjectsService } from "../projects/projects.service"
|
||||||
|
import { PROJECT_ACCESS_TOKEN_SCOPES, type ProjectAccessTokenScopeDto } from "./dto/project-access-token.dto"
|
||||||
|
import type {
|
||||||
|
CreateProjectAccessTokenRequestDto,
|
||||||
|
CreateProjectAccessTokenResponseDto,
|
||||||
|
ProjectAccessTokenResponseDto,
|
||||||
|
ProjectAccessTokensListResponseDto,
|
||||||
|
} from "./dto/project-access-token.dto"
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectAccessTokensService {
|
||||||
|
constructor(
|
||||||
|
private readonly database: DatabaseService,
|
||||||
|
private readonly projectsService: ProjectsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createToken(projectId: string, request: CreateProjectAccessTokenRequestDto): Promise<CreateProjectAccessTokenResponseDto> {
|
||||||
|
await this.projectsService.loadActiveProject(projectId)
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new BadRequestException("request body is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = normalizeTokenName(request.name)
|
||||||
|
const scopes = normalizeScopes(request.scopes)
|
||||||
|
const generated = generateToken()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [token] = await this.database.db
|
||||||
|
.insert(projectAccessTokens)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
projectId,
|
||||||
|
scopes,
|
||||||
|
tokenHash: hashToken(generated.secret),
|
||||||
|
tokenPrefix: generated.prefix,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("failed to create project access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapTokenResponse(token),
|
||||||
|
secret: generated.secret,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException("project access token prefix already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTokens(projectId: string): Promise<ProjectAccessTokensListResponseDto> {
|
||||||
|
await this.projectsService.loadActiveProject(projectId)
|
||||||
|
|
||||||
|
const rows = await this.database.db
|
||||||
|
.select()
|
||||||
|
.from(projectAccessTokens)
|
||||||
|
.where(eq(projectAccessTokens.projectId, projectId))
|
||||||
|
.orderBy(desc(projectAccessTokens.createdAt))
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens: rows.map(mapTokenResponse),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeToken(projectId: string, tokenId: string): Promise<ProjectAccessTokenResponseDto> {
|
||||||
|
await this.projectsService.loadActiveProject(projectId)
|
||||||
|
assertUuid(tokenId, "tokenId")
|
||||||
|
|
||||||
|
const [token] = await this.database.db
|
||||||
|
.update(projectAccessTokens)
|
||||||
|
.set({ revokedAt: new Date(), status: "revoked", updatedAt: new Date() })
|
||||||
|
.where(and(eq(projectAccessTokens.id, tokenId), eq(projectAccessTokens.projectId, projectId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new NotFoundException("project access token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapTokenResponse(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(secret: string) {
|
||||||
|
const prefix = parseTokenPrefix(secret)
|
||||||
|
const [row] = await this.database.db
|
||||||
|
.select({
|
||||||
|
projectId: projectAccessTokens.projectId,
|
||||||
|
projectStatus: projects.status,
|
||||||
|
scopes: projectAccessTokens.scopes,
|
||||||
|
status: projectAccessTokens.status,
|
||||||
|
tokenHash: projectAccessTokens.tokenHash,
|
||||||
|
tokenId: projectAccessTokens.id,
|
||||||
|
})
|
||||||
|
.from(projectAccessTokens)
|
||||||
|
.innerJoin(projects, eq(projects.id, projectAccessTokens.projectId))
|
||||||
|
.where(eq(projectAccessTokens.tokenPrefix, prefix))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row || row.status !== "active" || row.projectStatus !== "active") {
|
||||||
|
throw new UnauthorizedException("project access token is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeEqual(hashToken(secret), row.tokenHash)) {
|
||||||
|
throw new UnauthorizedException("project access token is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.database.db
|
||||||
|
.update(projectAccessTokens)
|
||||||
|
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(projectAccessTokens.id, row.tokenId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: row.projectId,
|
||||||
|
scopes: row.scopes,
|
||||||
|
tokenId: row.tokenId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTokenResponse(row: typeof projectAccessTokens.$inferSelect): ProjectAccessTokenResponseDto {
|
||||||
|
return {
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
id: row.id,
|
||||||
|
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
||||||
|
name: row.name,
|
||||||
|
projectId: row.projectId,
|
||||||
|
revokedAt: row.revokedAt?.toISOString() ?? null,
|
||||||
|
scopes: row.scopes,
|
||||||
|
status: row.status,
|
||||||
|
tokenPrefix: row.tokenPrefix,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken() {
|
||||||
|
const prefix = randomBytes(6).toString("hex")
|
||||||
|
const entropy = randomBytes(32).toString("base64url")
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
secret: `ip_prj_${prefix}_${entropy}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(secret: string) {
|
||||||
|
return createHash("sha256").update(secret).digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenPrefix(secret: string) {
|
||||||
|
const match = /^ip_prj_([a-f0-9]{12})_[A-Za-z0-9_-]{32,}$/.exec(secret)
|
||||||
|
|
||||||
|
if (!match?.[1]) {
|
||||||
|
throw new UnauthorizedException("project access token is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTokenName(value: string) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new BadRequestException("token name must be a string")
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim()
|
||||||
|
|
||||||
|
if (!normalized || normalized.length > 120) {
|
||||||
|
throw new BadRequestException("token name must be 1-120 chars")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScopes(scopes: ProjectAccessTokenScopeDto[]): ProjectAccessTokenScope[] {
|
||||||
|
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||||
|
throw new BadRequestException("token scopes must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueScopes = [...new Set(scopes)]
|
||||||
|
|
||||||
|
for (const scope of uniqueScopes) {
|
||||||
|
if (!PROJECT_ACCESS_TOKEN_SCOPES.includes(scope)) {
|
||||||
|
throw new BadRequestException(`unsupported token scope: ${scope}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUuid(value: string, name: string) {
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||||
|
throw new BadRequestException(`${name} must be a uuid`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(left: string, right: string) {
|
||||||
|
const leftBuffer = Buffer.from(left)
|
||||||
|
const rightBuffer = Buffer.from(right)
|
||||||
|
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown) {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
|
||||||
|
}
|
||||||
34
apps/backend/src/projects/dto/projects.dto.ts
Normal file
34
apps/backend/src/projects/dto/projects.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger"
|
||||||
|
|
||||||
|
export class CreateProjectRequestDto {
|
||||||
|
@ApiProperty({ description: "Человекочитаемое название проекта.", example: "Demo Shop" })
|
||||||
|
name!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Опциональный slug проекта. Если не передан, backend создаст его из имени.", example: "demo-shop", required: false })
|
||||||
|
slug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectResponseDto {
|
||||||
|
@ApiProperty({ description: "Внутренний UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Публичный slug проекта для UI и человекочитаемых ссылок.", example: "demo-shop" })
|
||||||
|
slug!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Название проекта.", example: "Demo Shop" })
|
||||||
|
name!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Статус проекта.", enum: ["active", "disabled"], example: "active" })
|
||||||
|
status!: "active" | "disabled"
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO-дата создания проекта.", example: "2026-05-12T10:00:00.000Z" })
|
||||||
|
createdAt!: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ISO-дата последнего обновления проекта.", example: "2026-05-12T10:00:00.000Z" })
|
||||||
|
updatedAt!: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectsListResponseDto {
|
||||||
|
@ApiProperty({ description: "Список проектов control plane.", type: [ProjectResponseDto] })
|
||||||
|
projects!: ProjectResponseDto[]
|
||||||
|
}
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
import { BadRequestException } from "@nestjs/common"
|
import { 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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/backend/src/projects/projects.module.ts
Normal file
14
apps/backend/src/projects/projects.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common"
|
||||||
|
|
||||||
|
import { AuthModule } from "../auth/auth.module"
|
||||||
|
import { DatabaseModule } from "../infra/database.module"
|
||||||
|
import { ProjectsController } from "./projects.controller"
|
||||||
|
import { ProjectsService } from "./projects.service"
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProjectsController],
|
||||||
|
exports: [ProjectsService],
|
||||||
|
imports: [AuthModule, DatabaseModule],
|
||||||
|
providers: [ProjectsService],
|
||||||
|
})
|
||||||
|
export class ProjectsModule {}
|
||||||
@@ -1,36 +1,39 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
|
import { 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
12
apps/cabinet/index.html
Normal 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
36
apps/cabinet/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/cabinet/postcss.config.mjs
Normal file
10
apps/cabinet/postcss.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@csstools/postcss-global-data': {
|
||||||
|
files: ['src/shared/styles/media.css'],
|
||||||
|
},
|
||||||
|
'postcss-custom-media': {},
|
||||||
|
'postcss-nesting': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
23
apps/cabinet/src/app/app-router.tsx
Normal file
23
apps/cabinet/src/app/app-router.tsx
Normal 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>
|
||||||
|
)
|
||||||
17
apps/cabinet/src/app/app.tsx
Normal file
17
apps/cabinet/src/app/app.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
apps/cabinet/src/app/authenticated-shell.tsx
Normal file
40
apps/cabinet/src/app/authenticated-shell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
apps/cabinet/src/app/main.tsx
Normal file
18
apps/cabinet/src/app/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
33
apps/cabinet/src/app/protected-route.tsx
Normal file
33
apps/cabinet/src/app/protected-route.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пропсы AuthenticatedShell.
|
||||||
|
*/
|
||||||
|
export type AuthenticatedShellProps = {
|
||||||
|
/** Контент защищённого маршрута. */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
9
apps/cabinet/src/app/types/protected-route-props.type.ts
Normal file
9
apps/cabinet/src/app/types/protected-route-props.type.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пропсы ProtectedRoute.
|
||||||
|
*/
|
||||||
|
export type ProtectedRouteProps = {
|
||||||
|
/** Защищённый route element. */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
15
apps/cabinet/src/business/auth/auth.factory.ts
Normal file
15
apps/cabinet/src/business/auth/auth.factory.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/cabinet/src/business/auth/hooks/use-login.hook.ts
Normal file
40
apps/cabinet/src/business/auth/hooks/use-login.hook.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/cabinet/src/business/auth/hooks/use-logout.hook.ts
Normal file
20
apps/cabinet/src/business/auth/hooks/use-logout.hook.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/cabinet/src/business/auth/index.ts
Normal file
3
apps/cabinet/src/business/auth/index.ts
Normal 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'
|
||||||
7
apps/cabinet/src/business/auth/lib/to-error.ts
Normal file
7
apps/cabinet/src/business/auth/lib/to-error.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const toError = (value: unknown) => {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(String(value))
|
||||||
|
}
|
||||||
29
apps/cabinet/src/business/auth/types/auth-api.type.ts
Normal file
29
apps/cabinet/src/business/auth/types/auth-api.type.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { AuthApi } from './auth-api.type'
|
||||||
|
|
||||||
|
export type AuthFactory = () => AuthApi
|
||||||
77
apps/cabinet/src/infra/backend-api/client.ts
Normal file
77
apps/cabinet/src/infra/backend-api/client.ts
Normal 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)
|
||||||
|
}
|
||||||
1
apps/cabinet/src/infra/backend-api/hooks/index.ts
Normal file
1
apps/cabinet/src/infra/backend-api/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { getAdminSessionKey, useGetAdminSession } from './use-get-admin-session.hook'
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
4
apps/cabinet/src/infra/backend-api/index.ts
Normal file
4
apps/cabinet/src/infra/backend-api/index.ts
Normal 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'
|
||||||
13
apps/cabinet/src/infra/backend-api/token-storage.ts
Normal file
13
apps/cabinet/src/infra/backend-api/token-storage.ts
Normal 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)
|
||||||
|
}
|
||||||
14
apps/cabinet/src/infra/backend-api/types/backend-api.type.ts
Normal file
14
apps/cabinet/src/infra/backend-api/types/backend-api.type.ts
Normal 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
|
||||||
|
}
|
||||||
2
apps/cabinet/src/infra/theme/index.ts
Normal file
2
apps/cabinet/src/infra/theme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ThemeProvider } from './theme-provider'
|
||||||
|
export type { ThemeProviderProps } from './types/theme-provider-props.type'
|
||||||
22
apps/cabinet/src/infra/theme/theme-provider.tsx
Normal file
22
apps/cabinet/src/infra/theme/theme-provider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пропсы ThemeProvider.
|
||||||
|
*/
|
||||||
|
export type ThemeProviderProps = {
|
||||||
|
/** Контент приложения. */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
2
apps/cabinet/src/layouts/main/index.ts
Normal file
2
apps/cabinet/src/layouts/main/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { MainLayout } from './main.layout'
|
||||||
|
export type { MainLayoutProps } from './types/main-layout-props.type'
|
||||||
82
apps/cabinet/src/layouts/main/main.layout.tsx
Normal file
82
apps/cabinet/src/layouts/main/main.layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
2
apps/cabinet/src/screens/login/index.ts
Normal file
2
apps/cabinet/src/screens/login/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { LoginScreen } from './login.screen'
|
||||||
|
export type { LoginScreenProps } from './types/login-screen-props.type'
|
||||||
106
apps/cabinet/src/screens/login/login.screen.tsx
Normal file
106
apps/cabinet/src/screens/login/login.screen.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
apps/cabinet/src/screens/login/styles/login.module.css
Normal file
3
apps/cabinet/src/screens/login/styles/login.module.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
2
apps/cabinet/src/screens/session/index.ts
Normal file
2
apps/cabinet/src/screens/session/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { SessionScreen } from './session.screen'
|
||||||
|
export type { SessionScreenProps } from './types/session-screen-props.type'
|
||||||
52
apps/cabinet/src/screens/session/session.screen.tsx
Normal file
52
apps/cabinet/src/screens/session/session.screen.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.root {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
3
apps/cabinet/src/shared/styles/global.css
Normal file
3
apps/cabinet/src/shared/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import "@mantine/core/styles.css";
|
||||||
|
@import "@mantine/notifications/styles.css";
|
||||||
|
@import "./variables.css";
|
||||||
17
apps/cabinet/src/shared/styles/media.css
Normal file
17
apps/cabinet/src/shared/styles/media.css
Normal 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);
|
||||||
12
apps/cabinet/src/shared/styles/variables.css
Normal file
12
apps/cabinet/src/shared/styles/variables.css
Normal 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
1
apps/cabinet/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
33
apps/cabinet/tsconfig.app.json
Normal file
33
apps/cabinet/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
4
apps/cabinet/tsconfig.json
Normal file
4
apps/cabinet/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
15
apps/cabinet/tsconfig.node.json
Normal file
15
apps/cabinet/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
31
apps/cabinet/vite.config.ts
Normal file
31
apps/cabinet/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
8
apps/old-backend/nest-cli.json
Normal file
8
apps/old-backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/old-backend/package.json
Normal file
35
apps/old-backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/old-backend/src/app.module.ts
Normal file
19
apps/old-backend/src/app.module.ts
Normal 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 {}
|
||||||
9
apps/old-backend/src/health/health-response.dto.ts
Normal file
9
apps/old-backend/src/health/health-response.dto.ts
Normal 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
|
||||||
|
}
|
||||||
21
apps/old-backend/src/health/health.controller.ts
Normal file
21
apps/old-backend/src/health/health.controller.ts
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/old-backend/src/infra/database.service.ts
Normal file
14
apps/old-backend/src/infra/database.service.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/old-backend/src/main.ts
Normal file
40
apps/old-backend/src/main.ts
Normal 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
Reference in New Issue
Block a user