В этом материале я опишу общую архитектуру и некоторые детали реализации серверной части Messenger. Полный исходный код сервера можно найти здесь. Однако, что бы показать основные моменты, по ходу повествования буду приводить некоторые фрагменты кода.
Сервер платформы CheckerWars работает на Node.js фреймворке Nest.js, использующем Typescript. Первоначальная версия создавалась на Express.js, но в нем не хватало встроенных возможностей — работа с Express больше напоминает работу с набором библиотек, чем с полноценным фреймворком. Тем более, у меня был некоторый опыт разработки на Yii Framework, и хотелось чего то похожего. Безусловно, в руках профессионала Express.js может быть хорошей основой для проекта, но мне, как новичку в js бэкэнд разработке, удобнее иметь изначально строгую архитектуру приложения. Бонусом, мне пришлось осваивать Typescript, и в результате я его очень полюбил — клиент на Vue.js так же будет полностью переписан с использованием Typescript. У нас есть подробный обзор клиентского приложения:
Продолжим разбираться с сервером на Nest.js.
Основные моменты:
- В качестве ORM используется Sequelize
- СУБД PostgreSQL
- Передача данных в реальном времени осуществляется при помощи библиотеки Socket.io, для работы с которой в Nest.js есть своя обертка
- Работа с электронной почтой осуществляется с помощью Nodemailer
- В проекте настроена система документирования API Swagger
- Сервер упакован в Docker контейнер, настроенный на работу с реверс прокси Traefik. Имеется два режима запуска проекта — developer и production.
В статье по ссылке ниже, приведена конфигурация Docker для этого приложения:
Предварительно перед запуском контейнера необходимо запустить скрипт первоначальной инициализации переменных и базы данных install.sh
:
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
ENV_FILE="/data/secrets/$SERVER_DOMAIN/messenger-backend/app.env"
trap 'echo -e "\033[31minstall.sh: Something went wrong\033[0m"; exit 1' ERR
set -e
export DEBIAN_FRONTEND=noninteractive
echo "Install messenger..."
cd /data/utils
POSTGRES_DB="messenger"
POSTGRES_PASSWORD=$(pwgen -s 20 1);
sudo bash env-gen-all.sh $ENV_FILE POSTGRES_PASSWORD $POSTGRES_PASSWORD
sudo bash init-db.sh $POSTGRES_DB $POSTGRES_PASSWORD
sudo bash env-gen-all.sh $ENV_FILE JWT_ACCESS_SECRET $(pwgen -s 32 1)
sudo bash env-gen-all.sh $ENV_FILE JWT_REFRESH_SECRET $(pwgen -s 32 1)
trap - ERR
echo "install messenger-backend complete"
Скрипт использует зависимости из репозитория.
Таким образом, вся конфигурация сервера осуществляется при помощи всего нескольких команд.
Всего в проекте на текущий момент присутствует 11 модулей и два провайдера:
// ...
imports: [
// ...
UsersModule,
ProfileModule,
AuthModule,
RolesModule,
PostsModule,
FilesModule,
TokensModule,
FriendsModule,
MessageModule,
RoomModule,
SubscribeModule,
],
providers: [MailService, ChatGateway],
// ...
Для начала разберемся, из чего состоит модуль:
*.module.ts
— используется для организации и управления зависимостями в приложении, объединяя связанные компоненты, сервисы и контроллеры в один модуль.*.model.ts
— описание структуры данных, например, схемы и интерфейсы, чтобы упорядочить и типизировать данные в приложении. В нашем случае, в первую очередь используются для описания структуры базы данных.*.service.ts
— используется для бизнес-логики приложения. Он обрабатывает данные и выполняет задачи, которые не связаны напрямую с веб-запросами или ответами.*.controller.ts
— нужен для обработки входящих запросов и отправки ответов. Он определяет, как приложение должно реагировать на определённые маршруты.*.dto.ts
(Data Transfer Object) — используется для структурирования данных при их передаче между клиентом и сервером, чтобы проще проверять входящие данные и преобразовывать их в нужный формат.
Далее сделаем краткий обзор всех модулей в проекте.
Модуль пользователя UsersModule
@Module({
controllers: [UsersController],
providers: [UsersService, MailService, ProfileService],
imports: [
SequelizeModule.forFeature([User, Role, UserRoles, Post, Profile]),
RolesModule,
FilesModule,
forwardRef(() => AuthModule),
],
exports: [UsersService],
})
export class UsersModule {}
Модуль отвечает за управление пользователями и связанными с ними сущностями. Он предоставляет функциональность, связанную с обработкой пользователей, включая управление их профилями и ролями.
Контроллеры:
- UsersController: Обрабатывает HTTP-запросы, связанные с пользователями, и направляет их в
UsersService
для выполнения бизнес-логики.
Поставщики:
- UsersService: Предоставляет методы для управления пользователями, включая создание, обновление и получение данных пользователей.
- MailService: Позволяет отправлять электронные письма пользователям, например, для подтверждения регистрации.
- ProfileService: Управляет данными профилей пользователей.
Импортируемые модули:
- SequelizeModule.forFeature([User, Role, UserRoles, Post, Profile]): Позволяет работать с моделями
User
,Role
,UserRoles
,Post
,Profile
через ORM Sequelize. - RolesModule: Предоставляет функциональность для управления ролями пользователей в системе.
- FilesModule: Обрабатывает операции с файлами, которые могут быть связаны с пользователями, например, загрузка аватаров.
- forwardRef(() => AuthModule): Зависимость от
AuthModule
для управления аутентификацией, реализована через отложенную загрузку, чтобы избежать циклических зависимостей.
Экспортируемые компоненты:
UsersService
: Экспортируется для использования в других модулях приложения, где требуется взаимодействие с пользователями.UsersModule
интегрируется с другими модулями системы, такими какAuthModule
, для обеспечения полной функциональности управления пользователями, включая аутентификацию, авторизацию и работу с профилями.
Модель User
Модель User представляет собой сущность пользователя в приложении, сопоставленную с таблицей user в базе данных. Эта модель включает в себя атрибуты, необходимые для создания, аутентификации и управления учетной записью пользователя. Основные атрибуты и их описания:
- id: Уникальный идентификатор пользователя.
- userName: Уникальное имя пользователя.
- email: Адрес электронной почты пользователя, необязательный.
- password: Пароль пользователя, необязательный.
- isActivated: Указывает, подтвержден ли электронный адрес пользователя.
- activationLink: Токен для активации электронной почты.
- banned: Статус, указывающий, заблокирован ли пользователь.
- banReason: Причина блокировки пользователя.
- avatar: Имя файла изображения аватара пользователя.
- isDeleted: Указывает, удалена ли учетная запись пользователя.
- userIp: IP-адрес пользователя при регистрации.
- userAgent: Данные о браузере пользователя во время регистрации.
- lastSeen: Метка времени последней активности пользователя.
- msgPrivacy: Настройки конфиденциальности для сообщений.
- userColor: Назначенный цвет для пользователя.
Установлены отношения через:
- roles: Отношение «многие ко многим» с моделью Role.
- posts: Отношение «один ко многим» с моделью Post.
- tokens: Отношение «один ко многим» с моделью Token.
- profile: Отношение «один к одному» с моделью Profile.
Модель обеспечивает целостность данных с помощью ограничений, таких как уникальные и ненулевые поля, предлагая комплексную структуру для управления данными, связанными с пользователями.
Контроллер UsersController
В контроллере User реализуются следующие методы API:
- getAlladm: Этот метод позволяет администратору получить список всех пользователей. Доступ к методу ограничен только для пользователей с ролью «ADMIN». Метод поддерживает постраничное отображение результатов с параметрами page и pageSize.
- getAll: Этот метод предоставляет возможность получить список всех пользователей. Доступ ограничен с помощью защитника AccessTokenGuard. Метод также поддерживает постраничное отображение с помощью параметров page и pageSize.
- searchUsers: Метод предназначен для поиска пользователей по заданным критериям. Пользователь передает данные в формате SearchUsersDto, содержащие запрос, страницу и размер страницы. Доступ к методу защищен AccessTokenGuard.
- getUserByIdAdmin: Этот метод позволяет администратору получить информацию об одном пользователе по его идентификатору (ID). Доступ к методу ограничен только для пользователей с ролью «ADMIN». Метод возвращает полные данные пользователя.
- getUserByIdPublic: Этот метод позволяет получить публичные данные одного пользователя по его идентификатору (ID). Доступ к методу защищён токеном доступа. Возвращаются только публичные данные, которые доступны для общего просмотра.
- getUserSelf: Этот метод позволяет пользователю получить собственные данные. Доступ к методу защищён токеном доступа, с проверкой, что запрашивающий пользователь является владельцем данных, либо имеет привилегии администратора или модератора.
- getByEmail: Этот метод позволяет администратору получить одного пользователя по его Email. Доступ к методу ограничен только для пользователей с ролью «ADMIN».
- getByUserNameAdmin: Этот метод позволяет администратору получить одного пользователя по его userName. Доступ ограничен для пользователей с ролью «ADMIN».
- getByUserName: Этот метод позволяет получить информацию о пользователе по его userName. Доступ к методу ограничен с использованием Access Token.
- editUserName: Этот метод позволяет пользователю или привилегированным пользователям (с ролями «ADMIN» или «MODERATOR») изменить имя пользователя. Запрос требует предоставления доступа с помощью AccessToken. После проверки прав доступа, метод вызывает сервис для редактирования имени пользователя.
- editEmail: Метод предназначен для изменения адреса электронной почты пользователя. Только сам пользователь или пользователи с ролями «ADMIN» или «MODERATOR» могут выполнять эту операцию. Используется защита с помощью AccessToken для проверки прав доступа.
- editPassword: Этот метод позволяет изменить пароль пользователя. Доступ к изменению пароля возможен для самого пользователя или для привилегированных пользователей с ролями «ADMIN» или «MODERATOR». Метод требует старый и новый пароли в теле запроса и защищен AccessToken’ом.
- editAdmin: Этот метод позволяет администратору редактировать информацию для всех пользователей. Доступен только пользователям с ролью «ADMIN».
- addRole: Метод позволяет администратору назначить роль пользователю. Доступ ограничен для пользователей с ролью «ADMIN».
- ban: Используйте этот метод, чтобы забанить пользователя. Доступ предоставляется только администраторам.
- confirmEmail: Этот метод отправляет ссылку для активации учетной записи пользователя на указанный Email. Доступен пользователю при наличии валидного токена, а также администратору и модератору.
- activate: Этот метод активирует пользователя по предоставленной ссылке. При успешной активации выводится сообщение об успешной активации аккаунта. В случае ошибки возвращается статус ошибки с соответствующим сообщением.
- setAvatar: Этот метод позволяет пользователю загрузить аватарку. Доступ к методу защищен и требует наличия действительного токена доступа. Загружаемый файл должен быть изображением с ограничением по размеру не более 10 МБ. Метод поддерживает проверку ролей, позволяя только самому пользователю или привилегированным ролям («ADMIN», «MODERATOR») изменить аватарку.
Сервис UsersService
В качестве примера кода приведем пару методов:
Метод searchUsers
для поиска пользователей по логину, фамилии, или имени:
async searchUsers(query: string, page: number, pageSize: number) {
const offset = (page - 1) * pageSize;
const lowercaseQuery = `%${query.toLowerCase()}%`;
const queryWithPagination = await this.userRepository.findAndCountAll({
where: {
isDeleted: false,
[Op.or]: [
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('userName')), {
[Op.like]: lowercaseQuery,
}),
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('profile.firstName')), {
[Op.like]: lowercaseQuery,
}),
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('profile.lastName')), {
[Op.like]: lowercaseQuery,
}),
],
},
include: [{
model: Profile,
required: false,
}],
limit: pageSize,
offset,
attributes: [
'id',
'userName',
'banned',
'banReason',
'avatar',
'isDeleted',
'createdAt',
],
distinct: true,
});
const totalPages = Math.ceil(queryWithPagination.count / pageSize);
return {
users: queryWithPagination.rows,
page,
pageSize,
totalPages,
totalUsers: queryWithPagination.count,
}
}
Метод setAvatar
для установки аватара:
async setAvatar(
userId: number,
image: any,
cropWidth: number,
cropHeight: number,
cropLeft: number,
cropTop: number
) {
const imageType = 'webp';
const fileName = uuid.v4();
const dbAvatarName = fileName + '.' + imageType;
await this.fileService.setAvatar('avatar', image, cropWidth, cropHeight, cropLeft, cropTop, imageType, fileName);
const user = await this.userRepository.findOne({ where: {
id: userId,
banned: false,
isDeleted: false
}});
await this.fileService.deleteAvatar('avatar', user.avatar);
user.avatar = dbAvatarName;
await user.save();
if(user.avatar === dbAvatarName) {
return { fileName: fileName + '.' + imageType };
} else throw new HttpException('Set avatar error', HttpStatus.INTERNAL_SERVER_ERROR);
}
Модуль профиля ProfileModule
@Module({
controllers: [ProfileController],
providers: [ProfileService],
imports: [
SequelizeModule.forFeature([Profile, User]),
],
exports: [ProfileService],
})
export class ProfileModule {}
Модуль ProfileModule
управляет функциональностью, связанной с профилями пользователей. Он включает контроллер ProfileController
для обработки HTTP-запросов и сервис ProfileService
для бизнес-логики. В модуле используются модели Profile
и User
с помощью SequelizeModule
, что позволяет взаимодействовать с базой данных. Также, модуль экспортирует ProfileService
, чтобы его можно было использовать в других модулях.
Модель Profile
Profile — это модель, представляющая профиль пользователя. Она содержит следующие поля:
- userId: уникальный идентификатор пользователя, являющийся первичным ключом и внешним ключом, связывающим профиль с пользователем.
- firstName: имя пользователя.
- lastName: фамилия пользователя.
- about: информация о пользователе.
- birthday: дата рождения пользователя.
- sex: пол пользователя (по умолчанию 0).
- country: страна, в которой находится пользователь.
- city: город, в котором находится пользователь.
Модель связывается с моделью User
с помощью ассоциации BelongsTo
.
Контроллер ProfileController
В контроллере Profile реализуются следующие методы API:
- getById: Этот метод позволяет получить профиль пользователя по его идентификатору. Если идентификатор не указан, возвращается профиль текущего аутентифицированного пользователя. Доступ к методу ограничен аутентифицированными пользователями с использованием защитного механизма AccessTokenGuard.
- editProfile: Этот метод позволяет редактировать профиль пользователя. Для выполнения операции необходимо наличие прав у текущего пользователя либо роль «ADMIN», либо «MODERATOR». Доступ к методу защищён с помощью AccessTokenGuard.
Сервис ProfileService
В качестве примера кода приведем пару методов:
Метод getProfile
для получения профиля по userId
:
async getProfile(userId: number) {
return await this.profileRepository.findByPk(userId, {
include: [
{ model: User, as: 'user', attributes: ['userName', 'avatar', 'lastSeen'],
include: [
{ model: Role, as: 'roles' }
]
}
]
});
}
Метод editProfile
для редактирования профиля:
async editProfile(dto: EditProfileDto): Promise<any> {
const updateData = {
firstName: dto.firstName,
lastName: dto.lastName,
about: dto.about,
birthday: dto.birthday,
sex: dto.sex,
country: dto.country,
city: dto.city,
};
const updateFields = Object.fromEntries(Object.entries(updateData).filter(([, value]) => value !== undefined));
const updatedProfile = await this.profileRepository.update(updateFields, { where: { userId: dto.userId } });
return { status: updatedProfile[0] ? true : false };
}
Модуль авторизации AuthModule
@Module({
imports: [
forwardRef(() => UsersModule),
JwtModule.register({}),
TokensModule,
],
controllers: [AuthController],
providers: [AuthService, AccessTokenStrategy, RefreshTokenStrategy],
exports: [
AuthService,
JwtModule
]
})
export class AuthModule {}
AuthModule
— модуль аутентификации приложения, который объединяет функции работы с пользователями, JWT токенами и стратегиями доступа. Включает контроллер для управления аутентификацией и сервис для обработки логики аутентификации. Экспортирует AuthService
и JwtModule
для использования в других модулях.
Контроллер AuthController
В контроллере Auth реализуются следующие методы API:
- registration: Этот метод осуществляет полную регистрацию нового пользователя. Принимает данные пользователя и создает учетную запись, запоминает IP-адрес и информацию о клиенте. После успешной регистрации возвращает токены доступа и обновления, сохраняет refresh токен в cookie, а также возвращает данные о пользователе и идентификатор сессии.
- simpleRegistration: Этот метод выполняет регистрацию пользователя, используя только имя пользователя (userName). После успешной регистрации возвращает токен доступа (accessToken), идентификатор сессии (sessionId) и данные пользователя (userData). Также устанавливает cookie с токеном обновления (refreshToken) для дальнейшей аутентификации. Пользовательские данные, такие как IP-адрес и User Agent, фиксируются для регистрации.
- login: Этот метод позволяет пользователю выполнить вход в систему. Он принимает учетные данные пользователя и возвращает accessToken и sessionId для аутентификации, а также сохраняет refreshToken в cookies. Метод также фиксирует IP-адрес и пользовательский агент пользователя.
- logout: Этот метод выполняет выход пользователя из системы, удаляя сессию пользователя. Доступ к методу ограничен только для самого пользователя или пользователей с ролями «ADMIN» и «MODERATOR». После выполнения операции куки-файл ‘refreshToken’ очищается.
- refreshTokens: Этот метод обновляет токены доступа для пользователя. Для выполнения требуется наличие refresh-токена, который отправляется в куки. Метод создает новые токены, обновляет куки-файлы и возвращает новый access-токен в ответе.
Сервис AuthService
В качестве примера кода приведем пару методов:
Метод login
для логина пользователя:
async login(userName: string, password: string, sessionId: number, userIp: string, userAgent: string): Promise<any> {
const user = await this.usersService.getUserByNameAdmin(userName);
if (!user) throw new ForbiddenException('Неверный логин');
const passwordMatches = await argon2.verify(user.password, password);
if (!passwordMatches)
throw new BadRequestException('Неверный пароль');
if(user.banned) throw new BadRequestException('Пользователь забанен: ' + user.banReason);
if(user.isDeleted) throw new BadRequestException('Пользователь удален');
const tokens = await this.getTokens(user.id, user.userName, user.roles);
const newRefreshToken = await this.saveRefreshToken(user.id, tokens.refreshToken, userIp, userAgent, sessionId);
return { tokens, sessionId: newRefreshToken.id, userData: {
userId: user.id,
userName: user.userName,
email: user.email,
userAvatar: user.avatar,
isActivated: user.isActivated,
isEmailActivated: !user.activationLink,
isPasswordSet: !!user.password,
banned: user.banned,
banReason: user.banReason,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
roles: user.roles
}};
}
Метод refreshTokens
для обновления токенов доступа:
async refreshTokens(sessionId: number, refreshToken: string, userIp: string, userAgent: string): Promise<any> {
const userSession = await this.tokensService.findUserSession(sessionId);
if (!userSession)
throw new ForbiddenException('Access Denied');
const refreshTokenMatches = await argon2.verify(
userSession.refreshToken,
refreshToken,
);
if (!refreshTokenMatches) throw new ForbiddenException('Access Denied');
const user = await this.usersService.getUserByIdAdmin(userSession.userId);
const tokens = await this.getTokens(user.userId, user.userName, user.roles);
await this.saveRefreshToken(user.userId, tokens.refreshToken, userIp, userAgent, sessionId);
return tokens;
}
Модуль ролей RolesModule
@Module({
providers: [RolesService],
controllers: [RolesController],
imports: [
SequelizeModule.forFeature([Role, User, UserRoles])
],
exports: [
RolesService
]
})
export class RolesModule {}
RolesModule
отвечает за управление ролями в приложении. Он включает в себя провайдер RolesService
для бизнес-логики, контроллер RolesController
для обработки HTTP-запросов, и использует SequelizeModule.forFeature
для работы с моделями Role
, User
и UserRoles
. Также экспортирует RolesService
для использования в других модулях.
Модель Roles
Roles— это модель, представляющая роли пользователя. Она содержит следующие поля:
- id: Уникальный идентификатор роли.
- value: Уникальное строковое значение, представляющее роль (например, ‘ADMIN’).
- description: Описание роли.
Кроме того, с помощью декоратора @BelongsToMany
устанавливается связь многие-ко-многим с моделью User
через промежуточную таблицу UserRoles
.
Модель UserRoles
UserRoles — это модель, представляющая связь между пользователями и их ролями в базе данных. Таблица ‘user.role’ состоит из уникальных идентификаторов для каждой записи, а также внешних ключей, ссылающихся на соответствующие записи в таблицах пользователей и ролей.
Контроллер RolesController
В контроллере Roles реализуются следующие методы API:
- create: Этот метод позволяет создать новую роль. Метод принимает объект CreateRoleDto, содержащий данные для создания роли, и возвращает созданную роль.
- getByValue: Этот метод позволяет получить роль на основе заданного значения. Метод принимает параметры запроса в виде объекта GetRoleDto и возвращает найденную роль.
Сервис RolesService
В данном случае кода совсем немного, и можно привести его целиком:
import { Injectable } from '@nestjs/common';
import { CreateRoleDto } from "./dto/create-role.dto";
import { InjectModel } from "@nestjs/sequelize";
import { Role } from "./roles.model";
@Injectable()
export class RolesService {
constructor(@InjectModel(Role) private roleRepository: typeof Role) {}
async createRole(dto: CreateRoleDto) {
const role = await this.roleRepository.create(dto);
return role;
}
async getRoleByValue(value: string) {
const role = await this.roleRepository.findOne({where: {value}});
return role;
}
}
Модуль файлов FilesModule
В этом модуле содержатся методы для работы с файлами. На текущий момент реализован набор функций, необходимый для работы с аватарами.
Приведу код files.service.ts
целиком:
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import * as path from 'path'
import * as fs from 'fs';
import * as sharp from 'sharp';
import { promises as fsPromises } from 'fs';
@Injectable()
export class FilesService {
setDir(loadType: string) {
const loadDir = 'static/user/';
switch(loadType) {
case 'file':
return loadDir + 'file';
case 'image.orig':
return loadDir + 'image/orig';
case 'image.small':
return loadDir + 'image/small';
case 'image.medium':
return loadDir + 'image/medium';
case 'image.high':
return loadDir + 'image/high';
case 'avatar.small':
return loadDir + 'image/avatar/small';
case 'avatar.medium':
return loadDir + 'image/avatar/medium';
case 'avatar.high':
return loadDir + 'image/avatar/high';
case 'room.small':
return loadDir + 'image/room/small';
case 'room.medium':
return loadDir + 'image/room/medium';
case 'room.high':
return loadDir + 'image/room/high';
default: return loadDir + 'file';
}
}
async createFile(file: Buffer, fileName: string, loadType: string, fileType: string): Promise<string> {
const dirname = this.setDir(loadType);
if(fileType) fileType = '.' + fileType;
fileName = fileName + fileType;
try {
const filePath = path.resolve(path.resolve(__dirname, '..', '..', dirname));
if (!fs.existsSync(filePath)) {
fs.mkdirSync(filePath, { recursive: true });
}
fs.writeFileSync(path.join(filePath, fileName), file);
return fileName;
} catch (error) {
console.error(error);
throw new HttpException('Произошла ошибка при записи файла', HttpStatus.INTERNAL_SERVER_ERROR)
}
}
async deleteFile(filePath: string): Promise<void> {
try {
await fsPromises.unlink(filePath);
} catch (error) {
console.error(`An error occurred while deleting the file ${filePath}:`, error);
}
}
async deleteAvatar(avatarType: string, dbAvatarName: string) {
if(!dbAvatarName) return;
await this.deleteFile(path.join(this.setDir(`${avatarType}.small`), dbAvatarName));
await this.deleteFile(path.join(this.setDir(`${avatarType}.medium`), dbAvatarName));
await this.deleteFile(path.join(this.setDir(`${avatarType}.high`), dbAvatarName));
}
// npm update npm -g
// npm install --os=win32 --cpu=x64 sharp
async cropImage(inputBuffer: Buffer, resize: number, width: number, height: number, offsetX: number, offsetY: number) {
const outputFormat = 'webp';
return await sharp(inputBuffer.buffer)
.rotate()
.extract({
left: offsetX,
top: offsetY,
width: width,
height: height
})
.resize(resize)
.webp({
quality: 80, // 1 - 100
lossless: false
})
.jpeg({
quality: 90,
progressive: true
})
.toFormat(outputFormat)
.toBuffer();
}
async setAvatar(
avatarType: string,
image: any,
cropWidth: number,
cropHeight: number,
cropLeft: number,
cropTop: number,
imageType: string,
fileName: string
) {
const fileTypeOrig = image.originalname.split('.').slice(-1)
await this.createFile(image.buffer, fileName, 'image.orig', fileTypeOrig);
const avatarCroppedSmall = await this.cropImage(image, 128, cropWidth, cropHeight, cropLeft, cropTop);
await this.createFile(avatarCroppedSmall, fileName, `${avatarType}.small`, imageType);
const avatarCroppedMedium = await this.cropImage(image, 256, cropWidth, cropHeight, cropLeft, cropTop);
await this.createFile(avatarCroppedMedium, fileName, `${avatarType}.medium`, imageType);
const avatarCroppedHigh = await this.cropImage(image, 512, cropWidth, cropHeight, cropLeft, cropTop);
await this.createFile(avatarCroppedHigh, fileName, `${avatarType}.high`, imageType);
}
}
Модуль токенов TokensModule
@Module({
imports: [
SequelizeModule.forFeature([Token])
],
providers: [TokensService],
controllers: [TokensController],
exports: [
TokensService
]
})
export class TokensModule {}
TokensModule
управляет функциональностью по работе с токенами. Он включает в себя подключение модели Token
через Sequelize, предоставляет логику обработки токенов через TokensService
и контролирует взаимодействие с API через TokensController
. Модуль также экспортирует TokensService
для использования в других частях приложения.
Модель Token
Token — это модель, представляющая сессию пользователя в приложении. Она содержит следующие ключевые поля:
- id: Уникальный идентификатор сессии.
- userId: ID пользователя, связанный с данной сессией.
- deviceId: Уникальный идентификатор устройства, с которого пользователь осуществил вход.
- userIp: IP-адрес пользователя, использующийся для аутентификации.
- userAgent: Информация о браузере и устройстве пользователя.
- refreshToken: Уникальный токен для обновления сессии.
Модель связывается с пользователем через отношение BelongsTo
, что позволяет получать информацию о пользователе для конкретной сессии.
Контроллер TokensController
Содержит один метод:
getTokens: позволяет пользователю получить список своих активных сессий. Доступ к методу ограничен и предоставляется лишь владельцу сессии, или пользователям, обладающим соответствующими правами доступа, такими как «ADMIN».
Сервис TokensService
TokensService — это сервис для управления токенами пользователей. Он предоставляет методы для обновления, сохранения и удаления токенов, а также для получения сессий пользователя:
- updateToken: обновляет существующий токен по его идентификатору.
- saveToken: сохраняет новый токен или обновляет существующий для указанной сессии пользователя.
- removeToken: удаляет токен по идентификатору сессии.
- findUserSession: находит сессию пользователя по ее идентификатору, включая имя пользователя.
- getUserSessions: возвращает все сессии для заданного пользователя, исключая поле
refreshToken
и упорядочивая их по времени обновления.
Приведу код сервиса целиком:
import { Injectable } from '@nestjs/common';
import { InjectModel } from "@nestjs/sequelize";
import { Token } from "./tokens.model";
import { TokenDto } from "./dto/token.dto";
import { User } from "../users/user.model";
@Injectable()
export class TokensService {
constructor(@InjectModel(Token) private tokenRepository: typeof Token) {}
async updateToken(id: number, tokenDto: TokenDto) {
const token = await this.tokenRepository.findByPk(id);
token.refreshToken = tokenDto.refreshToken;
await token.save();
return token;
}
async saveToken(userId: number, refreshToken: string, userIp: string, userAgent: string, sessionId?: number) {
if(sessionId) {
const tokenData = await this.tokenRepository.findOne({ where: { id: sessionId, userId }});
if (tokenData) {
tokenData.refreshToken = refreshToken;
tokenData.userIp = userIp;
tokenData.userAgent = userAgent;
return tokenData.save();
}
}
const token = await this.tokenRepository.create({ userId, refreshToken, userIp, userAgent });
return token;
}
async removeToken(sessionId: number): Promise<any> {
const isDeleted = await this.tokenRepository.destroy({ where: { id: sessionId }});
return isDeleted;
}
async findUserSession(sessionId: number): Promise<any> {
const userSession = await this.tokenRepository.findOne({ where: { id: sessionId },
include: [{ model: User, attributes: ['userName'] }]
});
if (userSession) {
return userSession;
}
}
async getUserSessions(userId: number): Promise<any> {
const userSessions = await this.tokenRepository.findAll({
where: { userId },
attributes: { exclude: ['refreshToken'] },
include: [{ model: User, attributes: ['lastSeen'] }],
order: [['updatedAt', 'DESC']],
});
return userSessions;
}
}
Модуль друзей FriendsModule
@Module({
providers: [FriendsService],
exports: [FriendsService],
controllers: [FriendsController],
imports: [
SequelizeModule.forFeature([Friend])
],
})
export class FriendsModule {}
Данный модуль предназначен для управления сущностями «друзья» в приложении. Он включает в себя FriendsService
для обработки бизнес-логики и FriendsController
для обработки HTTP-запросов. Модуль использует SequelizeModule
для работы с моделью Friend
, обеспечивая интеграцию с базой данных.
Модель Friend
Модель Friend
нужна для обеспечения функциональности добавления пользователей в друзья, подписки на них. Таблица user.friend
и содержит следующие основные поля:
- id: Уникальный идентификатор.
- from: Идентификатор пользователя, отправившего запрос на дружбу.
- to: Идентификатор пользователя, получившего запрос на дружбу.
- fromActive: Статус дружбы пользователя, инициировавшего запрос.
- toActive: Статус дружбы пользователя, принимающего запрос.
- fromActiveDate: Дата изменения статуса дружбы у пользователя, отправившего запрос.
- toActiveDate: Дата изменения статуса дружбы у пользователя, получившего запрос.
Дополнительно, модель содержит ссылки на связанные модели пользователей, которые представлены через поля fromUser
и toUser
.
Контроллер FriendsController
Все методы защищены и требует использования токена доступа:
- addFriend: добавляет пользователя в список друзей текущего пользователя.
- deleteFriend: удаляет пользователя из списка друзей текущего пользователя.
- isFriend: проверяет статус «дружбы» между текущим пользователем и указанным пользователем.
- getFriends: возвращает список друзей пользователя с возможностью фильтрации по типу и постраничного отображения.
- countFriends: возвращает количество друзей указанного пользователя.
- searchFriends: осуществляет поиск друзей пользователя на основе заданного запроса.
Сервис FriendsService
Приведу полностью код friends.service.ts
:
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectModel } from "@nestjs/sequelize";
import { Friend } from "./friends.model";
import { Sequelize, Op } from 'sequelize';
import { User } from "../users/user.model";
import { Profile } from "../profile/profile.model";
@Injectable()
export class FriendsService {
constructor(@InjectModel(Friend) private friendsRepository: typeof Friend) {}
async addFriend(from: number, to: number) {
const friend = await this.friendsRepository.findOne({
where: {
[Op.or]: [{ from, to }, { from: to, to: from }],
},
});
if (friend) {
if(friend.from === from) { // Если я был подписан когда то
friend.fromActive = true;
friend.fromActiveDate = new Date().toISOString();
} else if(friend.to === from) { // Если на меня подписан или когда то был
friend.toActive = true;
friend.toActiveDate = new Date().toISOString();
}
return await friend.save();
} else {
//const friendCreate =
await this.friendsRepository.create({from, to, fromActive: true, fromActiveDate: new Date().toISOString()});
//return friendCreate;
return { status: true}
}
}
async deleteFriend(from: number, to: number) {
const friend = await this.friendsRepository.findOne({
where: {
[Op.or]: [{ from, to }, { from: to, to: from }],
},
});
if (friend) {
if(friend.from === from) { // Если я был подписан когда то
friend.fromActive = false;
friend.fromActiveDate = new Date().toISOString();
} else if(friend.to === from) { // Если на меня подписан или когда то был
friend.toActive = false;
friend.toActiveDate = new Date().toISOString();
}
await friend.save();
return { status: true}
}
}
async isFriend(from: number, to: number) { // return: notFriends, friends, subscribed, subscriber
const friend = await this.friendsRepository.findOne({
where: {
[Op.or]: [{ from, to }, { from: to, to: from }],
},
});
if (friend) {
if(friend.from == from) { // Если я подписывался
if(friend.fromActive === true && friend.toActive === true) { // Взаимная подписка
return "mutually"; // Друзья
} else if(friend.fromActive === true && friend.toActive === false) { // Я подписчик на него
return "subscribed"; // Подписан
} else if(friend.fromActive === false && friend.toActive === true) { // Он подписчик на меня
return "subscriber"; // Подписчик
} else if(friend.fromActive === false && friend.toActive === false) { // Не друзя, но когда то
return "notFriends"; // Не друзья
}
} else if(friend.to == from) { // Если на меня подписывались
if(friend.toActive === true && friend.fromActive === true) { // Взаимная подписка
return "mutually"; // Друзья
} else if(friend.toActive === true && friend.fromActive === false) { // Я подписчик на него
return "subscribed"; // Подписан
} else if(friend.toActive === false && friend.fromActive === true) { // Он подписчик на меня
return "subscriber"; // Подписчик
} else if(friend.toActive === false && friend.fromActive === false) { // Не друзя, но когда то
return "notFriends"; // Не друзья
}
}
} else { // Не друзья, и никогда небыли
return "notFriends"; // Не друзья
}
}
async getFriends(
id: number,
type: string,
page: number = 1,
pageSize: number = 10
) {
const offset = (page - 1) * pageSize;
const limit = pageSize;
let condition = [];
switch(type) {
default: // mutually
condition = [{ from: id, fromActive: true, toActive: true }, { to: id, fromActive: true, toActive: true }];
break;
case "subscribed":
condition = [{ from: id, fromActive: true, toActive: false }, { to: id, fromActive: false, toActive: true }];
break;
case "subscriber":
condition = [{ from: id, fromActive: false, toActive: true }, { to: id, fromActive: true, toActive: false }];
break;
}
const friends = await this.friendsRepository.findAndCountAll({
where: {
[Op.or]: condition,
},
limit,
offset,
include: [
{
model: User,
as: 'fromUser',
attributes: ['id', 'userName', 'avatar'],
include: [{
model: Profile,
as: 'profile',
attributes: ['firstName', 'lastName']
}]
},
{
model: User,
as: 'toUser',
attributes: ['id', 'userName', 'avatar'],
include: [{
model: Profile,
as: 'profile',
attributes: ['firstName', 'lastName']
}]
},
],
attributes: [
'id',
'from',
'to',
'fromActive',
'toActive'
]
});
const totalPages = Math.ceil(friends.count / pageSize);
const formatted = friends.rows.map(function(item) {
let user: { id: number, userName: string, avatar: string, profile: { firstName?: string, lastName?: string } };
if(item.fromUser.id === id) {
user = item.toUser;
} else if(item.toUser.id === id) {
user = item.fromUser;
}
return {
userId: user.id,
userName: user.userName,
avatar: user.avatar,
firstName: user?.profile?.firstName,
lastName: user?.profile?.lastName,
};
})
return {
friends: formatted,
page,
pageSize,
totalPages,
totalFriends: friends.count
}
}
async searchFriends(
query: string,
id: number,
type: string,
page: number = 1,
pageSize: number = 10
) {
const offset = (page - 1) * pageSize;
const lowercaseQuery = `%${query.toLowerCase()}%`;
let conditionTypeFrom: object;
let conditionTypeTo: object;
switch(type) {
case "mutually":
conditionTypeFrom = { from: id, fromActive: true, toActive: true };
conditionTypeTo = { to: id, fromActive: true, toActive: true };
break;
case "subscribed":
conditionTypeFrom = { from: id, fromActive: true, toActive: false };
conditionTypeTo = { to: id, fromActive: false, toActive: true };
break;
case "subscriber":
conditionTypeFrom = { from: id, fromActive: false, toActive: true };
conditionTypeTo = { to: id, fromActive: true, toActive: false };
break;
default: throw new HttpException('friend type incorrect', HttpStatus.NOT_FOUND);
}
const userSearchConditionFrom = {
[Op.or]: [
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('fromUser.userName')), {
[Op.like]: lowercaseQuery,
}),
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('fromUser.profile.firstName')), {
[Op.like]: lowercaseQuery,
}),
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('fromUser.profile.lastName')), {
[Op.like]: lowercaseQuery,
}),
]
};
const userSearchConditionTo = {
[Op.or]: [
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('toUser.userName')), {
[Op.like]: lowercaseQuery,
}),
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('toUser.profile.firstName')), {
[Op.like]: lowercaseQuery,
}),
Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('toUser.profile.lastName')), {
[Op.like]: lowercaseQuery,
}),
]
};
const whereCondition = {
[Op.or]: [
{ [Op.and]: [ conditionTypeFrom, userSearchConditionTo ] },
{ [Op.and]: [ conditionTypeTo, userSearchConditionFrom ] }
]
}
const friends = await this.friendsRepository.findAndCountAll({
where: whereCondition,
limit: pageSize,
offset,
include: [
{
model: User,
as: 'fromUser',
attributes: ['id', 'userName', 'avatar'],
include: [{
model: Profile,
as: 'profile',
attributes: ['firstName', 'lastName']
}]
},
{
model: User,
as: 'toUser',
attributes: ['id', 'userName', 'avatar'],
include: [{
model: Profile,
as: 'profile',
attributes: ['firstName', 'lastName']
}]
},
],
attributes: [
'id',
'from',
'to',
'fromActive',
'toActive'
]
});
const totalPages = Math.ceil(friends.count / pageSize);
const formatted = friends.rows.map(function(item) {
let user: { id: number, userName: string, avatar: string, profile: { firstName?: string, lastName?: string } };
if(item.fromUser.id === id) {
user = item.toUser;
} else if(item.toUser.id === id) {
user = item.fromUser;
}
return {
id: item.id,
userId: user.id,
userName: user.userName,
avatar: user.avatar,
firstName: user?.profile?.firstName,
lastName: user?.profile?.lastName,
};
})
return {
friends: formatted,
page,
pageSize,
totalPages,
totalFriends: friends.count
}
}
async countFriends(id: number) {
const results = await this.friendsRepository.sequelize.query(`
SELECT
(SELECT COUNT(*) FROM "user.friend" WHERE ("from" = ? AND "fromActive" = true AND "toActive" = true) OR ("to" = ? AND "fromActive" = true AND "toActive" = true)) AS mutually,
(SELECT COUNT(*) FROM "user.friend" WHERE ("from" = ? AND "fromActive" = true AND "toActive" = false) OR ("to" = ? AND "fromActive" = false AND "toActive" = true)) AS subscribed,
(SELECT COUNT(*) FROM "user.friend" WHERE ("from" = ? AND "fromActive" = false AND "toActive" = true) OR ("to" = ? AND "fromActive" = true AND "toActive" = false)) AS subscriber
`, { replacements: [id, id, id, id, id, id] });
return results[0][0];
}
}
Модуль сообщений MessageModule
@Module({
providers: [MessageService],
exports: [MessageService],
controllers: [MessageController],
imports: [
SequelizeModule.forFeature([Message]),
SubscribeModule,
RoomModule,
UsersModule,
FriendsModule
],
})
export class MessageModule {}
MessageModule — модуль, отвечающий за управление сообщениями. Он предоставляет и экспортирует MessageService
, использует MessageController
для управления запросами и включает зависимости от нескольких модулей: SubscribeModule
, RoomModule
, UsersModule
и FriendsModule
. Модуль также взаимодействует с моделью Message
через Sequelize
.
Модель Message
Message — модель, представляющая сообщения в системе мессенджера. Она описывается следующими полями:
- id: Уникальный идентификатор сообщения.
- owner: Идентификатор пользователя, который отправил сообщение. Является внешним ключом, ссылающимся на модель
User
. - room: Идентификатор комнаты, в которой размещено сообщение. Является внешним ключом, ссылающимся на модель
Room
. - content: Содержимое сообщения. По умолчанию это пустая строка.
- type: Тип сообщения, например, текст, изображение или стикер.
- replyTo: Идентификатор сообщения, на которое идет ответ. Является внешним ключом на саму модель
Message
. - deletedSelf: Флаг, указывающий на то, что пользователь удалил сообщение только для себя.
- deletedAll: Флаг, указывающий на то, что сообщение удалено для всех пользователей.
Модель также определяет связи с другими моделями:
- replyToMessage: Связь с сообщением, на которое был дан ответ.
- ownerUser: Связь с владельцем сообщения, моделью
User
.
Контроллер MessageController
Доступ к методам защищен и требует валидного токена доступа:
- createMessage: метод предназначен для создания сообщения. Указывается идентификатор получателя или комнаты, содержимое сообщения, тип и, при необходимости, ответ на другое сообщение.
- getMessages: метод позволяет получить список сообщений. Указывается комната, из которой необходимо получить сообщения, а также параметры для навигации по страницам, такие как номер страницы и количество сообщений.
Сервис MessageService
В качестве примера кода приведем пару методов:
Метод createMessage
для создания сообщения:
async createMessage(
ownerId: number,
roomOrUserId: string,
content: string,
type: number,
replyTo: number
) {
let subscribe: Subscribe;
let roomId: number;
let dialogueCreated = false;
let denied: false | string = false;
if (typeof roomOrUserId === 'string' && roomOrUserId.charAt(0) === 'u') {
const companionId = parseInt(roomOrUserId.substring(1));
subscribe = await this.subscribeService.getSubscribeByCompanion(ownerId, companionId);
if(subscribe?.roomId) {
roomId = subscribe.roomId;
} else {
denied = await this.userCheck(ownerId, companionId);
if(denied) return { denied };
const createdDialogue = await this.roomService.createDialogue(ownerId, companionId);
roomId = createdDialogue.roomCreate.id;
dialogueCreated = true;
}
} else {
roomId = parseInt(roomOrUserId);
}
const subscribeRoom = await this.subscribeService.getSubscribe(ownerId, roomId);
if(!subscribeRoom?.dataValues && !subscribeRoom?.dataValues.isSubscribed) return { denied: 'roomSubscribersOnly' };
if(!subscribeRoom?.dataValues.isApproved) return { denied: 'roomNotApproved' };
if(subscribeRoom?.dataValues.banned) return { denied: 'userBan' };
if(subscribeRoom.dataValues.subscribeRoom.type === RoomType.dialogue) {
if(!dialogueCreated) {
denied = await this.userCheck(ownerId, subscribeRoom.dataValues.companionId);
if(denied) return { denied };
}
}
if(subscribeRoom.subscribeRoom.banned) return { denied: 'roomBanned' };
if(subscribeRoom.subscribeRoom.isDeleted) return { denied: 'roomDeleted' };
if(subscribeRoom.subscribeRoom.readonly) return { denied: 'roomReadonly' };
if(subscribeRoom.subscribeRoom.type === RoomType.channel) {
if(
subscribeRoom.userRole === SubscribeRole.subscriber ||
subscribeRoom.userRole === SubscribeRole.nobody
) return { denied: 'roomRole' };
}
replyTo = replyTo || null;
const createdMessage = await this.messageRepository.create({
owner: ownerId,
room: roomId,
content,
type,
replyTo
});
if(!createdMessage.id) throw new WsException('Create message error');
return {
denied,
dialogueCreated,
createdMessage
};
}
Метод getMessages
для получение списка сообщений:
async getMessages(
user: object,
roomId: number,
page: number = 1,
pageSize: number = 10
): Promise<any> {
const ownerId = user['sub'];
const isPrivileged = user['roles'].some(role => ['ADMIN', 'MODERATOR'].includes(role.value));
const room = await this.roomService.getRoom(roomId);
const subscribe = await this.subscribeService.getSubscribe(ownerId, roomId);
if(room?.dataValues.banned && !isPrivileged) return { denied: 'roomBanned' };
if(room?.dataValues.isDeleted && !isPrivileged) return { denied: 'roomDeleted' };
if(room.dataValues.type !== RoomType.dialogue) {
if(room?.dataValues?.privacy !== RoomPrivacy.all) {
if(!subscribe?.dataValues && !subscribe?.dataValues.isSubscribed) return { denied: 'roomSubscribersOnly' };
if(!subscribe?.dataValues.isApproved) return { denied: 'roomNotApproved' };
if(subscribe?.dataValues.banned) return { denied: 'userBan' };
}
} else {
if(!subscribe?.dataValues && !subscribe.dataValues.isSubscribed && !isPrivileged) return { denied: 'roomSubscribersOnly' };
}
const offset = (page - 1) * pageSize;
const limit = pageSize;
const messages = await this.messageRepository.findAndCountAll({
where: { room: roomId,
},
order: [['id', 'DESC']],
limit,
offset,
include: [
{ model: User, as: 'ownerUser', attributes: ['userName', 'avatar'] },
{ model: Message, as: 'replyToMessage' }
]
});
const totalPages = Math.ceil(messages.count / pageSize);
return {
denied: false,
messages: messages.rows,
page,
pageSize,
totalPages,
totalMessages: messages.count
}
}
Модуль комнат RoomModule
@Module({
providers: [RoomService],
controllers: [RoomController],
imports: [
SequelizeModule.forFeature([Room, User, Message]),
forwardRef(() => SubscribeModule),
FilesModule,
UsersModule,
FriendsModule
],
exports: [RoomService],
})
export class RoomModule {}
Модуль RoomModule
управляет функциональностью комнат чата и взаимодействует с компонентами системы через сервис RoomService
и контроллер RoomController
. Он использует SequelizeModule
для интеграции моделей Room
, User
и Message
, обеспечивая хранение и обработку данных. Импортируются зависимые модули, такие как SubscribeModule
(с отложенной загрузкой), FilesModule
, UsersModule
, и FriendsModule
. Экспортируется RoomService
.
Модель Room
Модель Room
представляет собой сущность комнаты в системе обмена сообщениями, определяемую в таблице базы данных с именем messenger.room
. Эта модель используется для управления основными атрибутами комнаты, включая информацию о создателе и владельце, параметры конфиденциальности и другие свойства. Модель содержит следующие поля:
- id: Уникальный идентификатор комнаты.
- ownerId: Идентификатор пользователя, который является владельцем комнаты. Может быть необязательным.
- creatorId: Идентификатор пользователя, который создал комнату. Обязательное поле.
- type: Тип комнаты, обозначаемый числовым значением. Обязательное поле.
- name: Название комнаты.
- about: Описание комнаты.
- roomAvatar: Имя файла, представляющего аватар комнаты.
- privacy: Уровень конфиденциальности комнаты.
- approved: Флаг, указывающий, подтверждена ли комната.
- banned: Флаг, указывающий, заблокирована ли комната.
- banReason: Причина блокировки комнаты.
- bannedAt: Дата, когда комната была заблокирована.
- isDeleted: Флаг, указывающий, удалена ли комната.
- disableEdit: Флаг для запрета редактирования комнаты.
- readonly: Флаг для установки режима только для чтения.
- allowSearch: Флаг, разрешающий поиск комнаты.
- countSubscribers: Число подписчиков комнаты.
Связи:
- ownerUser: Связь с пользователем, который является владельцем комнаты.
- creatorUser: Связь с пользователем, который создал комнату.
- roomMessages: Связь с сообщениями, принадлежащими данной комнате.
Контроллер RoomController
Доступ к методам ограничен и требуется наличия соответствующего токена доступа:
- createRoom: Метод для создания новой комнаты. Метод принимает данные о типе комнаты, ее названии, приватности и описании.
- editAvatar: Метод для изменения аватарки комнаты. Требует загрузки изображения с ограничением на размер (до 10 MB) и поддержкой определенных форматов (jpg, jpeg, png, gif, bmp, webp, avif). Включает возможность настраивать параметры обрезки изображения.
- createDialogue: Метод для создания диалога между пользователями. Принимает идентификатор пользователя, с которым необходимо создать диалог.
- getRoom: Метод для получения данных о конкретной комнате.
- editRoom: Метод для редактирования параметров комнаты. Пользователь с соответствующими правами может изменить параметры комнаты, предоставив необходимые данные.
- getRooms: Метод для получения списка всех комнат с возможностью фильтрации и постраничного отображения. Поддерживает параметры поиска и сортировки.
Сервис RoomService
В качестве примера кода приведем несколько методов:
Метод createRoom
создания комнаты:
async createRoom(
ownerId: number,
isPriveleged: boolean,
type: number,
name: string,
privacy: number,
about: string,
) {
if(!isPriveleged) {
if(privacy === RoomPrivacy.privileged) throw new HttpException('Нет доступа', HttpStatus.FORBIDDEN);
}
const roomExists = await this.getRoomByName(name);
if (roomExists) throw new HttpException('Название уже занято', HttpStatus.BAD_REQUEST);
const roomCreate = await this.roomRepository.create({ ownerId, type, name, privacy, about, creatorId: ownerId });
if(!roomCreate?.dataValues?.id) throw new HttpException('Ошибка создания', HttpStatus.INTERNAL_SERVER_ERROR);
const subscribe = await this.subscribeService.subscribe(ownerId, roomCreate.id, SubscribeRole.owner, isPriveleged);
return { roomCreate, subscribe };
}
Метод createDialogue
для создания диалога:
async createDialogue(fromUserId: number, toUserId: number ) {
if(fromUserId === toUserId) throw new HttpException('You can\'t write to yourself', HttpStatus.INTERNAL_SERVER_ERROR);
const roomCreate = await this.roomRepository.create({ type: RoomType.dialogue, creatorId: fromUserId });
if(roomCreate?.dataValues?.id) {
const subscribeFrom = await this.subscribeService.subscribeDialogue(roomCreate.id, fromUserId, toUserId);
const subscribeTo = await this.subscribeService.subscribeDialogue(roomCreate.id, toUserId, fromUserId);
if(subscribeFrom?.dataValues?.id && subscribeTo?.dataValues?.id) {
return {
roomCreate,
subscribeFrom,
}
} else throw new HttpException('Create room subscribe error', HttpStatus.INTERNAL_SERVER_ERROR);
} else throw new HttpException('Create room error', HttpStatus.INTERNAL_SERVER_ERROR);
}
Метод getRoomData
для получения данных комнаты. Реализована сложная проверка на права доступа — пользователь получит только те данные, на которые у него есть права:
async getRoomData(idMixed: string, user: object) {
let subscribe: Subscribe;
let roomId: number;
let companionId: number;
let isBannedCompanion = false;
if (idMixed.charAt(0) === 'u') {
companionId = parseInt(idMixed.substring(1));
subscribe = await this.subscribeService.getSubscribeByCompanion(user['sub'], companionId);
if(!subscribe?.roomId) {
const userData = await this.usersService.getUserByIdPublic(companionId);
if(!userData) throw new HttpException('User not found', HttpStatus.NOT_FOUND);
const userPrivacyAccess = await this.isAccessFriend(userData.msgPrivacy, user['sub'], companionId);
return {
type: RoomType.initDialogue,
denied: userPrivacyAccess ? false : 'userPrivacy',
user: userData
}
}
roomId = subscribe.roomId;
} else {
roomId = parseInt(idMixed);
subscribe = await this.subscribeService.getSubscribe(user['sub'], roomId);
companionId = subscribe?.companionId;
}
const room = await this.getRoom(roomId);
if(companionId) {
const companionSubscribe = await this.subscribeService.getSubscribe(companionId, roomId);
isBannedCompanion = companionSubscribe.banned;
}
const isPrivileged = user['roles'].some(role => ['ADMIN', 'MODERATOR'].includes(role.value));
const isSelf = user['sub'] === room.ownerId;
const isAll = room.privacy === RoomPrivacy.all;
const isApproval = room.privacy === RoomPrivacy.approval;
const isByInvitation = room.privacy === RoomPrivacy.byInvitation;
const isBanned = room.banned || false;
const isSubscriber = subscribe?.isSubscribed || false;
const isApproved = subscribe?.isApproved || false;
const isDialogue = room.type === RoomType.dialogue;
const isDeleted = room.isDeleted || false;
let isDenied: boolean | string = false;
if(subscribe?.banned) isDenied = 'userBan';
if(subscribe?.companionUser?.banned) isDenied = 'adminUserBan';
if(room?.banned) isDenied = 'roomBanned';
if(room?.isDeleted) isDenied = 'roomDeleted';
if(room?.readonly) isDenied = 'roomReadonly';
if(room?.privacy !== 1 && !isSubscriber) isDenied = 'roomSubscribersOnly';
const responseFull = {
responceType: 'full',
denied: isDenied,
id: room.id,
ownerId: room.ownerId,
creatorId: room.creatorId,
type: room.type,
name: room.name,
about: room.about,
roomAvatar: room.roomAvatar,
privacy: room.privacy,
banned: room.banned,
banReason: room.banReason,
bannedAt: room.bannedAt,
isDeleted: room.isDeleted,
disableEdit: room.disableEdit,
readonly: room.readonly,
moderated: room.moderated,
createdAt: room.createdAt,
updatedAt: room.updatedAt,
countSubscribers: room.countSubscribers,
ownerUser: room.ownerUser,
creatorUser: room.creatorUser,
subscribe
}
const responseApproval = {
responceType: 'approval',
denied: isDenied,
id: room.id,
type: room.type,
name: room.name,
about: room.about,
roomAvatar: room.roomAvatar,
privacy: room.privacy,
banned: room.banned,
banReason: room.banReason,
bannedAt: room.bannedAt,
isDeleted: room.isDeleted,
countSubscribers: room.countSubscribers,
subscribe
};
const responseDialogue = {
denied: isDenied,
isBannedCompanion,
type: room.type,
id: room.id,
createdAt: room.createdAt,
subscribe
};
const responseAccessDenied = {
denied: isDenied,
id: room.id,
type: room.type,
privacy: room.privacy,
banned: room.banned,
banReason: room.banReason,
bannedAt: room.bannedAt,
isDeleted: room.isDeleted,
subscribe
};
if(isDialogue && isSubscriber) return responseDialogue;
if(isDeleted && isPrivileged) return responseFull;
if(isBanned && isPrivileged) return responseFull;
if(isPrivileged) return responseFull;
if(isSelf) return responseFull;
if(isApproval && isApproved) return responseFull;
if(isApproval && !isApproved) return responseApproval;
if(isByInvitation && isApproved) return responseFull;
if(isAll) return responseFull;
return responseAccessDenied;
}
Модуль подписки SubscribeModule
@Module({
controllers: [SubscribeController],
providers: [SubscribeService],
imports: [
SequelizeModule.forFeature([Subscribe, Room]),
forwardRef(() => RoomModule)
],
exports: [SubscribeService],
})
export class SubscribeModule {}
Модуль SubscribeModule
представляет собой часть приложения, которая отвечает за управление подписками. Он включает контроллер SubscribeController
для обработки входящих запросов и сервис SubscribeService
для реализации бизнес-логики, связанной с подписками. Модуль использует SequelizeModule
для работы с моделями данных Subscribe
и Room
, что обеспечивает взаимодействие с базой данных. Он также импортирует модуль RoomModule
с помощью функции forwardRef
, чтобы избежать циклической зависимости. SubscribeService
экспортируется для использования в других модулях приложения.
Модель Subscribe
Subscribe
— модель реализует систему подписок пользователей на определенные комнаты в мессенджере. Данная модель содержит информацию о пользователе, комнате, а также статусах подписки и уведомлений. У модели следующая структура:
- id: Уникальный идентификатор подписки.
- userId: Идентификатор пользователя, который подписан на комнату. Является внешним ключом, связывающим с моделью
User
. - roomId: Идентификатор комнаты, на которую подписан пользователь. Является внешним ключом, связывающим с моделью
Room
. - companionId: Опциональный идентификатор другого пользователя, связанного с данной подпиской. Связь с моделью
User
. - isSubscribed: Флаг, показывающий, активна ли подписка.
- userRole: Роль пользователя в контексте подписки. Значения:
1
— пользователь,2
— администратор. - notifyState: Состояние уведомлений.
- isApproved: Флаг, показывающий, подтверждена ли подписка.
- banned: Флаг, показывающий, забанен ли пользователь в данной комнате.
- banReason: Причина блокировки пользователя в комнате.
- bannedAt: Дата и время блокировки пользователя.
- newMsgCount: Счетчик новых сообщений в комнате, которые пользователь еще не видел.
Связи:
subscribeUser
: Пользователь, связанный с данной подпиской черезuserId
.companionUser
: Опциональный пользователь, связанный черезcompanionId
.subscribeRoom
: Комната, связанная с подпиской черезroomId
.
Контроллер SubscribeController
Доступ к методам ограничен и требуется наличия соответствующего токена доступа:
subscribe: позволяет пользователю подписаться на определённую комнату. Метод учитывает привилегированные роли пользователя, такие как «ADMIN» или «MODERATOR», для обработки подписки.
getSubscribes: предоставляет пользователю возможность получить список всех его подписок. Метод поддерживает постраничный вывод результатов с возможностью поиска по ключевым словам.
changeNotifyState: позволяет пользователю изменить состояние уведомлений для конкретной комнаты. Пользователь может включить или выключить уведомления в зависимости от своих предпочтений.
Сервис SubscribeService
В качестве примера кода приведем несколько методов:
Метод subscribeRoom
для подписки пользователя:
async subscribeRoom(userId: number, roomId: number, userRole: number, isPrivileged: boolean): Promise<{
data?: boolean,
roomType?: number,
denied?: string,
error?: string
}> {
let typeRoom: number;
const getSubscribe = await this.getSubscribe(userId, roomId);
if(getSubscribe?.dataValues) {
const subscribe = getSubscribe.dataValues;
if(!subscribe.isApproved) return { denied: 'roomNotApproved' };
if(subscribe.banned) return { denied: 'userBan' };
const subscribeRoom = subscribe?.subscribeRoom;
if(!subscribeRoom || subscribeRoom.isDeleted) return { denied: 'roomDeleted' };
if(subscribeRoom.banned) return { denied: 'roomBanned' };
typeRoom = subscribeRoom.type;
const isDeniedRoomPrivacy = this.checkRoomPrivacy(subscribeRoom, userId, isPrivileged);
if(isDeniedRoomPrivacy) return { denied: isDeniedRoomPrivacy };
if(!subscribe.isSubscribed) {
const subscribeSave = await this.subscribeRepository.update({ isSubscribed: true }, { where: { userId, roomId }});
if(!subscribeSave[0]) {
return { error: 'saveError'};
} else return {
data: true,
roomType: typeRoom
};
} else return { error: 'Already subscribed'};
} else {
const room = await this.roomService.getRoom(roomId);
if(room.banned) return { denied: 'roomBanned' };
if(room.isDeleted) return { denied: 'roomDeleted' };
typeRoom = room.type;
const isDeniedRoomPrivacy = this.checkRoomPrivacy(room, userId, isPrivileged);
if(isDeniedRoomPrivacy) return { denied: isDeniedRoomPrivacy };
const subscribe = await this.subscribeRepository.create({ userId, roomId, userRole });
if(!subscribe?.dataValues?.id) {
return { error: 'saveError' };
} else {
this.roomService.updateCountSubscribers(roomId);
return {
data: true,
roomType: typeRoom
}; }
}
}
Метод getSubscribes
для получения подписок пользователя:
async getSubscribes(userId: number, page: number = 1, pageSize: number = 10, search: string) {
const offset = (page - 1) * pageSize;
console.log(search);
const subscribesWithPagination = await this.subscribeRepository.findAndCountAll({
where: { userId, isSubscribed: true, isApproved: true },
include: [
{
model: User,
as: 'subscribeUser',
attributes: ['id', 'userName', 'avatar'],
},
{
model: User,
as: 'companionUser',
attributes: ['id', 'userName', 'avatar'],
},
{
model: Room,
as: 'subscribeRoom',
attributes: ['id', 'name', 'roomAvatar', 'type'],
include: [{
model: Message,
limit: 1,
order: [['createdAt', 'DESC']],
attributes: ['id', 'owner', 'content', 'type', 'createdAt', 'updatedAt'],
include: [{
model: User,
as: 'ownerUser',
attributes: ['id', 'userName'],
}]
}]
},
],
limit: pageSize,
offset,
distinct: true
});
const totalPages = Math.ceil(subscribesWithPagination.count / pageSize);
return {
subscribes: subscribesWithPagination.rows,
page,
pageSize,
totalPages,
totalSubscribes: subscribesWithPagination.count
}
}
Метод banUser
для блокировки пользователя:
async banUser(
executor: AuthUser,
userId: number,
roomId: number,
banned: boolean
) {
const isPrivileged = executor['roles'].some(role => ['ADMIN', 'MODERATOR'].includes(role.value));
if(isPrivileged) return { data: await this.banUserRaw(userId, roomId, banned) };
const subscribeBanned = await this.getSubscribe(userId, roomId);
if(!subscribeBanned?.id) return { data: false, error: 'subscribeBanned'};
if(subscribeBanned.companionId === executor.sub) return { data: await this.banUserRaw(userId, roomId, banned) };
if(subscribeBanned.companionId === null) {
const subscribeExecutor = await this.getSubscribe(executor.sub, roomId);
if(!subscribeExecutor?.id) return { data: false, error: 'subscribeExecutor'};
if(
subscribeExecutor.userRole === SubscribeRole.owner ||
subscribeExecutor.userRole === SubscribeRole.admin ||
subscribeExecutor.userRole === SubscribeRole.moderator
) {
return { data: await this.banUserRaw(userId, roomId, banned) };
}
} else return { data: false, error: 'forbidden'};
}
Провайдер ChatGateway
ChatGateway
— это WebSocket-шлюз, который отвечает за обработку в реальном времени сообщений в чате, управление соединениями клиентов и их взаимодействие с комнатами в приложении.
Зависимости
ChatGateway
построен на следующих сервисах:
MessageService
для управления сообщениями.SubscribeService
для работы с подписками и комнатами.RoomService
для управления комнатами.UsersService
для управления пользователями и обновлением их статуса.
CORS
- Разрешены запросы с указанных клиентов, взятых из переменных окружения
WEB_CLIENT_URL
иAPP_CLIENT_URL
. - Используется атрибут
credentials: true
для передачи учетных данных в кросс-доменных запросах.
Основные методы
Все методы защищены с помощью WsJwtGuard
, что обеспечивает безопасность и проверку JWT-токена.
userOnlineEmit:
Метод для оповещения о статусе пользователя (онлайн/оффлайн) в определенной комнате. Обновляет время последнего появления пользователя с помощью usersService
и отправляет оповещение в соответствующую комнату.
handleJoinUserToAllRooms:
- Пользователь присоединяется ко всем своим подпискам и собственной комнате.
- Вызывает
userOnlineEmit
для отправки уведомления о входе.
Для примера, приведу код этого метода:
@UseGuards(WsJwtGuard)
@SubscribeMessage('joinUserToAllRooms')
async handleJoinUserToAllRooms(@MessageBody() data: { sessionId: string }, @ConnectedSocket() client: AuthSocket) {
try {
const roomList = await this.subscribeService.getAllSubscribesRoom(client.user.sub);
const selfRoom = 'u' + client.user.sub;
roomList.push(selfRoom);
await client.join(roomList);
this.userOnlineEmit(client, roomList, true, data.sessionId);
return { data: true };
} catch(e) {
console.error(e);
return {
data: false,
error: 'joinUserToAllRooms error'
};
}
}
handleJoinToUserRoom:
- Позволяет пользователю присоединиться к определенной пользовательской комнате.
- При успешном присоединении отправляет уведомление о статусе с помощью
userOnlineEmit
.
handleJoinToRoom:
- Метод предназначен для присоединения пользователя к определенной комнате.
- Проверяет уровень приватности комнаты через
roomService
и определяет доступ пользователя. - Если доступ разрешен, пользователь присоединяется к комнате, и вызывается метод
userOnlineEmit
для оповещения о его онлайн-статусе. - В случае отказа доступа возвращает сообщение об ошибке с информацией о запрете.
handleLeaveRoom:
- Метод отвечает за выход пользователя из комнаты.
- Оповещает о статусе пользователя (оффлайн) через метод
userOnlineEmit
. - Выполняет операцию выхода пользователя из комнаты и возвращает успешный результат. В случае ошибки возвращает сообщение об ошибке.
handleCallUser:
- Используется для определения статуса пользователя
- В случае успеха возвращает успешный ответ, или сообщение об ошибке.
handleUserOffline:
- Метод для оповещения всех комнат о том, что пользователь ушел в оффлайн.
- Использует userOnlineEmit, чтобы уведомить все комнаты, в которых состоял пользователь, о его уходе в оффлайн.
- Возвращает успешный ответ, или сообщение об ошибке.
handleTyping:
- Отправляет уведомление о статусе «печатает» в определенной комнате.
- Метод транслирует статус «печатает» пользователю через событие
broadcastTyping
, добавляя информацию о пользователе, времени, и комнате. - Возвращает успешный ответ, или сообщение об ошибке.
handleCreateMessage:
- Обрабатывает создание нового сообщения в чате.
- Принимает данные сообщения через DTO
MessageCreateDto
и объект сокетаAuthSocket
. - Вызывает
messageService
для сохранения нового сообщения в базе данных. - Если сообщение успешно создано, отправляет его всем пользователям в соответствующей комнате с помощью
client.broadcast
. - Обновляет данные о диалоге, если он был создан, и присоединяет клиента к комнате.
- Возвращает статус создания сообщения и данных о диалоге.
handleSubscribeRoom:
- Обрабатывает подписку пользователя на комнату.
- Принимает идентификатор комнаты и объект сокета
AuthSocket
. - Определяет, имеет ли пользователь привилегии администратора или модератора.
- Использует
subscribeService
для добавления пользователя в подписку на указанную комнату. - Если подписка успешно создана и это групповая комната, создаёт служебное сообщение о новом подписчике и отправляет его остальным пользователям в комнате.
- Возвращает статус успеха, или информацию об ошибке.
Для примера, приведу код этого метода:
@UseGuards(WsJwtGuard)
@SubscribeMessage('subscribeRoom')
async handleSubscribeRoom(@MessageBody() data: { roomId: number }, @ConnectedSocket() client: AuthSocket) {
const isPrivileged = client.user['roles'].some(role => ['ADMIN', 'MODERATOR'].includes(role.value));
const subscribeRoom = await this.subscribeService.subscribeRoom(client.user.sub, data.roomId, SubscribeRole.subscriber, isPrivileged);
if(subscribeRoom.data) {
if(subscribeRoom.roomType == RoomType.group) {
const savedMessage = await this.messageService.createMessage(
client.user.sub,
data.roomId.toString(),
'',
MessageType.newSubscribe,
null,
);
if(savedMessage.createdMessage) {
client.broadcast.to(data.roomId.toString()).emit('sendMessage', {
msgId: savedMessage.createdMessage.id,
roomId: savedMessage.createdMessage.room,
name: client.user.username,
date: savedMessage.createdMessage.createdAt,
type: MessageType.newSubscribe,
});
} else throw new WsException('Subscribe msg error');
}
return { data: true };
} else {
if(subscribeRoom?.error) throw new WsException(subscribeRoom.error);
if(subscribeRoom?.denied) return {
data: { denied: subscribeRoom.denied },
};
}
}
handleBanUser:
- Обрабатывает запрос на бан пользователя в указанной комнате.
- Принимает данные о пользователе, комнате и статусе бан через аргументы метода.
- Использует
subscribeService
для обновления статуса пользователя в комнате. - Возвращает результат операции либо выбрасывает исключение
WsException
, если возникает ошибка.
Проверка JWT-токена
Авторизация в JwtAuthGuard:
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Observable } from "rxjs";
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const req = context.switchToHttp().getRequest();
try {
const authHeader = req.headers.authorization;
const bearer = authHeader.split(' ')[0];
const token = authHeader.split(' ')[1];
if (bearer !== 'Bearer' || !token) {
throw new UnauthorizedException({ message: 'Пользователь не авторизован' });
}
const user = this.jwtService.verify(token);
req.user = user;
return true;
} catch (e) {
throw new UnauthorizedException({ message: 'Пользователь не авторизован' });
}
}
}
Проверка ролей в RolesGuard:
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
UnauthorizedException
} from "@nestjs/common";
import { Observable } from "rxjs";
import { JwtService } from "@nestjs/jwt";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "./roles-auth.decorator";
import { ConfigService } from '@nestjs/config';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
private configService: ConfigService
) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
try {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
])
if (!requiredRoles) {
return true;
}
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
const bearer = authHeader.split(' ')[0];
const token = authHeader.split(' ')[1];
if (bearer !== 'Bearer' || !token) {
throw new UnauthorizedException({message: 'Пользователь не авторизован'});
}
const tokenSecret = this.configService.get<string>('JWT_ACCESS_SECRET');
const user = this.jwtService.verify(token, { secret: tokenSecret });
request.user = user;
return user.roles.some(role => requiredRoles.includes(role.value));
} catch (e) {
console.log(e);
throw new HttpException('Нет доступа', HttpStatus.FORBIDDEN);
}
}
}
Вебсокеты в WsJwtGuard:
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Observable } from "rxjs";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from '@nestjs/config';
import { AuthSocket } from './messenger.interfaces';
@Injectable()
export class WsJwtGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private configService: ConfigService
) {
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const client: AuthSocket = context.switchToWs().getClient<AuthSocket>();
const token = client.handshake.headers.authorization;
if (!token) {
throw new UnauthorizedException({ message: 'Token not found' });
}
const user = this.validateToken(token);
if (!user) {
throw new UnauthorizedException({ message: 'Invalid token' });
}
client.user = user;
return true;
}
validateToken(authHeader: string): any {
try {
const bearer = authHeader.split(' ')[0];
const token = authHeader.split(' ')[1];
if (bearer !== 'Bearer' || !token) {
throw new UnauthorizedException({ message: 'User is not authorized' });
}
const tokenSecret = this.configService.get<string>('JWT_ACCESS_SECRET');
const user = this.jwtService.verify(token, { secret: tokenSecret });
return user;
} catch (e) {
throw new UnauthorizedException({ message: 'User is not authorized' });
}
}
}
Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru
Кроме того, автор проекта ищет работу. Мое резюме.