Канал для поиска исполнителей для разных задач и организации мини конкурсов
Last updated 1 week, 2 days ago
Новые и перспективные Web3 игры с добычей токенов.
Чат: https://t.me/Crypto_Wolf_Chat
Правила чата смотрите в описании чата.
Все свои вопросы направляйте в чат или главному модератору чата: @Exudna_118
По теме сотрудничества: @Zombini
Last updated 2 months, 3 weeks ago
Аутентификация и IdentityProvider
Для реализации идентификации и аутентификации мы неизбежно используем данные, не нужные основной логике приложения, а логика может быть достаточно сложной сама по себе:
• Для событий телеграм идентификация происходит на основе данных из события. Аутентификация пользователя не производится - мы только проверяем безопасность соединения с сервером
• Для бэкенда веб приложения мы часто используем сессии. В этом случае мы достаем их из cookie и дальше проверяем в какой-либо базе данных, откуда и достаем идентификатор пользователя, соответствующего сессии.
• Для API в микросервисной среде мы можем использовать JWT-токены, содержащие айди пользователя, которые проверяются на основе подписи.
• В некоторых сервисах мы можем полагаться на пользовательские TLS-сертификаты, заверенные сертифицирующем сервисом
• Проверка токена или сертификата может делаться как в коде приложения, так и на реверс прокси.
• При разработке или тестировании может использоваться фиксированный пользователь с определенными правами.
Множество вариантов реализации усложняется тем, что они могут использоваться одновременно с одной и той же бизнес логикой. Это приводит к необходимости выделения интерфейса (IdentityProvider
), скрывающего эти детали. Обращаю так же внимание, что такой объект не должен возвращать данные, относящиеся к текущему контексту приложения. Грубо, его можно свести к чему-то такому:
class IdentityProvider(Protocol):
def get\_current\_user\_id(self) \-> int: ...
def get\_current\_user\_roles(self) \-> list[Role]: ...
В простом случае реализация этого интерфейса является небольшим инфраструктурным сервисом, но в перспективе является прослойкой между бизнес логикой приложения и отдельным контекстом, занятым различными вопросами управления пользовательскими сессиями и авторизационными данными. Например, обработчики этого контекста могут заниматься обработкой процедуры логина в сервис, очисткой пользовательских сессий по его команде и т.п. Наши классы бизнес логики приложения будут зависеть от этого протокола, а реализация будет передаваться путем Dependency-injection.
Таким образом, связывая бизнес логику и логику аутентификации через протокол IdentityProvider
мы:
• Скрываем всю работу с аутентификацией и идентификацией за простым интерфейсом
• Оставляем возможность разной реализации, в том числе использующей базы данных или ключи шифрования
• Не обращаемся к внешним ресурсам самостоятельно из слоя представления
• Разделяем входные данные интерактора и контекст вызова
Дополнительные материалы:
• https://www.keycloak.org/docs/latest/authorization_services/index.html
• https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
• https://ru.wikipedia.org/wiki/Компоновщик_(шаблон_проектирования)
Аутентификация и авторизация
Наши приложения выполняют разные сценарии и для некоторых из них может быть важно, что за пользователь перед нами. То есть, для целей бизнес-логики может быть необходимо получить некоторые уникальные данные пользователя, которые позволят его отличить от других - это идентификация. Реализуется она различным способом: иногда мы можем явно спросить у пользователя, кто он, иногда мы получаем информацию из сетевых пакетов или системы. Идентификационные данные дальше могут использоваться по-разному: их можно записать в лог, использовать как ссылку на владельца при создании объектов в системе или в различных проверках внутри нашей логики.
Идентификация должна выполняться безопасно: иногда пользователь может попытаться выдать себя за другого. Процесс проверки, что пользователь не обманывает нас в том, кто он - аутентификация. Она не всегда актуальна: если мы получили сообщение от telegram, мы можем верить информации об отправителе, потому что доверяем серверам телеграма. Однако, если мы получили HTTP запрос, мы должны принять меры для обеспечения защиты от подделки личности пользователя (аутентифицировать его).
Когда пользователь первый раз обращается к нашему сайту, мы обычно отправляем его на сценарий входа (первичная аутентификация, login, sign in). Этот сценарий может быть достаточно сложным, состоять из нескольких шагов (например в случае двух- и многофакторной аутентификации), требовать использовании СУБД и внешних сервисов. Процедура входа скорее всего будет отделена от основной части приложения или даже реализовываться внешней системой (например, Keycloak). Иногда процедуру логина на сайт называют "авторизацией на сайте", но не следует это путать с авторизацией действий (см. ниже). В случае веб-приложений, после первичной аутентификации мы часто используем различные токены для того, чтобы в последующих действиях было проще его аутентифицировать. Проверка таких токенов связана с протоколом доставки, может задействовать базы данных и снова выполняется вне основной бизнес логики - адаптерами или отдельной подсистемой. В том числе, её иногда может выполнять реверс-прокси. Часто спустя какое-то время пользователя просят повторить процедуру входа.
Многие операции в нашем приложении мы не хотим разрешать выполнять кому попало. Например, мы можем разрешить редактировать какой-то объект только его владельцу, а блокировать пользователей - админам. Проверка, разрешено ли выполнять какой-то сценарий пользователю - это авторизация, часть бизнес логики. Есть разные модели авторизации, связанные с проверкой роли пользователя (RBAC), отношений пользователя и объекта (ReBAC) или даже с какими данными объекта он работает (ABAC). Выбор того или иного варианты авторизации определяется требованиями вашей системы.
С точки зрения архитектуры приложения
• Идентификация выполняется для целей бизнес-логики или логирования, адаптеры помогают её реализовать.
• Аутентификация не является частью основной бизнес-логики приложения, выполняется адаптерами или полностью отдельной частью логики.
• Авторизация выполняется только бизнес-логикой, она не может быть корректно вынесена в слой представления, но может быть отделена от основной логики интерактора.
Дополнительные материалы
• https://auth0.com/intro-to-iam/what-is-oauth-2
• https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/
• https://owasp.org/Top10/A01_2021-Broken_Access_Control/
У ребят из Podlodka Python Crew стартует новый сезон онлайн-конференции, тема — инфраструктура. Всё проходит онлайн, с 3 по 7 июня.
Я буду выступать там с докладом про Dependency Injection и dishka непосредственно.
Все доклады записываются, так что смотреть их день в день необязательно.
Что будет
• Мой доклад про DI
• Погружение в трейсинг: чем он полезен, как работает и как его внедрить.
• Поиск уязвимостей: практические задания с разбором
• Рассказ про неочевидные кейсы оптимизации.
• Обучение эффективному мониторингу: типы метрик, как их собирать и экспортировать.
И ещё много всего.
Конференция платная, но специально для подписчиков промокод INFRA_17
на скидку 1000р
Unit of work
Паттерн Unit of work (единица работы) предназначен для того, чтобы следить за изменениями объектов и потом координировано их сохранять в базу данных.
Это позволяет:
• Ограничить время жизни транзакции
• Не выполнять обращение к БД сразу при выполнении изменений, а значит попытаться сделать это более эффективно
• Более удобно следить за изменениями в случае сложной иерархии или большого количества типов моделей.
Принцип использования Unit of Work состоит из двух этапов:
register_new
, register_dirty
, register_deleted
). commit
) Изменения могут регистрировать как сами модели, так и прикладной код, использующий их. Таким образом, каждый раз, когда мы что-то делаем с моделями (добавляем, удаляем, изменяем), мы не отправляем сразу запрос в БД, а вместо этого добавляем эти изменения в UoW для последующего сохранения.
Хотя Unit of Work имеет метод для коммита изменений, он является более сложной вещью чем просто управление транзакциями. Суть его в том, чтобы накапливать изменения перед отправкой в базу данных. При этом он может выполнять оптимизации запросов, например, объединяя вставку данных в одну таблицу в один запрос. Также, в нем может быть реализована логика контроля целостности данных, например, с помощью оптимистических блокировок.
Сам Unit of work обращается в БД не напрямую, а через отдельные объекты, реализующие паттерн Data Mapper. Условно, в данном случае, каждый такой объект умеет отправлять в БД изменения (insert
, update
, delete
) модели определенного типа и UoW знает в какой из мапперов обращаться для каждой из сохраненных моделей. Обратите внимание, что Unit of Work не используется для доступа к мапперам / шлюзам к БД, его задача другая. Более того, использование его в таком смысле будет нарушением принципа разделения интерфейсов.
С концепциями Unit of Work и Data Mapper тесно связан паттерн Identity Map, когда мы храним реестр загруженных экземпляров моделей для их идентификаторов. И, хотя оба из них могут использоваться независимо друг от друга, хорошей идеей будет реализация Unit of Work, использующего IdM.
Некоторые ORM, такие как SQLAlchemy
, самостоятельно реализуют паттерн Unit of work: каждый экземпляр модели SQLAlchemy связана с объектом Session
и её изменения записываются в базу данных в момент вызова session.flush()
/session.commit()
.
Пример одной из возможных реализаций: https://github.com/Tishka17/python-uow-demo
Дополнительные материалы:
• https://martinfowler.com/eaaCatalog/unitOfWork.html
• https://martinfowler.com/eaaCatalog/dataMapper.html
• https://techspot.zzzeek.org/2012/02/07/patterns-implemented-by-sqlalchemy/
Абстрактные классы и интерфейсы
Если рассуждать, не привязываясь к языку программирования, то:
Абстрактный класс - это заготовка для класса. В нем часто есть методы с реализацией и методы, помеченные как абстрактные. Экземпляры такого класса напрямую создавать нельзя. Нужно отнаследоваться от него и заполнить пропущенные методы.
Абстрактный класс может содержать данные, обычные методы. Его отличает именно наличие абстрактных методов. В некоторых языках - это методы без тела (C++, Java), в некоторых (Python) - методы со специальной пометкой. Чтобы наследник класса перестал быть абстрактным, надо реализовать в нем все такие методы.
Интерфейс же - это требования к тому, что должен уметь объект. Это набор сигнатур операций. Как правило, речь о наборе названий методов, их параметрах и типе результата, но иногда речь и про доступ к атрибутам. В общем случае, интерфейс может не существовать в коде как именованная сущность.
Интерфейс существует просто по факту того что вы написали. Если ваша функция принимает объект и вызывает у него методы foo()
и bar()
, требуемый ей интерфейс можно выразить как "объект с методами foo и bar, которые не требуют аргументы". Если у вас есть класс с методами foo
и bar
, то его экземпляры удовлетворяют интерфейсам "любой объект", "объект с методом foo", "объект с методами foo и bar" и др.
С практической стороны работа с интерфейсами отличается от языка к языку:
• Python проверяет соответствие объекта ожиданиям функции по факту вызова операций с ним во время выполнения кода. Сторонние линтеры могут проверять это другим способом, ориентируясь на аннотации типов или ещё как-то. Для того чтобы выразить требования к интерфейсу в тайпхинтах, мы можем оформить класс, наследующийся от Protocol
. Для реализации такого интерфейса достаточно реализовать соответствующие методы, но можно и наследоваться от него для упрощения поиска ошибок.
• В Golang интерфейс описывается в коде с помощью ключевого слова interface
. В дальнейшем он используется как тип переменных или параметров функции. Соответствие структуры интерфейсу проверяется по факту реализации в ней нужных методов. Отдельно декларировать, что структура удовлетворяет интерфейсу, нельзя. Стоит отметить, что в Go не поддерживается наследование и поэтому об абстрактных классах не может идти и речи.
• В Java интерфейс описывается с помощью ключевого слова interface
и классы указывают, чему они соответствуют, с помощью implements
. Даже если класс фактически содержит все необходимые методы, он не соответствует интерфейсу, если сам это не задекларировал явно.
• В C++ отсутствует понятие интерфейса на уровне языка и принято использовать чисто абстрактные классы как их замену. Чтобы показать, что наш класс реализует интерфейс, мы наследуемся от соответствующего абстрактного класса. При этом язык шаблонов имеет свою отличающуюся логику.
Дополнительные материалы:
• https://philippegroarke.com/blog/2017/05/09/static-duck-typing-in-c/
• https://docs.oracle.com/javase/tutorial/java/IandI/defaultmethods.html
• https://peps.python.org/pep-0544/
Виртуальные окружения Python
Во многих случаях при разработке приложений на Python нам требуются сторонние библиотеки. Однако, если мы будем их устанавливать в глобальное окружение, мы в какой-то момент столкнемся с конфликтами между разными проектами, нам будет сложнее производить очистку такого окружения. А в некоторых ситуациях мы можем даже сломать системные приложения.
Чтобы избежать таких проблем, рекомендуется практически всегда использовать виртуальные окружения. Это специальная папка, куда устанавливаются библиотеки и которых может быть больше одной на вашем компьютере. В python 3 есть встроенное средства для управления ими - пакет venv
, но есть и сторонние популярные решения такие как virtualenv, poetry и многие другие.
Для того чтобы создать новое виртуальное окружение, выполните команду с указанием нужного вам питона:
```
python -m venv имя_папки
```
После этого в каталоге имя_папки будет создано множество служебных файлов и установлен pip
. Часто в качестве папки указывают venv
или .venv
.
Для того чтобы работать внутри виртуального окружения, вы можете:a. Запустить команду с указанием пути. Например, python, pip или поставляемые сторонними пакетами. На Linux это будет ./имя_папки/bin/python
, на Windows - имя_папки\Scripts\python
(пути могут быть относительные или абсолютные). Это бывает удобно внутри скриптов или файлов сервисов. В этом случае, sys.path
будет содержать каталог библиотек внутри виртуального окружения. Учтите, что так как переменная PATH
не меняется, то запуск других команд (например, через subprocess) без указания пути будет фактически происходить вне виртуального окружения.
b. Активировать его внутри вашей командной оболочки (основной сценарий). Для bash/zsh это source ./имя_папки/bin/activate
. Для Windows CMD - имя_папки\Scripts\activate.bat
. После этого в рамках сессии вашего шелла будет изменена переменная окружения PATH
, что приведет к изменению команд, доступных без указания пути. Соответственно, если таким образом будет запущена команда (python
, pip
и т.д.) из виртуального окружения, то и sys.path
будет изменен, как и в прошлом способе. Так же будет задана переменная окружения VIRTUAL_ENV
. Для заверешния работы с виртуальным окружением введите команду deactivate
.
Типичные ошибки при работе с venv:• Не использовать виртуальные окружения. Задумайтесь, что вы будете делать, когда двум вашим проектам потребуется одна библиотека разных версий. Или когда вы решите удалить библиотеки, не указанные в списке зависимостей, чтобы быть уверенным, что проект требует только их.
• Перемещать или копировать виртуальное окружение в другую локацию или на другой компьютер. Некоторые инструменты это позволяют делать в отдельных случаях, но venv
не из таких. Окружение просто сломается и будет вести себя непредсказуемо. Исключение - копирование между идентичными образами ОС, например, при сборке контейнеров.
• Добавлять папку с виртуальным окружением в систему контроля версий (например, git). Вместо этого стоит фиксировать список зависимостей, чтобы окружение можно было пересоздать в любой момент.
• Устанавливать pip вне виртуального окружения. Это может случайно привести к работе с зависимостями вне venv и путанице.
• Предполагать, что IDE сама знает, где находится виртуальное окружение. Несмотря на то, что часто это так, иногда бывает недопонимание с ней. Разберитесь, как в вашей IDE настраивать использование виртуального окружения. Также обязательно научитесь работать с окружениями без использования IDE.
• Помещать файлы проекта внутрь виртуального окружения. Это достаточно бессмысленное действие, потому что сценарии работы с venv и файлами проекта сильно отличаются (например, он может быть при необходимости пересоздан). Куда лучше хранить venv внутри папки проекта.
Хочу отметить, что если вы используете другие инструменты управления окружениями, то правила работы с ними могут отличаться.
Дополнительные материалы:
• https://docs.python.org/3/library/venv.html
• https://python-poetry.org/docs/
• https://peps.python.org/pep-0405/
Dependency Injection
Принцип внедрения зависимостей, будучи достаточно простым, концептуально оказывается часто неочевидным.
Суть его в том, что когда у нас одному из объектов требуется другой, то он не создает или ищет его сам, а принимает извне. Например, если вашей функции нужно соединение с БД, то она не должна ни импортировать его, ни брать из глобальной переменной, ни создавать сама. Ей это соединение должны передать.
Само собой, какой-то код будет создавать эти зависимости, и тут мы стараемся отделять его от кода, использующего их. Благодаря этому:
• во-первых, делаем этим зависимости более явными;
• во-вторых, можем управлять тем, будет ли использован один экземпляр зависимости или разные;
• в-третьих, можем использовать один и тот же код с разными реализациями зависимостей.
Представьте, что вашему классу нужны некоторые параметры конфигурации, которые влияют на его поведение, и вы хотите протестировать разные варианты. Если бы класс сам грузил настройки, то вам пришлось бы в тестах учитывать, как именно он это делает, и возможно манипулировать теми объектами, которые обычно не меняются в процессе работы программы. Если же код класса получает эти настройки извне, то вы просто сделаете несколько вызовов с разными настройками. И даже если код класса изменится, тесты останутся корректными.
Можно выделить три способа внедрения зависимостей:
1. Внедрение через параметры функции/метода. Просто передаем зависимость как ещё один параметр:
```
def clear_users(cursor):
cursor.execute("TRUNCATE users;")
cursor = connection.cursor()
clear_users(cursor)
clear_users(cursor)
```
```
class UsersDAO:
def __init__(self, cursor):
self.cursor = cursor
def clear_users(self):
self.cursor.execute("TRUNCATE users;")
dao = UsersDAO(connection.cursor())
dao.clear_users()
dao.clear_users()
```
```
class UsersDAO:
def clear_users(self):
self.cursor.execute("TRUNCATE users;")
dao = UsersDAO()
dao.cursor = connection.cursor()
dao.clear_users()
dao.clear_users()
```
Популярные заблуждения:
• Это что-то из мира java, в моем языке его нет. Неверно. Для того чтобы в языке можно было реализовать внедрение зависимостей, необходимо лишь иметь возможность передать ссылку на функцию или на объект. Это применимо как к Javascript и Python, так и к C и Golang.
• Он нужен только большим enterprise приложениям. Скорее, он критичен для больших приложений. Небольшие приложения без автоматических тестов могут действительно существовать без внедрения зависимостей, но по мере усложнения необходимость в нем будет все более и более заметна.
• Для него нужен специальный фреймворк/IoC-контейнер. Как показано выше, для DI не нужен никакой фреймворк, это возможность вашего языка. Фреймворки лишь помогут упростить построение графа зависимостей (когда у вас достаточно много разных связанных объектов) или решить какие-то прикладные задачи связанные с этим. Хуже, что неосторожное использование таких фреймворков может наоборот лишить вас DI, хотя вы будете думать что это не так.
Дополнительные материалы:
• https://martinfowler.com/articles/dipInTheWild.html
• https://www.jamesshore.com/v2/blog/2006/dependency-injection-demystified
Первичные ключи в БД
Чтобы отличать записи в реляционной БД, у них должны быть уникальные поля. Это может быть как одно поле, которое для всех записей принимает разные значения, так и целый набор.
Любой набор колонок, в которых значения будут уникальны для всех записей, называется суперключом. Имеется ввиду группа значений по всем колонкам, а не в каждой по отдельности.
Если же, выкидывая из такого набора любую колонку, мы теряем уникальность - это называется потенциальный ключ. То есть, потенциальный ключ - уникальный набор колонок, который нельзя уменьшить.
В БД может быть много потенциальных ключей, и поэтому мы выбираем один из них как основной, который мы будем использовать - это первичный ключ (primary key, PK, ПК). То есть:
• Первичный ключ в таблице всегда один.
• Первичный ключ - это не обязательно одна колонка (простой ключ), а может быть и группа из нескольких колонок (составной или композитный ключ).
• Даже если у нас простой ключ, он не обязательно называется id
, имя колонки может быть любым, хотя стоит придерживаться стандартных названий.
Иногда мы записываем в БД данные, в которых естественным образом уже есть потенциальные ключи, мы выбираем из них один как первичный - это естественный ключ. Но иногда потенциальных ключей сразу не наблюдается или они какие-то неудобные для использования (никто ведь не захочет везде таскать первичный ключ из 5 колонок?), в этом случае под первичный ключ заводят отдельную колонку со сгенерированными уникальным значениями - это суррогатный ключ.
Суррогатный ключ можно генерировать разными способами. Два наиболее популярных - псевдослучайный (например, с помощью uuid4
) и автоинкремент.
• Псевдослучайный ключ позволяет использовать его ещё до обращений в БД, что бывает полезно. Его использование усложняет перебор ключей и определение количества записей, что может быть важно. Но из-за алгоритма генерации может быть неэффективен для поиска в БД.
• Автоинкремент требует обращения в БД, которая в том или ином виде запоминает какие были уже выданы номера.
При использовании автоинкремента номера не обязаны идти по порядку и даже по возрастанию. С точки зрения целей использования первичного ключа это не требуется, поэтому для большей эффективности БД не пытается за этим следить. А конкретно есть несколько причин:
• При удалении записей номера освобождаются, но номера остальных записей не меняются. Если бы БД просматривала какие номера освободились, это заняло бы много времени.
• При конкурентных транзакциях будут сгенерированы несколько номеров одновременно. Но одна из транзакций может быть не зафиксирована и тогда номер не будет фактически использован. Следить за такими номерами тоже было бы достаточно не эффективно. Кроме того транзакции могут быть открыты и зафиксированы в разном порядке, что будет отличаться от порядка генерации ключей.
• При определенной настройке некоторые СУБД генерируют автоинкрементные PK не по одной, а несколько за раз и хранит внутри сессии. Тогда конкурентные вставки будут использовать номера из разных наборов, что сохранит уникальность, но нарушит порядок.
Дополнительные материалы
• https://ru.wikipedia.org/wiki/Нормальная_форма
• https://habr.com/ru/articles/572700/
• https://habr.com/ru/articles/747348/
Двухфазная инициализацияИногда, по каким-то причинам мы не можем выполнить всю инициализацию при создании класса (в конструкторе или в __init__
). Например, это может быть выполнение асинхронного ввода/вывода, простановка циклических ссылок между двумя созданными объектами или особоый механизм обработки ошибок инициализации. В этом случае иногда создают вспомогательный метод, который нужно вызвать сразу после создания объекта. Стоит использовать такой подход с осторожностью.
Например, мы хотим создать гейтвей для работы с БД. Следующий код не будет работать:
```
class SomeGW:
def __init__(self, db_uri):
self.connection = await asyncpg.connect(db_uri)
gw = SomeGw("postgresql://postgres@localhost/test")
```
Мы не можем выполнять async код в ините класса, поэтому можно попытаться сделать двухфазную инициализацию:
```
class SomeGW:
def __init__(self):
self.connection = None
async def connect(self, db\_uri):
self.connection = await asyncpg.connect(db\_uri)
gw = SomeGw()
await gw.connect("postgresql://postgres@localhost/test")
``
В таком случае необходимо следить, что соединение не будет использовано до завершения второй фазы инициализации (вызова connect). Также, у созданного объекта формально
self.connectionможет быть
None`, что приведет к дополнительным проверкам в коде всех методов и предупреждениям линтера. Проще было ввести дополнительную функцию:
```
class SomeGW:
def __init__(self, connection):
self.connection = connection
async def new_some_gw(db_uri):
connection = await asyncpg.connect(db_uri)
return SomeGw(connection)
gw = await new_some_gw("postgresql://postgres@localhost/test")
```
Проблемы многофазной инициализации:
• Объект может быть инициализирован частично, что приведет к ошибкам выполнения
• Линтеры будут требовать дополнительных проверок в методах
• Корректная последовательность инициализации класса неочевидна из его API. Ситуация становится сложнее, если у нас есть несколько вариантов второй фазы
• Возможно нарушение принципа единственности ответственности: объект смешивает логику, ради которой он создавался, и сложную процедуру инициализации
В общем случае, желательно, чтобы объект был работоспособен сразу после создания без необходимости вызова дополнительных методов. То есть, чтобы невозможно было создать объект в нерабочем состоянии.
В качестве альтернатив многофазной инициализации всегда стоит рассматривать введение дополнительной функции, классметода или даже применения паттерна абстрактная фабрика.
Дополнительные материалы• http://neo.dmcs.p.lodz.pl/symos/wyklady/04-TwoPhase.pdf
• https://wiki.wxpython.org/TwoStageCreation
• https://peps.python.org/pep-0489/
Канал для поиска исполнителей для разных задач и организации мини конкурсов
Last updated 1 week, 2 days ago
Новые и перспективные Web3 игры с добычей токенов.
Чат: https://t.me/Crypto_Wolf_Chat
Правила чата смотрите в описании чата.
Все свои вопросы направляйте в чат или главному модератору чата: @Exudna_118
По теме сотрудничества: @Zombini
Last updated 2 months, 3 weeks ago