Compare commits

9 Commits

Author SHA1 Message Date
a178183251 Fixed Dockerfile, improved README 2026-09-13 00:20:56 +03:00
4194f3da48 mab update, !short command, last_access fix
- Updated mab to v0.5.0
- Added `!short` command to add named tokens
- Fixed `last_access_at` field not being updated
2026-09-13 00:12:00 +03:00
4a947cec3d Updated mab version; added filters support 2026-09-02 18:34:56 +03:00
b28b7c583d Updated mab version; improved README 2026-09-02 14:51:27 +03:00
Nikita Tyukalov, ASUS, Linux
f8e9a528e2 Added Dockerfile and related manual 2026-08-29 20:06:53 +03:00
Nikita Tyukalov, ASUS, Linux
9266d8f32b Fixed README.md 2026-08-29 19:26:19 +03:00
Nikita Tyukalov, ASUS, Linux
d5e30f8c36 Updated requirements.txt 2026-08-29 19:25:21 +03:00
Nikita Tyukalov, ASUS, Linux
a803b80d59 Improved web server code 2026-08-29 19:24:33 +03:00
Nikita Tyukalov, ASUS, Linux
8094aaab10 First API implementation 2026-08-29 17:24:39 +03:00
13 changed files with 615 additions and 148 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.venv/
session_storage/
__pycache__/
runtime/
*.swp
*.swo
*.vscode
*.json
*.sqlite

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.venv/ .venv/
session_storage/ session_storage/
__pycache__/ __pycache__/
runtime/
*.swp *.swp
*.swo *.swo
*.vscode *.vscode

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install --no-install-recommends -y libmagic1 libolm3 libolm-dev git && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
WORKDIR /runtime
RUN chown -R 1000:1000 /usr/src /runtime
USER 1000:1000
CMD [ "python", "/usr/src/app/main.py" ]

View File

@@ -4,7 +4,30 @@
Раньше был бот с таким же функционалом, но для Telegram. Telegram больше не в Раньше был бот с таким же функционалом, но для Telegram. Telegram больше не в
почёте, и теперь у меня всё в локальном Matrix, поэтому бот тоже перенесён сюда. почёте, и теперь у меня всё в локальном Matrix, поэтому бот тоже перенесён сюда.
## Подготовка окружения ## Для запуска рекомендуется `docker`
1. [Скачайте образ](https://git.tyukalov.su/nikita/-/packages/container/2026-matrix-csonac) (измените версию на нужную вам):
```bash
sudo docker pull git.tyukalov.su/nikita/2026-matrix-csonac:0.4
```
2. Выполните первый запуск в интерактивном режиме
```bash
sudo mkdir runtime
sudo chown 1000:1000 runtime
sudo docker run -v ./runtime:/runtime -ti git.tyukalov.su/nikita/2026-matrix-csonac:0.4
```
3. В папке `runtime` появится файл `config.json`. Внесите туда нужные настройки
4. Запустите ещё раз в интерактивном режиме, чтобы авторизоваться
```bash
sudo docker run -v ./runtime:/runtime -ti git.tyukalov.su/nikita/2026-matrix-csonac:0.4
```
5. Дальше можно запускать контейнер в фоне
```bash
sudo docker run -p 127.0.0.1:4980:4980 -v ./runtime:/runtime --detach git.tyukalov.su/nikita/2026-matrix-csonac:0.4
```
> Рекомендуется запускать контейнер при помощи `docker compose`.
## Запуск без `docker`
1. Клонируйте репозиторий и перейдите в его директорию 1. Клонируйте репозиторий и перейдите в его директорию
```bash ```bash
@@ -26,17 +49,6 @@ python main.py
python main.py python main.py
``` ```
## Запуск
Для запуска приложения вы можете либо активировать `venv`, либо просто использовать полный путь до интерпретатора Python:
```bash
~/2026-matrix-downloader/.venv/bin/python main.py
```
```bash
. .venv/bin/activate
python main.py
```
## Как работает бот ## Как работает бот
Для каждой службы (канала уведомлений) создаётся своя собственная комната и в Для каждой службы (канала уведомлений) создаётся своя собственная комната и в
@@ -54,6 +66,7 @@ python main.py
- `!ban <IP> <SECONDS> [REASON]` - забанить указанный IP на указанное число секунд (можно указать причину) - `!ban <IP> <SECONDS> [REASON]` - забанить указанный IP на указанное число секунд (можно указать причину)
- `!unban <IP>` - разбанить указанный IP адрес - `!unban <IP>` - разбанить указанный IP адрес
- `!bans` - получить список забаненных IP адресов - `!bans` - получить список забаненных IP адресов
- `!short <TOKEN>` - создать токен с указанным именем
## Как работает веб-сервер ## Как работает веб-сервер
@@ -64,33 +77,45 @@ python main.py
> `nginx`. Это также позволит вам использовать защищённое соединение, что > `nginx`. Это также позволит вам использовать защищённое соединение, что
> исключит возможность применения атаки Man-in-the-Middle для перехвата токена. > исключит возможность применения атаки Man-in-the-Middle для перехвата токена.
Все запросы к веб-серверу требуют авторизации, используя HTTP заголовок Все запросы к веб-серверу являются GET-запросами и требуют авторизации.
`Authorization` и схему `Bearer`. Токен авторизации генерируется посредством Авторизоваться можно двумя путями:
взаимодействия с ботом в Matrix. 1. Использовать HTTP-заголовок `Authorization` и схему `Bearer`. Например:
```plain
Authorization: Bearer 1234567890abcdef
```
2. Использовать URL параметр `token`. Например:
```plain
https://csonac.su/api/notify?token=f1829e94d...
```
Рекомендуется использовать первый способ (HTTP-заголовок), так как это позволяет
избежать раскрытия токена в логах сервера и других местах, где можно посмотреть
URL прошлых запросов.
> Для каждого сервиса, использующего бота, рекомендуется генерировать свой > Для каждого сервиса, использующего бота, рекомендуется генерировать свой
> собственный токен. Это позволит отозвать токен только для одного серсива, если > собственный токен. Это позволит отозвать токен только для одного серсива, если
> токен будет украден. > токен будет украден.
Доступные эндпоинты: Доступные эндпоинты:
- `POST /<channel>/notify` - `GET /api/notify`
- **Описание.** Используется, чтобы отправить уведомление в указанный канал. - **Описание.** Используется, чтобы отправить уведомление в канал.
Вместо `<channel>` указывается код канала, получаемый при помощи команды - **Параметры запроса**
`!info`, выполненной в комнате Matrix. - `channel` - код канала, получаемый при помощи команды `!info`,
- **Тело запроса.** Тело запроса представляет собой `json` объект: выполненной в комнате Matrix
```json - `service` - имя сервиса (учитывается, только если для токена не было
настроено имя сервиса через бота)
- `text` - текст уведомления (форматирование не поддерживается)
- **Ответ**
- В случае успеха сервер вернёт `200` и JSON следующего формата:
```json
{ {
"service": "<название сервиса; указывается, если токен это позволяет>", "notification_id": "Notification ID will be here"
"text": "<текст уведомления>"
} }
``` ```
- **Тело ответа.** Тело ответа представляет собой `json` объект. В случае > В текущей версии `notification_id` не имеет практической пользы и его
успеха в нём будут все поля, перечисляемые ниже. В случае провала - только > формат будет меняться.
поле `error`, содержащее текстовое описание ошибки. - В случае ошибки сервер вернёт JSON следующего формата:
```json ```json
{ {
"error": null, "detail": "Error description in English"
"notification_id": "<здесь будет Notification ID>"
} }
``` ```
- ``

View File

@@ -4,8 +4,7 @@ import html
import time import time
import traceback import traceback
from mab import MatrixBot from mab import * # type: ignore
from nio import MatrixRoom, RoomMessageText
import util import util
from database import Database from database import Database
@@ -14,9 +13,21 @@ from datatypes import *
# #
# PRIVATE # PRIVATE
# #
_bot: MatrixBot = None # type: ignore
_db: Database = None # type: ignore _db: Database = None # type: ignore
_COMMANDS = {
"help": "Получить справку",
"info": "Получить сведения о комнате",
"tokens": "Получить список токенов",
"auth": "Создать новый токен",
"deauth": "Удалить существующий токен",
"name": "Задать (или удалить) имя для токена",
"ban": "Забанить IP адрес",
"unban": "Разбанить IP адрес",
"bans": "Получить список забаненных IP адресов",
"short": "(Небезопасно) Создать токен-имя",
}
def _generate_help_message() -> str: def _generate_help_message() -> str:
"""Generates help message in HTML markup""" """Generates help message in HTML markup"""
result = "<strong><i>Как использовать</i></strong><br><ol>" result = "<strong><i>Как использовать</i></strong><br><ol>"
@@ -27,75 +38,42 @@ def _generate_help_message() -> str:
result += "</ol>" result += "</ol>"
result += "<br><br><strong><i>Команды</i></strong>" result += "<br><br><strong><i>Команды</i></strong>"
for aliases in _COMMANDS: for key in _COMMANDS:
result += f"<br><code>!{aliases[0]}</code> - <i>{html.escape(_COMMANDS[aliases][1])}</i>" result += f"<br><code>!{key}</code> - <i>{html.escape(_COMMANDS[key])}</i>"
return result return result
#
# GENERIC EVENT HANDLERS
#
async def _on_text(room: MatrixRoom, event: RoomMessageText) -> None:
"""This callback is called when text message is received"""
if event.sender == _bot.get_client().user_id:
return
text = event.body
parts = [p for p in text.split() if p.strip()]
if len(parts) < 1:
return
cmd = parts[0].lower()
if not cmd.startswith("!"):
return
cb = None
for aliases in _COMMANDS:
for alias in aliases:
if alias == cmd.lstrip("!"):
cb = _COMMANDS[aliases][0]
break
if cb:
break
if cb is None:
await _bot.send_text_to_room(room.room_id, "<strong>Используйте <code>!help</code></strong>")
return
try:
await cb(room, parts[1:])
except:
traceback.print_exc()
# #
# COMMAND HANDLERS # COMMAND HANDLERS
# #
async def _on_cmd_help(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_help(ctx: EventContext) -> None:
"""!help""" """!help"""
await _bot.send_text_to_room(room.room_id, _generate_help_message(), is_html=True) await ctx.bot.send_text(ctx.room, _generate_help_message())
async def _on_cmd_info(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_info(ctx: EventContext) -> None:
"""!info""" """!info"""
# get room info (or add it) # get room info (or add it)
room_info = await _db.room_get(room.room_id) room_info = await _db.room_get(ctx.room.room_id)
if room_info is None: if room_info is None:
room_info = await _db.room_create(room.room_id) room_info = await _db.room_create(ctx.room.room_id)
# failure # failure
if room_info is None: if room_info is None:
await _bot.send_text_to_room( await ctx.bot.send_text(
room.room_id, ctx.room, "<strong>Нет информации о комнате</strong>"
"<strong>Нет информации о комнате</strong>",
is_html=True
) )
return return
# respond # respond
response = "<strong><i>Сведения о комнате</i></strong><br>" response = "<strong><i>Сведения о комнате</i></strong><br>"
response += f"<strong>Канал:</strong> <code>{html.escape(room_info.code)}</code>" response += f"<strong>Канал:</strong> <code>{html.escape(room_info.code)}</code>"
await _bot.send_text_to_room(room.room_id, response, is_html=True) await ctx.bot.send_text(ctx.room, response)
async def _on_cmd_tokens(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_tokens(ctx: EventContext) -> None:
"""!info""" """!info"""
# get all tokens and check if there are none # get all tokens and check if there are none
tokens = await _db.token_get_all() tokens = await _db.token_get_all()
if not tokens: if not tokens:
await _bot.send_text_to_room( await ctx.bot.send_text(
room.room_id, ctx.room,
"<strong>Нет токенов, используйте <code>!auth</code></strong>", "<strong>Нет токенов, используйте <code>!auth</code></strong>"
is_html=True
) )
return return
# create the list of tokens # create the list of tokens
@@ -110,15 +88,15 @@ async def _on_cmd_tokens(room: MatrixRoom, args: list[str]) -> None:
response += f"<li><strong>Последнее использование:</strong> <i>{util.date_to_text(token.last_access_at)}</i></li>" response += f"<li><strong>Последнее использование:</strong> <i>{util.date_to_text(token.last_access_at)}</i></li>"
response += "</ul>" response += "</ul>"
# respond # respond
await _bot.send_text_to_room(room.room_id, response, is_html=True) await ctx.bot.send_text(ctx.room, response)
async def _on_cmd_auth(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_auth(ctx: EventContext) -> None:
"""!auth""" """!auth"""
# create new token # create new token
token = await _db.token_create() token = await _db.token_create()
# set the name if it is provided # set the name if it is provided
if args: if ctx[CTX_CMD_ARGS]:
token.name = " ".join(args) token.name = " ".join(ctx[CTX_CMD_ARGS])
await _db.token_set_name(token.code, token.name) await _db.token_set_name(token.code, token.name)
# create the response # create the response
response = "<strong>Создан новый токен</strong>" response = "<strong>Создан новый токен</strong>"
@@ -129,39 +107,39 @@ async def _on_cmd_auth(room: MatrixRoom, args: list[str]) -> None:
else: else:
response += "указывается в запросе" response += "указывается в запросе"
# respond # respond
await _bot.send_text_to_room(room.room_id, response, is_html=True) await ctx.bot.send_text(ctx.room, response)
async def _on_cmd_deauth(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_deauth(ctx: EventContext) -> None:
"""!deauth""" """!deauth"""
# no token provided # no token provided
if len(args) != 1: if len(ctx[CTX_CMD_ARGS]) != 1:
await _bot.send_text_to_room(room.room_id, "<strong>Укажите токен, который надо удалить (должен быть ровно один аргумент)</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>Укажите токен, который надо удалить (должен быть ровно один аргумент)</strong>")
return return
token = args[0] token = ctx[CTX_CMD_ARGS][0]
# check if token does not exist # check if token does not exist
if await _db.token_get(token) is None: if await _db.token_get(token) is None:
await _bot.send_text_to_room(room.room_id, "<strong>Токен не найден</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>Токен не найден</strong>")
return return
# remove the token # remove the token
await _db.token_delete(token) await _db.token_delete(token)
# respond # respond
response = f"<strong>Удалён токен <code>{token}</code></strong>" response = f"<strong>Удалён токен <code>{token}</code></strong>"
await _bot.send_text_to_room(room.room_id, response, is_html=True) await ctx.bot.send_text(ctx.room, response)
async def _on_cmd_name(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_name(ctx: EventContext) -> None:
"""!name""" """!name"""
# check arguments # check arguments
if len(args) < 1: if len(ctx[CTX_CMD_ARGS]) < 1:
error = "<strong>Требуется указать как минимум токен. Если хотите убрать имя, то имя указывать не надо. Если имя нужно назначить или изменить, то после токена укажите новое имя.</strong>" error = "<strong>Требуется указать как минимум токен. Если хотите убрать имя, то имя указывать не надо. Если имя нужно назначить или изменить, то после токена укажите новое имя.</strong>"
await _bot.send_text_to_room(room.room_id, error, is_html=True) await ctx.bot.send_text(ctx.room, error)
return return
token = args[0] token = ctx[CTX_CMD_ARGS][0]
new_name = " ".join(args[1:]) new_name = " ".join(ctx[CTX_CMD_ARGS][1:])
if not new_name.strip(): if not new_name.strip():
new_name = None new_name = None
# check if token exists # check if token exists
if await _db.token_get(token) is None: if await _db.token_get(token) is None:
await _bot.send_text_to_room(room.room_id, "<strong>Токен не существует</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>Токен не существует</strong>")
return return
# set new name # set new name
await _db.token_set_name(token, new_name) await _db.token_set_name(token, new_name)
@@ -171,51 +149,51 @@ async def _on_cmd_name(room: MatrixRoom, args: list[str]) -> None:
else: else:
response = f"<strong>Удалено имя для токена <code>{token}</code></strong>" response = f"<strong>Удалено имя для токена <code>{token}</code></strong>"
# respond # respond
await _bot.send_text_to_room(room.room_id, response, is_html=True) await ctx.bot.send_text(ctx.room, response)
async def _on_cmd_ban(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_ban(ctx: EventContext) -> None:
"""!ban""" """!ban"""
# check arguments # check arguments
if len(args) < 2: if len(ctx[CTX_CMD_ARGS]) < 2:
error = "<strong>Формат: <code>!ban &lt;IP&gt; &lt;SECONDS&gt; [REASON]</code></strong>" error = "<strong>Формат: <code>!ban &lt;IP&gt; &lt;SECONDS&gt; [REASON]</code></strong>"
await _bot.send_text_to_room(room.room_id, error, is_html=True) await ctx.bot.send_text(ctx.room, error)
return return
# get args # get event.command_args
try: try:
ip = args[0] ip = ctx[CTX_CMD_ARGS][0]
duration = float(args[1]) duration = float(ctx[CTX_CMD_ARGS][1])
reason = " ".join(args[2:]) if args[2:] else "Manual ban" reason = " ".join(ctx[CTX_CMD_ARGS][2:]) if ctx[CTX_CMD_ARGS][2:] else "Manual ban"
except: except:
error = "<strong>Возникла ошибка. Наверняка неправильно указаны секунды.</strong>" error = "<strong>Возникла ошибка. Наверняка неправильно указаны секунды.</strong>"
await _bot.send_text_to_room(room.room_id, error, is_html=True) await ctx.bot.send_text(ctx.room, error)
return return
# ban # ban
try: try:
await _db.ban_create(ip, time.time() + duration, reason) await _db.ban_create(ip, time.time() + duration, reason)
await _bot.send_text_to_room(room.room_id, "<strong>IP адрес заблокирован</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>IP адрес заблокирован</strong>")
except: except:
await _bot.send_text_to_room(room.room_id, "<strong>Возникла ошибка</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>Возникла ошибка</strong>")
async def _on_cmd_unban(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_unban(ctx: EventContext) -> None:
"""!unban""" """!unban"""
# check arguments # check arguments
if len(args) != 1: if len(ctx[CTX_CMD_ARGS]) != 1:
error = "<strong>Формат: <code>!ban &lt;IP&gt;</code></strong>" error = "<strong>Формат: <code>!ban &lt;IP&gt;</code></strong>"
await _bot.send_text_to_room(room.room_id, error, is_html=True) await ctx.bot.send_text(ctx.room, error)
return return
# unban # unban
try: try:
await _db.ban_delete(args[0]) await _db.ban_delete(ctx[CTX_CMD_ARGS][0])
await _bot.send_text_to_room(room.room_id, "<strong>IP адрес разблокирован (если он был заблокирован)</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>IP адрес разблокирован (если он был заблокирован)</strong>")
except: except:
await _bot.send_text_to_room(room.room_id, "<strong>Возникла ошибка</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>Возникла ошибка</strong>")
async def _on_cmd_bans(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_bans(ctx: EventContext) -> None:
"""!bans""" """!bans"""
# list # list
bans = _db.ban_get_all() bans = _db.ban_get_all()
if not bans: if not bans:
await _bot.send_text_to_room(room.room_id, "<strong>Нет заблокированных IP адресов</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>Нет заблокированных IP адресов</strong>")
return return
# create the response # create the response
result = "<strong>Список заблокированных IP</strong><br><ul>" result = "<strong>Список заблокированных IP</strong><br><ul>"
@@ -225,27 +203,73 @@ async def _on_cmd_bans(room: MatrixRoom, args: list[str]) -> None:
result += f"<li><strong>Причина:</strong> <code>{html.escape(ban["reason"])}</code></li>" result += f"<li><strong>Причина:</strong> <code>{html.escape(ban["reason"])}</code></li>"
result += "</ul></li>" result += "</ul></li>"
result += "</ul>" result += "</ul>"
await _bot.send_text_to_room(room.room_id, result, is_html=True) await ctx.bot.send_text(ctx.room, result)
async def _on_cmd_short(ctx: EventContext) -> None:
"""!auth"""
# get token to use
if len(ctx[CTX_CMD_ARGS]) != 1:
error = "<strong>Формат: <code>!short &lt;token&gt;</code></strong>"
await ctx.bot.send_text(ctx.room, error)
return
# create new token
try:
token = await _db.token_create(ctx[CTX_CMD_ARGS][0])
except Exception as e:
await ctx.bot.send_text(ctx.room, f"Не удалось создать токен: {" ".join(e.args)}")
return
# create the response
response = "<strong>Создан новый токен</strong>"
response += f"<br><strong>Код:</strong> <code>{token.code}</code>"
response += f"<br><strong>Имя сервиса:</strong> указывается в запросе"
# respond
await ctx.bot.send_text(ctx.room, response)
_COMMANDS = {
("help", "h", "?"): (_on_cmd_help, "Получить справку"),
("info", "room", "i", "r"): (_on_cmd_info, "Получить сведения о комнате"),
("tokens", "t"): (_on_cmd_tokens, "Получить список токенов"),
("auth", "create", "a", "c"): (_on_cmd_auth, "Создать новый токен"),
("deauth", "delete", "d"): (_on_cmd_deauth, "Удалить существующий токен"),
("name", "n"): (_on_cmd_name, "Задать (или удалить) имя для токена"),
("ban", "b"): (_on_cmd_ban, "Забанить IP адрес"),
("unban", "u"): (_on_cmd_unban, "Разбанить IP адрес"),
("bans", "l"): (_on_cmd_bans, "Получить список забаненных IP адресов"),
}
# #
# PUBLIC # PUBLIC
# #
def setup(bot: MatrixBot, db: Database) -> None: def setup(bot: MatrixBot, db: Database) -> None:
"""Setup the callbacks""" """Setup the callbacks"""
global _bot, _db global _db
_db = db _db = db
_bot = bot bot.add_callback(
_bot.add_event_callback(_on_text, RoomMessageText) # type: ignore ~SenderIsBotFilter() & BodyCommandFilter(["info", "room", "i", "r"]),
_on_cmd_info
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["tokens", "t"]),
_on_cmd_tokens
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["auth", "create", "a", "c"]),
_on_cmd_auth
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["deauth", "delete", "d"]),
_on_cmd_deauth
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["name", "n"]),
_on_cmd_name
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["ban", "b"]),
_on_cmd_ban
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["unban", "u"]),
_on_cmd_unban
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["bans", "l"]),
_on_cmd_bans
)
bot.add_callback(
~SenderIsBotFilter() & BodyCommandFilter(["short", "s"]),
_on_cmd_short
)
bot.add_callback(
~SenderIsBotFilter() & NewMessageFilter(),
_on_cmd_help
)

View File

@@ -11,7 +11,11 @@ import util
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"matrix_homeserver": "https://matrix.domain.net", "matrix_homeserver": "https://matrix.domain.net",
"matrix_user": "short_username", "matrix_user": "short_username",
"store_dir": "session_storage" "store_dir": "session_storage",
"web_ip": "0.0.0.0",
"web_port": 4980,
"fails_to_ban": 50,
"ban_duration": 600
} }
@@ -50,4 +54,5 @@ async def load_config(path: str = "config.json") -> AppConfig | None:
cfg = AppConfig(**j) cfg = AppConfig(**j)
return cfg return cfg
except: except:
traceback.print_exc()
return None return None

View File

@@ -84,20 +84,33 @@ class Database:
wait_task = asyncio.create_task( wait_task = asyncio.create_task(
asyncio.sleep(self.BACKGROUND_ROUTINE_PERIOD) asyncio.sleep(self.BACKGROUND_ROUTINE_PERIOD)
) )
if self._connection is None:
continue
# get current time
current_time = time.time()
# delete old bans # delete old bans
try: try:
if self._connection is not None: statement = "DELETE FROM bans WHERE expires_at <= ? RETURNING ip"
statement = "DELETE FROM bans WHERE expires_at <= ? RETURNING ip" async with self._connection.execute(statement, (current_time,)) as cursor:
async with self._connection.execute(statement, (time.time(),)) as cursor: async for row in cursor:
async for row in cursor: ip = row["ip"]
ip = row["ip"] if ip in self._bans:
if ip in self._bans: del self._bans[ip]
del self._bans[ip] self._logger.debug(f"IP {ip} is not banned anymore")
self._logger.debug(f"IP {ip} is not banned anymore") await self._connection.commit()
await self._connection.commit()
self._logger.debug("Performed banned IPs cleanup") self._logger.debug("Performed banned IPs cleanup")
except: except:
self._logger.error(traceback.format_exc()) self._logger.error(traceback.format_exc())
# delete expired fails
try:
for ip in dict(self._fails):
self._fails[ip] = list(
filter(lambda x: x > current_time, self._fails[ip])
)
if not self._fails[ip]:
del self._fails[ip]
except:
self._logger.error(traceback.format_exc())
self._logger.debug("Stopped background worker") self._logger.debug("Stopped background worker")
async def _load_bans_from_database(self) -> None: async def _load_bans_from_database(self) -> None:
@@ -115,6 +128,12 @@ class Database:
self._logger.error(traceback.format_exc()) self._logger.error(traceback.format_exc())
return return
def _get_fails_for_ip(self, ip) -> int:
if ip not in self._fails:
return 0
t = time.time()
return sum([(1 if expires_at > t else 0) for expires_at in self._fails[ip]])
# #
# PUBLIC # PUBLIC
# #
@@ -127,6 +146,7 @@ class Database:
self._background_stop_event: asyncio.Event | None = None self._background_stop_event: asyncio.Event | None = None
self._background_task: asyncio.Task | None = None self._background_task: asyncio.Task | None = None
self._bans: dict = {} self._bans: dict = {}
self._fails: dict[str, list[float]] = {}
async def connect(self) -> bool: async def connect(self) -> bool:
"""Connect to the database. Returns False on failure.""" """Connect to the database. Returns False on failure."""
@@ -137,6 +157,7 @@ class Database:
self._connection.row_factory = Row self._connection.row_factory = Row
await self._setup_tables(self._connection) await self._setup_tables(self._connection)
self._bans = {} self._bans = {}
self._fails = {}
await self._load_bans_from_database() await self._load_bans_from_database()
self._background_stop_event = asyncio.Event() self._background_stop_event = asyncio.Event()
self._background_task = asyncio.create_task( self._background_task = asyncio.create_task(
@@ -231,11 +252,17 @@ class Database:
return None return None
async def token_create(self) -> ObjectToken: async def token_create(self, code: str | None = None) -> ObjectToken:
"""Create a token.""" """Create a token."""
if self._connection is None: if self._connection is None:
raise RuntimeError("Not connected to the database") raise RuntimeError("Not connected to the database")
code = get_hash(str(time.time()).encode() + os.urandom(64)) if code is None:
code = get_hash(str(time.time()).encode() + os.urandom(64))
else:
if len(code) < 2 or len(code) > 16:
raise RuntimeError("Token must be 2-16 symbols long")
if not all(ord(c) < 128 and (c.islower() or c.isdigit()) for c in code):
raise RuntimeError("Only digits and ASCII lowercase letters are allowed")
create_time = time.time() create_time = time.time()
statement = """ statement = """
INSERT INTO tokens (code, name, created_at, last_access_at) INSERT INTO tokens (code, name, created_at, last_access_at)
@@ -308,6 +335,17 @@ class Database:
except: except:
self._logger.error(traceback.format_exc()) self._logger.error(traceback.format_exc())
async def token_set_last_access(self, code: str, timestamp: float) -> None:
"""Set last_access_at for the token."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
try:
statement = "UPDATE tokens SET last_access_at=? WHERE code=?"
await self._connection.execute(statement, (timestamp, code))
await self._connection.commit()
except:
self._logger.error(traceback.format_exc())
async def ban_create(self, ip: str, expires_at: float, reason: str) -> None: async def ban_create(self, ip: str, expires_at: float, reason: str) -> None:
"""Save information about banned IP address. Replaces existing IPs.""" """Save information about banned IP address. Replaces existing IPs."""
@@ -346,4 +384,21 @@ class Database:
if ip in self._bans: if ip in self._bans:
del self._bans[ip] del self._bans[ip]
except: except:
self._logger.error(traceback.format_exc()) self._logger.error(traceback.format_exc())
def fail_create(self, ip: str, expires_at: float) -> int:
"""Adds failed access attempt. Returns count of failed attempts for IP."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
if ip not in self._fails:
self._fails[ip] = []
self._fails[ip].append(expires_at)
return self._get_fails_for_ip(ip)
def fail_clear(self, ip: str) -> None:
"""Clears failed attempts counter."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
if ip in self._fails:
del self._fails[ip]

View File

@@ -10,6 +10,10 @@ class AppConfig:
matrix_homeserver: str matrix_homeserver: str
matrix_user: str matrix_user: str
store_dir: str store_dir: str
web_ip: str
web_port: int
fails_to_ban: int
ban_duration: float
@dataclass @dataclass
class ObjectRoom: class ObjectRoom:

View File

@@ -14,6 +14,7 @@ import database
import config import config
import util import util
import bot_callbacks import bot_callbacks
import web
bot: MatrixBot = None # type: ignore bot: MatrixBot = None # type: ignore
@@ -47,17 +48,21 @@ async def main() -> None:
db = database.Database(Path("database.sqlite")) db = database.Database(Path("database.sqlite"))
# setup the callbacks # setup the callbacks
bot_callbacks.setup(bot, db) bot_callbacks.setup(bot, db)
# setup the web server
web_server = web.Web(cfg, bot, db)
# start the app # start the app
if not await db.connect(): if not await db.connect():
util.log_error("Can't connect to the database!") util.log_error("Can't connect to the database!")
return return
bot.start() await bot.start()
await web_server.start()
# wait for Ctrl+C # wait for Ctrl+C
await util.get_app_stop_event().wait() await util.get_app_stop_event().wait()
# stop the app # stop the app
await web_server.stop()
await bot.stop() await bot.stop()
await db.disconnect() await db.disconnect()

View File

@@ -1,3 +1,4 @@
agent-detector==1.1.0
aioconsole==0.8.2 aioconsole==0.8.2
aiofiles==25.1.0 aiofiles==25.1.0
aiohappyeyeballs==2.7.1 aiohappyeyeballs==2.7.1
@@ -5,29 +6,72 @@ aiohttp==3.14.3
aiohttp_socks==0.12.0 aiohttp_socks==0.12.0
aiosignal==1.4.0 aiosignal==1.4.0
aiosqlite==0.22.1 aiosqlite==0.22.1
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.14.2
atomicwrites==1.4.1 atomicwrites==1.4.1
attrs==26.1.0 attrs==26.1.0
build==1.5.0 build==1.5.0
cachetools==7.1.7 cachetools==7.1.7
certifi==2026.7.22
click==8.5.0
detect-installer==0.1.0
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.141.1
fastapi-cli==0.0.32
fastapi-cloud-cli==0.24.0
fastar==0.12.0
frozenlist==1.8.0 frozenlist==1.8.0
h11==0.16.0 h11==0.16.0
h2==4.4.1 h2==4.4.1
hpack==4.2.0 hpack==4.2.0
httpcore==1.0.9
httptools==0.8.0
httpx==0.28.1
hyperframe==6.1.0 hyperframe==6.1.0
idna==3.19 idna==3.19
Jinja2==3.1.6
jsonschema==4.26.0 jsonschema==4.26.0
jsonschema-specifications==2025.9.1 jsonschema-specifications==2025.9.1
mab @ git+https://git.tyukalov.su/nikita/mab@c3046307c7bc4e65113aab62b07bad2a148b49e5 mab @ git+https://git.tyukalov.su/nikita/mab@v0.5.0
markdown-it-py==4.2.0
MarkupSafe==3.0.3
matrix-nio==0.26.0 matrix-nio==0.26.0
mdurl==0.1.2
multidict==6.7.1 multidict==6.7.1
packaging==26.3 packaging==26.3
peewee==3.19.0 peewee==3.19.0
pillow==12.3.0
propcache==0.5.2 propcache==0.5.2
pycryptodome==3.23.0 pycryptodome==3.23.0
pydantic==2.13.5
pydantic-extra-types==2.11.1
pydantic-settings==2.15.0
pydantic_core==2.46.5
Pygments==2.21.0
pyproject_hooks==1.2.0 pyproject_hooks==1.2.0
python-dotenv==1.2.3
python-magic==0.4.27
python-multipart==0.0.32
python-socks==3.0.0 python-socks==3.0.0
PyYAML==6.0.3
referencing==0.37.0 referencing==0.37.0
rich==15.0.0
rich-toolkit==0.20.3
rignore==0.8.1
rpds-py==2026.6.3 rpds-py==2026.6.3
sentry-sdk==2.68.1
shellingham==1.5.4
starlette==1.6.0
typer==0.27.2
typing-inspection==0.4.4
typing_extensions==4.16.0
unpaddedbase64==2.1.0 unpaddedbase64==2.1.0
urllib3==2.7.0
uvicorn==0.52.4
uvloop==0.22.1
vodozemac==0.10.0 vodozemac==0.10.0
watchfiles==1.2.0
websockets==17.1
yarl==1.24.5 yarl==1.24.5

73
web.py Normal file
View File

@@ -0,0 +1,73 @@
"""This module implements API"""
import asyncio
import traceback
import time
import html
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from mab import MatrixBot
import uvicorn
from datatypes import AppConfig
from database import Database
import web_routes
import web_middleware
class Web:
#
# PRIVATE
#
async def _cb_exception(self, request: Request, exc: Exception):
traceback.print_exception(exc)
return JSONResponse(
status_code=500,
content={"detail": "Internal Server Error"}
)
#
# PUBLIC
#
def __init__(self, app_config: AppConfig, bot: MatrixBot, db: Database):
self._api = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
self._api.state.database = db
self._api.state.matrix = bot
self._api.state.app_config = app_config
self._api.include_router(web_routes.router)
self._api.add_middleware(web_middleware.AuthMiddleware, ["/api"])
self._api.add_middleware(web_middleware.BanCheckMiddleware, ["/api"])
self._api.add_middleware(web_middleware.RealIpResolver)
self._api.add_exception_handler(Exception, self._cb_exception)
self._server_config = uvicorn.Config(
self._api,
host=app_config.web_ip,
port=app_config.web_port
)
self._server: uvicorn.Server | None = None
self._server_task: asyncio.Task | None = None
async def start(self) -> None:
"""Start the API."""
if self._server is not None or self._server_task is not None:
raise RuntimeError("The server is already started")
self._server = uvicorn.Server(self._server_config)
self._server_task = asyncio.create_task(self._server.serve())
async def stop(self) -> None:
"""Stop the API server."""
if self._server is None or self._server_task is None:
raise RuntimeError("The server is not started yet")
self._server_task.cancel()
try:
await self._server_task
except asyncio.CancelledError:
pass
except:
traceback.print_exc()
self._server_task = None
self._server = None

136
web_middleware.py Normal file
View File

@@ -0,0 +1,136 @@
import time
from fastapi import Request
from fastapi import status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from database import Database
from datatypes import ObjectToken, AppConfig
class AuthMiddleware(BaseHTTPMiddleware):
"""Middleware for authorization"""
def _is_request_protected(self, request: Request) -> bool:
"""Returns True if request URL starts with one of private prefixes"""
for pp in self.private_prefixes:
if request.url.path.startswith(pp):
return True
return False
async def _fail_and_ban_if_needed(self, request: Request) -> None:
"""Adds a fail and bans the IP if too much failures are recorded."""
app_config: AppConfig = request.app.state.app_config
database: Database = request.app.state.database
current_time: float = time.time()
ip: str = request.state.ip
total_fails = database.fail_create(ip, current_time + 10.0)
if total_fails >= app_config.fails_to_ban:
await database.ban_create(
ip,
current_time + app_config.ban_duration,
"Banned by AuthMiddleware"
)
database.fail_clear(ip)
def __init__(self, app, private_prefixes: list[str]):
super().__init__(app)
self.private_prefixes = private_prefixes
async def dispatch(self, request: Request, call_next):
# do not check authorization for public endpoints
if not self._is_request_protected(request):
return await call_next(request)
token = None
# look for the token in headers
if "Authorization" in request.headers:
v = request.headers["Authorization"]
if not v.lower().startswith("bearer"):
await self._fail_and_ban_if_needed(request)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Invalid authorization scheme"}
)
parts = [p for p in v.split(" ") if p.strip()]
if len(parts) != 2:
await self._fail_and_ban_if_needed(request)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "No credentials provided in HTTP header"}
)
token = parts[1]
# look for the token in query parameters
elif "token" in request.query_params:
token = request.query_params.get("token")
if not token:
await self._fail_and_ban_if_needed(request)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "The token is empry"}
)
# no token found
else:
await self._fail_and_ban_if_needed(request)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "No token provided"}
)
# try to authorize
database: Database = request.app.state.database
token_data: ObjectToken | None = await database.token_get(token)
if not token_data:
await self._fail_and_ban_if_needed(request)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "No such token"}
)
await database.token_set_last_access(token, time.time())
request.state.token = token_data
return await call_next(request)
class BanCheckMiddleware(BaseHTTPMiddleware):
"""Middleware for checking if the IP is banned."""
def _is_request_protected(self, request: Request) -> bool:
"""Returns True if request URL starts with one of private prefixes"""
for pp in self.private_prefixes:
if request.url.path.startswith(pp):
return True
return False
def __init__(self, app, private_prefixes: list[str]):
super().__init__(app)
self.private_prefixes = private_prefixes
async def dispatch(self, request: Request, call_next):
# do not check authorization for public endpoints
if not self._is_request_protected(request):
return await call_next(request)
database: Database = request.app.state.database
if database.ban_check(request.state.ip):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "You are temporarily banned"}
)
return await call_next(request)
class RealIpResolver(BaseHTTPMiddleware):
"""Middleware that gets real IP address of the client."""
def __init__(self, app):
super().__init__(app)
async def dispatch(self, request: Request, call_next):
# assign None by default
request.state.ip = None
# look for IP address
x_forwarded_for = request.headers.get("x-forwarded-for")
if x_forwarded_for:
request.state.ip = x_forwarded_for.split(",")[0].strip()
else:
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
request.state.ip = x_real_ip
else:
request.state.ip = request.client.host if request.client else None
return await call_next(request)

70
web_routes.py Normal file
View File

@@ -0,0 +1,70 @@
"""API Routes"""
import html
import traceback
from fastapi import APIRouter, Request, Depends
from fastapi import HTTPException, status
from pydantic import BaseModel, Field
from mab import MatrixBot
from database import Database
from datatypes import *
router = APIRouter(prefix="/api")
#
# MODELS
#
class ModelGetNotify(BaseModel):
channel: str = Field(
...,
min_length=11,
max_length=11,
description="Notification channel (use `!info` bot command)"
)
text: str = Field(
...,
min_length=1,
max_length=1024,
description="Text to use as notification body"
)
service: str | None = Field(
None,
min_length=1,
max_length=1024,
description="Service name to use (ignored if token has name)"
)
#
# ENDPOINTS
#
@router.get("/notify")
async def _get_notify(request: Request, params: ModelGetNotify = Depends()):
# improve readability
database: Database = request.app.state.database
matrix: MatrixBot = request.app.state.matrix
token: ObjectToken = request.state.token
# check if room exists
room = await database.room_get(params.channel)
if room is None:
raise HTTPException(status.HTTP_200_OK, "No such channel")
# prepare service name
service = token.name or params.service or "Unnamed"
# prepare text of the notification
text = f"<strong>Уведомление от службы <code>{html.escape(service)}</code></strong>"
text += "<br>" * 2
text += html.escape(params.text)
# try to send the notification
try:
notification_id = await matrix.send_text(
room.matrix_id,
text,
is_html=True
)
except:
traceback.print_exc()
raise HTTPException(status.HTTP_200_OK, "Failed to send matrix message")
# success
return {"notification_id": notification_id}