first commit
This commit is contained in:
38
apps/api-gateway/Dockerfile
Normal file
38
apps/api-gateway/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
||||
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
COPY apps/sso-core/package.json ./apps/sso-core/
|
||||
COPY apps/api-gateway/package.json ./apps/api-gateway/
|
||||
COPY apps/frontend/package.json ./apps/frontend/
|
||||
COPY apps/docs/package.json ./apps/docs/
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm config set registry "${NPM_REGISTRY}" && \
|
||||
for i in 1 2 3 4 5; do \
|
||||
echo "npm ci: попытка $i/5" && \
|
||||
npm ci --no-audit --no-fund && exit 0; \
|
||||
echo "npm ci: ошибка сети, повтор через $((i * 15)) сек..."; \
|
||||
sleep $((i * 15)); \
|
||||
done; \
|
||||
echo "npm ci: все попытки исчерпаны"; \
|
||||
exit 1
|
||||
|
||||
COPY apps ./apps
|
||||
COPY shared ./shared
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
ENV PORT="3000"
|
||||
ENV SSO_CORE_GRPC_URL="sso-core:50051"
|
||||
ENV JWT_ACCESS_SECRET="docker-access-secret"
|
||||
|
||||
RUN npm --workspace @lendry/api-gateway run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/api-gateway/dist/main.js"]
|
||||
8
apps/api-gateway/nest-cli.json
Normal file
8
apps/api-gateway/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
31
apps/api-gateway/package.json
Normal file
31
apps/api-gateway/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@lendry/api-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/microservices": "^11.1.9",
|
||||
"@nestjs/platform-express": "^11.1.9",
|
||||
"@nestjs/swagger": "^11.2.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@types/node": "^24.10.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
60
apps/api-gateway/src/app.module.ts
Normal file
60
apps/api-gateway/src/app.module.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||
import { join } from 'node:path';
|
||||
import { AdminController } from './controllers/admin.controller';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { RbacController } from './controllers/rbac.controller';
|
||||
import { SecurityController } from './controllers/security.controller';
|
||||
import { SettingsController } from './controllers/settings.controller';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { PublicSettingsController } from './controllers/public-settings.controller';
|
||||
import { ProfileController } from './controllers/profile.controller';
|
||||
import { DocumentsController } from './controllers/documents.controller';
|
||||
import { AddressesController } from './controllers/addresses.controller';
|
||||
import { OAuthController } from './controllers/oauth.controller';
|
||||
import { AdvancedAuthController } from './controllers/advanced-auth.controller';
|
||||
import { FamilyController } from './controllers/family.controller';
|
||||
import { ChatController } from './controllers/chat.controller';
|
||||
import { NotificationsController } from './controllers/notifications.controller';
|
||||
import { MediaController } from './controllers/media.controller';
|
||||
import { CoreGrpcService } from './core-grpc.service';
|
||||
import { AdminGuard, SuperAdminGuard } from './guards/admin.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
JwtModule.register({}),
|
||||
ClientsModule.registerAsync([
|
||||
{
|
||||
name: 'SSO_CORE',
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat'],
|
||||
protoPath: [
|
||||
join(__dirname, '../../../shared/proto/auth.proto'),
|
||||
join(__dirname, '../../../shared/proto/admin.proto'),
|
||||
join(__dirname, '../../../shared/proto/rbac.proto'),
|
||||
join(__dirname, '../../../shared/proto/security.proto'),
|
||||
join(__dirname, '../../../shared/proto/profile.proto'),
|
||||
join(__dirname, '../../../shared/proto/documents.proto'),
|
||||
join(__dirname, '../../../shared/proto/addresses.proto'),
|
||||
join(__dirname, '../../../shared/proto/identity.proto'),
|
||||
join(__dirname, '../../../shared/proto/media.proto'),
|
||||
join(__dirname, '../../../shared/proto/notifications.proto'),
|
||||
join(__dirname, '../../../shared/proto/chat.proto')
|
||||
],
|
||||
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051')
|
||||
}
|
||||
})
|
||||
}
|
||||
])
|
||||
],
|
||||
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController],
|
||||
providers: [CoreGrpcService, AdminGuard, SuperAdminGuard]
|
||||
})
|
||||
export class AppModule {}
|
||||
14
apps/api-gateway/src/auth-token.ts
Normal file
14
apps/api-gateway/src/auth-token.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
export function extractBearerToken(authorization?: string): string {
|
||||
if (!authorization?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Не передан токен доступа');
|
||||
}
|
||||
|
||||
const token = authorization.slice('Bearer '.length).trim();
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Не передан токен доступа');
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
68
apps/api-gateway/src/controllers/addresses.controller.ts
Normal file
68
apps/api-gateway/src/controllers/addresses.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Post } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { UpsertAddressDto } from '../dto/addresses.dto';
|
||||
|
||||
@ApiTags('Адреса')
|
||||
@ApiBearerAuth()
|
||||
@Controller('addresses/users/:userId')
|
||||
export class AddressesController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private async assertSelfAccess(authorization: string | undefined, userId: string) {
|
||||
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
if (requesterId !== userId) {
|
||||
throw new ForbiddenException('Можно управлять только своими адресами');
|
||||
}
|
||||
return requesterId;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список адресов пользователя' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return firstValueFrom(
|
||||
this.core.addresses.ListAddresses({ userId }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { addresses?: unknown[] };
|
||||
return { addresses: payload.addresses ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Создать или обновить адрес' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: UpsertAddressDto })
|
||||
async upsert(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpsertAddressDto
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return firstValueFrom(this.core.addresses.UpsertAddress({ userId, ...dto }));
|
||||
}
|
||||
|
||||
@Delete(':addressId')
|
||||
@ApiOperation({ summary: 'Удалить адрес' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'addressId', description: 'ID адреса' })
|
||||
@ApiResponse({ status: 200, description: 'Адрес удалён' })
|
||||
async delete(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('addressId') addressId: string
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return firstValueFrom(this.core.addresses.DeleteAddress({ userId, addressId }));
|
||||
}
|
||||
}
|
||||
69
apps/api-gateway/src/controllers/admin.controller.ts
Normal file
69
apps/api-gateway/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, UpdateUserDto } from '../dto/admin.dto';
|
||||
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
||||
|
||||
@ApiTags('Администрирование')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/users')
|
||||
export class AdminController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список пользователей', description: 'Возвращает пользователей для административной таблицы.' })
|
||||
listUsers(@Query() query: ListUsersQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
if (!admin.canViewUsers && !admin.canManageUsers) {
|
||||
throw new ForbiddenException('Недостаточно прав для просмотра пользователей');
|
||||
}
|
||||
return this.core.admin.ListUsers(query).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { users?: Array<{ roles?: string[] }> };
|
||||
return {
|
||||
users: (payload.users ?? []).map((user) => ({
|
||||
...user,
|
||||
roles: user.roles ?? []
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':userId')
|
||||
@ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: UpdateUserDto })
|
||||
updateUser(@Param('userId') userId: string, @Body() dto: UpdateUserDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageUsers');
|
||||
return this.core.admin.UpdateUserProfile({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Post(':userId/reset-password')
|
||||
@ApiOperation({ summary: 'Сбросить пароль', description: 'Назначает пользователю новый пароль.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: ResetPasswordDto })
|
||||
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageUsers');
|
||||
return this.core.admin.ResetPassword({ userId, newPassword: dto.newPassword });
|
||||
}
|
||||
|
||||
@Post(':userId/suspend')
|
||||
@ApiOperation({ summary: 'Заблокировать пользователя', description: 'Переводит пользователя в статус SUSPENDED и отзывает активные сессии.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
suspend(@Param('userId') userId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageUsers');
|
||||
return this.core.admin.SuspendUser({ userId });
|
||||
}
|
||||
|
||||
@Patch(':userId/super-admin')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Выдать или снять права супер-администратора', description: 'Доступно только супер-администратору.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: SetSuperAdminDto })
|
||||
setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin });
|
||||
}
|
||||
}
|
||||
42
apps/api-gateway/src/controllers/advanced-auth.controller.ts
Normal file
42
apps/api-gateway/src/controllers/advanced-auth.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
|
||||
|
||||
@ApiTags('Биометрия и QR-вход')
|
||||
@Controller('auth/advanced')
|
||||
export class AdvancedAuthController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Post('webauthn/register/challenge')
|
||||
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
|
||||
@ApiBody({ type: WebAuthnDto })
|
||||
@ApiResponse({ status: 201, description: 'Challenge создан' })
|
||||
webAuthnRegister(@Body() dto: WebAuthnDto) {
|
||||
return this.core.advancedAuth.CreateWebAuthnRegistrationChallenge(dto);
|
||||
}
|
||||
|
||||
@Post('webauthn/login/challenge')
|
||||
@ApiOperation({ summary: 'Challenge входа WebAuthn', description: 'Создает challenge для входа по лицу или отпечатку.' })
|
||||
@ApiBody({ type: WebAuthnDto })
|
||||
@ApiResponse({ status: 201, description: 'Challenge создан' })
|
||||
webAuthnLogin(@Body() dto: WebAuthnDto) {
|
||||
return this.core.advancedAuth.CreateWebAuthnLoginChallenge(dto);
|
||||
}
|
||||
|
||||
@Post('qr/session')
|
||||
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
|
||||
@ApiBody({ type: QrSessionDto })
|
||||
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
|
||||
createQr(@Body() dto: QrSessionDto) {
|
||||
return this.core.advancedAuth.CreateQrSession(dto);
|
||||
}
|
||||
|
||||
@Get('qr/session/:sessionId')
|
||||
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/CONFIRMED/EXPIRED.' })
|
||||
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
|
||||
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
|
||||
pollQr(@Param('sessionId') sessionId: string) {
|
||||
return this.core.advancedAuth.PollQrSession({ sessionId });
|
||||
}
|
||||
}
|
||||
129
apps/api-gateway/src/controllers/auth.controller.ts
Normal file
129
apps/api-gateway/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Body, Controller, Get, Headers, Post } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto } from '../dto/auth.dto';
|
||||
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
|
||||
|
||||
@ApiTags('Аутентификация')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Регистрация пользователя', description: 'Создает пользователя. Первый пользователь безопасно получает права super admin.' })
|
||||
@ApiBody({ type: RegisterDto })
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.core.auth.Register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.core.auth.Login(dto);
|
||||
}
|
||||
|
||||
@Post('identify')
|
||||
@ApiOperation({ summary: 'Проверить способ входа', description: 'Identifier-first шаг: проверяет, существует ли пользователь, задан ли пароль и включен ли PIN.' })
|
||||
@ApiBody({ type: IdentifyDto })
|
||||
identify(@Body() dto: IdentifyDto) {
|
||||
return this.core.auth.Identify(dto);
|
||||
}
|
||||
|
||||
@Post('otp/send')
|
||||
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
|
||||
@ApiBody({ type: PasswordlessOtpDto })
|
||||
sendOtp(@Body() dto: PasswordlessOtpDto) {
|
||||
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel });
|
||||
}
|
||||
|
||||
@Post('otp/verify')
|
||||
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
|
||||
@ApiBody({ type: PasswordlessVerifyDto })
|
||||
verifyOtp(@Body() dto: PasswordlessVerifyDto) {
|
||||
return this.core.auth.VerifyOtp(dto);
|
||||
}
|
||||
|
||||
@Post('login/password')
|
||||
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
|
||||
@ApiBody({ type: PasswordLoginDto })
|
||||
loginWithPassword(@Body() dto: PasswordLoginDto) {
|
||||
return this.core.auth.LoginWithPassword(dto);
|
||||
}
|
||||
|
||||
@Post('ldap/login')
|
||||
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
|
||||
@ApiBody({ type: LdapLoginDto })
|
||||
loginWithLdap(@Body() dto: LdapLoginDto) {
|
||||
return this.core.auth.LoginWithLdap(dto);
|
||||
}
|
||||
|
||||
@Post('pin/verify')
|
||||
@ApiOperation({ summary: 'Подтвердить PIN-код', description: 'Разблокирует временную сессию и выдает JWT с pinVerified=true.' })
|
||||
@ApiBody({ type: VerifyPinDto })
|
||||
verifyPin(@Body() dto: VerifyPinDto) {
|
||||
return this.core.auth.VerifyPin(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
|
||||
@ApiBody({ type: RefreshSessionDto })
|
||||
refresh(@Body() dto: RefreshSessionDto) {
|
||||
return this.core.auth.RefreshSession(dto);
|
||||
}
|
||||
|
||||
@Get('session')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Состояние текущей сессии',
|
||||
description: 'Возвращает профиль и флаг PIN-блокировки. Не выбрасывает пользователя из аккаунта при заблокированном PIN.'
|
||||
})
|
||||
async session(@Headers('authorization') authorization?: string) {
|
||||
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||
|
||||
if (!payload.sessionId) {
|
||||
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
|
||||
return { requiresPin: false, sessionId: null, pinVerified: true, user };
|
||||
}
|
||||
|
||||
const validation = (await firstValueFrom(
|
||||
this.core.auth.ValidateSession({
|
||||
userId: payload.sub,
|
||||
sessionId: payload.sessionId,
|
||||
touchActivity: false
|
||||
})
|
||||
)) as { requiresPin: boolean; sessionId: string; pinVerified: boolean };
|
||||
|
||||
const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
|
||||
|
||||
if (!validation.requiresPin) {
|
||||
await firstValueFrom(
|
||||
this.core.auth.ValidateSession({
|
||||
userId: payload.sub,
|
||||
sessionId: payload.sessionId,
|
||||
touchActivity: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
requiresPin: validation.requiresPin,
|
||||
sessionId: validation.sessionId,
|
||||
pinVerified: validation.pinVerified,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Текущий пользователь', description: 'Возвращает профиль пользователя по access token.' })
|
||||
async me(@Headers('authorization') authorization?: string) {
|
||||
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }));
|
||||
}
|
||||
}
|
||||
142
apps/api-gateway/src/controllers/chat.controller.ts
Normal file
142
apps/api-gateway/src/controllers/chat.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import {
|
||||
CreateChatRoomDto,
|
||||
SendChatMessageDto,
|
||||
SetRoomNotificationsMutedDto,
|
||||
UpdateChatRoomDto,
|
||||
VotePollDto
|
||||
} from '../dto/chat.dto';
|
||||
|
||||
@ApiTags('Чат')
|
||||
@ApiBearerAuth()
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private async auth(authorization?: string) {
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
}
|
||||
|
||||
@Get('groups/:groupId/rooms')
|
||||
@ApiOperation({ summary: 'Список чатов семьи' })
|
||||
async listRooms(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.ListRooms({ userId, groupId }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { rooms?: unknown[] };
|
||||
return { rooms: payload.rooms ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/rooms')
|
||||
@ApiOperation({ summary: 'Создать групповой чат' })
|
||||
async createRoom(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: CreateChatRoomDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.CreateRoom({ userId, groupId, name: dto.name, memberUserIds: dto.memberUserIds ?? [] })
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('rooms/:roomId')
|
||||
@ApiOperation({ summary: 'Настройки чата' })
|
||||
async updateRoom(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: UpdateChatRoomDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.UpdateRoomSettings({
|
||||
userId,
|
||||
roomId,
|
||||
name: dto.name,
|
||||
notificationsMuted: dto.notificationsMuted
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('rooms/:roomId/messages')
|
||||
@ApiOperation({ summary: 'Сообщения чата' })
|
||||
async listMessages(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Query('beforeMessageId') beforeMessageId?: string,
|
||||
@Query('limit') limit?: string
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.ListMessages({
|
||||
userId,
|
||||
roomId,
|
||||
beforeMessageId,
|
||||
limit: limit ? Number(limit) : 50
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { messages?: unknown[] };
|
||||
return { messages: payload.messages ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/messages')
|
||||
@ApiOperation({ summary: 'Отправить сообщение' })
|
||||
async sendMessage(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: SendChatMessageDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.SendMessage({
|
||||
userId,
|
||||
roomId,
|
||||
type: dto.type,
|
||||
content: dto.content,
|
||||
replyToId: dto.replyToId,
|
||||
storageKey: dto.storageKey,
|
||||
mimeType: dto.mimeType,
|
||||
metadataJson: dto.metadataJson,
|
||||
poll: dto.poll
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Post('messages/:messageId/vote')
|
||||
@ApiOperation({ summary: 'Голос в опросе' })
|
||||
async votePoll(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('messageId') messageId: string,
|
||||
@Body() dto: VotePollDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.VotePoll({ userId, messageId, optionIds: dto.optionIds }));
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/mute')
|
||||
@ApiOperation({ summary: 'Выключить/включить уведомления чата' })
|
||||
async setMuted(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: SetRoomNotificationsMutedDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted }));
|
||||
}
|
||||
}
|
||||
97
apps/api-gateway/src/controllers/documents.controller.ts
Normal file
97
apps/api-gateway/src/controllers/documents.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
|
||||
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
|
||||
|
||||
@ApiTags('Хранилище данных и документы')
|
||||
@ApiBearerAuth()
|
||||
@Controller('documents/users/:userId')
|
||||
export class DocumentsController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Создать документ', description: 'Создает документ пользователя с данными формы. Поля шифруются в SSO Core.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: CreateDocumentDto })
|
||||
async create(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: CreateDocumentDto
|
||||
) {
|
||||
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(
|
||||
this.core.documents.CreateDocument({
|
||||
userId,
|
||||
type: dto.type,
|
||||
number: dto.number ?? '—',
|
||||
issuedAt: dto.issuedAt,
|
||||
expiresAt: dto.expiresAt,
|
||||
metadataJson: dto.metadataJson
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список документов', description: 'Возвращает все документы пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(
|
||||
this.core.documents.ListDocuments({ userId }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { documents?: unknown[] };
|
||||
return { documents: payload.documents ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':documentId')
|
||||
@ApiOperation({ summary: 'Получить документ', description: 'Возвращает один документ пользователя с расшифрованными полями.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'documentId', description: 'ID документа' })
|
||||
async get(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('documentId') documentId: string
|
||||
) {
|
||||
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(this.core.documents.GetDocument({ userId, documentId }));
|
||||
}
|
||||
|
||||
@Patch(':documentId')
|
||||
@ApiOperation({ summary: 'Обновить документ', description: 'Обновляет данные формы документа.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'documentId', description: 'ID документа' })
|
||||
@ApiBody({ type: UpdateDocumentDto })
|
||||
async update(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('documentId') documentId: string,
|
||||
@Body() dto: UpdateDocumentDto
|
||||
) {
|
||||
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(this.core.documents.UpdateDocument({ userId, documentId, ...dto }));
|
||||
}
|
||||
|
||||
@Delete(':documentId')
|
||||
@ApiOperation({ summary: 'Удалить документ', description: 'Удаляет документ пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'documentId', description: 'ID документа' })
|
||||
@ApiResponse({ status: 200, description: 'Документ удалён' })
|
||||
async delete(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Param('documentId') documentId: string
|
||||
) {
|
||||
await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId);
|
||||
return firstValueFrom(this.core.documents.DeleteDocument({ userId, documentId }));
|
||||
}
|
||||
}
|
||||
130
apps/api-gateway/src/controllers/family.controller.ts
Normal file
130
apps/api-gateway/src/controllers/family.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
|
||||
import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||
|
||||
|
||||
|
||||
@ApiTags('Семья')
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@Controller('family')
|
||||
|
||||
export class FamilyController {
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly core: CoreGrpcService,
|
||||
|
||||
private readonly jwt: JwtService
|
||||
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
private async auth(authorization?: string) {
|
||||
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Post('groups')
|
||||
|
||||
@ApiOperation({ summary: 'Создать семейную группу' })
|
||||
|
||||
@ApiBody({ type: CreateFamilyGroupDto })
|
||||
|
||||
async createGroup(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateFamilyGroupDto) {
|
||||
|
||||
const userId = await this.auth(authorization);
|
||||
|
||||
if (dto.ownerId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Можно создавать семью только от своего имени');
|
||||
|
||||
}
|
||||
|
||||
return firstValueFrom(this.core.family.CreateFamilyGroup(dto));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Get('users/:userId/groups')
|
||||
|
||||
@ApiOperation({ summary: 'Список семей пользователя' })
|
||||
|
||||
async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
|
||||
const requesterId = await this.auth(authorization);
|
||||
|
||||
if (requesterId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Можно просматривать только свои семьи');
|
||||
|
||||
}
|
||||
|
||||
return firstValueFrom(
|
||||
|
||||
this.core.family.ListFamilyGroups({ userId }).pipe(
|
||||
|
||||
map((response) => {
|
||||
|
||||
const payload = response as { groups?: unknown[] };
|
||||
|
||||
return { groups: payload.groups ?? [] };
|
||||
|
||||
})
|
||||
|
||||
)
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Get('groups/:groupId')
|
||||
|
||||
@ApiOperation({ summary: 'Получить семейную группу' })
|
||||
|
||||
async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
|
||||
const requesterId = await this.auth(authorization);
|
||||
|
||||
return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId }));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Patch('groups/:groupId')
|
||||
|
||||
@ApiOperation({ summary: 'Обновить семейную группу' })
|
||||
|
||||
async updateGroup(
|
||||
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
|
||||
@Param('groupId') groupId: string,
|
||||
|
||||
@Body() dto: UpdateFamilyGroupDto
|
||||
|
||||
) {
|
||||
|
||||
const requesterId = await this.auth(authorization);
|
||||
|
||||
9
apps/api-gateway/src/controllers/health.controller.ts
Normal file
9
apps/api-gateway/src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'api-gateway' };
|
||||
}
|
||||
}
|
||||
215
apps/api-gateway/src/controllers/media.controller.ts
Normal file
215
apps/api-gateway/src/controllers/media.controller.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { AvatarUploadDto, ChatMediaUploadDto, ConfirmAvatarDto, DocumentPhotoUploadDto } from '../dto/media.dto';
|
||||
import { buildContentDisposition } from '../media-content-disposition';
|
||||
|
||||
type StreamResponse = {
|
||||
setHeader: (key: string, value: string) => void;
|
||||
status: (code: number) => { json: (body: unknown) => void };
|
||||
} & NodeJS.WritableStream;
|
||||
|
||||
@ApiTags('Медиа')
|
||||
@Controller('media')
|
||||
export class MediaController {
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private getS3Client() {
|
||||
if (!this.s3Client) {
|
||||
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000';
|
||||
const useSsl = process.env.MINIO_USE_SSL === 'true';
|
||||
this.s3Client = new S3Client({
|
||||
endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`,
|
||||
region: process.env.MINIO_REGION ?? 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
|
||||
secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
}
|
||||
return this.s3Client;
|
||||
}
|
||||
|
||||
private async authUserId(authorization?: string) {
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
}
|
||||
|
||||
@Post('avatars/upload-url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })
|
||||
@ApiBody({ type: AvatarUploadDto })
|
||||
async createAvatarUploadUrl(@Headers('authorization') authorization: string | undefined, @Body() dto: AvatarUploadDto) {
|
||||
const userId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.CreateAvatarUploadUrl({ userId, contentType: dto.contentType }));
|
||||
}
|
||||
|
||||
@Post('avatars/confirm')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Подтвердить загрузку аватара', description: 'Сохраняет ключ объекта MinIO как аватар пользователя.' })
|
||||
@ApiBody({ type: ConfirmAvatarDto })
|
||||
async confirmAvatar(@Headers('authorization') authorization: string | undefined, @Body() dto: ConfirmAvatarDto) {
|
||||
const userId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.ConfirmAvatar({ userId, storageKey: dto.storageKey }));
|
||||
}
|
||||
|
||||
@Get('avatars/:userId/url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить временную ссылку на аватар', description: 'Возвращает ссылку, действующую 15 минут. Без авторизации ссылка не выдаётся.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
async getAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.GetAvatarAccessUrl({ requesterId, targetUserId: userId }));
|
||||
}
|
||||
|
||||
@Post('documents/:documentId/photo/upload-url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' })
|
||||
@ApiBody({ type: DocumentPhotoUploadDto })
|
||||
async createDocumentPhotoUploadUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('documentId') documentId: string,
|
||||
@Body() dto: DocumentPhotoUploadDto
|
||||
) {
|
||||
const userId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType }));
|
||||
}
|
||||
|
||||
@Get('users/:userId/documents/photo-url')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' })
|
||||
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
|
||||
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
|
||||
async getDocumentPhotoUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Query('storageKey') storageKey: string
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey })
|
||||
);
|
||||
}
|
||||
|
||||
@Get('stream/:token')
|
||||
@ApiOperation({ summary: 'Потоковая выдача медиа', description: 'Отдаёт файл по временному токену. Для файлов чата требуется Authorization. Токен действует 15 минут.' })
|
||||
@ApiParam({ name: 'token', description: 'Временный JWT-токен доступа' })
|
||||
@ApiResponse({ status: 403, description: 'Ссылка недействительна или истекла' })
|
||||
async stream(
|
||||
@Param('token') token: string,
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Query('download') download: string | undefined,
|
||||
@Res({ passthrough: false }) response: StreamResponse
|
||||
) {
|
||||
let requesterId: string | undefined;
|
||||
if (authorization) {
|
||||
try {
|
||||
requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
} catch {
|
||||
requesterId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = await firstValueFrom(this.core.media.ResolveStreamToken({ token, requesterId }));
|
||||
const payload = resolved as { storageKey: string; contentType: string; fileName?: string };
|
||||
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
|
||||
const object = await this.getS3Client().send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: payload.storageKey
|
||||
})
|
||||
);
|
||||
|
||||
response.setHeader('Content-Type', payload.contentType);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
if (payload.fileName) {
|
||||
const asAttachment = download === '1' || download === 'true';
|
||||
response.setHeader(
|
||||
'Content-Disposition',
|
||||
buildContentDisposition(asAttachment ? 'attachment' : 'inline', payload.fileName)
|
||||
);
|
||||
}
|
||||
const body = object.Body;
|
||||
if (!body) {
|
||||
response.status(404).json({ message: 'Файл не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = body as NodeJS.ReadableStream;
|
||||
stream.pipe(response);
|
||||
}
|
||||
|
||||
@Post('families/:groupId/avatar/upload-url')
|
||||
@ApiBearerAuth()
|
||||
async createFamilyAvatarUploadUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: AvatarUploadDto
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.CreateFamilyAvatarUploadUrl({ requesterId, groupId, contentType: dto.contentType })
|
||||
);
|
||||
}
|
||||
|
||||
@Post('families/:groupId/avatar/confirm')
|
||||
@ApiBearerAuth()
|
||||
async confirmFamilyAvatar(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: ConfirmAvatarDto
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.ConfirmFamilyAvatar({ requesterId, groupId, storageKey: dto.storageKey })
|
||||
);
|
||||
}
|
||||
|
||||
@Get('families/:groupId/avatar/url')
|
||||
@ApiBearerAuth()
|
||||
async getFamilyAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(this.core.media.GetFamilyAvatarAccessUrl({ requesterId, groupId }));
|
||||
}
|
||||
|
||||
@Post('chat/:roomId/media/upload-url')
|
||||
@ApiBearerAuth()
|
||||
async createChatMediaUploadUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: ChatMediaUploadDto
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.CreateChatMediaUploadUrl({
|
||||
requesterId,
|
||||
roomId,
|
||||
contentType: dto.contentType,
|
||||
fileName: dto.fileName
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('chat/:roomId/media/url')
|
||||
@ApiBearerAuth()
|
||||
async getChatMediaUrl(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Query('storageKey') storageKey: string,
|
||||
@Query('fileName') fileName?: string
|
||||
) {
|
||||
const requesterId = await this.authUserId(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName })
|
||||
);
|
||||
}
|
||||
}
|
||||
82
apps/api-gateway/src/controllers/notifications.controller.ts
Normal file
82
apps/api-gateway/src/controllers/notifications.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { MarkNotificationReadDto } from '../dto/notifications.dto';
|
||||
|
||||
@ApiTags('Уведомления')
|
||||
@ApiBearerAuth()
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список уведомлений' })
|
||||
async list(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('unreadOnly') unreadOnly?: string
|
||||
) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(
|
||||
this.core.notifications.ListNotifications({
|
||||
userId,
|
||||
limit: limit ? Number(limit) : 30,
|
||||
unreadOnly: unreadOnly === 'true'
|
||||
}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { notifications?: unknown[] };
|
||||
return { notifications: payload.notifications ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
|
||||
async unreadCount(@Headers('authorization') authorization: string | undefined) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
|
||||
}
|
||||
|
||||
@Patch(':notificationId/read')
|
||||
@ApiOperation({ summary: 'Удалить просмотренное уведомление' })
|
||||
async markRead(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('notificationId') notificationId: string,
|
||||
@Body() _dto: MarkNotificationReadDto
|
||||
) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
|
||||
}
|
||||
|
||||
@Delete(':notificationId')
|
||||
@ApiOperation({ summary: 'Удалить уведомление' })
|
||||
async deleteNotification(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('notificationId') notificationId: string
|
||||
) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId }));
|
||||
}
|
||||
|
||||
@Post('read-all')
|
||||
@ApiOperation({ summary: 'Удалить все уведомления' })
|
||||
async markAllRead(@Headers('authorization') authorization: string | undefined) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@ApiOperation({ summary: 'Удалить все уведомления' })
|
||||
async deleteAll(@Headers('authorization') authorization: string | undefined) {
|
||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
||||
}
|
||||
}
|
||||
38
apps/api-gateway/src/controllers/oauth.controller.ts
Normal file
38
apps/api-gateway/src/controllers/oauth.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
|
||||
import { extractBearerToken } from '../auth-token';
|
||||
|
||||
@ApiTags('OAuth 2.0')
|
||||
@Controller('oauth')
|
||||
export class OAuthController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('authorize')
|
||||
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
|
||||
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
|
||||
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
|
||||
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
|
||||
@ApiResponse({ status: 200, description: 'Authorization code создан' })
|
||||
authorize(@Query() query: OAuthAuthorizeQueryDto) {
|
||||
return this.core.oauth.Authorize(query);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
|
||||
@ApiBody({ type: OAuthTokenDto })
|
||||
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
|
||||
token(@Body() dto: OAuthTokenDto) {
|
||||
return this.core.oauth.Token(dto);
|
||||
}
|
||||
|
||||
@Get('userinfo')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
|
||||
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
|
||||
userInfo(@Headers('authorization') authorization?: string) {
|
||||
return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) });
|
||||
}
|
||||
}
|
||||
26
apps/api-gateway/src/controllers/otp.controller.ts
Normal file
26
apps/api-gateway/src/controllers/otp.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { SendOtpDto, VerifyOtpDto } from '../dto/identity.dto';
|
||||
|
||||
@ApiTags('OTP и 2FA')
|
||||
@Controller('auth/otp')
|
||||
export class OtpController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Post('send')
|
||||
@ApiOperation({ summary: 'Отправить OTP код', description: 'Создает одноразовый код в AuthCode. Fallback-доставка пишет код в console.log.' })
|
||||
@ApiBody({ type: SendOtpDto })
|
||||
@ApiResponse({ status: 201, description: 'Код создан и отправлен' })
|
||||
send(@Body() dto: SendOtpDto) {
|
||||
return this.core.otp.SendOtp(dto);
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
@ApiOperation({ summary: 'Проверить OTP код', description: 'Проверяет одноразовый код, срок жизни и помечает его использованным.' })
|
||||
@ApiBody({ type: VerifyOtpDto })
|
||||
@ApiResponse({ status: 201, description: 'Код подтвержден' })
|
||||
verify(@Body() dto: VerifyOtpDto) {
|
||||
return this.core.otp.VerifyOtp(dto);
|
||||
}
|
||||
}
|
||||
83
apps/api-gateway/src/controllers/profile.controller.ts
Normal file
83
apps/api-gateway/src/controllers/profile.controller.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
|
||||
import { SetPasswordDto, UpdateAvatarDto, UpdateContactsDto, UpdateProfileDto } from '../dto/profile.dto';
|
||||
|
||||
|
||||
|
||||
@ApiTags('Профиль и биометрия')
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@Controller('profile/users/:userId')
|
||||
|
||||
export class ProfileController {
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly core: CoreGrpcService,
|
||||
|
||||
private readonly jwt: JwtService
|
||||
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
private async assertSelfAccess(authorization: string | undefined, userId: string) {
|
||||
|
||||
const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
|
||||
if (requesterId !== userId) {
|
||||
|
||||
throw new ForbiddenException('Можно изменять только свой профиль');
|
||||
|
||||
}
|
||||
|
||||
return requesterId;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Get()
|
||||
|
||||
@ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' })
|
||||
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
|
||||
@ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' })
|
||||
|
||||
@ApiResponse({ status: 404, description: 'Пользователь не найден' })
|
||||
|
||||
getProfile(@Param('userId') userId: string) {
|
||||
|
||||
return this.core.profile.GetProfile({ userId });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Patch('avatar')
|
||||
|
||||
@ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' })
|
||||
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
|
||||
@ApiBody({ type: UpdateAvatarDto })
|
||||
|
||||
@ApiResponse({ status: 200, description: 'Аватар обновлен' })
|
||||
|
||||
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
|
||||
|
||||
return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey });
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
|
||||
@ApiTags('Публичные настройки')
|
||||
@Controller('settings')
|
||||
export class PublicSettingsController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('public')
|
||||
@ApiOperation({ summary: 'Публичные настройки', description: 'Возвращает несекретные настройки для интерфейса без авторизации.' })
|
||||
listPublic() {
|
||||
return this.core.settings.ListPublicSettings({}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { settings?: Array<{ key: string; value: string }> };
|
||||
return { settings: payload.settings ?? [] };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
103
apps/api-gateway/src/controllers/rbac.controller.ts
Normal file
103
apps/api-gateway/src/controllers/rbac.controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto';
|
||||
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
||||
|
||||
@ApiTags('RBAC и OAuth')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/rbac')
|
||||
export class RbacController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('roles')
|
||||
@ApiOperation({ summary: 'Список ролей', description: 'Возвращает роли вместе с назначенными правами.' })
|
||||
listRoles() {
|
||||
return this.core.rbac.ListRoles({}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { roles?: Array<{ permissions?: unknown[] }> };
|
||||
return {
|
||||
roles: (payload.roles ?? []).map((role) => ({
|
||||
...role,
|
||||
permissions: role.permissions ?? []
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' })
|
||||
listPermissions() {
|
||||
return this.core.rbac.ListPermissions({});
|
||||
}
|
||||
|
||||
@Get('oauth-scopes')
|
||||
@ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' })
|
||||
listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.ListOAuthScopes({});
|
||||
}
|
||||
|
||||
@Get('oauth-clients')
|
||||
@ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' })
|
||||
listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.ListOAuthClients({});
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' })
|
||||
@ApiBody({ type: CreateRoleDto })
|
||||
createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto });
|
||||
}
|
||||
|
||||
@Post('users/:userId/roles')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: AssignUserRoleDto })
|
||||
assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.rbac.AssignUserRole({ actorUserId: admin.id, userId, roleSlug: dto.roleSlug });
|
||||
}
|
||||
|
||||
@Delete('users/:userId/roles/:roleSlug')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
|
||||
removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug });
|
||||
}
|
||||
|
||||
@Post('oauth-clients')
|
||||
@ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' })
|
||||
@ApiBody({ type: CreateOAuthClientDto })
|
||||
createOAuthClient(@Body() dto: CreateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.CreateOAuthClient({ actorUserId: admin.id, ...dto });
|
||||
}
|
||||
|
||||
@Patch('oauth-clients/:clientId')
|
||||
@ApiOperation({ summary: 'Обновить OAuth-приложение', description: 'Изменяет redirect URI, scopes и статус приложения.' })
|
||||
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
|
||||
@ApiBody({ type: UpdateOAuthClientDto })
|
||||
updateOAuthClient(@Param('clientId') clientId: string, @Body() dto: UpdateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto });
|
||||
}
|
||||
|
||||
@Post('oauth-clients/:clientId/rotate-secret')
|
||||
@ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' })
|
||||
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
|
||||
rotateSecret(@Param('clientId') clientId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageOAuth');
|
||||
return this.core.rbac.RotateOAuthSecret({ actorUserId: admin.id, clientId });
|
||||
}
|
||||
}
|
||||
111
apps/api-gateway/src/controllers/security.controller.ts
Normal file
111
apps/api-gateway/src/controllers/security.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { OptionalPinDto, PinDto, VerifySecurityPinDto } from '../dto/security.dto';
|
||||
|
||||
@ApiTags('Безопасность')
|
||||
@ApiBearerAuth()
|
||||
@Controller('security')
|
||||
export class SecurityController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get('users/:userId/devices')
|
||||
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Список устройств получен' })
|
||||
listDevices(@Param('userId') userId: string) {
|
||||
return this.core.security.ListActiveDevices({ userId });
|
||||
}
|
||||
|
||||
@Get('users/:userId/sessions')
|
||||
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Список активных сессий получен' })
|
||||
listSessions(@Param('userId') userId: string) {
|
||||
return this.core.security.ListActiveSessions({ userId });
|
||||
}
|
||||
|
||||
@Get('users/:userId/sign-in-history')
|
||||
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'История входов получена' })
|
||||
listHistory(@Param('userId') userId: string) {
|
||||
return this.core.security.ListSignInHistory({ userId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/setup')
|
||||
@ApiOperation({ summary: 'Настроить PIN-код', description: 'Создает или включает PIN-код пользователя. PIN хранится только в виде bcrypt hash.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: PinDto })
|
||||
@ApiResponse({ status: 201, description: 'PIN-код настроен' })
|
||||
setupPin(@Param('userId') userId: string, @Body() dto: PinDto) {
|
||||
return this.core.security.SetupPin({ userId, pin: dto.pin });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/update')
|
||||
@ApiOperation({ summary: 'Обновить PIN-код', description: 'Меняет PIN-код и переводит активные сессии пользователя в LOCKED до повторной проверки.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: PinDto })
|
||||
@ApiResponse({ status: 201, description: 'PIN-код обновлен' })
|
||||
updatePin(@Param('userId') userId: string, @Body() dto: PinDto) {
|
||||
return this.core.security.UpdatePin({ userId, pin: dto.pin });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/delete-request')
|
||||
@ApiOperation({ summary: 'Запросить удаление PIN-кода', description: 'Запускает период ожидания перед отключением PIN-кода согласно SystemSetting.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: OptionalPinDto })
|
||||
requestPinDeletion(@Param('userId') userId: string, @Body() dto: OptionalPinDto) {
|
||||
return this.core.security.RequestPinDeletion({ userId, pin: dto.pin ?? '' });
|
||||
}
|
||||
|
||||
@Post('users/:userId/pin/delete-cancel')
|
||||
@ApiOperation({ summary: 'Отменить удаление PIN-кода', description: 'Отменяет запланированное удаление PIN-кода.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
cancelPinDeletion(@Param('userId') userId: string) {
|
||||
return this.core.security.CancelPinDeletion({ userId });
|
||||
}
|
||||
|
||||
@Post('pin/verify')
|
||||
@ApiOperation({ summary: 'Проверить PIN-код', description: 'Проверяет PIN-код и возвращает access token с pinVerified=true для указанной сессии.' })
|
||||
@ApiBody({ type: VerifySecurityPinDto })
|
||||
@ApiResponse({ status: 201, description: 'PIN-код проверен' })
|
||||
verifyPin(@Body() dto: VerifySecurityPinDto) {
|
||||
return this.core.security.VerifyPinCode(dto);
|
||||
}
|
||||
|
||||
@Post('users/:userId/sessions/:sessionId/lock')
|
||||
@ApiOperation({ summary: 'Заблокировать сессию', description: 'Переводит активную сессию в LOCKED и сбрасывает pinVerified=false.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
|
||||
@ApiResponse({ status: 201, description: 'Сессия заблокирована' })
|
||||
lockSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
|
||||
return this.core.security.LockSession({ userId, sessionId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/sessions/:sessionId/revoke')
|
||||
@ApiOperation({ summary: 'Выйти с устройства', description: 'Отзывает конкретную сессию пользователя и завершает доступ с выбранного устройства.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'sessionId', description: 'ID сессии' })
|
||||
@ApiResponse({ status: 201, description: 'Сессия отозвана' })
|
||||
revokeSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) {
|
||||
return this.core.security.RevokeSession({ userId, sessionId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/devices/:deviceId/revoke')
|
||||
@ApiOperation({ summary: 'Завершить сессии устройства', description: 'Отзывает все активные сессии выбранного устройства пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'deviceId', description: 'ID устройства' })
|
||||
@ApiResponse({ status: 201, description: 'Сессии устройства завершены' })
|
||||
revokeDevice(@Param('userId') userId: string, @Param('deviceId') deviceId: string) {
|
||||
return this.core.security.RevokeDevice({ userId, deviceId });
|
||||
}
|
||||
|
||||
@Post('users/:userId/revoke-all-sessions')
|
||||
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 201, description: 'Все сессии отозваны' })
|
||||
revokeAll(@Param('userId') userId: string) {
|
||||
return this.core.security.RevokeAllSessions({ userId });
|
||||
}
|
||||
}
|
||||
125
apps/api-gateway/src/controllers/settings.controller.ts
Normal file
125
apps/api-gateway/src/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { map } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
|
||||
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
|
||||
|
||||
const settingsWritePipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: false
|
||||
});
|
||||
|
||||
@ApiTags('Глобальные настройки')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/settings')
|
||||
export class SettingsController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список системных настроек', description: 'Возвращает динамические настройки, включая PIN timeout и OAuth providers.' })
|
||||
@ApiResponse({ status: 200, description: 'Список настроек получен' })
|
||||
list(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.ListSettings({}).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { settings?: unknown[] };
|
||||
return { settings: payload.settings ?? [] };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('key/:key')
|
||||
@ApiOperation({ summary: 'Получить настройку', description: 'Возвращает SystemSetting по ключу.' })
|
||||
@ApiParam({ name: 'key', description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
|
||||
@ApiResponse({ status: 200, description: 'Настройка получена' })
|
||||
get(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.GetSetting({ key });
|
||||
}
|
||||
|
||||
@Put()
|
||||
@UsePipes(settingsWritePipe)
|
||||
@ApiOperation({ summary: 'Создать или обновить настройку', description: 'Меняет значение SystemSetting без изменения кода frontend.' })
|
||||
@ApiBody({ type: UpsertSettingDto })
|
||||
@ApiResponse({ status: 200, description: 'Настройка создана или обновлена' })
|
||||
upsert(@Body() dto: UpsertSettingDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.UpsertSetting({
|
||||
key: dto.key,
|
||||
value: dto.value,
|
||||
description: dto.description,
|
||||
isSecret: dto.isSecret
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('key/:key')
|
||||
@ApiOperation({ summary: 'Удалить настройку', description: 'Удаляет SystemSetting по ключу.' })
|
||||
@ApiParam({ name: 'key', description: 'Ключ настройки' })
|
||||
@ApiResponse({ status: 200, description: 'Настройка удалена' })
|
||||
delete(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.DeleteSetting({ key });
|
||||
}
|
||||
|
||||
@Get('oauth/providers')
|
||||
@ApiOperation({ summary: 'Список OAuth провайдеров', description: 'Возвращает глобальные настройки Google/Yandex и других social login providers.' })
|
||||
@ApiResponse({ status: 200, description: 'Список провайдеров получен' })
|
||||
listProviders(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.ListSocialProviders({});
|
||||
}
|
||||
|
||||
@Put('oauth/providers')
|
||||
@UsePipes(settingsWritePipe)
|
||||
@ApiOperation({ summary: 'Создать или обновить OAuth провайдера', description: 'Управляет clientId/clientSecret и глобальным включением Google/Yandex входа.' })
|
||||
@ApiBody({ type: UpsertSocialProviderDto })
|
||||
@ApiResponse({ status: 200, description: 'Провайдер создан или обновлен' })
|
||||
upsertProvider(@Body() dto: UpsertSocialProviderDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.UpsertSocialProvider({
|
||||
providerName: dto.providerName,
|
||||
clientId: dto.clientId,
|
||||
clientSecret: dto.clientSecret,
|
||||
isEnabled: dto.isEnabled
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('oauth/providers/:providerName')
|
||||
@ApiOperation({ summary: 'Удалить OAuth провайдера', description: 'Удаляет глобальную настройку social login provider.' })
|
||||
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
|
||||
@ApiResponse({ status: 200, description: 'Провайдер удален' })
|
||||
deleteProvider(@Param('providerName') providerName: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertAdminPermission(admin, 'canManageSettings');
|
||||
return this.core.settings.DeleteSocialProvider({ providerName });
|
||||
}
|
||||
|
||||
@Get('linked-accounts/users/:userId')
|
||||
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Список внешних аккаунтов получен' })
|
||||
listLinkedAccounts(@Param('userId') userId: string) {
|
||||
return this.core.security.ListLinkedAccounts({ userId });
|
||||
}
|
||||
|
||||
@Put('linked-accounts/users/:userId')
|
||||
@ApiOperation({ summary: 'Подключить внешний аккаунт', description: 'Создает или обновляет LinkedAccount для social OAuth логина пользователя.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiBody({ type: ConnectLinkedAccountDto })
|
||||
@ApiResponse({ status: 200, description: 'Внешний аккаунт подключен' })
|
||||
connectLinkedAccount(@Param('userId') userId: string, @Body() dto: ConnectLinkedAccountDto) {
|
||||
return this.core.security.ConnectLinkedAccount({ userId, ...dto });
|
||||
}
|
||||
|
||||
@Delete('linked-accounts/users/:userId/:providerName')
|
||||
@ApiOperation({ summary: 'Отключить внешний аккаунт', description: 'Удаляет связь пользователя с Google/Yandex или другим social provider.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' })
|
||||
@ApiResponse({ status: 200, description: 'Внешний аккаунт отключен' })
|
||||
disconnectLinkedAccount(@Param('userId') userId: string, @Param('providerName') providerName: string) {
|
||||
return this.core.security.DisconnectLinkedAccount({ userId, providerName });
|
||||
}
|
||||
}
|
||||
44
apps/api-gateway/src/core-grpc.service.ts
Normal file
44
apps/api-gateway/src/core-grpc.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ClientGrpc } from '@nestjs/microservices';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
type GrpcMethod<TRequest = unknown, TResponse = unknown> = (request: TRequest) => Observable<TResponse>;
|
||||
|
||||
@Injectable()
|
||||
export class CoreGrpcService implements OnModuleInit {
|
||||
auth!: Record<string, GrpcMethod>;
|
||||
admin!: Record<string, GrpcMethod>;
|
||||
rbac!: Record<string, GrpcMethod>;
|
||||
security!: Record<string, GrpcMethod>;
|
||||
settings!: Record<string, GrpcMethod>;
|
||||
profile!: Record<string, GrpcMethod>;
|
||||
documents!: Record<string, GrpcMethod>;
|
||||
addresses!: Record<string, GrpcMethod>;
|
||||
oauth!: Record<string, GrpcMethod>;
|
||||
otp!: Record<string, GrpcMethod>;
|
||||
advancedAuth!: Record<string, GrpcMethod>;
|
||||
notifications!: Record<string, GrpcMethod>;
|
||||
chat!: Record<string, GrpcMethod>;
|
||||
family!: Record<string, GrpcMethod>;
|
||||
media!: Record<string, GrpcMethod>;
|
||||
|
||||
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.auth = this.client.getService<Record<string, GrpcMethod>>('AuthService');
|
||||
this.admin = this.client.getService<Record<string, GrpcMethod>>('AdminService');
|
||||
this.rbac = this.client.getService<Record<string, GrpcMethod>>('RbacService');
|
||||
this.security = this.client.getService<Record<string, GrpcMethod>>('SecurityService');
|
||||
this.settings = this.client.getService<Record<string, GrpcMethod>>('SettingsService');
|
||||
this.profile = this.client.getService<Record<string, GrpcMethod>>('ProfileService');
|
||||
this.documents = this.client.getService<Record<string, GrpcMethod>>('DocumentsService');
|
||||
this.addresses = this.client.getService<Record<string, GrpcMethod>>('AddressesService');
|
||||
this.oauth = this.client.getService<Record<string, GrpcMethod>>('OAuthCoreService');
|
||||
this.otp = this.client.getService<Record<string, GrpcMethod>>('OtpService');
|
||||
this.advancedAuth = this.client.getService<Record<string, GrpcMethod>>('AdvancedAuthService');
|
||||
this.family = this.client.getService<Record<string, GrpcMethod>>('FamilyService');
|
||||
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
|
||||
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
|
||||
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AdminRequestUser } from '../guards/admin.guard';
|
||||
|
||||
export const CurrentAdmin = createParamDecorator((_data: unknown, context: ExecutionContext): AdminRequestUser => {
|
||||
const request = context.switchToHttp().getRequest<{ adminUser: AdminRequestUser }>();
|
||||
return request.adminUser;
|
||||
});
|
||||
52
apps/api-gateway/src/document-access.ts
Normal file
52
apps/api-gateway/src/document-access.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from './core-grpc.service';
|
||||
import { assertSessionUnlocked, verifyAccessToken } from './session-auth';
|
||||
|
||||
interface RequesterProfile {
|
||||
id: string;
|
||||
isSuperAdmin: boolean;
|
||||
canViewUserDocuments?: boolean;
|
||||
}
|
||||
|
||||
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> {
|
||||
const payload = await verifyAccessToken(jwt, authorization);
|
||||
await assertSessionUnlocked(core, payload);
|
||||
const profile = (await firstValueFrom(core.auth.GetMe({ userId: payload.sub }))) as RequesterProfile & { id: string };
|
||||
return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments };
|
||||
}
|
||||
|
||||
export async function assertDocumentsReadAccess(
|
||||
jwt: JwtService,
|
||||
core: CoreGrpcService,
|
||||
authorization: string | undefined,
|
||||
targetUserId: string
|
||||
) {
|
||||
const requester = await getRequesterProfile(jwt, core, authorization);
|
||||
if (requester.id === targetUserId) {
|
||||
return requester.id;
|
||||
}
|
||||
if (!requester.canViewUserDocuments && !requester.isSuperAdmin) {
|
||||
throw new ForbiddenException('Нет доступа к документам пользователя');
|
||||
}
|
||||
return requester.id;
|
||||
}
|
||||
|
||||
export async function assertDocumentsWriteAccess(
|
||||
jwt: JwtService,
|
||||
core: CoreGrpcService,
|
||||
authorization: string | undefined,
|
||||
targetUserId: string
|
||||
) {
|
||||
const requester = await getRequesterProfile(jwt, core, authorization);
|
||||
if (requester.id !== targetUserId) {
|
||||
throw new ForbiddenException('Нельзя изменять документы другого пользователя');
|
||||
}
|
||||
return requester.id;
|
||||
}
|
||||
|
||||
export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) {
|
||||
const requester = await getRequesterProfile(jwt, core, authorization);
|
||||
return requester.id;
|
||||
}
|
||||
53
apps/api-gateway/src/dto/addresses.dto.ts
Normal file
53
apps/api-gateway/src/dto/addresses.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpsertAddressDto {
|
||||
@ApiPropertyOptional({ description: 'ID адреса для обновления' })
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: 'ID адреса должен быть UUID' })
|
||||
addressId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Тип адреса', enum: ['HOME', 'WORK', 'OTHER'] })
|
||||
@IsIn(['HOME', 'WORK', 'OTHER'], { message: 'Некорректный тип адреса' })
|
||||
label!: 'HOME' | 'WORK' | 'OTHER';
|
||||
|
||||
@ApiProperty({ description: 'Город' })
|
||||
@IsString({ message: 'Город должен быть строкой' })
|
||||
@MinLength(1, { message: 'Укажите город' })
|
||||
city!: string;
|
||||
|
||||
@ApiProperty({ description: 'Улица' })
|
||||
@IsString({ message: 'Улица должна быть строкой' })
|
||||
@MinLength(1, { message: 'Укажите улицу' })
|
||||
street!: string;
|
||||
|
||||
@ApiProperty({ description: 'Дом' })
|
||||
@IsString({ message: 'Дом должен быть строкой' })
|
||||
@MinLength(1, { message: 'Укажите дом' })
|
||||
house!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Квартира или офис' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Квартира должна быть строкой' })
|
||||
apartment?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Комментарий' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Комментарий должен быть строкой' })
|
||||
comment?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Широта' })
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: 'Широта должна быть числом' })
|
||||
latitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Долгота' })
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: 'Долгота должна быть числом' })
|
||||
longitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Полный адрес одной строкой' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Полный адрес должен быть строкой' })
|
||||
fullAddress?: string;
|
||||
}
|
||||
60
apps/api-gateway/src/dto/admin.dto.ts
Normal file
60
apps/api-gateway/src/dto/admin.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export enum GatewayUserStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
SUSPENDED = 'SUSPENDED',
|
||||
DELETED = 'DELETED'
|
||||
}
|
||||
|
||||
export class ListUsersQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Поиск по почте, телефону, имени или логину' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Поисковая строка должна быть текстом' })
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional({ description: 'Отображаемое имя' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Имя должно быть строкой' })
|
||||
displayName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Основная почта' })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Укажите корректную почту' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Основной телефон' })
|
||||
@IsOptional()
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный телефон' })
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Резервная почта' })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Укажите корректную резервную почту' })
|
||||
backupEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Резервный телефон' })
|
||||
@IsOptional()
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный резервный телефон' })
|
||||
backupPhone?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Статус пользователя', enum: GatewayUserStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(GatewayUserStatus, { message: 'Укажите корректный статус пользователя' })
|
||||
status?: GatewayUserStatus;
|
||||
}
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ description: 'Новый пароль пользователя', minLength: 8 })
|
||||
@IsString({ message: 'Пароль должен быть строкой' })
|
||||
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
export class SetSuperAdminDto {
|
||||
@ApiProperty({ description: 'Выдать или снять права супер-администратора' })
|
||||
@IsBoolean({ message: 'isSuperAdmin должно быть boolean' })
|
||||
isSuperAdmin!: boolean;
|
||||
}
|
||||
189
apps/api-gateway/src/dto/auth.dto.ts
Normal file
189
apps/api-gateway/src/dto/auth.dto.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, Length, Matches, MinLength } from 'class-validator';
|
||||
|
||||
const EMAIL_OR_PHONE_PATTERN = /^([^\s@]+@[^\s@]+\.[^\s@]+|\+?[1-9]\d{9,14})$/;
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiPropertyOptional({ description: 'Основная почта пользователя', example: 'user@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Укажите корректную почту' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Основной телефон пользователя', example: '+79990000000' })
|
||||
@IsOptional()
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный номер телефона' })
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Пароль пользователя. Не требуется для passwordless-регистрации.', minLength: 8 })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Пароль должен быть строкой' })
|
||||
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
|
||||
password?: string;
|
||||
|
||||
@ApiProperty({ description: 'Имя, которое отображается в профиле', example: 'Иван Петров' })
|
||||
@IsString({ message: 'Имя должно быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите имя профиля' })
|
||||
displayName!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Публичный логин пользователя', example: 'ivan' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Логин должен быть строкой' })
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ description: 'Почта, телефон или логин', example: 'user@example.com' })
|
||||
@IsString({ message: 'Логин должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите логин' })
|
||||
login!: string;
|
||||
|
||||
@ApiProperty({ description: 'Пароль пользователя' })
|
||||
@IsString({ message: 'Пароль должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите пароль' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
|
||||
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Передайте отпечаток устройства' })
|
||||
fingerprint!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Название устройства', example: 'Chrome на Windows' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Название устройства должно быть строкой' })
|
||||
deviceName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Тип устройства должен быть строкой' })
|
||||
deviceType?: string;
|
||||
}
|
||||
|
||||
export class IdentifyDto {
|
||||
@ApiProperty({ description: 'Почта или телефон пользователя', example: 'user@example.com' })
|
||||
@IsString({ message: 'Логин должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите почту или телефон' })
|
||||
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
|
||||
login!: string;
|
||||
}
|
||||
|
||||
export class PasswordlessOtpDto {
|
||||
@ApiProperty({ description: 'Почта или телефон для входа', example: 'user@example.com' })
|
||||
@IsString({ message: 'Получатель должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите почту или телефон' })
|
||||
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
|
||||
recipient!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Альтернативный канал доставки кода', enum: ['primary', 'email', 'phone', 'backupEmail', 'backupPhone'] })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Канал должен быть строкой' })
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export class PasswordlessVerifyDto {
|
||||
@ApiProperty({ description: 'Почта или телефон, на который отправлен код', example: 'user@example.com' })
|
||||
@IsString({ message: 'Получатель должен быть строкой' })
|
||||
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
|
||||
recipient!: string;
|
||||
|
||||
@ApiProperty({ description: 'OTP-код из 6 цифр', example: '123456' })
|
||||
@IsString({ message: 'Код должен быть строкой' })
|
||||
@Length(6, 6, { message: 'Код должен содержать 6 цифр' })
|
||||
@Matches(/^\d+$/, { message: 'Код должен содержать только цифры' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
|
||||
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
|
||||
fingerprint!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Название устройства' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Название устройства должно быть строкой' })
|
||||
deviceName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Тип устройства должен быть строкой' })
|
||||
deviceType?: string;
|
||||
}
|
||||
|
||||
export class PasswordLoginDto {
|
||||
@ApiPropertyOptional({ description: 'Почта или телефон пользователя для identifier-first входа' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Логин должен быть строкой' })
|
||||
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
|
||||
login?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Временный auth_token после успешной OTP-проверки, если используется legacy challenge flow' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Временный токен должен быть строкой' })
|
||||
tempAuthToken?: string;
|
||||
|
||||
@ApiProperty({ description: 'Пароль пользователя' })
|
||||
@IsString({ message: 'Пароль должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите пароль' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
|
||||
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
|
||||
fingerprint!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Название устройства' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Название устройства должно быть строкой' })
|
||||
deviceName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Тип устройства должен быть строкой' })
|
||||
deviceType?: string;
|
||||
}
|
||||
|
||||
export class VerifyPinDto {
|
||||
@ApiProperty({ description: 'ID временно заблокированной сессии' })
|
||||
@IsString({ message: 'ID сессии должен быть строкой' })
|
||||
sessionId!: string;
|
||||
|
||||
@ApiProperty({ description: 'PIN-код из 4-6 цифр', example: '1234' })
|
||||
@IsString({ message: 'PIN-код должен быть строкой' })
|
||||
@Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' })
|
||||
@Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' })
|
||||
pin!: string;
|
||||
}
|
||||
|
||||
export class RefreshSessionDto {
|
||||
@ApiProperty({ description: 'Refresh token текущей сессии' })
|
||||
@IsString({ message: 'Refresh token должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Передайте refresh token' })
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ID сессии для ускоренной проверки' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'ID сессии должен быть строкой' })
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export class LdapLoginDto {
|
||||
@ApiProperty({ description: 'Логин LDAP (sAMAccountName, uid, mail и т.д.)', example: 'ivan.petrov' })
|
||||
@IsString({ message: 'Логин должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите LDAP-логин' })
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ description: 'Пароль LDAP' })
|
||||
@IsString({ message: 'Пароль должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите пароль' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
|
||||
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
|
||||
fingerprint!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Название устройства' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Название устройства должно быть строкой' })
|
||||
deviceName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Тип устройства должен быть строкой' })
|
||||
deviceType?: string;
|
||||
}
|
||||
68
apps/api-gateway/src/dto/chat.dto.ts
Normal file
68
apps/api-gateway/src/dto/chat.dto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateChatRoomDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
memberUserIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateChatRoomDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationsMuted?: boolean;
|
||||
}
|
||||
|
||||
export class SendChatMessageDto {
|
||||
@IsString()
|
||||
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL'])
|
||||
type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
replyToId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storageKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mimeType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
metadataJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
poll?: {
|
||||
question: string;
|
||||
options: string[];
|
||||
allowsMultiple?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class VotePollDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
optionIds!: string[];
|
||||
}
|
||||
|
||||
export class SetRoomNotificationsMutedDto {
|
||||
@IsBoolean()
|
||||
muted!: boolean;
|
||||
}
|
||||
62
apps/api-gateway/src/dto/documents.dto.ts
Normal file
62
apps/api-gateway/src/dto/documents.dto.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsEnum, IsJSON, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export enum DocumentTypeDto {
|
||||
PASSPORT_RF = 'PASSPORT_RF',
|
||||
FOREIGN_PASSPORT = 'FOREIGN_PASSPORT',
|
||||
BIRTH_CERTIFICATE = 'BIRTH_CERTIFICATE',
|
||||
DRIVER_LICENSE = 'DRIVER_LICENSE',
|
||||
VEHICLE_REGISTRATION = 'VEHICLE_REGISTRATION',
|
||||
OMS = 'OMS',
|
||||
DMS = 'DMS',
|
||||
INN = 'INN',
|
||||
SNILS = 'SNILS'
|
||||
}
|
||||
|
||||
export class CreateDocumentDto {
|
||||
@ApiProperty({ description: 'Тип документа', enum: DocumentTypeDto, example: DocumentTypeDto.PASSPORT_RF })
|
||||
@IsEnum(DocumentTypeDto, { message: 'Некорректный тип документа' })
|
||||
type!: DocumentTypeDto;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Номер документа. Перед сохранением будет зашифрован.', example: '4512 123456' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Номер документа должен быть строкой' })
|
||||
number?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Дата выдачи документа в ISO формате', example: '2020-01-10T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: 'Дата выдачи должна быть в ISO формате' })
|
||||
issuedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Дата окончания действия документа в ISO формате', example: '2030-01-10T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: 'Дата окончания должна быть в ISO формате' })
|
||||
expiresAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Данные формы документа в JSON. Перед сохранением будут зашифрованы.' })
|
||||
@IsOptional()
|
||||
@IsJSON({ message: 'Дополнительные данные должны быть валидным JSON' })
|
||||
metadataJson?: string;
|
||||
}
|
||||
|
||||
export class UpdateDocumentDto {
|
||||
@ApiPropertyOptional({ description: 'Номер документа' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Номер документа должен быть строкой' })
|
||||
number?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Дата выдачи в ISO формате' })
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: 'Дата выдачи должна быть в ISO формате' })
|
||||
issuedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Дата окончания в ISO формате' })
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: 'Дата окончания должна быть в ISO формате' })
|
||||
expiresAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Данные формы документа в JSON' })
|
||||
@IsOptional()
|
||||
@IsJSON({ message: 'Дополнительные данные должны быть валидным JSON' })
|
||||
metadataJson?: string;
|
||||
}
|
||||
140
apps/api-gateway/src/dto/identity.dto.ts
Normal file
140
apps/api-gateway/src/dto/identity.dto.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthAuthorizeQueryDto {
|
||||
@ApiProperty({ description: 'ID пользователя, который подтверждает OAuth доступ' })
|
||||
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ description: 'OAuth client_id приложения' })
|
||||
@IsString({ message: 'client_id должен быть строкой' })
|
||||
clientId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Разрешенный redirect_uri приложения' })
|
||||
@IsString({ message: 'redirect_uri должен быть строкой' })
|
||||
redirectUri!: string;
|
||||
|
||||
@ApiProperty({ description: 'Запрошенные scopes через пробел', example: 'openid profile email' })
|
||||
@IsString({ message: 'scope должен быть строкой' })
|
||||
scope!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth state для защиты клиента' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'state должен быть строкой' })
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export class OAuthTokenDto {
|
||||
@ApiProperty({ description: 'Тип grant', enum: ['authorization_code', 'refresh_token'] })
|
||||
@IsIn(['authorization_code', 'refresh_token'], { message: 'grant_type должен быть authorization_code или refresh_token' })
|
||||
grantType!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Authorization code' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'code должен быть строкой' })
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Refresh token' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'refresh_token должен быть строкой' })
|
||||
refreshToken?: string;
|
||||
|
||||
@ApiProperty({ description: 'OAuth client_id' })
|
||||
@IsString({ message: 'client_id должен быть строкой' })
|
||||
clientId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth client_secret' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'client_secret должен быть строкой' })
|
||||
clientSecret?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'redirect_uri для authorization_code grant' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'redirect_uri должен быть строкой' })
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
export class SendOtpDto {
|
||||
@ApiProperty({ description: 'Почта или телефон получателя' })
|
||||
@IsString({ message: 'Получатель должен быть строкой' })
|
||||
target!: string;
|
||||
|
||||
@ApiProperty({ description: 'Канал доставки', enum: ['email', 'sms'] })
|
||||
@IsIn(['email', 'sms'], { message: 'Канал должен быть email или sms' })
|
||||
channel!: string;
|
||||
|
||||
@ApiProperty({ description: 'Назначение кода', example: 'login' })
|
||||
@IsString({ message: 'Назначение должно быть строкой' })
|
||||
purpose!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ID пользователя, если код привязан к аккаунту' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@ApiProperty({ description: 'Почта или телефон получателя' })
|
||||
@IsString({ message: 'Получатель должен быть строкой' })
|
||||
target!: string;
|
||||
|
||||
@ApiProperty({ description: 'Код подтверждения' })
|
||||
@IsString({ message: 'Код должен быть строкой' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: 'Назначение кода', example: 'login' })
|
||||
@IsString({ message: 'Назначение должно быть строкой' })
|
||||
purpose!: string;
|
||||
}
|
||||
|
||||
export class WebAuthnDto {
|
||||
@ApiProperty({ description: 'ID пользователя' })
|
||||
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||
userId!: string;
|
||||
}
|
||||
|
||||
export class QrSessionDto {
|
||||
@ApiProperty({ description: 'Название устройства', example: 'Chrome на Windows' })
|
||||
@IsString({ message: 'Название устройства должно быть строкой' })
|
||||
deviceName!: string;
|
||||
}
|
||||
|
||||
export class CreateFamilyGroupDto {
|
||||
@ApiProperty({ description: 'ID владельца семьи' })
|
||||
@IsString({ message: 'ID владельца должен быть строкой' })
|
||||
ownerId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Название семейной группы', example: 'Семья Мамедовых' })
|
||||
@IsString({ message: 'Название семьи должно быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите название семьи' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class UpdateFamilyGroupDto {
|
||||
@ApiProperty({ description: 'Новое название семейной группы' })
|
||||
@IsString({ message: 'Название семьи должно быть строкой' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class AddFamilyMemberDto {
|
||||
@ApiProperty({ description: 'ID пользователя, которого нужно добавить' })
|
||||
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Роль участника семьи', example: 'member' })
|
||||
@IsString({ message: 'Роль должна быть строкой' })
|
||||
role!: string;
|
||||
}
|
||||
|
||||
export class SendFamilyInviteDto {
|
||||
@ApiProperty({ description: 'Email, телефон или логин приглашаемого' })
|
||||
@IsString({ message: 'Укажите контакт приглашаемого' })
|
||||
@IsNotEmpty({ message: 'Укажите контакт приглашаемого' })
|
||||
target!: string;
|
||||
}
|
||||
|
||||
export class RespondFamilyInviteDto {
|
||||
@ApiProperty({ description: 'Принять приглашение' })
|
||||
@IsBoolean({ message: 'Укажите accept: true или false' })
|
||||
accept!: boolean;
|
||||
}
|
||||
40
apps/api-gateway/src/dto/media.dto.ts
Normal file
40
apps/api-gateway/src/dto/media.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
|
||||
export class AvatarUploadDto {
|
||||
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
||||
@IsString({ message: 'Укажите MIME-тип изображения' })
|
||||
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' })
|
||||
contentType!: string;
|
||||
}
|
||||
|
||||
export class ConfirmAvatarDto {
|
||||
@ApiProperty({ description: 'Ключ объекта в MinIO после загрузки', example: 'avatars/uuid/photo.jpg' })
|
||||
@IsString({ message: 'Ключ хранилища должен быть строкой' })
|
||||
storageKey!: string;
|
||||
}
|
||||
|
||||
export class DocumentPhotoUploadDto {
|
||||
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
||||
@IsString({ message: 'Укажите MIME-тип изображения' })
|
||||
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' })
|
||||
contentType!: string;
|
||||
}
|
||||
|
||||
export class ChatMediaUploadDto {
|
||||
@ApiProperty({ description: 'MIME-тип файла', example: 'audio/webm' })
|
||||
@IsString({ message: 'Укажите MIME-тип файла' })
|
||||
contentType!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'voice.webm' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Имя файла должно быть строкой' })
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export class UpdateAvatarStorageDto {
|
||||
@ApiProperty({ description: 'Ключ объекта в MinIO', example: 'avatars/uuid/photo.jpg' })
|
||||
@IsString({ message: 'Ключ хранилища должен быть строкой' })
|
||||
storageKey!: string;
|
||||
}
|
||||
1
apps/api-gateway/src/dto/notifications.dto.ts
Normal file
1
apps/api-gateway/src/dto/notifications.dto.ts
Normal file
@@ -0,0 +1 @@
|
||||
export class MarkNotificationReadDto {}
|
||||
79
apps/api-gateway/src/dto/profile.dto.ts
Normal file
79
apps/api-gateway/src/dto/profile.dto.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Matches, Max, Min, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateAvatarDto {
|
||||
@ApiPropertyOptional({ description: 'Публичная ссылка на аватар (legacy)', example: 'https://cdn.lendry.ru/avatars/user.png' })
|
||||
@IsOptional()
|
||||
@IsUrl({}, { message: 'Укажите корректную ссылку на аватар' })
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Ключ объекта в MinIO', example: 'avatars/uuid/photo.jpg' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Ключ хранилища должен быть строкой' })
|
||||
avatarStorageKey?: string;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiPropertyOptional({ description: 'Имя пользователя', example: 'Дмитрий' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Имя должно быть строкой' })
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Фамилия пользователя', example: 'Мамедов' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Фамилия должна быть строкой' })
|
||||
lastName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Краткое описание профиля', example: 'Разработчик и владелец аккаунта' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Описание должно быть строкой' })
|
||||
bio?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Возраст пользователя', minimum: 0, maximum: 130, example: 24 })
|
||||
@IsOptional()
|
||||
@IsInt({ message: 'Возраст должен быть целым числом' })
|
||||
@Min(0, { message: 'Возраст не может быть отрицательным' })
|
||||
@Max(130, { message: 'Возраст указан некорректно' })
|
||||
age?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Пол пользователя', example: 'male' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Пол должен быть строкой' })
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
export class UpdateContactsDto {
|
||||
@ApiPropertyOptional({ description: 'Основная почта пользователя', example: 'user@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Укажите корректную почту' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Основной телефон пользователя', example: '+79990000000' })
|
||||
@IsOptional()
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный телефон' })
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Резервная почта пользователя', example: 'backup@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: 'Укажите корректную резервную почту' })
|
||||
backupEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Резервный телефон пользователя', example: '+79991112233' })
|
||||
@IsOptional()
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный резервный телефон' })
|
||||
backupPhone?: string;
|
||||
}
|
||||
|
||||
export class SetPasswordDto {
|
||||
@ApiProperty({ description: 'Новый пароль для входа', minLength: 8 })
|
||||
@IsString({ message: 'Пароль должен быть строкой' })
|
||||
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class UserIdParamDto {
|
||||
@ApiProperty({ description: 'ID пользователя' })
|
||||
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Передайте ID пользователя' })
|
||||
userId!: string;
|
||||
}
|
||||
79
apps/api-gateway/src/dto/rbac.dto.ts
Normal file
79
apps/api-gateway/src/dto/rbac.dto.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export enum GatewayOAuthClientType {
|
||||
CONFIDENTIAL = 'CONFIDENTIAL',
|
||||
PUBLIC = 'PUBLIC'
|
||||
}
|
||||
|
||||
export class CreateRoleDto {
|
||||
@ApiProperty({ description: 'Уникальный slug роли', example: 'support' })
|
||||
@IsString({ message: 'Slug роли должен быть строкой' })
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({ description: 'Название роли', example: 'Поддержка' })
|
||||
@IsString({ message: 'Название роли должно быть строкой' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Описание роли' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Описание должно быть строкой' })
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Список slug прав', type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'Права должны быть массивом' })
|
||||
@IsString({ each: true, message: 'Каждое право должно быть строкой' })
|
||||
permissionSlugs?: string[];
|
||||
}
|
||||
|
||||
export class AssignUserRoleDto {
|
||||
@ApiProperty({ description: 'Slug роли', example: 'admin' })
|
||||
@IsString({ message: 'Slug роли должен быть строкой' })
|
||||
roleSlug!: string;
|
||||
}
|
||||
|
||||
export class CreateOAuthClientDto {
|
||||
@ApiProperty({ description: 'Название приложения', example: 'Lendry Docs' })
|
||||
@IsString({ message: 'Название должно быть строкой' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Redirect URI', type: [String], example: ['https://app.example.com/oauth/callback'] })
|
||||
@IsArray({ message: 'Redirect URI должны быть массивом' })
|
||||
@IsString({ each: true, message: 'Каждый redirect URI должен быть строкой' })
|
||||
redirectUris!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Scopes', type: [String], example: ['openid', 'profile', 'email'] })
|
||||
@IsArray({ message: 'Scopes должны быть массивом' })
|
||||
@IsString({ each: true, message: 'Каждый scope должен быть строкой' })
|
||||
scopes!: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Тип клиента', enum: GatewayOAuthClientType })
|
||||
@IsOptional()
|
||||
@IsEnum(GatewayOAuthClientType, { message: 'Укажите корректный тип клиента' })
|
||||
type?: GatewayOAuthClientType;
|
||||
}
|
||||
|
||||
export class UpdateOAuthClientDto {
|
||||
@ApiPropertyOptional({ description: 'Название приложения' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Название должно быть строкой' })
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Redirect URI', type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'Redirect URI должны быть массивом' })
|
||||
@IsString({ each: true, message: 'Каждый redirect URI должен быть строкой' })
|
||||
redirectUris?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Scopes', type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'Scopes должны быть массивом' })
|
||||
@IsString({ each: true, message: 'Каждый scope должен быть строкой' })
|
||||
scopes?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Активность приложения' })
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: 'isActive должно быть boolean' })
|
||||
isActive?: boolean;
|
||||
}
|
||||
25
apps/api-gateway/src/dto/security.dto.ts
Normal file
25
apps/api-gateway/src/dto/security.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class PinDto {
|
||||
@ApiProperty({ description: 'PIN-код из 4-6 цифр', example: '1234' })
|
||||
@IsString({ message: 'PIN-код должен быть строкой' })
|
||||
@Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' })
|
||||
@Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' })
|
||||
pin!: string;
|
||||
}
|
||||
|
||||
export class OptionalPinDto {
|
||||
@ApiPropertyOptional({ description: 'PIN-код для подтверждения действия' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'PIN-код должен быть строкой' })
|
||||
@Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' })
|
||||
@Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' })
|
||||
pin?: string;
|
||||
}
|
||||
|
||||
export class VerifySecurityPinDto extends PinDto {
|
||||
@ApiProperty({ description: 'ID сессии, которую нужно разблокировать PIN-кодом' })
|
||||
@IsString({ message: 'ID сессии должен быть строкой' })
|
||||
sessionId!: string;
|
||||
}
|
||||
69
apps/api-gateway/src/dto/settings.dto.ts
Normal file
69
apps/api-gateway/src/dto/settings.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpsertSettingDto {
|
||||
@ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
|
||||
@IsString({ message: 'Ключ настройки должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите ключ настройки' })
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ description: 'Значение настройки', example: '15' })
|
||||
@IsString({ message: 'Значение настройки должно быть строкой' })
|
||||
value!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Описание настройки для администраторов' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Описание должно быть строкой' })
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Скрывать ли значение в интерфейсе' })
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: 'Признак секрета должен быть логическим значением' })
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export class UpsertSocialProviderDto {
|
||||
@ApiProperty({ description: 'Название OAuth провайдера', example: 'google' })
|
||||
@IsString({ message: 'Название провайдера должно быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите провайдера' })
|
||||
providerName!: string;
|
||||
|
||||
@ApiProperty({ description: 'OAuth Client ID провайдера' })
|
||||
@IsString({ message: 'Client ID должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите Client ID' })
|
||||
clientId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth Client Secret провайдера' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Client Secret должен быть строкой' })
|
||||
clientSecret?: string;
|
||||
|
||||
@ApiProperty({ description: 'Включен ли провайдер для входа' })
|
||||
@IsBoolean({ message: 'Статус провайдера должен быть логическим значением' })
|
||||
isEnabled!: boolean;
|
||||
}
|
||||
|
||||
export class ConnectLinkedAccountDto {
|
||||
@ApiProperty({ description: 'Название провайдера', example: 'google' })
|
||||
@IsString({ message: 'Название провайдера должно быть строкой' })
|
||||
providerName!: string;
|
||||
|
||||
@ApiProperty({ description: 'ID пользователя у внешнего провайдера', example: 'google-user-123' })
|
||||
@IsString({ message: 'ID провайдера должен быть строкой' })
|
||||
providerId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Почта внешнего аккаунта' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Почта внешнего аккаунта должна быть строкой' })
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Отображаемое имя внешнего аккаунта' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Имя внешнего аккаунта должно быть строкой' })
|
||||
displayName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Аватар внешнего аккаунта' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'Аватар внешнего аккаунта должен быть строкой' })
|
||||
avatarUrl?: string;
|
||||
}
|
||||
44
apps/api-gateway/src/grpc-exception.filter.ts
Normal file
44
apps/api-gateway/src/grpc-exception.filter.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import { status as GrpcStatus } from '@grpc/grpc-js';
|
||||
|
||||
interface HttpResponseLike {
|
||||
status(code: number): { json(body: unknown): unknown };
|
||||
}
|
||||
|
||||
function grpcToHttp(code?: number): number {
|
||||
switch (code) {
|
||||
case GrpcStatus.INVALID_ARGUMENT:
|
||||
case GrpcStatus.FAILED_PRECONDITION:
|
||||
case GrpcStatus.OUT_OF_RANGE:
|
||||
return 400;
|
||||
case GrpcStatus.UNAUTHENTICATED:
|
||||
return 401;
|
||||
case GrpcStatus.PERMISSION_DENIED:
|
||||
return 403;
|
||||
case GrpcStatus.NOT_FOUND:
|
||||
return 404;
|
||||
case GrpcStatus.ALREADY_EXISTS:
|
||||
return 409;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const response = host.switchToHttp().getResponse<HttpResponseLike>();
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const statusCode = exception.getStatus();
|
||||
const payload = exception.getResponse();
|
||||
response.status(statusCode).json(typeof payload === 'string' ? { statusCode, message: payload } : payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const grpcError = exception as { code?: number; details?: string; message?: string };
|
||||
const statusCode = grpcToHttp(grpcError?.code);
|
||||
const message = grpcError?.details || grpcError?.message || 'Ошибка сервиса';
|
||||
response.status(statusCode).json({ statusCode, message });
|
||||
}
|
||||
}
|
||||
78
apps/api-gateway/src/guards/admin.guard.ts
Normal file
78
apps/api-gateway/src/guards/admin.guard.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { assertSessionUnlocked, verifyAccessToken } from '../session-auth';
|
||||
|
||||
export interface AdminRequestUser {
|
||||
id: string;
|
||||
isSuperAdmin: boolean;
|
||||
canAccessAdmin: boolean;
|
||||
canManageRoles: boolean;
|
||||
canManageOAuth: boolean;
|
||||
canManageUsers: boolean;
|
||||
canViewUsers: boolean;
|
||||
canManageSettings: boolean;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly core: CoreGrpcService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{ headers: { authorization?: string }; adminUser?: AdminRequestUser }>();
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await verifyAccessToken(this.jwt, request.headers.authorization);
|
||||
await assertSessionUnlocked(this.core, payload);
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException || error instanceof ForbiddenException) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException('Недействительный токен доступа');
|
||||
}
|
||||
|
||||
const profile = (await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }))) as AdminRequestUser & { id: string };
|
||||
if (!profile.canAccessAdmin) {
|
||||
throw new ForbiddenException('Доступ к админ-панели запрещён');
|
||||
}
|
||||
|
||||
request.adminUser = {
|
||||
id: profile.id,
|
||||
isSuperAdmin: profile.isSuperAdmin,
|
||||
canAccessAdmin: Boolean(profile.canAccessAdmin),
|
||||
canManageRoles: Boolean(profile.canManageRoles),
|
||||
canManageOAuth: Boolean(profile.canManageOAuth),
|
||||
canManageUsers: Boolean(profile.canManageUsers),
|
||||
canManageSettings: Boolean(profile.canManageSettings),
|
||||
canViewUsers: Boolean(profile.canViewUsers),
|
||||
roles: profile.roles ?? [],
|
||||
permissions: profile.permissions ?? []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ adminUser?: AdminRequestUser }>();
|
||||
if (!request.adminUser?.isSuperAdmin) {
|
||||
throw new ForbiddenException('Только супер-администратор может выполнять это действие');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAdminPermission(user: AdminRequestUser | undefined, permission: keyof Pick<AdminRequestUser, 'canManageOAuth' | 'canManageUsers' | 'canManageSettings'>) {
|
||||
if (!user?.[permission]) {
|
||||
throw new ForbiddenException('Недостаточно прав для выполнения действия');
|
||||
}
|
||||
}
|
||||
34
apps/api-gateway/src/main.ts
Normal file
34
apps/api-gateway/src/main.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './grpc-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true
|
||||
})
|
||||
);
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Lendry ID API')
|
||||
.setDescription('REST API для единого входа, безопасности, RBAC и администрирования Lendry ID.')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document, {
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
customSiteTitle: 'Документация Lendry ID API'
|
||||
});
|
||||
|
||||
await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
5
apps/api-gateway/src/media-content-disposition.ts
Normal file
5
apps/api-gateway/src/media-content-disposition.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function buildContentDisposition(type: 'inline' | 'attachment', fileName: string) {
|
||||
const asciiFallback = fileName.replace(/[^\x20-\x7E]+/g, '_').replace(/["\\]/g, '_') || 'file';
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
62
apps/api-gateway/src/session-auth.ts
Normal file
62
apps/api-gateway/src/session-auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { extractBearerToken } from './auth-token';
|
||||
import { CoreGrpcService } from './core-grpc.service';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string;
|
||||
sessionId?: string;
|
||||
pinVerified?: boolean;
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(jwt: JwtService, authorization?: string): Promise<AccessTokenPayload> {
|
||||
const token = extractBearerToken(authorization);
|
||||
|
||||
try {
|
||||
return await jwt.verifyAsync<AccessTokenPayload>(token, {
|
||||
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
|
||||
issuer: 'id.lendry.ru'
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({
|
||||
statusCode: 401,
|
||||
message: 'Токен доступа недействителен или истёк',
|
||||
code: 'TOKEN_EXPIRED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSessionUnlocked(core: CoreGrpcService, payload: AccessTokenPayload) {
|
||||
if (!payload.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = (await firstValueFrom(
|
||||
core.auth.ValidateSession({
|
||||
userId: payload.sub,
|
||||
sessionId: payload.sessionId,
|
||||
touchActivity: true
|
||||
})
|
||||
)) as { requiresPin: boolean; sessionId: string };
|
||||
|
||||
if (validation.requiresPin) {
|
||||
throw new ForbiddenException({
|
||||
statusCode: 403,
|
||||
message: 'Требуется подтверждение PIN-кода',
|
||||
code: 'PIN_REQUIRED',
|
||||
sessionId: validation.sessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAuthorizedPayload(
|
||||
jwt: JwtService,
|
||||
core: CoreGrpcService,
|
||||
authorization?: string
|
||||
): Promise<AccessTokenPayload> {
|
||||
const payload = await verifyAccessToken(jwt, authorization);
|
||||
await assertSessionUnlocked(core, payload);
|
||||
return payload;
|
||||
}
|
||||
10
apps/api-gateway/tsconfig.json
Normal file
10
apps/api-gateway/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user