From 61a2eb8076f0fa422b4a98e8be243a7a95e6bdb5 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 1 Sep 2026 17:26:42 +0300 Subject: [PATCH] Merge pull request #4 from IgorVolochay/frontend --- .env | 6 - .env_example | 16 + .github/workflows/app-actions.yml | 67 --- .github/workflows/backend-ci.yml | 105 ++++ .github/workflows/deploy-main.yml | 166 ++++++ .github/workflows/frontend-ci.yml | 45 ++ .github/workflows/prebuild-ci.yml | 134 +++++ .github/workflows/prebuild.yml | 41 ++ app/.dockerignore | 15 + app/dockerfile | 11 - app/dockerfile.app | 32 ++ app/dockerfile.bot | 34 ++ app/logger.py | 110 ++++ app/main.py | 427 ++++++++++----- app/middleware.py | 69 +++ app/mongo_worker.py | 460 +++++++++++------ app/rabbit_worker.py | 77 +++ app/requirements.txt | 9 +- app/schemas/api_schemas.py | 12 +- app/schemas/base_schemas.py | 2 +- app/tests/conftest.py | 44 ++ app/tests/test_cards.py | 68 +-- app/tests/test_security.py | 486 ++++++++++++++++++ app/tests/test_tg_auth.py | 151 ++++++ app/tests/test_user_info.py | 32 +- app/tests/test_visited_cards.py | 72 +-- app/tg_auth.py | 172 +++++++ app/tg_bot.py | 188 +++++++ app/tools/_add_base_cards.py | 10 +- docker-compose.yml | 99 +++- frontend/public/index.html | 1 + frontend/src/App.css | 8 +- frontend/src/App.js | 7 +- frontend/src/App.test.js | 17 +- .../src/components/BottomBar/BottomBar.css | 28 +- .../src/components/BottomBar/BottomBar.jsx | 44 +- frontend/src/components/CardPair/CardPair.css | 61 ++- frontend/src/components/CardPair/CardPair.jsx | 80 ++- .../src/components/Comments/CommentItem.css | 47 +- .../src/components/Comments/CommentItem.jsx | 48 +- .../src/components/Comments/CommentsPanel.css | 70 ++- .../src/components/Comments/CommentsPanel.jsx | 157 ++++-- frontend/src/components/Menu/AboutPage.jsx | 41 +- frontend/src/components/Menu/CreateCard.css | 32 +- frontend/src/components/Menu/CreateCard.jsx | 40 +- frontend/src/components/Menu/MenuPanel.jsx | 32 +- .../src/components/common/BannedScreen.css | 150 ++++++ .../src/components/common/BannedScreen.jsx | 51 ++ frontend/src/context/AppContext.jsx | 314 ++++++++--- frontend/src/services/api.js | 77 ++- frontend/src/services/auth.js | 66 ++- frontend/src/setupTests.js | 6 +- 52 files changed, 3785 insertions(+), 752 deletions(-) delete mode 100644 .env create mode 100644 .env_example delete mode 100644 .github/workflows/app-actions.yml create mode 100644 .github/workflows/backend-ci.yml create mode 100644 .github/workflows/deploy-main.yml create mode 100644 .github/workflows/frontend-ci.yml create mode 100644 .github/workflows/prebuild-ci.yml create mode 100644 .github/workflows/prebuild.yml create mode 100644 app/.dockerignore delete mode 100644 app/dockerfile create mode 100644 app/dockerfile.app create mode 100644 app/dockerfile.bot create mode 100644 app/logger.py create mode 100644 app/middleware.py create mode 100644 app/rabbit_worker.py create mode 100644 app/tests/conftest.py create mode 100644 app/tests/test_security.py create mode 100644 app/tests/test_tg_auth.py create mode 100644 app/tg_auth.py create mode 100644 app/tg_bot.py create mode 100644 frontend/src/components/common/BannedScreen.css create mode 100644 frontend/src/components/common/BannedScreen.jsx 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/.github/workflows/prebuild.yml b/.github/workflows/prebuild.yml new file mode 100644 index 0000000..dcc2734 --- /dev/null +++ b/.github/workflows/prebuild.yml @@ -0,0 +1,41 @@ +name: Prebuild Workflow + +on: + pull_request: + branches: + - prebuild + +jobs: + test_build: + name: Test build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Try to build project + run: docker-compose up --build + + deploy_to_test_server: + name: Deploy to test server + needs: test_build + runs-on: ubuntu-latest + steps: + - name: Deploy to test server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.TEST_SERVER_ADDRESS }} + username: ${{ secrets.TEST_SERVER_USER }} + port: ${{ secrets.TEST_SERVER_PORT }} + password: ${{ secrets.TEST_SERVER_PASSWORD }} + script: | + REPO_NAME=$(basename "${{ github.repository }}") + if [ ! -d "$REPO_NAME" ]; then + git clone https://github.com/${{ github.repository }}.git $REPO_NAME + fi + cd $REPO_NAME + + git checkout prebuild + git pull + + docker-compose down || true + docker-compose up -d --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 d3133a4..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, - commet_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/schemas/base_schemas.py b/app/schemas/base_schemas.py index 52f5464..d5a58c2 100644 --- a/app/schemas/base_schemas.py +++ b/app/schemas/base_schemas.py @@ -43,6 +43,6 @@ class Comment(BaseModel): author_id: int card_id: int - commet_text: str + comment_text: str creation_date: 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 ; + } if (isLoading) { return ; diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js index 1f03afe..ea98d24 100644 --- a/frontend/src/App.test.js +++ b/frontend/src/App.test.js @@ -1,8 +1,15 @@ -import { render, screen } from '@testing-library/react'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; import App from './App'; -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +test('renders app without crashing', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(async () => { + const root = createRoot(container); + root.render(); + }); + + expect(container.innerHTML).toBeTruthy(); }); diff --git a/frontend/src/components/BottomBar/BottomBar.css b/frontend/src/components/BottomBar/BottomBar.css index fe3b5a1..e5533e0 100644 --- a/frontend/src/components/BottomBar/BottomBar.css +++ b/frontend/src/components/BottomBar/BottomBar.css @@ -2,8 +2,9 @@ display: flex; align-items: center; justify-content: space-around; - height: var(--bar-height); + min-height: var(--bar-height); padding: 0 var(--space-lg); + padding-bottom: max(0px, env(safe-area-inset-bottom)); background: var(--color-bg-elevated); border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; @@ -13,7 +14,7 @@ display: flex; flex-direction: column; align-items: center; - gap: 2px; + gap: 3px; padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); transition: color var(--duration-fast) var(--ease-smooth), @@ -21,18 +22,19 @@ color: var(--color-muted); } -.bar-btn:active { +.bar-btn:active:not(:disabled) { transform: scale(0.92); } -.bar-btn--disabled { - opacity: 0.4; - pointer-events: none; +.bar-btn:disabled:not(.bar-btn--active) { + opacity: 0.35; + cursor: default; } -.bar-btn--disabled.bar-btn--active { +.bar-btn--disabled.bar-btn--active, +.bar-btn:disabled.bar-btn--active { opacity: 1; - pointer-events: none; + cursor: default; } /* Active states */ @@ -47,12 +49,8 @@ } .bar-btn--comments { - color: var(--color-muted); -} - -.bar-btn--comments:not(.bar-btn--disabled) { - opacity: 1; - pointer-events: auto; + color: var(--color-text); + opacity: 0.9; } .bar-icon { @@ -63,7 +61,7 @@ .bar-count { font-family: var(--font-mono); font-size: 11px; - font-weight: 500; + font-weight: 600; line-height: 1; } diff --git a/frontend/src/components/BottomBar/BottomBar.jsx b/frontend/src/components/BottomBar/BottomBar.jsx index 038d749..31ead75 100644 --- a/frontend/src/components/BottomBar/BottomBar.jsx +++ b/frontend/src/components/BottomBar/BottomBar.jsx @@ -1,51 +1,54 @@ import React, { useState } from 'react'; import { useApp } from '../../context/AppContext'; +import { hapticImpact } from '../../services/auth'; import './BottomBar.css'; export default function BottomBar() { const { currentCard, chosenCard, likeCard, dislikeCard, setIsCommentsOpen, user } = useApp(); - const [reactionState, setReactionState] = useState(null); // 'liked' | 'disliked' | null + const [isSubmitting, setIsSubmitting] = useState(false); const isRevealed = chosenCard !== null; // Check if user already reacted to this card const alreadyLiked = user?.liked_card_ids?.includes(currentCard?.card_id); const alreadyDisliked = user?.disliked_card_ids?.includes(currentCard?.card_id); - const currentReaction = reactionState || (alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null); + const currentReaction = alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null; const handleLike = async () => { - if (!isRevealed || currentReaction) return; - const result = await likeCard(); - if (result && !result.error) { - setReactionState('liked'); + if (!isRevealed || currentReaction || isSubmitting) return; + setIsSubmitting(true); + try { + await likeCard(); + } finally { + setIsSubmitting(false); } }; const handleDislike = async () => { - if (!isRevealed || currentReaction) return; - const result = await dislikeCard(); - if (result && !result.error) { - setReactionState('disliked'); + if (!isRevealed || currentReaction || isSubmitting) return; + setIsSubmitting(true); + try { + await dislikeCard(); + } finally { + setIsSubmitting(false); } }; const handleComments = () => { + hapticImpact('light'); setIsCommentsOpen(true); }; - // Reset reaction state when card changes - React.useEffect(() => { - setReactionState(null); - }, [currentCard?.card_id]); - - const likes = (currentCard?.count_likes || 0) + (reactionState === 'liked' ? 1 : 0); - const dislikes = (currentCard?.count_dislikes || 0) + (reactionState === 'disliked' ? 1 : 0); + const likes = currentCard?.count_likes || 0; + const dislikes = currentCard?.count_dislikes || 0; + const commentsCount = currentCard?.comments?.length || 0; return (
- + + -
@@ -34,7 +84,7 @@ export default function CardPair() { let percentB = 50; if (chosenCard) { - // Add the current user's vote to the count for display + // Add current user's choice for visual distribution const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0); const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0); const newTotal = votesA + votesB; @@ -43,16 +93,22 @@ export default function CardPair() { percentB = 100 - percentA; } - // Clamp to 75/25 max for readability - if (percentA > 75) { percentA = 75; percentB = 25; } - if (percentB > 75) { percentB = 75; percentA = 25; } + // Clamp between 25% and 75% for readable card size balance + if (percentA > 75) { + percentA = 75; + percentB = 25; + } + if (percentB > 75) { + percentB = 75; + percentA = 25; + } } - // flex-grow values for animation + // flex-grow values for smooth spring animation const growA = chosenCard ? percentA : 50; const growB = chosenCard ? percentB : 50; - // Actual percentages for display (unclamped) + // Actual display percentages let displayPercentA = 50; let displayPercentB = 50; if (chosenCard && total >= 0) { diff --git a/frontend/src/components/Comments/CommentItem.css b/frontend/src/components/Comments/CommentItem.css index 47a5cca..2ac381d 100644 --- a/frontend/src/components/Comments/CommentItem.css +++ b/frontend/src/components/Comments/CommentItem.css @@ -2,18 +2,26 @@ display: flex; gap: var(--space-md); padding: var(--space-md) 0; + transition: background var(--duration-fast) var(--ease-smooth); } .comment-item + .comment-item { border-top: 1px solid rgba(255, 255, 255, 0.06); } +.comment-item--me { + background: rgba(124, 58, 237, 0.04); + border-radius: var(--radius-sm); + padding: var(--space-sm) var(--space-xs); +} + .comment-avatar { flex-shrink: 0; - width: 40px; - height: 40px; + width: 38px; + height: 38px; border-radius: 50%; overflow: hidden; + border: 1px solid var(--glass-border); } .comment-avatar-img { @@ -28,13 +36,17 @@ justify-content: center; width: 100%; height: 100%; - background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + background: linear-gradient(135deg, var(--color-card-a-from), var(--color-card-b-from)); font-family: var(--font-display); - font-size: 16px; + font-size: 15px; font-weight: 700; color: var(--color-text); } +.comment-avatar-fallback--me { + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); +} + .comment-body { flex: 1; min-width: 0; @@ -42,20 +54,43 @@ .comment-header { display: flex; - align-items: baseline; + align-items: center; + justify-content: space-between; gap: var(--space-sm); margin-bottom: 4px; } +.comment-author-group { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .comment-author { font-weight: 600; font-size: 14px; color: var(--color-stat-a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.comment-me-badge { + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 4px; + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + color: #FFFFFF; + text-transform: uppercase; + letter-spacing: 0.5px; } .comment-date { - font-size: 12px; + font-size: 11px; color: var(--color-muted); + flex-shrink: 0; } .comment-text { diff --git a/frontend/src/components/Comments/CommentItem.jsx b/frontend/src/components/Comments/CommentItem.jsx index 77cd028..c7579a2 100644 --- a/frontend/src/components/Comments/CommentItem.jsx +++ b/frontend/src/components/Comments/CommentItem.jsx @@ -1,31 +1,57 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { currentUser } from '../../services/auth'; import './CommentItem.css'; export default function CommentItem({ comment, author }) { - const displayName = author?.username || author?.first_name || 'Аноним'; - const date = comment.creation_date + const [imageError, setImageError] = useState(false); + const isMe = comment.author_id === currentUser?.id; + + const displayName = isMe + ? (currentUser?.username ? `@${currentUser.username}` : currentUser?.first_name || 'Вы') + : (author?.username ? `@${author.username}` : author?.first_name || `Игрок #${comment.author_id}`); + + const photoUrl = isMe ? currentUser?.photo_url : author?.photo_url; + + useEffect(() => { + setImageError(false); + }, [photoUrl]); + + const dateStr = comment.creation_date ? new Date(comment.creation_date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', - year: 'numeric', + hour: '2-digit', + minute: '2-digit', }) : ''; + const initial = displayName.replace(/^@/, '')[0]?.toUpperCase() || '?'; + return ( -
+
- {author?.photo_url ? ( - + {photoUrl && !imageError ? ( + setImageError(true)} + /> ) : ( - - {displayName[0]?.toUpperCase() || '?'} + + {initial} )}
+
- {displayName} - {date} +
+ {displayName} + {isMe && Вы} +
+ {dateStr}

{comment.comment_text || comment.commet_text}

diff --git a/frontend/src/components/Comments/CommentsPanel.css b/frontend/src/components/Comments/CommentsPanel.css index c8f5c61..4cdaaa0 100644 --- a/frontend/src/components/Comments/CommentsPanel.css +++ b/frontend/src/components/Comments/CommentsPanel.css @@ -7,6 +7,8 @@ background: var(--color-bg); transform: translateY(100%); transition: transform var(--duration-normal) var(--ease-smooth); + padding-top: max(0px, env(safe-area-inset-top)); + padding-bottom: max(0px, env(safe-area-inset-bottom)); } .comments-panel--open { @@ -40,6 +42,15 @@ font-weight: 600; } +.comments-count { + font-size: 12px; + font-weight: 700; + color: var(--color-muted); + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px; + border-radius: var(--radius-full); +} + /* List */ .comments-list { flex: 1; @@ -47,8 +58,31 @@ padding: 0 var(--space-lg); } -.comments-placeholder { +.comments-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; padding: var(--space-xl); + gap: var(--space-md); +} + +.comments-spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--color-card-a-to); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.comments-placeholder { text-align: center; color: var(--color-muted); font-size: 14px; @@ -60,9 +94,15 @@ align-items: center; justify-content: center; height: 100%; + min-height: 200px; gap: var(--space-sm); } +.comments-empty-icon { + font-size: 32px; + margin-bottom: var(--space-xs); +} + .comments-empty-text { font-family: var(--font-display); font-size: 16px; @@ -89,15 +129,20 @@ .comments-input { flex: 1; resize: none; - padding: var(--space-sm) var(--space-md); + padding: 10px var(--space-md); background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); color: var(--color-text); font-size: 14px; line-height: 1.4; - min-height: 40px; + min-height: 42px; max-height: 100px; + transition: border-color var(--duration-fast) var(--ease-smooth); +} + +.comments-input:focus { + border-color: var(--color-card-a-to); } .comments-input::placeholder { @@ -108,8 +153,8 @@ display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 42px; + height: 42px; border-radius: 50%; background: var(--glass-bg); color: var(--color-muted); @@ -118,10 +163,21 @@ } .comments-send--active { - background: var(--color-like); - color: var(--color-text); + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + color: #FFFFFF; + box-shadow: 0 0 12px rgba(124, 58, 237, 0.3); } .comments-send:disabled { cursor: default; + opacity: 0.6; +} + +.comments-btn-spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: #FFFFFF; + border-radius: 50%; + animation: spin 0.8s linear infinite; } diff --git a/frontend/src/components/Comments/CommentsPanel.jsx b/frontend/src/components/Comments/CommentsPanel.jsx index fb24621..428efe2 100644 --- a/frontend/src/components/Comments/CommentsPanel.jsx +++ b/frontend/src/components/Comments/CommentsPanel.jsx @@ -1,14 +1,23 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useApp } from '../../context/AppContext'; -import { showBackButton } from '../../services/auth'; +import { showBackButton, currentUser, hapticNotification, hapticImpact } from '../../services/auth'; import { api } from '../../services/api'; -import { currentUser } from '../../services/auth'; import CommentItem from './CommentItem'; import './CommentsPanel.css'; export default function CommentsPanel() { - const { isCommentsOpen, setIsCommentsOpen, currentCard, showToast } = useApp(); + const { + isCommentsOpen, + setIsCommentsOpen, + currentCard, + showToast, + getUserProfile, + handleApiResponse, + syncCardComments, + addCommentToCard, + } = useApp(); const [comments, setComments] = useState([]); + const [authors, setAuthors] = useState({}); const [isLoading, setIsLoading] = useState(false); const [newComment, setNewComment] = useState(''); const [isSending, setIsSending] = useState(false); @@ -17,59 +26,100 @@ export default function CommentsPanel() { // Telegram BackButton useEffect(() => { if (isCommentsOpen) { - const cleanup = showBackButton(() => setIsCommentsOpen(false)); + const cleanup = showBackButton(() => { + hapticImpact('light'); + setIsCommentsOpen(false); + }); return cleanup; } }, [isCommentsOpen, setIsCommentsOpen]); - // Load comments when panel opens - useEffect(() => { - if (isCommentsOpen && currentCard) { - loadComments(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isCommentsOpen, currentCard?.card_id]); + const cardId = currentCard?.card_id; - async function loadComments() { + const loadComments = useCallback(async () => { + if (!cardId) return; setIsLoading(true); + try { - // TODO: Replace with real GET /get_comments when backend implements it - const result = await api.getComments(currentCard.card_id); - if (!result.error) { - setComments(result.result || []); + const result = await api.getComments(cardId); + handleApiResponse(result); + + if (!result.error && Array.isArray(result.result)) { + const loadedComments = result.result; + setComments(loadedComments); + syncCardComments(cardId, loadedComments.map((c) => c.comment_id)); + + // Fetch author profiles for all unique authors + const uniqueAuthorIds = Array.from(new Set(loadedComments.map((c) => c.author_id))); + const authorsData = {}; + + await Promise.all( + uniqueAuthorIds.map(async (authorId) => { + const profile = await getUserProfile(authorId); + if (profile) { + authorsData[authorId] = profile; + } + }) + ); + + setAuthors(authorsData); + } else { + setComments([]); } } catch (err) { console.error('Load comments error:', err); } finally { setIsLoading(false); } - } + }, [cardId, getUserProfile, handleApiResponse, syncCardComments]); + + // Load comments only when panel opens or active card ID changes + useEffect(() => { + if (isCommentsOpen && cardId) { + loadComments(); + } + }, [isCommentsOpen, cardId, loadComments]); async function handleSend() { - if (!newComment.trim() || isSending) return; + const text = newComment.trim(); + if (!text || isSending || !currentCard) return; + setIsSending(true); + hapticImpact('light'); + try { - const result = await api.addComment(currentUser.id, currentCard.card_id, newComment.trim()); - if (!result.error) { + const result = await api.addComment(currentUser.id, currentCard.card_id, text); + handleApiResponse(result); + + if (!result.error && result.result) { setNewComment(''); - showToast('Комментарий отправлен'); - - // Optimistically add the new comment to the list - if (result.result) { - setComments(prev => [...prev, result.result]); - - // Scroll to bottom after adding - setTimeout(() => { - if (listRef.current) { - listRef.current.scrollTop = listRef.current.scrollHeight; - } - }, 100); - } + showToast('Комментарий опубликован'); + hapticNotification('success'); + + const createdComment = result.result; + setComments((prev) => [...prev, createdComment]); + addCommentToCard(currentCard.card_id, createdComment.comment_id); + + // Add current user to authors map + setAuthors((prev) => ({ + ...prev, + [currentUser.id]: currentUser, + })); + + // Scroll to bottom + setTimeout(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, 100); } else { - showToast('Ошибка: ' + (result.result || 'неизвестная')); + showToast(typeof result.result === 'string' ? result.result : 'Ошибка отправки комментария'); + hapticNotification('error'); } } catch (err) { console.error('Send comment error:', err); + showToast('Ошибка отправки'); + hapticNotification('error'); } finally { setIsSending(false); } @@ -82,30 +132,44 @@ export default function CommentsPanel() { } } + const handleClose = () => { + hapticImpact('light'); + setIsCommentsOpen(false); + }; + return (

Комментарии

+ {comments.length}
{isLoading ? ( -

Загрузка...

+
+
+

Загрузка комментариев...

+
) : comments.length === 0 ? (
+
💬

Комментариев пока нет

-

Будь первым!

+

Будь первым, кто поделится мнением!

) : ( comments.map((comment, i) => ( - + )) )}
@@ -113,22 +177,27 @@ export default function CommentsPanel() {