Compare commits

5 Commits

Author SHA1 Message Date
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
12 changed files with 465 additions and 43 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/
session_storage/
__pycache__/
runtime/
*.swp
*.swo
*.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 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 больше не в
почёте, и теперь у меня всё в локальном Matrix, поэтому бот тоже перенесён сюда.
## Подготовка окружения
## Для запуска рекомендуется `docker`
1. Соберите образ:
```bash
sudo docker build -t csonac .
```
2. Выполните первый запуск в интерактивном режиме
```bash
sudo mkdir runtime
sudo chown 1000:1000 runtime
sudo docker run -v ./runtime:/runtime -ti csonac:latest
```
3. В папке `runtime` появится файл `config.json`. Внесите туда нужные настройки
4. Запустите ещё раз в интерактивном режиме, чтобы авторизоваться
```bash
sudo docker run -v ./runtime:/runtime -ti csonac:latest
```
5. Дальше можно запускать контейнер в фоне
```bash
sudo docker run -p 127.0.0.1:4980:4980 -v ./runtime:/runtime --detach csonac:latest
```
> Рекомендуется запускать контейнер при помощи `docker compose`.
## Запуск без `docker`
1. Клонируйте репозиторий и перейдите в его директорию
```bash
@@ -26,17 +49,6 @@ python main.py
python main.py
```
## Запуск
Для запуска приложения вы можете либо активировать `venv`, либо просто использовать полный путь до интерпретатора Python:
```bash
~/2026-matrix-downloader/.venv/bin/python main.py
```
```bash
. .venv/bin/activate
python main.py
```
## Как работает бот
Для каждой службы (канала уведомлений) создаётся своя собственная комната и в
@@ -64,33 +76,45 @@ python main.py
> `nginx`. Это также позволит вам использовать защищённое соединение, что
> исключит возможность применения атаки Man-in-the-Middle для перехвата токена.
Все запросы к веб-серверу требуют авторизации, используя HTTP заголовок
`Authorization` и схему `Bearer`. Токен авторизации генерируется посредством
взаимодействия с ботом в Matrix.
Все запросы к веб-серверу являются GET-запросами и требуют авторизации.
Авторизоваться можно двумя путями:
1. Использовать HTTP-заголовок `Authorization` и схему `Bearer`. Например:
```plain
Authorization: Bearer 1234567890abcdef
```
2. Использовать URL параметр `token`. Например:
```plain
https://csonac.su/api/notify?token=f1829e94d...
```
Рекомендуется использовать первый способ (HTTP-заголовок), так как это позволяет
избежать раскрытия токена в логах сервера и других местах, где можно посмотреть
URL прошлых запросов.
> Для каждого сервиса, использующего бота, рекомендуется генерировать свой
> собственный токен. Это позволит отозвать токен только для одного серсива, если
> токен будет украден.
Доступные эндпоинты:
- `POST /<channel>/notify`
- **Описание.** Используется, чтобы отправить уведомление в указанный канал.
Вместо `<channel>` указывается код канала, получаемый при помощи команды
`!info`, выполненной в комнате Matrix.
- **Тело запроса.** Тело запроса представляет собой `json` объект:
```json
- `GET /api/notify`
- **Описание.** Используется, чтобы отправить уведомление в канал.
- **Параметры запроса**
- `channel` - код канала, получаемый при помощи команды `!info`,
выполненной в комнате Matrix
- `service` - имя сервиса (учитывается, только если для токена не было
настроено имя сервиса через бота)
- `text` - текст уведомления (форматирование не поддерживается)
- **Ответ**
- В случае успеха сервер вернёт `200` и JSON следующего формата:
```json
{
"service": "<название сервиса; указывается, если токен это позволяет>",
"text": "<текст уведомления>"
"notification_id": "Notification ID will be here"
}
```
- **Тело ответа.** Тело ответа представляет собой `json` объект. В случае
успеха в нём будут все поля, перечисляемые ниже. В случае провала - только
поле `error`, содержащее текстовое описание ошибки.
```json
```
> В текущей версии `notification_id` не имеет практической пользы и его
> формат будет меняться.
- В случае ошибки сервер вернёт JSON следующего формата:
```json
{
"error": null,
"notification_id": "<здесь будет Notification ID>"
"detail": "Error description in English"
}
```
- ``
```

View File

@@ -11,7 +11,11 @@ import util
DEFAULT_CONFIG = {
"matrix_homeserver": "https://matrix.domain.net",
"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)
return cfg
except:
traceback.print_exc()
return None

View File

@@ -84,20 +84,33 @@ class Database:
wait_task = asyncio.create_task(
asyncio.sleep(self.BACKGROUND_ROUTINE_PERIOD)
)
if self._connection is None:
continue
# get current time
current_time = time.time()
# delete old bans
try:
if self._connection is not None:
statement = "DELETE FROM bans WHERE expires_at <= ? RETURNING ip"
async with self._connection.execute(statement, (time.time(),)) as cursor:
async for row in cursor:
ip = row["ip"]
if ip in self._bans:
del self._bans[ip]
self._logger.debug(f"IP {ip} is not banned anymore")
await self._connection.commit()
statement = "DELETE FROM bans WHERE expires_at <= ? RETURNING ip"
async with self._connection.execute(statement, (current_time,)) as cursor:
async for row in cursor:
ip = row["ip"]
if ip in self._bans:
del self._bans[ip]
self._logger.debug(f"IP {ip} is not banned anymore")
await self._connection.commit()
self._logger.debug("Performed banned IPs cleanup")
except:
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")
async def _load_bans_from_database(self) -> None:
@@ -115,6 +128,12 @@ class Database:
self._logger.error(traceback.format_exc())
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
#
@@ -127,6 +146,7 @@ class Database:
self._background_stop_event: asyncio.Event | None = None
self._background_task: asyncio.Task | None = None
self._bans: dict = {}
self._fails: dict[str, list[float]] = {}
async def connect(self) -> bool:
"""Connect to the database. Returns False on failure."""
@@ -137,6 +157,7 @@ class Database:
self._connection.row_factory = Row
await self._setup_tables(self._connection)
self._bans = {}
self._fails = {}
await self._load_bans_from_database()
self._background_stop_event = asyncio.Event()
self._background_task = asyncio.create_task(
@@ -346,4 +367,21 @@ class Database:
if ip in self._bans:
del self._bans[ip]
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_user: str
store_dir: str
web_ip: str
web_port: int
fails_to_ban: int
ban_duration: float
@dataclass
class ObjectRoom:

View File

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

View File

@@ -1,3 +1,4 @@
agent-detector==1.1.0
aioconsole==0.8.2
aiofiles==25.1.0
aiohappyeyeballs==2.7.1
@@ -5,29 +6,70 @@ aiohttp==3.14.3
aiohttp_socks==0.12.0
aiosignal==1.4.0
aiosqlite==0.22.1
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.14.2
atomicwrites==1.4.1
attrs==26.1.0
build==1.5.0
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
h11==0.16.0
h2==4.4.1
hpack==4.2.0
httpcore==1.0.9
httptools==0.8.0
httpx==0.28.1
hyperframe==6.1.0
idna==3.19
Jinja2==3.1.6
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
mab @ git+https://git.tyukalov.su/nikita/mab@c3046307c7bc4e65113aab62b07bad2a148b49e5
markdown-it-py==4.2.0
MarkupSafe==3.0.3
matrix-nio==0.26.0
mdurl==0.1.2
multidict==6.7.1
packaging==26.3
peewee==3.19.0
propcache==0.5.2
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
python-dotenv==1.2.3
python-multipart==0.0.32
python-socks==3.0.0
PyYAML==6.0.3
referencing==0.37.0
rich==15.0.0
rich-toolkit==0.20.3
rignore==0.8.1
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
urllib3==2.7.0
uvicorn==0.52.4
uvloop==0.22.1
vodozemac==0.10.0
watchfiles==1.2.0
websockets==17.1
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

135
web_middleware.py Normal file
View File

@@ -0,0 +1,135 @@
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"}
)
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_to_room(
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}