all

Установка PgAdmin c использованием Docker и Traefik

В этой статье мы рассмотрим настройку и запуск PgAdmin в контейнере Docker. PgAdmin — удобный инструмент для управления базами данных PostgreSQL, и в некоторых случаях может быть полезным иметь его у себя на сервере. Также мы интегрируем реверс-прокси Traefik для управления SSL-сертификатами, а так же для реализации базовой HTTP-аутентификации в целях ограничения доступа к веб-интерфейсу PgAdmin.

Конфигурация успешно используется на нашем сервере.

Предварительные требования

Перед началом убедитесь, что у вас установлены следующие компоненты:

Так же вам необходимо настроить поддомен https://pgadmin.<your_domain>

Создание файла Docker Compose

Создайте файл docker-compose.yml и вставьте в него следующий код:

YAML
services:
  pgadmin:
    container_name: pgadmin
    image: dpage/pgadmin4:8.11.0
    restart: unless-stopped
    networks:
      - proxynet
    env_file:
      - /data/secrets/${SERVER_DOMAIN}/${SERVER_DOMAIN}.env
    environment:
       PGADMIN_CONFIG_SERVER_MODE: 'False'
       PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
       PGADMIN_DEFAULT_EMAIL: ${ADMIN_EMAIL}
    labels:
       - "traefik.enable=true"
       - "traefik.http.routers.pgadmin.rule=Host(`pgadmin.${SERVER_DOMAIN}`)"
       - "traefik.http.routers.pgadmin.middlewares=pgadmin-auth"
       - "traefik.http.middlewares.pgadmin-auth.basicauth.usersfile=/httpauth/usersfile.htpasswd"
    volumes:
      - /data/secrets/${SERVER_DOMAIN}/httpauth:/httpauth
      - /data/appdata/pgadmin:/var/lib/pgadmin
networks:
  proxynet:
    external: true

Разбор конфигурации

  • services: — Раздел, где определяются различные сервисы. В нашем случае это один сервис — pgadmin.
  • pgadmin: — Имя сервиса. Так будет называться контейнер.
  • container_name: pgadmin — Устанавливает имя контейнера в Docker.
  • image: dpage/pgadmin4:8.11.0 — Используемый образ контейнера. В данном случае это PgAdmin версии 8.11.0.
  • restart: unless-stopped — Политика перезапуска контейнера. Контейнер будет перезапущен, если остановится из-за сбоев, но не будет перезапущен, если его остановить вручную.
  • networks: — Указание сетей, к которым подключен контейнер. Здесь используется сеть proxynet.
  • env_file: — Указание файла с переменными окружения. Этот файл должен содержать переменные, такие как имя сервера и домен сервера.
  • environment: — Переменные окружения для настройки PgAdmin.
  • PGADMIN_CONFIG_SERVER_MODE: 'False' — Отключает режим сервера (многопользовательский), позволяя запускать PgAdmin в режиме рабочего стола (однопользовательский). Мы устанавливаем для него значение False, поэтому не будут запрашиваться учетные данные для входа. Вместо этого используется базовая HTTP аутентификация.
  • PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' — Позволяет обойти запрашивание мастер-пароля.
  • PGADMIN_DEFAULT_EMAIL: ${ADMIN_EMAIL} — Устанавливает адрес электронной почты администратора, который берется из переменных окружения.
  • labels: — Используются для конфигурации Traefik, который выступает обратным прокси-сервером.
  • traefik.enable=true — Включает Traefik для данного сервиса.
  • traefik.http.routers.pgadmin.rule=Host(pgadmin.${SERVER_DOMAIN}) — Устанавливает правила маршрутизации по домену.
  • traefik.http.routers.pgadmin.middlewares=pgadmin-auth — Указывает использовать middleware для аутентификации.
  • traefik.http.middlewares.pgadmin-auth.basicauth.usersfile=/httpauth/usersfile.htpasswd — Указывает файл с пользователями для базовой HTTP-аутентификации.
  • volumes: — Раздел для монтирования внешних папок в контейнер.
  • /data/secrets/${SERVER_DOMAIN}/httpauth:/httpauth — Монтирует директорию с конфигом для HTTP-аутентификации.
  • /data/appdata/pgadmin:/var/lib/pgadmin — Монтирует директорию для хранения данных PgAdmin.
  • networks: — Определение используемых сетей. Сеть proxynet задана как внешняя.

Подготовка окружения

У нас на сервере используется специально разработанная система скриптов для автоматизации установки. Эти скрипты по большей части опубликованы в нашем репозитории и описаны в отдельной статье. В нашем случае, вместе с этим docker-compose.yml идет Bash скрипт для инициализации всех необходимых параметров:

Bash
#!/bin/bash

if [ "$(id -u)" != "0" ]; then
    echo -e "\033[31mThis script requires superuser rights.\033[0m"
    exit 0
fi

trap 'echo -e "\033[31minstall.sh: Something went wrong\033[0m"; exit 1' ERR
set -e

export DEBIAN_FRONTEND=noninteractive

echo "Install pgadmin..."
mkdir -p /data/appdata/pgadmin
chown -R 5050:5050 /data/appdata/pgadmin
cd /data/utils
sudo bash env-gen.sh PGADMIN_DEFAULT_PASSWORD
cd /data/pgadmin
sudo docker compose up -d

trap - ERR
echo "Install pgadmin complete"

В начале мы создаем директорию данных для PgAdmin и устанавливаем ей необходимые права доступа:

Bash
mkdir -p /data/appdata/pgadmin
chown -R 5050:5050 /data/appdata/pgadmin


В своем случае, вы можете выполнить эти команды вручную.

Далее нужно инициализировать переменные окружения:

Bash
sudo bash env-gen.sh PGADMIN_DEFAULT_PASSWORD


На нашем сервере используется специализированный скрипт env-gen.sh из репозитория utils, который добавляет записи в .env файл. Но вы так же можете вручную создать файл ${SERVER_DOMAIN}.env в директории /data/secrets/${SERVER_DOMAIN}/ и заполнить его необходимыми значениями:

PGADMIN_DEFAULT_PASSWORD=your_default_password

Замените your_default_password на свое значение.

Так же для работы потребуется указать глобальные переменные сервера PGADMIN_DEFAULT_EMAIL и SERVER_DOMAIN. Для этих целей у нас в репозитории так же есть специальный скрипт. Например, можно сделать так:

Bash
cd /data/utils
bash global-env.sh PGADMIN_DEFAULT_EMAIL admin@checkerwars.com
bash global-env.sh SERVER_DOMAIN checkerwars.com


Так же необходимо настроить HTTP-аутентификацию. В нашей конфигурации файл, в котором хранятся акаунты для входа находится по пути /data/secrets/${SERVER_DOMAIN}/httpauth/usersfile.htpasswd. В него необходимо добавить строчку вида admin:passoword_hash, генерация которой описана в этой статье. Но например, для генерации вы так же можете использовать сторонний сервис вроде этого.

Запуск сервиса

Откройте терминал, перейдите в директорию с файлом docker-compose.yml и выполните команду:

docker-compose up -d

Эта команда загрузит образ PgAdmin, создаст и запустит контейнер в фоновом режиме. После успешного запуска вы сможете пользоваться PgAdmin через веб-браузер, набрав https://pgadmin.<your_domain>. Для доступа вам потребуется ввести логин и пароль, сконфигурированные ранее.

Репозиторий этой конфигурации можно найти здесь.


Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.

Готовый скрипт для установки Docker на Ubuntu 24.04 LTS

В этом руководстве мы разберем Bash-скрипт, который автоматизирует процесс установки Docker на дистрибутиве Ubuntu 24.04 LTS.

Проверка прав доступа

Для начала проверим, запущен ли скрипт с правами суперпользователя:

Bash
if [ "$(id -u)" != "0" ]; then
    echo -e "\033[31mThis script requires superuser rights.\033[0m"
    exit 0
fi

  • id -u возвращает идентификатор текущего пользователя. 0 соответствует пользователю root.
  • Если текущий пользователь не root, скрипт выводит сообщение об ошибке и завершает выполнение.

Настройка обработки ошибок

Bash
trap 'echo -e "\033[31minit-docker.sh: Something went wrong\033[0m"; exit 1' ERR
set -e


Здесь используется команда trap, чтобы установить обработчик ошибок, который будет активироваться при любой ошибке выполнения команд (ERR).

  • trap позволяет выполнить специальные команды при возникновении ошибок.
  • set -e завершает скрипт при возникновении любой ошибки, делая скрипт более устойчивым.

Отключение интерактивного режима

Bash
export DEBIAN_FRONTEND=noninteractive


Переменная окружения DEBIAN_FRONTEND устанавливается в значение noninteractive, чтобы установка не требовала пользовательского ввода. Это полезно для автоматического скрипта, при запуске которого не будет возможности взаимодействия с пользователем.

Установка зависимостей и Docker

Этот код отвечает за установку необходимых пакетов и самого Docker:

Bash
apt-get install -y apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/docker.gpg
add-apt-repository -y "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"
apt-get update -y
apt-get install -y docker-ce

  1. Установка зависимостей:
  • apt-transport-https — поддержка HTTPS для репозиториев.
  • ca-certificates — работа с сертификатами SSL.
  • curl — для получения данных из URL.
  • software-properties-common — добавление новых репозиториев.
  1. Импорт GPG-ключа Docker: скачивается ключ GPG и добавляется в систему, что необходимо для проверки пакетов Docker.
  2. Добавляется репозиторий Docker для Ubuntu.
  3. Обновляется список пакетов APT и устанавливается Docker CE (Community Edition).

Создание сети Docker

После успешной установки Docker создается сеть Docker proxynet. Это может быть полезно для изоляции контейнеров и обеспечения внутренней связи:

Bash
echo "Creating docker network proxynet..."
docker network create proxynet

Завершение

В завершении, снимаем обработчик ошибок и выводим сообщение о том, что Docker успешно установлен:

Bash
trap - ERR
echo "Docker installed"

Полный код скрипта:

Bash
#!/bin/bash

if [ "$(id -u)" != "0" ]; then
    echo -e "\033[31mThis script requires superuser rights.\033[0m"
    exit 0
fi

trap 'echo -e "\033[31minit-docker.sh: Something went wrong\033[0m"; exit 1' ERR
set -e

export DEBIAN_FRONTEND=noninteractive

apt-get install -y apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/docker.gpg
add-apt-repository -y "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"
apt-get update -y
apt-get install -y docker-ce

echo "Creating docker network proxynet..."
docker network create proxynet

trap - ERR
echo "Docker installed"


Используя данный скрипт, вы легко установите Docker без необходимости вручную проходить каждый из этапов.

Данный скрипт является частью нашего репозитория utils

Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.

Конфигурация Docker для Vue.js приложения в сети Traefik

Данная конфигурация представляет собой полный цикл разработки и развертывания веб-приложения Quasar Vue.js, начиная от написания кода и заканчивая его публикацией.

Приложение упаковано в Docker контейнер, настроенный на работу с реверс прокси Traefik. Имеется два режима запуска проекта — developer и production.

Конфигурация используется в нашем приложении Messenger.

Ниже приведем код docker-compose.yml (production):

YAML
services:
  messenger-frontend:
    container_name: messenger-frontend
    build:
      context: .
      target: 'production-stage'
      args:
        VITE_APP_VERSION: 0.3
        VITE_CLIENT_URL: https://messenger.${SERVER_DOMAIN}
        VITE_API_URL: https://msg.${SERVER_DOMAIN}
    environment:
      NODE_ENV: production
    restart: unless-stopped
    networks:
      - proxynet
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.msgfe.rule=Host(`messenger.${SERVER_DOMAIN}`)"
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
networks:
  proxynet:
    external: true


В production осуществляется полная сборка билда и последующая его упаковка в контейнер с Nginx веб-сервером.


Для development так же есть свой docker-compose.dev.yml:

YAML
services:
  messenger-frontend-dev:
    container_name: messenger-frontend-dev
    build:
      context: .
      target: 'develop-stage'
    environment:
      NODE_ENV: development
      PORT: 9000
      VITE_APP_VERSION: 0.3
      VITE_CLIENT_URL: https://messenger.${SERVER_DOMAIN}
      VITE_API_URL: https://msg.${SERVER_DOMAIN}
    volumes:
      - .:/app:rw
    command: sh -c "npm install && quasar dev"
    restart: unless-stopped
    networks:
      - proxynet
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.msgfe.rule=Host(`messenger.${SERVER_DOMAIN}`)"
      - "traefik.http.routers.msgfe.entrypoints=https"
      - "traefik.http.routers.msgfe.service=msgfe-service"
      - "traefik.http.services.msgfe-service.loadbalancer.server.port=9000"
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
networks:
  proxynet:
    external: true


  • В development режиме клиент запускается командой quasar dev и используется сервер Node.js. Этот режим позволяет динамически отслеживать изменения в коде, что бы сразу отображать их в браузере.
  • В volumes добавляется .:/app:rw, что позволяет в процессе разработки динамически обновлять файлы проекта в контейнере.
  • command: sh -c "npm install && quasar dev" — здесь, npm install необходимо указать для того, что бы дирекотория node_modules была доступна для отслеживания зависимостей в VS Code.

Dockerfile

Эта конфигурация Dockerfile описывает многоэтапную сборку образа для Quasar Vue.js приложения:

Dockerfile

# develop stage
FROM node:22.8.0-alpine3.20 AS develop-stage
WORKDIR /app
COPY package*.json ./
RUN npm config set fund false --location=global
RUN npm install -g @quasar/cli
COPY . .


# build stage
FROM develop-stage AS build-stage
RUN npm install

ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}

ARG VITE_APP_VERSION
ENV VITE_APP_VERSION=${VITE_APP_VERSION}

ARG VITE_CLIENT_URL
ENV VITE_CLIENT_URL=${VITE_CLIENT_URL}

#RUN quasar build
RUN quasar build -m pwa


# production stage
FROM nginx:1.27.1-alpine3.20 AS production-stage
WORKDIR /app
COPY --from=build-stage /app/dist/pwa /usr/share/nginx/html
# COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80


Он состоит из трёх основных этапов:

  1. Разработка (develop stage):
  • Базовый образ: Используется образ node:22.8.0-alpine3.20, что обеспечивает легковесную среду на основе Alpine Linux для работы с Node.js.
  • Рабочий каталог: Устанавливается /app как основной рабочий каталог для всех последующих команд.
  • Зависимости: Копируются файлы package*.json для последующей установки необходимых зависимостей.
  • Настройки npm: Отключает спам в консоли о финансировании (fund) пакетов в NPM.
  • Установка Quasar CLI: Глобально устанавливается Quasar CLI для возможности управления приложением Quasar.
  • Копирование приложения: Все исходные файлы приложения копируются в контейнер.
  1. Сборка (build stage):
  • Исходный образ: Используется ранее созданный develop-stage как основа.
  • Зависимости: Выполняется установка всех зависимостей приложения.
  • Переменные окружения: Аргументы и переменные окружения (VITE_API_URL, VITE_APP_VERSION, VITE_CLIENT_URL) передаются в окружение сборки для использования в конфигурациях.
  • Сборка приложения: Приложение собирается в режиме PWA при помощи Quasar CLI.
  1. Продакшн (production stage):
  • Базовый образ: Используется образ nginx:1.27.1-alpine3.20, что обеспечивает легковесную и производительную среду для развертывания статических файлов.
  • Рабочий каталог: Устанавливается /app как основной рабочий каталог.
  • Копирование файлов сборки: Скомпилированные файлы PWA копируются в каталог Nginx /usr/share/nginx/html для обслуживания.
  • Конфигурация Nginx: В комментарии указана возможность изменения конфигурации Nginx, что позволяет настраивать его работу при необходимости.
  • Открытие порта: Контейнер будет прослушивать стандартный 80 порт (в дальнейшем он будет перенаправлен через реверс-прокси Traefik, который позволит управлять SSL сертификатами для обеспечения соединения HTTPS).



Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.

Конфигурация Docker для Nest.js приложения в сети Traefik

Данная конфигурация представляет собой полный цикл разработки и развертывания серверного Nest.js приложения, начиная от написания кода и заканчивая его публикацией.

Сервер упакован в Docker контейнер, настроенный на работу с реверс прокси Traefik. Имеется два режима запуска проекта — developer и production.

Конфигурация используется в нашем приложении Messenger.

Ниже приведем код docker-compose.yml (production):

YAML
services:
  messenger-backend:
    container_name: messenger-backend
    build:
      context: .
      target: 'production-stage'
    env_file:
      - /data/secrets/${SERVER_DOMAIN}/messenger-backend/app.env
      - /data/secrets/${SERVER_DOMAIN}/messenger-backend/static.env
    environment:
      PORT: 5000
      NODE_ENV: production

      POSTGRES_HOST: postgres
      POSTGRES_USER: messenger
      POSTGRES_DB: messenger
      POSTGRES_PORT: 5432

      WEB_CLIENT_URL: https://messenger.${SERVER_DOMAIN}
      APP_CLIENT_URL: https://localhost
      API_URL: https://msg.${SERVER_DOMAIN}

      ACCESS_TOKEN_MAX_AGE: 60d
      REFRESH_TOKEN_MAX_AGE: 15m
      COOKIE_REFRESH_TOKEN_MAX_AGE: 6048000000
    volumes:
      - /data/appdata/messenger:/app/static/user
    command: ["node", "dist/main.js"]
    restart: unless-stopped
    networks:
      - proxynet
    labels: 
      - "traefik.enable=true"
      - "traefik.http.routers.msgbe.rule=Host(`msg.${SERVER_DOMAIN}`)"
      - "traefik.http.routers.msgbe.entrypoints=https"
      - "traefik.http.routers.msgbe.service=msgbe-service"
      - "traefik.http.services.msgbe-service.loadbalancer.server.port=5000"
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
networks:
  proxynet:
    external: true
    


Для development так же есть свой docker-compose.dev.yml:

YAML
services:
  messenger-backend-dev:
    container_name: messenger-backend-dev
    build:
      context: .
      target: 'develop-stage'
    env_file:
      - /data/secrets/${SERVER_DOMAIN}/messenger-backend/app.env
      - /data/secrets/${SERVER_DOMAIN}/messenger-backend/static.env
    environment:
      PORT: 5000
      NODE_ENV: development

      POSTGRES_HOST: postgres
      POSTGRES_USER: messenger
      POSTGRES_DB: messenger
      POSTGRES_PORT: 5432

      WEB_CLIENT_URL: https://messenger.${SERVER_DOMAIN}
      APP_CLIENT_URL: https://localhost
      API_URL: https://msg.${SERVER_DOMAIN}

      ACCESS_TOKEN_MAX_AGE: 365d
      REFRESH_TOKEN_MAX_AGE: 365d
      COOKIE_REFRESH_TOKEN_MAX_AGE: 6048000000
    volumes:
      - .:/app:rw
      - /data/appdata/messenger:/app/static/user
    command: sh -c "npm install && npm run start:dev"
    restart: unless-stopped
    networks:
      - proxynet
    labels: 
      - "traefik.enable=true"
      - "traefik.http.routers.msgbe.rule=Host(`msg.${SERVER_DOMAIN}`)"
      - "traefik.http.routers.msgbe.entrypoints=https"
      - "traefik.http.routers.msgbe.service=msgbe-service"
      - "traefik.http.services.msgbe-service.loadbalancer.server.port=5000"
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
networks:
  proxynet:
    external: true
    


Его содержимое похоже на конфигурацию production, но есть несколько важных отличий:

  • target: 'develop-stage'
  • В volumes добавляется еще .:/app:rw, что позволяет в процессе разработки динамически обновлять файлы проекта в контейнере
  • command: sh -c "npm install && npm run start:dev" — здесь, npm install необходимо указать для того, что бы дирекотория node_modules была доступна для отслеживания зависимостей в VS Code.

Dockerfile

Эта конфигурация Dockerfile описывает многоэтапную сборку образа для Node.js приложения:

Dockerfile
# develop stage
FROM node:22.8.0-alpine3.20 AS develop-stage
WORKDIR /app
COPY package*.json ./
RUN npm config set fund false --location=global
COPY . .


# build stage
FROM develop-stage AS build-stage
RUN npm install
RUN npm run build


# production stage
FROM node:22.8.0-alpine3.20 AS production-stage
WORKDIR /app
COPY --from=build-stage /app/node_modules /app/node_modules
COPY --from=build-stage /app/dist /app/dist
COPY --from=build-stage /app/static /app/static
COPY package.json ./
CMD ["node", "dist/main.js"]
EXPOSE 5000


Он состоит из трёх основных этапов:

  1. Разработка (develop stage):
  • Базируется на Alpine-образе Node.js для его легковесности и эффективности.
  • Устанавливает рабочую директорию /app.
  • Копирует файлы package.json и package-lock.json (package*.json для большей гибкости).
  • Отключает спам в консоли о финансировании (fund) пакетов в NPM.
  • Копирует все остальные файлы приложения в контейнер.
  1. Сборка (build stage):
  • Использует промежуточный образ develop-stage как основу.
  • Устанавливает зависимости с помощью npm install.
  • Запускает процесс сборки приложения с помощью npm run build, формируя конечную сборку в директории dist.
  1. Продакшн (production stage):
  • Начинается с чистого Node.js Alpine-образа той же версии.
  • Устанавливает рабочую директорию /app.
  • Копирует директорию node_modules из build-stage для использования в продакшене.
  • Копирует директории dist и static из build-stage, содержащие скомпилированные и статические файлы приложения.
  • Копирует package.json.
  • Определяет, что при запуске контейнера должен исполняться файл dist/main.js через Node.js.
  • Открывает 5000 порт (в дальнейшем он будет перенаправлен через реверс-прокси Traefik, который позволит управлять SSL сертификатами для обеспечения соединения HTTPS).


Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.

Установка Traefik с использованием Docker

Traefik — современный прокси-сервер и балансировщик нагрузки, с помощью которого можно легко управлять маршрутизацией трафика в приложениях, работающих внутри Docker контейнеров.

Конфигурация успешно используется в нашем приложении Messenger

YAML
services:
  traefik:
    container_name: traefik
    image: traefik:v3.1.2
    restart: unless-stopped
    env_file:
      - /data/secrets/${SERVER_DOMAIN}/${SERVER_DOMAIN}.env
    command: 
     - "--providers.file.filename=/traefik/certs.yml"
     - "--providers.docker.network=proxynet"
     - "--api.insecure=false"
     - "--api.dashboard=true" 
     - "--providers.docker"
     - "--log=true"
     - "--log.level=${TRAEFIK_LOG_LEVEL}"
     - "--providers.docker.exposedByDefault=false"
    #Entrypoints:
     - "--entrypoints.http.address=:80"
     - "--entrypoints.https.address=:443"
     - "--entrypoints.postgres.address=:5432"
     - "--entrypoints.mariadb.address=:3306"
     - "--entrypoints.http.http.redirections.entrypoint.to=https"
     - "--entrypoints.http.http.redirections.entrypoint.scheme=https"
    #SSL Let's Encrypt:
     - "--entrypoints.https.http.tls.certResolver=le"
     - "--certificatesresolvers.le.acme.tlschallenge=true"
     - "--certificatesresolvers.le.acme.email=${ADMIN_EMAIL}"
     - "--certificatesresolvers.le.acme.storage=/letsencrypt/${SERVER_DOMAIN}.acme.json"
     #Dashboard secure:
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.${SERVER_DOMAIN}`)"
      - "traefik.http.routers.dashboard.entrypoints=https"
      - "traefik.http.routers.dashboard.tls=true"
      - "traefik.http.routers.dashboard.tls.certresolver=le"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=auth"
      - "traefik.http.middlewares.auth.basicauth.usersfile=/httpauth/usersfile.htpasswd"
    ports:
      - "80:80"
      - "443:443"
      - "5432:5432"
      - "3306:3306"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /data/secrets/${SERVER_DOMAIN}/letsencrypt:/letsencrypt
      - /data/secrets/${SERVER_DOMAIN}/httpauth:/httpauth
      - /data/traefik/certs.yml:/traefik/certs.yml
      - /data/secrets/${SERVER_DOMAIN}/selfsigned:/selfsigned
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
networks:
  default:
    name: proxynet
    external: true

Основные моменты

  1. Образ и контейнер:
  • Мы используем образ traefik:v3.1.2 и настраиваем его так, чтобы он автоматически запускался вместе с системой (restart: unless-stopped).
  • Переменные окружения загружаются из файла .env.
  1. Командная строка:
  • Traefik конфигурируется через параметры командной строки. Важные параметры включают указание файлов для хранения конфигурации сертификатов и определение сетевой среды Docker, в которой будет работать Traefik.
  1. Точки входа:
  • Точки входа определяют порты, на которые будет поступать входящий трафик (http, https, postgres и mariadb). Также обеспечивается редирект HTTP на HTTPS для повышения безопасности.
  1. SSL и Let’s Encrypt:
  • Включена поддержка автоматической выдачи SSL-сертификатов Let’s Encrypt через certResolver. Это позволяет обеспечивать безопасное соединение с использованием TLS.
  1. Панель управления (Dashboard):
  • Traefik предоставляет веб-интерфейс для мониторинга и управления (dashboard). Он защищён с помощью HTTP-базовой аутентификации, данные для которой хранятся в файле usersfile.htpasswd.
  1. Монтирование томов:
  • Конфигурация involves монтирование различных ресурсов и секретов в контейнер Traefik. Это включает сокет Docker для интеграции с контейнерами, данные для сертификатов и аутентификации.
  1. Сетевые настройки:
  • Используемая сеть называется proxynet и она должна быть внешней для обеспечения корректной работы Traefik с другими контейнерами.
  1. Логирование:
  • Происходит настройка логирования через json-file с ограничением размера файла в 1 MB, что помогает управлять объемом логов и упрощает диагностику.

Репозиторий этой конфигурации можно найти здесь.


Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.

Установка PostgreSQL с использованием Docker и Traefik

В этой статье мы рассмотрим, как запустить и настроить экземпляр базы данных PostgreSQL с помощью Docker. Также мы интегрируем Traefik для проксирования соединений.

Конфигурация успешно используется в нашем приложении Messenger

Предварительные требования

Перед началом убедитесь, что у вас установлены следующие компоненты:

Создать файл docker-compose.yml

Создайте файл docker-compose.yml и вставьте в него следующий код:

YAML
services:
  postgres:
    container_name: postgres
    image: postgres:16.4-alpine3.20
    restart: unless-stopped
    networks:
      - proxynet
    env_file:
      - /data/secrets/${SERVER_DOMAIN}/${SERVER_DOMAIN}.env
    environment:
      PG_DATA: /var/lib/postgresql/data
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - /data/appdata/pgdata:/var/lib/postgresql/data
    labels:
      - "traefik.enable=true"
      - "traefik.tcp.routers.postgresql.rule=HostSNI(*)"
      - "traefik.tcp.services.postgresql.loadbalancer.server.port=5432"
      - "traefik.tcp.routers.postgresql.entrypoints=postgres"
networks:
  proxynet:
    external: true

Разбор конфигурации

  • services.postgres: Конфигурация для контейнера PostgreSQL.
  • container_name: Устанавливает имя контейнера в Docker.
  • image: Используется образ postgres:16.4-alpine3.20, который является более легким вариантом на базе Alpine Linux.
  • restart: Политика перезапуска unless-stopped гарантирует, что контейнер автоматически перезапустится в случае сбоев.
  • networks: Подключение к внешней сети proxynet, которая может использоваться Traefik.
  • env_file: Переменные окружения загружаются из файла, что позволяет хранить конфиденциальные данные вне кода.
  • environment: Переменные окружения, такие как POSTGRES_USER и POSTGRES_DB, определяют пользователя и базу данных по умолчанию.
  • volumes: Монтирует локальную директорию /data/appdata/pgdata в контейнер для постоянного хранения данных.
  • labels: Настройка Traefik для проксирования TCP соединений к PostgreSQL.
  • networks.proxynet: Используется для подключения к существующей сети Traefik.

Подготовка окружения

У нас на сервере используется специально разработанная система скриптов для автоматизации установки. Эти скрипты по большей части опубликованы в нашем репозитории и описаны в отдельной статье. В нашем случае, вместе с этим docker-compose.yml идет Bash скрипт для инициализации всех необходимых параметров:

Bash
#!/bin/bash

if [ "$(id -u)" != "0" ]; then
    echo -e "\033[31mThis script requires superuser rights.\033[0m"
    exit 0
fi

trap 'echo -e "\033[31minstall.sh: Something went wrong\033[0m"; exit 1' ERR
set -e

export DEBIAN_FRONTEND=noninteractive

echo "Install postgres..."
cd /data/utils
bash global-env.sh POSTGRES_USER admin
bash global-env.sh POSTGRES_DB postgres
sudo bash env-gen.sh POSTGRES_PASSWORD
cd /data/postgres
sudo docker compose up -d

trap - ERR
echo "Install postgres complete"


Этот скрипт инициализирует глобальные переменные окружения сервера:

Bash
cd /data/utils
bash global-env.sh POSTGRES_USER admin
bash global-env.sh POSTGRES_DB postgres


На нашем сервере используется специализированный скрипт global-env.sh из репозитория utils, который добавляет переменные окружения на сервер глобально.

Если переменная SERVER_DOMAIN не была инициализирована ранее, ее так же можно добавить в глобальные переменные сервера:

Bash
bash global-env.sh SERVER_DOMAIN checkerwars.com


Затем добавляется переменная в .env файл, для чего используется специализированный скрипт env-gen.sh из того же репозитория utils:

Bash
sudo bash env-gen.sh POSTGRES_PASSWORD


Но вы так же можете вручную создать файл ${SERVER_DOMAIN}.env в директории /data/secrets/${SERVER_DOMAIN}/ и заполнить его необходимыми значениями:

POSTGRES_PASSWORD=your_postgres_password

Замените your_postgres_password на свое значение.


В конце производится запуск Docker контейнера. Перейдите в директорию с файлом docker-compose.yml и выполните:

Bash
cd /data/postgres
sudo docker compose up -d


Эта команда загрузит образ PostgreSQL, создаст и запустит контейнер в фоновом режиме.

Проверка конфигурации

После запуска контейнера вы можете проверить работу сервиса, подключившись к базе данных. Например, используйте команду ниже, чтобы войти в контейнер и подключиться к PostgreSQL:

docker exec -it postgres psql -U your_postgres_user -d your_postgres_db

Замените your_postgres_user и your_postgres_db на ваши значения.


Репозиторий этой конфигурации можно найти здесь.


Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.

Messenger. Технический обзор клиента (Vue.js)

В этом материале я опишу общую архитектуру и некоторые детали реализации клиентской части Messenger. Обзор будет не слишком подробным, т.к если пытаться описать каждый компонент приложения, статья напротив, получится очень объемной. В целом, принципы реализации от компонента к компоненту схожи. Но в случаях, когда есть на что обратить особое внимание, я постараюсь раскрыть детали подробнее.

Клиент платформы CheckerWars написан на фреймворке Vue.js с использованием Quasar Framework. Первоначально разработка велась на чистом Javascript, но в последствии было принято решение переписать все на Typescript — что в значительной степени уже сделано. Кроме того, я так же принял решение отказаться от Qusasr, часть страниц, компонентов уже используют чистый Vue.js с Typescript.

Так же есть подробный обзор сервера, который написан на Nest.js:

Но продолжим разбираться с клиентом на Vue.js.

При разработке клиентского приложения CheckerWars я обращаю серьезное внимание на оптимизацию зависимостей. В конечном итоге, я хочу минимизировать использование внешних зависимостей, что бы иметь максимальный контроль над кодом и функционалом, а так же не загромождать код тяжеловесными и избыточными во многих случаях библиотеками, вроде Moment.js.

Основные моменты:

  • Используется фреймворк Vue.js 3
  • Преимущественно используется Options API
  • Код пишется на Typescript
  • Интерфейс во многом сделан с использованием Quasar framework (но в процессе отказа от него)
  • Используется хранилище Pinia
  • Передача данных в реальном времени осуществляется при помощи библиотеки Socket.io
  • Версия PWA (средствами Quasar framework)
  • Версия под Android с использованием Capacitor (средствами Quasar framework)
  • Клиент, как и сервер, упакован в Docker контейнер. Имеется два режима запуска проекта — developer и production.

В статье по ссылке ниже, приведена конфигурация Docker для этого приложения:


Роутер

Данная структура маршрутизации в проекте Vue.js определяет различные пути и их соответствующие компоненты для рендера. Ниже приведем краткое описание каждого маршрута:

Главный файл маршрута

Это основная конфигурация роутера, в нее подключаются маршруты для вложенных страниц (файлы маршрутов размещены в папках модулей проекта):

Главная страница

  • Путь: '/'
  • Компонент: HelloPage.vue
  • Описание: Отображает статичную страницу приветствия

Приложение

  • Путь: '/app'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: app
  • Описание: Второстепенные страницы приложения, такие как «О проекте»

Мессенджер

  • Путь: '/im'
  • Компонент: ImLayout.vue
  • Вложенные маршруты: messenger
  • Описание: Страницы мессенджера

Пользователи

  • Путь: '/users'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: users
  • Описание: Страницы для управления и просмотра информации о пользователях.

Аккаунт

  • Путь: '/account'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: account
  • Описание: Управление настройками учетной записи.

Профиль

  • Путь: '/profile'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: profile
  • Описание: Просмотр и редактирование профиля пользователя.

Друзья

  • Путь: '/friends'
  • Компонент: AppLayout.vue
  • Вложенные маршруты: friends
  • Описание: Взаимодействие с друзьями пользователя.

Аутентификация

  • Путь: '/auth'
  • Компонент: NLayout.vue
  • Вложенные маршруты: auth
  • Описание: Управление входом и регистрацией пользователей.

Кроме того, есть маршрут /:catchAll(.*)* который перехватывает неопознанные страницы, и переадресовавает запросы на страницу ErrorNotFound.vue.

Этот список описывает, как различные части приложения ассоциируются с определенными путями на уровне маршрутизации, обеспечивая организацию и доступ к различным функциональным областям приложения.

Маршруты для Messenger:

Главная страница подписки

  • Путь: ''
  • Компонент: SubscribePage
  • Описание: Страница друзей и подписок пользователя

Страница чата

  • Путь: ':idMixed'
  • Компонент: ChatPage
  • Описание: Страница с чатом

Страница создания комнаты

  • Путь: 'create/:type'
  • Компонент: RoomCreate
  • Описание: Страница с формой создания новой коснаты (группа, канал)

Редактирование аватара

  • Путь: 'avatar/:roomId'
  • Компонент: AvatarEdit
  • Описание: Страница с формой для редакирования аватара

Страница комнаты

  • Путь: 'room/:pageId'
  • Компонент: RoomPage
  • Описание: Страница с описанием комнаты (группа, канал)

Редактирование комнаты

  • Путь: 'room-edit/:pageId'
  • Компонент: RoomEdit
  • Описание: Страница редактирования коснаты (группы, канала)

Поиск комнат

  • Путь: 'rooms/:type'
  • Компонент: RoomSearch
  • Описание: Страница со списком и формой поиска комнат (групп, каналы)

Маршруты для Account

Получение информации об аккаунте

  • Путь: /:userId
  • Компонент: GetAccount.vue
  • Описание: Отображает информацию о пользователе и загружает соответствующий компонент

Редактирование никнейма

  • Путь: /nickname/:userId
  • Компонент: EditNickname.vue
  • Описание: Позволяет изменить никнейм пользователя

Редактирование email

  • Путь: /email/:userId
  • Компонент: EditEmail.vue
  • Описание: Позволяет пользователю изменить свой email

Изменение пароля

  • Путь: /password/:userId
  • Компонент: EditPassword.vue
  • Описание: Предоставляет функциональность смены пароля пользователя

Редактирование аватара

  • Путь: /avatar/:userId
  • Компонент: EditAvatar.vue
  • Описание: Позволяет пользователю изменить аватар

Просмотр сессий

  • Путь: /sessions/:userId
  • Компонент: GetSessions.vue
  • Описание: Отображает активные сессии пользователя

Маршруты для Profile

Основной маршрут

  • Путь: /:userId
  • Компонент: GetProfile.vue
  • Описание: Отображение профиля конкретного пользователя

Редактирование имени

  • Путь: name/:userId
  • Компонент: EditName.vue
  • Описание: Интерфейс для изменения имени пользователя

Редактирование даты рождения

  • Путь: birthday/:userId
  • Компонент: EditBirthday.vue
  • Описание: Изменение данных о дате рождения пользователя

Редактирование пола

  • Путь: sex/:userId
  • Компонент: EditSex.vue
  • Описание: Изменение данных о поле пользователя

Редактирование местоположения

  • Путь: location/:userId
  • Компонент: EditLocation.vue
  • Описание: Интерфейс для изменения местоположения пользователя

Редактирование информации о себе

  • Путь: about/:userId
  • Компонент: EditAbout.vue
  • Описание: Изменение информации о пользователе

Маршруты для Friend

Маршрут «Взаимные друзья» (mutually):

  • Путь: mutually/:userId
  • Компонент: FriendsUsers.vue
  • Описание: Вкладка «взаимные друзья» на странице списка друзей

Маршрут «Подписки» (subscribed):

  • Путь: subscribed/:userId
  • Компонент: FriendsUsers.vue
  • Описание: Вкладка «подписки» на странице списка друзей

Маршрут «Подписчики» (subscriber):

  • Путь: subscriber/:userId
  • Компонент: FriendsUsers.vue
  • Описание: Вкладка «подписчики» на странице списка друзей

Маршруты для Auth

Login (Авторизация)

  • Путь: 'login'
  • Компонент: LoginPage.vue
  • Описание: Страница логина пользователя

Logout (Выход из системы)

  • Путь: 'logout'
  • Компонент: LogoutPage.vue
  • Описание: Страница выхода пользователя

Registration (Регистрация)

  • Путь: 'registration'
  • Компонент: RegistrationPage.vue
  • Описание: Страница регистрации нового пользователя

Create Password (Создание Пароля)

  • Путь: 'password/'
  • Компонент: CreatePassword.vue
  • Описание: Страница для создания нового пароля

Работа с REST API

Для начала необходимо сконфигурировать Axios:

TypeScript
import { boot } from 'quasar/wrappers';
import axios, { AxiosInstance } from 'axios';

declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $axios: AxiosInstance;
    $api: AxiosInstance;
  }
}

const api = axios.create({
  withCredentials: true,
  baseURL: import.meta.env.VITE_API_URL + '/api',
});

api.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${localStorage.getItem(
    'accessToken'
  )}`;
  return config;
});

api.interceptors.response.use(
  (config) => {
    return config;
  },
  async (error) => {
    const originalRequest = error.config;
    if (
      error.response?.status == 401 &&
      error.config &&
      !error.config._isRetry
    ) {
      originalRequest._isRetry = true;
      try {
        const sessionId = localStorage.getItem('sessionId');
        const response = await axios.post(
          `${import.meta.env.VITE_API_URL}/api/refresh`,
          { sessionId, withCredentials: true }
        );
        localStorage.setItem('accessToken', response.data.accessToken);
        return api.request(originalRequest);
      } catch (e) {
        console.log('Not Authorized');
      }
    }
    throw error;
  }
);

export default boot(({ app }) => {
  app.config.globalProperties.$axios = axios;
  app.config.globalProperties.$api = api;
});

export { api };


В данном коде осуществляется базовая конфигурация Axios: разрешается использование cookie, а так же устанавливается базовый url.

Затем, устанавливается два перехватчика — на запрос, и ответ. Перехватчики (interceptors) — это функции, которые выполняются при каждом запросе axios. В данном случае, при каждом запросе на сервер, к заголовкам запроса обновляется токен авторизации. В свою очередь, при каждом полученном ответе от сервера, выполняется проверка на ошибку «401 Unauthorized». Если ошибка произошла, клиент делает запрос к серверу на обновление токена доступа, и повторяет предыдущий запрос уже с новым токеном.

В файле api.ts находятся основные методы приложения для работы с удаленным API:

TypeScript
import { api } from '../boot/axios';
import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import { ApiResponse, ServerErrorResponse, ApiData } from './api.interfaces';
import { Notify, Loading, QSpinnerCube } from 'quasar';

export function axiosError(e: unknown) {
  if (e instanceof AxiosError) {
    const error = e;
    if (error.response) {
      const errorData = error.response.data as ServerErrorResponse;
      if (errorData && 'message' in errorData) {
        if (Array.isArray(errorData.message)) {
          errorData.message = errorData.message.join(', ');
        }
        return {
          status: error.response.status ?? 500,
          message: errorData.message,
        };
      }

      const errorDataArray = error.response.data as {
        validationErrors: [];
      };
      if (errorDataArray && 'validationErrors' in errorDataArray) {
        return {
          status: error.response.status ?? 500,
          message: 'Ошибка валидации',
        };
      }
    }

    if (error.code === 'ECONNABORTED' || error.message === 'Network Error') {
      return {
        status: 0,
        message: 'Ошибка соединения с сервером',
      };
    }
  } else {
    // Обработка ошибок, не являющихся AxiosError
    console.error(e);
  }

  return {
    status: 0,
    message: 'Неизвестная ошибка',
  };
}

export async function sendApiRequest<T>(
  method: 'get' | 'post' | 'put' | 'delete',
  url: string,
  config: AxiosRequestConfig = {}
): Promise<ApiResponse<T>> {
  try {
    const response: AxiosResponse<T> = await api.request<T>({
      url,
      method,
      ...config,
    });
    return {
      status: response.status,
      response: response.data,
    };
  } catch (error) {
    return axiosError(error);
  }
}


Этот код формирует запрос Axios, а так же производит обработку ошибок.

Далее формируем запросы к серверу. Для примера приведу несколько функций из messenger.api.ts:

TypeScript
export default class MessengerApi {
  static async roomCreate(
    type: number,
    name: string,
    privacy: number,
    about: string
  ): Promise<ApiResponse<RoomCreate>> {
    return sendApiRequest<RoomCreate>('post', '/room/create', {
      data: {
        type,
        name,
        privacy,
        about,
      },
    });
  }

  static async editAvatar(
    roomId: number,
    imageBlob: Blob,
    cropWidth: number,
    cropHeight: number,
    cropLeft: number,
    cropTop: number
  ): Promise<ApiResponse<EditAvatar>> {
    const formData = new FormData();
    formData.append('roomId', roomId.toString());
    formData.append('image', imageBlob);
    formData.append('cropWidth', cropWidth.toString());
    formData.append('cropHeight', cropHeight.toString());
    formData.append('cropLeft', cropLeft.toString());
    formData.append('cropTop', cropTop.toString());

    return sendApiRequest<EditAvatar>('post', '/room/avatar', {
      data: formData,
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    });
  }

  static async dialogueCreate(
    to: number
  ): Promise<ApiResponse<DialogueCreate>> {
    return sendApiRequest<DialogueCreate>('post', '/room/createDialogue', {
      params: {
        to,
      },
    });
  }

  static async roomGet(idMixed: string): Promise<ApiResponse<Room>> {
    return sendApiRequest<Room>('get', '/room', {
      params: {
        id: idMixed,
      },
    });
  }


В целом, эти функции типовые для всех запросов, и приведенный код демонстрирует общий принцип реализации работы с api в приложении.

messenger.store.ts:

TypeScript
actions: {
    async roomCreate(
      type: number,
      name: string,
      privacy: number,
      about: string
    ): Promise<RoomCreate | boolean | { error: string; code: number }> {
      return await setResponse({
        func: MessengerApi.roomCreate,
        args: [type, name, privacy, about],
      });
    },

    async roomGet(
      idMixed: string
    ): Promise<Room | boolean | { error: string; code: number }> {
      return await setResponse({
        func: MessengerApi.roomGet,
        args: [idMixed],
      });
    },
    
    async editAvatar(
      roomId: number,
      imageBlob: Blob,
      cropWidth: number,
      cropHeight: number,
      cropLeft: number,
      cropTop: number
    ): Promise<EditAvatar | boolean | { error: string; code: number }> {
      return await setResponse({
        func: MessengerApi.editAvatar,
        args: [roomId, imageBlob, cropWidth, cropHeight, cropLeft, cropTop],
      });
    },


Эти методы уже непосредственно вызываются из компонентов приложения.

В целом, формирование и обработка запроса в приложении происходит в несколько этапов. В будущем, я хочу лучше оптимизировать и улучшить архитектуру формирования запросов, т.к даже нахождение методов запросов к API в хранилище Pinia (и они в основном не используют его возможности) выглядит не вполне корректным, и сохранилось по историческим причинам — первые примеры кода, с которых я начинал изучение Vue.js были очень упрощенные, и работа с API там проходила полностью в хранилище и самих компонентах. В моей же реализации, код работы с API уже полностью вынесен из компонентов, сделана обработка ошибок (пока неидеально), формирование запросов. В дальнейших итерациях разработки я планирую оставить в хранилищах лишь тот код, которому непосредственно необходимы возможности хранилища. А код работы с API вынести в отдельные файлы и оптимизировать еще лучше.

Работа с Socket.io API

Работа с API через Socket.io вынесена в отделный файл socket.service.ts. Приведу его содержимое полностью:

TypeScript
import { io, Socket } from 'socket.io-client';

const sessionId = localStorage.getItem('sessionId');

export interface Msg {
  msgId: number;
  roomId: number;
  name: string;
  userId: number;
  avatar: string;
  date: string;
  type: number;
  content: string;
  replyTo: number;
}

export interface Online {
  userId: number;
  online: boolean;
  timestamp: number;
  counter: number;
}

export interface Typing {
  userId: number;
  userName: string;
  typing: boolean;
  timestamp: number;
  roomId: number;
}

export interface Exception {
  status: string;
  message: string;
}

export default class SocketService {
  socket = {} as Socket;

  constructor() {
    this.connectToSocket();
  }

  connectToSocket() {
    const timerSocketConnect = setInterval(async () => {
      const accessToken = localStorage.getItem('accessToken');
      if (accessToken !== null) {
        this.socket = io(import.meta.env.VITE_API_URL, {
          withCredentials: true,
          extraHeaders: {
            Authorization: `Bearer ${accessToken}`,
          },
        });
        clearInterval(timerSocketConnect);
      }
    }, 3000);
  }

  disconnectFromSocket() {
    if (this.socket) {
      this.socket.disconnect();
    }
  }

  emit(event: string, data: { sessionId?: string | null }) {
    data.sessionId = sessionId;
    return new Promise((resolve, reject) => {
      if (!this.socket) {
        reject('No socket connection');
      } else {
        this.socket.emit(
          event,
          data,
          (response: { data: object; error: string }) => {
            if (response?.data) {
              resolve(response);
            } else {
              console.error(
                'Error on emit: ' + event + 'Server respose: ' + response //?.error
              );
              reject(response);
            }
          }
        );
      }
    });
  }

  async joinUserToAllRooms(userId: number) {
    return await this.emit('joinUserToAllRooms', { userId });
  }

  async joinToUserRoom(userId: number) {
    return await this.emit('joinToUserRoom', { userId });
  }

  async joinToRoom(roomId: number) {
    return await this.emit('joinToRoom', { roomId });
  }

  async leaveRoom(roomId: number) {
    return await this.emit('leaveRoom', { roomId });
  }

  async callUser(counter = 1) {
    return await this.emit('callUser', { counter });
  }

  async userOffline() {
    return await this.emit('userOffline', {});
  }

  async createMessage(message: object) {
    return await this.emit('createMessage', message);
  }

  async typing(roomId: string) {
    return await this.emit('typing', { roomId });
  }

  async subscribeRoom(roomId: number) {
    return await this.emit('subscribeRoom', { roomId });
  }

  async banUser(userId: number, roomId: number, banned: boolean) {
    return await this.emit('banUser', { userId, roomId, banned });
  }

  onUserOnlineStatus(action: (online: Online) => void) {
    this.socket.on('userOnlineStatus', action);
  }

  onSendMessage(action: (msg: Msg) => void) {
    this.socket.on('sendMessage', action);
  }

  onTyping(action: (typing: Typing) => void) {
    this.socket.on('broadcastTyping', action);
  }

  onException(action: (exception: Exception) => void) {
    this.socket.on('exception', action);
  }

  onConnect(action: () => void) {
    this.socket.on('connect', action);
  }

  onDisconnect(action: () => void) {
    this.socket.on('disconnect', action);
  }
}

Компоненты

Для начала рассмотрим главный компонент, служащий основой для большей части приложения:

TypeScript
<template>
  <router-view />
  <div class="modal-wrapper">
    <transition name="toggleBB">
      <div v-if="showMessage" class="modal modal-message">
        <div class="message" @click="onMessage">
          <div class="truncate-text">
            {{ messageLabel }}
          </div>
        </div>
      </div>
    </transition>

    <transition name="toggleBB">
      <div v-if="showAlert" class="modal modal-error">
        <div class="message">
          <div class="truncate-text">
            {{ alertLabel }}
          </div>
        </div>
      </div>
    </transition>
  </div>

  <div v-if="appStore.srvErrorOverlay" class="srvErrorOverlay">
    <div>
      <div>Что-то пошло не так...</div>
      <button @click="reloadPage">Обновить</button>
    </div>
  </div>

  <div v-if="appStore.loadingOverlay" class="loadingOverlay">
    <qq-spinner />
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { useAppStore } from './stores/app.store';
import { useUserStore } from '../user/user.store';
import QqSpinner from './QqSpinner.vue';
import SocketService from './socket.service';

export default defineComponent({
  name: 'App',
  components: { QqSpinner },
  data() {
    return {
      showAlert: false,
      showMessage: false,
      alertLabel: '',
      messageLabel: '',
      alertTimeout: {} as NodeJS.Timeout,
      msgTimeout: {} as NodeJS.Timeout,
    };
  },
  setup() {
    const appStore = useAppStore();
    const userStore = useUserStore();
    const socketService = new SocketService();
    appStore.socketService = socketService;
    return { appStore, userStore, socketService };
  },
  async mounted() {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        this.socketService.callUser();
      } else {
        this.socketService.userOffline();
      }
    });

    setInterval(() => {
      this.appStore.isSocketConnected = this.socketService.socket.connected;
    }, 2000);

    setTimeout(() => {
      this.initEvents();
      this.joinUserToAllRooms();
    }, 3000);
  },
  methods: {
    joinUserToAllRooms() {
      let timerJoinToAllRooms = setInterval(async () => {
        const accessToken = localStorage.getItem('accessToken');
        if (accessToken !== null) {
          await this.socketService.joinUserToAllRooms(this.userStore.userId);
          clearInterval(timerJoinToAllRooms);
        }
      }, 3000);
    },
    onMessage() {
      this.showAlert = true;
    },
    reloadPage() {
      location.reload();
    },
    initEvents() {
      this.socketService.onConnect(() => {
        this.appStore.isSocketConnected = true;
        console.log('CONNECTED');
        this.joinUserToAllRooms();
      });

      this.socketService.onDisconnect(() => {
        this.appStore.isSocketConnected = false;
        console.error('DISCONNECTED');
      });

      this.socketService.onException((exception) => {
        const error = exception.message;
        this.appStore.setAlertMsg(error);
        console.error(error);
      });

      this.socketService.onUserOnlineStatus((data) => {
        if (data?.userId) {
          this.appStore.userOnline = data;

          if (data.counter <= 1) {
            if (document.visibilityState === 'visible') {
              this.socketService.callUser(data.counter + 1);
            } else {
            }
          }
        }
      });

      this.socketService.onSendMessage((data) => {
        const message = {
          msgId: data.msgId,
          roomId: data.roomId,
          name: data.name,
          userId: data.userId,
          avatar: data.avatar,
          date: data.date,
          type: data.type,
          content: data.content,
          replyTo: data.replyTo,
        };

        this.appStore.lastMessage = message;
      });

      this.socketService.onTyping((data) => {
        const typing = {
          userId: data.userId,
          userName: data.userName,
          typing: data.typing,
          timestamp: data.timestamp,
          roomId: data.roomId,
        };

        this.appStore.typing = typing;
      });
    },
  },
  computed: {
    alertMessage() {
      return this.appStore.alertMessage.timestamp;
    },
    messageAlert() {
      return this.appStore.lastMessage.date;
    },
  },
  watch: {
    alertMessage() {
      clearTimeout(this.alertTimeout);
      this.showAlert = true;
      this.alertLabel = this.appStore.alertMessage.message;
      this.alertTimeout = setTimeout(() => {
        this.showAlert = false;
      }, 3000);
    },
    messageAlert() {
      if (
        this.appStore.activeRoom !== this.appStore.lastMessage.roomId &&
        this.appStore.lastMessage.userId !== this.userStore.userId
      ) {
        clearTimeout(this.msgTimeout);
        this.showMessage = true;

        switch (this.appStore.lastMessage.type) {
          case 1:
            this.messageLabel =
              this.appStore.lastMessage.name +
              ': ' +
              this.appStore.lastMessage.content;
            break;
          default:
            this.messageLabel = this.appStore.lastMessage.name + ': ...';
        }

        this.msgTimeout = setTimeout(() => {
          this.showMessage = false;
        }, 3000);
      }
    },
  },
});
</script>

<style>
body {
  background-size: cover;
  background-color: #ffffff;
  background-image: url('tr.jpg');
  font-family: Roboto, -apple-system, 'Helvetica Neue', Helvetica, Arial,
    sans-serif;
  box-sizing: border-box;
  font-size: 14px;
  direction: ltr;
  unicode-bidi: isolate;
  text-size-adjust: 100%;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
  line-height: 21px;
  overflow: hidden;
}

a {
  text-decoration: none;
  color: black;
}

/* Remove outline on the forms and links */
:active,
:hover,
:focus {
  outline: 0;
  outline-offset: 0;
}

.app-header {
  flex: 0 0 auto;
  padding: 0 5px;
  background: #272727;
  color: white;
  text-align: center;
  height: 60px;
  display: flex;
  flex-direction: row;
  align-items: center;
  z-index: 10000;
}
</style>

<style scoped>
.modal-wrapper {
  display: block;
}

.modal {
  bottom: 0px;
  left: 0px;
  right: 0px;
  position: fixed;
  display: flex;
  flex-wrap: nowrap;
  flex-direction: column;
  align-items: center;
  z-index: 9500;
  margin-left: 30px;
  margin-right: 30px;
  user-select: none;
  height: 30px;
}

.modal-error {
  margin-bottom: 70px;
  bottom: 0px;
}

.modal-message {
  margin-top: 80px;
  top: 0px;
  cursor: pointer;
}

.message {
  display: flex;
  align-items: center;
  max-width: 100%;
  height: 30px;
  padding: 0 14px 0 15px;
  border-radius: 20px;
  background: #1d1d1d;
  color: rgb(250, 249, 249);
}

.truncate-text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

@keyframes aniUp {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes aniDown {
  to {
    opacity: 0;
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.3s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.5s;
}

.srvErrorOverlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(26, 26, 26, 0.97);
  z-index: 10000000;
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgb(228, 228, 228);
  font-size: 20px;
}

.srvErrorOverlay > div {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding-top: 20px;
}

.srvErrorOverlay > div > div {
  margin: 30px 0;
}

.srvErrorOverlay > div > button {
  padding: 15px 70px;
  border: 1px solid rgb(63, 63, 63);
  cursor: pointer;
  background-color: rgb(228, 228, 228);
  font-size: 20px;
  border-radius: 5px;
}

.srvErrorOverlay > div > button:active {
  background-color: rgb(202, 202, 202);
}

.loadingOverlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(26, 26, 26, 0.185);
  z-index: 10000000;
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgb(228, 228, 228);
  font-size: 20px;
}
</style>


Здесь реализованы всплывающие уведомления, оверлей с ошибкой и загрузкой, а так же целый ряд функций, связанных с работой мессенджера через Socket.io (отправка событий онлайн или офлайн в зависимости от видимости окна, первоначальная инициализация вроде joinUserToAllRooms(), устанавливается ряд обработчиков событий сокетов onConnect, onDisconnect, onException, onUserOnlineStatus, onSendMessage, onTyping).

Выпадающее меню в окне чата DropMenu:


Полностью реализовано самостоятельно. Код целиком:

TypeScript
<template>
  <div
    v-if="appStore.showDropMenu"
    @click.self="hide()"
    class="menu-layout"
  ></div>
  <transition name="toggleBB">
    <div v-if="appStore.showDropMenu" class="menu" @click="hide()">
      <div class="items-container">
        <div
          v-for="(item, index) in content"
          :key="index"
          @click="item.click"
          class="item"
          :style="'color:' + item.color"
        >
          <div class="item-icon">
            <div class="icon">
              <svg-icon type="mdi" :path="item.icon"></svg-icon>
            </div>
          </div>
          <div class="item-data">
            <div class="item-text">
              {{ item.title }}
            </div>
          </div>
        </div>
      </div>
    </div>
  </transition>
</template>

<script lang="js">
import { defineComponent } from 'vue';
import { useAppStore } from '../common/stores/app.store';
import { useUserStore } from '../user/user.store';
import SvgIcon from '@jamescoyle/vue-icon';

export default defineComponent({
  name: 'DropMenu',
  props: ['content'],
  components: { SvgIcon },
  setup() {
    const appStore = useAppStore();
    const userStore = useUserStore();
    return { appStore, userStore };
  },
  methods: {
    hide() {
      this.appStore.showDropMenu = false;
    },
  },
});
</script>

<style scoped>
.menu-layout {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 19000;
  display: flex;
  flex-direction: column;
}

.menu {
  position: fixed;
  top: 10px;
  right: 8px;
  width: 300px;
  z-index: 20000;
  background-color: #313131;
  display: flex;
  flex-direction: column;
  border-radius: 5px;
  box-shadow: 1px 1px 1px #252525;
  user-select: none;
}

.item-arrow {
  color: #dab3b3;
}

.item {
  display: flex;
  flex-direction: row;
  width: 100%;
  min-height: 48px;
  color: #fde2e2;
  padding-left: 15px;
}

.item:active {
  background-color: #3a3a3a;
}

.item-icon {
  display: flex;
  align-items: center;
}

.icon {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
}

.item-data {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-left: 18px;
  width: 100%;
}

.item-text {
  font-size: 16px;
}

@keyframes aniUp {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes aniDown {
  to {
    opacity: 0;
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.2s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.15s;
}
</style>

Боковое меню DrawerPanel:

Полностью реализовано самостоятельно. Код целиком:

TypeScript
<template>
  <transition name="toggleDC">
    <div
      v-if="appStore.showDrawer"
      @click.self="hide()"
      class="drawer-container"
    ></div>
  </transition>
  <transition name="toggleBB">
    <div
      v-if="appStore.showDrawer"
      class="drawer"
      @touchstart="onTouchStart"
      @touchmove="onTouchMove"
    >
      <div class="header">
        <div class="header-caption">
          <img src="icon.png" width="23px" />
          <RouterLink
            to="/app"
            style="text-decoration: none; color: white; padding-top: 1px"
            >essenger</RouterLink
          >
        </div>
      </div>

      <div>
        <router-link :to="`/profile/${userStore.userId}`">
          <div class="account">
            <div class="user-avatar">
              <user-avatar
                :userName="userStore.userName"
                :avatar="userStore.userAvatar"
              />
            </div>
            <div class="user-info">
              <div class="user-name">{{ userStore.userName }}</div>
              <div class="user-name-caption">Профиль</div>
            </div>
            <div class="user-right">
              <div class="item-arrow">〉</div>
            </div>
          </div>
        </router-link>
      </div>

      <div class="items-container" @click="hide()">
        <router-link
          :to="item.link"
          v-for="(item, index) in content"
          :key="index"
        >
          <div class="item">
            <div class="item-icon">
              <div class="icon">
                <svg-icon type="mdi" :path="item.icon"></svg-icon>
              </div>
            </div>
            <div class="item-data">
              <div class="item-text">{{ item.title }}</div>
              <div v-if="item.caption" class="item-caption">
                {{ item.caption }}
              </div>
            </div>
            <div class="item-right">
              <div v-if="item.counter" class="item-counter">
                <div class="item-counter-leveler">
                  {{ item.counter }}
                </div>
              </div>
            </div>
          </div>
        </router-link>
      </div>
    </div>
  </transition>
</template>

<script lang="js">
import { defineComponent } from 'vue';
import UserAvatar from 'src/user/UserAvatar.vue';
import { useAppStore } from '../common/stores/app.store';
import { useUserStore } from '../user/user.store';
import SvgIcon from '@jamescoyle/vue-icon';

export default defineComponent({
  name: 'DrawerPanel',
  props: ['content'],
  //emits: ['close'],
  components: { UserAvatar, SvgIcon },
  setup() {
    const appStore = useAppStore();
    const userStore = useUserStore();
    return { appStore, userStore };
  },
  data() {
    return {
      touchStartX: 0,
      touchStartY: 0,
      swipeThreshold: 80, // минимальное расстояние для свайпа
    };
  },
  methods: {
    hide() {
      this.appStore.showDrawer = false;
    },
    onTouchStart(event) {
      const touch = event.touches[0];
      this.touchStartX = touch.clientX;
      this.touchStartY = touch.clientY;
    },
    onTouchMove(event) {
      if (!this.appStore.showDrawer) {
        return;
      }

      const touch = event.touches[0];
      const touchEndX = touch.clientX;
      const touchEndY = touch.clientY;

      const deltaX = this.touchStartX - touchEndX;
      const deltaY = this.touchStartY - touchEndY;

      // Проверяем, является ли движение свайпом влево и достаточно ли оно большое
      if (deltaX > this.swipeThreshold && Math.abs(deltaY) < this.swipeThreshold) {
        this.appStore.showDrawer = false;
      }
    }
  },
});
</script>

<style scoped>
@font-face {
  font-family: 'headFont';
  src: url('/crimson/Crimson-SemiboldItalic.otf');
}

a {
  text-decoration: none;
}

.header-caption {
  font-size: 21px;
  padding-top: 21px;
  color: #ffefef;
  letter-spacing: 0.2px;
  height: 26px;
  display: flex;
  align-items: center;
  margin-left: 7px;
  margin-top: 5px;
}

.drawer-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 19000;
  background-color: hsla(0, 0%, 4%, 0.493);
  display: flex;
  flex-direction: column;
}

.drawer {
  position: fixed;
  top: 0;
  left: 0;
  width: 300px;
  height: 100%;
  z-index: 20000;
  background-color: #272727;
  display: flex;
  flex-direction: column;
}

.header {
  width: 100%;
  height: 60px;
  padding-left: 16px;
}

.account {
  display: flex;
  flex-direction: row;
  width: 100%;
  height: 75px;
  background-color: #5e4242;
  padding-left: 16px;
  margin-bottom: 18px;
}

.user-avatar {
  display: flex;
  align-items: center;
}

.user-info {
  padding-left: 12px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.user-name {
  color: white;
}

.user-name-caption {
  color: #dab3b3;
  font-size: 12px;
}

.user-right {
  display: flex;
  color: white;
  width: 100%;
  padding-right: 15px;

  flex-direction: row-reverse;
  align-items: center;
}

.item-arrow {
  color: #dab3b3;
}

.items-container {
  overflow-y: auto;
}

.item {
  display: flex;
  flex-direction: row;
  width: 100%;
  min-height: 48px;
  color: #fde2e2;
  padding-left: 12px;
}

.item:active {
  background-color: #3a3a3a;
}

.item-icon {
  display: flex;
  align-items: center;
}

.icon {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
}

.icon span {
  font-size: 23px;
}

.item-data {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-left: 30px;
  width: 100%;
}

.item-caption {
  color: #dab3b3;
  font-size: 12px;
}

.item-right {
  display: flex;
  flex-direction: row-reverse;
  align-items: center;
  padding-right: 15px;
}

.item-counter {
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 15px;
  min-width: 25px;
  height: 25px;
  background-color: #dddddd;
  font-size: 12px;
  color: #404749;
  padding: 8px;
}

.item-counter-leveler {
  margin-left: -1px;
  margin-bottom: -1px;
}

@keyframes aniUp {
  from {
    transform: translateX(-150%);
  }
  to {
    transform: translateX(0);
  }
}

@keyframes aniDown {
  to {
    transform: translateX(-150%);
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.3s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.5s;
}

@keyframes containerShow {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes containerHide {
  to {
    opacity: 0;
  }
}

.toggleDC-enter-active,
.toggleDC-leave-active {
  animation: containerShow 0.3s;
}

.toggleDC-leave-active,
.toggleDC-leave-to {
  animation: containerHide 0.5s;
}
</style>

Выбор аватара

Образка аватара реализована при помощи сторннего компонента vue-advanced-cropper. Этот компонент позволяет удобно определить координаты обрезки для изображения аватара, а затем уже на сервере, будет произведена обрезка. Ниже привожу код страницы загрузки аватара полностью:

TypeScript
<template>
  <div class="q-pa-md">
    <div class="square" v-if="!file" @click="pickFiles">
      <q-icon
        name="add_a_photo"
        size="150px"
        class="cursor-pointer inner-block"
        style="color: #c5c5c5"
      />
    </div>

    <cropper
      v-if="file"
      ref="cropper"
      class="cropper"
      :src="image.src"
      :stencil-props="{
        aspectRatio: 1 / 1,
      }"
      @change="change"
      :stencil-component="nonReactiveCircleStencil"
    />
    <br />

    <div class="buttonBar">
      <div class="center">
        <q-file
          ref="file"
          filled
          bottom-slots
          v-model="file"
          label="Выбрать аватар"
          counter
          @update:model-value="handleFileChange"
          accept=".jpg, .jpeg, .png, .gif, .bmp, .webp, .avif"
          class="full-width"
          :max-file-size="10 * 1_048_576"
        >
          <template v-slot:prepend>
            <q-icon name="cloud_upload" @click.stop.prevent />
          </template>
          <template v-slot:append>
            <q-icon
              name="close"
              @click.stop.prevent="reset"
              class="cursor-pointer"
            />
          </template>

          <template v-slot:hint> Не более 10 Мб </template>
        </q-file>
      </div>

      <div class="center">
        <q-btn
          color="brown-10"
          class="full-width"
          size="lg"
          @click="onSubmit"
          :label="submitLabel"
        />
      </div>
    </div>
  </div>
</template>

<script lang="js">
import { defineComponent, markRaw } from 'vue';
import { useUserStore } from '../../user/user.store';
import { Cropper, CircleStencil } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';

const userStore = useUserStore();

function getMimeType(file, fallback = null) {
  const byteArray = new Uint8Array(file).subarray(0, 4);
  let header = '';
  for (let i = 0; i < byteArray.length; i++) {
    header += byteArray[i].toString(16);
  }
  switch (header) {
    case '89504e47':
      return 'image/png';
    case '47494638':
      return 'image/gif';
    case 'ffd8ffe0':
    case 'ffd8ffe1':
    case 'ffd8ffe2':
    case 'ffd8ffe3':
    case 'ffd8ffe8':
      return 'image/jpeg';
    case '424d':
    case '4d42':
      return 'image/bmp';
    case '52494646':
      return 'image/webp';
    case '61764966':
      return 'image/avif';
    default:
      return fallback;
  }
}

export default defineComponent({
  name: 'EditAvatar',
  components: { Cropper },

  setup() {
    const nonReactiveCircleStencil = markRaw(CircleStencil);
    return {
      nonReactiveCircleStencil,
    };
  },

  data() {
    return {
      CircleStencil,
      submitLabel: 'Отмена',
      imageBlob: null,
      image: {
        src: null,
        type: null,
      },
      cropWidth: 0,
      cropHeight: 0,
      cropLeft: 0,
      cropTop: 0,
      file: null,
    };
  },

  methods: {
    pickFiles(){
      this.$refs.file.pickFiles();
    },

    handleFileChange() {
      if (this.file) {
        if (this.image.src) {
          URL.revokeObjectURL(this.image.src);
        }
        const blobUrl = URL.createObjectURL(this.file);
        const reader = new FileReader();
        reader.onload = (e) => {
          this.image = {
            src: blobUrl,
            type: getMimeType(e.target.result, this.file.type),
          };
        };
        reader.readAsArrayBuffer(this.file);
        this.imageBlob = this.file;
      }
    },

    change({ coordinates }) {
      this.cropWidth = coordinates.width;
      this.cropHeight = coordinates.height;
      this.cropLeft = coordinates.left;
      this.cropTop = coordinates.top;
    },

    reset() {
      this.file = null;
      this.image = {
        src: null,
        type: null,
      };
    },

    async onSubmit() {
      const formData = {
        imageBlob: this.imageBlob,
        cropWidth: this.cropWidth,
        cropHeight: this.cropHeight,
        cropLeft: this.cropLeft,
        cropTop: this.cropTop,
      };

      if (this.file) {
        this.$emit('submitForm', formData);
      } else {
        this.$emit('submitForm', false);
      }
    },

  watch: {
    file() {
      if (this.file) {
        this.submitLabel = 'Сохранить';
      } else {
        this.submitLabel = 'Отмена';
      }
    },
  },

  unmounted() {
    if (this.image.src) {
      URL.revokeObjectURL(this.image.src);
    }
  },
});
</script>

<style scoped>
.cropper {
  min-height: 300px;
  max-height: 600px;
  margin: auto;
}

.center {
  display: flex;
  justify-content: center;
  margin-top: 17px;
}

.buttonBar {
  position: fixed;
  bottom: 0;

  left: 0;
  right: 0;
  padding: 10px;
  padding-bottom: 40px;
}

.square {
  margin: 0 auto;
  padding-top: 20px;
  padding-bottom: 20px;
  width: calc(100% - 40px);
  max-width: 500px;
  min-width: 100px;
  aspect-ratio: 1 / 1;
  max-height: 500px;
  min-height: 100px;
  position: relative;
  box-sizing: border-box;
  border: 1px dashed rgb(194, 194, 194);
  background-color: #ebebeb69;
  margin-top: 20px;
}

.inner-block {
  margin-top: -8px;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(
    -50%,
    -50%
  );
  width: 50%;
  height: 50%;
}
</style>

Страница чата ChatPage

На текущий момент — самая сложная часть приложения. Особенно хочу отметить, что здесь реализована «бесконечная» прокрутка сообщений в чате, и сделано это без использования сторонних компонентов (Для примера был найден готовый пример реализации такого алгоритма, который, однако, потребовал полного вникания в его принцип работы и значительной доработки). Я пробовал использовать сторонние компоненты, но ни одно из решений меня не устроило, а так же являлось «черным ящиком». В конечном итоге, я решил, что для столь важной части приложения необходимо иметь полное понимание и контроль над кодом, что привело к созданию кастомного решения.


Пока это первая черновая реализация чата, и далее планируется глубокий рефакторинг — большая часть кода будет выделена в отдельные компоненты и переписана. Кроме того, будут исправлены некотрые баги и недоделки. Тем не менее, я приведу все 1755 строк кода этой страницы целиком (включая код CSS), не смотря на имеющиеся недочеты:

TypeScript
<template>
  <div class="container-all-chat no-framework-text">
    <div v-if="showChatLoadingError" class="chat-loading-overlay-err">
      <div>
        <div>{{ showChatLoadingErrorMsg }}</div>

        <button @click="reloadChat">Обновить</button>
      </div>
    </div>
    <div class="chat-container">
      <div class="app-header">
        <div class="header-button-arrow">
          <router-link :to="'/im'">
            <div class="btn-icon">
              <svg-icon type="mdi" :path="mdiArrowLeft" size="26"></svg-icon>
            </div>
          </router-link>
        </div>

        <div class="avatar-header">
          <chat-avatar
            :size="'msg'"
            :type="roomAvatarType"
            :name="roomName"
            :avatar="roomAvatar"
          />
        </div>
        <router-link :to="roomLink">
          <div class="header-caption">
            <div class="header-caption-title">{{ roomName }}</div>
            <div
              v-if="chatOnline && !isTyping && !isConnecting"
              class="header-caption-desc online"
            >
              в сети
            </div>
            <div
              v-if="isTyping && !isConnecting"
              class="header-caption-desc loading"
            >
              печатает...
            </div>
            <div
              v-if="isConnecting"
              class="header-caption-desc loading"
              style="color: palevioletred"
            >
              соединение...
            </div>
            <div
              v-if="!chatOnline && !isTyping && !isConnecting"
              class="header-caption-desc"
            >
              {{ chatDesc }}
            </div>
          </div>
        </router-link>
        <div class="header-menu">
          <div class="header-button-menu" @click="onChatMenuBtn">
            <div class="btn-icon">
              <svg-icon type="mdi" :path="mdiDotsVertical" size="26"></svg-icon>
            </div>
          </div>
        </div>
      </div>
      <div class="msg-overlay" :style="chatBackground">
        <div ref="messages" class="messages" v-on:scroll.passive="handleScroll">
          <div
            class="chunk"
            v-for="(chunk, index) in chunks"
            :style="{ transform: 'translateY(' + chunk.offset + 'px)' }"
            :key="index"
          >
            <div
              class="sentinel-start"
              v-if="index === 0"
              :class="{ disabled: lastChunk < 3 }"
            ></div>

            <div
              class="list-item"
              v-for="(item, index) in chunk.items"
              :key="index"
            >
              <chat-message
                v-if="item.type === 1"
                :index="index"
                :userId="item.userId"
                :name="item.name"
                :text="item.text"
                :date="item.date"
                :avatar="item.avatar"
                :sent="item.sent"
                :showAvatar="isShowAvatar"
                :showName="isShowName"
                :status="item.status"
                :showStatus="false"
              />
              <chat-notify v-if="item.type === 12" :name="item.name" />
            </div>

            <div
              class="sentinel"
              v-if="index === chunks.length - 1"
              :class="{ disabled: startFlag }"
            ></div>
          </div>

          <svg height="0" width="0">
            <defs>
              <clipPath id="svgPathRight">
                <path fill="#FFFFFF" d="M20,20H0V0H0A20,20,0,0,0,20,20Z" />
              </clipPath>

              <clipPath id="svgPathLeft">
                <path fill="#FFFFFF" d="M0,20H20V0H20A20,20,0,0,1,0,20Z" />
              </clipPath>
            </defs>
          </svg>
        </div>
      </div>

      <div v-if="inputArea === 'input'" class="input-area">
        <textarea
          ref="inputField"
          rows="1"
          v-model="inputMessage"
          @keydown="inputHandler"
          class="input-field"
          placeholder="Сообщение"
          @input="onInputMessage"
        ></textarea>
        <div>
          <div @click="sendMessage" class="btn-icon btn-send">
            <svg-icon type="mdi" :path="mdiSend" size="26"></svg-icon>
          </div>
        </div>
      </div>

      <div v-if="inputArea === 'alert'" class="input-area">
        <div class="input-area-alert">
          <div>
            <div class="input-area-alert-icon">
              <svg-icon type="mdi" :path="inputAreaAlertIcon"></svg-icon>
            </div>
          </div>
          <div class="input-area-alert-msg">{{ deniedMessage }}</div>
        </div>
      </div>

      <div
        v-if="inputArea === 'subscribeChannel'"
        class="chat-button"
        @click="onSubscribe"
      >
        Подписаться
      </div>

      <div
        v-if="inputArea === 'subscribeGroup'"
        class="chat-button"
        @click="onSubscribe"
      >
        Присоединиться к группе
      </div>

      <div
        v-if="inputArea === 'notifyStateOff'"
        class="chat-button"
        @click="onDisableNotifications"
      >
        Откл. уведомления
      </div>
      <div
        v-if="inputArea === 'notifyStateOn'"
        class="chat-button"
        @click="onEnableNotifications"
      >
        Вкл. уведомления
      </div>
    </div>

    <transition name="toggleBB">
      <div
        v-if="toBeginButtonVisible"
        class="toBeginButton"
        @click="toBeginButton"
      >
        <svg-icon type="mdi" :path="mdiChevronDown" size="26" />
      </div>
    </transition>

    <drop-menu :content="messengerMenu" />
  </div>
</template>

<script>
import { debounce } from 'lodash';
import { defineComponent } from 'vue';
import ChatMessage from './chat/ChatBubble.vue';
import ChatNotify from './chat/ChatNotify.vue';
import ChatAvatar from '../avatar/ChatAvatar.vue';
import { useQuasar } from 'quasar';
import { RoomType, NotifyState, SubscribeRole } from '../common/enums';

import { useUserStore } from '../../user/user.store';
import { useMessengerStore } from '../common/messenger.store';
import { useAppStore } from '../../common/stores/app.store';

import { date } from 'quasar';
import dateTokens from '../../i18n/ru/date';

import SvgIcon from '@jamescoyle/vue-icon';

import DropMenu from '../../common/DropMenu.vue';

import {
  mdiBroom,
  mdiDelete,
  mdiBrushVariant,
  mdiBell,
  mdiBellCancel,
  mdiCancel,
  mdiExclamationThick,
  mdiArrowLeft,
  mdiSend,
  mdiDotsVertical,
  mdiChevronDown,
} from '@mdi/js';

export default defineComponent({
  name: 'ChatPage',
  props: ['idMixed'],
  components: { ChatMessage, ChatNotify, ChatAvatar, SvgIcon, DropMenu }, // DrawerPanel

  setup() {
    const appStore = useAppStore();
    const messengerStore = useMessengerStore();
    const userStore = useUserStore();

    const $q = useQuasar();

    return {
      showNotif() {
        $q.notify({
          timeout: 0,
          message: 'Ошибка',
          actions: [
            {
              label: 'Перезагрузить',
              color: 'blue',
              handler: () => {
                this.loading = false;

                switch (this.lastCallback) {
                  case 'endCallback':
                    this.endCallbackFunc();
                    break;
                  case 'startCallback':
                    this.startCallbackFunc();
                    break;
                  default:
                    location.reload();
                }
              },
            },
          ],
        });
      },
      appStore,
      messengerStore,
      userStore,
    };
  },

  data() {
    return {
      chatBackground: `background-image: url('${
        import.meta.env.VITE_API_URL
      }/background/tgbg1/Theme=Free-Time.svg'), url('${
        import.meta.env.VITE_API_URL
      }/background/tgbg1/Gradient=Fresh-morning.svg'); background-size: auto, cover; background-color: rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5);`,

      isShowAvatar: true,
      isShowName: true,

      type: null,
      privacy: null,
      roomName: '?',
      roomAvatar: '',
      countSubscribers: 0,
      inputMessage: '',

      startOffsetTop: 0,
      correctionOffset: 0,

      allowReload: false,
      toBeginButtonVisible: false,

      newMessages: [],

      data: [],
      chunks: [],
      offsets: { 1: 0 },
      lastChunk: 1,
      startFlag: false,
      rooms: [],
      page: 1,
      pageSize: 50,
      totalPages: 0,
      onePage: false,
      emptyPage: false,
      loading: false,
      pageData: [],
      startCallbackFunc: null,
      endCallbackFunc: null,
      lastCallback: 'none',
      showSidebar: true,
      roomId: null,

      roomAvatarType: 'room',
      inputArea: 'input',
      inputAreaAlertIcon: mdiExclamationThick,

      lastSeen: 'был(а) давно',
      chatOnline: false,
      companionId: null,

      chatOnlineValue: false,

      isTyping: false,

      typingTimer: 0,

      newDialogue: false,
      sendShiftEnter: false,

      userSex: null,
      deniedMessage: '',
      roomLink: '',
      mdiArrowLeft,
      mdiSend,
      mdiDotsVertical,
      mdiChevronDown,

      showTopMenu: true,

      mdiBroom,
      mdiDelete,
      mdiBrushVariant,
      mdiBell,
      mdiBellCancel,
      mdiCancel,

      ldOvTimer: 0,
      showChatLoading: false,
      showChatLoadingError: false,

      loadingOverlayTimer: {}, // as NodeJS.Timeout,
      roomDenied: false, // as false | string,
      showChatLoadingErrorMsg: 'Что то пошло не так...0',
      companionClientsOnline: {},
      isSentOffline: false,

      messengerMenu: [],
      userNotifyMenu: 0, // 0: hide, 1: ON, 2: OFF
      blockUserMenu: 0,
    };
  },

  async created() {
    const roomRequest = await this.messengerStore.roomGet(this.idMixed);

    if (!(roomRequest && roomRequest !== true)) {
      //
    }

    this.roomDenied = roomRequest.denied;

    switch (roomRequest.type) {
      case RoomType.initDialogue: {
        this.roomLink = `/profile/${roomRequest.user.userId}`;
        this.type = roomRequest.type;
        this.newDialogue = true;
        this.roomName = roomRequest.user.userName;
        this.roomAvatar = roomRequest.user.userAvatar;
        this.roomAvatarType = 'avatar';
        this.lastSeen = roomRequest.user.lastSeen;
        this.companionId = roomRequest.user.userId;
        this.roomId = this.idMixed;
        this.userSex = roomRequest.user.sex;
        this.isShowAvatar = false;
        this.isShowName = false;

        await this.appStore.socketService.joinToUserRoom(
          roomRequest.user.userId
        );
        this.pageData = [{ type: 0 }]; // Empty invisible message
        break;
      }
      case RoomType.dialogue: {
        this.roomLink = `/profile/${roomRequest.subscribe.companionUser.id}`;
        this.companionId = roomRequest.subscribe.companionUser.id;
        this.roomId = roomRequest.id;
        this.type = roomRequest.type;
        this.lastSeen = roomRequest.subscribe.companionUser.lastSeen;
        this.userSex = roomRequest.subscribe.companionUser.profile.sex;
        this.roomAvatar = roomRequest.subscribe.companionUser.avatar;
        this.roomAvatarType = 'avatar';
        this.roomName = roomRequest.subscribe.companionUser.userName;
        this.isShowAvatar = false;
        this.isShowName = false;

        this.userNotifyMenu = 1;

        if (roomRequest.subscribe.notifyState === NotifyState.disabled) {
          this.userNotifyMenu = 1;
        } else this.userNotifyMenu = 2;

        if (roomRequest.isBannedCompanion) {
          this.blockUserMenu = 2;
        } else this.blockUserMenu = 1;

        break;
      }

      case RoomType.channel: {
        if (roomRequest.subscribe === null) {
          this.inputArea = 'subscribeChannel';
        } else if (
          roomRequest.subscribe.userRole === SubscribeRole.subscriber
        ) {
          if (roomRequest.subscribe.notifyState === NotifyState.disabled) {
            this.userNotifyMenu = 1;
            this.notifyButtonOn(true);
          } else {
            this.userNotifyMenu = 2;
            this.notifyButtonOn(false);
          }
        }

        this.roomLink = `/im/room/${roomRequest.id}`;
        this.roomId = roomRequest.id;
        this.type = roomRequest.type;
        this.roomAvatar = roomRequest.roomAvatar;
        this.roomAvatarType = 'room';
        this.roomName = roomRequest.name;
        this.isShowAvatar = false;
        this.countSubscribers = roomRequest.countSubscribers;

        await this.appStore.socketService.joinToRoom(roomRequest.id);
        break;
      }

      case RoomType.group: //{
        if (roomRequest.subscribe === null) {
          this.inputArea = 'subscribeGroup';
        } else if (roomRequest.subscribe.notifyState === NotifyState.disabled) {
          this.userNotifyMenu = 1;
        } else this.userNotifyMenu = 2;

        this.roomLink = `/im/room/${roomRequest.id}`;
        this.roomId = roomRequest.id;
        this.type = roomRequest.type;
        this.roomAvatar = roomRequest.roomAvatar;
        this.roomAvatarType = 'room';
        this.roomName = roomRequest.name;
        this.isShowAvatar = true;
        this.countSubscribers = roomRequest.countSubscribers;

        const joinResult = await this.appStore.socketService.joinToRoom(
          roomRequest.id
        );
        console.log(joinResult);
        if (joinResult?.data?.denied) {
          this.deniedMessage = 'Доступ запрещен';
          this.inputArea = 'alert';
        }

        break;
    }

    if (roomRequest.type !== RoomType.initDialogue) {
      await this.loadData();
    }

    this.chunks = [
      {
        offset: 0,
        items: this.pageData,
      },
    ];
  },

  async mounted() {
    setInterval(async () => {
      this.chatOnlineValue = false;

      for (let sessionId in this.companionClientsOnline) {
        if (
          this.companionClientsOnline[sessionId].timestamp >=
          new Date() + 1000 * 10
        )
          delete this.companionClientsOnline[sessionId];
      }

      if (document.visibilityState === 'visible') {
        this.appStore.socketService.callUser();
        this.isSentOffline = false;
      } else {
        if (!this.isSentOffline) {
          this.appStore.socketService.userOffline();
          this.isSentOffline = true;
        }
      }

      setTimeout(() => {
        if (this.chatOnlineValue === false) {
          this.chatOnline = false;
        } else this.chatOnline = true;
      }, 3000);
    }, 10000);
  },

  async beforeUnmount() {
    if (typeof this.roomId === 'string' && this.roomId.charAt(0) === 'u') {
      this.appStore.socketService.leaveRoom(this.roomId.toString());
    }
    this.appStore.activeRoom = null;
  },

  async updated() {
    await this.appStore.socketService.callUser();
    const options = {
      root: document.querySelector('.area'),
    };

    const handleEndCallback = debounce(async (entries) => {
      endCallback(entries);
    }, 200);

    const handleStartCallback = debounce(async (entries) => {
      startCallback(entries);
    }, 0);

    const endCallback = async (entries) => {
      this.lastCallback = 'endCallback';

      if (!this.loading) {
        let entriesCheck = false;
        if (entries === undefined) {
          entriesCheck = true;
        } else {
          entriesCheck = entries[0].intersectionRatio > 0;
        }

        if (entriesCheck) {
          if (this.lastChunk >= this.totalPages) return;

          let newChunks = { ...this.chunks };
          newChunks = Object.keys(newChunks).map(function (key) {
            return newChunks[key];
          });
          this.page = this.lastChunk + 1;

          if (!(await this.loadData())) return;

          let offset;
          if (this.startOffsetTop === 0) {
            offset = document.querySelector('.sentinel').offsetTop;
          } else {
            offset = this.startOffsetTop;
            this.correctionOffset =
              document.querySelector('.sentinel').offsetTop -
              this.startOffsetTop;
            this.startOffsetTop = 0;
          }

          newChunks.push({
            items: this.pageData,
            offset: this.offsets[this.lastChunk] + offset + 1000,
          });

          this.offsets[this.lastChunk + 1] =
            this.offsets[this.lastChunk] + offset + 1000;
          if (newChunks.length > 2) {
            newChunks.shift();
          }

          this.chunks = newChunks;

          if (this.chunks.length === 2) {
            this.chunks[1].offset =
              this.chunks[1].offset + this.correctionOffset;
          }

          this.lastChunk++;
          setTimeout(function () {
            startObserver.observe(document.querySelector('.sentinel-start'));
            endObserver.observe(document.querySelector('.sentinel'));
          }, 1000);
        }
      }
    };

    const startCallback = async (entries) => {
      this.lastCallback = 'startCallback';
      if (!this.loading) {
        let entriesCheck = false;
        if (entries === undefined) {
          entriesCheck = true;
        } else {
          entriesCheck = entries[0].intersectionRatio > 0;
        }

        if (entriesCheck) {
          this.$refs.messages.style.overflow = 'hidden';
          let newChunks = { ...this.chunks };
          newChunks = Object.keys(newChunks).map(function (key) {
            return newChunks[key];
          });

          this.page = this.lastChunk - 2;

          if (!(await this.loadData())) return;

          if (this.lastChunk === 3) {
            this.chunks[0].offset =
              this.chunks[0].offset - this.correctionOffset;
            this.chunks[1].offset =
              this.chunks[1].offset - this.correctionOffset;
            this.correctionOffset = 0;
          }

          newChunks.unshift({
            items: this.pageData,
            offset:
              this.offsets['' + (this.lastChunk - 2)] + this.correctionOffset,
          });
          if (newChunks.length >= 2) {
            newChunks.pop();
            this.lastChunk--;
          }
          this.chunks = newChunks;
          setTimeout(function () {
            startObserver.observe(document.querySelector('.sentinel-start'));
            endObserver.observe(document.querySelector('.sentinel'));
          }, 1000);
        }
      }
    };

    this.startCallbackFunc = handleStartCallback;
    this.endCallbackFunc = handleEndCallback;

    let endObserver = new IntersectionObserver(handleEndCallback, options);

    let endTarget = document.querySelector('.sentinel');
    if (endTarget !== null && typeof endTarget === 'object') {
      endObserver.observe(endTarget);
    }

    let startObserver = new IntersectionObserver(handleStartCallback, options);

    let startTarget = document.querySelector('.sentinel-start');
    if (startTarget !== null && typeof startTarget === 'object') {
      startObserver.observe(startTarget);
    }
  },

  methods: {
    onDisableNotifications() {
      if (this.changeNotifyState(0)) {
        this.userNotifyMenu = 1;
        this.appStore.setAlertMsg('Уведомления отключены');
        if (this.type === RoomType.channel) this.notifyButtonOn(true);
      }
    },
    onEnableNotifications() {
      if (this.changeNotifyState(1)) {
        this.userNotifyMenu = 2;
        this.appStore.setAlertMsg('Уведомления включены');
        if (this.type === RoomType.channel) this.notifyButtonOn(false);
      }
    },

    async changeNotifyState(notifyState) {
      return (changeNotifyState = await this.messengerStore.changeNotifyState(
        Number(this.roomId),
        notifyState
      ));
    },

    notifyButtonOn(on) {
      if (on) {
        this.inputArea = 'notifyStateOn';
      } else this.inputArea = 'notifyStateOff';
    },

    async onBlockUser() {
      const ban = await this.appStore.socketService.banUser(
        this.companionId,
        this.roomId,
        true
      );
      if (ban.data.banned) {
        this.blockUserMenu = 2;
        this.appStore.setAlertMsg('Пользователь заблокирован');
      }
    },

    async onUnblockUser() {
      const ban = await this.appStore.socketService.banUser(
        this.companionId,
        this.roomId,
        false
      );
      if (!ban.data.banned) {
        this.blockUserMenu = 1;
        this.appStore.setAlertMsg('Пользователь разблокирован');
      }
    },

    async onSubscribe() {
      const subscribeRoom = await this.appStore.socketService.subscribeRoom(
        this.roomId
      );

      if (subscribeRoom?.data) {
        if (this.type === RoomType.channel) {
          this.userNotifyMenu = 2;
          this.notifyButtonOn(false);
        } else {
          this.inputArea = 'input';
          this.userNotifyMenu = 2;
        }

        const messageSent = {
          name: this.userStore.userName,
          type: 12,
        };

        this.addMessage(messageSent);
      }
      if (subscribeRoom?.denied) this.roomDenied = subscribeRoom.denied;
    },

    reloadChat() {
      location.reload();
    },
    setLoadingChat() {
      clearTimeout(this.loadingOverlayTimer);
      this.showChatLoading = true;
      this.loadingOverlayTimer = setTimeout(() => {
        this.showChatLoading = false;
        this.showChatLoadingError = true;
      }, 10000);
    },
    offLoadingChat() {
      clearTimeout(this.loadingOverlayTimer);
      this.showChatLoading = false;
      this.showChatLoadingError = false;
    },

    showChatLoadingOverlay() {
      this.showChatLoading = true;
    },

    setLoading(enable = true) {
      if (typeof enable === 'string') {
        this.showChatLoadingErrorMsg = enable;
        clearTimeout(this.ldOvTimer);
        this.showChatLoading = false;
        this.showChatLoadingError = true;
      } else if (enable) {
        clearTimeout(this.ldOvTimer);
        this.showChatLoading = true;
        this.ldOvTimer = setTimeout(() => {
          this.showChatLoading = false;
          this.showChatLoadingError = true;
        }, 10000);
      } else {
        clearTimeout(this.ldOvTimer);
        this.showChatLoading = false;
        this.showChatLoadingError = false;
      }
    },

    hideChatLoadingOverlay() {
      this.showChatLoading = false;
    },

    async onInputMessage(event) {
      event.target.style.height = 'auto';
      event.target.style.height = event.target.scrollHeight + 'px';
      await this.appStore.socketService.typing(this.roomId.toString());
    },

    toBeginButton() {
      this.$refs.messages.scrollTop = 0;
      this.reload();
    },

    handleScroll(event) {
      const scrollTop = event.target.scrollTop;
      if (scrollTop === 0 && this.allowReload) {
        this.reload();
      } else {
        if (scrollTop > 2500) this.allowReload = true;
        if (scrollTop > 700) {
          this.toBeginButtonVisible = true;
        } else this.toBeginButtonVisible = false;
      }
    },

    onChatMenuBtn() {
      const menuNotifyEnabled = {
        title: 'Включить уведомления',
        icon: mdiBell,
        click: this.onEnableNotifications,
      };
      const menuNotifyDisabled = {
        title: 'Отключить уведомления',
        icon: mdiBellCancel,
        click: this.onDisableNotifications,
      };

      const menuBlockUser = {
        title: 'Заблокировать',
        icon: mdiCancel,
        color: 'red', // color: '#e46d6d',
        click: this.onBlockUser,
      };
      const menuUnblockUser = {
        title: 'Разблокировать',
        icon: mdiCancel,
        color: 'green',
        click: this.onUnblockUser,
      };

      const menuProfilePage = {
        title: 'Профиль пользователя',
        icon: mdiExclamationThick,
        click: () => {
          this.$router.push(`/profile/${this.companionId}`);
        },
      };

      const menuRoomPage = {
        title: 'Информация',
        icon: mdiExclamationThick,
        click: () => {
          this.$router.push(`/im/room/${this.roomId}`);
        },
      };

      this.messengerMenu = [];

      switch (this.type) {
        case 0:
          this.messengerMenu.push(menuProfilePage);
          break;
        case 1:
          this.messengerMenu.push(menuProfilePage);
          break;
        default:
          this.messengerMenu.push(menuRoomPage);
          break;
      }

      switch (this.userNotifyMenu) {
        case 1:
          this.messengerMenu.push(menuNotifyEnabled);
          break;
        case 2:
          this.messengerMenu.push(menuNotifyDisabled);
          break;
      }

      switch (this.blockUserMenu) {
        case 1:
          this.messengerMenu.push(menuBlockUser);
          break;
        case 2:
          this.messengerMenu.push(menuUnblockUser);
          break;
      }

      this.appStore.showDropMenu = true;
    },

    async menuTest() {
      this.chunks[0].items[0].status = 1;
    },

    async menuTestAdd() {
      //
    },

    addMessage(message) {
      console.log(message);
      this.chunks[0].items.unshift(message);
    },

    async inputHandler(e) {
      if (e.keyCode === 13 && !e.shiftKey && !this.sendShiftEnter) {
        e.preventDefault();
        this.sendMessage();
        e.target.style.height = 'auto';
      }

      if (e.keyCode === 13 && e.shiftKey && this.sendShiftEnter) {
        e.preventDefault();
        this.sendMessage();
        e.target.style.height = 'auto';
      }
    },

    async sendMessage() {
      if (!this.inputMessage) return false;

      if (this.startOffsetTop === 0) {
        this.startOffsetTop = document.querySelector('.sentinel').offsetTop;
      }

      if (this.$refs.messages.scrollTop > 700) {
        this.$refs.messages.scrollTop = 0;
        this.reload();
      }

      const message = {
        roomOrUserId: this.roomId.toString(),
        content: this.inputMessage,
        type: 1,
        sendAvatar: this.userStore.userAvatar,
      };

      const newMessage = await this.appStore.socketService.createMessage(
        message
      );

      if (newMessage.data.dialogueCreated) {
        console.log('Dialogue created');
        this.roomId = newMessage.data.roomId;
        this.userNotifyMenu = 2;
        this.blockUserMenu = 1;
      }

      if (newMessage.data.denied) {
        console.log('Room access denied: ' + newMessage.data.denied);
        this.roomDenied = newMessage.data.denied;
        return false;
      }

      let msgName = this.userStore.userName;
      if (this.type === RoomType.channel) {
        msgName = this.roomName;
      }

      const messageSent = {
        name: msgName,
        text: this.inputMessage,
        date: new Date(),
        avatar: this.userStore.userAvatar,
        sent: false,
        type: 1,
        status: 0,
      };

      this.inputMessage = '';
      this.$refs.inputField.style.height = 'auto';
      this.addMessage(messageSent);
    },

    async reload() {
      this.chunks = [];
      this.offsets = { 1: 0 };
      this.lastChunk = 1;
      this.startFlag = false;
      this.page = 1;
      this.pageSize = 50;
      this.totalPages = 0;
      this.loading = false;
      this.pageData = [];
      this.lastCallback = 'none';
      this.allowReload = false;

      await this.loadData();
      this.chunks = [
        {
          offset: 0,
          items: this.pageData,
        },
      ];
    },

    isSent(owner) {
      if (this.type === RoomType.channel || this.type === RoomType.group) {
        return false;
      }

      if (owner === this.userStore.userId) {
        return false;
      } else return true;
    },

    async loadData() {
      this.newMessages = [];

      if (this.page >= 1) {
        this.loading = true;
        this.setLoading();
        const response = await this.messengerStore.messagesGet(
          this.roomId,
          this.page,
          this.pageSize
        );
        if (response.messages) {
          this.totalPages = response.totalPages;

          this.pageData = response.messages.map((item) => {
            let type = Number(item.type);
            let msgName = item.ownerUser.userName;
            if (this.type === RoomType.channel) {
              if (item.type == 12) type = 0;
              msgName = this.roomName;
            }

            const pageDataMapped = {
              userId: item.owner,
              name: msgName,
              text: item.content,
              date: item.createdAt,
              avatar: item.ownerUser.avatar,
              sent: this.isSent(item.owner),
              type, //: Number(item.type),
            };

            return pageDataMapped;
          });

          setTimeout(() => {
            this.$refs.messages.style.overflow = 'auto';
          }, 200);

          this.loading = false;
          this.setLoading(false);
          return true;
        } else {
          this.loading = false;
          this.setLoading(false);
          if (response.denied) {
          } else {
            this.loading = false;
            this.setLoading(response.error);
            console.log('Messages get error: ' + response.error);
          }

          this.pageData = [{ type: 0 }]; // Empty invisible message
          this.$refs.messages.style.overflow = 'auto';
          return false;
        }
      }
    },

    declOfNum(number, titles) {
      const cases = [2, 0, 1, 1, 1, 2];
      return titles[
        number % 100 > 4 && number % 100 < 20
          ? 2
          : cases[number % 10 < 5 ? number % 10 : 5]
      ];
    },
  },
  computed: {
    chatDesc() {
      switch (this.type) {
        case RoomType.bot:
          return 'Бот';
        case RoomType.channel:
          const subscriberWord = this.declOfNum(this.countSubscribers, [
            'подписчик',
            'подписчика',
            'подписчиков',
          ]);
          return this.countSubscribers + ' ' + subscriberWord;
        case RoomType.initDialogue:
        case RoomType.dialogue: {
          let label;

          switch (this.userSex) {
            case 1:
              label = 'был ';
              break;
            case 2:
              label = 'была ';
              break;
            default:
              label = 'был(а) ';
          }

          if (this.lastSeen) {
            return (
              label +
              date.formatDate(this.lastSeen, 'D.MM.YYYY в HH:mm', dateTokens)
            );
          } else return label + 'никогда';
        }

        case RoomType.group:
          const memberWord = this.declOfNum(this.countSubscribers, [
            'участник',
            'участника',
            'участников',
          ]);
          return this.countSubscribers + ' ' + memberWord;
        default:
          return 'загрузка...';
      }
    },
    newMessage() {
      return this.appStore.lastMessage;
    },
    userOnline() {
      return this.appStore.userOnline; //?.online;
    },

    typing() {
      return this.appStore.typing;
    },

    isConnecting() {
      return !this.appStore.isSocketConnected;
    },
  },
  watch: {
    idMixed() {
      //
    },

    roomId() {
      this.appStore.activeRoom = this.roomId;
    },

    newMessage() {
      const msg = this.appStore.lastMessage;

      let sent = false;
      let msgName = msg.name;
      let type = Number(msg.type);

      if (msg.userId !== this.userStore.userId) {
        sent = true;
      }

      if (this.type === RoomType.channel) {
        sent = false;
        msgName = this.roomName;
        if (msg.type == 12) type = 0;
      }

      if (this.type === RoomType.group) {
        sent = false;
      }

      if (msg.msgId) {
        const message = {
          name: msgName,
          text: msg.content,
          date: msg.date,
          avatar: msg.avatar,
          sent,
          type, //: msg.type,
        };
        if (msg.roomId === this.roomId) this.addMessage(message);
      }
    },

    userOnline() {
      if (this.appStore.userOnline.userId === this.companionId) {
        if (this.appStore.userOnline.online) {
          this.lastSeen = new Date(
            this.appStore.userOnline.timestamp
          ).toISOString();
        }

        this.companionClientsOnline[this.appStore.userOnline.sessionId] = {
          status: this.appStore.userOnline.online,
          timestamp: this.appStore.userOnline.timestamp,
        };

        for (let sessionId in this.companionClientsOnline) {
          if (this.companionClientsOnline[sessionId].status) {
            this.chatOnline = true;
            this.chatOnlineValue = true;
            return;
          }
        }

        this.chatOnline = false;
        this.chatOnlineValue = false;
      }
    },

    typing() {
      clearTimeout(this.typingTimer);
      if (
        this.appStore.typing.typing === true &&
        this.appStore.typing.roomId === this.roomId.toString()
      ) {
        this.isTyping = true;
        this.typingTimer = setTimeout(() => {
          this.isTyping = false;
        }, 1000);
      }
    },

    roomIdRaw() {
      //
    },

    roomDenied() {
      console.log('Denied: ' + this.roomDenied);
      switch (this.roomDenied) {
        case false:
          this.inputArea = 'input';
          break;
        case 'userPrivacy':
          this.deniedMessage =
            'Пользователь ограничил круг лиц, которые могут присылать ему сообщения';
          this.inputArea = 'alert';
          break;
        case 'userBan':
          this.deniedMessage = 'Пользователь заблокировал вас';
          this.inputArea = 'alert';
          break;
        case 'adminUserBan':
          this.deniedMessage = 'Пользователь забанен администратором';
          this.inputArea = 'alert';
          break;
        case 'roomDeleted':
          this.deniedMessage = 'Комната удалена';
          this.inputArea = 'alert';
          break;
        case 'roomBanned':
          this.deniedMessage = 'Комната заблокирована';
          this.inputArea = 'alert';
          break;
        case 'roomReadonly':
          this.deniedMessage = 'Только для чтения';
          this.inputArea = 'alert';
          break;

        case 'roomSubscribersOnly':
          this.deniedMessage = 'Доступ только для подписчиков';
          this.inputArea = 'alert';
          break;

        case 'roomNotApproved':
          this.deniedMessage =
            'Доступ только после подтверждения администратором';
          this.inputArea = 'alert';
          break;

        case 'roomRole':
          this.deniedMessage = 'У вас нет прав создавать посты в этом канале';
          this.inputArea = 'alert';
          break;
      }
    },
  },
});
</script>

<style>
html,
body {
  overflow: hidden;
  margin: 0;
  padding: 0;
}
</style>

<style scoped lang="scss">
.container-all-chat {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 2000;
}

.no-framework-text *,
.no-framework-text p,
.no-framework-text h1,
.no-framework-text h2,
.no-framework-text h3,
.no-framework-text h4,
.no-framework-text h5,
.no-framework-text h6 {
  margin: 0;
  padding: 0;
  font-size: inherit;
  font-weight: normal;
  line-height: normal;
  text-align: inherit;
  color: inherit;
  font-family: inherit;
  text-decoration: none;
  text-transform: none;
  letter-spacing: normal;
  word-spacing: normal;
  white-space: normal;
}

input[type='text'],
input[type='email'],
input[type='tel'],
input[type='number'],
textarea {
  -webkit-appearance: none;
  -moz-appearance: none;
  -ms-appearance: none;
  appearance: none;
  outline: 0;
  box-shadow: none;
}

/* -------------------------------------------------------------------------------- */
.header {
  flex: 0 0 auto;
  padding: 0 5px;
  background: #272727;
  color: white;
  text-align: center;
  height: 60px;
  display: flex;
  flex-direction: row;
  align-items: center;
  z-index: 10000;
}

.header-button-arrow {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-left: 5px;
  color: white;
}

.header-caption {
  display: flex;
  flex-direction: column;
  align-items: start;
  margin-left: 12px;
  white-space: nowrap;
}

.header-caption-title {
  font-weight: bold;
  white-space: inherit;
  font-size: 17px;
  color: rgb(243, 243, 243);
}

.header-caption-desc {
  padding-top: 2px;
  font-size: 14px;
  color: #aeaecc;
  white-space: inherit;
}

.online {
  color: #aba2ff;
}

.loading {
  /* font-weight: bold; */
  display: inline-block;
  font-family: monospace;
  clip-path: inset(0 3ch 0 0);
  animation: l 1s steps(4) infinite;
}

@keyframes l {
  to {
    clip-path: inset(0 -1ch 0 0);
  }
}

.header-menu {
  display: flex;
  flex-direction: row-reverse;
  width: 100%;
}

.header-button-menu {
  display: flex;
  align-items: center;
  height: 100%;
  padding-right: 5px;
  color: white;
}

.avatar-header {
  display: flex;
  justify-content: center;
  align-items: center;
  padding-left: 15px;
  color: white;
}

.avatar-chat-body {
  display: flex;
  align-items: end;
}

/* ------------------------------------------------------------------------------------------- */
.input-area {
  display: flex;
  flex: 0 0 auto;
  padding: 5px;
  padding-left: 15px;
  background: #272727;
  z-index: 1001;
  height: auto;
}

.input-field {
  flex: 1;
  margin-right: 10px;
  border: none;
  font-size: 19px;
  background: #272727;
  caret-color: white;
  color: white;
  padding: 9px 7px 3px 5px;
  max-height: 300px;
  resize: none;
}

.send-button {
  padding-right: 5px;
  border: none;
  background: transparent;
  color: #6589ff;
}

/* ------------------------------------------------------------------------------------------- */

.chat-container {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background-size: contain;
  background-color: #000000;
}

.messages {
  flex: 1 1 auto;
  position: relative;
  overflow-y: auto;
  transform: scale(1, -1);
}

.messagesXXX {
  overflow-y: auto;
  transform: scale(1, -1);
  width: 100%;
}

.msg-overlay {
  display: flex;
  flex: 1 1 auto;
  overflow: hidden;
}

.msg-loading {
  overflow: hidden;
  display: flex;
  flex: 1 1 auto;
}

.bgChat {
  position: sticky;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 10000;
  background-color: #0000003d;
}

.bgChatZ {
  position: fixed;
  width: 100%;
  height: 100%;
  z-index: 11000000;
  background-color: #ffffff;
}

.overlay-chatQ {
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1000000;
  background-color: #00000044;
  transform: rotate(180deg);
}

.chunk {
  width: 100%;
  position: absolute;
  top: 0;
}

.new-chunk {
  width: 100%;
  position: absolute;
  top: 0;
}

.chunk > .sentinel {
  position: absolute;
  pointer-events: none;
  height: 1000px;
  width: 100%;
  visibility: hidden;
  bottom: 0;
  z-index: 9999;
  border: 3px solid blue;
}

.chunk > .sentinel-start {
  position: absolute;
  pointer-events: none;
  height: 1000px;
  width: 100%;
  visibility: hidden;
  top: 0;
  z-index: 9999;
  border: 3px solid red;
}

.chunk > .sentinel-start.disabled,
.chunk > .sentinel.disabled {
  display: none;
}

.toBeginButton {
  width: 45px;
  height: 45px;
  position: absolute;
  z-index: 1000;
  bottom: 70px;
  right: 10px;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #272727;
  color: white;
}

@keyframes aniUp {
  from {
    transform: translateY(150%);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

@keyframes aniDown {
  to {
    transform: translateY(150%);
    opacity: 0;
  }
}

.toggleBB-enter-active,
.toggleBB-leave-active {
  animation: aniUp 0.3s;
}

.toggleBB-leave-active,
.toggleBB-leave-to {
  animation: aniDown 0.5s;
}

.input-area-alert {
  display: flex;
  flex-direction: row;
  align-items: center;
  height: 55px;
}

.input-area-alert-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  color: #ffffff;
  font-size: 10px;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  background-color: #f6b100;
}

.input-area-alert-msg {
  padding: 0 15px;
  color: white;
  font-size: 14px;
}

.chat-button {
  background: #272727;
  z-index: 1001;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 50px;
  color: white;
  width: 100%;
  font-size: 15px;
  text-transform: uppercase;
}

.chat-button:active {
  background-color: #2c2c2c;
}

.btn-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  cursor: pointer;
}

.btn-icon:active {
  background-color: #353535;
}

.btn-send {
  color: #3f97f6;
}

.chat-loading-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #00000027;
  z-index: 1001;
  display: flex;
  justify-content: center;
  align-items: center;
}

.chat-loading-overlay-err {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(26, 26, 26, 0.918);
  z-index: 1001;
  display: flex;
  justify-content: center;
  align-items: center;
}

.chat-loading-overlay-err > div {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding-top: 20px;
}

.chat-loading-overlay-err > div > div {
  margin: 30px 0;
  color: white;
}

.chat-loading-overlay-err > div > button {
  padding: 15px 70px;
  border: 1px solid rgb(63, 63, 63);
  cursor: pointer;
  background-color: rgb(228, 228, 228);
  font-size: 20px;
  border-radius: 5px;
}

.chat-loading-overlay-err > div > button:active {
  background-color: rgb(202, 202, 202);
}

.loader,
.loader:before,
.loader:after {
  border-radius: 50%;
  width: 2em;
  height: 2em;
  animation-fill-mode: both;
  animation: bblFadInOut 1.2s infinite ease-in-out;
}
.loader {
  color: #222222;
  font-size: 7px;
  position: relative;
  text-indent: -9999em;
  transform: translateZ(0);
  animation-delay: -0.16s;
}
.loader:before,
.loader:after {
  content: '';
  position: absolute;
  top: 0;
}
.loader:before {
  left: -3.5em;
  animation-delay: -0.32s;
}
.loader:after {
  left: 3.5em;
}

@keyframes bblFadInOut {
  0%,
  80%,
  100% {
    box-shadow: 0 2.5em 0 -1.3em;
  }
  40% {
    box-shadow: 0 2.5em 0 0;
  }
}

.newMessage {
  width: 100px;
  height: 30px;
  background-color: #3f97f6;
}
</style>


В дополнение к коду выше стоит привести реализацию компонента ChatBubble:

TypeScript
<template>
  <div>
    <div v-if="!sent" class="message">
      <div class="chat">
        <div v-if="showAvatar" class="avatar-chat-body">
          <router-link :to="`/profile/${userId}`">
            <chat-avatar
              :size="'msg'"
              :type="'avatar'"
              :name="name"
              :avatar="avatar"
            />
          </router-link>
        </div>
        <div class="bubble-body" :style="theme.colorBubble">
          <div v-if="showName" class="bubble-name" :style="theme.colorName">
            {{ name }}
          </div>
          <div class="bubble-text" :style="theme.colorText">
            <div class="msg-text">{{ text }}</div>
            <div class="msg-date">{{ msgDate }}</div>
          </div>

          <div class="footer">
            <div v-if="showStatus" class="msg-status-icon" :style="statusColor">
              <svg-icon type="mdi" :path="statusIcon" size="15"></svg-icon>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div v-if="sent" class="message msg-sent">
      <div class="chat">
        <div class="bubble-body-sent" :style="theme.colorBubbleSent">
          <div
            v-if="showName"
            class="bubble-name-sent"
            :style="theme.colorNameSent"
          >
            {{ name }}
          </div>
          <div class="bubble-text-sent" :style="theme.colorTextSent">
            <div class="msg-text">{{ text }}</div>
            <div class="msg-date-sent">{{ msgDate }}</div>
          </div>
          <div class="footer">
            <div v-if="showStatus" class="msg-status-icon" :style="statusColor">
              <svg-icon
                type="mdi"
                :path="statusIcon"
                size="15"
                :style="statusColor"
              ></svg-icon>
            </div>
          </div>
        </div>
        <div v-if="showAvatar" class="avatar-chat-body-sent">
          <router-link :to="`/profile/${userId}`">
            <chat-avatar
              :size="'msg'"
              :type="'avatar'"
              :name="name"
              :avatar="avatar"
            />
          </router-link>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import ChatAvatar from '../../avatar/ChatAvatar.vue';
import { date } from 'quasar';
import dateTokens from '../../../i18n/ru/date';
import SvgIcon from '@jamescoyle/vue-icon';

import { mdiUpdate, mdiCheck, mdiCheckAll } from '@mdi/js';

export default defineComponent({
  name: 'ChatBubble',
  props: [
    'sent',
    'name',
    'text',
    'date',
    'avatar',
    'status',
    'showAvatar',
    'showName',
    'showStatus',
    'userId',
  ],
  components: { ChatAvatar, SvgIcon },

  data() {
    return {
      theme: {
        colorBubble: { backgroundColor: 'white' },
        colorBubbleSent: { backgroundColor: '#effddb' },
        colorName: { color: '#141313' },
        colorNameSent: { color: '#141313' },
        colorDate: { color: '#141313' },
        colorDateSent: { color: '#141313' },
        colorText: { color: 'black' },
        colorTextSent: { color: 'black' },
      },

      mdiUpdate,
      mdiCheck,
      mdiCheckAll,
    };
  },
  computed: {
    msgDate() {
      return date.formatDate(this.date, 'HH:mm', dateTokens);
    },
    statusIcon() {
      switch (this.status) {
        case 0:
          return mdiUpdate;
        case 1:
          return mdiCheck;
        case 2:
          return mdiCheckAll;
        default:
          return mdiUpdate;
      }
    },
    statusColor() {
      switch (this.status) {
        case 0:
          return { color: 'black' };
        case 1:
          return { color: '#green' };
        case 2:
          return { color: '#green' };
        default:
          return { color: 'red' };
      }
    },
  },
});
</script>

<style scoped>
.avatar-chat-body {
  display: flex;
  align-items: end;
  margin-right: -8px;
}

.avatar-chat-body-sent {
  display: flex;
  align-items: end;
  margin-left: -7px;
}

/* ------------------------------------------------------------------------------------------- */

.message {
  display: flex;
  flex-direction: column;
  width: 100%;
  margin-bottom: 7px;
  margin-top: 7px;
  padding-left: 5px;
  padding-right: 5px;
  background: transparent;
  transform: scale(1, -1);
  overflow-wrap: break-word;
}

.msg-sent {
  align-items: end;
}

/* ------------------------------------------------------------------------------------------- */

.chat {
  display: flex;
  flex-direction: row;
  min-height: 35px;
  background-color: transparent;
}

.bubble-body {
  border-radius: 15px 15px 15px 0;
  background-color: white;
  margin-left: 7px;
  position: relative;
  padding: 7px 10px 7px 14px;
  display: flex;
  flex-flow: column;
  margin-left: 15px;
  max-width: calc(100vw - 33px);
  margin-right: 15px;
}

.bubble-body::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: -19px;
  display: block;
  height: 20px;
  width: 20px;
  z-index: 100;
  background: inherit;
  clip-path: url(#svgPathLeft);
  background-color: inherit;
}

.bubble-name {
  font-size: 14px;
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  color: #141313;
  margin-bottom: 3px;
  font-weight: bold;
}

.bubble-text {
  color: black;
  font-size: 17px;
  line-height: 20px;
}

.msg-text {
  display: inline;
}

.msg-date {
  font-size: 12px;
  padding-top: 8px;
  flex-wrap: wrap;
  padding-left: 10px;
  margin-top: -2px;
  display: inline;
  float: right;
  color: #9e9d9d;
}

.msg-date-sent {
  font-size: 12px;
  padding-top: 8px;
  flex-wrap: wrap;
  padding-left: 10px;
  margin-top: -2px;
  display: inline;
  float: right;
  color: rgb(101, 172, 109);
}

.bubble-date {
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  text-align: right;
  font-size: 12px;
  color: #181515;
  margin-top: 3px;
}

.footer {
  display: flex;
}

.msg-status-icon {
  padding-top: 1px;
  padding-left: 4px;
  margin-bottom: -3px;
  margin-right: -3px;
}

/* ------------------------------------------------------------------------------------------- */

.bubble-bodyss {
  border-radius: 15px 15px 15px 0;
  background-color: white;
  margin-left: 7px;
  position: relative;
  padding: 7px 10px 7px 14px;
  display: flex;
  flex-flow: column;
  margin-left: 15px;
  max-width: 370px;
}

.bubble-body-sent {
  border-radius: 15px 15px 0 15px;
  background-color: #3490eb;
  margin-right: 14px;
  position: relative;
  padding: 7px 10px 7px 14px;
  display: flex;
  flex-flow: column;
  color: white;
  margin-left: 15px;
  max-width: calc(100vw - 31px);
  overflow-wrap: break-word;
}

.bubble-body-sent::after {
  content: '';
  position: absolute;
  bottom: 0;
  right: -19px;
  display: block;
  height: 20px;
  width: 20px;
  z-index: 100;
  background: inherit;
  clip-path: url(#svgPathRight);
  background-color: inherit;
}

.bubble-name-sent {
  font-size: 14px;
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  color: #ffffff;
  margin-bottom: 3px;
  font-weight: bold;
}

.bubble-text-sent {
  color: black;
  font-family: 'Helvetica Neue', Helvetica, sans-serif;
  font-size: 18px;
  color: #ffffff;
}
</style>

Страница профиля пользователя GetProfile


Приведу ее код целиком:

TypeScript
<template>
  <div class="q-pa-md" style="max-width: 700px; margin: auto">
    <q-list separator>
      <q-item
        clickable
        v-ripple
        class="profile"
        :to="isOwner || isPrivileged ? '/account/avatar/' + id : ''"
      >
        <user-avatar
          :userId="id"
          :avatar="avatar"
          :userName="userName"
          :size="'high'"
          :badgeType="'edit'"
        />
      </q-item>
      <q-item
        :to="isOwner || isPrivileged ? '/account/nickname/' + id : ''"
        clickable
        v-ripple
        class="profile"
        style="border-top: none; padding: 12px 0 10px 0; font-size: 25px"
      >
        {{ userName }}
        <q-icon
          v-if="isOwner || isPrivileged"
          name="edit"
          size="1.1rem"
          color="primary"
        />
      </q-item>

      <q-item v-if="!isOwner" style="border-top: none; padding-bottom: 15px">
        <div style="display: flex; justify-content: space-between; width: 100%">
          <friend-button :toId="userId" v-if="userId"></friend-button>

          <q-btn :to="'/im/u' + userId" flat style="color: #3b3847"
            ><q-icon name="chat"></q-icon>  Сообщение</q-btn
          >
        </div>
      </q-item>

      <q-item style="border-top: none">
        <q-item-section>
          <q-item-label
            ><div class="friendbox">
              <div class="friendcolumn">
                <router-link :to="'/friends/mutually/' + id">
                  <div class="friendButton">
                    Друзья: {{ mutuallyCount }}
                  </div>
                </router-link>
              </div>
              <div class="friendcolumn">
                <router-link :to="'/friends/subscribed/' + id">
                  <div class="friendButton">
                    Подписки: {{ subscribedCount }}
                  </div>
                </router-link>
              </div>
              <div class="friendcolumn">
                <router-link :to="'/friends/subscriber/' + id">
                  <div class="friendButton">
                    Подписчики: {{ subscriberCount }}
                  </div>
                </router-link>
              </div>
            </div></q-item-label
          >
        </q-item-section>
      </q-item>

      <q-item v-ripple>
        <q-item-section>
          <q-item-label>{{ lastSeenLabel }} </q-item-label>
          <q-item-label caption>{{ lastSeenSex }} в сети</q-item-label>
        </q-item-section>
      </q-item>

      <q-item
        clickable
        v-ripple
        :Xto="!isPrivileged ? '/account/roles/' + id : ''"
        v-if="isPrivileged || isPrivilegedUser"
      >
        <q-item-section>
          <q-item-label style="color: red">{{ rolesDescription }}</q-item-label>
          <q-item-label caption>Роли пользователя</q-item-label>
        </q-item-section>
      </q-item>
      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/name/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ firstName }} {{ lastName }}</q-item-label>
          <q-item-label caption>Имя, фамилия</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>
      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/birthday/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ profileBirthday }}</q-item-label>
          <q-item-label caption>День рождения</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>
      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/sex/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ profileGender }}</q-item-label>
          <q-item-label caption>Пол</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>

      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/location/' + id : ''"
      >
        <q-item-section>
          <q-item-label
            >{{ country }}<span><span v-show="country && city">,</span></span>
            {{ city }}</q-item-label
          >
          <q-item-label caption>Страна, город</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>

      <q-item
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/profile/about/' + id : ''"
      >
        <q-item-section>
          <q-item-label>{{ about }}</q-item-label>
          <q-item-label caption>О себе</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>

      <q-item
        v-if="isOwner || isPrivileged"
        clickable
        v-ripple
        :to="isOwner || isPrivileged ? '/account/' + id : ''"
      >
        <q-item-section>
          <q-item-label>Аккаунт</q-item-label>
          <q-item-label caption>Настройка почты, пароля, и др.</q-item-label>
        </q-item-section>
        <q-item-section side v-if="isOwner || isPrivileged">

        </q-item-section>
      </q-item>
    </q-list>
  </div>

  <Overlay-User-404 v-show="!isLoaded" />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import UserAvatar from '../UserAvatar.vue';
import FriendButton from '../../friend/FriendButton.vue';
import OverlayUser404 from '../OverlayUser404.vue';
import { useAppStore } from '../../common/stores/app.store';
import { useUserStore } from '../user.store';
import { useFriendStore } from '../../friend/friend.store';
import { Sex } from '../../params/const';
import { date } from 'quasar';
import dateTokens from '../../i18n/ru/date';

import { DATE_FORMAT } from '../../params/const';

const appStore = useAppStore();
const userStore = useUserStore();
const friendStore = useFriendStore();

export default defineComponent({
  components: { FriendButton, OverlayUser404, UserAvatar },
  name: 'ProfilePage',
  props: ['id'],
  data() {
    return {
      isLoaded: true,
      tabsBar: 'profile',
      userId: 0,
      firstName: '',
      lastName: '',
      about: '',
      birthday: '',
      sex: 0,
      country: '',
      city: '',
      createdAt: '',
      updatedAt: '',
      userName: '',
      avatar: '',
      mutuallyCount: 0,
      subscribedCount: 0,
      subscriberCount: 0,
      rolesDescription: '',
      isPrivilegedUser: false,
      lastSeen: '',
    };
  },
  async mounted() {
    await this.getUserProfile();
    await this.getCountFriends();
    appStore.setCaption('Профиль', '/im');
  },
  methods: {
    async getCountFriends() {
      const count = await friendStore.countFriends(this.id);
      if (count) {
        this.mutuallyCount = count.mutually;
        this.subscribedCount = count.subscribed;
        this.subscriberCount = count.subscriber;
      }
    },

    async getUserProfile() {
      const profile = await userStore.getProfile(this.id);
      if (profile) {
        this.userId = profile.userId;
        this.firstName = profile.firstName ?? '';
        this.lastName = profile.lastName ?? '';
        this.about = profile.about ?? '';
        this.birthday = profile.birthday ?? '';
        this.sex = profile.sex ?? 0;
        this.country = profile.country ?? '';
        this.city = profile.city ?? '';
        this.createdAt = profile.createdAt;
        this.updatedAt = profile.updatedAt;
        this.userName = profile.user.userName;
        this.avatar = profile.user.avatar;
        this.lastSeen = profile.user.lastSeen;
        this.rolesDescription = profile.user.roles
          .map(function (role) {
            return role.description;
          })
          .join(', ');

        const privilegedRoles = ['ADMIN', 'MODERATOR'];
        this.isPrivilegedUser = profile.user.roles.some((role) =>
          privilegedRoles.includes(role.value)
        );

        this.isLoaded = true;
      } else this.isLoaded = false;
    },
  },
  computed: {
    lastSeenLabel() {
      return date.formatDate(this.lastSeen, 'D MMMM YYYY, в H:mm', dateTokens);
    },

    profileBirthday() {
      return date.formatDate(this.birthday, 'D MMMM YYYY', dateTokens);
    },
    profileGender() {
      switch (this.sex) {
        case Sex.none:
          return 'Не указан';
        case Sex.male:
          return 'Мужской';
        case Sex.female:
          return 'Женский';
        case Sex.other:
          return 'Другой';
        default:
          return 'Не указан';
      }
    },

    lastSeenSex() {
      switch (this.sex) {
        case Sex.none:
          return 'Был(а)';
        case Sex.male:
          return 'Был';
        case Sex.female:
          return 'Была';
        case Sex.other:
          return 'Был(а)';
        default:
          return 'Был(а)';
      }
    },

    isOwner() {
      if (userStore.userId === this.id) {
        return true;
      } else {
        return false;
      }
    },

    isPrivileged() {
      return userStore.isPrivileged();
    },
  },

  watch: {
    async id() {
      await this.getUserProfile();
      await this.getCountFriends();
    },
  },
});
</script>

<style scoped>
.profile {
  padding: 10px 0 5px 0;
  display: flex;
  justify-content: center;
}

.tabsBar {
  background: rgba(0, 0, 0, 0.541);
  padding: 0.25rem;
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
}

.friendbox {
  display: flex;
  width: 100%;
}

.friendcolumn {
  flex: 1;
  text-align: center;
  margin-left: -10px;
  box-sizing: border-box;
}

.friendButton {
  padding: 10px;
  margin: 8px;
  border: 1px solid transparent;
}

a {
  text-decoration: none;
  color: black;
}

.friendButton:hover {
  border: 1px solid rgb(240, 240, 240);
  background-color: rgba(230, 230, 230, 0.301);
}

.friendButton:active {
  border: 1px solid rgb(240, 240, 240);
  background-color: rgba(209, 209, 209, 0.301);
}
</style>


Форма EditPassword


Если активировать видимость пароля, форма изменится соответственно:


Приведу код целиком:

TypeScript
<template>
  <center-page>
    <q-form @submit.prevent.stop="onSubmit" class="q-gutter-md">
      <q-input
        v-if="isPasswordSet"
        filled
        class="full-width"
        v-model="oldPassword"
        label="Введите старый пароль"
        :hint="hint"
        lazy-rules
        :rules="passwordRules"
        :type="isPwd ? 'password' : 'text'"
      >
        <template v-slot:append>
          <q-icon
            :name="isPwd ? 'visibility_off' : 'visibility'"
            class="cursor-pointer"
            @click="isPwd = !isPwd"
          />
        </template>
      </q-input>

      <q-input
        filled
        class="full-width"
        v-model="newPassword"
        label="Новый пароль"
        :hint="hint"
        lazy-rules
        :rules="passwordRules"
        :type="isPwd ? 'password' : 'text'"
      >
        <template v-slot:append>
          <q-icon
            v-if="!isPasswordSet"
            :name="isPwd ? 'visibility_off' : 'visibility'"
            class="cursor-pointer"
            @click="isPwd = !isPwd"
          />
        </template>
      </q-input>

      <q-input
        v-if="isPwd"
        filled
        class="full-width"
        v-model="confirmPassword"
        label="Повторите пароль"
        :hint="hint"
        lazy-rules
        :rules="passwordRules"
        type="password"
      />

      <br />
      <div class="buttonBar">
        <q-btn
          unelevated
          label="Сохранить"
          type="submit"
          color="grey-10"
          class="full-width"
          size="lg"
        />
      </div>

      <p class="text-red-6" style="height: 50px; text-align: center">
        {{ error }}
      </p>
    </q-form>
  </center-page>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import CenterPage from '../../components/UI/CenterPage.vue';
import { useAppStore } from '../../common/stores/app.store';
import { useUserStore } from '../../user/user.store';

const appStore = useAppStore();
const userStore = useUserStore();

const passwordRules = [
  (val: string) => (val && val.length > 7) || 'Не менее 8 символов',
  (val: string) => (val && val.length <= 50) || 'Не более 50 символов',
];

export default defineComponent({
  name: 'EditPassword',
  components: { CenterPage },
  props: ['id'],
  data() {
    return {
      hint: '8 - 50 символов',
      passwordRules,
      oldPassword: '',
      newPassword: '',
      confirmPassword: '',
      error: '',
      isPasswordSet: false,
      isPwd: true,
    };
  },
  mounted() {
    appStore.setCaption('Пароль', `/account/${this.id}`);
    this.getAccount();
  },
  methods: {
    async getAccount() {
      const user = await userStore.getUserSelf(this.id);
      if (user) {
        this.isPasswordSet = user.isPasswordSet;
      }
    },
    async onSubmit() {
      if (this.newPassword === this.confirmPassword || !this.isPwd) {
        const editPassword = await userStore.editPassword(
          this.id,
          this.oldPassword,
          this.newPassword
        );

        if (editPassword) {
          this.$router.push(`/account/${this.id}`);
        } else {
          this.error = userStore.error;
        }
      } else {
        this.error = 'Пароли не совпадают';
      }
    },
    cancel() {
      this.$router.push(`/account/${this.id}`);
    },
  },
});
</script>

<style scoped>
.buttonBar {
  position: fixed;
  bottom: 0;
  margin: 0;
  left: 0;
  right: 0;
  padding: 10px;
  padding-bottom: 40px;
}
</style>



Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.

Messenger. Технический обзор сервера (Nest.js)

В этом материале я опишу общую архитектуру и некоторые детали реализации серверной части 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:

Bash
#!/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 модулей и два провайдера:

TypeScript
// ...
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

TypeScript
@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:

  1. getAlladm: Этот метод позволяет администратору получить список всех пользователей. Доступ к методу ограничен только для пользователей с ролью «ADMIN». Метод поддерживает постраничное отображение результатов с параметрами page и pageSize.
  2. getAll: Этот метод предоставляет возможность получить список всех пользователей. Доступ ограничен с помощью защитника AccessTokenGuard. Метод также поддерживает постраничное отображение с помощью параметров page и pageSize.
  3. searchUsers: Метод предназначен для поиска пользователей по заданным критериям. Пользователь передает данные в формате SearchUsersDto, содержащие запрос, страницу и размер страницы. Доступ к методу защищен AccessTokenGuard.
  4. getUserByIdAdmin: Этот метод позволяет администратору получить информацию об одном пользователе по его идентификатору (ID). Доступ к методу ограничен только для пользователей с ролью «ADMIN». Метод возвращает полные данные пользователя.
  5. getUserByIdPublic: Этот метод позволяет получить публичные данные одного пользователя по его идентификатору (ID). Доступ к методу защищён токеном доступа. Возвращаются только публичные данные, которые доступны для общего просмотра.
  6. getUserSelf: Этот метод позволяет пользователю получить собственные данные. Доступ к методу защищён токеном доступа, с проверкой, что запрашивающий пользователь является владельцем данных, либо имеет привилегии администратора или модератора.
  7. getByEmail: Этот метод позволяет администратору получить одного пользователя по его Email. Доступ к методу ограничен только для пользователей с ролью «ADMIN».
  8. getByUserNameAdmin: Этот метод позволяет администратору получить одного пользователя по его userName. Доступ ограничен для пользователей с ролью «ADMIN».
  9. getByUserName: Этот метод позволяет получить информацию о пользователе по его userName. Доступ к методу ограничен с использованием Access Token.
  10. editUserName: Этот метод позволяет пользователю или привилегированным пользователям (с ролями «ADMIN» или «MODERATOR») изменить имя пользователя. Запрос требует предоставления доступа с помощью AccessToken. После проверки прав доступа, метод вызывает сервис для редактирования имени пользователя.
  11. editEmail: Метод предназначен для изменения адреса электронной почты пользователя. Только сам пользователь или пользователи с ролями «ADMIN» или «MODERATOR» могут выполнять эту операцию. Используется защита с помощью AccessToken для проверки прав доступа.
  12. editPassword: Этот метод позволяет изменить пароль пользователя. Доступ к изменению пароля возможен для самого пользователя или для привилегированных пользователей с ролями «ADMIN» или «MODERATOR». Метод требует старый и новый пароли в теле запроса и защищен AccessToken’ом.
  13. editAdmin: Этот метод позволяет администратору редактировать информацию для всех пользователей. Доступен только пользователям с ролью «ADMIN».
  14. addRole: Метод позволяет администратору назначить роль пользователю. Доступ ограничен для пользователей с ролью «ADMIN».
  15. ban: Используйте этот метод, чтобы забанить пользователя. Доступ предоставляется только администраторам.
  16. confirmEmail: Этот метод отправляет ссылку для активации учетной записи пользователя на указанный Email. Доступен пользователю при наличии валидного токена, а также администратору и модератору.
  17. activate: Этот метод активирует пользователя по предоставленной ссылке. При успешной активации выводится сообщение об успешной активации аккаунта. В случае ошибки возвращается статус ошибки с соответствующим сообщением.
  18. setAvatar: Этот метод позволяет пользователю загрузить аватарку. Доступ к методу защищен и требует наличия действительного токена доступа. Загружаемый файл должен быть изображением с ограничением по размеру не более 10 МБ. Метод поддерживает проверку ролей, позволяя только самому пользователю или привилегированным ролям («ADMIN», «MODERATOR») изменить аватарку.

Сервис UsersService

В качестве примера кода приведем пару методов:

Метод searchUsers для поиска пользователей по логину, фамилии, или имени:

TypeScript
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 для установки аватара:

TypeScript
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

TypeScript
@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:

  1. getById: Этот метод позволяет получить профиль пользователя по его идентификатору. Если идентификатор не указан, возвращается профиль текущего аутентифицированного пользователя. Доступ к методу ограничен аутентифицированными пользователями с использованием защитного механизма AccessTokenGuard.
  2. editProfile: Этот метод позволяет редактировать профиль пользователя. Для выполнения операции необходимо наличие прав у текущего пользователя либо роль «ADMIN», либо «MODERATOR». Доступ к методу защищён с помощью AccessTokenGuard.

Сервис ProfileService

В качестве примера кода приведем пару методов:

Метод getProfile для получения профиля по userId:

TypeScript
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 для редактирования профиля:

TypeScript
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

TypeScript
@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:

  1. registration: Этот метод осуществляет полную регистрацию нового пользователя. Принимает данные пользователя и создает учетную запись, запоминает IP-адрес и информацию о клиенте. После успешной регистрации возвращает токены доступа и обновления, сохраняет refresh токен в cookie, а также возвращает данные о пользователе и идентификатор сессии.
  2. simpleRegistration: Этот метод выполняет регистрацию пользователя, используя только имя пользователя (userName). После успешной регистрации возвращает токен доступа (accessToken), идентификатор сессии (sessionId) и данные пользователя (userData). Также устанавливает cookie с токеном обновления (refreshToken) для дальнейшей аутентификации. Пользовательские данные, такие как IP-адрес и User Agent, фиксируются для регистрации.
  3. login: Этот метод позволяет пользователю выполнить вход в систему. Он принимает учетные данные пользователя и возвращает accessToken и sessionId для аутентификации, а также сохраняет refreshToken в cookies. Метод также фиксирует IP-адрес и пользовательский агент пользователя.
  4. logout: Этот метод выполняет выход пользователя из системы, удаляя сессию пользователя. Доступ к методу ограничен только для самого пользователя или пользователей с ролями «ADMIN» и «MODERATOR». После выполнения операции куки-файл ‘refreshToken’ очищается.
  5. refreshTokens: Этот метод обновляет токены доступа для пользователя. Для выполнения требуется наличие refresh-токена, который отправляется в куки. Метод создает новые токены, обновляет куки-файлы и возвращает новый access-токен в ответе.

Сервис AuthService

В качестве примера кода приведем пару методов:

Метод login для логина пользователя:

TypeScript
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 для обновления токенов доступа:

TypeScript
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

TypeScript
@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:

  1. create: Этот метод позволяет создать новую роль. Метод принимает объект CreateRoleDto, содержащий данные для создания роли, и возвращает созданную роль.
  2. getByValue: Этот метод позволяет получить роль на основе заданного значения. Метод принимает параметры запроса в виде объекта GetRoleDto и возвращает найденную роль.

Сервис RolesService

В данном случае кода совсем немного, и можно привести его целиком:

TypeScript
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 целиком:

TypeScript
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

TypeScript
@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 и упорядочивая их по времени обновления.

Приведу код сервиса целиком:

TypeScript
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

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

Все методы защищены и требует использования токена доступа:

  1. addFriend: добавляет пользователя в список друзей текущего пользователя.
  2. deleteFriend: удаляет пользователя из списка друзей текущего пользователя.
  3. isFriend: проверяет статус «дружбы» между текущим пользователем и указанным пользователем.
  4. getFriends: возвращает список друзей пользователя с возможностью фильтрации по типу и постраничного отображения.
  5. countFriends: возвращает количество друзей указанного пользователя.
  6. searchFriends: осуществляет поиск друзей пользователя на основе заданного запроса.

Сервис FriendsService

Приведу полностью код friends.service.ts:

TypeScript
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

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

Доступ к методам защищен и требует валидного токена доступа:

  1. createMessage: метод предназначен для создания сообщения. Указывается идентификатор получателя или комнаты, содержимое сообщения, тип и, при необходимости, ответ на другое сообщение.
  2. getMessages: метод позволяет получить список сообщений. Указывается комната, из которой необходимо получить сообщения, а также параметры для навигации по страницам, такие как номер страницы и количество сообщений.

Сервис MessageService

В качестве примера кода приведем пару методов:

Метод createMessage для создания сообщения:

TypeScript
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 для получение списка сообщений:

TypeScript
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

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

Доступ к методам ограничен и требуется наличия соответствующего токена доступа:

  1. createRoom: Метод для создания новой комнаты. Метод принимает данные о типе комнаты, ее названии, приватности и описании.
  2. editAvatar: Метод для изменения аватарки комнаты. Требует загрузки изображения с ограничением на размер (до 10 MB) и поддержкой определенных форматов (jpg, jpeg, png, gif, bmp, webp, avif). Включает возможность настраивать параметры обрезки изображения.
  3. createDialogue: Метод для создания диалога между пользователями. Принимает идентификатор пользователя, с которым необходимо создать диалог.
  4. getRoom: Метод для получения данных о конкретной комнате.
  5. editRoom: Метод для редактирования параметров комнаты. Пользователь с соответствующими правами может изменить параметры комнаты, предоставив необходимые данные.
  6. getRooms: Метод для получения списка всех комнат с возможностью фильтрации и постраничного отображения. Поддерживает параметры поиска и сортировки.


Сервис RoomService

В качестве примера кода приведем несколько методов:

Метод createRoom создания комнаты:

TypeScript
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 для создания диалога:

TypeScript
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 для получения данных комнаты. Реализована сложная проверка на права доступа — пользователь получит только те данные, на которые у него есть права:

TypeScript
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

TypeScript
@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 для подписки пользователя:

TypeScript
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 для получения подписок пользователя:

TypeScript
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 для блокировки пользователя:

TypeScript
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 для отправки уведомления о входе.

Для примера, приведу код этого метода:

TypeScript
@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 для добавления пользователя в подписку на указанную комнату.
  • Если подписка успешно создана и это групповая комната, создаёт служебное сообщение о новом подписчике и отправляет его остальным пользователям в комнате.
  • Возвращает статус успеха, или информацию об ошибке.

Для примера, приведу код этого метода:

TypeScript
@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:

TypeScript
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:

TypeScript
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:

TypeScript
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

Кроме того, автор проекта ищет работу. Мое резюме.

Готовый скрипт для установки Dante SOCKS5 прокси сервера

Socks5 — это универсальный протокол сетевого уровня, который используется для маршрутизации трафика между клиентами и серверами через прокси. Он предоставляет пользователям возможность безопасно и анонимно подключаться к интернет-ресурсам, поддерживая различные типы запросов, включая TCP и UDP. Протокол Socks5 стал популярен благодаря своей гибкости и относительно низкой задержке, что делает его хорошим выбором для обхода региональных ограничений, защиты конфиденциальности и оптимизации работы сетевых приложений.

В этой статье мы рассмотрим скрипт, который автоматизирует установку и конфигурирование прокси-сервера Dante на Ubuntu Server 24.04 LTS , с использованием аутентификации на основе имени пользователя и пароля. Dante является одним из наиболее известных и надежных серверных решений для реализации Socks5-прокси.

1. Проверяем, выполняется ли скрипт с правами суперпользователя, используя id -u. Если нет, завершаем с ошибкой:

Bash
if [ "$(id -u)" != "0" ]; then
    echo -e "\033[31mThis script requires superuser rights.\033[0m"
    exit 1
fi


2. Проверяем, указан ли пароль в качестве аргумента скрипта:

Bash
if [ -z "$1" ]; then
  echo "Please provide socks5 password as argument"
  exit 1
fi


3. Устанавливаем необходимые параметры конфигурации:

Bash
DANTE_CONF="/etc/danted.conf"
DANTE_USER="usrsocks"
DANTE_PASSWORD=$1


4. Определение активного сетевого интерфейса: с помощью команды ip скрипт находит активный сетевой интерфейс, который используется для подключения к сети:

Bash
echo "Find active network interface..."
INTERFACE=$(ip -o -4 route show to default | awk '{print $5}')

if [ -z "$INTERFACE" ]; then
    echo "Failed to find active network interface"
    exit 1
fi

echo "Active network interface found: $INTERFACE"


5. Установка dante-server: с помощью команды apt-get устанавливается dante-server. Перед этим выполняется проверка на блокировку системы apt:

Bash
# Wait for unlocking:
   wait_for_lock() {
     while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
       echo "Waiting for /var/lib/dpkg/lock-frontend to be unlocked..."
       sleep 5
     done
   }

echo "Install dante-server..."
wait_for_lock
apt-get update
wait_for_lock
apt-get install -y dante-server


6. Создание резервной копии конфигурационного файла: если конфигурационный файл dante уже существует, скрипт создаёт его резервную копию:

Bash
echo "Backup existing configuration file $DANTE_CONF..."
if [ -f "${DANTE_CONF}" ]; then
    cp "$DANTE_CONF" "${DANTE_CONF}.bak"
    echo -e "\033[32mBackup existing configuration file\033[0m"
fi


7. Создание нового конфигурационного файла: новый конфигурационный файл формируется и записывается в нужное место:

Bash
NEW_CONFIG=$(cat <<-EOM

   logoutput: syslog stdout /data/logs/danted.log

   internal: $INTERFACE port = 1080
   external: $INTERFACE

   socksmethod: username
   user.privileged: root
   user.unprivileged: nobody
   user.libwrap: nobody

    client pass {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: error connect disconnect
    }
    
    client block {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: connect error
    }
    
    socks pass {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: error connect disconnect
    }
    
    socks block {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: connect error
    }

EOM
)

echo "Write configuration file $DANTE_CONF..."
echo "$NEW_CONFIG" > "$DANTE_CONF"


8. Создание учётной записи пользователя для аутентификации: создаётся новый пользователь и пароль для аутентификации при подключении к proxy. Имя пользователя — usrsocks, а пароль — тот, который вы передали в качестве аргумента:

Bash
echo "Create user $DANTE_USER for SOCKS5 auth..."
useradd -s /bin/false $DANTE_USER
echo "$DANTE_USER:$DANTE_PASSWORD" | chpasswd


9. Перезапуск службы danted: скрипт перезапускает сервис и добавляет его в автозапуск:

Bash
echo "restart danted service..."
systemctl restart danted
systemctl enable danted


10. Для дополнительной оптимизации автоматизированного выполнения, обернем основное тело скрипта таким образом:

Bash
trap 'echo -e "\033[31mSomething went wrong\033[0m"; exit 1' ERR
set -e

export DEBIAN_FRONTEND=noninteractive

# Script BODY
# ...

trap - ERR
echo -e "\033[32mDante SOCKS5 proxy has been installed and configured with authentication\033[0m"


Таким образом, мы будем отслеживать любые ошибки во время выполнения команд, чем предотвратим дальнейшее частичное выполнение скрипта в результате возможных ошибок, а так же отключим любые нежелательные запросы, которые могут прервать выполнение установщика.

Использование скрипта

  1. Скопируйте скрипт и сохраните его в файл (например, socks5.sh). Или клонируйте репозиторий.
  2. Убедитесь, что у файла есть права на выполнение:
chmod +x socks5.sh
  1. Запустите скрипт с использованием прав суперпользователя и передав ему пароль для аутентификации:
   sudo install_dante.sh your_password

Как альтернативу назначению прав на выполнение, можно запустить скрипт через прямое указание интерпретатора bash, что может быть удобно при автоматизации:

sudo bash socks5.sh your_password
  • Проверка установки: убедитесь, что прокси сервер работает, проверив статус danted сервиса:
   systemctl status danted

Сервис должен быть в состоянии active (running).

Запуск скрипта

Настройте ваше приложение для использования SOCKS5 прокси с адресом вашей машины и портом 1080. Введите учётные данные, указанные в скрипте (usrsocks и пароль your_password, переданный в качестве аргумента).


Полная версия скрипта приведена ниже:

Bash
 #!/bin/bash

if [ "$(id -u)" != "0" ]; then
    echo -e "\033[31mThis script requires superuser rights.\033[0m"
    exit 1
fi

if [ -z "$1" ]; then
  echo "Please provide socks5 password as argument"
  exit 1
fi

DANTE_CONF="/etc/danted.conf"
DANTE_USER="usrsocks"
DANTE_PASSWORD=$1

trap 'echo -e "\033[31mSomething went wrong\033[0m"; exit 1' ERR
set -e

export DEBIAN_FRONTEND=noninteractive

# Wait for unlocking:
   wait_for_lock() {
     while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
       echo "Waiting for /var/lib/dpkg/lock-frontend to be unlocked..."
       sleep 5
     done
   }


echo "Find active network interface..."
INTERFACE=$(ip -o -4 route show to default | awk '{print $5}')

if [ -z "$INTERFACE" ]; then
    echo "Failed to find active network interface"
    exit 1
fi

echo "Active network interface found: $INTERFACE"

echo "Install dante-server..."
wait_for_lock
apt-get update
wait_for_lock
apt-get install -y dante-server

echo "Backup existing configuration file $DANTE_CONF..."
if [ -f "${DANTE_CONF}" ]; then
    cp "$DANTE_CONF" "${DANTE_CONF}.bak"
    echo -e "\033[32mBackup existing configuration file\033[0m"
fi

NEW_CONFIG=$(cat <<-EOM

   logoutput: syslog stdout /data/logs/danted.log

   internal: $INTERFACE port = 1080
   external: $INTERFACE

   socksmethod: username
   user.privileged: root
   user.unprivileged: nobody
   user.libwrap: nobody

    client pass {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: error connect disconnect
    }
    
    client block {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: connect error
    }
    
    socks pass {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: error connect disconnect
    }
    
    socks block {
            from: 0.0.0.0/0 to: 0.0.0.0/0
            log: connect error
    }

EOM
)

echo "Write configuration file $DANTE_CONF..."
echo "$NEW_CONFIG" > "$DANTE_CONF"

echo "Create user $DANTE_USER for SOCKS5 auth..."
useradd -s /bin/false $DANTE_USER
echo "$DANTE_USER:$DANTE_PASSWORD" | chpasswd

echo "restart danted service..."
systemctl restart danted
systemctl enable danted

trap - ERR
echo -e "\033[32mDante SOCKS5 proxy has been installed and configured with authentication\033[0m"


Репозиторий проекта: https://git.checkerwars.com/leo/socks5

Бонусом в репозитории есть скрипт socks5-uninstall.sh, который отменяет выполнение скрипта установки.


Если вам интересен наш проект, есть вопросы, замечания, или предложения — оставляйте комментарии или пишите на почту: checkerwars@mail.ru

Кроме того, автор проекта ищет работу. Мое резюме.