Добавление всех наработок за период практики.

This commit is contained in:
SkyForces
2026-07-24 00:46:19 +03:00
parent 24943b8a73
commit ae4141ba28
56 changed files with 3987 additions and 136 deletions
+3
View File
@@ -0,0 +1,3 @@
from .app import create_app
__all__ = ["create_app"]
+69
View File
@@ -0,0 +1,69 @@
import atexit
import logging
import httpx
from flask import Flask, jsonify
from dotenv import load_dotenv
from werkzeug.middleware.proxy_fix import ProxyFix
from .binding import BindingService, SiteBindingRepository
from .bitrix import BitrixAuthError, BitrixClient
from .config import SiteConfig
from .crypto import TokenCipher
from .database import SiteDatabase
from .routes import SiteInputError, create_blueprint
logger = logging.getLogger(__name__)
def create_app() -> Flask:
load_dotenv()
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
config = SiteConfig.from_env()
app = Flask(__name__)
app.config["PUBLIC_BASE_URL"] = config.public_base_url
if config.trust_proxy:
# Используется конфигурация, при которой снаружи контейнера находится
# обратный прокси nginx.
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=1,
x_proto=1,
x_host=1,
)
database = SiteDatabase(config.database_url)
database.open()
bitrix = BitrixClient(
config.bitrix_client_id,
config.bitrix_client_secret,
config.oauth_token_url,
)
bindings = BindingService(
SiteBindingRepository(database),
TokenCipher(config.token_encryption_key),
config.bot_username,
config.binding_ttl_seconds,
)
app.extensions["database"] = database
app.register_blueprint(create_blueprint(bindings, bitrix))
atexit.register(database.close)
atexit.register(bitrix.close)
@app.errorhandler(SiteInputError)
def input_error(error: SiteInputError):
return jsonify(error=str(error)), 400
@app.errorhandler(BitrixAuthError)
def auth_error(error: BitrixAuthError):
return jsonify(error=str(error)), 403
@app.errorhandler(httpx.HTTPError)
def bitrix_error(error: httpx.HTTPError):
logger.exception("Bitrix request failed")
return jsonify(error="Не удалось проверить пользователя Bitrix24"), 502
return app
+98
View File
@@ -0,0 +1,98 @@
import hashlib
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from .database import SiteDatabase
from .crypto import TokenCipher
def hash_token(token: str) -> bytes:
return hashlib.sha256(token.encode("utf-8")).digest()
@dataclass(frozen=True)
class BindingLink:
url: str
expires_at: datetime
class SiteBindingRepository:
"""Доступ сайта только к функциям выпуска токенов."""
def __init__(self, database: SiteDatabase) -> None:
self.database = database
def issue(
self,
member_id: str,
domain: str,
bitrix_user_id: int,
token_hash: bytes,
token_expires_at: datetime,
access_token: bytes,
refresh_token: bytes,
oauth_expires_at: datetime,
) -> None:
"""Сохраняет в БД информацию о токене, выданном пользователю."""
query = """
SELECT *
FROM binding.issue_v1(%s, %s, %s, %s, %s, %s, %s, %s) \
"""
with self.database.transaction() as connection:
connection.execute(
query,
(
member_id,
domain,
bitrix_user_id,
token_hash,
token_expires_at,
access_token,
refresh_token,
oauth_expires_at,
),
).fetchone()
class BindingService:
def __init__(
self,
repository: SiteBindingRepository,
cipher: TokenCipher,
bot_username: str,
ttl_seconds: int,
) -> None:
self.repository = repository
self.cipher = cipher
self.bot_username = bot_username
self.ttl_seconds = ttl_seconds
def issue(
self,
member_id: str,
domain: str,
bitrix_user_id: int,
access_token: str,
refresh_token: str,
auth_expires_seconds: int,
) -> BindingLink:
"""Выдает ссылку для привязки аккаунта."""
token = secrets.token_urlsafe(32)
now = datetime.now(UTC)
token_expires_at = now + timedelta(seconds=self.ttl_seconds)
oauth_expires_at = now + timedelta(seconds=auth_expires_seconds)
self.repository.issue(
member_id,
domain,
bitrix_user_id,
hash_token(token),
token_expires_at,
self.cipher.encrypt(access_token),
self.cipher.encrypt(refresh_token),
oauth_expires_at,
)
return BindingLink(
url=f"https://t.me/{self.bot_username}?start=bind_{token}",
expires_at=token_expires_at,
)
+118
View File
@@ -0,0 +1,118 @@
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
class BitrixAuthError(RuntimeError):
pass
@dataclass(frozen=True)
class BitrixUser:
id: int
name: str
@dataclass(frozen=True)
class BitrixAuth:
member_id: str
domain: str
access_token: str
refresh_token: str
expires_in: int
user: BitrixUser
class BitrixClient:
"""Получает доверенный OAuth-контекст и проверяет пользователя."""
def __init__(
self,
client_id: str,
client_secret: str,
oauth_token_url: str,
client: httpx.Client | None = None,
) -> None:
self.client_id = client_id
self.client_secret = client_secret
self.oauth_token_url = oauth_token_url
# Клиент передается как внешняя зависимость для модульного тестирования.
self._client = client or httpx.Client(timeout=15)
# Соответственно, если клиент внешний, то класс
# этим ресурсом не управляет.
self._owns_client = client is None
def authorize(self, refresh_token: str) -> BitrixAuth:
try:
# Обмениваем рефреш-токен на новую пару токенов.
# https://apidocs.bitrix24.com/settings/oauth/auto-renewal.html
# https://apidocs.bitrix24.com/settings/oauth/simple-way.html
response = self._client.get(
self.oauth_token_url,
params={
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": refresh_token,
},
)
response.raise_for_status()
except httpx.HTTPError:
# URL запроса содержит секреты, поэтому не пробрасываем его выше.
raise BitrixAuthError("Не удалось обновить OAuth-токен") from None
data = response.json()
if "error" in data:
raise BitrixAuthError(
str(data.get("error_description") or data["error"]))
# Получаем эндпоинт, с которым связаны наши токены.
endpoint = str(data.get("client_endpoint") or "")
parsed_endpoint = urlparse(endpoint)
if parsed_endpoint.scheme != "https" or not parsed_endpoint.hostname:
raise BitrixAuthError("Bitrix вернул некорректный REST endpoint")
# Сохраняем токен доступа и проверяем пользователя.
access_token = str(data["access_token"])
user = self._current_user(endpoint, access_token)
expected_user_id = data.get("user_id")
if expected_user_id is not None and user.id != int(expected_user_id):
raise BitrixAuthError(
"OAuth-токен принадлежит другому пользователю")
return BitrixAuth(
member_id=str(data["member_id"]),
domain=parsed_endpoint.hostname.lower(),
access_token=access_token,
refresh_token=str(data["refresh_token"]),
expires_in=int(data.get("expires_in", 3600)),
user=user,
)
def _current_user(self, endpoint: str, access_token: str) -> BitrixUser:
"""Получение информации о пользователе для проверки работоспособности."""
# https://apidocs.bitrix24.com/api-reference/user/user-current.html
response = self._client.post(
endpoint.rstrip("/") + "/user.current.json",
data={"auth": access_token},
)
response.raise_for_status()
data = response.json()
if "error" in data or not data.get("result"):
raise BitrixAuthError("Bitrix не подтвердил текущего пользователя")
user = data["result"]
name = " ".join(
part
for part in (
str(user.get("NAME") or "").strip(),
str(user.get("LAST_NAME") or "").strip(),
)
if part
)
return BitrixUser(id=int(user["ID"]), name=name or f"ID {user['ID']}")
def close(self) -> None:
if self._owns_client:
self._client.close()
+57
View File
@@ -0,0 +1,57 @@
import os
from dataclasses import dataclass
from urllib.parse import urlparse
def _required(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"{name} is not set")
return value
def _as_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class SiteConfig:
"""Настройки HTTP-приложения."""
database_url: str
public_base_url: str
bot_username: str
token_encryption_key: str
bitrix_client_id: str
bitrix_client_secret: str
oauth_token_url: str
binding_ttl_seconds: int = 600
trust_proxy: bool = True
@classmethod
def from_env(cls) -> "SiteConfig":
public_base_url = _required("PUBLIC_BASE_URL").rstrip("/")
parsed_url = urlparse(public_base_url)
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
raise RuntimeError("PUBLIC_BASE_URL must be an absolute URL")
ttl = int(os.getenv("BINDING_TOKEN_TTL_SECONDS", "600"))
if not 60 <= ttl <= 3600:
raise RuntimeError("BINDING_TOKEN_TTL_SECONDS must be 60..3600")
return cls(
database_url=_required("DATABASE_URL"),
public_base_url=public_base_url,
bot_username=_required("BOT_USERNAME").lstrip("@"),
token_encryption_key=_required("TOKEN_ENCRYPTION_KEY"),
bitrix_client_id=_required("BITRIX_CLIENT_ID"),
bitrix_client_secret=_required("BITRIX_CLIENT_SECRET"),
oauth_token_url=os.getenv(
"BITRIX_OAUTH_TOKEN_URL",
"https://oauth.bitrix.info/oauth/token/",
),
binding_ttl_seconds=ttl,
trust_proxy=_as_bool(os.getenv("TRUST_PROXY"), default=True),
)
+14
View File
@@ -0,0 +1,14 @@
from cryptography.fernet import Fernet
class TokenCipher:
"""Шифрует OAuth-токены перед передачей в БД."""
def __init__(self, key: str) -> None:
try:
self._fernet = Fernet(key.encode("ascii"))
except (ValueError, UnicodeEncodeError) as error:
raise RuntimeError("TOKEN_ENCRYPTION_KEY is invalid") from error
def encrypt(self, value: str) -> bytes:
return self._fernet.encrypt(value.encode("utf-8"))
+39
View File
@@ -0,0 +1,39 @@
from collections.abc import Generator
from contextlib import contextmanager
from psycopg import Connection
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
class SiteDatabase:
"""Данный класс представляет собой обертку над пулом соединений
с базой данных PostgreSQL."""
def __init__(self, database_url: str) -> None:
# Пул может содержать в себе максимум 5 соединений.
self.pool = ConnectionPool(
conninfo=database_url,
min_size=1,
max_size=5,
open=False,
# Фабрика для представления строк БД как словарей.
kwargs={"row_factory": dict_row},
)
def open(self) -> None:
self.pool.open(wait=True)
def close(self) -> None:
self.pool.close()
@contextmanager
def transaction(self) -> Generator[Connection]:
with self.pool.connection() as connection:
with connection.transaction():
yield connection
def ping(self) -> bool:
"""Простая проверка подключения к БД."""
with self.pool.connection() as connection:
return connection.execute("SELECT 1").fetchone() is not None
+7
View File
@@ -0,0 +1,7 @@
import os
bind = f"{os.getenv('SITE_HOST', '0.0.0.0')}:{os.getenv('SITE_PORT', '8000')}"
workers = int(os.getenv("SITE_WORKERS", "2"))
accesslog = "-"
errorlog = "-"
timeout = 30
+67
View File
@@ -0,0 +1,67 @@
from collections.abc import Mapping
from typing import Any
from flask import Blueprint, current_app, jsonify, render_template, request
from .binding import BindingService
from .bitrix import BitrixClient
class SiteInputError(ValueError):
pass
def _field(payload: Mapping[str, Any], name: str) -> str:
"""Проверка существования обязательного поля с именем name."""
for key in (name, name.lower(), name.upper()):
value = payload.get(key)
if value is not None and str(value).strip():
return str(value).strip()
raise SiteInputError(f"Не передано поле {name}")
def create_blueprint(
bindings: BindingService,
bitrix: BitrixClient,
) -> Blueprint:
blueprint = Blueprint("site", __name__)
@blueprint.get("/")
def index():
return jsonify(
service="bitrix-telegram-binding",
public_url=current_app.config["PUBLIC_BASE_URL"],
)
@blueprint.get("/health")
def health():
database = current_app.extensions["database"]
try:
available = database.ping()
except Exception:
available = False
status = "ok" if available else "error"
return jsonify(status=status), 200 if available else 503
@blueprint.post("/bitrix/bind")
def bind():
payload = request.get_json(silent=True) or request.form
refresh_token = _field(payload, "REFRESH_ID")
# OAuth-ответ дает доверенные ID портала и пользователя.
auth = bitrix.authorize(refresh_token)
link = bindings.issue(
auth.member_id,
auth.domain,
auth.user.id,
auth.access_token,
auth.refresh_token,
auth.expires_in,
)
return render_template(
"binding.html",
user=auth.user,
link=link,
)
return blueprint
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Привязка Telegram</title>
<style>
body {
font: 16px sans-serif;
max-width: 560px;
margin: 48px auto;
padding: 0 20px;
}
a {
display: inline-block;
padding: 12px 18px;
color: white;
background: #168acd;
border-radius: 8px;
text-decoration: none;
}
small {
display: block;
margin-top: 16px;
color: #666;
}
</style>
</head>
<body>
<h1>Привязка Telegram</h1>
<p>{{ user.name }}, откройте бота и подтвердите привязку.</p>
<a href="{{ link.url }}" target="_blank" rel="noopener">Открыть Telegram</a>
<small>Ссылка одноразовая и действует до {{ link.expires_at.strftime('%H:%M
UTC') }}.</small>
</body>
</html>