global update and global fix
This commit is contained in:
@@ -20,6 +20,9 @@ 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 { BotController } from './controllers/bot.controller';
|
||||
import { AdminBotController } from './controllers/admin-bot.controller';
|
||||
import { TelegramBotApiController } from './controllers/telegram-bot-api.controller';
|
||||
import { CoreGrpcService } from './core-grpc.service';
|
||||
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
|
||||
|
||||
@@ -35,7 +38,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
|
||||
useFactory: (config: ConfigService) => ({
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat'],
|
||||
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat', 'bot'],
|
||||
protoPath: [
|
||||
join(__dirname, '../../../shared/proto/auth.proto'),
|
||||
join(__dirname, '../../../shared/proto/admin.proto'),
|
||||
@@ -47,7 +50,8 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
|
||||
join(__dirname, '../../../shared/proto/identity.proto'),
|
||||
join(__dirname, '../../../shared/proto/media.proto'),
|
||||
join(__dirname, '../../../shared/proto/notifications.proto'),
|
||||
join(__dirname, '../../../shared/proto/chat.proto')
|
||||
join(__dirname, '../../../shared/proto/chat.proto'),
|
||||
join(__dirname, '../../../shared/proto/bot.proto')
|
||||
],
|
||||
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051')
|
||||
}
|
||||
@@ -55,7 +59,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
|
||||
}
|
||||
])
|
||||
],
|
||||
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController],
|
||||
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController],
|
||||
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { ListAdminBotsQueryDto, SetBotActiveDto } from '../dto/bot.dto';
|
||||
import { AdminGuard, AdminRequestUser } from '../guards/admin.guard';
|
||||
|
||||
function assertManageAllBots(admin: AdminRequestUser) {
|
||||
if (admin.isSuperAdmin || admin.permissions.includes('bots.manage.all')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('Недостаточно прав для управления всеми ботами');
|
||||
}
|
||||
|
||||
@ApiTags('Администрирование ботов')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/bots')
|
||||
export class AdminBotController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список всех ботов', description: 'Административный список Telegram-ботов с поиском и пагинацией.' })
|
||||
listAllBots(@Query() query: ListAdminBotsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(
|
||||
this.core.bot.ListAllBots({
|
||||
requesterId: admin.id,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
search: query.search,
|
||||
page: query.page,
|
||||
limit: query.limit
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('metrics')
|
||||
@ApiOperation({ summary: 'Метрики ботов', description: 'Сводная статистика по ботам системы.' })
|
||||
getMetrics(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(this.core.bot.GetBotMetrics({ requesterId: admin.id, isSuperAdmin: admin.isSuperAdmin }));
|
||||
}
|
||||
|
||||
@Get(':botId')
|
||||
@ApiOperation({ summary: 'Получить бота (админ)', description: 'Возвращает любого бота по ID.' })
|
||||
getBot(@Param('botId') botId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(
|
||||
this.core.bot.GetBot({ requesterId: admin.id, botId, isSuperAdmin: admin.isSuperAdmin })
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId/active')
|
||||
@ApiOperation({ summary: 'Заблокировать или разблокировать бота', description: 'Анти-abuse: отключает Bot API токен.' })
|
||||
@ApiBody({ type: SetBotActiveDto })
|
||||
setActive(@Param('botId') botId: string, @Body() dto: SetBotActiveDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(
|
||||
this.core.bot.SetBotActive({
|
||||
requesterId: admin.id,
|
||||
botId,
|
||||
isActive: dto.isActive,
|
||||
isSuperAdmin: admin.isSuperAdmin
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CreateBotDto, SetBotWebAppDto, SubmitBotCallbackDto, SubmitBotMessageDto, UpdateBotDto, UpdateBotProfileDto, ValidateWebAppInitDataDto } from '../dto/bot.dto';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
|
||||
@ApiTags('BotFather')
|
||||
@ApiBearerAuth()
|
||||
@Controller('bots')
|
||||
export class BotController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private async auth(authorization?: string) {
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список моих ботов', description: 'Возвращает Telegram-ботов текущего пользователя.' })
|
||||
listMyBots(@Headers('authorization') authorization?: string) {
|
||||
return this.auth(authorization).then((userId) => firstValueFrom(this.core.bot.ListMyBots({ ownerId: userId })));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Создать бота', description: 'Регистрирует нового бота и возвращает токен Bot API.' })
|
||||
@ApiBody({ type: CreateBotDto })
|
||||
createBot(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateBotDto) {
|
||||
return this.auth(authorization).then((ownerId) =>
|
||||
firstValueFrom(this.core.bot.CreateBot({ ownerId, name: dto.name, username: dto.username }))
|
||||
);
|
||||
}
|
||||
|
||||
@Get('by-username/:botRef/messages')
|
||||
@ApiOperation({ summary: 'История чата с ботом', description: 'Возвращает сообщения пользователя с ботом в хронологическом порядке.' })
|
||||
listBotMessages(@Headers('authorization') authorization: string | undefined, @Param('botRef') botRef: string) {
|
||||
return this.auth(authorization).then((userId) =>
|
||||
firstValueFrom(this.core.bot.ListBotChatMessages({ userId, botRef }))
|
||||
);
|
||||
}
|
||||
|
||||
@Post('by-username/:botRef/messages')
|
||||
@ApiOperation({
|
||||
summary: 'Написать боту',
|
||||
description: 'Публикует inbound-событие в RabbitMQ для доставки боту через webhook или getUpdates.'
|
||||
})
|
||||
@ApiBody({ type: SubmitBotMessageDto })
|
||||
submitMessage(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botRef') botRef: string,
|
||||
@Body() dto: SubmitBotMessageDto
|
||||
) {
|
||||
return this.auth(authorization).then((senderUserId) =>
|
||||
firstValueFrom(this.core.bot.SubmitBotInboundMessage({ senderUserId, botRef, text: dto.text }))
|
||||
);
|
||||
}
|
||||
|
||||
@Post('by-username/:botRef/callback')
|
||||
@ApiOperation({
|
||||
summary: 'Нажатие inline-кнопки',
|
||||
description: 'Публикует callback_query Update для webhook или getUpdates.'
|
||||
})
|
||||
@ApiBody({ type: SubmitBotCallbackDto })
|
||||
submitCallback(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botRef') botRef: string,
|
||||
@Body() dto: SubmitBotCallbackDto
|
||||
) {
|
||||
return this.auth(authorization).then((senderUserId) =>
|
||||
firstValueFrom(
|
||||
this.core.bot.SubmitBotCallbackQuery({
|
||||
senderUserId,
|
||||
botRef,
|
||||
messageId: dto.messageId,
|
||||
callbackData: dto.callbackData
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('web-app/validate')
|
||||
@ApiOperation({
|
||||
summary: 'Проверить initData Mini App',
|
||||
description: 'Валидирует Telegram Web App initData по HMAC-SHA256, как в официальном Bot API.'
|
||||
})
|
||||
@ApiBody({ type: ValidateWebAppInitDataDto })
|
||||
validateWebApp(@Body() dto: ValidateWebAppInitDataDto) {
|
||||
return firstValueFrom(this.core.bot.ValidateWebAppInitData(dto));
|
||||
}
|
||||
|
||||
@Get(':botId')
|
||||
@ApiOperation({ summary: 'Получить бота', description: 'Возвращает настройки бота, принадлежащего пользователю.' })
|
||||
getBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.GetBot({ requesterId, botId, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId')
|
||||
@ApiOperation({ summary: 'Обновить бота', description: 'Изменяет имя или username бота.' })
|
||||
@ApiBody({ type: UpdateBotDto })
|
||||
updateBot(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botId') botId: string,
|
||||
@Body() dto: UpdateBotDto
|
||||
) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.UpdateBot({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':botId')
|
||||
@ApiOperation({ summary: 'Удалить бота', description: 'Удаляет бота и все связанные чаты/сообщения.' })
|
||||
deleteBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.DeleteBot({ requesterId, botId, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':botId/revoke-token')
|
||||
@ApiOperation({ summary: 'Перевыпустить токен', description: 'Инвалидирует старый токен и возвращает новый.' })
|
||||
revokeToken(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.RevokeBotToken({ requesterId, botId, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId/profile')
|
||||
@ApiOperation({
|
||||
summary: 'Профиль бота',
|
||||
description: 'Обновляет description, aboutText, botPicUrl и глобальную кнопку меню (Web App).'
|
||||
})
|
||||
@ApiBody({ type: UpdateBotProfileDto })
|
||||
updateBotProfile(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botId') botId: string,
|
||||
@Body() dto: UpdateBotProfileDto
|
||||
) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.UpdateBotProfile({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId/web-app')
|
||||
@ApiOperation({ summary: 'Настроить Mini App', description: 'Привязывает URL Web App к боту.' })
|
||||
@ApiBody({ type: SetBotWebAppDto })
|
||||
setWebApp(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botId') botId: string,
|
||||
@Body() dto: SetBotWebAppDto
|
||||
) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.SetBotWebApp({ requesterId, botId, webAppUrl: dto.webAppUrl, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ import { getAuthorizedUserId } from '../document-access';
|
||||
import {
|
||||
AddChatRoomMemberDto,
|
||||
CreateChatRoomDto,
|
||||
CreateE2EChatRoomDto,
|
||||
EditChatMessageDto,
|
||||
ForwardMessagesDto,
|
||||
MarkRoomReadDto,
|
||||
SendChatMessageDto,
|
||||
SetRoomNotificationsMutedDto,
|
||||
ToggleMessageReactionDto,
|
||||
UpdateChatRoomDto,
|
||||
VotePollDto
|
||||
} from '../dto/chat.dto';
|
||||
@@ -56,6 +59,17 @@ export class ChatController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/e2e-rooms')
|
||||
@ApiOperation({ summary: 'Создать секретный E2E чат' })
|
||||
async createE2ERoom(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: CreateE2EChatRoomDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.CreateE2ERoom({ userId, groupId, peerUserId: dto.peerUserId }));
|
||||
}
|
||||
|
||||
@Patch('rooms/:roomId')
|
||||
@ApiOperation({ summary: 'Настройки чата' })
|
||||
async updateRoom(
|
||||
@@ -138,7 +152,8 @@ export class ChatController {
|
||||
storageKey: dto.storageKey,
|
||||
mimeType: dto.mimeType,
|
||||
metadataJson: dto.metadataJson,
|
||||
poll: dto.poll
|
||||
poll: dto.poll,
|
||||
isEncrypted: dto.isEncrypted
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -183,6 +198,35 @@ export class ChatController {
|
||||
return firstValueFrom(this.core.chat.DeleteMessage({ userId, messageId }));
|
||||
}
|
||||
|
||||
@Post('messages/:messageId/reactions')
|
||||
@ApiOperation({ summary: 'Поставить или снять реакцию' })
|
||||
async toggleReaction(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('messageId') messageId: string,
|
||||
@Body() dto: ToggleMessageReactionDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.ToggleMessageReaction({ userId, messageId, emoji: dto.emoji }));
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/forward')
|
||||
@ApiOperation({ summary: 'Переслать сообщения в другой чат' })
|
||||
async forwardMessages(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: ForwardMessagesDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.ForwardMessages({ userId, targetRoomId: roomId, messageIds: dto.messageIds }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { messages?: unknown[] };
|
||||
return { messages: payload.messages ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/read')
|
||||
@ApiOperation({ summary: 'Отметить сообщения чата прочитанными' })
|
||||
async markRoomRead(
|
||||
@@ -195,4 +239,21 @@ export class ChatController {
|
||||
this.core.chat.MarkRoomRead({ userId, roomId, lastMessageId: dto.lastMessageId })
|
||||
);
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/typing')
|
||||
@ApiOperation({ summary: 'Сообщить о наборе текста в чате' })
|
||||
async reportTyping(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.ReportTyping({ userId, roomId }));
|
||||
}
|
||||
|
||||
@Delete('rooms/:roomId')
|
||||
@ApiOperation({ summary: 'Удалить чат' })
|
||||
async deleteRoom(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.DeleteRoom({ userId, roomId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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';
|
||||
import { AddFamilyMemberDto, AddBotToFamilyDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||
|
||||
@ApiTags('Семья')
|
||||
@ApiBearerAuth()
|
||||
@@ -151,5 +151,24 @@ export class FamilyController {
|
||||
const requesterId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId }));
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/botfather')
|
||||
@ApiOperation({ summary: 'Добавить BotFather в семью', description: 'Добавляет системного бота BotFather для создания Telegram-ботов через чат.' })
|
||||
async addBotFather(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
const requesterId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.family.AddBotFatherToFamily({ requesterId, groupId }));
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/bots')
|
||||
@ApiOperation({ summary: 'Добавить бота в семью', description: 'Добавляет Telegram-бота (своего или другого пользователя) в семейную группу.' })
|
||||
@ApiBody({ type: AddBotToFamilyDto })
|
||||
async addBot(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: AddBotToFamilyDto
|
||||
) {
|
||||
const requesterId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.family.AddBotToFamily({ requesterId, groupId, botId: dto.botId }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,129 @@
|
||||
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
|
||||
import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
|
||||
import { extractBearerToken } from '../auth-token';
|
||||
import { appendQueryParams, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params';
|
||||
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||
import { verifyAccessToken } from '../session-auth';
|
||||
|
||||
type HttpResponse = {
|
||||
redirect(status: number, url: string): void;
|
||||
};
|
||||
|
||||
const oauthValidationPipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: false
|
||||
});
|
||||
|
||||
@ApiTags('OAuth 2.0')
|
||||
@Controller('oauth')
|
||||
export class OAuthController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@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);
|
||||
@UsePipes(oauthValidationPipe)
|
||||
@ApiOperation({
|
||||
summary: 'OAuth / OIDC авторизация',
|
||||
description:
|
||||
'Поддерживает стандартные query-параметры RFC 6749 / OIDC (client_id, redirect_uri, response_type, code_challenge) и legacy camelCase. ' +
|
||||
'Без userId и без Bearer-токена перенаправляет на страницу подтверждения доступа.'
|
||||
})
|
||||
@ApiQuery({ name: 'client_id', required: false, description: 'OAuth client_id (RFC 6749)' })
|
||||
@ApiQuery({ name: 'clientId', required: false, description: 'OAuth client_id (legacy)' })
|
||||
@ApiQuery({ name: 'redirect_uri', required: false })
|
||||
@ApiQuery({ name: 'redirectUri', required: false })
|
||||
@ApiQuery({ name: 'scope', required: false })
|
||||
@ApiQuery({ name: 'state', required: false })
|
||||
@ApiQuery({ name: 'response_type', required: false, example: 'code' })
|
||||
@ApiQuery({ name: 'code_challenge', required: false })
|
||||
@ApiQuery({ name: 'code_challenge_method', required: false })
|
||||
@ApiQuery({ name: 'userId', required: false, description: 'Legacy: ID пользователя после входа' })
|
||||
@ApiResponse({ status: 302, description: 'Redirect на redirect_uri с authorization code' })
|
||||
async authorize(
|
||||
@Query() query: OAuthAuthorizeIncomingDto,
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Headers('accept') acceptHeader: string | undefined,
|
||||
@Res({ passthrough: true }) res: HttpResponse
|
||||
) {
|
||||
const normalized = normalizeAuthorizeQuery(query as Record<string, unknown>);
|
||||
let userId = normalized.userId;
|
||||
|
||||
if (!userId && authorization) {
|
||||
try {
|
||||
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||
userId = payload.sub;
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, query as Record<string, unknown>);
|
||||
res.redirect(302, consentUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await firstValueFrom(
|
||||
this.core.oauth.Authorize({
|
||||
userId,
|
||||
clientId: normalized.clientId,
|
||||
redirectUri: normalized.redirectUri,
|
||||
scope: normalized.scope,
|
||||
state: normalized.state
|
||||
})
|
||||
)) as { redirectUrl?: string; code?: string; state?: string };
|
||||
|
||||
const accept = acceptHeader ?? '';
|
||||
const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json');
|
||||
if (wantsJson) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result.redirectUrl) {
|
||||
throw new UnauthorizedException('Не удалось создать authorization code');
|
||||
}
|
||||
|
||||
res.redirect(302, result.redirectUrl);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
|
||||
@ApiBody({ type: OAuthTokenDto })
|
||||
@UsePipes(oauthValidationPipe)
|
||||
@ApiOperation({
|
||||
summary: 'Выдать OAuth токены',
|
||||
description:
|
||||
'Поддерживает grant_type=authorization_code и grant_type=refresh_token. ' +
|
||||
'Принимает application/x-www-form-urlencoded (RFC 6749) и JSON. ' +
|
||||
'Ответ — snake_case: access_token, token_type, expires_in, refresh_token, id_token.'
|
||||
})
|
||||
@ApiBody({ type: OAuthTokenIncomingDto })
|
||||
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
|
||||
token(@Body() dto: OAuthTokenDto) {
|
||||
return this.core.oauth.Token(dto);
|
||||
async token(@Body() body: OAuthTokenIncomingDto, @Headers('authorization') authorization?: string) {
|
||||
const normalized = mergeTokenCredentials(normalizeTokenBody(body as Record<string, unknown>), authorization);
|
||||
const result = (await firstValueFrom(
|
||||
this.core.oauth.Token({
|
||||
grantType: normalized.grantType,
|
||||
code: normalized.code,
|
||||
refreshToken: normalized.refreshToken,
|
||||
clientId: normalized.clientId,
|
||||
clientSecret: normalized.clientSecret,
|
||||
redirectUri: normalized.redirectUri
|
||||
})
|
||||
)) as {
|
||||
accessToken?: string;
|
||||
tokenType?: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
};
|
||||
return mapTokenResponseToStandard(result);
|
||||
}
|
||||
|
||||
@Get('userinfo')
|
||||
|
||||
@@ -139,5 +139,22 @@ export class ProfileController {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.GetAccountDeletionStatus({ userId });
|
||||
}
|
||||
|
||||
@Get('e2e-public-key')
|
||||
@ApiOperation({ summary: 'Получить публичный E2E-ключ пользователя' })
|
||||
async getE2EPublicKey(@Param('userId') userId: string) {
|
||||
return this.core.profile.GetE2EPublicKey({ userId });
|
||||
}
|
||||
|
||||
@Patch('e2e-public-key')
|
||||
@ApiOperation({ summary: 'Сохранить свой публичный E2E-ключ' })
|
||||
async setE2EPublicKey(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: { publicKey: string }
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.SetE2EPublicKey({ userId, publicKey: dto.publicKey });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { All, Body, Controller, HttpCode, Param, Req, Res } from '@nestjs/common';
|
||||
import { ApiExcludeController } from '@nestjs/swagger';
|
||||
import { firstValueFrom, timeout } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
|
||||
type HttpRequest = {
|
||||
method: string;
|
||||
query: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type HttpResponse = {
|
||||
status(code: number): HttpResponse;
|
||||
setHeader(name: string, value: string): void;
|
||||
};
|
||||
|
||||
@ApiExcludeController()
|
||||
@Controller()
|
||||
export class TelegramBotApiController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@All(':botPath/:method')
|
||||
@HttpCode(200)
|
||||
async handleTelegramMethod(
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
@Param('botPath') botPath: string,
|
||||
@Param('method') method: string,
|
||||
@Body() body: Record<string, unknown>
|
||||
) {
|
||||
if (!botPath.startsWith('bot')) {
|
||||
response.status(404);
|
||||
return { ok: false, error_code: 404, description: 'Not Found' };
|
||||
}
|
||||
|
||||
const token = decodeURIComponent(botPath.slice(3));
|
||||
if (!token) {
|
||||
response.status(401);
|
||||
return { ok: false, error_code: 401, description: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const payload = request.method === 'GET' ? { ...request.query } : body ?? {};
|
||||
const grpcCall = this.core.bot.ExecuteBotMethod({
|
||||
token,
|
||||
method,
|
||||
payloadJson: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const waitSeconds = method === 'getUpdates' ? Number(payload.timeout ?? 0) : 0;
|
||||
const grpcTimeoutMs = method === 'getUpdates' ? Math.min(Math.max(waitSeconds, 0), 50) * 1000 + 10_000 : 30_000;
|
||||
|
||||
const result = (await firstValueFrom(
|
||||
grpcCall.pipe(timeout(grpcTimeoutMs)) as typeof grpcCall
|
||||
)) as { responseJson: string; httpStatus: number };
|
||||
|
||||
response.status(result.httpStatus ?? 200);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
return JSON.parse(result.responseJson);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export class CoreGrpcService implements OnModuleInit {
|
||||
chat!: Record<string, GrpcMethod>;
|
||||
family!: Record<string, GrpcMethod>;
|
||||
media!: Record<string, GrpcMethod>;
|
||||
bot!: Record<string, GrpcMethod>;
|
||||
|
||||
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
|
||||
|
||||
@@ -40,5 +41,6 @@ export class CoreGrpcService implements OnModuleInit {
|
||||
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');
|
||||
this.bot = this.client.getService<Record<string, GrpcMethod>>('BotService');
|
||||
}
|
||||
}
|
||||
|
||||
101
apps/api-gateway/src/dto/bot.dto.ts
Normal file
101
apps/api-gateway/src/dto/bot.dto.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Max, Min, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateBotDto {
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class UpdateBotDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export class SetBotWebAppDto {
|
||||
@IsOptional()
|
||||
@IsUrl({}, { message: 'Укажите корректный URL Mini App' })
|
||||
webAppUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateBotProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
aboutText?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
botPicUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
menuButtonJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
menuButtonUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
menuButtonText?: string;
|
||||
}
|
||||
|
||||
export class SetBotActiveDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export class ListAdminBotsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class ValidateWebAppInitDataDto {
|
||||
@IsString()
|
||||
initData!: string;
|
||||
|
||||
@IsString()
|
||||
botToken!: string;
|
||||
}
|
||||
|
||||
export class SubmitBotMessageDto {
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'Текст сообщения не может быть пустым' })
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class SubmitBotCallbackDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
messageId!: number;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'callbackData не может быть пустым' })
|
||||
callbackData!: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateChatRoomDto {
|
||||
@IsString()
|
||||
@@ -11,6 +11,11 @@ export class CreateChatRoomDto {
|
||||
memberUserIds?: string[];
|
||||
}
|
||||
|
||||
export class CreateE2EChatRoomDto {
|
||||
@IsString()
|
||||
peerUserId!: string;
|
||||
}
|
||||
|
||||
export class UpdateChatRoomDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -54,6 +59,10 @@ export class SendChatMessageDto {
|
||||
allowsMultiple?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
};
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEncrypted?: boolean;
|
||||
}
|
||||
|
||||
export class VotePollDto {
|
||||
@@ -83,3 +92,16 @@ export class MarkRoomReadDto {
|
||||
@IsString()
|
||||
lastMessageId?: string;
|
||||
}
|
||||
|
||||
export class ToggleMessageReactionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
emoji!: string;
|
||||
}
|
||||
|
||||
export class ForwardMessagesDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
messageIds!: string[];
|
||||
}
|
||||
|
||||
@@ -153,3 +153,10 @@ export class RespondFamilyInviteDto {
|
||||
@IsBoolean({ message: 'Укажите accept: true или false' })
|
||||
accept!: boolean;
|
||||
}
|
||||
|
||||
export class AddBotToFamilyDto {
|
||||
@ApiProperty({ description: 'ID бота для добавления в семью' })
|
||||
@IsString({ message: 'ID бота должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Укажите ID бота' })
|
||||
botId!: string;
|
||||
}
|
||||
|
||||
133
apps/api-gateway/src/dto/oauth.dto.ts
Normal file
133
apps/api-gateway/src/dto/oauth.dto.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthAuthorizeIncomingDto {
|
||||
@ApiPropertyOptional({ description: 'ID пользователя (legacy camelCase)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ID пользователя (OIDC legacy alias)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
user_id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth client_id (legacy camelCase)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth client_id (RFC 6749)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'redirect_uri (legacy camelCase)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirectUri?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'redirect_uri (RFC 6749)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirect_uri?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Scopes через пробел', example: 'openid profile email' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth state' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth response_type', example: 'code' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
response_type?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Legacy camelCase alias response_type' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
responseType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'PKCE code_challenge' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
codeChallenge?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'PKCE code_challenge_method', example: 'S256' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge_method?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge_method' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
codeChallengeMethod?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OpenID Connect nonce' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export class OAuthTokenIncomingDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
grantType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
grant_type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refreshToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refresh_token?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clientSecret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_secret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirectUri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirect_uri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
codeVerifier?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_verifier?: string;
|
||||
}
|
||||
@@ -37,6 +37,38 @@ export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http
|
||||
return normalizeBaseUrl(fallback);
|
||||
}
|
||||
|
||||
export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http://localhost:3002'): Promise<string> {
|
||||
const envFrontend = process.env.PUBLIC_FRONTEND_URL?.trim();
|
||||
if (envFrontend) {
|
||||
return normalizeBaseUrl(envFrontend);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_FRONTEND_URL' }))) as { value?: string };
|
||||
const fromDb = response.value?.trim();
|
||||
if (fromDb) {
|
||||
return normalizeBaseUrl(fromDb);
|
||||
}
|
||||
} catch {
|
||||
// fallback ниже
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_DOMAIN' }))) as { value?: string };
|
||||
const domain = response.value?.trim();
|
||||
if (domain) {
|
||||
if (domain.startsWith('http://') || domain.startsWith('https://')) {
|
||||
return normalizeBaseUrl(domain);
|
||||
}
|
||||
return `https://${domain.replace(/^\/+/, '')}`;
|
||||
}
|
||||
} catch {
|
||||
// fallback ниже
|
||||
}
|
||||
|
||||
return normalizeBaseUrl(fallback);
|
||||
}
|
||||
|
||||
export function buildOpenIdConfiguration(issuer: string) {
|
||||
const base = normalizeBaseUrl(issuer);
|
||||
return {
|
||||
@@ -49,8 +81,9 @@ export function buildOpenIdConfiguration(issuer: string) {
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['HS256'],
|
||||
scopes_supported: ['openid', 'profile', 'email', 'phone', 'address', 'documents'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
claims_supported: ['sub', 'email', 'phone', 'name', 'picture']
|
||||
};
|
||||
}
|
||||
|
||||
147
apps/api-gateway/src/lib/oauth-params.ts
Normal file
147
apps/api-gateway/src/lib/oauth-params.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export interface NormalizedAuthorizeQuery {
|
||||
userId?: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
responseType?: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface NormalizedTokenBody {
|
||||
grantType: string;
|
||||
code?: string;
|
||||
refreshToken?: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUri?: string;
|
||||
codeVerifier?: string;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0].trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeAuthorizeQuery(query: Record<string, unknown>): NormalizedAuthorizeQuery {
|
||||
const clientId = readString(query.clientId) ?? readString(query.client_id);
|
||||
const redirectUri = readString(query.redirectUri) ?? readString(query.redirect_uri);
|
||||
const scope = readString(query.scope) ?? 'openid profile';
|
||||
const userId = readString(query.userId) ?? readString(query.user_id);
|
||||
const state = readString(query.state);
|
||||
const responseType = readString(query.response_type) ?? readString(query.responseType);
|
||||
const codeChallenge = readString(query.code_challenge) ?? readString(query.codeChallenge);
|
||||
const codeChallengeMethod = readString(query.code_challenge_method) ?? readString(query.codeChallengeMethod);
|
||||
const nonce = readString(query.nonce);
|
||||
|
||||
if (!clientId) {
|
||||
throw new BadRequestException('Укажите client_id');
|
||||
}
|
||||
if (!redirectUri) {
|
||||
throw new BadRequestException('Укажите redirect_uri');
|
||||
}
|
||||
if (responseType && responseType !== 'code') {
|
||||
throw new BadRequestException('Поддерживается только response_type=code');
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
clientId,
|
||||
redirectUri,
|
||||
scope,
|
||||
state,
|
||||
responseType,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTokenBody(body: Record<string, unknown>): NormalizedTokenBody {
|
||||
const grantType = readString(body.grantType) ?? readString(body.grant_type);
|
||||
const clientId = readString(body.clientId) ?? readString(body.client_id);
|
||||
const clientSecret = readString(body.clientSecret) ?? readString(body.client_secret);
|
||||
const code = readString(body.code);
|
||||
const refreshToken = readString(body.refreshToken) ?? readString(body.refresh_token);
|
||||
const redirectUri = readString(body.redirectUri) ?? readString(body.redirect_uri);
|
||||
const codeVerifier = readString(body.codeVerifier) ?? readString(body.code_verifier);
|
||||
|
||||
if (!grantType) {
|
||||
throw new BadRequestException('Укажите grant_type');
|
||||
}
|
||||
if (!clientId) {
|
||||
throw new BadRequestException('Укажите client_id');
|
||||
}
|
||||
|
||||
return {
|
||||
grantType,
|
||||
clientId,
|
||||
clientSecret,
|
||||
code,
|
||||
refreshToken,
|
||||
redirectUri,
|
||||
codeVerifier
|
||||
};
|
||||
}
|
||||
|
||||
export function appendQueryParams(baseUrl: string, query: Record<string, unknown>) {
|
||||
const url = new URL(baseUrl);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => url.searchParams.append(key, String(item)));
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function parseBasicClientCredentials(authorization?: string) {
|
||||
if (!authorization?.startsWith('Basic ')) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8');
|
||||
const separator = decoded.indexOf(':');
|
||||
if (separator < 0) return null;
|
||||
return {
|
||||
clientId: decoded.slice(0, separator),
|
||||
clientSecret: decoded.slice(separator + 1)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTokenResponseToStandard(result: {
|
||||
accessToken?: string;
|
||||
tokenType?: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
}) {
|
||||
return {
|
||||
access_token: result.accessToken,
|
||||
token_type: result.tokenType ?? 'Bearer',
|
||||
expires_in: result.expiresIn ?? 900,
|
||||
refresh_token: result.refreshToken,
|
||||
id_token: result.idToken
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeTokenCredentials(
|
||||
body: NormalizedTokenBody,
|
||||
authorization?: string
|
||||
): NormalizedTokenBody {
|
||||
const basic = parseBasicClientCredentials(authorization);
|
||||
if (!basic) return body;
|
||||
return {
|
||||
...body,
|
||||
clientId: body.clientId || basic.clientId,
|
||||
clientSecret: body.clientSecret || basic.clientSecret
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user