Merge app and frontend #3

Merged
IgorVolochay merged 32 commits from app into frontend 2026-09-01 09:00:36 +00:00
24 changed files with 2241 additions and 435 deletions
-6
View File
@@ -1,6 +0,0 @@
DISABLE_DOCS=true
MONGO_HOST=127.0.0.1
MONGO_PORT=27017
MONGO_USER=user
MONGO_PASS=pass
+16
View File
@@ -0,0 +1,16 @@
DEV_MODE=true # true = docs enabled + auth disabled; false = production mode
MONGO_HOST=127.0.0.1
MONGO_PORT=27017
MONGO_USER=user
MONGO_PASS=pass
RABBIT_HOST=127.0.0.1
RABBIT_PORT=5672
RABBIT_USER=user
RABBIT_PASS=pass
API_BASE_URL='http://localhost:5000'
MODERATION_SECRET=secret
TG_BOT_TOKEN='token'
TG_ADMIN_CHAT_ID=000000000
+60 -14
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
paths: paths:
- '**.py' - 'app/**'
branches: branches:
- main - main
- app - app
@@ -19,13 +19,14 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: 3.9 python-version: "3.12"
architecture: x64 cache: pip
cache-dependency-path: app/requirements.txt
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -38,30 +39,75 @@ jobs:
pytest: pytest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
MONGO_HOST: ${{ secrets.MONGO_HOST }} MONGO_HOST: "127.0.0.1"
MONGO_PORT: ${{ secrets.MONGO_PORT }} MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
MONGO_USER: ${{ secrets.MONGO_USER }} MONGO_USER: ${{ secrets.MONGO_USER }}
MONGO_PASS: ${{ secrets.MONGO_PASS }} MONGO_PASS: ${{ secrets.MONGO_PASS }}
RABBIT_HOST: "127.0.0.1"
RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
RABBIT_USER: ${{ secrets.RABBIT_USER }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS }}
DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: 3.9 python-version: "3.12"
architecture: x64 cache: pip
cache-dependency-path: app/requirements.txt
- name: Setup MongoDB - name: Start MongoDB
run: docker run --name mongodb -d -p ${{ secrets.MONGO_PORT }}:27017 -e MONGO_INITDB_ROOT_USERNAME=${{ secrets.MONGO_USER }} -e MONGO_INITDB_ROOT_PASSWORD=${{ secrets.MONGO_PASS }} mongodb/mongodb-community-server run: |
docker run -d --name mongodb \
-p "${MONGO_PORT}:27017" \
-e "MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}" \
-e "MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS}" \
mongodb/mongodb-community-server
for i in $(seq 1 30); do
docker exec mongodb mongosh \
--username "${MONGO_USER}" --password "${MONGO_PASS}" \
--eval "db.runCommand({ping:1})" && break
sleep 1
done
- name: Start RabbitMQ
run: |
docker run -d --name rabbitmq \
-p "${RABBIT_PORT}:5672" \
-e "RABBITMQ_DEFAULT_USER=${RABBIT_USER}" \
-e "RABBITMQ_DEFAULT_PASS=${RABBIT_PASS}" \
rabbitmq:3.13-alpine
for i in $(seq 1 30); do
docker exec rabbitmq rabbitmq-diagnostics -q ping && break
sleep 1
done
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip
pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1 pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
pip install -r app/requirements.txt pip install -r app/requirements.txt
- name: Setup moderated base cards
- name: Setup moderated base cards
working-directory: ./app/tools working-directory: ./app/tools
run: python3 _add_base_cards.py -a 2 -f data/base_cards.json run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
- name: Run pytest - name: Run pytest
run: pytest -vs run: pytest -vs
docker-build:
runs-on: ubuntu-latest
needs: [mypy, pytest]
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build backend image
run: docker build -f app/dockerfile.app -t tort-backend:ci ./app
- name: Build bot image
run: docker build -f app/dockerfile.bot -t tort-tg-bot:ci ./app
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.venv/
.env
tests/
security.log
.git/
.github/
*.md
dockerfile.app
dockerfile.bot
.dockerignore
-11
View File
@@ -1,11 +0,0 @@
FROM python:3.9.21-alpine
WORKDIR /app
COPY . .
RUN pip3 install -r requirements.txt
EXPOSE 5000
CMD ["python3", "main.py"]
+32
View File
@@ -0,0 +1,32 @@
# ── Stage 1: Install dependencies ────────────────────────────
FROM python:3.12.4-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Stage 2: Production image ────────────────────────────────
FROM python:3.12.4-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code (respects .dockerignore)
COPY . .
# Create non-root user
RUN groupadd --gid 1000 appuser && \
useradd --uid 1000 --gid appuser --shell /bin/sh appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
CMD ["python3", "main.py"]
+34
View File
@@ -0,0 +1,34 @@
# ── Stage 1: Install dependencies ────────────────────────────
FROM python:3.12.4-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Stage 2: Production image ────────────────────────────────
FROM python:3.12.4-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code (respects .dockerignore)
COPY . .
# Create non-root user
RUN groupadd --gid 1000 appuser && \
useradd --uid 1000 --gid appuser --shell /bin/sh appuser && \
chown -R appuser:appuser /app
USER appuser
# Healthcheck: verify RabbitMQ connection is possible
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import socket; s=socket.create_connection(('${RABBIT_HOST:-rabbitmq}', int('${RABBIT_PORT:-5672}')), timeout=3); s.close()" || exit 1
CMD ["python3", "tg_bot.py"]
+110
View File
@@ -0,0 +1,110 @@
"""
Centralized logging configuration using Loguru.
Outputs structured JSON to stdout (INFO/WARNING) and stderr (ERROR/CRITICAL).
Designed for Docker + Grafana Loki / Promtail.
Usage:
from logger import logger, setup_logging
setup_logging() # call once at application entry point
logger.info("message")
"""
from __future__ import annotations
import os
import sys
import logging
from types import FrameType
from typing import TYPE_CHECKING
from dotenv import load_dotenv
from loguru import logger
if TYPE_CHECKING:
from loguru import Record
def get_log_level() -> str:
"""Reads LOG_LEVEL or log_level from .env or environment, defaults to 'INFO'."""
load_dotenv()
level = os.getenv("LOG_LEVEL") or os.getenv("log_level") or "INFO"
return level.strip().upper()
# ── Stdout / stderr filters ───────────────────────────────────────────────────
def _stdout_filter(record: Record) -> bool:
"""Pass DEBUG / INFO / WARNING to stdout."""
return record["level"].no < logging.ERROR
def _stderr_filter(record: Record) -> bool:
"""Pass ERROR / CRITICAL to stderr."""
return record["level"].no >= logging.ERROR
# ── Stdlib → Loguru bridge ────────────────────────────────────────────────────
class InterceptHandler(logging.Handler):
"""Redirect all stdlib logging calls into Loguru."""
def emit(self, record: logging.LogRecord) -> None:
level: str | int
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame: FrameType | None = sys._getframe(6)
depth = 6
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
# ── Public setup function ─────────────────────────────────────────────────────
def setup_logging(level: str | None = None) -> None:
"""
Configure Loguru sinks and intercept all stdlib loggers.
Call once at the very start of the application entry point.
"""
if not level:
level = get_log_level()
else:
level = level.strip().upper()
logger.remove() # remove default sink
common: dict = {
"level": level,
"serialize": True, # JSON output
"backtrace": False,
"diagnose": False,
}
# stdout — DEBUG / INFO / WARNING
logger.add(sys.stdout, filter=_stdout_filter, **common)
# stderr — ERROR / CRITICAL
logger.add(sys.stderr, filter=_stderr_filter, **{**common, "level": "ERROR"})
# Redirect all stdlib loggers (uvicorn, motor, aiogram, aio_pika …)
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
# Suppress noisy third-party loggers — we handle HTTP access via middleware
_quiet = {
"uvicorn.access": logging.WARNING, # replaced by our middleware
"motor": logging.WARNING,
"aio_pika": logging.WARNING,
"aiormq": logging.WARNING,
}
for name, lvl in _quiet.items():
_lib_logger = logging.getLogger(name)
_lib_logger.handlers = [InterceptHandler()]
_lib_logger.setLevel(lvl)
_lib_logger.propagate = False
+231 -86
View File
@@ -1,191 +1,336 @@
import os import os
import secrets
import uvicorn import uvicorn
import asyncio import asyncio
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, Depends, Response, status from fastapi import FastAPI, Depends, Response, Header, HTTPException, status
from guard import SecurityMiddleware, SecurityConfig, SecurityDecorator
from contextlib import asynccontextmanager
from schemas.api_schemas import * from typing import Optional
from schemas.base_schemas import *
from schemas.api_schemas import BaseResponse, AddUserBody, AddCardBody, SelectChoice, ReactionCard, AddCommentBody
from schemas.base_schemas import Card
from mongo_worker import MongoWorker from mongo_worker import MongoWorker
from rabbit_worker import RabbitWorker
from tools.base_moderation import moderate_text from tools.base_moderation import moderate_text
from logger import logger, setup_logging
from middleware import RequestLoggingMiddleware
from tg_auth import get_current_user_id
setup_logging()
load_dotenv() load_dotenv()
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
app: FastAPI = FastAPI(title="This OR That", DEV_MODE: bool = os.getenv("DEV_MODE", "false").lower() == "true"
mongo_worker = MongoWorker()
_rabbit_worker: Optional[RabbitWorker] = None
def get_rabbit_worker() -> RabbitWorker:
"""Returns a singleton instance of the RabbitWorker."""
global _rabbit_worker
if _rabbit_worker is None:
_rabbit_worker = RabbitWorker()
return _rabbit_worker
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manages application startup and shutdown events, such as database index creation and cleanup."""
await mongo_worker.create_indexes()
if DEV_MODE:
logger.warning("⚠️ DEV_MODE is enabled — docs are exposed and Telegram initData auth is DISABLED")
logger.info("Application started on :5000")
yield
mongo_worker.client.close()
logger.info("Application shutdown completed.")
app: FastAPI = FastAPI(
title="This OR That",
summary="OpenAPI schema for \"This OR That\" project!", summary="OpenAPI schema for \"This OR That\" project!",
version="0.1", version="0.1",
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"}, contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
docs_url=None if disable_docs else "/docs", docs_url="/docs" if DEV_MODE else None,
redoc_url=None if disable_docs else "/redoc", redoc_url="/redoc" if DEV_MODE else None,
openapi_url=None if disable_docs else "/openapi.json") openapi_url="/openapi.json" if DEV_MODE else None,
mongo_worker = MongoWorker() lifespan=lifespan,
)
config = SecurityConfig(
enable_rate_limiting=True,
rate_limit=10, # TODO: check rate limits in real usage
rate_limit_window=3, # TODO: check rate limits in real usage
enable_redis=False,
enable_ip_banning=True,
enable_penetration_detection=True,
auto_ban_threshold=3,
auto_ban_duration=3600,
detection_compiler_timeout=2.0,
detection_max_content_length=10000,
detection_preserve_attack_patterns=True,
detection_semantic_threshold=0.7,
detection_anomaly_threshold=3.0,
detection_slow_pattern_threshold=0.1,
detection_monitor_history_size=1000,
detection_max_tracked_patterns=1000,
)
guard_deco = SecurityDecorator(config)
_security_middleware = SecurityMiddleware(app.router, config=config)
app.add_middleware(SecurityMiddleware, config=config)
app.add_middleware(RequestLoggingMiddleware)
app.state.guard_decorator = guard_deco
app.state._security_middleware = _security_middleware
MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production")
async def verify_moderation_secret(
x_moderation_secret: str = Header(..., alias="X-Moderation-Secret"),
) -> str:
"""Verifies the moderation secret provided in the request headers."""
if not secrets.compare_digest(x_moderation_secret, MODERATION_SECRET):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid moderation secret",
)
return x_moderation_secret
@app.get("/check_user", status_code=200) @app.get("/check_user", status_code=200)
async def check_user(user_id: NonNegativeInt, async def check_user(
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: user_id: int,
result = mongo.check_user(user_id) mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Checks if a user exists in the database by their user_id."""
result = await mongo.check_user(user_id)
return BaseResponse(result=result) return BaseResponse(result=result)
@app.get("/get_user", status_code=200) @app.get("/get_user", status_code=200)
async def get_user(user_id: NonNegativeInt, async def get_user(
user_id: int,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
if mongo.check_user(user_id): """Retrieves a user's details by their user_id."""
result = mongo.get_user(user_id) if await mongo.check_user(user_id):
result = await mongo.get_user(user_id)
return BaseResponse(result=result) return BaseResponse(result=result)
else:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="User doesn't exist", error=True) return BaseResponse(result="User doesn't exist", error=True)
@app.post("/add_user", status_code=201) @app.post("/add_user", status_code=201)
async def add_user(new_user: AddUserBody, @guard_deco.rate_limit(requests=3, window=60)
async def add_user(
new_user: AddUserBody,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: auth_user_id: Optional[int] = Depends(get_current_user_id),
if not mongo.check_user(new_user.user_id): mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
result = mongo.add_user(new_user.user_id, """Registers a new user in the database if they do not already exist."""
user_id = auth_user_id if auth_user_id is not None else new_user.user_id
if user_id is None:
raise HTTPException(status_code=422, detail="user_id is required")
if not await mongo.check_user(user_id):
result = await mongo.add_user(
user_id,
new_user.username, new_user.username,
new_user.first_name, new_user.first_name,
new_user.last_name, new_user.last_name,
new_user.photo_url) new_user.photo_url,
)
return BaseResponse(result=result) return BaseResponse(result=result)
else:
response.status_code = status.HTTP_409_CONFLICT response.status_code = status.HTTP_409_CONFLICT
return BaseResponse(result="User already exist", error=True) return BaseResponse(result="User already exist", error=True)
@app.get("/get_card", status_code=200) @app.get("/get_card", status_code=200)
async def get_card(card_id: NonNegativeInt, async def get_card(
card_id: int,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
card = mongo.get_card(card_id) """Retrieves a card's details by its card_id."""
card = await mongo.get_card(card_id)
if card: if card:
return BaseResponse(result=card) return BaseResponse(result=card)
else:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="There is no card with this card_id", error=True) return BaseResponse(result="There is no card with this card_id", error=True)
@app.get("/get_random_cards", status_code=200) @app.get("/get_random_cards", status_code=200)
async def get_random_cards(user_id: NonNegativeInt, @guard_deco.rate_limit(requests=5, window=60)
async def get_random_cards(
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: user_id: Optional[int] = None,
cards_visited = mongo.get_visited_cards(user_id) auth_user_id: Optional[int] = Depends(get_current_user_id),
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Fetches a set of random active cards that the user has not yet visited."""
resolved_user_id = auth_user_id if auth_user_id is not None else user_id
if resolved_user_id is None:
raise HTTPException(status_code=422, detail="user_id is required")
cards_visited = await mongo.get_visited_cards(resolved_user_id)
if cards_visited.error: if cards_visited.error:
response.status_code = status.HTTP_401_UNAUTHORIZED
return cards_visited
elif not cards_visited.result.cards_visited:
random_cards = mongo.get_random_cards(10, True)
if random_cards:
return BaseResponse(result=random_cards)
else:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="No active cards", error=True) return cards_visited
exclude_ids = cards_visited.result.cards_visited or None
random_cards = await mongo.get_random_cards(10, True, exclude_ids=exclude_ids)
result: list[Card] = list()
trys = 3
while len(result) < 10 and trys != 0:
random_cards = mongo.get_random_cards(10, True)
if not random_cards: if not random_cards:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="No active cards", error=True) return BaseResponse(result="No active cards for this user", error=True)
filtered_cards, filtered_cards_id = mongo.filter_cards(random_cards, cards_visited.result.cards_visited)
trys -= 1
if not filtered_cards:
continue
else:
result.extend(filtered_cards)
cards_visited.result.cards_visited.update(filtered_cards_id)
if not result: return BaseResponse(result=random_cards)
response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="No active cards fo this user", error=True)
else:
return BaseResponse(result=result)
@app.post("/add_card", status_code=201) @app.post("/add_card", status_code=201)
async def add_card(new_card: AddCardBody, @guard_deco.rate_limit(requests=3, window=60)
async def add_card(
new_card: AddCardBody,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: auth_user_id: Optional[int] = Depends(get_current_user_id),
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Adds a new card to the database and sends it for moderation."""
author_id = auth_user_id if auth_user_id is not None else new_card.author_id
if author_id is None:
raise HTTPException(status_code=422, detail="author_id is required")
if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B): if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
card = mongo.add_card_by_api(new_card.choice_A, card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, author_id)
new_card.choice_B, try:
new_card.author_id) await get_rabbit_worker().send_to_moderation(card)
except Exception as exc:
logger.error("Failed to send card {} to moderation queue: {}", card.card_id, exc)
return BaseResponse(result=card) return BaseResponse(result=card)
else:
response.status_code = status.HTTP_400_BAD_REQUEST response.status_code = status.HTTP_400_BAD_REQUEST
return BaseResponse(result="Card has not passed base moderation", error=True) return BaseResponse(result="Card has not passed base moderation", error=True)
@app.patch("/card_accept", status_code=200, dependencies=[Depends(verify_moderation_secret)])
async def card_accept(
card_id: int,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Accepts a card after moderation, making it active and visible to users."""
result = await mongo.accept_card(card_id)
if result.error:
response.status_code = status.HTTP_404_NOT_FOUND
return result
@app.patch("/card_reject", status_code=200, dependencies=[Depends(verify_moderation_secret)])
async def card_reject(
card_id: int,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Rejects a card during moderation and removes it from the database."""
result = await mongo.reject_card(card_id)
if result.error:
response.status_code = status.HTTP_404_NOT_FOUND
return result
@app.patch("/select_choice", status_code=200) @app.patch("/select_choice", status_code=200)
async def select_choice(choice_data: SelectChoice, async def select_choice(
choice_data: SelectChoice,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: auth_user_id: Optional[int] = Depends(get_current_user_id),
check_visited = mongo.get_visited_cards(choice_data.user_id) mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
if check_visited.error: """Records a user's choice (A or B) for a specific card."""
user_id = auth_user_id if auth_user_id is not None else choice_data.user_id
if user_id is None:
raise HTTPException(status_code=422, detail="user_id is required")
# Verify that the user exists before proceeding.
if not await mongo.check_user(user_id):
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return check_visited return BaseResponse(result="User doesn't exist", error=True)
elif not check_visited.error and choice_data.card_id in check_visited.result.cards_visited:
# Atomically mark the card as visited.
newly_visited = await mongo.try_mark_visited(user_id, choice_data.card_id)
if not newly_visited:
response.status_code = status.HTTP_403_FORBIDDEN response.status_code = status.HTTP_403_FORBIDDEN
return BaseResponse(result="Card already visited!", error=True) return BaseResponse(result="Card already visited!", error=True)
else:
select_choice_result = mongo.select_choice(choice_data.card_id, choice_data.choice) select_choice_result = await mongo.select_choice(choice_data.card_id, choice_data.choice)
if select_choice_result.error: if select_choice_result.error:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return select_choice_result return select_choice_result
else:
update_visited_result = mongo.update_visited_cards(choice_data.user_id, choice_data.card_id) return BaseResponse(result="Select choice complete!")
return BaseResponse(result="Select choice complite!")
@app.patch("/like_card", status_code=200) @app.patch("/like_card", status_code=200)
async def like_card(like_data: ReactionCard, async def like_card(
like_data: ReactionCard,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: auth_user_id: Optional[int] = Depends(get_current_user_id),
result = mongo.like_card(like_data.card_id, like_data.user_id) mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Adds a like to a specific card from a user."""
user_id = auth_user_id if auth_user_id is not None else like_data.user_id
if user_id is None:
raise HTTPException(status_code=422, detail="user_id is required")
result = await mongo.like_card(like_data.card_id, user_id)
if not result.error and result.result: if not result.error and result.result:
return BaseResponse(result="Added like to card") return BaseResponse(result="Added like to card")
else:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return result return result
@app.patch("/dislike_card", status_code=200) @app.patch("/dislike_card", status_code=200)
async def dislike_card(dislike_data: ReactionCard, async def dislike_card(
dislike_data: ReactionCard,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: auth_user_id: Optional[int] = Depends(get_current_user_id),
result = mongo.dislike_card(dislike_data.card_id, dislike_data.user_id) mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Adds a dislike to a specific card from a user."""
user_id = auth_user_id if auth_user_id is not None else dislike_data.user_id
if user_id is None:
raise HTTPException(status_code=422, detail="user_id is required")
result = await mongo.dislike_card(dislike_data.card_id, user_id)
if not result.error and result.result: if not result.error and result.result:
return BaseResponse(result="Added dislike to card") return BaseResponse(result="Added dislike to card")
else:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return result return result
@app.post("/comment", status_code=201) @app.post("/comment", status_code=201)
async def comment(comment_info: AddCommentBody, @guard_deco.rate_limit(requests=5, window=20)
async def comment(
comment_info: AddCommentBody,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: auth_user_id: Optional[int] = Depends(get_current_user_id),
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Adds a comment to a specific card after passing basic moderation."""
author_id = auth_user_id if auth_user_id is not None else comment_info.author_id
if author_id is None:
raise HTTPException(status_code=422, detail="author_id is required")
if not moderate_text(comment_info.comment_text): if not moderate_text(comment_info.comment_text):
response.status_code = status.HTTP_400_BAD_REQUEST response.status_code = status.HTTP_400_BAD_REQUEST
return BaseResponse(result="Comment has not passed base moderation", error=True) return BaseResponse(result="Comment has not passed base moderation", error=True)
result = mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text) result = await mongo.add_comment(author_id, comment_info.card_id, comment_info.comment_text)
if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]: if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]:
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return result return result
elif result.error: if result.error:
response.status_code = status.HTTP_400_BAD_REQUEST response.status_code = status.HTTP_400_BAD_REQUEST
return result return result
else: return result
@app.get("/get_comments", status_code=200)
async def get_comments(
card_id: int,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
"""Retrieves all comments for a specific card."""
result = await mongo.get_comments(card_id)
if result.error:
response.status_code = status.HTTP_404_NOT_FOUND
return result
return result return result
async def main(): async def main():
config = uvicorn.Config("main:app", port=5000, log_level="debug") """Starts the Uvicorn web server running the FastAPI application."""
config = uvicorn.Config("main:app", host="0.0.0.0", port=5000, log_level="warning")
server = uvicorn.Server(config) server = uvicorn.Server(config)
await server.serve() await server.serve()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
+69
View File
@@ -0,0 +1,69 @@
"""
HTTP request/response logging middleware for FastAPI.
Normal requests (2xx/3xx):
INFO — method, path, query_params, status_code, duration_ms
Error responses (4xx):
WARNING — all above + request_body (truncated to 1000 chars)
Server errors (5xx):
ERROR — all above + request_body (truncated to 1000 chars)
"""
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from logger import logger
_BODY_METHODS = frozenset({"POST", "PUT", "PATCH"})
_BODY_MAX_LEN = 1000
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
start = time.perf_counter()
# Read body only for methods that carry a payload
body: str | None = None
if request.method in _BODY_METHODS:
raw = await request.body()
body = raw.decode(errors="replace")[:_BODY_MAX_LEN]
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 1)
status = response.status_code
client_ip = request.headers.get("X-Forwarded-For")
if client_ip:
client_ip = client_ip.split(",")[0].strip()
else:
client_ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
base_fields = {
"method": request.method,
"path": request.url.path,
"query": str(request.query_params) or None,
"client_ip": client_ip,
"status": status,
"duration_ms": duration_ms,
}
if status >= 500:
logger.bind(**base_fields, request_body=body).error(
"{method} {path}{status} ({duration_ms}ms)", **base_fields
)
elif status >= 400:
logger.bind(**base_fields, request_body=body).warning(
"{method} {path}{status} ({duration_ms}ms)", **base_fields
)
else:
logger.bind(**base_fields).info(
"{method} {path}{status} ({duration_ms}ms)", **base_fields
)
return response
+262 -138
View File
@@ -1,22 +1,35 @@
import os import os
import pymongo import motor.motor_asyncio
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
from typing import Optional from typing import Optional
from pymongo import ReturnDocument
from schemas.base_schemas import * from schemas.base_schemas import User, Visited, Card, Comment
from schemas.api_schemas import * from schemas.api_schemas import BaseResponse
from logger import logger
class MongoWorker: class MongoWorker:
"""Worker class for handling all MongoDB database operations."""
def __init__(self): def __init__(self):
"""Initializes the MongoDB connection and sets up collection references."""
load_dotenv() load_dotenv()
self.client = pymongo.MongoClient(host = os.getenv('MONGO_HOST'), self.client = motor.motor_asyncio.AsyncIOMotorClient(
port = int(os.getenv('MONGO_PORT')), host=os.getenv('MONGO_HOST'),
port=int(os.getenv('MONGO_PORT', 27017)),
username=os.getenv('MONGO_USER'), username=os.getenv('MONGO_USER'),
password = os.getenv('MONGO_PASS')) password=os.getenv('MONGO_PASS'),
serverSelectionTimeoutMS=5000,
connectTimeoutMS=5000,
maxPoolSize=50,
minPoolSize=5,
maxIdleTimeMS=60000,
waitQueueTimeoutMS=5000
)
logger.info("MongoDB connection established.")
self.db = self.client["data"] self.db = self.client["data"]
self.users_data = self.db["users"] self.users_data = self.db["users"]
self.visited_data = self.db["visited"] self.visited_data = self.db["visited"]
@@ -24,191 +37,302 @@ class MongoWorker:
self.game_data = self.db["cards"] self.game_data = self.db["cards"]
self.comments_data = self.db["comments"] self.comments_data = self.db["comments"]
async def create_indexes(self) -> None:
"""Creates indexes on application startup."""
await self.users_data.create_index("user_id", unique=True)
await self.game_data.create_index("card_id", unique=True)
await self.game_data.create_index("active_status")
await self.visited_data.create_index("user_id", unique=True)
await self.comments_data.create_index("comment_id", unique=True)
logger.info("MongoDB indexes created.")
def check_user(self, user_id: int) -> bool:
if self.users_data.find_one({"user_id": user_id}):
return True
else:
return False
def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User: async def check_user(self, user_id: int) -> bool:
new_user = User(user_id=user_id, """Checks if a user exists in the database by their user_id."""
document = await self.users_data.find_one({"user_id": user_id}, {"_id": 1})
return document is not None
async def add_user(
self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User:
"""Creates a new user record in the database."""
new_user = User(
user_id=user_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
photo_url=photo_url, photo_url=photo_url,
registration_date=datetime.now().isoformat()) registration_date=datetime.now().isoformat(),
try: )
self.users_data.insert_one(new_user.model_dump()) await self.users_data.insert_one(new_user.model_dump())
return new_user logger.debug("User added: user_id={}, username={}", user_id, username)
except Exception as exception:
return new_user return new_user
def get_user(self, user_id: int) -> User: async def get_user(self, user_id: int) -> User:
return User.model_validate(self.users_data.find_one({"user_id": user_id})) """Retrieves a user's details from the database."""
document = await self.users_data.find_one({"user_id": user_id})
return User.model_validate(document)
def get_and_update_counter(self, counter_name: str) -> int: async def get_and_update_counter(self, counter_name: str) -> int:
counter = self.counters.find_one_and_update( """Atomically increments the counter and returns the new value."""
counter = await self.counters.find_one_and_update(
{"counter_name": counter_name}, {"counter_name": counter_name},
{"$inc": {"counter": 1}}, {"$inc": {"counter": 1}},
upsert=True, upsert=True,
return_document=True) return_document=ReturnDocument.AFTER,
)
return counter["counter"] return counter["counter"]
def get_visited_cards(self, user_id: int) -> BaseResponse: async def get_visited_cards(self, user_id: int) -> BaseResponse:
document = self.visited_data.find_one({"user_id": user_id}) """Retrieves the set of card IDs that a user has already visited."""
document = await self.visited_data.find_one({"user_id": user_id})
if not document: if not document:
check_user = self.check_user(user_id) if await self.check_user(user_id):
if check_user: return BaseResponse(result=Visited(user_id=user_id, cards_visited=set()))
return BaseResponse(result=Visited(user_id=user_id,
cards_visited=set()))
else:
return BaseResponse(result="User doesn't exist", error=True) return BaseResponse(result="User doesn't exist", error=True)
else:
return BaseResponse(result=Visited.model_validate(document)) return BaseResponse(result=Visited.model_validate(document))
def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]: async def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited:
filtered_cards = [card for card in random_cards if card.card_id not in cards_visited] """Adds a specific card ID to the user's set of visited cards."""
filtered_cards_id = [filtered_card.card_id for filtered_card in filtered_cards] updated = await self.visited_data.find_one_and_update(
{"user_id": user_id},
return filtered_cards, filtered_cards_id
def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited:
update_visited = self.visited_data.find_one_and_update({"user_id": user_id},
{"$addToSet": {"cards_visited": visited_card_id}}, {"$addToSet": {"cards_visited": visited_card_id}},
upsert=True, upsert=True,
return_document=True) return_document=ReturnDocument.AFTER,
return Visited.model_validate(update_visited) )
return Visited.model_validate(updated)
async def try_mark_visited(self, user_id: int, card_id: int) -> bool:
"""
Atomically marks a card as visited for the user.
Returns True if the card was newly marked (was not visited before).
Returns False if the card was already in the visited set.
Uses a conditional update filter (cards_visited: {$ne: card_id}) so that
only one concurrent request can "win" the mark — eliminating the TOCTOU
race condition between checking and writing.
"""
result = await self.visited_data.update_one(
{"user_id": user_id, "cards_visited": {"$ne": card_id}},
{"$addToSet": {"cards_visited": card_id}},
)
if result.modified_count == 1:
return True
# No document matched: either the visited doc doesn't exist yet,
# or the card is already in the set.
doc = await self.visited_data.find_one({"user_id": user_id}, {"cards_visited": 1})
if doc is None:
# First vote ever for this user — create the visited document.
await self.visited_data.update_one(
{"user_id": user_id},
{"$addToSet": {"cards_visited": card_id}},
upsert=True,
)
return True
# Card is already present in the visited set.
return False
def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card: async def get_card(self, card_id: int) -> Optional[Card]:
new_card = Card(card_id=self.get_and_update_counter(counter_name="card"), """Retrieves a card's details from the database by its card_id."""
document = await self.game_data.find_one({"card_id": card_id})
if document:
return Card.model_validate(document)
return None
async def get_random_cards(self, amount: int,active_status: bool,exclude_ids: Optional[set[int]] = None,) -> Optional[list[Card]]:
"""Returns random cards, excluding already visited ones (in a single query)."""
match_filter: dict = {"active_status": active_status}
if exclude_ids:
match_filter["card_id"] = {"$nin": list(exclude_ids)}
pipeline = [
{"$match": match_filter},
{"$sample": {"size": amount}},
]
raw_items = await self.game_data.aggregate(pipeline).to_list(length=amount)
if raw_items:
return [Card.model_validate(item) for item in raw_items]
return None
def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]:
"""Filters a list of random cards to exclude those already visited by the user."""
filtered_cards = [card for card in random_cards if card.card_id not in cards_visited]
filtered_cards_id = [card.card_id for card in filtered_cards]
return filtered_cards, filtered_cards_id
async def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card:
"""Creates a new card in the database with data received from the API."""
new_card = Card(
card_id=await self.get_and_update_counter(counter_name="card"),
choice_A=choice_A, choice_A=choice_A,
choice_B=choice_B, choice_B=choice_B,
author_id=author_id, author_id=author_id,
creation_date=datetime.now().isoformat()) creation_date=datetime.now().isoformat(),
)
await self.game_data.insert_one(new_card.model_dump())
logger.debug("Card created by API: card_id={}, author_id={}", new_card.card_id, author_id)
return new_card
async def add_card_by_base_model(self, new_card: Card) -> Optional[Card]:
"""Inserts a Card model directly into the database."""
new_card.card_id = await self.get_and_update_counter(counter_name="card")
try: try:
self.game_data.insert_one(new_card.model_dump()) await self.game_data.insert_one(new_card.model_dump())
return new_card
except Exception as exception:
print(exception)
return new_card return new_card
except Exception as exc:
logger.error("Failed to insert card: {}", exc)
raise
def add_card_by_base_model(self, new_card: Card) -> Optional[Card]: async def accept_card(self, card_id: int) -> BaseResponse:
new_card.card_id = self.get_and_update_counter(counter_name="card") """Accepts a card: sets active_status=True and moderation_date=now."""
try: result = await self.game_data.find_one_and_update(
self.game_data.insert_one(new_card.model_dump()) {"card_id": card_id},
return new_card {"$set": {
except Exception as exception: "active_status": True,
print(exception) "moderation_date": datetime.now().isoformat(),
return new_card }},
return_document=ReturnDocument.AFTER,
)
if not result:
logger.debug("Attempted to accept non-existent card: card_id={}", card_id)
return BaseResponse(result="Card doesn't exist", error=True)
logger.debug("Card accepted: card_id={}", card_id)
return BaseResponse(result=Card.model_validate(result))
def get_card(self, card_id: int) -> Optional[Card]: async def reject_card(self, card_id: int) -> BaseResponse:
document = self.game_data.find_one({"card_id": card_id}) """Rejects a card: deletes it from the database."""
if document: result = await self.game_data.delete_one({"card_id": card_id})
return Card.model_validate(document) if result.deleted_count == 0:
else: logger.debug("Attempted to reject non-existent card: card_id={}", card_id)
return None return BaseResponse(result="Card doesn't exist", error=True)
logger.debug("Card rejected and deleted: card_id={}", card_id)
return BaseResponse(result=f"Card {card_id} rejected and deleted")
def get_random_cards(self, amount: int, active_status: bool) -> Optional[list[Card]]: async def select_choice(self, card_id: int, choice: str) -> BaseResponse:
pipeline = [{"$match": {"active_status": active_status}}, """Increments the vote count for the selected choice (A or B) and total votes on a card."""
{"$sample": {"size": amount}}]
raw_items = list(self.game_data.aggregate(pipeline))
if raw_items:
validated_items = [Card.model_validate(item) for item in raw_items]
return validated_items
else:
return None
def select_choice(self, card_id: int, choice: str) -> BaseResponse:
if choice == "A": if choice == "A":
count_choice = "count_choice_A" count_field = "count_choice_A"
elif choice == "B": elif choice == "B":
count_choice = "count_choice_B" count_field = "count_choice_B"
else: else:
return BaseResponse(result="Wrong choice", error=True) return BaseResponse(result="Wrong choice", error=True)
result = self.game_data.find_one_and_update({"card_id": card_id}, result = await self.game_data.find_one_and_update(
{"$inc": {"count_total": 1, count_choice: 1}}) {"card_id": card_id},
{"$inc": {"count_total": 1, count_field: 1}},
)
if not result: if not result:
return BaseResponse(result="Card doesn't exist", error=True) return BaseResponse(result="Card doesn't exist", error=True)
else: return BaseResponse(result=True, error=False)
return BaseResponse(result=result, error=False)
def check_user_reactions(self, user_id: int, card_id: int) -> BaseResponse:
user_info: User = self.get_user(user_id)
liked_card_ids: list = user_info.liked_card_ids
disliked_card_ids: list = user_info.disliked_card_ids
if card_id in liked_card_ids: async def like_card(self, card_id: int, user_id: int) -> BaseResponse:
return BaseResponse(result="Card already liked", error=True) """Atomically adds a like to a card and records the user's like action."""
elif card_id in disliked_card_ids: if not await self.check_user(user_id):
return BaseResponse(result="Card already disliked", error=True) return BaseResponse(result="User doesn't exist", error=True)
else:
return BaseResponse(result="No reactions", error=False)
def like_card(self, card_id: int, user_id: int) -> BaseResponse: # Atomically add card_id to liked_card_ids ONLY IF it is not already
if self.check_user(user_id): # present in liked_card_ids OR disliked_card_ids.
user_reaction = self.check_user_reactions(user_id, card_id) # Using a conditional filter makes this a single, race-condition-free
if user_reaction.error: # test-and-set: if modified_count == 0, another request already won.
return user_reaction user_update = await self.users_data.find_one_and_update(
update_card_info = self.game_data.find_one_and_update({"card_id": card_id}, {
{"$inc": {"count_likes": 1}}) "user_id": user_id,
if not update_card_info: "liked_card_ids": {"$ne": card_id},
"disliked_card_ids": {"$ne": card_id},
},
{"$addToSet": {"liked_card_ids": card_id}},
projection={"_id": 1},
)
if not user_update:
return BaseResponse(result="Card already liked or disliked", error=True)
updated_card = await self.game_data.find_one_and_update(
{"card_id": card_id},
{"$inc": {"count_likes": 1}},
)
if not updated_card:
# Card doesn't exist — roll back the user update (best effort).
await self.users_data.update_one(
{"user_id": user_id},
{"$pull": {"liked_card_ids": card_id}},
)
return BaseResponse(result="Card doesn't exist", error=True) return BaseResponse(result="Card doesn't exist", error=True)
add_card_to_user = self.users_data.update_one({'user_id': user_id}, logger.debug("Card liked: card_id={}, user_id={}", card_id, user_id)
{'$push': {'liked_card_ids': card_id}})
if not add_card_to_user:
return BaseResponse(result="User doesn't exist", error=True)
else:
return BaseResponse(result=True, error=False) return BaseResponse(result=True, error=False)
else:
async def dislike_card(self, card_id: int, user_id: int) -> BaseResponse:
"""Atomically adds a dislike to a card and records the user's dislike action."""
if not await self.check_user(user_id):
return BaseResponse(result="User doesn't exist", error=True) return BaseResponse(result="User doesn't exist", error=True)
def dislike_card(self, card_id: int, user_id: int) -> BaseResponse: # Same atomic test-and-set pattern as like_card.
if self.check_user(user_id): user_update = await self.users_data.find_one_and_update(
user_reaction = self.check_user_reactions(user_id, card_id) {
if user_reaction.error: "user_id": user_id,
return user_reaction "liked_card_ids": {"$ne": card_id},
update_card_info = self.game_data.find_one_and_update({"card_id": card_id}, "disliked_card_ids": {"$ne": card_id},
{"$inc": {"count_dislikes": 1}}) },
if not update_card_info: {"$addToSet": {"disliked_card_ids": card_id}},
projection={"_id": 1},
)
if not user_update:
return BaseResponse(result="Card already liked or disliked", error=True)
updated_card = await self.game_data.find_one_and_update(
{"card_id": card_id},
{"$inc": {"count_dislikes": 1}},
)
if not updated_card:
# Card doesn't exist — roll back the user update (best effort).
await self.users_data.update_one(
{"user_id": user_id},
{"$pull": {"disliked_card_ids": card_id}},
)
return BaseResponse(result="Card doesn't exist", error=True) return BaseResponse(result="Card doesn't exist", error=True)
add_card_to_user = self.users_data.update_one({'user_id': user_id}, logger.debug("Card disliked: card_id={}, user_id={}", card_id, user_id)
{'$push': {'disliked_card_ids': card_id}})
if not add_card_to_user:
return BaseResponse(result="User doesn't exist", error=True)
else:
return BaseResponse(result=True, error=False) return BaseResponse(result=True, error=False)
else:
return BaseResponse(result="User doesn't exist", error=True)
def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse:
if self.check_user(user_id): async def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse:
if self.get_card(card_id): """Adds a new comment to a card and links it to the user."""
new_comment = Comment(comment_id=self.get_and_update_counter(counter_name="comment"), if not await self.check_user(user_id):
return BaseResponse(result="User doesn't exist", error=True)
if not await self.get_card(card_id):
return BaseResponse(result="Card doesn't exist", error=True)
new_comment = Comment(
comment_id=await self.get_and_update_counter(counter_name="comment"),
author_id=user_id, author_id=user_id,
card_id=card_id, card_id=card_id,
comment_text=comment_text, comment_text=comment_text,
creation_date=datetime.now().isoformat()) creation_date=datetime.now().isoformat(),
result = self.comments_data.insert_one(new_comment.model_dump()) )
if result: await self.comments_data.insert_one(new_comment.model_dump())
update_user_comments = self.users_data.find_one_and_update({"user_id": user_id},
{"$addToSet": {"comments_ids": new_comment.comment_id}}) updated_user = await self.users_data.find_one_and_update(
if update_user_comments: {"user_id": user_id},
return BaseResponse(result=new_comment) {"$addToSet": {"comments_ids": new_comment.comment_id}},
else: return_document=ReturnDocument.AFTER,
)
if not updated_user:
return BaseResponse(result="Difficulty adding comment_id to user", error=True) return BaseResponse(result="Difficulty adding comment_id to user", error=True)
else:
return BaseResponse(result="Add comment error", error=True) logger.debug("Comment added: comment_id={}, card_id={}, author_id={}", new_comment.comment_id, card_id, user_id)
else: return BaseResponse(result=new_comment)
async def get_comments(self, card_id: int) -> BaseResponse:
"""Retrieves all comments associated with a specific card_id."""
if not await self.get_card(card_id):
return BaseResponse(result="Card doesn't exist", error=True) return BaseResponse(result="Card doesn't exist", error=True)
else: comments = await self.comments_data.find({"card_id": card_id}).sort("creation_date", -1).to_list(length=None)
return BaseResponse(result="User doesn't exist", error=True) comments = [Comment.model_validate(comment) for comment in comments]
return BaseResponse(result=comments)
+77
View File
@@ -0,0 +1,77 @@
import os
import json
import asyncio
from typing import Callable, Awaitable
import aio_pika
from aio_pika.abc import AbstractIncomingMessage
from dotenv import load_dotenv
from schemas.base_schemas import Card
from logger import logger
class RabbitWorker:
"""Handles RabbitMQ connections and message publishing/consuming for moderation."""
def __init__(self):
"""Initializes the RabbitWorker with connection credentials from environment variables."""
load_dotenv()
self.url = (
f"amqp://{os.getenv('RABBIT_USER')}:{os.getenv('RABBIT_PASS')}"
f"@{os.getenv('RABBIT_HOST')}:{os.getenv('RABBIT_PORT')}"
)
logger.info("RabbitWorker connection established.")
async def send_to_moderation(self, card: Card) -> None:
"""Publishes a card to the 'moderation' RabbitMQ queue."""
logger.debug("Preparing to send card {} to moderation queue...", card.card_id)
connection = await aio_pika.connect_robust(self.url)
async with connection:
channel = await connection.channel()
queue = await channel.declare_queue("moderation", durable=True)
await channel.default_exchange.publish(
aio_pika.Message(
body=card.model_dump_json().encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
),
routing_key="moderation",
)
logger.debug("Card {} successfully published to moderation queue", card.card_id)
logger.info("Card {} sent to moderation queue", card.card_id)
async def consume_moderation(
self,
callback: Callable[[Card], Awaitable[None]],
) -> None:
"""
Consumes messages from the 'moderation' queue and processes them using the provided callback.
Args:
callback: An async function that takes a Card object and processes it.
"""
connection = await aio_pika.connect_robust(self.url)
async with connection:
channel = await connection.channel()
await channel.set_qos(prefetch_count=1)
queue = await channel.declare_queue("moderation", durable=True)
logger.info("Started consuming moderation queue...")
async def on_message(message: AbstractIncomingMessage) -> None:
async with message.process():
try:
card_data = json.loads(message.body.decode())
card = Card.model_validate(card_data)
await callback(card)
except Exception as exc:
logger.error("Error processing moderation message: {}", exc)
await queue.consume(on_message)
# Keep consumer alive while allowing cancellation (Ctrl+C)
stop_event = asyncio.Event()
try:
await stop_event.wait()
except asyncio.CancelledError:
logger.info("Moderation consumer shutting down...")
raise
+6 -1
View File
@@ -1,4 +1,9 @@
fastapi==0.115.7 fastapi==0.115.7
pymongo==4.10.1 fastapi_guard==7.6.0
motor==3.7.0
python-dotenv==1.0.1 python-dotenv==1.0.1
uvicorn==0.34.0 uvicorn==0.34.0
aio-pika==10.0.1
aiogram==3.18.0
aiohttp==3.11.18
loguru==0.7.3
+7 -5
View File
@@ -2,13 +2,15 @@ import typing
from pydantic import BaseModel, NonNegativeInt from pydantic import BaseModel, NonNegativeInt
from typing import Optional
class BaseResponse(BaseModel): class BaseResponse(BaseModel):
result: typing.Any result: typing.Any
error: bool = False error: bool = False
class AddUserBody(BaseModel): class AddUserBody(BaseModel):
user_id: NonNegativeInt user_id: Optional[NonNegativeInt] = None
username: str username: str
first_name: str first_name: str
@@ -19,20 +21,20 @@ class AddCardBody(BaseModel):
choice_A: str choice_A: str
choice_B: str choice_B: str
author_id: NonNegativeInt author_id: Optional[NonNegativeInt] = None
class SelectChoice(BaseModel): class SelectChoice(BaseModel):
user_id: NonNegativeInt user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt card_id: NonNegativeInt
choice: typing.Literal["A", "B"] choice: typing.Literal["A", "B"]
class ReactionCard(BaseModel): class ReactionCard(BaseModel):
user_id: NonNegativeInt user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt card_id: NonNegativeInt
class AddCommentBody(BaseModel): class AddCommentBody(BaseModel):
author_id: NonNegativeInt author_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt card_id: NonNegativeInt
comment_text: str comment_text: str
+44
View File
@@ -0,0 +1,44 @@
"""
conftest.py — fixtures for resetting rate-limiter state and IP-ban
between tests so functional tests do not hit 429 errors.
"""
import pytest
from guard import ip_ban_manager
from guard_core.handlers.ratelimit_handler import RateLimitManager
def _reset_all():
"""Full reset of rate-limiter, IP-ban, and suspicious counts."""
# Rate limit timestamps
rl: RateLimitManager | None = RateLimitManager._instance
if rl is not None:
rl.request_timestamps.clear()
# IP bans
ip_ban_manager.banned_ips.clear()
ip_ban_manager.banned_networks.clear()
# Suspicious request counts via direct reference stored in app.state
# Because FastAPI's add_middleware creates a new instance internally,
# navigating app.state or app.middleware_stack is unreliable.
# We use gc to robustly find the active SecurityMiddleware instance(s) and clear them.
try:
import gc
from guard.middleware import SecurityMiddleware
for obj in gc.get_objects():
if isinstance(obj, SecurityMiddleware):
obj.suspicious_request_counts.clear()
except Exception:
pass
@pytest.fixture(autouse=True)
def reset_guard_state():
"""
Synchronous fixture (autouse) that resets guard middleware
state before and after each test.
"""
_reset_all()
yield
_reset_all()
+38 -28
View File
@@ -14,9 +14,9 @@ NON_EXIST_CARD_ID = 1000
# ---------- /add_card ---------- # ---------- /add_card ----------
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_card_valid(): async def test_add_card_valid():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
payload = { payload = {
"choice_A": "Option A", "choice_A": "Option A",
"choice_B": "Option B", "choice_B": "Option B",
@@ -32,9 +32,9 @@ async def test_add_card_valid():
assert card.choice_B == payload["choice_B"] assert card.choice_B == payload["choice_B"]
assert card.author_id == payload["author_id"] assert card.author_id == payload["author_id"]
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_card_missing_field(): async def test_add_card_missing_field():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
payload = { payload = {
#choice_A #choice_A
"choice_B": "Option B", "choice_B": "Option B",
@@ -44,9 +44,9 @@ async def test_add_card_missing_field():
print(f"\nINPUT: endpoint=/add_card | payload (missing field)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/add_card | payload (missing field)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_card_wrong_type(): async def test_add_card_wrong_type():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
payload = { payload = {
"choice_A": 123, "choice_A": 123,
"choice_B": "Option B", "choice_B": "Option B",
@@ -56,9 +56,9 @@ async def test_add_card_wrong_type():
print(f"\nINPUT: endpoint=/add_card | payload (wrong type)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/add_card | payload (wrong type)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_card_empty_strings(): async def test_add_card_empty_strings():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
payload = { payload = {
"choice_A": "", "choice_A": "",
"choice_B": "", "choice_B": "",
@@ -68,9 +68,9 @@ async def test_add_card_empty_strings():
print(f"\nINPUT: endpoint=/add_card | payload (empty strings)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/add_card | payload (empty strings)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 400 assert response.status_code == 400
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_card_long_strings(): async def test_add_card_long_strings():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
long_str = "A" * 5000 # long string long_str = "A" * 5000 # long string
payload = { payload = {
"choice_A": long_str, "choice_A": long_str,
@@ -81,9 +81,9 @@ async def test_add_card_long_strings():
print(f"\nINPUT: endpoint=/add_card | payload with long strings (length={len(long_str)})\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/add_card | payload with long strings (length={len(long_str)})\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 400 assert response.status_code == 400
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_card_negative_author_id(): async def test_add_card_negative_author_id():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
payload = { payload = {
"choice_A": "Option A", "choice_A": "Option A",
"choice_B": "Option B", "choice_B": "Option B",
@@ -93,9 +93,9 @@ async def test_add_card_negative_author_id():
print(f"\nINPUT: endpoint=/add_card | payload (negative author_id)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/add_card | payload (negative author_id)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_card_malformed_json(): async def test_add_card_malformed_json():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
malformed_json = '{"choice_A": "Option A", "choice_B": "Option B", "author_id": 123' # broken json malformed_json = '{"choice_A": "Option A", "choice_B": "Option B", "author_id": 123' # broken json
response = await client.post( response = await client.post(
"/add_card", "/add_card",
@@ -105,11 +105,16 @@ async def test_add_card_malformed_json():
print(f"\nINPUT: endpoint=/add_card | payload (malformed JSON)={malformed_json}\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No JSON'}") print(f"\nINPUT: endpoint=/add_card | payload (malformed JSON)={malformed_json}\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No JSON'}")
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_async_card_creation(): async def test_async_card_creation():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: """
Test parallel card creation.
Limit on /add_card — 3 requests/60s (decorator).
Send only 2 parallel requests to avoid exceeding the limit.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
tasks = [] tasks = []
num_cards = 8 num_cards = 2 # at most 3 (decorator limit), leaving a margin
for i in range(num_cards): for i in range(num_cards):
payload = { payload = {
"choice_A": f"Async Option A {i}", "choice_A": f"Async Option A {i}",
@@ -136,19 +141,24 @@ async def test_async_card_creation():
# ---------- /get_card ---------- # ---------- /get_card ----------
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_card_valid(): async def test_get_card_valid():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
# First create a card
payload = { payload = {
"choice_A": "GetTest A", "choice_A": "GetTest A",
"choice_B": "GetTest B", "choice_B": "GetTest B",
"author_id": EXIST_AUTHOR "author_id": EXIST_AUTHOR
} }
create_resp = await client.post("/add_card", json=payload) create_resp = await client.post("/add_card", json=payload)
assert create_resp.status_code in (200, 201), (
f"Failed to create card: {create_resp.status_code} {create_resp.text}"
)
base_create = BaseResponse.model_validate(create_resp.json()) base_create = BaseResponse.model_validate(create_resp.json())
card = Card.model_validate(base_create.result) card = Card.model_validate(base_create.result)
card_id = card.card_id card_id = card.card_id
# Now retrieve it
response = await client.get("/get_card", params={"card_id": card_id}) response = await client.get("/get_card", params={"card_id": card_id})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {card_id}}}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {card_id}}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 200 assert response.status_code == 200
@@ -157,30 +167,30 @@ async def test_get_card_valid():
card_from_get = Card.model_validate(base_resp.result) card_from_get = Card.model_validate(base_resp.result)
assert card_from_get.card_id == card_id assert card_from_get.card_id == card_id
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_card_nonexistent(): async def test_get_card_nonexistent():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
response = await client.get("/get_card", params={"card_id": NON_EXIST_CARD_ID}) response = await client.get("/get_card", params={"card_id": NON_EXIST_CARD_ID})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {NON_EXIST_CARD_ID}}}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {NON_EXIST_CARD_ID}}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_card_missing_param(): async def test_get_card_missing_param():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
response = await client.get("/get_card") response = await client.get("/get_card")
print(f"\nINPUT: endpoint=/get_card (missing card_id param)\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No content'}") print(f"\nINPUT: endpoint=/get_card (missing card_id param)\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No content'}")
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_card_wrong_type(): async def test_get_card_wrong_type():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
response = await client.get("/get_card", params={"card_id": "abc"}) response = await client.get("/get_card", params={"card_id": "abc"})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': 'abc'}}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/get_card | params={{'card_id': 'abc'}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_card_negative(): async def test_get_card_negative():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
response = await client.get("/get_card", params={"card_id": -10}) response = await client.get("/get_card", params={"card_id": -10})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': -10}}\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/get_card | params={{'card_id': -10}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422 assert response.status_code == 404
+486
View File
@@ -0,0 +1,486 @@
"""
test_security.py — tests for checking rate limiting and penetration detection.
Rate limiting settings from main.py:
- Global: 10 requests / 3 sec (middleware)
- /add_user: 3 requests / 60 sec (decorator)
- /add_card: 3 requests / 60 sec (decorator)
- /get_random_cards: 5 requests / 60 sec (decorator)
- /comment: 5 requests / 20 sec (decorator)
Penetration detection:
- enable_penetration_detection=True
- auto_ban_threshold=3 (ban after 3 suspicious requests)
- auto_ban_duration=3600 (ban for 1 hour)
"""
import random
import asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
# Client IP and port
CLIENT = ("7.214.201.94", 50000)
# ========================================================================
# RATE LIMIT TESTS
# ========================================================================
class TestGlobalRateLimit:
"""Tests for global rate limit: 10 requests / 3 seconds."""
@pytest.mark.asyncio(loop_scope="session")
async def test_global_rate_limit_allows_under_threshold(self):
"""Requests within the limit (<=10) should pass."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
for i in range(9):
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 200, (
f"Request {i+1}/9 returned {resp.status_code}, expected 200: {resp.text}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_global_rate_limit_blocks_over_threshold(self):
"""
After exceeding global limit (10 requests/3s) -> 429.
/check_user does not have a rate_limit decorator, so only global limit applies.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Send 10 requests (fill the limit)
for i in range(10):
await client.get("/check_user", params={"user_id": 1})
# 11th request should return 429
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 429, (
f"Expected 429 after exceeding global limit, got {resp.status_code}"
)
assert "Too many requests" in resp.text
@pytest.mark.asyncio(loop_scope="session")
async def test_global_rate_limit_response_format(self):
"""Verify response format on rate limit."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Exhaust limit
for _ in range(10):
await client.get("/check_user", params={"user_id": 1})
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 429
assert resp.text == "Too many requests"
class TestDecoratorRateLimit:
"""Tests for rate limit via @guard_deco.rate_limit() decorator."""
@pytest.mark.asyncio(loop_scope="session")
async def test_add_user_rate_limit(self):
"""
/add_user: limit 3 requests / 60 sec.
First 3 requests pass (422 due to invalid data is OK, main point is not 429).
4th request -> 429.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
data = {
"user_id": random.randint(100000000, 999999999),
"username": "RateTest",
"first_name": "F",
"last_name": "L",
"photo_url": "http://test.test/photo.jpg"
}
# First 3 requests — not 429
for i in range(3):
resp = await client.post("/add_user", json=data)
assert resp.status_code != 429, (
f"Request {i+1}/3 returned 429, limit should not be exceeded yet"
)
# 4th request -> 429
resp = await client.post("/add_user", json=data)
assert resp.status_code == 429, (
f"Expected 429 after 3 requests to /add_user, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_add_card_rate_limit(self):
"""
/add_card: limit 3 requests / 60 sec.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
author_id = random.randint(100000000, 999999999)
for i in range(3):
payload = {
"choice_A": f"Rate A {i}",
"choice_B": f"Rate B {i}",
"author_id": author_id
}
resp = await client.post("/add_card", json=payload)
assert resp.status_code != 429, (
f"Request {i+1}/3 to /add_card returned 429 prematurely"
)
payload = {
"choice_A": "Rate A overflow",
"choice_B": "Rate B overflow",
"author_id": author_id
}
resp = await client.post("/add_card", json=payload)
assert resp.status_code == 429, (
f"Expected 429 after 3 requests to /add_card, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_get_random_cards_rate_limit(self):
"""
/get_random_cards: limit 5 requests / 60 sec.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
user_id = random.randint(100000000, 999999999)
for i in range(5):
resp = await client.get("/get_random_cards", params={"user_id": user_id})
# Can be 200 or 404 (if no cards/user), but not 429
assert resp.status_code != 429, (
f"Request {i+1}/5 to /get_random_cards returned 429 prematurely"
)
resp = await client.get("/get_random_cards", params={"user_id": user_id})
assert resp.status_code == 429, (
f"Expected 429 after 5 requests to /get_random_cards, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_comment_rate_limit(self):
"""
/comment: limit 5 requests / 20 sec.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
for i in range(5):
payload = {
"author_id": random.randint(100000000, 999999999),
"card_id": 1,
"comment_text": f"Rate test comment {i}"
}
resp = await client.post("/comment", json=payload)
# Can be 201, 400 (moderation), 404 (card/user not found) — but not 429
assert resp.status_code != 429, (
f"Request {i+1}/5 to /comment returned 429 prematurely"
)
payload = {
"author_id": random.randint(100000000, 999999999),
"card_id": 1,
"comment_text": "Overflow comment"
}
resp = await client.post("/comment", json=payload)
assert resp.status_code == 429, (
f"Expected 429 after 5 requests to /comment, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_different_endpoints_have_independent_limits(self):
"""
Decorator rate limit is tracked separately for each endpoint.
Requests to /check_user should not affect /add_card limit.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# 5 requests to /check_user (no decorator, but global limit 10/3s)
for _ in range(5):
await client.get("/check_user", params={"user_id": 1})
# First request to /add_card — should pass (its own separate limit)
payload = {
"choice_A": "IndepA",
"choice_B": "IndepB",
"author_id": random.randint(100000000, 999999999)
}
resp = await client.post("/add_card", json=payload)
assert resp.status_code != 429, (
f"Request to /add_card blocked after requests to /check_user: {resp.status_code}"
)
class TestRateLimitParallel:
"""Rate limit tests with parallel requests."""
@pytest.mark.asyncio(loop_scope="session")
async def test_parallel_requests_hit_rate_limit(self):
"""
Multiple parallel requests should lead to 429 for some of them.
Send 15 parallel requests with global limit of 10/3s.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
tasks = [
client.get("/check_user", params={"user_id": 1})
for _ in range(15)
]
responses = await asyncio.gather(*tasks)
statuses = [r.status_code for r in responses]
count_200 = statuses.count(200)
count_429 = statuses.count(429)
print(f"\nParallel requests: 200={count_200}, 429={count_429}")
assert count_429 > 0, (
f"No request received 429 during 15 parallel requests: {statuses}"
)
assert count_200 > 0, (
f"All requests were blocked, none passed: {statuses}"
)
# ========================================================================
# PENETRATION DETECTION TESTS
# ========================================================================
class TestPenetrationDetection:
"""
Tests for malicious request detection.
enable_penetration_detection=True
auto_ban_threshold=3
auto_ban_duration=3600
"""
@pytest.mark.asyncio(loop_scope="session")
async def test_sql_injection_detected(self):
"""SQL injection in query parameters should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/get_card",
params={"card_id": "1 OR 1=1; DROP TABLE users;--"}
)
print(f"\nSQL injection test: status={resp.status_code} | text={resp.text[:200]}")
# Expect: 400 (suspicious activity) or 422 (validation) — but NOT 200
assert resp.status_code in (400, 403, 422), (
f"SQL injection was not blocked, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_xss_in_query_params_detected(self):
"""XSS attack in query parameters should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/get_card",
params={"card_id": "<script>alert('XSS')</script>"}
)
print(f"\nXSS in params test: status={resp.status_code} | text={resp.text[:200]}")
assert resp.status_code in (400, 403, 422), (
f"XSS attack was not detected, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_path_traversal_detected(self):
"""Path traversal attack should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get("/get_card/../../../etc/passwd")
print(f"\nPath traversal test: status={resp.status_code} | text={resp.text[:200]}")
# Can be 400, 403, 404, or 422 — but MUST NOT expose file contents
assert resp.status_code != 200 or "root:" not in resp.text, (
"Path traversal not detected — system file accessed!"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_xss_in_post_body_detected(self):
"""XSS attack in POST body should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
payload = {
"choice_A": "<script>document.cookie</script>",
"choice_B": "Normal option",
"author_id": random.randint(100000000, 999999999)
}
resp = await client.post("/add_card", json=payload)
print(f"\nXSS in body test: status={resp.status_code} | text={resp.text[:200]}")
# 400 (suspicious), 403 (banned), or 422 — but not 201
assert resp.status_code in (400, 403, 422), (
f"XSS in request body was not detected, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_command_injection_detected(self):
"""Command injection attempt should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
payload = {
"choice_A": "; cat /etc/passwd; echo",
"choice_B": "$(whoami)",
"author_id": random.randint(100000000, 999999999)
}
resp = await client.post("/add_card", json=payload)
print(f"\nCommand injection test: status={resp.status_code} | text={resp.text[:200]}")
# 400 (suspicious), 403 (banned) — not 201
assert resp.status_code in (400, 403, 422), (
f"Command injection was not detected, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_sql_union_injection_detected(self):
"""UNION-based SQL injection should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/get_card",
params={"card_id": "1 UNION SELECT password FROM users"}
)
print(f"\nUNION SQL injection test: status={resp.status_code} | text={resp.text[:200]}")
assert resp.status_code in (400, 403, 422), (
f"UNION SQL injection was not blocked, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_legitimate_request_not_blocked(self):
"""Legitimate request with normal data should not be blocked as suspicious."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get("/get_card", params={"card_id": 1})
print(f"\nLegitimate request test: status={resp.status_code}")
# 200 (card found) or 404 (not found) — but not 400/403
assert resp.status_code in (200, 404), (
f"Legitimate request blocked: {resp.status_code}: {resp.text[:200]}"
)
class TestAutoIPBan:
"""
Tests for automatic IP banning after repeated suspicious requests.
auto_ban_threshold=3, auto_ban_duration=3600
"""
@pytest.mark.asyncio(loop_scope="session")
async def test_repeated_attacks_trigger_ip_ban(self):
"""
After auto_ban_threshold (3) suspicious requests, the IP should be banned.
Subsequent requests (even legitimate ones) should return 403.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Send suspicious requests sequentially (SQL injection variants)
injection_payloads = [
"1' OR '1'='1",
"1; DROP TABLE cards;--",
"1 UNION SELECT * FROM users;--",
"1' AND 1=CONVERT(int,(SELECT TOP 1 name FROM sysobjects));--",
]
detected_as_suspicious = 0
for payload in injection_payloads:
resp = await client.get("/get_card", params={"card_id": payload})
if resp.status_code in (400, 403):
detected_as_suspicious += 1
print(f" Attack attempt: status={resp.status_code} | payload={payload[:50]}")
print(f"\nSuspicious requests detected: {detected_as_suspicious}/{len(injection_payloads)}")
if detected_as_suspicious >= 3:
# Threshold reached — verify ban on legitimate request
resp = await client.get("/check_user", params={"user_id": 1})
print(f"Post-attack legitimate request: status={resp.status_code}")
assert resp.status_code == 403, (
f"IP should be banned after {detected_as_suspicious} suspicious requests, "
f"but legitimate request returned {resp.status_code}: {resp.text[:200]}"
)
else:
pytest.skip(
f"Only {detected_as_suspicious} of {len(injection_payloads)} attacks detected, "
f"ban threshold (3) not reached"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_banned_ip_returns_403_on_all_endpoints(self):
"""
If IP is banned, all endpoints should return 403.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Send various attacks to guarantee hitting the threshold
attacks = [
"1' OR '1'='1; --",
"1; DROP TABLE cards; --",
"<script>alert(1)</script>",
"../../etc/shadow",
"1 UNION SELECT password FROM users",
]
detected_count = 0
for payload in attacks:
resp = await client.get("/get_card", params={"card_id": payload})
if resp.status_code in (400, 403):
detected_count += 1
print(f" [{payload[:40]}] status={resp.status_code}")
print(f"\nDetected: {detected_count}/{len(attacks)}")
if detected_count >= 3:
# Check ban on different endpoints
endpoints = [
("GET", "/check_user", {"user_id": 99999}),
("GET", "/get_user", {"user_id": 99999}),
("GET", "/get_card", {"card_id": 1}),
]
for method, path, params in endpoints:
resp = await client.get(path, params=params)
assert resp.status_code == 403, (
f"IP is banned, but {method} {path} returned {resp.status_code}"
)
else:
pytest.skip(
f"Only {detected_count} attacks detected, ban threshold (3) not reached"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_banned_ip_message(self):
"""Banned IP should receive 'IP address banned' message."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
attacks = [
"1' OR '1'='1; --",
"1; DROP TABLE cards; --",
"1 UNION SELECT password FROM users",
"<script>alert(1)</script>",
]
detected = 0
for payload in attacks:
resp = await client.get("/get_card", params={"card_id": payload})
if resp.status_code in (400, 403):
detected += 1
if detected >= 3:
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 403
assert "IP address banned" in resp.text, (
f"Expected message 'IP address banned', got: {resp.text[:200]}"
)
else:
pytest.skip(f"Only {detected} attacks detected, threshold not reached")
class TestSuspiciousHeaders:
"""Tests for suspicious header detection."""
@pytest.mark.asyncio(loop_scope="session")
async def test_suspicious_user_agent(self):
"""Request with suspicious User-Agent may be blocked."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/check_user",
params={"user_id": 1},
headers={"User-Agent": "sqlmap/1.6.12#stable (http://sqlmap.org)"}
)
print(f"\nSuspicious UA test: status={resp.status_code}")
# sqlmap is a known SQL injection tool
# Expect block (403) or pass (200 — if UA is not in blocklist)
assert resp.status_code in (200, 400, 403), (
f"Unexpected status code for suspicious User-Agent: {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_xss_in_headers(self):
"""XSS attack via custom headers."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/check_user",
params={"user_id": 1},
headers={"X-Forwarded-For": "<script>alert(1)</script>"}
)
print(f"\nXSS in headers test: status={resp.status_code}")
# Header may be ignored or detected as suspicious
assert resp.status_code in (200, 400, 403), (
f"Unexpected status code for XSS in headers: {resp.status_code}"
)
+151
View File
@@ -0,0 +1,151 @@
"""
Tests for Telegram initData HMAC-SHA256 validation (tg_auth module).
These tests directly exercise the ``validate_init_data`` function with
synthetic initData, covering happy-path and all failure modes.
"""
import hashlib
import hmac
import json
import time
from urllib.parse import urlencode
import pytest
from fastapi import HTTPException
from tg_auth import validate_init_data
import os
BOT_TOKEN = os.getenv("TG_BOT_TOKEN", "test:mock_token_for_testing_12345")
def _build_init_data(
bot_token: str,
user: dict,
auth_date: int | None = None,
tamper_hash: bool = False,
omit_hash: bool = False,
omit_user: bool = False,
) -> str:
"""Helper that constructs a valid (or intentionally broken) initData string."""
if auth_date is None:
auth_date = int(time.time())
params: dict[str, str] = {
"auth_date": str(auth_date),
}
if not omit_user:
params["user"] = json.dumps(user)
# Build data-check-string (sorted, \n-separated).
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items()))
# secret_key = HMAC-SHA256("WebAppData", bot_token)
secret_key = hmac.new(
key=b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
if tamper_hash:
computed_hash = "a" * 64 # obviously wrong
if not omit_hash:
params["hash"] = computed_hash
return urlencode(params)
VALID_USER = {
"id": 123456789,
"first_name": "Igor",
"last_name": "Volochay",
"username": "IgorVolochay",
"photo_url": "https://t.me/photo.jpg",
}
# ── Happy path ──────────────────────────────────────────────────────────
def test_valid_init_data():
raw = _build_init_data(BOT_TOKEN, VALID_USER)
result = validate_init_data(raw, BOT_TOKEN)
assert result["user_id"] == 123456789
assert result["username"] == "IgorVolochay"
assert result["first_name"] == "Igor"
assert result["last_name"] == "Volochay"
assert result["photo_url"] == "https://t.me/photo.jpg"
# ── Failure modes ───────────────────────────────────────────────────────
def test_empty_init_data():
with pytest.raises(HTTPException) as exc:
validate_init_data("", BOT_TOKEN)
assert exc.value.status_code == 403
def test_missing_hash():
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_hash=True)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "hash" in str(exc.value.detail).lower()
def test_tampered_hash():
raw = _build_init_data(BOT_TOKEN, VALID_USER, tamper_hash=True)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "signature" in str(exc.value.detail).lower()
def test_expired_auth_date():
old_date = int(time.time()) - 7200 # 2 hours ago
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=old_date)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN, max_age=3600)
assert exc.value.status_code == 403
assert "expired" in str(exc.value.detail).lower()
def test_missing_user():
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_user=True)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "user" in str(exc.value.detail).lower()
def test_missing_user_id():
user_no_id = {"first_name": "Igor", "username": "test"}
raw = _build_init_data(BOT_TOKEN, user_no_id)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "user.id" in str(exc.value.detail).lower()
def test_wrong_bot_token():
raw = _build_init_data(BOT_TOKEN, VALID_USER)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, "wrong:token")
assert exc.value.status_code == 403
def test_fresh_auth_date_passes():
"""auth_date exactly 5 seconds ago should be fine with default max_age."""
recent = int(time.time()) - 5
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=recent)
result = validate_init_data(raw, BOT_TOKEN)
assert result["user_id"] == 123456789
+16 -16
View File
@@ -16,9 +16,9 @@ NON_EXIST_USER = random.randint(100000000, 1000000000)
# TEST ADD USERS UTILS # # TEST ADD USERS UTILS #
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_user_non_full_data(): async def test_add_user_non_full_data():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/add_user" end_point = "/add_user"
data = { data = {
@@ -30,9 +30,9 @@ async def test_add_user_non_full_data():
assert raw_response.status_code == 422 assert raw_response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_user_negative_int_id(): async def test_add_user_negative_int_id():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/add_user" end_point = "/add_user"
data = { data = {
@@ -47,9 +47,9 @@ async def test_add_user_negative_int_id():
assert raw_response.status_code == 422 assert raw_response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_new_user(): async def test_add_new_user():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/add_user" end_point = "/add_user"
data = { data = {
@@ -67,9 +67,9 @@ async def test_add_new_user():
assert response.error == False assert response.error == False
assert User.model_validate(response.result) assert User.model_validate(response.result)
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_already_exist_user(): async def test_add_already_exist_user():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/add_user" end_point = "/add_user"
data = { data = {
@@ -92,9 +92,9 @@ async def test_add_already_exist_user():
# TEST CHECK USERS UTILS # # TEST CHECK USERS UTILS #
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_check_non_exist_user(): async def test_check_non_exist_user():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/check_user" end_point = "/check_user"
params = {"user_id": NON_EXIST_USER} params = {"user_id": NON_EXIST_USER}
@@ -106,9 +106,9 @@ async def test_check_non_exist_user():
assert response.error == False assert response.error == False
assert response.result == False assert response.result == False
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_check_exist_user(): async def test_check_exist_user():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/check_user" end_point = "/check_user"
params = {"user_id": EXIST_USER} params = {"user_id": EXIST_USER}
@@ -125,9 +125,9 @@ async def test_check_exist_user():
# TEST GET USERS UTILS # # TEST GET USERS UTILS #
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_non_exist_user(): async def test_get_non_exist_user():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/get_user" end_point = "/get_user"
params = {"user_id": NON_EXIST_USER} params = {"user_id": NON_EXIST_USER}
@@ -139,9 +139,9 @@ async def test_get_non_exist_user():
assert response.error == True assert response.error == True
assert response.result == "User doesn't exist" assert response.result == "User doesn't exist"
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_exist_user(): async def test_get_exist_user():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/get_user" end_point = "/get_user"
params = {"user_id": EXIST_USER} params = {"user_id": EXIST_USER}
+38 -24
View File
@@ -14,9 +14,9 @@ ACTIVE_CARDS_LESS_THAN_TEN = False
# ------------- /add_user --------------- # ------------- /add_user ---------------
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_add_new_user(): async def test_add_new_user():
async with AsyncClient(transport=ASGITransport(app=app), async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client: base_url='http://test') as client:
end_point = "/add_user" end_point = "/add_user"
data = { data = {
@@ -36,13 +36,13 @@ async def test_add_new_user():
# ---------- /get_random_cards ---------- # ---------- /get_random_cards ----------
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_random_cards_valid(): async def test_get_random_cards_valid():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
params = {"user_id": EXIST_USER} params = {"user_id": EXIST_USER}
response = await client.get("/get_random_cards", params=params) response = await client.get("/get_random_cards", params=params)
print(f"\nINPUT: endpoint=/get_random_cards\nOUTPUT: status={response.status_code} | json={response.json()}") print(f"\nINPUT: endpoint=/get_random_cards\nOUTPUT: status={response.status_code} | json={response.json()}")
if response.status_code == 404 and BaseResponse.model_validate(response.json()).result == "No active cards": if response.status_code == 404 and "No active cards" in str(BaseResponse.model_validate(response.json()).result):
global NO_ACTIVE_CARDS_STATUS global NO_ACTIVE_CARDS_STATUS
NO_ACTIVE_CARDS_STATUS = True NO_ACTIVE_CARDS_STATUS = True
pytest.skip(reason="No active cards in MongoDB") pytest.skip(reason="No active cards in MongoDB")
@@ -61,32 +61,46 @@ async def test_get_random_cards_valid():
ACTIVE_CARDS_LESS_THAN_TEN = True ACTIVE_CARDS_LESS_THAN_TEN = True
pytest.skip(reason="The number of active cards is less than 10 in MongoDB") pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
@pytest.mark.asyncio @pytest.mark.asyncio(loop_scope="session")
async def test_get_random_cards_randomness(): async def test_get_random_cards_randomness():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
params = {"user_id": EXIST_USER}
response1 = await client.get("/get_random_cards", params=params)
response2 = await client.get("/get_random_cards", params=params)
result1 = response1.json().get("result")
result2 = response2.json().get("result")
print(f"\nINPUT: endpoint=/get_random_cards (двойной вызов)\nOUTPUT 1: {result1}\nOUTPUT 2: {result2}")
if len(result1) == 10 and len(result2) == 10:
assert result1 != result2
@pytest.mark.asyncio
async def test_get_random_cards_parallel_requests():
if NO_ACTIVE_CARDS_STATUS: if NO_ACTIVE_CARDS_STATUS:
pytest.skip(reason="No active cards in MongoDB") pytest.skip(reason="No active cards in MongoDB")
elif ACTIVE_CARDS_LESS_THAN_TEN: elif ACTIVE_CARDS_LESS_THAN_TEN:
pytest.skip(reason="The number of active cards is less than 10 in MongoDB") pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
else:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
params = {"user_id": EXIST_USER} params = {"user_id": EXIST_USER}
tasks = [client.get("/get_random_cards", params=params) for _ in range(5)] response1 = await client.get("/get_random_cards", params=params)
response2 = await client.get("/get_random_cards", params=params)
print(f"\nRandomness check: r1={response1.status_code}, r2={response2.status_code}")
assert response1.status_code == 200, f"First request returned {response1.status_code}: {response1.text}"
assert response2.status_code == 200, f"Second request returned {response2.status_code}: {response2.text}"
result1 = response1.json().get("result")
result2 = response2.json().get("result")
print(f"\nINPUT: endpoint=/get_random_cards (double call)\nOUTPUT 1: {result1}\nOUTPUT 2: {result2}")
if len(result1) == 10 and len(result2) == 10:
assert result1 != result2
@pytest.mark.asyncio(loop_scope="session")
async def test_get_random_cards_parallel_requests():
"""
Parallel requests to /get_random_cards.
Decorator limit: 5 requests/60s.
Make 3 parallel requests to stay within limit.
"""
if NO_ACTIVE_CARDS_STATUS:
pytest.skip(reason="No active cards in MongoDB")
elif ACTIVE_CARDS_LESS_THAN_TEN:
pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
params = {"user_id": EXIST_USER}
tasks = [client.get("/get_random_cards", params=params) for _ in range(3)]
responses = await asyncio.gather(*tasks) responses = await asyncio.gather(*tasks)
for resp in responses: for resp in responses:
print(f"\nParallel call: status={resp.status_code} | json={resp.json()}") print(f"\nParallel call: status={resp.status_code} | text={resp.text[:200]}")
assert resp.status_code == 200 assert resp.status_code == 200, (
f"Expected 200, got {resp.status_code}: {resp.text}"
)
result = resp.json().get("result") result = resp.json().get("result")
assert isinstance(result, list) assert isinstance(result, list)
assert len(result) == 10
+172
View File
@@ -0,0 +1,172 @@
"""
Telegram Mini App initData authentication module.
Validates initData from the Telegram WebApp using HMAC-SHA256
per the official specification:
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
In DEV_MODE (default) authentication is skipped — user_id is taken
from the request body / query parameters as-is.
"""
import hashlib
import hmac
import json
import os
import time
from typing import Optional
from urllib.parse import parse_qs
from dotenv import load_dotenv
from fastapi import HTTPException, Request, status
from logger import logger
load_dotenv()
DEV_MODE: bool = os.getenv("DEV_MODE", "false").lower() == "true"
TG_BOT_TOKEN: str = os.getenv("TG_BOT_TOKEN", "")
# Maximum allowed age of initData in seconds (1 hour).
INIT_DATA_MAX_AGE: int = int(os.getenv("INIT_DATA_MAX_AGE", "3600"))
def validate_init_data(
init_data_raw: str,
bot_token: str,
max_age: int = INIT_DATA_MAX_AGE,
) -> dict:
"""
Validates Telegram Mini App initData and returns the parsed ``user`` dict.
Algorithm (per Telegram docs):
1. Parse the query-string into key→value pairs.
2. Extract the ``hash`` value; build ``data-check-string`` from the
remaining fields sorted by key, joined with ``\\n``.
3. ``secret_key = HMAC-SHA256(bot_token, "WebAppData")``
4. ``computed = HMAC-SHA256(data_check_string, secret_key)``
5. Compare ``computed`` with ``hash`` using constant-time comparison.
6. Optionally verify ``auth_date`` freshness.
Returns a dict with keys: user_id, username, first_name, last_name, photo_url.
Raises ``HTTPException(403)`` on any validation failure.
"""
if not init_data_raw:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing initData",
)
parsed = parse_qs(init_data_raw, keep_blank_values=True)
# parse_qs returns lists — flatten to single values.
flat: dict[str, str] = {k: v[0] for k, v in parsed.items()}
received_hash = flat.pop("hash", None)
if not received_hash:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing hash in initData",
)
# Build data-check-string: sorted key=value pairs joined by \n.
data_check_string = "\n".join(
f"{k}={v}" for k, v in sorted(flat.items())
)
# secret_key = HMAC-SHA256("WebAppData", bot_token)
secret_key = hmac.new(
key=b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed_hash, received_hash):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid initData signature",
)
# Verify auth_date freshness.
auth_date_str = flat.get("auth_date")
if auth_date_str:
try:
auth_date = int(auth_date_str)
if time.time() - auth_date > max_age:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="initData expired",
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid auth_date",
)
# Extract user data.
user_raw = flat.get("user")
if not user_raw:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing user in initData",
)
try:
user = json.loads(user_raw)
except (json.JSONDecodeError, TypeError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid user JSON in initData",
)
user_id = user.get("id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing user.id in initData",
)
return {
"user_id": int(user_id),
"username": user.get("username", ""),
"first_name": user.get("first_name", ""),
"last_name": user.get("last_name", ""),
"photo_url": user.get("photo_url", ""),
}
async def get_current_user_id(request: Request) -> Optional[int]:
"""
FastAPI dependency that resolves the authenticated user_id.
- **DEV_MODE=true**: returns ``None`` — endpoints use user_id from
body/params as before (backward compatible).
- **DEV_MODE=false**: reads ``X-Init-Data`` header, validates it
via HMAC-SHA256, and returns the verified ``user_id``.
"""
if DEV_MODE:
return None
init_data_raw = request.headers.get("X-Init-Data", "")
if not init_data_raw:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="X-Init-Data header is required",
)
if not TG_BOT_TOKEN:
logger.error("TG_BOT_TOKEN is not set but DEV_MODE is disabled")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Server authentication misconfiguration",
)
user_data = validate_init_data(init_data_raw, TG_BOT_TOKEN)
return user_data["user_id"]
+188
View File
@@ -0,0 +1,188 @@
"""
Telegram card moderation bot.
Listens to the RabbitMQ "moderation" queue and sends cards
to the admin chat with inline buttons "Accept ✅" / "Reject ❌".
When a button is pressed, the bot calls protected endpoints
/card_accept or /card_reject with a secret header.
"""
import os
import json
import asyncio
from datetime import datetime
import aiohttp
from dotenv import load_dotenv
from aiogram import Bot, Dispatcher, F
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from schemas.base_schemas import Card
from rabbit_worker import RabbitWorker
from logger import logger, setup_logging
load_dotenv()
setup_logging()
# ── Configuration ──────────────────────────────────────────────
BOT_TOKEN = os.getenv("TG_BOT_TOKEN") or ""
ADMIN_CHAT_ID = int(os.getenv("TG_ADMIN_CHAT_ID", "0"))
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5000")
MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production")
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
rabbit = RabbitWorker()
# ── Sending card to admin ──────────────────────────
async def _get_author_username(author_id: int) -> str:
"""Fetches the author's username via API."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{API_BASE_URL}/get_user", params={"user_id": author_id}
) as resp:
if resp.status == 200:
data = await resp.json()
username = data.get("result", {}).get("username", "")
if username:
return f"@{username}"
except Exception as exc:
logger.warning("Failed to fetch username for {}: {}", author_id, exc)
return str(author_id)
def _format_date(iso_date: str) -> str:
"""Converts ISO date to DD.MM.YYYY HH:MM:SS format."""
try:
dt = datetime.fromisoformat(iso_date)
return dt.strftime("%d.%m.%Y %H:%M:%S")
except (ValueError, TypeError):
return iso_date
async def send_card_to_admin(card: Card) -> None:
"""Formats message and inline keyboard for a card."""
author_display = await _get_author_username(card.author_id)
date_display = _format_date(card.creation_date)
text = (
f"🆕 <b>Новая карточка #{card.card_id}</b>\n\n"
f"🅰️ {card.choice_A}\n"
f"🅱️ {card.choice_B}\n\n"
f"👤 Автор: {author_display}\n"
f"📅 Создана: {date_display}"
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="Принять ✅",
callback_data=f"accept:{card.card_id}",
),
InlineKeyboardButton(
text="Отклонить ❌",
callback_data=f"reject:{card.card_id}",
),
]
]
)
await bot.send_message(
chat_id=ADMIN_CHAT_ID,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info("Sent card {} to admin chat", card.card_id)
# ── Calling protected API endpoints ──────────────────────────
async def call_moderation_api(action: str, card_id: int) -> dict:
"""
Calls /card_accept or /card_reject with secret header.
action: 'accept' | 'reject'
"""
endpoint = f"{API_BASE_URL}/card_{action}"
headers = {"X-Moderation-Secret": MODERATION_SECRET}
params = {"card_id": card_id}
async with aiohttp.ClientSession() as session:
async with session.patch(endpoint, headers=headers, params=params) as resp:
data = await resp.json()
return data
# ── Callback button handlers ───────────────────────────────
@dp.callback_query(F.data.startswith("accept:"))
async def on_accept(callback: CallbackQuery) -> None:
"""Handles the 'Accept' inline button click for a card."""
if not callback.data or not isinstance(callback.message, Message):
return
card_id = int(callback.data.split(":")[1])
result = await call_moderation_api("accept", card_id)
if result.get("error"):
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
return
orig_text = callback.message.text or ""
await callback.message.edit_text(
orig_text + "\n\n✅ <b>ПРИНЯТА</b>",
parse_mode="HTML",
)
await callback.answer("Карточка принята!")
logger.info("Card {} accepted by admin", card_id)
@dp.callback_query(F.data.startswith("reject:"))
async def on_reject(callback: CallbackQuery) -> None:
"""Handles the 'Reject' inline button click for a card."""
if not callback.data or not isinstance(callback.message, Message):
return
card_id = int(callback.data.split(":")[1])
result = await call_moderation_api("reject", card_id)
if result.get("error"):
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
return
orig_text = callback.message.text or ""
await callback.message.edit_text(
orig_text + "\n\n❌ <b>ОТКЛОНЕНА</b>",
parse_mode="HTML",
)
await callback.answer("Карточка отклонена!")
logger.info("Card {} rejected by admin", card_id)
# ── aiogram Lifecycle hooks ────────────────────────────────────
_rabbit_task: asyncio.Task | None = None
@dp.startup()
async def on_startup() -> None:
"""Starts the RabbitMQ consumer task when the bot starts."""
global _rabbit_task
_rabbit_task = asyncio.create_task(
rabbit.consume_moderation(send_card_to_admin)
)
logger.info("Moderation bot started, RabbitMQ consumer running")
@dp.shutdown()
async def on_shutdown() -> None:
"""Cancels the RabbitMQ consumer task when the bot shuts down."""
if _rabbit_task:
_rabbit_task.cancel()
try:
await _rabbit_task
except asyncio.CancelledError:
pass
logger.info("Moderation bot stopped")
if __name__ == "__main__":
dp.run_polling(bot)
+6 -4
View File
@@ -41,10 +41,12 @@ def write_json(cards_list, json_file):
except Exception as e: except Exception as e:
print(f"Error writing to JSON file: {e}") print(f"Error writing to JSON file: {e}")
def add_cards_to_mongodb(cards_list): import asyncio
async def add_cards_to_mongodb(cards_list):
mongo = MongoWorker() mongo = MongoWorker()
for card in cards_list: for card in cards_list:
mongo.add_card_by_base_model(card) await mongo.add_card_by_base_model(card)
print(f"Successfully added {len(cards_list)} cards to MongoDB") print(f"Successfully added {len(cards_list)} cards to MongoDB")
@@ -60,13 +62,13 @@ if __name__ == "__main__":
if args.action == 0: if args.action == 0:
cards = create_cards(args.num, args.user) cards = create_cards(args.num, args.user)
add_cards_to_mongodb(cards) asyncio.run(add_cards_to_mongodb(cards))
elif args.action == 1: elif args.action == 1:
cards = create_cards(args.num, args.user) cards = create_cards(args.num, args.user)
write_json(cards, args.file) write_json(cards, args.file)
elif args.action == 2: elif args.action == 2:
cards = read_json(args.file) cards = read_json(args.file)
if cards: if cards:
add_cards_to_mongodb(cards) asyncio.run(add_cards_to_mongodb(cards))
else: else:
print("No valid cards found in JSON file.") print("No valid cards found in JSON file.")
+90 -9
View File
@@ -1,33 +1,114 @@
version: '3.8'
services: services:
mongodb: mongodb:
image: mongo:latest image: mongo
container_name: tort-mongodb container_name: tort-mongodb
restart: always restart: always
network_mode: bridge
environment: environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER} MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASS} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASS}
ports: ports:
- "127.0.0.1:${MONGO_PORT}:27017" - "127.0.0.1:${MONGO_PORT}:27017"
networks:
- tort-net
command: mongod --quiet
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "1"
healthcheck: healthcheck:
test: [ "CMD", "mongosh", "--username", "${MONGO_USER}", "--password", "${MONGO_PASS}", "--eval", "db.runCommand({ ping: 1 })" ] test: [ "CMD", "mongosh", "--username", "${MONGO_USER}", "--password", "${MONGO_PASS}", "--eval", "db.runCommand({ ping: 1 })" ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 2 retries: 3
rabbitmq:
image: rabbitmq:3.13-management-alpine
container_name: tort-rabbitmq
restart: always
environment:
RABBITMQ_DEFAULT_USER: ${RABBIT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBIT_PASS}
ports:
- "127.0.0.1:5672:5672"
- "127.0.0.1:15672:15672"
networks:
- tort-net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "1"
healthcheck:
test: [ "CMD", "rabbitmq-diagnostics", "-q", "ping" ]
interval: 10s
timeout: 5s
retries: 3
backend: backend:
build: build:
context: ./app context: ./app
dockerfile: dockerfile.app
image: tort-backend:latest image: tort-backend:latest
container_name: tort-backend
restart: always
depends_on: depends_on:
mongodb: mongodb:
condition: service_healthy condition: service_healthy
container_name: tort-backend rabbitmq:
network_mode: "host" condition: service_healthy
environment: environment:
MONGO_HOST: ${MONGO_HOST} MONGO_HOST: mongodb
MONGO_PORT: ${MONGO_PORT} MONGO_PORT: "27017"
MONGO_USER: ${MONGO_USER} MONGO_USER: ${MONGO_USER}
MONGO_PASS: ${MONGO_PASS} MONGO_PASS: ${MONGO_PASS}
RABBIT_HOST: rabbitmq
RABBIT_PORT: "5672"
RABBIT_USER: ${RABBIT_USER}
RABBIT_PASS: ${RABBIT_PASS}
MODERATION_SECRET: ${MODERATION_SECRET}
DEV_MODE: ${DEV_MODE:-false}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "2"
ports:
- "127.0.0.1:5000:5000"
networks:
- tort-net
tg-bot:
build:
context: ./app
dockerfile: dockerfile.bot
image: tort-tg-bot:latest
container_name: tort-tg-bot
restart: always
depends_on:
rabbitmq:
condition: service_healthy
backend:
condition: service_started
environment:
TG_BOT_TOKEN: ${TG_BOT_TOKEN}
TG_ADMIN_CHAT_ID: ${TG_ADMIN_CHAT_ID}
API_BASE_URL: http://backend:5000
MODERATION_SECRET: ${MODERATION_SECRET}
RABBIT_HOST: rabbitmq
RABBIT_PORT: "5672"
RABBIT_USER: ${RABBIT_USER}
RABBIT_PASS: ${RABBIT_PASS}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "1"
networks:
- tort-net
networks:
tort-net:
driver: bridge