diff --git a/.env b/.env
deleted file mode 100644
index 1e2f09a..0000000
--- a/.env
+++ /dev/null
@@ -1,6 +0,0 @@
-DISABLE_DOCS=true
-
-MONGO_HOST=127.0.0.1
-MONGO_PORT=27017
-MONGO_USER=user
-MONGO_PASS=pass
\ No newline at end of file
diff --git a/.env_example b/.env_example
new file mode 100644
index 0000000..dfb56d3
--- /dev/null
+++ b/.env_example
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml
deleted file mode 100644
index 9af1516..0000000
--- a/.github/workflows/app-actions.yml
+++ /dev/null
@@ -1,67 +0,0 @@
-name: app-actions
-
-on:
- workflow_dispatch:
- push:
- paths:
- - '**.py'
- branches:
- - main
- - app
- pull_request:
- branches:
- - main
-
-
-jobs:
- mypy:
- runs-on: ubuntu-latest
- continue-on-error: true
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Setup Python
- uses: actions/setup-python@v4
- with:
- python-version: 3.9
- architecture: x64
-
- - name: Install dependencies
- run: |
- pip install mypy
- pip install -r app/requirements.txt
-
- - name: Run mypy
- run: mypy --ignore-missing-imports ./app
-
- pytest:
- runs-on: ubuntu-latest
- env:
- MONGO_HOST: ${{ secrets.MONGO_HOST }}
- MONGO_PORT: ${{ secrets.MONGO_PORT }}
- MONGO_USER: ${{ secrets.MONGO_USER }}
- MONGO_PASS: ${{ secrets.MONGO_PASS }}
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Setup Python
- uses: actions/setup-python@v4
- with:
- python-version: 3.9
- architecture: x64
-
- - name: Setup 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
-
- - name: Install dependencies
- run: |
- pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
- pip install -r app/requirements.txt
- - name: Setup moderated base cards
-
- working-directory: ./app/tools
- run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
- - name: Run pytest
- run: pytest -vs
\ No newline at end of file
diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml
new file mode 100644
index 0000000..db551c2
--- /dev/null
+++ b/.github/workflows/backend-ci.yml
@@ -0,0 +1,105 @@
+name: Backend CI
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - 'app/**'
+ branches:
+ - app
+ - prebuild
+ - main
+ pull_request:
+ paths:
+ - 'app/**'
+ branches:
+ - app
+ - prebuild
+ - main
+
+jobs:
+ mypy:
+ name: Backend Lint (mypy)
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: app/requirements.txt
+
+ - name: Install dependencies
+ run: |
+ pip install mypy
+ pip install -r app/requirements.txt
+
+ - name: Run mypy
+ run: mypy --ignore-missing-imports ./app
+
+ pytest:
+ name: Backend Tests (pytest)
+ runs-on: ubuntu-latest
+ env:
+ MONGO_HOST: "127.0.0.1"
+ MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
+ MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
+ MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
+ RABBIT_HOST: "127.0.0.1"
+ RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
+ RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
+ RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
+ DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: app/requirements.txt
+
+ - name: Start MongoDB
+ 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
+ run: |
+ python -m pip install --upgrade pip
+ pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
+ pip install -r app/requirements.txt
+
+ - name: Setup moderated base cards
+ working-directory: ./app/tools
+ run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
+
+ - name: Run pytest
+ run: pytest -vs
diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml
new file mode 100644
index 0000000..5f7cdc0
--- /dev/null
+++ b/.github/workflows/deploy-main.yml
@@ -0,0 +1,166 @@
+name: Publish to GHCR (Main)
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ frontend-test-build:
+ name: Frontend (Test & Build)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ working-directory: ./frontend
+ run: npm ci
+
+ - name: Run frontend tests
+ working-directory: ./frontend
+ run: npm test -- --watchAll=false
+
+ - name: Build React app
+ working-directory: ./frontend
+ run: npm run build
+
+ backend-mypy:
+ name: Backend Lint (mypy)
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: app/requirements.txt
+
+ - name: Install dependencies
+ run: |
+ pip install mypy
+ pip install -r app/requirements.txt
+
+ - name: Run mypy
+ run: mypy --ignore-missing-imports ./app
+
+ backend-pytest:
+ name: Backend Tests (pytest)
+ runs-on: ubuntu-latest
+ env:
+ MONGO_HOST: "127.0.0.1"
+ MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
+ MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
+ MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
+ RABBIT_HOST: "127.0.0.1"
+ RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
+ RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
+ RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
+ DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: app/requirements.txt
+
+ - name: Start MongoDB
+ 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
+ run: |
+ python -m pip install --upgrade pip
+ pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
+ pip install -r app/requirements.txt
+
+ - name: Setup moderated base cards
+ working-directory: ./app/tools
+ run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
+
+ - name: Run pytest
+ run: pytest -vs
+
+ docker-publish-ghcr:
+ name: Build & Push Images to GHCR
+ runs-on: ubuntu-latest
+ needs: [frontend-test-build, backend-mypy, backend-pytest]
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set lowercase repository owner
+ id: repo_owner
+ run: echo "OWNER_LC=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
+
+ - name: Build and push Backend image
+ uses: docker/build-push-action@v6
+ with:
+ context: ./app
+ file: ./app/dockerfile.app
+ push: true
+ tags: |
+ ghcr.io/${{ env.OWNER_LC }}/tort-backend:latest
+ ghcr.io/${{ env.OWNER_LC }}/tort-backend:${{ github.sha }}
+
+ - name: Build and push Bot image
+ uses: docker/build-push-action@v6
+ with:
+ context: ./app
+ file: ./app/dockerfile.bot
+ push: true
+ tags: |
+ ghcr.io/${{ env.OWNER_LC }}/tort-tg-bot:latest
+ ghcr.io/${{ env.OWNER_LC }}/tort-tg-bot:${{ github.sha }}
diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml
new file mode 100644
index 0000000..7fb77c9
--- /dev/null
+++ b/.github/workflows/frontend-ci.yml
@@ -0,0 +1,45 @@
+name: Frontend CI
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - 'frontend/**'
+ branches:
+ - frontend
+ - prebuild
+ - main
+ pull_request:
+ paths:
+ - 'frontend/**'
+ branches:
+ - frontend
+ - prebuild
+ - main
+
+jobs:
+ frontend-test-build:
+ name: React Test & Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ working-directory: ./frontend
+ run: npm ci
+
+ - name: Run frontend tests
+ working-directory: ./frontend
+ run: npm test -- --watchAll=false
+
+ - name: Build React app
+ working-directory: ./frontend
+ run: npm run build
diff --git a/.github/workflows/prebuild-ci.yml b/.github/workflows/prebuild-ci.yml
new file mode 100644
index 0000000..764a71a
--- /dev/null
+++ b/.github/workflows/prebuild-ci.yml
@@ -0,0 +1,134 @@
+name: Prebuild CI & Docker Test Build
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - prebuild
+ pull_request:
+ branches:
+ - prebuild
+
+jobs:
+ frontend-test-build:
+ name: Frontend (Test & Build)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ working-directory: ./frontend
+ run: npm ci
+
+ - name: Run frontend tests
+ working-directory: ./frontend
+ run: npm test -- --watchAll=false
+
+ - name: Build React app
+ working-directory: ./frontend
+ run: npm run build
+
+ backend-mypy:
+ name: Backend Lint (mypy)
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: app/requirements.txt
+
+ - name: Install dependencies
+ run: |
+ pip install mypy
+ pip install -r app/requirements.txt
+
+ - name: Run mypy
+ run: mypy --ignore-missing-imports ./app
+
+ backend-pytest:
+ name: Backend Tests (pytest)
+ runs-on: ubuntu-latest
+ env:
+ MONGO_HOST: "127.0.0.1"
+ MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
+ MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
+ MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
+ RABBIT_HOST: "127.0.0.1"
+ RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
+ RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
+ RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
+ DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: app/requirements.txt
+
+ - name: Start MongoDB
+ 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
+ run: |
+ python -m pip install --upgrade pip
+ pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
+ pip install -r app/requirements.txt
+
+ - name: Setup moderated base cards
+ working-directory: ./app/tools
+ run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
+
+ - name: Run pytest
+ run: pytest -vs
+
+ docker-test-build:
+ name: Docker Images Test Build (No Push)
+ runs-on: ubuntu-latest
+ needs: [frontend-test-build, backend-mypy, backend-pytest]
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Build Docker images (dry-run test via docker compose)
+ run: docker compose build
diff --git a/app/.dockerignore b/app/.dockerignore
new file mode 100644
index 0000000..4016b01
--- /dev/null
+++ b/app/.dockerignore
@@ -0,0 +1,15 @@
+__pycache__/
+*.pyc
+*.pyo
+.pytest_cache/
+.venv/
+.env
+tests/
+security.log
+.git/
+.github/
+*.md
+dockerfile.app
+dockerfile.bot
+.dockerignore
+
diff --git a/app/dockerfile b/app/dockerfile
deleted file mode 100644
index 281fd13..0000000
--- a/app/dockerfile
+++ /dev/null
@@ -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"]
\ No newline at end of file
diff --git a/app/dockerfile.app b/app/dockerfile.app
new file mode 100644
index 0000000..2157f69
--- /dev/null
+++ b/app/dockerfile.app
@@ -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"]
\ No newline at end of file
diff --git a/app/dockerfile.bot b/app/dockerfile.bot
new file mode 100644
index 0000000..4dec4c8
--- /dev/null
+++ b/app/dockerfile.bot
@@ -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"]
diff --git a/app/logger.py b/app/logger.py
new file mode 100644
index 0000000..f40a301
--- /dev/null
+++ b/app/logger.py
@@ -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
diff --git a/app/main.py b/app/main.py
index e5cbb3e..7dc82c1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,191 +1,336 @@
import os
+import secrets
import uvicorn
import asyncio
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 schemas.base_schemas import *
+from typing import Optional
+
+from schemas.api_schemas import BaseResponse, AddUserBody, AddCardBody, SelectChoice, ReactionCard, AddCommentBody
+from schemas.base_schemas import Card
from mongo_worker import MongoWorker
+from rabbit_worker import RabbitWorker
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()
-disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
-app: FastAPI = FastAPI(title="This OR That",
- summary="OpenAPI schema for \"This OR That\" project!",
- version="0.1",
- contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
- docs_url=None if disable_docs else "/docs",
- redoc_url=None if disable_docs else "/redoc",
- openapi_url=None if disable_docs else "/openapi.json")
+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!",
+ version="0.1",
+ contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
+ docs_url="/docs" if DEV_MODE else None,
+ redoc_url="/redoc" if DEV_MODE else None,
+ openapi_url="/openapi.json" if DEV_MODE else None,
+ 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)
-async def check_user(user_id: NonNegativeInt,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- result = mongo.check_user(user_id)
+async def check_user(
+ user_id: int,
+ 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)
@app.get("/get_user", status_code=200)
-async def get_user(user_id: NonNegativeInt,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- if mongo.check_user(user_id):
- result = mongo.get_user(user_id)
+async def get_user(
+ user_id: int,
+ response: Response,
+ mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
+ """Retrieves a user's details by their user_id."""
+ if await mongo.check_user(user_id):
+ result = await mongo.get_user(user_id)
return BaseResponse(result=result)
- else:
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return BaseResponse(result="User doesn't exist", error=True)
+
+@app.post("/add_user", status_code=201)
+@guard_deco.rate_limit(requests=3, window=60)
+async def add_user(
+ new_user: AddUserBody,
+ response: Response,
+ auth_user_id: Optional[int] = Depends(get_current_user_id),
+ mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
+ """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.first_name,
+ new_user.last_name,
+ new_user.photo_url,
+ )
+ return BaseResponse(result=result)
+ response.status_code = status.HTTP_409_CONFLICT
+ return BaseResponse(result="User already exist", error=True)
+
+
+@app.get("/get_card", status_code=200)
+async def get_card(
+ card_id: int,
+ response: Response,
+ mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
+ """Retrieves a card's details by its card_id."""
+ card = await mongo.get_card(card_id)
+ if card:
+ return BaseResponse(result=card)
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return BaseResponse(result="There is no card with this card_id", error=True)
+
+@app.get("/get_random_cards", status_code=200)
+@guard_deco.rate_limit(requests=5, window=60)
+async def get_random_cards(
+ response: Response,
+ user_id: Optional[int] = None,
+ 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:
+ response.status_code = status.HTTP_404_NOT_FOUND
+ 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)
+
+ if not random_cards:
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return BaseResponse(result="No active cards for this user", error=True)
+
+ return BaseResponse(result=random_cards)
+
+@app.post("/add_card", status_code=201)
+@guard_deco.rate_limit(requests=3, window=60)
+async def add_card(
+ new_card: AddCardBody,
+ response: Response,
+ 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):
+ card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, author_id)
+ try:
+ 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)
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ 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)
+async def select_choice(
+ choice_data: SelectChoice,
+ response: Response,
+ auth_user_id: Optional[int] = Depends(get_current_user_id),
+ mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
+ """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
return BaseResponse(result="User doesn't exist", error=True)
-@app.post("/add_user", status_code=201)
-async def add_user(new_user: AddUserBody,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- if not mongo.check_user(new_user.user_id):
- result = mongo.add_user(new_user.user_id,
- new_user.username,
- new_user.first_name,
- new_user.last_name,
- new_user.photo_url)
- return BaseResponse(result=result)
- else:
- response.status_code = status.HTTP_409_CONFLICT
- return BaseResponse(result="User already exist", error=True)
-
-
-@app.get("/get_card", status_code=200)
-async def get_card(card_id: NonNegativeInt,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- card = mongo.get_card(card_id)
- if card:
- return BaseResponse(result=card)
- else:
- response.status_code = status.HTTP_404_NOT_FOUND
- return BaseResponse(result="There is no card with this card_id", error=True)
-
-@app.get("/get_random_cards", status_code=200)
-async def get_random_cards(user_id: NonNegativeInt,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- cards_visited = mongo.get_visited_cards(user_id)
-
- 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
- return BaseResponse(result="No active cards", error=True)
-
- 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:
- response.status_code = status.HTTP_404_NOT_FOUND
- return BaseResponse(result="No active cards", 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:
- 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)
-async def add_card(new_card: AddCardBody,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
- card = mongo.add_card_by_api(new_card.choice_A,
- new_card.choice_B,
- new_card.author_id)
- return BaseResponse(result=card)
- else:
- response.status_code = status.HTTP_400_BAD_REQUEST
- return BaseResponse(result="Card has not passed base moderation", error=True)
-
-
-@app.patch("/select_choice", status_code=200)
-async def select_choice(choice_data: SelectChoice,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- check_visited = mongo.get_visited_cards(choice_data.user_id)
- if check_visited.error:
- response.status_code = status.HTTP_404_NOT_FOUND
- return check_visited
- 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
return BaseResponse(result="Card already visited!", error=True)
- else:
- select_choice_result = mongo.select_choice(choice_data.card_id, choice_data.choice)
- if select_choice_result.error:
- response.status_code = status.HTTP_404_NOT_FOUND
- 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 complite!")
-
+
+ select_choice_result = await mongo.select_choice(choice_data.card_id, choice_data.choice)
+ if select_choice_result.error:
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return select_choice_result
+
+ return BaseResponse(result="Select choice complete!")
+
+
@app.patch("/like_card", status_code=200)
-async def like_card(like_data: ReactionCard,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- result = mongo.like_card(like_data.card_id, like_data.user_id)
+async def like_card(
+ like_data: ReactionCard,
+ response: Response,
+ auth_user_id: Optional[int] = Depends(get_current_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:
return BaseResponse(result="Added like to card")
- else:
- response.status_code = status.HTTP_404_NOT_FOUND
- return result
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return result
@app.patch("/dislike_card", status_code=200)
-async def dislike_card(dislike_data: ReactionCard,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
- result = mongo.dislike_card(dislike_data.card_id, dislike_data.user_id)
+async def dislike_card(
+ dislike_data: ReactionCard,
+ response: Response,
+ auth_user_id: Optional[int] = Depends(get_current_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:
return BaseResponse(result="Added dislike to card")
- else:
- response.status_code = status.HTTP_404_NOT_FOUND
- return result
-
+ response.status_code = status.HTTP_404_NOT_FOUND
+ return result
+
@app.post("/comment", status_code=201)
-async def comment(comment_info: AddCommentBody,
- response: Response,
- mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
+@guard_deco.rate_limit(requests=5, window=20)
+async def comment(
+ comment_info: AddCommentBody,
+ response: Response,
+ 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):
response.status_code = status.HTTP_400_BAD_REQUEST
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"]:
response.status_code = status.HTTP_404_NOT_FOUND
return result
- elif result.error:
+ if result.error:
response.status_code = status.HTTP_400_BAD_REQUEST
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
+
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)
await server.serve()
+
if __name__ == "__main__":
asyncio.run(main())
\ No newline at end of file
diff --git a/app/middleware.py b/app/middleware.py
new file mode 100644
index 0000000..764c889
--- /dev/null
+++ b/app/middleware.py
@@ -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
diff --git a/app/mongo_worker.py b/app/mongo_worker.py
index 28362ce..a8eea91 100644
--- a/app/mongo_worker.py
+++ b/app/mongo_worker.py
@@ -1,22 +1,35 @@
import os
-import pymongo
+import motor.motor_asyncio
from datetime import datetime
from dotenv import load_dotenv
from typing import Optional
+from pymongo import ReturnDocument
-from schemas.base_schemas import *
-from schemas.api_schemas import *
+from schemas.base_schemas import User, Visited, Card, Comment
+from schemas.api_schemas import BaseResponse
+from logger import logger
class MongoWorker:
+ """Worker class for handling all MongoDB database operations."""
def __init__(self):
+ """Initializes the MongoDB connection and sets up collection references."""
load_dotenv()
- self.client = pymongo.MongoClient(host = os.getenv('MONGO_HOST'),
- port = int(os.getenv('MONGO_PORT')),
- username = os.getenv('MONGO_USER'),
- password = os.getenv('MONGO_PASS'))
+ self.client = motor.motor_asyncio.AsyncIOMotorClient(
+ host=os.getenv('MONGO_HOST'),
+ port=int(os.getenv('MONGO_PORT', 27017)),
+ username=os.getenv('MONGO_USER'),
+ 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.users_data = self.db["users"]
self.visited_data = self.db["visited"]
@@ -24,191 +37,302 @@ class MongoWorker:
self.game_data = self.db["cards"]
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:
- new_user = User(user_id=user_id,
- username=username,
- first_name=first_name,
- last_name=last_name,
- photo_url=photo_url,
- registration_date=datetime.now().isoformat())
- try:
- self.users_data.insert_one(new_user.model_dump())
- return new_user
- except Exception as exception:
- return new_user
+ async def check_user(self, user_id: int) -> bool:
+ """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
- def get_user(self, user_id: int) -> User:
- return User.model_validate(self.users_data.find_one({"user_id": user_id}))
-
+ 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,
+ first_name=first_name,
+ last_name=last_name,
+ photo_url=photo_url,
+ registration_date=datetime.now().isoformat(),
+ )
+ await self.users_data.insert_one(new_user.model_dump())
+ logger.debug("User added: user_id={}, username={}", user_id, username)
+ return new_user
- def get_and_update_counter(self, counter_name: str) -> int:
- counter = self.counters.find_one_and_update(
+ async def get_user(self, user_id: int) -> User:
+ """Retrieves a user's details from the database."""
+ document = await self.users_data.find_one({"user_id": user_id})
+ return User.model_validate(document)
+
+
+ async def get_and_update_counter(self, counter_name: str) -> int:
+ """Atomically increments the counter and returns the new value."""
+ counter = await self.counters.find_one_and_update(
{"counter_name": counter_name},
{"$inc": {"counter": 1}},
upsert=True,
- return_document=True)
+ return_document=ReturnDocument.AFTER,
+ )
return counter["counter"]
-
-
- def get_visited_cards(self, user_id: int) -> BaseResponse:
- document = self.visited_data.find_one({"user_id": user_id})
+
+
+ async def get_visited_cards(self, user_id: int) -> BaseResponse:
+ """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:
- check_user = self.check_user(user_id)
- if check_user:
- return BaseResponse(result=Visited(user_id=user_id,
- cards_visited=set()))
- else:
- return BaseResponse(result="User doesn't exist", error=True)
- else:
- return BaseResponse(result=Visited.model_validate(document))
-
- def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]:
- filtered_cards = [card for card in random_cards if card.card_id not in cards_visited]
- filtered_cards_id = [filtered_card.card_id for filtered_card in filtered_cards]
-
- 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}},
- upsert=True,
- return_document=True)
- return Visited.model_validate(update_visited)
+ if await self.check_user(user_id):
+ return BaseResponse(result=Visited(user_id=user_id, cards_visited=set()))
+ return BaseResponse(result="User doesn't exist", error=True)
+ return BaseResponse(result=Visited.model_validate(document))
+
+ async def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited:
+ """Adds a specific card ID to the user's set of visited cards."""
+ updated = await self.visited_data.find_one_and_update(
+ {"user_id": user_id},
+ {"$addToSet": {"cards_visited": visited_card_id}},
+ upsert=True,
+ return_document=ReturnDocument.AFTER,
+ )
+ 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:
- new_card = Card(card_id=self.get_and_update_counter(counter_name="card"),
- choice_A=choice_A,
- choice_B=choice_B,
- author_id=author_id,
- creation_date=datetime.now().isoformat())
- try:
- self.game_data.insert_one(new_card.model_dump())
- return new_card
- except Exception as exception:
- print(exception)
- return new_card
-
- def add_card_by_base_model(self, new_card: Card) -> Optional[Card]:
- new_card.card_id = self.get_and_update_counter(counter_name="card")
- try:
- self.game_data.insert_one(new_card.model_dump())
- return new_card
- except Exception as exception:
- print(exception)
- return new_card
-
- def get_card(self, card_id: int) -> Optional[Card]:
- document = self.game_data.find_one({"card_id": card_id})
+ async def get_card(self, card_id: int) -> Optional[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)
- else:
- return None
+ return None
- def get_random_cards(self, amount: int, active_status: bool) -> Optional[list[Card]]:
- pipeline = [{"$match": {"active_status": active_status}},
- {"$sample": {"size": amount}}]
- raw_items = list(self.game_data.aggregate(pipeline))
+ 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:
- validated_items = [Card.model_validate(item) for item in raw_items]
- return validated_items
- else:
- return None
-
+ return [Card.model_validate(item) for item in raw_items]
+ return None
- def select_choice(self, card_id: int, choice: str) -> BaseResponse:
+ 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_B=choice_B,
+ author_id=author_id,
+ 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:
+ await self.game_data.insert_one(new_card.model_dump())
+ return new_card
+ except Exception as exc:
+ logger.error("Failed to insert card: {}", exc)
+ raise
+
+ async def accept_card(self, card_id: int) -> BaseResponse:
+ """Accepts a card: sets active_status=True and moderation_date=now."""
+ result = await self.game_data.find_one_and_update(
+ {"card_id": card_id},
+ {"$set": {
+ "active_status": True,
+ "moderation_date": datetime.now().isoformat(),
+ }},
+ 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))
+
+ async def reject_card(self, card_id: int) -> BaseResponse:
+ """Rejects a card: deletes it from the database."""
+ result = await self.game_data.delete_one({"card_id": card_id})
+ if result.deleted_count == 0:
+ logger.debug("Attempted to reject non-existent card: card_id={}", card_id)
+ 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")
+
+ async def select_choice(self, card_id: int, choice: str) -> BaseResponse:
+ """Increments the vote count for the selected choice (A or B) and total votes on a card."""
if choice == "A":
- count_choice = "count_choice_A"
+ count_field = "count_choice_A"
elif choice == "B":
- count_choice = "count_choice_B"
+ count_field = "count_choice_B"
else:
return BaseResponse(result="Wrong choice", error=True)
-
- result = self.game_data.find_one_and_update({"card_id": card_id},
- {"$inc": {"count_total": 1, count_choice: 1}})
-
+
+ result = await self.game_data.find_one_and_update(
+ {"card_id": card_id},
+ {"$inc": {"count_total": 1, count_field: 1}},
+ )
if not result:
return BaseResponse(result="Card doesn't exist", error=True)
- else:
- 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
+ return BaseResponse(result=True, error=False)
- if card_id in liked_card_ids:
- return BaseResponse(result="Card already liked", error=True)
- elif card_id in disliked_card_ids:
- return BaseResponse(result="Card already disliked", error=True)
- else:
- return BaseResponse(result="No reactions", error=False)
- def like_card(self, card_id: int, user_id: int) -> BaseResponse:
- if self.check_user(user_id):
- user_reaction = self.check_user_reactions(user_id, card_id)
- if user_reaction.error:
- return user_reaction
- update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
- {"$inc": {"count_likes": 1}})
- if not update_card_info:
- return BaseResponse(result="Card doesn't exist", error=True)
-
- add_card_to_user = self.users_data.update_one({'user_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)
- else:
+ async def like_card(self, card_id: int, user_id: int) -> BaseResponse:
+ """Atomically adds a like to a card and records the user's like action."""
+ if not await self.check_user(user_id):
return BaseResponse(result="User doesn't exist", error=True)
-
- def dislike_card(self, card_id: int, user_id: int) -> BaseResponse:
- if self.check_user(user_id):
- user_reaction = self.check_user_reactions(user_id, card_id)
- if user_reaction.error:
- return user_reaction
- update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
- {"$inc": {"count_dislikes": 1}})
- if not update_card_info:
- return BaseResponse(result="Card doesn't exist", error=True)
-
- add_card_to_user = self.users_data.update_one({'user_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)
- else:
+
+ # Atomically add card_id to liked_card_ids ONLY IF it is not already
+ # present in liked_card_ids OR disliked_card_ids.
+ # Using a conditional filter makes this a single, race-condition-free
+ # test-and-set: if modified_count == 0, another request already won.
+ user_update = await self.users_data.find_one_and_update(
+ {
+ "user_id": user_id,
+ "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)
+
+ logger.debug("Card liked: card_id={}, user_id={}", card_id, user_id)
+ return BaseResponse(result=True, error=False)
+
+ 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)
-
- def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse:
- if self.check_user(user_id):
- if self.get_card(card_id):
- new_comment = Comment(comment_id=self.get_and_update_counter(counter_name="comment"),
- author_id=user_id,
- card_id=card_id,
- comment_text=comment_text,
- creation_date=datetime.now().isoformat())
- result = self.comments_data.insert_one(new_comment.model_dump())
- if result:
- update_user_comments = self.users_data.find_one_and_update({"user_id": user_id},
- {"$addToSet": {"comments_ids": new_comment.comment_id}})
- if update_user_comments:
- return BaseResponse(result=new_comment)
- else:
- return BaseResponse(result="Difficulty adding comment_id to user", error=True)
- else:
- return BaseResponse(result="Add comment error", error=True)
- else:
- return BaseResponse(result="Card doesn't exist", error=True)
- else:
- return BaseResponse(result="User doesn't exist", error=True)
\ No newline at end of file
+
+ # Same atomic test-and-set pattern as like_card.
+ user_update = await self.users_data.find_one_and_update(
+ {
+ "user_id": user_id,
+ "liked_card_ids": {"$ne": card_id},
+ "disliked_card_ids": {"$ne": card_id},
+ },
+ {"$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)
+
+ logger.debug("Card disliked: card_id={}, user_id={}", card_id, user_id)
+ return BaseResponse(result=True, error=False)
+
+
+ async def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse:
+ """Adds a new comment to a card and links it to the user."""
+ 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,
+ card_id=card_id,
+ comment_text=comment_text,
+ creation_date=datetime.now().isoformat(),
+ )
+ await self.comments_data.insert_one(new_comment.model_dump())
+
+ updated_user = await self.users_data.find_one_and_update(
+ {"user_id": user_id},
+ {"$addToSet": {"comments_ids": new_comment.comment_id}},
+ return_document=ReturnDocument.AFTER,
+ )
+ if not updated_user:
+ return BaseResponse(result="Difficulty adding comment_id to user", error=True)
+
+ logger.debug("Comment added: comment_id={}, card_id={}, author_id={}", new_comment.comment_id, card_id, user_id)
+ 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)
+ comments = await self.comments_data.find({"card_id": card_id}).sort("creation_date", -1).to_list(length=None)
+ comments = [Comment.model_validate(comment) for comment in comments]
+ return BaseResponse(result=comments)
diff --git a/app/rabbit_worker.py b/app/rabbit_worker.py
new file mode 100644
index 0000000..38189b1
--- /dev/null
+++ b/app/rabbit_worker.py
@@ -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
\ No newline at end of file
diff --git a/app/requirements.txt b/app/requirements.txt
index f0ee27b..68f6a0b 100644
--- a/app/requirements.txt
+++ b/app/requirements.txt
@@ -1,4 +1,9 @@
fastapi==0.115.7
-pymongo==4.10.1
+fastapi_guard==7.6.0
+motor==3.7.0
python-dotenv==1.0.1
-uvicorn==0.34.0
\ No newline at end of file
+uvicorn==0.34.0
+aio-pika==10.0.1
+aiogram==3.18.0
+aiohttp==3.11.18
+loguru==0.7.3
\ No newline at end of file
diff --git a/app/schemas/api_schemas.py b/app/schemas/api_schemas.py
index 745d791..605d83a 100644
--- a/app/schemas/api_schemas.py
+++ b/app/schemas/api_schemas.py
@@ -2,13 +2,15 @@ import typing
from pydantic import BaseModel, NonNegativeInt
+from typing import Optional
+
class BaseResponse(BaseModel):
result: typing.Any
error: bool = False
class AddUserBody(BaseModel):
- user_id: NonNegativeInt
+ user_id: Optional[NonNegativeInt] = None
username: str
first_name: str
@@ -19,20 +21,20 @@ class AddCardBody(BaseModel):
choice_A: str
choice_B: str
- author_id: NonNegativeInt
+ author_id: Optional[NonNegativeInt] = None
class SelectChoice(BaseModel):
- user_id: NonNegativeInt
+ user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
choice: typing.Literal["A", "B"]
class ReactionCard(BaseModel):
- user_id: NonNegativeInt
+ user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
class AddCommentBody(BaseModel):
- author_id: NonNegativeInt
+ author_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
comment_text: str
\ No newline at end of file
diff --git a/app/tests/conftest.py b/app/tests/conftest.py
new file mode 100644
index 0000000..6155a3f
--- /dev/null
+++ b/app/tests/conftest.py
@@ -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()
diff --git a/app/tests/test_cards.py b/app/tests/test_cards.py
index 1e0cbab..ebe388d 100644
--- a/app/tests/test_cards.py
+++ b/app/tests/test_cards.py
@@ -14,9 +14,9 @@ NON_EXIST_CARD_ID = 1000
# ---------- /add_card ----------
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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 = {
"choice_A": "Option A",
"choice_B": "Option B",
@@ -32,9 +32,9 @@ async def test_add_card_valid():
assert card.choice_B == payload["choice_B"]
assert card.author_id == payload["author_id"]
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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 = {
#choice_A
"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()}")
assert response.status_code == 422
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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 = {
"choice_A": 123,
"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()}")
assert response.status_code == 422
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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 = {
"choice_A": "",
"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()}")
assert response.status_code == 400
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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
payload = {
"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()}")
assert response.status_code == 400
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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 = {
"choice_A": "Option A",
"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()}")
assert response.status_code == 422
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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
response = await client.post(
"/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'}")
assert response.status_code == 422
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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 = []
- num_cards = 8
+ num_cards = 2 # at most 3 (decorator limit), leaving a margin
for i in range(num_cards):
payload = {
"choice_A": f"Async Option A {i}",
@@ -118,7 +123,7 @@ async def test_async_card_creation():
}
tasks.append(client.post("/add_card", json=payload))
responses = await asyncio.gather(*tasks)
-
+
card_ids = []
for idx, response in enumerate(responses):
print(f"\nAsync creation {idx}: status={response.status_code}, response={response.json()}")
@@ -136,19 +141,24 @@ async def test_async_card_creation():
# ---------- /get_card ----------
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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 = {
"choice_A": "GetTest A",
"choice_B": "GetTest B",
"author_id": EXIST_AUTHOR
}
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())
card = Card.model_validate(base_create.result)
card_id = card.card_id
+ # Now retrieve it
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()}")
assert response.status_code == 200
@@ -157,30 +167,30 @@ async def test_get_card_valid():
card_from_get = Card.model_validate(base_resp.result)
assert card_from_get.card_id == card_id
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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})
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
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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")
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
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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"})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': 'abc'}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': -10}}\nOUTPUT: status={response.status_code} | json={response.json()}")
- assert response.status_code == 422
\ No newline at end of file
+ assert response.status_code == 404
\ No newline at end of file
diff --git a/app/tests/test_security.py b/app/tests/test_security.py
new file mode 100644
index 0000000..804984a
--- /dev/null
+++ b/app/tests/test_security.py
@@ -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": ""}
+ )
+ 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": "",
+ "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; --",
+ "",
+ "../../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",
+ "",
+ ]
+ 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": ""}
+ )
+ 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}"
+ )
diff --git a/app/tests/test_tg_auth.py b/app/tests/test_tg_auth.py
new file mode 100644
index 0000000..3f4a276
--- /dev/null
+++ b/app/tests/test_tg_auth.py
@@ -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
diff --git a/app/tests/test_user_info.py b/app/tests/test_user_info.py
index 67f52c4..afdc0ac 100644
--- a/app/tests/test_user_info.py
+++ b/app/tests/test_user_info.py
@@ -16,9 +16,9 @@ NON_EXIST_USER = random.randint(100000000, 1000000000)
# TEST ADD USERS UTILS #
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/add_user"
data = {
@@ -30,9 +30,9 @@ async def test_add_user_non_full_data():
assert raw_response.status_code == 422
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/add_user"
data = {
@@ -47,9 +47,9 @@ async def test_add_user_negative_int_id():
assert raw_response.status_code == 422
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/add_user"
data = {
@@ -67,9 +67,9 @@ async def test_add_new_user():
assert response.error == False
assert User.model_validate(response.result)
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/add_user"
data = {
@@ -92,9 +92,9 @@ async def test_add_already_exist_user():
# TEST CHECK USERS UTILS #
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/check_user"
params = {"user_id": NON_EXIST_USER}
@@ -106,9 +106,9 @@ async def test_check_non_exist_user():
assert response.error == False
assert response.result == False
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/check_user"
params = {"user_id": EXIST_USER}
@@ -125,9 +125,9 @@ async def test_check_exist_user():
# TEST GET USERS UTILS #
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/get_user"
params = {"user_id": NON_EXIST_USER}
@@ -139,9 +139,9 @@ async def test_get_non_exist_user():
assert response.error == True
assert response.result == "User doesn't exist"
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/get_user"
params = {"user_id": EXIST_USER}
diff --git a/app/tests/test_visited_cards.py b/app/tests/test_visited_cards.py
index 62ea676..e031836 100644
--- a/app/tests/test_visited_cards.py
+++ b/app/tests/test_visited_cards.py
@@ -14,9 +14,9 @@ ACTIVE_CARDS_LESS_THAN_TEN = False
# ------------- /add_user ---------------
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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:
end_point = "/add_user"
data = {
@@ -36,13 +36,13 @@ async def test_add_new_user():
# ---------- /get_random_cards ----------
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="session")
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}
response = await client.get("/get_random_cards", params=params)
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
NO_ACTIVE_CARDS_STATUS = True
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
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 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:
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")
- else:
- async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
- params = {"user_id": EXIST_USER}
- tasks = [client.get("/get_random_cards", params=params) for _ in range(5)]
- responses = await asyncio.gather(*tasks)
- for resp in responses:
- print(f"\nParallel call: status={resp.status_code} | json={resp.json()}")
- assert resp.status_code == 200
- result = resp.json().get("result")
- assert isinstance(result, list)
- assert len(result) == 10
\ No newline at end of file
+
+ async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), 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)
+ 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)
+ for resp in responses:
+ print(f"\nParallel call: status={resp.status_code} | text={resp.text[:200]}")
+ assert resp.status_code == 200, (
+ f"Expected 200, got {resp.status_code}: {resp.text}"
+ )
+ result = resp.json().get("result")
+ assert isinstance(result, list)
\ No newline at end of file
diff --git a/app/tg_auth.py b/app/tg_auth.py
new file mode 100644
index 0000000..bb025fa
--- /dev/null
+++ b/app/tg_auth.py
@@ -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"]
diff --git a/app/tg_bot.py b/app/tg_bot.py
new file mode 100644
index 0000000..b68505e
--- /dev/null
+++ b/app/tg_bot.py
@@ -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"🆕 Новая карточка #{card.card_id}\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✅ ПРИНЯТА",
+ 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❌ ОТКЛОНЕНА",
+ 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)
diff --git a/app/tools/_add_base_cards.py b/app/tools/_add_base_cards.py
index 0e8ce00..7d52e99 100644
--- a/app/tools/_add_base_cards.py
+++ b/app/tools/_add_base_cards.py
@@ -41,10 +41,12 @@ def write_json(cards_list, json_file):
except Exception as 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()
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")
@@ -60,13 +62,13 @@ if __name__ == "__main__":
if args.action == 0:
cards = create_cards(args.num, args.user)
- add_cards_to_mongodb(cards)
+ asyncio.run(add_cards_to_mongodb(cards))
elif args.action == 1:
cards = create_cards(args.num, args.user)
write_json(cards, args.file)
elif args.action == 2:
cards = read_json(args.file)
if cards:
- add_cards_to_mongodb(cards)
+ asyncio.run(add_cards_to_mongodb(cards))
else:
print("No valid cards found in JSON file.")
diff --git a/docker-compose.yml b/docker-compose.yml
index 70dd320..ea4a80c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,33 +1,114 @@
-version: '3.8'
-
services:
mongodb:
- image: mongo:latest
+ image: mongo
container_name: tort-mongodb
restart: always
- network_mode: bridge
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASS}
ports:
- "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:
test: [ "CMD", "mongosh", "--username", "${MONGO_USER}", "--password", "${MONGO_PASS}", "--eval", "db.runCommand({ ping: 1 })" ]
interval: 10s
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:
build:
context: ./app
+ dockerfile: dockerfile.app
image: tort-backend:latest
+ container_name: tort-backend
+ restart: always
depends_on:
mongodb:
condition: service_healthy
- container_name: tort-backend
- network_mode: "host"
+ rabbitmq:
+ condition: service_healthy
environment:
- MONGO_HOST: ${MONGO_HOST}
- MONGO_PORT: ${MONGO_PORT}
+ MONGO_HOST: mongodb
+ MONGO_PORT: "27017"
MONGO_USER: ${MONGO_USER}
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
diff --git a/frontend/public/index.html b/frontend/public/index.html
index d34e4fe..0d24ce3 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -6,6 +6,7 @@
+
diff --git a/frontend/src/App.css b/frontend/src/App.css
index fa4a015..3f7e04e 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -5,8 +5,10 @@
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
- height: 48px;
+ padding-top: max(var(--space-sm), env(safe-area-inset-top));
+ height: calc(48px + max(0px, env(safe-area-inset-top)));
flex-shrink: 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.app-logo {
@@ -15,6 +17,7 @@
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.3px;
+ user-select: none;
}
.app-logo-or {
@@ -25,6 +28,7 @@
border-radius: 6px;
font-size: 12px;
vertical-align: middle;
+ box-shadow: 0 0 10px rgba(124, 58, 237, 0.3);
}
/* Hamburger menu */
@@ -41,7 +45,7 @@
}
.menu-toggle:active {
- background: rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.08);
}
.menu-toggle-line {
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 8f6e9f4..4525857 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -1,6 +1,7 @@
import React from 'react';
import { AppProvider, useApp } from './context/AppContext';
import LoadingScreen from './components/common/LoadingScreen';
+import BannedScreen from './components/common/BannedScreen';
import Toast from './components/common/Toast';
import CardPair from './components/CardPair/CardPair';
import BottomBar from './components/BottomBar/BottomBar';
@@ -9,7 +10,11 @@ import MenuPanel from './components/Menu/MenuPanel';
import './App.css';
function AppContent() {
- const { isLoading, error, openMenu, toast } = useApp();
+ const { isLoading, error, isBanned, handleRetryAfterBan, openMenu, toast } = useApp();
+
+ if (isBanned) {
+ return