Необычная система проверки подписок и Flask-панель как отдельное окно приложения

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0

Предисловие​

В данной статье будет продолжена работа с самописной нейросетью для игр, но работа будет заключаться не с самой нейросетью, а с её составляющей — интерфейсом. Интерфейс для управления нейросетью написан на Flask, и в данной теме будет показано, как сделать так, чтобы панель Flask функционировала как отдельное приложение. Также будет создана система подписок и проверка этих подписок необычным методом, который позволяет уберечь IP вашего сервера при вскрытии исходников софта.

Flask панель как отдельное окно приложения​

Первым делом будет реализован интерфейс Flask, но как отдельное окно, а не вкладка в браузере. Чтобы это реализовать, потребуется создать свое подобие браузера с блэкджеком. И сделано это будет с помощью PyQt5.

Как это будет работать?​

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

Реализация​

Итак, после краткого разбора принципа работы можно приступать к написанию кода.

Первым делом нужно создать отдельный Python-файл, в котором будет реализована логика создания приложения на PyQt5. Назовём файл gui_initialization. После создания файла в нём нужно указать используемые библиотеки.
Python: Скопировать в буфер обмена
Код:
import sys
from PyQt5.QtCore import QUrl
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.QtCore import Qt

Затем нужно создать функцию, которая в дальнейшем будет вызываться при запуске нейросети. В этой функции будет реализована вся логика создания и настройки окна приложения PyQt5.
Python: Скопировать в буфер обмена
def run_browser():

Далее нужно инициализировать QApplication, который необходим для обработки графического интерфейса, обработки нажатий клавиш и мыши, обновления интерфейса и т.д.
Python: Скопировать в буфер обмена
app = QApplication(sys.argv)
sys.argv — это список аргументов, передаваемых при запуске приложения. Аргументы поступают из модуля sys.

Далее нужно создать объект для создания окна приложения на PyQt5.
Python: Скопировать в буфер обмена
main_window = QMainWindow()

После этого нужно создать две переменные, в которых будут храниться значения размера окна приложения.
Python: Скопировать в буфер обмена
Код:
window_width = 1000  # Ширина окна
window_height = 600  # Высота окна

Далее нужно вызвать метод для назначения размеров окна приложения, в который будут передаваться два аргумента в виде ранее созданных переменных.
Python: Скопировать в буфер обмена
main_window.setFixedSize(window_width, window_height)

Затем нужно вызвать метод setWindowFlags для добавления флагов, позволяющих сделать окно с нередактируемым размером.
Python: Скопировать в буфер обмена
main_window.setWindowFlags(Qt.Window | Qt.MSWindowsFixedSizeDialogHint)
Флаг Qt.Window указывает, что окно будет независимым, а не дочерним окном другого приложения.
Флаг Qt.MSWindowsFixedSizeDialogHint указывает, что окно будет с фиксированным размером, который нельзя изменить.
main_window — это окно, которому будут назначены флаги.

С настройкой окна приложения пока что закончено. Теперь нужно поработать со встроенным браузером. Для этого нужно сначала создать объект, который позволяет отображать веб-страницы внутри приложения PyQt5. Встроенный браузер основан на движке Chromium.
Python: Скопировать в буфер обмена
browser = QWebEngineView()

Далее нужно создать переменную, в которой будет храниться ссылка на страницу панели, которая в дальнейшем будет открываться.
Python: Скопировать в буфер обмена
url = "http://127.0.0.1:228"

Затем нужно написать код для открытия ссылки, указанной в переменной url, используя объект браузера, записанный в переменную browser.
Python: Скопировать в буфер обмена
browser.setUrl(QUrl(url))

Так как встроенный браузер — это всё-таки браузер, при нажатии правой кнопки мыши будет появляться контекстное меню, которое в данном софте абсолютно не нужно и будет только портить общий вид приложения. Поэтому контекстное меню будет отключено.
Python: Скопировать в буфер обмена
browser.setContextMenuPolicy(Qt.NoContextMenu)

Далее нужно установить встроенный браузер как основной виджет окна приложения PyQt5 из переменной main_window, используя метод setCentralWidget, в который будет передаваться объект браузера из переменной browser.
Python: Скопировать в буфер обмена
main_window.setCentralWidget(browser)

Далее будет настроен заголовок окна приложения.
Python: Скопировать в буфер обмена
main_window.setWindowTitle("HooliShot")

Затем нужно использовать метод show() для отображения окна приложения на экране. Без этого метода окно не будет отображаться, даже если оно настроено.
Python: Скопировать в буфер обмена
main_window.show()

Затем нужно вызвать цикл обработки действий с окном приложения
Python: Скопировать в буфер обмена
sys.exit(app.exec_())
app.exec_() запускает цикл обработки действий в приложении, используя QApplication(sys.argv) из переменной app.
При закрытии окна app.exec_() передает в sys.exit код, сообщающий о закрытии.
sys.exit() завершает выполнение программы при получении кода.

Далее нужно при запуске приложения вызвать функцию, в которой был написан весь этот код. Функция будет вызываться в отдельном потоке, чтобы не блокировать функцию запуска самой нейросети.
Python: Скопировать в буфер обмена
Код:
browser_thread = threading.Thread(target=run_browser)
browser_thread.start()
Также важно вызывать создание потока перед тем, как будет вызываться функция запуска нейросети, так как если первой будет запущена функция нейросети, основной поток будет занят ей, и в таком случае не получится запустить функцию отображения интерфейса в отдельном приложении.

На этом реализация отображения Flask-интерфейса как отдельного окна приложения закончена. Результат показан на скриншоте ниже:
1729007597903.png


Необычный способ проверки подписок​

Теперь хотелось бы показать необычный способ проверки подписки в софте. Чем же он необычен?
Обычно софт отправляет запрос на сервер по IP или домену, и сервер уже проверяет данные по типу логина и пароля, даты подписки и т.д. Но в способе, который будет показан далее, отправлять запросы на сервер в привычном понимании не будут.

Принцип работы​

  1. В софте будет отправка логина и пароля с Telegram-аккаунта на Telegram-бота.
  2. Далее будет код для работы Telegram-бота. Этот Telegram-бот будет принимать сообщения и проверять их на наличие логина и пароля. Если логин и пароль есть в базе данных (в которую пока что пользователи будут записываться вручную), то проверяется дата окончания подписки (также будет записана в столбец в базе данных).
  3. Если дата окончания подписки ещё не прошла, то бот отправит в этом же диалоге сообщение “complete”.
  4. Далее софт, в котором находится код, отправляющий логин и пароль боту, после отправки этих данных сделает паузу на 2 секунды, а после двух секунд спарсит последнее сообщение в диалоге, и этим сообщением будет как раз ответ бота. Если ответ “complete”, то софт продолжит свою работу.

Чем этот вариант мне показался лучше классического?​

Если софт вскроют и узнают адрес сервера, то есть риск обнаружить атаки на свой сервер или утечку личных данных того, кто оформлял этот сервер (если, конечно, указывались реальные данные). Метод, который был описан выше, лишен этой проблемы. Так как при вскрытии исходников проекта всё, что смогут получить недоброжелатели, — это пустой Telegram-аккаунт и не более. Более того, в таком случае даже сервер не понадобится; можно запустить бота на любом ПК и обойтись без открытия портов.

Реализация отправки данных боту​

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

Получение сессии аккаунта​

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

Данная библиотека предназначена конкретно для работы с Telegram: через неё можно создавать ботов, отправлять сообщения, авторизоваться в аккаунтах. И как раз авторизация в данном случае и потребуется.
Python: Скопировать в буфер обмена
Код:
from pyrogram import Client

api_id = 123213123
api_hash = ""

with Client("my_account", api_id, api_hash) as client:
   try:
       client.start()
   except:
       pass
   print("Сессия:", client.export_session_string())
Как было написано выше, нужна простая авторизация в аккаунте; как раз это в данном коде и происходит. Авторизация происходит, используя API ID и API hash, для того чтобы получить доступ к API Telegram. То есть передача API ID и API hash даёт понять Telegram, что у вашего приложения есть право на управление конкретным аккаунтом.

Получение api id и api hash​

Возможно, не все знают, как получить API ID и API hash, поэтому вот краткая инструкция:
Нужно перейти по данной ссылке: https://my.telegram.org/apps
Ввести номер телефона от аккаунта, которым хотите управлять.
1729007785687.png



Далее нужно заполнить все поля, кроме последнего (оно не обязательно).
1729007809760.png



После заполнения нужно нажать на кнопку Create; после этого откроется страница, где и будут находиться API ID и API hash.

После того как в код вставлены API ID и API hash, можно запустить данный скрипт. Первое, что потребуется, это ввести номер телефона от аккаунта. После этого, если у вас включена двухфакторная аутентификация, то потребуется ввести облачный пароль, и только после этого вы получите сессию в виде строки.
1729007848969.png


Отправка сообщения с данными в бота​

Это был лишь небольшой и простенький скрипт ради того, чтобы получить сессию. Далее можно приступать к написанию логики отправки сообщения от аккаунта с полученной сессией в Telegram-бота. Писать этот код нужно соответственно в софте, где нужна проверка подписки, в текущем случае — нейросеть для игр. В проекте нейросети первым делом был создан новый Python-файл с названием license_initialization.py. И первое, что нужно указать в этом файле, так это импорт библиотеки pyrogram и переменные, в которые нужно записать API ID, API hash, сессию и бота, которому нужно отправлять сообщение.
Python: Скопировать в буфер обмена
Код:
api_id = 23211
api_hash = "123"
session_string = "213"
target = "@123"

Далее нужно создать функцию check_license, в которой сразу же вызвать объект pyrogram для авторизации в аккаунте, используя API ID и API hash.
Python: Скопировать в буфер обмена
Код:
def check_license():
   app = Client(name="my_session", session_string=session_string, api_id=api_id, api_hash=api_hash)

Затем, внутри функции нужно создать 3 переменные: login, password, command (текст, который будет отправляться боту).
Python: Скопировать в буфер обмена
Код:
login = load_config('login')
password = load_config('password')
command = f"Login: {login}, Password: {password}"
В переменных login и password значения берутся из конфигурационного файла нейросети.

Далее нужно использовать конструкцию with app:. Данная конструкция позволит выполнить действия внутри своего блока (установить подключение к аккаунту Telegram). Когда все действия внутри блока будут выполнены, автоматически закроется подключение к аккаунту. Внутри конструкции первым делом нужно сделать отправку сообщения, используя метод send_message.
Python: Скопировать в буфер обмена
Код:
with app:
   app.send_message(target, command)
target — это аккаунт, на который будет отправляться сообщение, а command — это текст, который будет отправляться.

Далее нужно сделать небольшую паузу, а после неё добавить проверку последнего сообщения в диалоге, чтобы спарсить ответ от бота (которого пока ещё нет).
Python: Скопировать в буфер обмена
Код:
time.sleep(2)
for message in app.get_chat_history(target, limit=1):
В данном цикле вызывается метод get_chat_history, который позволяет получить историю сообщений. target обозначает пользователя, с которым нужно спарсить диалог, а limit=1 означает, что у цикла будет всего одна итерация, и, следовательно, спарсится только одно сообщение — последнее. После этого сообщение будет записано в переменную message.

Затем, внутри цикла нужно проверять полученное последнее сообщение на то, чтобы оно являлось словом “complete”. Если это так, то возвращать True.
Python: Скопировать в буфер обмена
Код:
if message.text == "complete":
   print("Подписка обнаружена")
   return True  # Подписка обнаружена

Далее нужно указать else, если сообщение не “complete”:
Python: Скопировать в буфер обмена
Код:
else:
   print(f"Ошибка: {message.text}")
break

Затем, под циклом for, а не внутри, нужно возвращать False.
Python: Скопировать в буфер обмена
return False

Теперь в файле проекта, где задаются функции, запускаемые при старте программы, нужно добавить условие if, запускающее функцию отправки данных боту для проверки подписок. Если функция вернет True, то программа продолжит свою работу и запустит все остальные функции; если вернет False, то ничего не будет запускаться.
Python: Скопировать в буфер обмена
Код:
if __name__ == "__main__":
   if check_license():  # Запускаем проверку лицензии и проверяем результат
       flask_thread = threading.Thread(target=lambda: app.run(host="0.0.0.0", port="228", debug=True, use_reloader=False))
       flask_thread.start()
       run_gui_thread = threading.Thread(target=run_gui)
       run_gui_thread.start()
       game_initialization()
   else:
       print("Не удалось обнаружить подписку.")

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

Бот для проверки подписки​

Для этого будет создан отдельный проект, который в дальнейшем будет нужен не только для проверки подписок, но и для регистрации и покупки подписок (не в этой статье). В новом проекте нужно создать Python-файл с названием check_license.py.

Что потребуется из библиотек?​

  1. aiogram
  2. flask
  3. flask-sqlalchemy
Flask и Flask-SQLAlchemy потребуются для создания базы данных (работа с базой данных будет именно через Flask, так как в дальнейшем планируется расширить этот проект веб-страницами для регистрации и покупки софта). Aiogram был выбран для написания бота, так как это, наверное, самая популярная библиотека для ботов на Python, и если захочется дополнить бота, то обучающих роликов и документации будет предостаточно.

Написание логики​

Итак, после импорта вышеперечисленных библиотек можно начинать написание логики. Первым делом нужно инициализировать объект Flask, объект SQLAlchemy и несколько параметров для работы с базой данных. В приведенном ниже коде все подписано комментариями:
Python: Скопировать в буфер обмена
Код:
# Инициализация объекта flask
app = Flask(__name__)
# Путь до базы данных
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
# Отключает флаг для постоянного отслеживания изменений в базе данных
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Инициализация объекта SQLAlchemy с привязкой к flask
db = SQLAlchemy(app)

Далее нужно создать класс со структурой базы данных. Думаю, по комментариям всё будет понятно:
Python: Скопировать в буфер обмена
Код:
class Users(db.Model):
   # Столбцы в базе данных
   id = db.Column(db.Integer, primary_key=True)  # Числовой столбец
   login = db.Column(db.String(80), nullable=False)  # Столбец со строкой
   password = db.Column(db.String(80), nullable=False)  # Столбец со строкой
   license = db.Column(db.Date, nullable=False)  # Столбец для хранения даты (без времени)

Вид получившейся таблицы:
1729008207358.png



Затем нужно создать переменную с токеном бота.
Python: Скопировать в буфер обмена
TOKEN ='1232131231'

После этого нужно инициализировать бота с использованием токена из переменной выше.
Python: Скопировать в буфер обмена
bot = Bot(token=TOKEN)

Далее нужно инициализировать диспетчер.
Python: Скопировать в буфер обмена
dp = Dispatcher()
Если по-простому, то диспетчер — это как бы координатор, который при получении сообщений будет определять, что с ними делать и в какой обработчик эти сообщения отправить для обработки.

Теперь можно создать функцию, где и будет происходить основная логика проверки данных и отправки сообщения. Называться функция будет check_credentials.
Python: Скопировать в буфер обмена
Код:
@dp.message()
async def check_credentials(message: types.Message):
dp.message — это декоратор, благодаря которому диспетчер при получении сообщения отправляет его в функцию check_credentials. В общем, этот декоратор как бы говорит диспетчеру, что функция под ним должна вызываться, когда бот получает сообщение.

Затем, внутри функции нужно указать try и except. В try будет логика парсинга сообщений и проверка этих данных с данными из базы данных, в общем, вся логика. Первое, что нужно указать в try, так это парсинг полного сообщения и разбивка его на 2 части. Если не забыли, то в коде отправки сообщения боту оно выглядит примерно так: Login: 228, Password: 114, и, ориентируясь на это, можно разделить сообщение относительно запятой и записать результат в две переменные.
Python: Скопировать в буфер обмена
login, password = message.text.split(',')

Далее нужно взять эти две переменные, вытащить из них данные после двоеточия и снова записать в переменные.
Python: Скопировать в буфер обмена
Код:
login = login.strip().split(':')[1].strip()
password = password.strip().split(':')[1].strip()
В итоге, в переменных будут храниться только значения 228 и 114.

Далее нужно сверить данные из переменных с соответствующими столбцами в базе данных, чтобы проверить, существуют ли они и совпадают ли с записанными значениями.
Python: Скопировать в буфер обмена
Код:
with app.app_context():
   user = Users.query.filter_by(login=login, password=password).first()
Users.query.filter_by делает запрос к базе данных users, фильтруя строки, где данные совпадают с данными из переменных. Метод .first() возвращает первый найденный результат, который соответствует критериям, и этот результат сохраняется в переменной user.

Теперь, все так же в блоке try, но и внутри with app.app_context(), нужно добавить условие if, которое проверит, найден ли пользователь.
Python: Скопировать в буфер обмена
if user:

Внутри блока первым делом нужно получить текущее время и дату, а затем конвертировать их в формат даты, как в базе данных. Напомню, что в базе данных хранится только дата без времени, а в системе будет парситься и дата, и время. Конвертация формата необходима для того, чтобы затем сравнить текущую дату с датой из базы данных.
Python: Скопировать в буфер обмена
current_date = datetime.now().date()

После этого, внутри блока if user, нужно добавить еще одно условие if, которое проверяет, что текущая дата меньше или равна дате из базы данных. Если условие выполнено, бот отправляет сообщение “complete”.
Python: Скопировать в буфер обмена
Код:
if user.license > current_date:
   await message.answer("complete"

В блоке else, соответственно, нужно указать отправку сообщения с уведомлением об ошибке.
Python: Скопировать в буфер обмена
Код:
else:
   await message.answer("error")

Под условием if user: нужно указать блок else, который будет отправлять сообщение о том, что пользователь не найден.
Python: Скопировать в буфер обмена
Код:
else:
   await message.answer("not_found")

Весь этот код находится внутри блока try, и с ним мы закончили. Теперь нужно добавить блок except, который будет срабатывать в случае, если что-то пойдет не по плану.
Python: Скопировать в буфер обмена
Код:
except Exception as e:
   await message.answer(f"error: {e}")

Далее нужно создать функцию, которую в дальнейшем нужно будет вызывать при старте программы. Функция будет использовать инициализированный диспетчер и токен бота, чтобы запустить метод start_polling. Этот метод начнет бесконечный процесс, который будет проверять новые сообщения и обрабатывать их.
Python: Скопировать в буфер обмена
Код:
async def start_bot():
   await dp.start_polling(bot)

Теперь можно указать, что будет запускаться при запуске программы, а именно: создание базы данных в контексте приложения во Flask (app — это инициализированный объект Flask, app.app_context() — это контекст приложения). Также в самом конце запускается сам Flask в отдельном потоке а после этого запускается функция start_bot которая была описана выше
Python: Скопировать в буфер обмена
Код:
with app.app_context():
   db.create_all()
flask_thread = threading.Thread(target=lambda: app.run(host="0.0.0.0", port="1488", debug=True, use_reloader=False))
flask_thread.start()
asyncio.run(start_bot())

На этом с написанием логики бота для проверки подписок закончено, и вот что получается:
1729008578750.png


Софт отправляет сообщение боту с логином и паролем. Бот проверяет эти данные и сверяет столбец с датой подписки. Если дата еще не наступила, то бот отправляет сообщение “complete”. Софт парсит это сообщение и запускает основную логику программы.

Привязка по HWID​

Теперь, как бонус, будет показано то, как сделать привязку по HWID компьютера, чтобы аккаунт не передавался из рук в руки.

Что такое HWID​

Если кратко, то это уникальный ID компьютера, который создается на основе данных о железе. Он не меняется при повторной попытке его получить, в отличие от UUID, который каждый раз рандомный. HWID может измениться только при замене комплектующих в ПК или при переустановке Windows.

Обновление кода софта​

Для начала нужно перейти в проект софта в файл license_initialization.py.
Затем внутри файла импортировать библиотеку hwid.
Python: Скопировать в буфер обмена
import hwid

Далее, внутри функции check_license() нужно вызвать метод get_hwid() из библиотеки hwid и записать результат в переменную. Результатом как раз и будет сам HWID.
Python: Скопировать в буфер обмена
user_hwid = hwid.get_hwid()

Затем нужно изменить переменную command, в которой хранился текст для отправки сообщения.
Было:
Python: Скопировать в буфер обмена
command = f"Login: {login}, Password: {password}"

Стало:
Python: Скопировать в буфер обмена
command = f"Login: {login}, Password: {password}, HWID: {user_hwid}"

На этом с работой в коде софта закончено.

Как устроен код​

Получается, когда отправляется сообщение, вызывается переменная command с текстом, внутри которой в свою очередь вызывается переменная user_hwid, внутри которой вызывается метод для получения HWID. Таким образом, боту отправляется не только логин и пароль, но и актуальный HWID.
1729008752049.png


Обновление кода бота​

Теперь нужно дополнить код бота, чтобы он обрабатывал новый формат сообщения. И первое, что будет сделано, это изменена структура базы данных. В неё нужно добавить новый столбец с названием hwid.
Python: Скопировать в буфер обмена
hwid = db.Column(db.String(80), nullable=True)
1729008773393.png



Далее нужно перейти в функцию check_credentials и изменить строку, в которой разделялось полученное сообщение на две части и записывалось в две переменные.
Было:
Python: Скопировать в буфер обмена
login, password = message.text.split(',')

Стало:
Python: Скопировать в буфер обмена
login, password, user_hwid = message.text.split(',')
Теперь будет записываться в 3 переменные.

Теперь чуть ниже нужно дописать логику для удаления лишнего из переменной user_hwid. То есть сейчас в переменной что-то такое: HWID: 03000200, а нужно 03000200. Для этого нужно записать в переменную символы только после знака двоеточия.
Python: Скопировать в буфер обмена
user_hwid = user_hwid.strip().split(':')[1].strip()

Далее, всё так же внутри функции нужно найти условие, проверяющее дату подписки.
Python: Скопировать в буфер обмена
Код:
if user.license > current_date:
   await message.answer("complete")

И дополнить его таким образом:
Python: Скопировать в буфер обмена
Код:
if user.license > current_date:
   if not user.hwid:
       user.hwid = user_hwid
       db.session.commit()
       await message.answer("complete")
   elif user.hwid == user_hwid:
       await message.answer("complete")
   else:
       await message.answer("error: HWID не подходит")
Условие if not user.hwid проверяет, чтобы если в базе данных столбец hwid пустой, то записывать туда HWID, полученный из сообщения, и отправлять ответное SMS “complete”.
В elif происходит проверка на то, что если HWID из сообщения такой же, как HWID из базы данных, то также отправляется “complete”.

Как устроен код​

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

Вывод​

На этом статья закончена. Мне кажется, показанный в статье метод проверки подписок может кому-то пригодиться, так как его реализация совершенно не сложная и, возможно, в чем-то лучше классического подхода. Поэтому мне захотелось поделиться ею с вами. Описание того, как сделать Flask-панель в виде отдельного окна приложения, было также сделано для того, чтобы продемонстрировать, на мой взгляд, нестандартное решение данной задачи и узнать мнение читателей. Если вы знаете другие способы реализации, то с удовольствием об этом прочитаю.


Ссылка на статью в виде документа: https://docs.google.com/document/d/1Keo7tGWD_XtFIdQ_NuZxthx7XIrEFpKr9Of5nXlTTmc/edit?usp=sharing

Сделано OverlordGameDev специально для форума XSS.IS
 
Сверху Снизу