Merge pull request #4 from IgorVolochay/frontend
Merge frontend and prebuild
This commit was merged in pull request #4.
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
DISABLE_DOCS=true
|
||||
|
||||
MONGO_HOST=127.0.0.1
|
||||
MONGO_PORT=27017
|
||||
MONGO_USER=user
|
||||
MONGO_PASS=pass
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
.env
|
||||
tests/
|
||||
security.log
|
||||
.git/
|
||||
.github/
|
||||
*.md
|
||||
dockerfile.app
|
||||
dockerfile.bot
|
||||
.dockerignore
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# ── Stage 1: Install dependencies ────────────────────────────
|
||||
FROM python:3.12.4-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# ── Stage 2: Production image ────────────────────────────────
|
||||
FROM python:3.12.4-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application code (respects .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd --gid 1000 appuser && \
|
||||
useradd --uid 1000 --gid appuser --shell /bin/sh appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
# Healthcheck: verify RabbitMQ connection is possible
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python3 -c "import socket; s=socket.create_connection(('${RABBIT_HOST:-rabbitmq}', int('${RABBIT_PORT:-5672}')), timeout=3); s.close()" || exit 1
|
||||
|
||||
CMD ["python3", "tg_bot.py"]
|
||||
+110
@@ -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
|
||||
+286
-141
@@ -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())
|
||||
@@ -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
|
||||
+292
-168
@@ -1,22 +1,35 @@
|
||||
import os
|
||||
|
||||
import pymongo
|
||||
import motor.motor_asyncio
|
||||
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
from typing import Optional
|
||||
from pymongo import ReturnDocument
|
||||
|
||||
from schemas.base_schemas import *
|
||||
from schemas.api_schemas import *
|
||||
from schemas.base_schemas import User, Visited, Card, Comment
|
||||
from schemas.api_schemas import BaseResponse
|
||||
from logger import logger
|
||||
|
||||
|
||||
class MongoWorker:
|
||||
"""Worker class for handling all MongoDB database operations."""
|
||||
def __init__(self):
|
||||
"""Initializes the MongoDB connection and sets up collection references."""
|
||||
load_dotenv()
|
||||
self.client = pymongo.MongoClient(host = os.getenv('MONGO_HOST'),
|
||||
port = int(os.getenv('MONGO_PORT')),
|
||||
username = os.getenv('MONGO_USER'),
|
||||
password = os.getenv('MONGO_PASS'))
|
||||
self.client = motor.motor_asyncio.AsyncIOMotorClient(
|
||||
host=os.getenv('MONGO_HOST'),
|
||||
port=int(os.getenv('MONGO_PORT', 27017)),
|
||||
username=os.getenv('MONGO_USER'),
|
||||
password=os.getenv('MONGO_PASS'),
|
||||
serverSelectionTimeoutMS=5000,
|
||||
connectTimeoutMS=5000,
|
||||
maxPoolSize=50,
|
||||
minPoolSize=5,
|
||||
maxIdleTimeMS=60000,
|
||||
waitQueueTimeoutMS=5000
|
||||
)
|
||||
logger.info("MongoDB connection established.")
|
||||
self.db = self.client["data"]
|
||||
self.users_data = self.db["users"]
|
||||
self.visited_data = self.db["visited"]
|
||||
@@ -24,191 +37,302 @@ class MongoWorker:
|
||||
self.game_data = self.db["cards"]
|
||||
self.comments_data = self.db["comments"]
|
||||
|
||||
async def create_indexes(self) -> None:
|
||||
"""Creates indexes on application startup."""
|
||||
await self.users_data.create_index("user_id", unique=True)
|
||||
await self.game_data.create_index("card_id", unique=True)
|
||||
await self.game_data.create_index("active_status")
|
||||
await self.visited_data.create_index("user_id", unique=True)
|
||||
await self.comments_data.create_index("comment_id", unique=True)
|
||||
logger.info("MongoDB indexes created.")
|
||||
|
||||
def check_user(self, user_id: int) -> bool:
|
||||
if self.users_data.find_one({"user_id": user_id}):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User:
|
||||
new_user = User(user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
photo_url=photo_url,
|
||||
registration_date=datetime.now().isoformat())
|
||||
try:
|
||||
self.users_data.insert_one(new_user.model_dump())
|
||||
return new_user
|
||||
except Exception as exception:
|
||||
return new_user
|
||||
async def check_user(self, user_id: int) -> bool:
|
||||
"""Checks if a user exists in the database by their user_id."""
|
||||
document = await self.users_data.find_one({"user_id": user_id}, {"_id": 1})
|
||||
return document is not None
|
||||
|
||||
def get_user(self, user_id: int) -> User:
|
||||
return User.model_validate(self.users_data.find_one({"user_id": user_id}))
|
||||
|
||||
async def add_user(
|
||||
self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User:
|
||||
"""Creates a new user record in the database."""
|
||||
new_user = User(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
photo_url=photo_url,
|
||||
registration_date=datetime.now().isoformat(),
|
||||
)
|
||||
await self.users_data.insert_one(new_user.model_dump())
|
||||
logger.debug("User added: user_id={}, username={}", user_id, username)
|
||||
return new_user
|
||||
|
||||
def get_and_update_counter(self, counter_name: str) -> int:
|
||||
counter = self.counters.find_one_and_update(
|
||||
async def get_user(self, user_id: int) -> User:
|
||||
"""Retrieves a user's details from the database."""
|
||||
document = await self.users_data.find_one({"user_id": user_id})
|
||||
return User.model_validate(document)
|
||||
|
||||
|
||||
async def get_and_update_counter(self, counter_name: str) -> int:
|
||||
"""Atomically increments the counter and returns the new value."""
|
||||
counter = await self.counters.find_one_and_update(
|
||||
{"counter_name": counter_name},
|
||||
{"$inc": {"counter": 1}},
|
||||
upsert=True,
|
||||
return_document=True)
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return counter["counter"]
|
||||
|
||||
|
||||
def get_visited_cards(self, user_id: int) -> BaseResponse:
|
||||
document = self.visited_data.find_one({"user_id": user_id})
|
||||
|
||||
|
||||
async def get_visited_cards(self, user_id: int) -> BaseResponse:
|
||||
"""Retrieves the set of card IDs that a user has already visited."""
|
||||
document = await self.visited_data.find_one({"user_id": user_id})
|
||||
if not document:
|
||||
check_user = self.check_user(user_id)
|
||||
if check_user:
|
||||
return BaseResponse(result=Visited(user_id=user_id,
|
||||
cards_visited=set()))
|
||||
else:
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
else:
|
||||
return BaseResponse(result=Visited.model_validate(document))
|
||||
|
||||
def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]:
|
||||
filtered_cards = [card for card in random_cards if card.card_id not in cards_visited]
|
||||
filtered_cards_id = [filtered_card.card_id for filtered_card in filtered_cards]
|
||||
|
||||
return filtered_cards, filtered_cards_id
|
||||
|
||||
def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited:
|
||||
update_visited = self.visited_data.find_one_and_update({"user_id": user_id},
|
||||
{"$addToSet": {"cards_visited": visited_card_id}},
|
||||
upsert=True,
|
||||
return_document=True)
|
||||
return Visited.model_validate(update_visited)
|
||||
if await self.check_user(user_id):
|
||||
return BaseResponse(result=Visited(user_id=user_id, cards_visited=set()))
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
return BaseResponse(result=Visited.model_validate(document))
|
||||
|
||||
async def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited:
|
||||
"""Adds a specific card ID to the user's set of visited cards."""
|
||||
updated = await self.visited_data.find_one_and_update(
|
||||
{"user_id": user_id},
|
||||
{"$addToSet": {"cards_visited": visited_card_id}},
|
||||
upsert=True,
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return Visited.model_validate(updated)
|
||||
|
||||
async def try_mark_visited(self, user_id: int, card_id: int) -> bool:
|
||||
"""
|
||||
Atomically marks a card as visited for the user.
|
||||
|
||||
Returns True if the card was newly marked (was not visited before).
|
||||
Returns False if the card was already in the visited set.
|
||||
|
||||
Uses a conditional update filter (cards_visited: {$ne: card_id}) so that
|
||||
only one concurrent request can "win" the mark — eliminating the TOCTOU
|
||||
race condition between checking and writing.
|
||||
"""
|
||||
result = await self.visited_data.update_one(
|
||||
{"user_id": user_id, "cards_visited": {"$ne": card_id}},
|
||||
{"$addToSet": {"cards_visited": card_id}},
|
||||
)
|
||||
if result.modified_count == 1:
|
||||
return True
|
||||
|
||||
# No document matched: either the visited doc doesn't exist yet,
|
||||
# or the card is already in the set.
|
||||
doc = await self.visited_data.find_one({"user_id": user_id}, {"cards_visited": 1})
|
||||
if doc is None:
|
||||
# First vote ever for this user — create the visited document.
|
||||
await self.visited_data.update_one(
|
||||
{"user_id": user_id},
|
||||
{"$addToSet": {"cards_visited": card_id}},
|
||||
upsert=True,
|
||||
)
|
||||
return True
|
||||
|
||||
# Card is already present in the visited set.
|
||||
return False
|
||||
|
||||
|
||||
def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card:
|
||||
new_card = Card(card_id=self.get_and_update_counter(counter_name="card"),
|
||||
choice_A=choice_A,
|
||||
choice_B=choice_B,
|
||||
author_id=author_id,
|
||||
creation_date=datetime.now().isoformat())
|
||||
try:
|
||||
self.game_data.insert_one(new_card.model_dump())
|
||||
return new_card
|
||||
except Exception as exception:
|
||||
print(exception)
|
||||
return new_card
|
||||
|
||||
def add_card_by_base_model(self, new_card: Card) -> Optional[Card]:
|
||||
new_card.card_id = self.get_and_update_counter(counter_name="card")
|
||||
try:
|
||||
self.game_data.insert_one(new_card.model_dump())
|
||||
return new_card
|
||||
except Exception as exception:
|
||||
print(exception)
|
||||
return new_card
|
||||
|
||||
def get_card(self, card_id: int) -> Optional[Card]:
|
||||
document = self.game_data.find_one({"card_id": card_id})
|
||||
async def get_card(self, card_id: int) -> Optional[Card]:
|
||||
"""Retrieves a card's details from the database by its card_id."""
|
||||
document = await self.game_data.find_one({"card_id": card_id})
|
||||
if document:
|
||||
return Card.model_validate(document)
|
||||
else:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_random_cards(self, amount: int, active_status: bool) -> Optional[list[Card]]:
|
||||
pipeline = [{"$match": {"active_status": active_status}},
|
||||
{"$sample": {"size": amount}}]
|
||||
raw_items = list(self.game_data.aggregate(pipeline))
|
||||
async def get_random_cards(self, amount: int,active_status: bool,exclude_ids: Optional[set[int]] = None,) -> Optional[list[Card]]:
|
||||
"""Returns random cards, excluding already visited ones (in a single query)."""
|
||||
match_filter: dict = {"active_status": active_status}
|
||||
if exclude_ids:
|
||||
match_filter["card_id"] = {"$nin": list(exclude_ids)}
|
||||
|
||||
pipeline = [
|
||||
{"$match": match_filter},
|
||||
{"$sample": {"size": amount}},
|
||||
]
|
||||
raw_items = await self.game_data.aggregate(pipeline).to_list(length=amount)
|
||||
|
||||
if raw_items:
|
||||
validated_items = [Card.model_validate(item) for item in raw_items]
|
||||
return validated_items
|
||||
else:
|
||||
return None
|
||||
|
||||
return [Card.model_validate(item) for item in raw_items]
|
||||
return None
|
||||
|
||||
def select_choice(self, card_id: int, choice: str) -> BaseResponse:
|
||||
def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]:
|
||||
"""Filters a list of random cards to exclude those already visited by the user."""
|
||||
filtered_cards = [card for card in random_cards if card.card_id not in cards_visited]
|
||||
filtered_cards_id = [card.card_id for card in filtered_cards]
|
||||
return filtered_cards, filtered_cards_id
|
||||
|
||||
async def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card:
|
||||
"""Creates a new card in the database with data received from the API."""
|
||||
new_card = Card(
|
||||
card_id=await self.get_and_update_counter(counter_name="card"),
|
||||
choice_A=choice_A,
|
||||
choice_B=choice_B,
|
||||
author_id=author_id,
|
||||
creation_date=datetime.now().isoformat(),
|
||||
)
|
||||
await self.game_data.insert_one(new_card.model_dump())
|
||||
logger.debug("Card created by API: card_id={}, author_id={}", new_card.card_id, author_id)
|
||||
return new_card
|
||||
|
||||
async def add_card_by_base_model(self, new_card: Card) -> Optional[Card]:
|
||||
"""Inserts a Card model directly into the database."""
|
||||
new_card.card_id = await self.get_and_update_counter(counter_name="card")
|
||||
try:
|
||||
await self.game_data.insert_one(new_card.model_dump())
|
||||
return new_card
|
||||
except Exception as exc:
|
||||
logger.error("Failed to insert card: {}", exc)
|
||||
raise
|
||||
|
||||
async def accept_card(self, card_id: int) -> BaseResponse:
|
||||
"""Accepts a card: sets active_status=True and moderation_date=now."""
|
||||
result = await self.game_data.find_one_and_update(
|
||||
{"card_id": card_id},
|
||||
{"$set": {
|
||||
"active_status": True,
|
||||
"moderation_date": datetime.now().isoformat(),
|
||||
}},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
if not result:
|
||||
logger.debug("Attempted to accept non-existent card: card_id={}", card_id)
|
||||
return BaseResponse(result="Card doesn't exist", error=True)
|
||||
logger.debug("Card accepted: card_id={}", card_id)
|
||||
return BaseResponse(result=Card.model_validate(result))
|
||||
|
||||
async def reject_card(self, card_id: int) -> BaseResponse:
|
||||
"""Rejects a card: deletes it from the database."""
|
||||
result = await self.game_data.delete_one({"card_id": card_id})
|
||||
if result.deleted_count == 0:
|
||||
logger.debug("Attempted to reject non-existent card: card_id={}", card_id)
|
||||
return BaseResponse(result="Card doesn't exist", error=True)
|
||||
logger.debug("Card rejected and deleted: card_id={}", card_id)
|
||||
return BaseResponse(result=f"Card {card_id} rejected and deleted")
|
||||
|
||||
async def select_choice(self, card_id: int, choice: str) -> BaseResponse:
|
||||
"""Increments the vote count for the selected choice (A or B) and total votes on a card."""
|
||||
if choice == "A":
|
||||
count_choice = "count_choice_A"
|
||||
count_field = "count_choice_A"
|
||||
elif choice == "B":
|
||||
count_choice = "count_choice_B"
|
||||
count_field = "count_choice_B"
|
||||
else:
|
||||
return BaseResponse(result="Wrong choice", error=True)
|
||||
|
||||
result = self.game_data.find_one_and_update({"card_id": card_id},
|
||||
{"$inc": {"count_total": 1, count_choice: 1}})
|
||||
|
||||
|
||||
result = await self.game_data.find_one_and_update(
|
||||
{"card_id": card_id},
|
||||
{"$inc": {"count_total": 1, count_field: 1}},
|
||||
)
|
||||
if not result:
|
||||
return BaseResponse(result="Card doesn't exist", error=True)
|
||||
else:
|
||||
return BaseResponse(result=result, error=False)
|
||||
|
||||
def check_user_reactions(self, user_id: int, card_id: int) -> BaseResponse:
|
||||
user_info: User = self.get_user(user_id)
|
||||
liked_card_ids: list = user_info.liked_card_ids
|
||||
disliked_card_ids: list = user_info.disliked_card_ids
|
||||
return BaseResponse(result=True, error=False)
|
||||
|
||||
if card_id in liked_card_ids:
|
||||
return BaseResponse(result="Card already liked", error=True)
|
||||
elif card_id in disliked_card_ids:
|
||||
return BaseResponse(result="Card already disliked", error=True)
|
||||
else:
|
||||
return BaseResponse(result="No reactions", error=False)
|
||||
|
||||
def like_card(self, card_id: int, user_id: int) -> BaseResponse:
|
||||
if self.check_user(user_id):
|
||||
user_reaction = self.check_user_reactions(user_id, card_id)
|
||||
if user_reaction.error:
|
||||
return user_reaction
|
||||
update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
|
||||
{"$inc": {"count_likes": 1}})
|
||||
if not update_card_info:
|
||||
return BaseResponse(result="Card doesn't exist", error=True)
|
||||
|
||||
add_card_to_user = self.users_data.update_one({'user_id': user_id},
|
||||
{'$push': {'liked_card_ids': card_id}})
|
||||
if not add_card_to_user:
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
else:
|
||||
return BaseResponse(result=True, error=False)
|
||||
else:
|
||||
async def like_card(self, card_id: int, user_id: int) -> BaseResponse:
|
||||
"""Atomically adds a like to a card and records the user's like action."""
|
||||
if not await self.check_user(user_id):
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
|
||||
def dislike_card(self, card_id: int, user_id: int) -> BaseResponse:
|
||||
if self.check_user(user_id):
|
||||
user_reaction = self.check_user_reactions(user_id, card_id)
|
||||
if user_reaction.error:
|
||||
return user_reaction
|
||||
update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
|
||||
{"$inc": {"count_dislikes": 1}})
|
||||
if not update_card_info:
|
||||
return BaseResponse(result="Card doesn't exist", error=True)
|
||||
|
||||
add_card_to_user = self.users_data.update_one({'user_id': user_id},
|
||||
{'$push': {'disliked_card_ids': card_id}})
|
||||
if not add_card_to_user:
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
else:
|
||||
return BaseResponse(result=True, error=False)
|
||||
else:
|
||||
|
||||
# Atomically add card_id to liked_card_ids ONLY IF it is not already
|
||||
# present in liked_card_ids OR disliked_card_ids.
|
||||
# Using a conditional filter makes this a single, race-condition-free
|
||||
# test-and-set: if modified_count == 0, another request already won.
|
||||
user_update = await self.users_data.find_one_and_update(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"liked_card_ids": {"$ne": card_id},
|
||||
"disliked_card_ids": {"$ne": card_id},
|
||||
},
|
||||
{"$addToSet": {"liked_card_ids": card_id}},
|
||||
projection={"_id": 1},
|
||||
)
|
||||
if not user_update:
|
||||
return BaseResponse(result="Card already liked or disliked", error=True)
|
||||
|
||||
updated_card = await self.game_data.find_one_and_update(
|
||||
{"card_id": card_id},
|
||||
{"$inc": {"count_likes": 1}},
|
||||
)
|
||||
if not updated_card:
|
||||
# Card doesn't exist — roll back the user update (best effort).
|
||||
await self.users_data.update_one(
|
||||
{"user_id": user_id},
|
||||
{"$pull": {"liked_card_ids": card_id}},
|
||||
)
|
||||
return BaseResponse(result="Card doesn't exist", error=True)
|
||||
|
||||
logger.debug("Card liked: card_id={}, user_id={}", card_id, user_id)
|
||||
return BaseResponse(result=True, error=False)
|
||||
|
||||
async def dislike_card(self, card_id: int, user_id: int) -> BaseResponse:
|
||||
"""Atomically adds a dislike to a card and records the user's dislike action."""
|
||||
if not await self.check_user(user_id):
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
|
||||
def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse:
|
||||
if self.check_user(user_id):
|
||||
if self.get_card(card_id):
|
||||
new_comment = Comment(comment_id=self.get_and_update_counter(counter_name="comment"),
|
||||
author_id=user_id,
|
||||
card_id=card_id,
|
||||
comment_text=comment_text,
|
||||
creation_date=datetime.now().isoformat())
|
||||
result = self.comments_data.insert_one(new_comment.model_dump())
|
||||
if result:
|
||||
update_user_comments = self.users_data.find_one_and_update({"user_id": user_id},
|
||||
{"$addToSet": {"comments_ids": new_comment.comment_id}})
|
||||
if update_user_comments:
|
||||
return BaseResponse(result=new_comment)
|
||||
else:
|
||||
return BaseResponse(result="Difficulty adding comment_id to user", error=True)
|
||||
else:
|
||||
return BaseResponse(result="Add comment error", error=True)
|
||||
else:
|
||||
return BaseResponse(result="Card doesn't exist", error=True)
|
||||
else:
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
uvicorn==0.34.0
|
||||
aio-pika==10.0.1
|
||||
aiogram==3.18.0
|
||||
aiohttp==3.11.18
|
||||
loguru==0.7.3
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+39
-29
@@ -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
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
test_security.py — tests for checking rate limiting and penetration detection.
|
||||
|
||||
Rate limiting settings from main.py:
|
||||
- Global: 10 requests / 3 sec (middleware)
|
||||
- /add_user: 3 requests / 60 sec (decorator)
|
||||
- /add_card: 3 requests / 60 sec (decorator)
|
||||
- /get_random_cards: 5 requests / 60 sec (decorator)
|
||||
- /comment: 5 requests / 20 sec (decorator)
|
||||
|
||||
Penetration detection:
|
||||
- enable_penetration_detection=True
|
||||
- auto_ban_threshold=3 (ban after 3 suspicious requests)
|
||||
- auto_ban_duration=3600 (ban for 1 hour)
|
||||
"""
|
||||
|
||||
import random
|
||||
import asyncio
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
# Client IP and port
|
||||
CLIENT = ("7.214.201.94", 50000)
|
||||
|
||||
# ========================================================================
|
||||
# RATE LIMIT TESTS
|
||||
# ========================================================================
|
||||
|
||||
|
||||
class TestGlobalRateLimit:
|
||||
"""Tests for global rate limit: 10 requests / 3 seconds."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_global_rate_limit_allows_under_threshold(self):
|
||||
"""Requests within the limit (<=10) should pass."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
for i in range(9):
|
||||
resp = await client.get("/check_user", params={"user_id": 1})
|
||||
assert resp.status_code == 200, (
|
||||
f"Request {i+1}/9 returned {resp.status_code}, expected 200: {resp.text}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_global_rate_limit_blocks_over_threshold(self):
|
||||
"""
|
||||
After exceeding global limit (10 requests/3s) -> 429.
|
||||
/check_user does not have a rate_limit decorator, so only global limit applies.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
# Send 10 requests (fill the limit)
|
||||
for i in range(10):
|
||||
await client.get("/check_user", params={"user_id": 1})
|
||||
|
||||
# 11th request should return 429
|
||||
resp = await client.get("/check_user", params={"user_id": 1})
|
||||
assert resp.status_code == 429, (
|
||||
f"Expected 429 after exceeding global limit, got {resp.status_code}"
|
||||
)
|
||||
assert "Too many requests" in resp.text
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_global_rate_limit_response_format(self):
|
||||
"""Verify response format on rate limit."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
# Exhaust limit
|
||||
for _ in range(10):
|
||||
await client.get("/check_user", params={"user_id": 1})
|
||||
|
||||
resp = await client.get("/check_user", params={"user_id": 1})
|
||||
assert resp.status_code == 429
|
||||
assert resp.text == "Too many requests"
|
||||
|
||||
|
||||
class TestDecoratorRateLimit:
|
||||
"""Tests for rate limit via @guard_deco.rate_limit() decorator."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_user_rate_limit(self):
|
||||
"""
|
||||
/add_user: limit 3 requests / 60 sec.
|
||||
First 3 requests pass (422 due to invalid data is OK, main point is not 429).
|
||||
4th request -> 429.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
data = {
|
||||
"user_id": random.randint(100000000, 999999999),
|
||||
"username": "RateTest",
|
||||
"first_name": "F",
|
||||
"last_name": "L",
|
||||
"photo_url": "http://test.test/photo.jpg"
|
||||
}
|
||||
|
||||
# First 3 requests — not 429
|
||||
for i in range(3):
|
||||
resp = await client.post("/add_user", json=data)
|
||||
assert resp.status_code != 429, (
|
||||
f"Request {i+1}/3 returned 429, limit should not be exceeded yet"
|
||||
)
|
||||
|
||||
# 4th request -> 429
|
||||
resp = await client.post("/add_user", json=data)
|
||||
assert resp.status_code == 429, (
|
||||
f"Expected 429 after 3 requests to /add_user, got {resp.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_card_rate_limit(self):
|
||||
"""
|
||||
/add_card: limit 3 requests / 60 sec.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
author_id = random.randint(100000000, 999999999)
|
||||
for i in range(3):
|
||||
payload = {
|
||||
"choice_A": f"Rate A {i}",
|
||||
"choice_B": f"Rate B {i}",
|
||||
"author_id": author_id
|
||||
}
|
||||
resp = await client.post("/add_card", json=payload)
|
||||
assert resp.status_code != 429, (
|
||||
f"Request {i+1}/3 to /add_card returned 429 prematurely"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"choice_A": "Rate A overflow",
|
||||
"choice_B": "Rate B overflow",
|
||||
"author_id": author_id
|
||||
}
|
||||
resp = await client.post("/add_card", json=payload)
|
||||
assert resp.status_code == 429, (
|
||||
f"Expected 429 after 3 requests to /add_card, got {resp.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_get_random_cards_rate_limit(self):
|
||||
"""
|
||||
/get_random_cards: limit 5 requests / 60 sec.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
user_id = random.randint(100000000, 999999999)
|
||||
|
||||
for i in range(5):
|
||||
resp = await client.get("/get_random_cards", params={"user_id": user_id})
|
||||
# Can be 200 or 404 (if no cards/user), but not 429
|
||||
assert resp.status_code != 429, (
|
||||
f"Request {i+1}/5 to /get_random_cards returned 429 prematurely"
|
||||
)
|
||||
|
||||
resp = await client.get("/get_random_cards", params={"user_id": user_id})
|
||||
assert resp.status_code == 429, (
|
||||
f"Expected 429 after 5 requests to /get_random_cards, got {resp.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_comment_rate_limit(self):
|
||||
"""
|
||||
/comment: limit 5 requests / 20 sec.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
for i in range(5):
|
||||
payload = {
|
||||
"author_id": random.randint(100000000, 999999999),
|
||||
"card_id": 1,
|
||||
"comment_text": f"Rate test comment {i}"
|
||||
}
|
||||
resp = await client.post("/comment", json=payload)
|
||||
# Can be 201, 400 (moderation), 404 (card/user not found) — but not 429
|
||||
assert resp.status_code != 429, (
|
||||
f"Request {i+1}/5 to /comment returned 429 prematurely"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"author_id": random.randint(100000000, 999999999),
|
||||
"card_id": 1,
|
||||
"comment_text": "Overflow comment"
|
||||
}
|
||||
resp = await client.post("/comment", json=payload)
|
||||
assert resp.status_code == 429, (
|
||||
f"Expected 429 after 5 requests to /comment, got {resp.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_different_endpoints_have_independent_limits(self):
|
||||
"""
|
||||
Decorator rate limit is tracked separately for each endpoint.
|
||||
Requests to /check_user should not affect /add_card limit.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
# 5 requests to /check_user (no decorator, but global limit 10/3s)
|
||||
for _ in range(5):
|
||||
await client.get("/check_user", params={"user_id": 1})
|
||||
|
||||
# First request to /add_card — should pass (its own separate limit)
|
||||
payload = {
|
||||
"choice_A": "IndepA",
|
||||
"choice_B": "IndepB",
|
||||
"author_id": random.randint(100000000, 999999999)
|
||||
}
|
||||
resp = await client.post("/add_card", json=payload)
|
||||
assert resp.status_code != 429, (
|
||||
f"Request to /add_card blocked after requests to /check_user: {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
class TestRateLimitParallel:
|
||||
"""Rate limit tests with parallel requests."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_parallel_requests_hit_rate_limit(self):
|
||||
"""
|
||||
Multiple parallel requests should lead to 429 for some of them.
|
||||
Send 15 parallel requests with global limit of 10/3s.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
tasks = [
|
||||
client.get("/check_user", params={"user_id": 1})
|
||||
for _ in range(15)
|
||||
]
|
||||
responses = await asyncio.gather(*tasks)
|
||||
|
||||
statuses = [r.status_code for r in responses]
|
||||
count_200 = statuses.count(200)
|
||||
count_429 = statuses.count(429)
|
||||
|
||||
print(f"\nParallel requests: 200={count_200}, 429={count_429}")
|
||||
assert count_429 > 0, (
|
||||
f"No request received 429 during 15 parallel requests: {statuses}"
|
||||
)
|
||||
assert count_200 > 0, (
|
||||
f"All requests were blocked, none passed: {statuses}"
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# PENETRATION DETECTION TESTS
|
||||
# ========================================================================
|
||||
|
||||
|
||||
class TestPenetrationDetection:
|
||||
"""
|
||||
Tests for malicious request detection.
|
||||
enable_penetration_detection=True
|
||||
auto_ban_threshold=3
|
||||
auto_ban_duration=3600
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_sql_injection_detected(self):
|
||||
"""SQL injection in query parameters should be detected."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/get_card",
|
||||
params={"card_id": "1 OR 1=1; DROP TABLE users;--"}
|
||||
)
|
||||
print(f"\nSQL injection test: status={resp.status_code} | text={resp.text[:200]}")
|
||||
# Expect: 400 (suspicious activity) or 422 (validation) — but NOT 200
|
||||
assert resp.status_code in (400, 403, 422), (
|
||||
f"SQL injection was not blocked, got {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_xss_in_query_params_detected(self):
|
||||
"""XSS attack in query parameters should be detected."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/get_card",
|
||||
params={"card_id": "<script>alert('XSS')</script>"}
|
||||
)
|
||||
print(f"\nXSS in params test: status={resp.status_code} | text={resp.text[:200]}")
|
||||
assert resp.status_code in (400, 403, 422), (
|
||||
f"XSS attack was not detected, got {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_path_traversal_detected(self):
|
||||
"""Path traversal attack should be detected."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
resp = await client.get("/get_card/../../../etc/passwd")
|
||||
print(f"\nPath traversal test: status={resp.status_code} | text={resp.text[:200]}")
|
||||
# Can be 400, 403, 404, or 422 — but MUST NOT expose file contents
|
||||
assert resp.status_code != 200 or "root:" not in resp.text, (
|
||||
"Path traversal not detected — system file accessed!"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_xss_in_post_body_detected(self):
|
||||
"""XSS attack in POST body should be detected."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
payload = {
|
||||
"choice_A": "<script>document.cookie</script>",
|
||||
"choice_B": "Normal option",
|
||||
"author_id": random.randint(100000000, 999999999)
|
||||
}
|
||||
resp = await client.post("/add_card", json=payload)
|
||||
print(f"\nXSS in body test: status={resp.status_code} | text={resp.text[:200]}")
|
||||
# 400 (suspicious), 403 (banned), or 422 — but not 201
|
||||
assert resp.status_code in (400, 403, 422), (
|
||||
f"XSS in request body was not detected, got {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_command_injection_detected(self):
|
||||
"""Command injection attempt should be detected."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
payload = {
|
||||
"choice_A": "; cat /etc/passwd; echo",
|
||||
"choice_B": "$(whoami)",
|
||||
"author_id": random.randint(100000000, 999999999)
|
||||
}
|
||||
resp = await client.post("/add_card", json=payload)
|
||||
print(f"\nCommand injection test: status={resp.status_code} | text={resp.text[:200]}")
|
||||
# 400 (suspicious), 403 (banned) — not 201
|
||||
assert resp.status_code in (400, 403, 422), (
|
||||
f"Command injection was not detected, got {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_sql_union_injection_detected(self):
|
||||
"""UNION-based SQL injection should be detected."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/get_card",
|
||||
params={"card_id": "1 UNION SELECT password FROM users"}
|
||||
)
|
||||
print(f"\nUNION SQL injection test: status={resp.status_code} | text={resp.text[:200]}")
|
||||
assert resp.status_code in (400, 403, 422), (
|
||||
f"UNION SQL injection was not blocked, got {resp.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_legitimate_request_not_blocked(self):
|
||||
"""Legitimate request with normal data should not be blocked as suspicious."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
resp = await client.get("/get_card", params={"card_id": 1})
|
||||
print(f"\nLegitimate request test: status={resp.status_code}")
|
||||
# 200 (card found) or 404 (not found) — but not 400/403
|
||||
assert resp.status_code in (200, 404), (
|
||||
f"Legitimate request blocked: {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
class TestAutoIPBan:
|
||||
"""
|
||||
Tests for automatic IP banning after repeated suspicious requests.
|
||||
auto_ban_threshold=3, auto_ban_duration=3600
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_repeated_attacks_trigger_ip_ban(self):
|
||||
"""
|
||||
After auto_ban_threshold (3) suspicious requests, the IP should be banned.
|
||||
Subsequent requests (even legitimate ones) should return 403.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
# Send suspicious requests sequentially (SQL injection variants)
|
||||
injection_payloads = [
|
||||
"1' OR '1'='1",
|
||||
"1; DROP TABLE cards;--",
|
||||
"1 UNION SELECT * FROM users;--",
|
||||
"1' AND 1=CONVERT(int,(SELECT TOP 1 name FROM sysobjects));--",
|
||||
]
|
||||
detected_as_suspicious = 0
|
||||
for payload in injection_payloads:
|
||||
resp = await client.get("/get_card", params={"card_id": payload})
|
||||
if resp.status_code in (400, 403):
|
||||
detected_as_suspicious += 1
|
||||
print(f" Attack attempt: status={resp.status_code} | payload={payload[:50]}")
|
||||
|
||||
print(f"\nSuspicious requests detected: {detected_as_suspicious}/{len(injection_payloads)}")
|
||||
|
||||
if detected_as_suspicious >= 3:
|
||||
# Threshold reached — verify ban on legitimate request
|
||||
resp = await client.get("/check_user", params={"user_id": 1})
|
||||
print(f"Post-attack legitimate request: status={resp.status_code}")
|
||||
assert resp.status_code == 403, (
|
||||
f"IP should be banned after {detected_as_suspicious} suspicious requests, "
|
||||
f"but legitimate request returned {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
else:
|
||||
pytest.skip(
|
||||
f"Only {detected_as_suspicious} of {len(injection_payloads)} attacks detected, "
|
||||
f"ban threshold (3) not reached"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_banned_ip_returns_403_on_all_endpoints(self):
|
||||
"""
|
||||
If IP is banned, all endpoints should return 403.
|
||||
"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
# Send various attacks to guarantee hitting the threshold
|
||||
attacks = [
|
||||
"1' OR '1'='1; --",
|
||||
"1; DROP TABLE cards; --",
|
||||
"<script>alert(1)</script>",
|
||||
"../../etc/shadow",
|
||||
"1 UNION SELECT password FROM users",
|
||||
]
|
||||
detected_count = 0
|
||||
for payload in attacks:
|
||||
resp = await client.get("/get_card", params={"card_id": payload})
|
||||
if resp.status_code in (400, 403):
|
||||
detected_count += 1
|
||||
print(f" [{payload[:40]}] status={resp.status_code}")
|
||||
|
||||
print(f"\nDetected: {detected_count}/{len(attacks)}")
|
||||
|
||||
if detected_count >= 3:
|
||||
# Check ban on different endpoints
|
||||
endpoints = [
|
||||
("GET", "/check_user", {"user_id": 99999}),
|
||||
("GET", "/get_user", {"user_id": 99999}),
|
||||
("GET", "/get_card", {"card_id": 1}),
|
||||
]
|
||||
for method, path, params in endpoints:
|
||||
resp = await client.get(path, params=params)
|
||||
assert resp.status_code == 403, (
|
||||
f"IP is banned, but {method} {path} returned {resp.status_code}"
|
||||
)
|
||||
else:
|
||||
pytest.skip(
|
||||
f"Only {detected_count} attacks detected, ban threshold (3) not reached"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_banned_ip_message(self):
|
||||
"""Banned IP should receive 'IP address banned' message."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
attacks = [
|
||||
"1' OR '1'='1; --",
|
||||
"1; DROP TABLE cards; --",
|
||||
"1 UNION SELECT password FROM users",
|
||||
"<script>alert(1)</script>",
|
||||
]
|
||||
detected = 0
|
||||
for payload in attacks:
|
||||
resp = await client.get("/get_card", params={"card_id": payload})
|
||||
if resp.status_code in (400, 403):
|
||||
detected += 1
|
||||
|
||||
if detected >= 3:
|
||||
resp = await client.get("/check_user", params={"user_id": 1})
|
||||
assert resp.status_code == 403
|
||||
assert "IP address banned" in resp.text, (
|
||||
f"Expected message 'IP address banned', got: {resp.text[:200]}"
|
||||
)
|
||||
else:
|
||||
pytest.skip(f"Only {detected} attacks detected, threshold not reached")
|
||||
|
||||
|
||||
class TestSuspiciousHeaders:
|
||||
"""Tests for suspicious header detection."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_suspicious_user_agent(self):
|
||||
"""Request with suspicious User-Agent may be blocked."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/check_user",
|
||||
params={"user_id": 1},
|
||||
headers={"User-Agent": "sqlmap/1.6.12#stable (http://sqlmap.org)"}
|
||||
)
|
||||
print(f"\nSuspicious UA test: status={resp.status_code}")
|
||||
# sqlmap is a known SQL injection tool
|
||||
# Expect block (403) or pass (200 — if UA is not in blocklist)
|
||||
assert resp.status_code in (200, 400, 403), (
|
||||
f"Unexpected status code for suspicious User-Agent: {resp.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_xss_in_headers(self):
|
||||
"""XSS attack via custom headers."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/check_user",
|
||||
params={"user_id": 1},
|
||||
headers={"X-Forwarded-For": "<script>alert(1)</script>"}
|
||||
)
|
||||
print(f"\nXSS in headers test: status={resp.status_code}")
|
||||
# Header may be ignored or detected as suspicious
|
||||
assert resp.status_code in (200, 400, 403), (
|
||||
f"Unexpected status code for XSS in headers: {resp.status_code}"
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Tests for Telegram initData HMAC-SHA256 validation (tg_auth module).
|
||||
|
||||
These tests directly exercise the ``validate_init_data`` function with
|
||||
synthetic initData, covering happy-path and all failure modes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tg_auth import validate_init_data
|
||||
|
||||
import os
|
||||
BOT_TOKEN = os.getenv("TG_BOT_TOKEN", "test:mock_token_for_testing_12345")
|
||||
|
||||
|
||||
def _build_init_data(
|
||||
bot_token: str,
|
||||
user: dict,
|
||||
auth_date: int | None = None,
|
||||
tamper_hash: bool = False,
|
||||
omit_hash: bool = False,
|
||||
omit_user: bool = False,
|
||||
) -> str:
|
||||
"""Helper that constructs a valid (or intentionally broken) initData string."""
|
||||
if auth_date is None:
|
||||
auth_date = int(time.time())
|
||||
|
||||
params: dict[str, str] = {
|
||||
"auth_date": str(auth_date),
|
||||
}
|
||||
if not omit_user:
|
||||
params["user"] = json.dumps(user)
|
||||
|
||||
# Build data-check-string (sorted, \n-separated).
|
||||
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items()))
|
||||
|
||||
# secret_key = HMAC-SHA256("WebAppData", bot_token)
|
||||
secret_key = hmac.new(
|
||||
key=b"WebAppData",
|
||||
msg=bot_token.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
computed_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if tamper_hash:
|
||||
computed_hash = "a" * 64 # obviously wrong
|
||||
|
||||
if not omit_hash:
|
||||
params["hash"] = computed_hash
|
||||
|
||||
return urlencode(params)
|
||||
|
||||
|
||||
VALID_USER = {
|
||||
"id": 123456789,
|
||||
"first_name": "Igor",
|
||||
"last_name": "Volochay",
|
||||
"username": "IgorVolochay",
|
||||
"photo_url": "https://t.me/photo.jpg",
|
||||
}
|
||||
|
||||
|
||||
# ── Happy path ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_valid_init_data():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER)
|
||||
result = validate_init_data(raw, BOT_TOKEN)
|
||||
assert result["user_id"] == 123456789
|
||||
assert result["username"] == "IgorVolochay"
|
||||
assert result["first_name"] == "Igor"
|
||||
assert result["last_name"] == "Volochay"
|
||||
assert result["photo_url"] == "https://t.me/photo.jpg"
|
||||
|
||||
|
||||
# ── Failure modes ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_init_data():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data("", BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_missing_hash():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_hash=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "hash" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_tampered_hash():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, tamper_hash=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "signature" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_expired_auth_date():
|
||||
old_date = int(time.time()) - 7200 # 2 hours ago
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=old_date)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN, max_age=3600)
|
||||
assert exc.value.status_code == 403
|
||||
assert "expired" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_missing_user():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_user=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "user" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_missing_user_id():
|
||||
user_no_id = {"first_name": "Igor", "username": "test"}
|
||||
raw = _build_init_data(BOT_TOKEN, user_no_id)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, BOT_TOKEN)
|
||||
assert exc.value.status_code == 403
|
||||
assert "user.id" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_wrong_bot_token():
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_init_data(raw, "wrong:token")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_fresh_auth_date_passes():
|
||||
"""auth_date exactly 5 seconds ago should be fine with default max_age."""
|
||||
recent = int(time.time()) - 5
|
||||
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=recent)
|
||||
result = validate_init_data(raw, BOT_TOKEN)
|
||||
assert result["user_id"] == 123456789
|
||||
+16
-16
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Telegram Mini App initData authentication module.
|
||||
|
||||
Validates initData from the Telegram WebApp using HMAC-SHA256
|
||||
per the official specification:
|
||||
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
|
||||
In DEV_MODE (default) authentication is skipped — user_id is taken
|
||||
from the request body / query parameters as-is.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from logger import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEV_MODE: bool = os.getenv("DEV_MODE", "false").lower() == "true"
|
||||
TG_BOT_TOKEN: str = os.getenv("TG_BOT_TOKEN", "")
|
||||
|
||||
# Maximum allowed age of initData in seconds (1 hour).
|
||||
INIT_DATA_MAX_AGE: int = int(os.getenv("INIT_DATA_MAX_AGE", "3600"))
|
||||
|
||||
|
||||
def validate_init_data(
|
||||
init_data_raw: str,
|
||||
bot_token: str,
|
||||
max_age: int = INIT_DATA_MAX_AGE,
|
||||
) -> dict:
|
||||
"""
|
||||
Validates Telegram Mini App initData and returns the parsed ``user`` dict.
|
||||
|
||||
Algorithm (per Telegram docs):
|
||||
1. Parse the query-string into key→value pairs.
|
||||
2. Extract the ``hash`` value; build ``data-check-string`` from the
|
||||
remaining fields sorted by key, joined with ``\\n``.
|
||||
3. ``secret_key = HMAC-SHA256(bot_token, "WebAppData")``
|
||||
4. ``computed = HMAC-SHA256(data_check_string, secret_key)``
|
||||
5. Compare ``computed`` with ``hash`` using constant-time comparison.
|
||||
6. Optionally verify ``auth_date`` freshness.
|
||||
|
||||
Returns a dict with keys: user_id, username, first_name, last_name, photo_url.
|
||||
|
||||
Raises ``HTTPException(403)`` on any validation failure.
|
||||
"""
|
||||
if not init_data_raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing initData",
|
||||
)
|
||||
|
||||
parsed = parse_qs(init_data_raw, keep_blank_values=True)
|
||||
|
||||
# parse_qs returns lists — flatten to single values.
|
||||
flat: dict[str, str] = {k: v[0] for k, v in parsed.items()}
|
||||
|
||||
received_hash = flat.pop("hash", None)
|
||||
if not received_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing hash in initData",
|
||||
)
|
||||
|
||||
# Build data-check-string: sorted key=value pairs joined by \n.
|
||||
data_check_string = "\n".join(
|
||||
f"{k}={v}" for k, v in sorted(flat.items())
|
||||
)
|
||||
|
||||
# secret_key = HMAC-SHA256("WebAppData", bot_token)
|
||||
secret_key = hmac.new(
|
||||
key=b"WebAppData",
|
||||
msg=bot_token.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
computed_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed_hash, received_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid initData signature",
|
||||
)
|
||||
|
||||
# Verify auth_date freshness.
|
||||
auth_date_str = flat.get("auth_date")
|
||||
if auth_date_str:
|
||||
try:
|
||||
auth_date = int(auth_date_str)
|
||||
if time.time() - auth_date > max_age:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="initData expired",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid auth_date",
|
||||
)
|
||||
|
||||
# Extract user data.
|
||||
user_raw = flat.get("user")
|
||||
if not user_raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing user in initData",
|
||||
)
|
||||
|
||||
try:
|
||||
user = json.loads(user_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid user JSON in initData",
|
||||
)
|
||||
|
||||
user_id = user.get("id")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing user.id in initData",
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": int(user_id),
|
||||
"username": user.get("username", ""),
|
||||
"first_name": user.get("first_name", ""),
|
||||
"last_name": user.get("last_name", ""),
|
||||
"photo_url": user.get("photo_url", ""),
|
||||
}
|
||||
|
||||
|
||||
async def get_current_user_id(request: Request) -> Optional[int]:
|
||||
"""
|
||||
FastAPI dependency that resolves the authenticated user_id.
|
||||
|
||||
- **DEV_MODE=true**: returns ``None`` — endpoints use user_id from
|
||||
body/params as before (backward compatible).
|
||||
- **DEV_MODE=false**: reads ``X-Init-Data`` header, validates it
|
||||
via HMAC-SHA256, and returns the verified ``user_id``.
|
||||
"""
|
||||
if DEV_MODE:
|
||||
return None
|
||||
|
||||
init_data_raw = request.headers.get("X-Init-Data", "")
|
||||
if not init_data_raw:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="X-Init-Data header is required",
|
||||
)
|
||||
|
||||
if not TG_BOT_TOKEN:
|
||||
logger.error("TG_BOT_TOKEN is not set but DEV_MODE is disabled")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Server authentication misconfiguration",
|
||||
)
|
||||
|
||||
user_data = validate_init_data(init_data_raw, TG_BOT_TOKEN)
|
||||
return user_data["user_id"]
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Telegram card moderation bot.
|
||||
|
||||
Listens to the RabbitMQ "moderation" queue and sends cards
|
||||
to the admin chat with inline buttons "Accept ✅" / "Reject ❌".
|
||||
|
||||
When a button is pressed, the bot calls protected endpoints
|
||||
/card_accept or /card_reject with a secret header.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
from dotenv import load_dotenv
|
||||
from aiogram import Bot, Dispatcher, F
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
|
||||
from schemas.base_schemas import Card
|
||||
from rabbit_worker import RabbitWorker
|
||||
from logger import logger, setup_logging
|
||||
|
||||
|
||||
load_dotenv()
|
||||
setup_logging()
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
BOT_TOKEN = os.getenv("TG_BOT_TOKEN") or ""
|
||||
ADMIN_CHAT_ID = int(os.getenv("TG_ADMIN_CHAT_ID", "0"))
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5000")
|
||||
MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production")
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
dp = Dispatcher()
|
||||
rabbit = RabbitWorker()
|
||||
|
||||
|
||||
# ── Sending card to admin ──────────────────────────
|
||||
async def _get_author_username(author_id: int) -> str:
|
||||
"""Fetches the author's username via API."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{API_BASE_URL}/get_user", params={"user_id": author_id}
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
username = data.get("result", {}).get("username", "")
|
||||
if username:
|
||||
return f"@{username}"
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch username for {}: {}", author_id, exc)
|
||||
return str(author_id)
|
||||
|
||||
|
||||
def _format_date(iso_date: str) -> str:
|
||||
"""Converts ISO date to DD.MM.YYYY HH:MM:SS format."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_date)
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return iso_date
|
||||
|
||||
|
||||
async def send_card_to_admin(card: Card) -> None:
|
||||
"""Formats message and inline keyboard for a card."""
|
||||
author_display = await _get_author_username(card.author_id)
|
||||
date_display = _format_date(card.creation_date)
|
||||
|
||||
text = (
|
||||
f"🆕 <b>Новая карточка #{card.card_id}</b>\n\n"
|
||||
f"🅰️ {card.choice_A}\n"
|
||||
f"🅱️ {card.choice_B}\n\n"
|
||||
f"👤 Автор: {author_display}\n"
|
||||
f"📅 Создана: {date_display}"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="Принять ✅",
|
||||
callback_data=f"accept:{card.card_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="Отклонить ❌",
|
||||
callback_data=f"reject:{card.card_id}",
|
||||
),
|
||||
]
|
||||
]
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=ADMIN_CHAT_ID,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info("Sent card {} to admin chat", card.card_id)
|
||||
|
||||
|
||||
# ── Calling protected API endpoints ──────────────────────────
|
||||
async def call_moderation_api(action: str, card_id: int) -> dict:
|
||||
"""
|
||||
Calls /card_accept or /card_reject with secret header.
|
||||
action: 'accept' | 'reject'
|
||||
"""
|
||||
endpoint = f"{API_BASE_URL}/card_{action}"
|
||||
headers = {"X-Moderation-Secret": MODERATION_SECRET}
|
||||
params = {"card_id": card_id}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.patch(endpoint, headers=headers, params=params) as resp:
|
||||
data = await resp.json()
|
||||
return data
|
||||
|
||||
|
||||
# ── Callback button handlers ───────────────────────────────
|
||||
@dp.callback_query(F.data.startswith("accept:"))
|
||||
async def on_accept(callback: CallbackQuery) -> None:
|
||||
"""Handles the 'Accept' inline button click for a card."""
|
||||
if not callback.data or not isinstance(callback.message, Message):
|
||||
return
|
||||
|
||||
card_id = int(callback.data.split(":")[1])
|
||||
result = await call_moderation_api("accept", card_id)
|
||||
|
||||
if result.get("error"):
|
||||
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
|
||||
return
|
||||
|
||||
orig_text = callback.message.text or ""
|
||||
await callback.message.edit_text(
|
||||
orig_text + "\n\n✅ <b>ПРИНЯТА</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("Карточка принята!")
|
||||
logger.info("Card {} accepted by admin", card_id)
|
||||
|
||||
|
||||
@dp.callback_query(F.data.startswith("reject:"))
|
||||
async def on_reject(callback: CallbackQuery) -> None:
|
||||
"""Handles the 'Reject' inline button click for a card."""
|
||||
if not callback.data or not isinstance(callback.message, Message):
|
||||
return
|
||||
|
||||
card_id = int(callback.data.split(":")[1])
|
||||
result = await call_moderation_api("reject", card_id)
|
||||
|
||||
if result.get("error"):
|
||||
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
|
||||
return
|
||||
|
||||
orig_text = callback.message.text or ""
|
||||
await callback.message.edit_text(
|
||||
orig_text + "\n\n❌ <b>ОТКЛОНЕНА</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("Карточка отклонена!")
|
||||
logger.info("Card {} rejected by admin", card_id)
|
||||
|
||||
# ── aiogram Lifecycle hooks ────────────────────────────────────
|
||||
_rabbit_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@dp.startup()
|
||||
async def on_startup() -> None:
|
||||
"""Starts the RabbitMQ consumer task when the bot starts."""
|
||||
global _rabbit_task
|
||||
_rabbit_task = asyncio.create_task(
|
||||
rabbit.consume_moderation(send_card_to_admin)
|
||||
)
|
||||
logger.info("Moderation bot started, RabbitMQ consumer running")
|
||||
|
||||
|
||||
@dp.shutdown()
|
||||
async def on_shutdown() -> None:
|
||||
"""Cancels the RabbitMQ consumer task when the bot shuts down."""
|
||||
if _rabbit_task:
|
||||
_rabbit_task.cancel()
|
||||
try:
|
||||
await _rabbit_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Moderation bot stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
dp.run_polling(bot)
|
||||
@@ -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.")
|
||||
|
||||
+90
-9
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070711" />
|
||||
<meta name="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+6
-1
@@ -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 <BannedScreen onRetry={handleRetryAfterBan} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
|
||||
@@ -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(<App />);
|
||||
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(<App />);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="bottom-bar">
|
||||
<button
|
||||
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
|
||||
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
|
||||
onClick={handleDislike}
|
||||
disabled={!isRevealed || !!currentReaction || isSubmitting}
|
||||
aria-label="Дизлайк"
|
||||
>
|
||||
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}>
|
||||
@@ -62,12 +65,13 @@ export default function BottomBar() {
|
||||
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z" />
|
||||
</svg>
|
||||
<span className="bar-count">{formatCount(currentCard?.comments?.length || 0)}</span>
|
||||
<span className="bar-count">{formatCount(commentsCount)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
|
||||
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
|
||||
onClick={handleLike}
|
||||
disabled={!isRevealed || !!currentReaction || isSubmitting}
|
||||
aria-label="Лайк"
|
||||
>
|
||||
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -31,52 +31,93 @@
|
||||
.card-pair--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-width: 340px;
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
animation: hintFadeIn 600ms var(--ease-smooth);
|
||||
}
|
||||
|
||||
.empty-badge-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-pair-empty-text {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-pair-empty-sub {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
padding: 12px var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.empty-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.empty-btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.empty-btn--refresh {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(2, 132, 199, 0.2));
|
||||
border-color: rgba(124, 58, 237, 0.4);
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-btn--primary {
|
||||
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.empty-btn--primary:active {
|
||||
transform: scale(0.97);
|
||||
.refresh-icon {
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.refresh-icon--spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,74 @@ import React from 'react';
|
||||
import Card from './Card';
|
||||
import OrBadge from './OrBadge';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { openExternalLink, hapticImpact } from '../../services/auth';
|
||||
import './CardPair.css';
|
||||
|
||||
export default function CardPair() {
|
||||
const { currentCard, chosenCard, chooseCard, openMenu, setMenuScreen } = useApp();
|
||||
const { currentCard, chosenCard, chooseCard, loadCards, isLoadingCards, openMenu, setMenuScreen } = useApp();
|
||||
|
||||
if (!currentCard) {
|
||||
return (
|
||||
<div className="card-pair card-pair--empty">
|
||||
<div className="empty-content">
|
||||
<p className="card-pair-empty-text">Карточки закончились!</p>
|
||||
<div className="empty-badge-icon">✨</div>
|
||||
<h2 className="card-pair-empty-text">Карточки закончились!</h2>
|
||||
<p className="card-pair-empty-sub">
|
||||
Вы посмотрели все доступные карточки. Новые карточки появляются после прохождения модерации.
|
||||
</p>
|
||||
|
||||
<div className="empty-actions">
|
||||
<button className="empty-btn empty-btn--primary" onClick={() => { openMenu(); setMenuScreen('create'); }}>
|
||||
Создать карточку
|
||||
<button
|
||||
className="empty-btn empty-btn--refresh"
|
||||
onClick={() => loadCards(true)}
|
||||
disabled={isLoadingCards}
|
||||
>
|
||||
<svg
|
||||
className={`refresh-icon ${isLoadingCards ? 'refresh-icon--spinning' : ''}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
|
||||
</svg>
|
||||
<span>{isLoadingCards ? 'Проверяем...' : 'Проверить новые карточки'}</span>
|
||||
</button>
|
||||
<button className="empty-btn" onClick={() => { openMenu(); setMenuScreen('about'); }}>
|
||||
|
||||
<button
|
||||
className="empty-btn empty-btn--primary"
|
||||
onClick={() => {
|
||||
openMenu();
|
||||
setMenuScreen('create');
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span>Создать карточку</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="empty-btn"
|
||||
onClick={() => {
|
||||
openMenu();
|
||||
setMenuScreen('about');
|
||||
}}
|
||||
>
|
||||
О проекте
|
||||
</button>
|
||||
<button className="empty-btn" onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}>
|
||||
|
||||
<button
|
||||
className="empty-btn empty-btn--donate"
|
||||
onClick={() => {
|
||||
hapticImpact('light');
|
||||
openExternalLink('https://boosty.to/pseudodev/donate');
|
||||
}}
|
||||
>
|
||||
Поддержать проект
|
||||
</button>
|
||||
</div>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="comment-item">
|
||||
<div className={`comment-item ${isMe ? 'comment-item--me' : ''}`}>
|
||||
<div className="comment-avatar">
|
||||
{author?.photo_url ? (
|
||||
<img src={author.photo_url} alt="" className="comment-avatar-img" />
|
||||
{photoUrl && !imageError ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt=""
|
||||
className="comment-avatar-img"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="comment-avatar-fallback">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
<span className={`comment-avatar-fallback ${isMe ? 'comment-avatar-fallback--me' : ''}`}>
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="comment-body">
|
||||
<div className="comment-header">
|
||||
<span className="comment-author">{displayName}</span>
|
||||
<span className="comment-date">{date}</span>
|
||||
<div className="comment-author-group">
|
||||
<span className="comment-author">{displayName}</span>
|
||||
{isMe && <span className="comment-me-badge">Вы</span>}
|
||||
</div>
|
||||
<span className="comment-date">{dateStr}</span>
|
||||
</div>
|
||||
<p className="comment-text">{comment.comment_text || comment.commet_text}</p>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}>
|
||||
<div className="comments-header">
|
||||
<button
|
||||
className="comments-close"
|
||||
onClick={() => setIsCommentsOpen(false)}
|
||||
onClick={handleClose}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<h2 className="comments-title">Комментарии</h2>
|
||||
<span className="comments-count">{comments.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="comments-list custom-scroll" ref={listRef}>
|
||||
{isLoading ? (
|
||||
<p className="comments-placeholder">Загрузка...</p>
|
||||
<div className="comments-loading">
|
||||
<div className="comments-spinner" />
|
||||
<p className="comments-placeholder">Загрузка комментариев...</p>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="comments-empty">
|
||||
<div className="comments-empty-icon">💬</div>
|
||||
<p className="comments-empty-text">Комментариев пока нет</p>
|
||||
<p className="comments-empty-sub">Будь первым!</p>
|
||||
<p className="comments-empty-sub">Будь первым, кто поделится мнением!</p>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment, i) => (
|
||||
<CommentItem key={comment.comment_id || i} comment={comment} author={null} />
|
||||
<CommentItem
|
||||
key={comment.comment_id || i}
|
||||
comment={comment}
|
||||
author={authors[comment.author_id] || null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -113,22 +177,27 @@ export default function CommentsPanel() {
|
||||
<div className="comments-input-area">
|
||||
<textarea
|
||||
className="comments-input"
|
||||
placeholder="Ваш комментарий..."
|
||||
placeholder="Напишите комментарий..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={300}
|
||||
rows={1}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<button
|
||||
className={`comments-send ${newComment.trim() ? 'comments-send--active' : ''}`}
|
||||
className={`comments-send ${newComment.trim() && !isSending ? 'comments-send--active' : ''}`}
|
||||
onClick={handleSend}
|
||||
disabled={!newComment.trim() || isSending}
|
||||
aria-label="Отправить"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
{isSending ? (
|
||||
<span className="comments-btn-spinner" />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import React from 'react';
|
||||
import { hapticImpact, hapticNotification, openExternalLink } from '../../services/auth';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import './AboutPage.css';
|
||||
|
||||
export default function AboutPage({ onBack }) {
|
||||
const { showToast } = useApp();
|
||||
|
||||
function handleCopyEmail() {
|
||||
navigator.clipboard.writeText('pseudo.developer.ru@gmail.com').then(() => {
|
||||
// Visual feedback handled by CSS :active
|
||||
}).catch(() => {
|
||||
// Fallback — select text
|
||||
});
|
||||
hapticImpact('light');
|
||||
navigator.clipboard
|
||||
.writeText('pseudo.developer.ru@gmail.com')
|
||||
.then(() => {
|
||||
hapticNotification('success');
|
||||
showToast('Email скопирован в буфер обмена');
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('pseudo.developer.ru@gmail.com');
|
||||
});
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
hapticImpact('light');
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleOpenGithub = (e) => {
|
||||
e.preventDefault();
|
||||
hapticImpact('light');
|
||||
openExternalLink('https://github.com/IgorVolochay/thisORthat');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="about-page custom-scroll">
|
||||
<button className="about-back" onClick={onBack} aria-label="Назад">
|
||||
<button className="about-back" onClick={handleBack} aria-label="Назад">
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
@@ -35,17 +55,12 @@ export default function AboutPage({ onBack }) {
|
||||
</div>
|
||||
|
||||
<div className="about-links">
|
||||
<a
|
||||
href="https://github.com/IgorVolochay/thisORthat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="about-link"
|
||||
>
|
||||
<button className="about-link" onClick={handleOpenGithub}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
<span>Исходники проекта</span>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<button className="about-link" onClick={handleCopyEmail}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="20" height="20">
|
||||
|
||||
@@ -23,17 +23,24 @@
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.create-rules-box {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.create-rules {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--space-lg);
|
||||
line-height: 1.5;
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.create-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
@@ -41,7 +48,8 @@
|
||||
position: relative;
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-lg);
|
||||
min-height: 100px;
|
||||
min-height: 110px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.create-field--a {
|
||||
@@ -65,7 +73,7 @@
|
||||
}
|
||||
|
||||
.create-textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.create-counter {
|
||||
@@ -74,15 +82,15 @@
|
||||
right: var(--space-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.create-submit {
|
||||
width: 100%;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
padding: 14px var(--space-lg);
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-muted);
|
||||
background: var(--glass-bg);
|
||||
@@ -91,15 +99,17 @@
|
||||
}
|
||||
|
||||
.create-submit--active {
|
||||
color: var(--color-text);
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
.create-submit:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.create-submit--active:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { currentUser } from '../../services/auth';
|
||||
import { currentUser, hapticImpact, hapticNotification } from '../../services/auth';
|
||||
import { api } from '../../services/api';
|
||||
import './CreateCard.css';
|
||||
|
||||
const MAX_LENGTH = 150;
|
||||
|
||||
export default function CreateCard({ onBack }) {
|
||||
const { showToast, closeMenu } = useApp();
|
||||
const { showToast, closeMenu, handleApiResponse } = useApp();
|
||||
const [choiceA, setChoiceA] = useState('');
|
||||
const [choiceB, setChoiceB] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
@@ -17,47 +17,60 @@ export default function CreateCard({ onBack }) {
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
setIsSending(true);
|
||||
hapticImpact('light');
|
||||
|
||||
try {
|
||||
const result = await api.addCard(choiceA.trim(), choiceB.trim(), currentUser.id);
|
||||
handleApiResponse(result);
|
||||
|
||||
if (!result.error) {
|
||||
hapticNotification('success');
|
||||
showToast('Карточка отправлена на модерацию!');
|
||||
setTimeout(() => {
|
||||
closeMenu();
|
||||
}, 2000);
|
||||
}, 1800);
|
||||
} else {
|
||||
showToast('Ошибка: ' + (result.result || 'неизвестная'));
|
||||
hapticNotification('error');
|
||||
showToast(typeof result.result === 'string' ? result.result : 'Ошибка модерации или отправки');
|
||||
setIsSending(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Add card error:', err);
|
||||
hapticNotification('error');
|
||||
showToast('Ошибка отправки');
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
hapticImpact('light');
|
||||
onBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-card custom-scroll">
|
||||
<button className="create-back" onClick={onBack} aria-label="Назад">
|
||||
<button className="create-back" onClick={handleBack} aria-label="Назад">
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<h2 className="create-title">Создать карточку</h2>
|
||||
|
||||
<p className="create-rules">
|
||||
При создании карточек запрещается использование мата и ссылок.
|
||||
Все карточки проходят процесс модерации перед публикацией.
|
||||
Лимит по длине текста: {MAX_LENGTH} символов.
|
||||
</p>
|
||||
<div className="create-rules-box">
|
||||
<p className="create-rules">
|
||||
💡 <strong>Правила публикации:</strong> Запрещены нецензурные выражения, оскорбления и спам-ссылки. Все карточки проверяются перед публикацией.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="create-fields">
|
||||
<div className="create-field create-field--a">
|
||||
<textarea
|
||||
className="create-textarea"
|
||||
placeholder="Первый вариант"
|
||||
placeholder="Вариант А"
|
||||
value={choiceA}
|
||||
onChange={(e) => setChoiceA(e.target.value.slice(0, MAX_LENGTH))}
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={3}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<span className="create-counter">
|
||||
{choiceA.length}/{MAX_LENGTH}
|
||||
@@ -67,11 +80,12 @@ export default function CreateCard({ onBack }) {
|
||||
<div className="create-field create-field--b">
|
||||
<textarea
|
||||
className="create-textarea"
|
||||
placeholder="Второй вариант"
|
||||
placeholder="Вариант Б"
|
||||
value={choiceB}
|
||||
onChange={(e) => setChoiceB(e.target.value.slice(0, MAX_LENGTH))}
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={3}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<span className="create-counter">
|
||||
{choiceB.length}/{MAX_LENGTH}
|
||||
@@ -84,7 +98,7 @@ export default function CreateCard({ onBack }) {
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSending ? 'Отправка...' : 'Отправить!'}
|
||||
{isSending ? 'Отправка на модерацию...' : 'Отправить на модерацию'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { showBackButton } from '../../services/auth';
|
||||
import { showBackButton, hapticImpact, openExternalLink } from '../../services/auth';
|
||||
import Overlay from '../common/Overlay';
|
||||
import AboutPage from './AboutPage';
|
||||
import CreateCard from './CreateCard';
|
||||
@@ -12,24 +12,40 @@ export default function MenuPanel() {
|
||||
// Telegram BackButton for sub-screens
|
||||
useEffect(() => {
|
||||
if (isMenuOpen && menuScreen !== 'menu') {
|
||||
const cleanup = showBackButton(() => setMenuScreen('menu'));
|
||||
const cleanup = showBackButton(() => {
|
||||
hapticImpact('light');
|
||||
setMenuScreen('menu');
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
if (isMenuOpen && menuScreen === 'menu') {
|
||||
const cleanup = showBackButton(() => closeMenu());
|
||||
const cleanup = showBackButton(() => {
|
||||
hapticImpact('light');
|
||||
closeMenu();
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [isMenuOpen, menuScreen, setMenuScreen, closeMenu]);
|
||||
|
||||
if (!isMenuOpen) return null;
|
||||
|
||||
const handleNavigate = (screen) => {
|
||||
hapticImpact('light');
|
||||
setMenuScreen(screen);
|
||||
};
|
||||
|
||||
const handleExternalLink = (url) => {
|
||||
hapticImpact('light');
|
||||
openExternalLink(url);
|
||||
};
|
||||
|
||||
// Sub-screens
|
||||
if (menuScreen === 'about') {
|
||||
return (
|
||||
<>
|
||||
<Overlay visible={true} onClick={closeMenu} />
|
||||
<div className="menu-panel menu-panel--open">
|
||||
<AboutPage onBack={() => setMenuScreen('menu')} />
|
||||
<AboutPage onBack={() => handleNavigate('menu')} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -40,7 +56,7 @@ export default function MenuPanel() {
|
||||
<>
|
||||
<Overlay visible={true} onClick={closeMenu} />
|
||||
<div className="menu-panel menu-panel--open">
|
||||
<CreateCard onBack={() => setMenuScreen('menu')} />
|
||||
<CreateCard onBack={() => handleNavigate('menu')} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -51,7 +67,7 @@ export default function MenuPanel() {
|
||||
<Overlay visible={true} onClick={closeMenu} />
|
||||
<div className="menu-panel menu-panel--open">
|
||||
<nav className="menu-list">
|
||||
<button className="menu-item" onClick={() => setMenuScreen('about')}>
|
||||
<button className="menu-item" onClick={() => handleNavigate('about')}>
|
||||
<span className="menu-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||
@@ -61,7 +77,7 @@ export default function MenuPanel() {
|
||||
<span className="menu-label">О проекте</span>
|
||||
</button>
|
||||
|
||||
<button className="menu-item" onClick={() => setMenuScreen('create')}>
|
||||
<button className="menu-item" onClick={() => handleNavigate('create')}>
|
||||
<span className="menu-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||
@@ -73,7 +89,7 @@ export default function MenuPanel() {
|
||||
|
||||
<button
|
||||
className="menu-item"
|
||||
onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}
|
||||
onClick={() => handleExternalLink('https://boosty.to/pseudodev/donate')}
|
||||
>
|
||||
<span className="menu-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
.banned-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
padding: var(--space-lg);
|
||||
z-index: 1000;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.banned-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.15);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
animation: bannedAppear 400ms var(--ease-spring);
|
||||
}
|
||||
|
||||
@keyframes bannedAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.banned-icon-wrapper {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
color: #EF4444;
|
||||
box-shadow: 0 0 24px rgba(239, 68, 68, 0.25);
|
||||
animation: pulseGlow 2.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 32px rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.banned-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.banned-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-sm);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.banned-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.banned-info-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: #CBD5E1;
|
||||
text-align: left;
|
||||
margin-bottom: var(--space-xl);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banned-info-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #EF4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.banned-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banned-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 12px var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.banned-btn--primary {
|
||||
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.banned-btn--primary:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.banned-btn--primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.banned-btn--secondary {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.banned-btn--secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import './BannedScreen.css';
|
||||
|
||||
export default function BannedScreen({ onRetry }) {
|
||||
const handleSupportClick = () => {
|
||||
window.open('https://t.me/IgorVolochay', '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="banned-screen">
|
||||
<div className="banned-card">
|
||||
<div className="banned-icon-wrapper">
|
||||
<svg className="banned-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="banned-title">Доступ ограничен</h1>
|
||||
|
||||
<p className="banned-subtitle">
|
||||
Система безопасности зафиксировала подозрительную активность с вашего IP-адреса.
|
||||
</p>
|
||||
|
||||
<div className="banned-info-box">
|
||||
<div className="banned-info-dot" />
|
||||
<span>Блокировка длится 1 час и снимается автоматически.</span>
|
||||
</div>
|
||||
|
||||
<div className="banned-actions">
|
||||
{onRetry && (
|
||||
<button className="banned-btn banned-btn--primary" onClick={onRetry}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
|
||||
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
|
||||
</svg>
|
||||
<span>Повторить попытку</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="banned-btn banned-btn--secondary" onClick={handleSupportClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
|
||||
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
|
||||
</svg>
|
||||
<span>Написать в поддержку</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { currentUser, initTelegramApp } from '../services/auth';
|
||||
import { currentUser, initTelegramApp, hapticImpact, hapticNotification } from '../services/auth';
|
||||
|
||||
const AppContext = createContext(null);
|
||||
|
||||
@@ -9,11 +9,13 @@ export function AppProvider({ children }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [isBanned, setIsBanned] = useState(false);
|
||||
|
||||
// Card queue
|
||||
const [cardQueue, setCardQueue] = useState([]);
|
||||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||
const [chosenCard, setChosenCard] = useState(null); // null | "A" | "B"
|
||||
const [isLoadingCards, setIsLoadingCards] = useState(false);
|
||||
|
||||
// Panels
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -22,84 +24,152 @@ export function AppProvider({ children }) {
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState(null);
|
||||
const toastTimeoutRef = useRef(null);
|
||||
|
||||
// User Profile Cache for comments
|
||||
const userProfileCacheRef = useRef(new Map());
|
||||
|
||||
// Initialization ref for React StrictMode
|
||||
const isInitializingRef = useRef(false);
|
||||
|
||||
// Current card helper
|
||||
const currentCard = cardQueue[currentCardIndex] || null;
|
||||
|
||||
// Initialize app
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
initTelegramApp();
|
||||
|
||||
// Check/register user
|
||||
const checkResult = await api.checkUser(currentUser.id);
|
||||
if (!checkResult.result) {
|
||||
await api.addUser({
|
||||
user_id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
first_name: currentUser.first_name,
|
||||
last_name: currentUser.last_name,
|
||||
photo_url: currentUser.photo_url,
|
||||
});
|
||||
}
|
||||
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
if (!userResult.error) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
|
||||
// Load first batch of cards
|
||||
await loadCards();
|
||||
} catch (err) {
|
||||
setError('Не удалось загрузить приложение');
|
||||
console.error('Init error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// Toast helper
|
||||
const showToast = useCallback((message, duration = 2500) => {
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
}
|
||||
init();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
setToast(message);
|
||||
toastTimeoutRef.current = setTimeout(() => setToast(null), duration);
|
||||
}, []);
|
||||
|
||||
// Response interceptor helper
|
||||
const handleApiResponse = useCallback((res) => {
|
||||
if (res?.isBanned) {
|
||||
setIsBanned(true);
|
||||
}
|
||||
if (res?.status === 429) {
|
||||
showToast(typeof res.result === 'string' ? res.result : 'Слишком много запросов. Подождите немного.');
|
||||
}
|
||||
return res;
|
||||
}, [showToast]);
|
||||
|
||||
// Load cards batch
|
||||
const loadCards = useCallback(async () => {
|
||||
const loadCards = useCallback(async (isManualRefresh = false) => {
|
||||
setIsLoadingCards(true);
|
||||
if (isManualRefresh) {
|
||||
hapticImpact('light');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.getRandomCards(currentUser.id);
|
||||
handleApiResponse(result);
|
||||
|
||||
if (!result.error && Array.isArray(result.result) && result.result.length > 0) {
|
||||
setCardQueue(result.result);
|
||||
setCurrentCardIndex(0);
|
||||
setChosenCard(null);
|
||||
if (isManualRefresh) {
|
||||
showToast('Карточки обновлены!');
|
||||
hapticNotification('success');
|
||||
}
|
||||
} else {
|
||||
// No more cards or error
|
||||
// Pool is empty or all cards seen
|
||||
setCardQueue([]);
|
||||
setCurrentCardIndex(0);
|
||||
setChosenCard(null);
|
||||
if (isManualRefresh) {
|
||||
showToast('Новых карточек пока нет');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load cards error:', err);
|
||||
setError('Ошибка загрузки карточек');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Choose a card (A or B)
|
||||
const chooseCard = useCallback((choice) => {
|
||||
if (chosenCard) {
|
||||
// Second tap on chosen card — go next
|
||||
if (choice === chosenCard) {
|
||||
goToNextCard();
|
||||
if (isManualRefresh) {
|
||||
showToast('Ошибка загрузки карточек');
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
setIsLoadingCards(false);
|
||||
}
|
||||
setChosenCard(choice);
|
||||
// Fire select_choice to backend
|
||||
if (currentCard) {
|
||||
api.selectChoice(currentUser.id, currentCard.card_id, choice).catch(console.error);
|
||||
}, [handleApiResponse, showToast]);
|
||||
|
||||
// Initialize app
|
||||
const initApp = useCallback(async () => {
|
||||
try {
|
||||
initTelegramApp();
|
||||
|
||||
// Check/register user
|
||||
const checkResult = await api.checkUser(currentUser.id);
|
||||
handleApiResponse(checkResult);
|
||||
if (checkResult?.isBanned) return;
|
||||
|
||||
if (!checkResult.error && !checkResult.result) {
|
||||
const addResult = await api.addUser({
|
||||
user_id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
first_name: currentUser.first_name,
|
||||
last_name: currentUser.last_name,
|
||||
photo_url: currentUser.photo_url,
|
||||
});
|
||||
handleApiResponse(addResult);
|
||||
if (addResult?.isBanned) return;
|
||||
}
|
||||
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
handleApiResponse(userResult);
|
||||
if (userResult?.isBanned) return;
|
||||
|
||||
if (!userResult.error && userResult.result) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
|
||||
// Load first batch of cards
|
||||
await loadCards();
|
||||
} catch (err) {
|
||||
setError('Не удалось загрузить приложение');
|
||||
console.error('Init error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chosenCard, currentCard]);
|
||||
}, [handleApiResponse, loadCards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitializingRef.current) return;
|
||||
isInitializingRef.current = true;
|
||||
initApp();
|
||||
}, [initApp]);
|
||||
|
||||
// User Profile resolver for comments
|
||||
const getUserProfile = useCallback(async (userId) => {
|
||||
if (!userId) return null;
|
||||
if (userId === currentUser.id) {
|
||||
return {
|
||||
user_id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
first_name: currentUser.first_name,
|
||||
last_name: currentUser.last_name,
|
||||
photo_url: currentUser.photo_url,
|
||||
};
|
||||
}
|
||||
if (userProfileCacheRef.current.has(userId)) {
|
||||
return userProfileCacheRef.current.get(userId);
|
||||
}
|
||||
try {
|
||||
const res = await api.getUser(userId);
|
||||
handleApiResponse(res);
|
||||
if (!res.error && res.result) {
|
||||
userProfileCacheRef.current.set(userId, res.result);
|
||||
return res.result;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get user profile:', userId, err);
|
||||
}
|
||||
return null;
|
||||
}, [handleApiResponse]);
|
||||
|
||||
// Go to next card
|
||||
const goToNextCard = useCallback(async () => {
|
||||
hapticImpact('light');
|
||||
const nextIndex = currentCardIndex + 1;
|
||||
if (nextIndex < cardQueue.length) {
|
||||
setCurrentCardIndex(nextIndex);
|
||||
@@ -110,50 +180,155 @@ export function AppProvider({ children }) {
|
||||
}
|
||||
}, [currentCardIndex, cardQueue.length, loadCards]);
|
||||
|
||||
// Choose a card (A or B)
|
||||
const chooseCard = useCallback((choice) => {
|
||||
if (chosenCard) {
|
||||
// Second tap on chosen card — go next
|
||||
if (choice === chosenCard) {
|
||||
goToNextCard();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hapticImpact('medium');
|
||||
setChosenCard(choice);
|
||||
|
||||
if (currentCard) {
|
||||
api.selectChoice(currentUser.id, currentCard.card_id, choice)
|
||||
.then(handleApiResponse)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [chosenCard, currentCard, goToNextCard, handleApiResponse]);
|
||||
|
||||
const syncCardComments = useCallback((cardId, commentsArrayOrIds) => {
|
||||
if (!Array.isArray(commentsArrayOrIds)) return;
|
||||
setCardQueue((prev) => {
|
||||
const targetCard = prev.find((c) => c.card_id === cardId);
|
||||
if (!targetCard) return prev;
|
||||
if (targetCard.comments && targetCard.comments.length === commentsArrayOrIds.length) {
|
||||
return prev; // No change, avoid re-render
|
||||
}
|
||||
return prev.map((c) =>
|
||||
c.card_id === cardId
|
||||
? { ...c, comments: commentsArrayOrIds }
|
||||
: c
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addCommentToCard = useCallback((cardId, commentId) => {
|
||||
setCardQueue((prev) =>
|
||||
prev.map((c) =>
|
||||
c.card_id === cardId
|
||||
? {
|
||||
...c,
|
||||
comments: c.comments ? [...c.comments, commentId] : [commentId],
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Auto-sync real comments count for current card from /get_comments
|
||||
useEffect(() => {
|
||||
const cardId = currentCard?.card_id;
|
||||
if (!cardId) return;
|
||||
|
||||
let isMounted = true;
|
||||
api.getComments(cardId)
|
||||
.then((res) => {
|
||||
if (isMounted && !res.error && Array.isArray(res.result)) {
|
||||
syncCardComments(cardId, res.result.map((c) => c.comment_id));
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [currentCard?.card_id, syncCardComments]);
|
||||
|
||||
// Reactions
|
||||
const likeCard = useCallback(async () => {
|
||||
if (!currentCard || !chosenCard) return;
|
||||
hapticImpact('light');
|
||||
|
||||
const result = await api.likeCard(currentUser.id, currentCard.card_id);
|
||||
if (!result.error) {
|
||||
// Refresh user data to get updated liked_card_ids
|
||||
handleApiResponse(result);
|
||||
|
||||
if (result && !result.error) {
|
||||
// Update local card counts in cardQueue
|
||||
setCardQueue((prev) =>
|
||||
prev.map((c) =>
|
||||
c.card_id === currentCard.card_id
|
||||
? { ...c, count_likes: (c.count_likes || 0) + 1 }
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
||||
// Refresh user data for liked_card_ids
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
if (!userResult.error) setUser(userResult.result);
|
||||
handleApiResponse(userResult);
|
||||
if (!userResult.error && userResult.result) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [currentCard, chosenCard]);
|
||||
}, [currentCard, chosenCard, handleApiResponse]);
|
||||
|
||||
const dislikeCard = useCallback(async () => {
|
||||
if (!currentCard || !chosenCard) return;
|
||||
hapticImpact('light');
|
||||
|
||||
const result = await api.dislikeCard(currentUser.id, currentCard.card_id);
|
||||
if (!result.error) {
|
||||
handleApiResponse(result);
|
||||
|
||||
if (result && !result.error) {
|
||||
// Update local card counts in cardQueue
|
||||
setCardQueue((prev) =>
|
||||
prev.map((c) =>
|
||||
c.card_id === currentCard.card_id
|
||||
? { ...c, count_dislikes: (c.count_dislikes || 0) + 1 }
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
||||
// Refresh user data for disliked_card_ids
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
if (!userResult.error) setUser(userResult.result);
|
||||
handleApiResponse(userResult);
|
||||
if (!userResult.error && userResult.result) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [currentCard, chosenCard]);
|
||||
|
||||
// Toast helper
|
||||
const showToast = useCallback((message, duration = 2500) => {
|
||||
setToast(message);
|
||||
setTimeout(() => setToast(null), duration);
|
||||
}, []);
|
||||
}, [currentCard, chosenCard, handleApiResponse]);
|
||||
|
||||
// Menu helpers
|
||||
const openMenu = useCallback(() => {
|
||||
hapticImpact('light');
|
||||
setIsMenuOpen(true);
|
||||
setMenuScreen('menu');
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
hapticImpact('light');
|
||||
setIsMenuOpen(false);
|
||||
setMenuScreen('menu');
|
||||
}, []);
|
||||
|
||||
const handleRetryAfterBan = useCallback(() => {
|
||||
setIsBanned(false);
|
||||
setIsLoading(true);
|
||||
initApp();
|
||||
}, [initApp]);
|
||||
|
||||
const value = {
|
||||
// State
|
||||
isLoading,
|
||||
isLoadingCards,
|
||||
user,
|
||||
error,
|
||||
isBanned,
|
||||
currentCard,
|
||||
chosenCard,
|
||||
cardQueue,
|
||||
@@ -169,12 +344,17 @@ export function AppProvider({ children }) {
|
||||
loadCards,
|
||||
likeCard,
|
||||
dislikeCard,
|
||||
getUserProfile,
|
||||
handleApiResponse,
|
||||
syncCardComments,
|
||||
addCommentToCard,
|
||||
openMenu,
|
||||
closeMenu,
|
||||
setMenuScreen,
|
||||
setIsCommentsOpen,
|
||||
showToast,
|
||||
setError,
|
||||
handleRetryAfterBan,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
/**
|
||||
* API service — all backend requests for This OR That.
|
||||
* All endpoints return { result, error } (BaseResponse).
|
||||
* Includes X-Init-Data header injection, rate limit handling, and IP ban detection.
|
||||
* All endpoints return { result, error, status, isBanned }.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_API_URL || '/api';
|
||||
import { getTelegramInitData } from './auth';
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
async function request(method, path, body = null) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const initData = getTelegramInitData();
|
||||
if (initData) {
|
||||
headers['X-Init-Data'] = initData;
|
||||
}
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}${path}`, options);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}${path}`, options);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = { result: response.statusText, error: !response.ok };
|
||||
}
|
||||
|
||||
// Check for IP ban by FastAPI-guard / penetration detection
|
||||
const isIpBanned = response.status === 403 && (
|
||||
(typeof data?.detail === 'string' && /banned|ip.*banned|suspicious/i.test(data.detail)) ||
|
||||
(typeof data?.result === 'string' && /banned|ip.*banned|suspicious/i.test(data.result))
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
result: data?.detail || 'Слишком много запросов. Подождите несколько секунд.',
|
||||
error: true,
|
||||
status: 429,
|
||||
isBanned: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: data?.result || data?.detail || `Ошибка сервера (${response.status})`,
|
||||
error: true,
|
||||
status: response.status,
|
||||
isBanned: isIpBanned,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: data?.result !== undefined ? data.result : data,
|
||||
error: data?.error || false,
|
||||
status: response.status,
|
||||
isBanned: false,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`API request error [${method} ${path}]:`, err);
|
||||
return {
|
||||
result: 'Ошибка соединения с сервером',
|
||||
error: true,
|
||||
status: 0,
|
||||
isBanned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const GET = (path) => request('GET', path);
|
||||
@@ -58,9 +116,6 @@ export const api = {
|
||||
addComment: (authorId, cardId, commentText) =>
|
||||
POST('/comment', { author_id: authorId, card_id: cardId, comment_text: commentText }),
|
||||
|
||||
// TODO: GET /get_comments — endpoint not yet implemented on backend
|
||||
getComments: (cardId) => {
|
||||
console.warn('GET /get_comments not implemented on backend yet');
|
||||
return Promise.resolve({ result: [], error: false });
|
||||
},
|
||||
getComments: (cardId) =>
|
||||
GET(`/get_comments?card_id=${cardId}`),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Auth service — detects Telegram WebApp user or falls back to mock.
|
||||
* Auto-registers user on backend if not yet registered.
|
||||
* Provides Telegram Mini App initialization, initData extraction, and Haptic Feedback.
|
||||
*/
|
||||
|
||||
const MOCK_USER = {
|
||||
@@ -11,7 +11,21 @@ const MOCK_USER = {
|
||||
photo_url: '',
|
||||
};
|
||||
|
||||
function getTelegramUser() {
|
||||
/**
|
||||
* Extracts raw Telegram initData query-string for backend HMAC validation.
|
||||
*/
|
||||
export function getTelegramInitData() {
|
||||
try {
|
||||
return window.Telegram?.WebApp?.initData || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts parsed user info from initDataUnsafe for UI display.
|
||||
*/
|
||||
export function getTelegramUser() {
|
||||
try {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const user = tg?.initDataUnsafe?.user;
|
||||
@@ -33,14 +47,47 @@ function getTelegramUser() {
|
||||
export const currentUser = getTelegramUser() ?? MOCK_USER;
|
||||
export const isTelegram = !!getTelegramUser();
|
||||
|
||||
/**
|
||||
* Initializes Telegram WebApp environment (theme, fullscreen expand, close confirmation).
|
||||
*/
|
||||
export function initTelegramApp() {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (tg) {
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
try {
|
||||
tg.setHeaderColor?.('#070711');
|
||||
tg.setBackgroundColor?.('#070711');
|
||||
tg.enableClosingConfirmation?.();
|
||||
} catch {
|
||||
// Ignored in unsupported client versions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers Telegram Haptic Feedback impact.
|
||||
* @param {'light' | 'medium' | 'heavy' | 'rigid' | 'soft'} style
|
||||
*/
|
||||
export function hapticImpact(style = 'light') {
|
||||
try {
|
||||
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(style);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers Telegram Haptic Feedback notification.
|
||||
* @param {'error' | 'success' | 'warning'} type
|
||||
*/
|
||||
export function hapticNotification(type = 'success') {
|
||||
try {
|
||||
window.Telegram?.WebApp?.HapticFeedback?.notificationOccurred(type);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Telegram BackButton lifecycle with cleanup.
|
||||
*/
|
||||
export function showBackButton(onBack) {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (tg?.BackButton) {
|
||||
@@ -51,5 +98,18 @@ export function showBackButton(onBack) {
|
||||
tg.BackButton.hide();
|
||||
};
|
||||
}
|
||||
return () => { };
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely opens external link in Telegram WebApp or browser.
|
||||
*/
|
||||
export function openExternalLink(url) {
|
||||
try {
|
||||
if (window.Telegram?.WebApp?.openLink) {
|
||||
window.Telegram.WebApp.openLink(url);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
// setupTests.js
|
||||
|
||||
Reference in New Issue
Block a user