RELEASE 1.0! #5

Merged
IgorVolochay merged 95 commits from prebuild into main 2026-09-09 08:45:32 +00:00
82 changed files with 24053 additions and 69 deletions
+16
View File
@@ -0,0 +1,16 @@
DEV_MODE=true # true = docs enabled + auth disabled; false = production mode
MONGO_HOST=127.0.0.1
MONGO_PORT=27017
MONGO_USER=user
MONGO_PASS=pass
RABBIT_HOST=127.0.0.1
RABBIT_PORT=5672
RABBIT_USER=user
RABBIT_PASS=pass
API_BASE_URL='http://localhost:5000'
MODERATION_SECRET=secret
TG_BOT_TOKEN='token'
TG_ADMIN_CHAT_ID=000000000
+108
View File
@@ -0,0 +1,108 @@
name: Backend CI
permissions:
contents: read
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:
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/3)
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
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/6)
+166
View File
@@ -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 }}
+48
View File
@@ -0,0 +1,48 @@
name: Frontend CI
permissions:
contents: read
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
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/4)
-22
View File
@@ -1,22 +0,0 @@
name: mypy-test
on:
push:
paths:
- '**.py'
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: 3.9
architecture: x64
- name: Checkout
uses: actions/checkout@v3
- name: Install mypy
run: pip install mypy
- name: Run mypy
run: mypy --ignore-missing-imports ./app
+137
View File
@@ -0,0 +1,137 @@
name: Prebuild CI & Docker Test Build
on:
workflow_dispatch:
push:
branches:
- prebuild
pull_request:
branches:
- prebuild
permissions:
contents: read
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:
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/2)
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:
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/5)
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:
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/7)
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
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/10)
+44
View File
@@ -0,0 +1,44 @@
name: Prebuild Workflow
on:
pull_request:
branches:
- prebuild
permissions:
contents: read
jobs:
test_build:
name: Test build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Try to build project
run: docker-compose up --build
deploy_to_test_server:
github-advanced-security[bot] commented 2026-09-09 08:26:32 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{contents: read}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/8)
name: Deploy to test server
needs: test_build
runs-on: ubuntu-latest
steps:
- name: Deploy to test server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.TEST_SERVER_ADDRESS }}
username: ${{ secrets.TEST_SERVER_USER }}
port: ${{ secrets.TEST_SERVER_PORT }}
password: ${{ secrets.TEST_SERVER_PASSWORD }}
script: |
REPO_NAME=$(basename "${{ github.repository }}")
if [ ! -d "$REPO_NAME" ]; then
git clone https://github.com/${{ github.repository }}.git $REPO_NAME
fi
cd $REPO_NAME
git checkout prebuild
git pull
docker-compose down || true
docker-compose up -d --build
github-advanced-security[bot] commented 2026-09-09 08:26:33 +00:00 (Migrated from github.com)
Review

CodeQL / Workflow does not contain permissions

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{}}

Show more details

## CodeQL / Workflow does not contain permissions Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{}} [Show more details](https://github.com/IgorVolochay/thisORthat/security/code-scanning/9)
+16
View File
@@ -2,6 +2,7 @@
__pycache__/
*.py[cod]
*$py.class
.idea/
# C extensions
*.so
@@ -170,3 +171,18 @@ cython_debug/
# PyPI configuration file
.pypirc
app/.env
# Node.js and React
node_modules/
build/
dist/
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.eslintcache
.DS_Store
+144 -13
View File
@@ -1,23 +1,154 @@
# this OR that
# this OR that | Telegram Mini App
Telegram mini-app where you have to choose one of two things.
## Installation
<img width="1447" height="248" alt="thisORthat_logo" src="https://github.com/user-attachments/assets/fee632cf-f778-4a72-95d7-bee2f8602bc0" />
1. Installing the repository:
```bash
git clone https://github.com/IgorVolochay/thisORthat
## Описание проекта
[«this OR that»](https://t.me/thisorthat_rubot?startapp) — это интерактивное веб-приложение формата Telegram Mini App, предлагающее пользователю сделать выбор между двумя альтернативными вариантами («Это или То»). Сразу после голосования открывается статистика голосов других участников, открывается доступ к обсуждению в комментариях, реакциям (лайки / дизлайки), а также предоставляется возможность создать собственную карточку.
### Идея и источник вдохновения
Вдохновением для проекта послужил русскоязычный ресурс [thisorthat.ru](https://thisorthat.ru), где собраны тысячи вопросов для размышления. Основная цель данного проекта - адаптировать и переосмыслить эту механику в современный экосистемный формат **Telegram Mini App**.
> [!NOTE]
> **Это pet-проект.** Проект носит исключительно некоммерческий, учебно-исследовательский характер. Он был создан для отработки и демонстрации практических навыков проектирования асинхронных бэкендов, построения событийно-ориентированной архитектуры (Event-Driven Architecture), интеграции очередей сообщений, работы с Telegram WebApp API и контейнеризации сервисов.
<img width="1800" height="1244" alt="thisORthat_telegram" src="https://github.com/user-attachments/assets/172e8243-f6da-4dcf-a362-74fa18ce68e9" />
## Технологический стек, подходы и инструменты
В ходе разработки были применены современные технологии, обеспечивающие высокую производительность, масштабируемость и безопасность:
### Backend & Асинхронная экосистема
* **Python 3.12+**: основной язык разработки сервисов.
* **[FastAPI](https://github.com/fastapi/fastapi)**: высокопроизводительный асинхронный веб-фреймворк для реализации REST API.
* **[Motor](https://github.com/mongodb/motor)**: асинхронный драйвер для интеграции с базой данных MongoDB.
* **[Pydantic v2](https://github.com/pydantic/pydantic)**: строгая валидация входящих и исходящих данных через типизированные схемы.
* **[aio-pika](https://github.com/mosquito/aio-pika)**: асинхронный клиент для взаимодействия с брокером сообщений RabbitMQ.
* **[aiogram 3](https://github.com/aiogram/aiogram)**: асинхронный фреймворк для Telegram-бота модерации.
* **[FastAPI-guard](https://github.com/rennf93/fastapi-guard)**: модуль rate limiting, защита от попыток проникновения, а также временная блокировка подозрительных IP-адресов.
* **[Loguru](https://github.com/Delgan/loguru)**: структурированное логирование.
* **[Pytest](https://github.com/pytest-dev/pytest)**: тестовый фреймворк для покрытия эндпоинтов, логики карточек и проверки безопасности.
### Frontend
* **[React 19](https://github.com/react/react)**: библиотека для построения динамичного пользовательского интерфейса (SPA).
* **Telegram WebApp API**: интеграция с окружением мессенджера (Haptic Feedback для тактильного отклика, автоматическая адаптация к системной теме Telegram, SafeArea и управление кнопками).
* **Vanilla CSS & Flexbox/Grid**: адаптивная верстка под любые размеры мобильных экранов, плавные микро-анимации, кастомные скроллбары и модальные окна без утяжеления сторонними CSS-библиотеками.
### Базы данных и очереди
* **[MongoDB](https://www.mongodb.com/)**: NoSQL база данных для гибкого хранения карточек, голосов пользователей, профилей и древовидных комментариев.
* **[RabbitMQ](https://www.rabbitmq.com/)**: брокер сообщений, обеспечивающий отказоустойчивую буферизацию задач между бэкендом и ботом модерации.
### Архитектурные подходы и паттерны
* **Event-Driven Moderation (EDA)**: отправка предложенных пользователями карточек в очередь сообщений без задержек основного пользовательского API.
* **Zero-Trust авторизация через Telegram**: валидация подписи `initData` по алгоритму HMAC-SHA256 с использованием секретного ключа бота.
* **Защита API и Rate Limiting**: многоуровневая фильтрация запросов через FastAPI-guard с возвратом специализированных экранов блокировки на фронтенде при превышении лимитов.
* **Service-Oriented Architecture (SOA)**: разделение приложения на изолированные контейнеры: БД, брокер, API-сервис и бот.
---
## Архитектура проекта и взаимосвязь компонентов
Проект построен по сервисной архитектуре, где каждый компонент выполняет строго отведенную роль:
```mermaid
flowchart TD
subgraph Client["Клиентская часть"]
TMA["Telegram Mini App (React 19)"]
end
subgraph CoreBackend["Бэкенд инфраструктура"]
API["FastAPI REST Backend :5000"]
Mongo[("MongoDB Database :27017")]
RMQ[["RabbitMQ Broker :5672"]]
Bot["Telegram Модерация Bot (aiogram 3)"]
end
subgraph TelegramCloud["Инфраструктура Telegram"]
TGUser["Пользователь Telegram"]
TGAdmin["Администратор в Telegram"]
end
%% Взаимодействия
TGUser <--> |Запуск Mini App / initData| TMA
TMA <--> |REST API запросы / Голосование / Комментарии| API
API <--> |Асинхронные запросы через Motor| Mongo
API --> |Публикация новой карточки в очередь moderation| RMQ
RMQ --> |Потребление карточки из очереди| Bot
Bot --> |Уведомление с кнопками Одобрить / Отклонить| TGAdmin
TGAdmin --> |Инлайн-решение| Bot
Bot --> |Защищенный вызов API с MODERATION_SECRET| API
```
2. The project is written in Python3.9. Make sure you have it on your system. Go to the project folder, create a virtual environment and download pip requirements:
### Сценарии взаимодействия:
1. **Пользовательский сценарий:**
- Пользователь открывает Mini App внутри Telegram. Приложение передает `initData`, которая верифицируется бэкендом.
- Пользователь получает случайные пары карточек из MongoDB, делает выбор, голосует, оставляет комментарии и реакции.
2. **Пайплайн предложения и модерации карточек:**
- Пользователь предлагает свою карточку через интерфейс приложения.
- Сервис FastAPI сохраняет карточку в MongoDB со статусом ожидания и мгновенно публикует событие в очередь `moderation` брокера **RabbitMQ**.
- Сервис **Telegram Bot** (aiogram) слушает очередь, получает карточку и пересылает ее в чат администратора (`TG_ADMIN_CHAT_ID`) с инлайн-кнопками «Одобрить ✅» / «Отклонить ❌».
- Администратор принимает решение в Telegram. Бот выполняет защищенный внутренний запрос к API (`/card_accept` или `/card_reject`) с заголовком `MODERATION_SECRET`, после чего статус карточки в базе обновляется.
---
## Краткое руководство по запуску
Для запуска проекта на локальной машине потребуются установленные **Docker**, **Docker Compose** и **Node.js** (версии 18+).
### Шаг 1. Конфигурация окружения
Создайте файл переменных окружения `.env` в корне проекта на основе образца `.env_example`:
```bash
cd ./thisORthat
python3.9 -m venv venv
source ./venv/bin/activate
pip3 install -r requirements.txt
cp .env_example .env
```
3. Installing MongoDB database. You can use the [official manual](https://www.mongodb.com/docs/manual/installation/) to install MongoDB manually, or use a [Docker image](https://hub.docker.com/r/mongodb/mongodb-community-server) to run the container:
Заполните ключевые параметры в `.env`:
* `TG_BOT_TOKEN` — токен вашего Telegram-бота от [@BotFather](https://t.me/BotFather).
* `TG_ADMIN_CHAT_ID` — ваш Telegram ID или ID чата для модерации карточек.
* `MODERATION_SECRET` — произвольная секретная строка для взаимодействия между ботом и API.
* При необходимости скорректируйте учетные данные MongoDB и RabbitMQ. Для локальной разработки без валидации Telegram `DEV_MODE` можно оставить равным `true`.
### Шаг 2. Сборка фронтенда
Соберите статическую версию React-приложения:
```bash
docker run --name mongodb -d -p 27017:27017 mongodb/mongodb-community-server
cd frontend
npm install
npm run build
cd ..
```
### Шаг 3. Запуск сервисов через Docker Compose
Запустите сборку и старт всех сервисов в фоновом режиме:
```bash
docker compose up -d --build
```
После завершения запуска будут активны следующие компоненты:
* **Backend API:** `http://localhost:5000` (документация Swagger доступна по адресу `http://localhost:5000/docs` при `DEV_MODE=true`).
* **Панель RabbitMQ Management:** `http://localhost:15672` (логин и пароль задаются в `.env`).
* **MongoDB:** порт `27017`.
* **Telegram Bot:** сервис подключится к Telegram и начнет обработку очереди модерации.
---
## Контакты
* **Автор:** Волочай Игорь (Igor Volochay)
* **Telegram:** [@VIAproger](https://t.me/VIAproger)
* **Email:** [pseudo.developer.ru@gmail.com](mailto:pseudo.developer.ru@gmail.com)
* **GitHub репозиторий:** [https://github.com/IgorVolochay/thisORthat](https://github.com/IgorVolochay/thisORthat)
---
## Благодарности
### Идейный вдохновитель
* **[thisorthat.ru](https://thisorthat.ru)** — оригинальный проект, послуживший источником вдохновения для идеи, концепции дилемм и механики выбора.
### Open-Source сообщество, библиотеки и фреймворки
Выражаю глубокую благодарность разработчикам и мейнтейнерам ключевых библиотек и инструментов, на которых построен проект.
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.venv/
.env
tests/
security.log
.git/
.github/
*.md
dockerfile.app
dockerfile.bot
.dockerignore
+32
View File
@@ -0,0 +1,32 @@
# ── Stage 1: Install dependencies ────────────────────────────
FROM python:3.12.4-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Stage 2: Production image ────────────────────────────────
FROM python:3.12.4-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code (respects .dockerignore)
COPY . .
# Create non-root user
RUN groupadd --gid 1000 appuser && \
useradd --uid 1000 --gid appuser --shell /bin/sh appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
CMD ["python3", "main.py"]
+34
View File
@@ -0,0 +1,34 @@
# ── Stage 1: Install dependencies ────────────────────────────
FROM python:3.12.4-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Stage 2: Production image ────────────────────────────────
FROM python:3.12.4-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code (respects .dockerignore)
COPY . .
# Create non-root user
RUN groupadd --gid 1000 appuser && \
useradd --uid 1000 --gid appuser --shell /bin/sh appuser && \
chown -R appuser:appuser /app
USER appuser
# Healthcheck: verify RabbitMQ connection is possible
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import socket; s=socket.create_connection(('${RABBIT_HOST:-rabbitmq}', int('${RABBIT_PORT:-5672}')), timeout=3); s.close()" || exit 1
CMD ["python3", "tg_bot.py"]
+110
View File
@@ -0,0 +1,110 @@
"""
Centralized logging configuration using Loguru.
Outputs structured JSON to stdout (INFO/WARNING) and stderr (ERROR/CRITICAL).
Designed for Docker + Grafana Loki / Promtail.
Usage:
from logger import logger, setup_logging
setup_logging() # call once at application entry point
logger.info("message")
"""
from __future__ import annotations
import os
import sys
import logging
from types import FrameType
from typing import TYPE_CHECKING
from dotenv import load_dotenv
from loguru import logger
if TYPE_CHECKING:
from loguru import Record
def get_log_level() -> str:
"""Reads LOG_LEVEL or log_level from .env or environment, defaults to 'INFO'."""
load_dotenv()
level = os.getenv("LOG_LEVEL") or os.getenv("log_level") or "INFO"
return level.strip().upper()
# ── Stdout / stderr filters ───────────────────────────────────────────────────
def _stdout_filter(record: Record) -> bool:
"""Pass DEBUG / INFO / WARNING to stdout."""
return record["level"].no < logging.ERROR
def _stderr_filter(record: Record) -> bool:
"""Pass ERROR / CRITICAL to stderr."""
return record["level"].no >= logging.ERROR
# ── Stdlib → Loguru bridge ────────────────────────────────────────────────────
class InterceptHandler(logging.Handler):
"""Redirect all stdlib logging calls into Loguru."""
def emit(self, record: logging.LogRecord) -> None:
level: str | int
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame: FrameType | None = sys._getframe(6)
depth = 6
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
# ── Public setup function ─────────────────────────────────────────────────────
def setup_logging(level: str | None = None) -> None:
"""
Configure Loguru sinks and intercept all stdlib loggers.
Call once at the very start of the application entry point.
"""
if not level:
level = get_log_level()
else:
level = level.strip().upper()
logger.remove() # remove default sink
common: dict = {
"level": level,
"serialize": True, # JSON output
"backtrace": False,
"diagnose": False,
}
# stdout — DEBUG / INFO / WARNING
logger.add(sys.stdout, filter=_stdout_filter, **common)
# stderr — ERROR / CRITICAL
logger.add(sys.stderr, filter=_stderr_filter, **{**common, "level": "ERROR"})
# Redirect all stdlib loggers (uvicorn, motor, aiogram, aio_pika …)
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
# Suppress noisy third-party loggers — we handle HTTP access via middleware
_quiet = {
"uvicorn.access": logging.WARNING, # replaced by our middleware
"motor": logging.WARNING,
"aio_pika": logging.WARNING,
"aiormq": logging.WARNING,
}
for name, lvl in _quiet.items():
_lib_logger = logging.getLogger(name)
_lib_logger.handlers = [InterceptHandler()]
_lib_logger.setLevel(lvl)
_lib_logger.propagate = False
+333 -1
View File
@@ -1,4 +1,336 @@
import os
import secrets
import uvicorn
import asyncio
from dotenv import load_dotenv
from fastapi import FastAPI, Depends, Response, Header, HTTPException, status
from guard import SecurityMiddleware, SecurityConfig, SecurityDecorator
from contextlib import asynccontextmanager
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()
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,
rate_limit_window=3,
enable_redis=False,
enable_ip_banning=True,
enable_penetration_detection=True,
auto_ban_threshold=3,
auto_ban_duration=600,
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: 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: 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)
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)
# 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)
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,
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")
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,
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")
response.status_code = status.HTTP_404_NOT_FOUND
return result
@app.post("/comment", status_code=201)
@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 = 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
if result.error:
response.status_code = status.HTTP_400_BAD_REQUEST
return result
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():
"""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__":
print("Hello World!")
asyncio.run(main())
+69
View File
@@ -0,0 +1,69 @@
"""
HTTP request/response logging middleware for FastAPI.
Normal requests (2xx/3xx):
INFO — method, path, query_params, status_code, duration_ms
Error responses (4xx):
WARNING — all above + request_body (truncated to 1000 chars)
Server errors (5xx):
ERROR — all above + request_body (truncated to 1000 chars)
"""
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from logger import logger
_BODY_METHODS = frozenset({"POST", "PUT", "PATCH"})
_BODY_MAX_LEN = 1000
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
start = time.perf_counter()
# Read body only for methods that carry a payload
body: str | None = None
if request.method in _BODY_METHODS:
raw = await request.body()
body = raw.decode(errors="replace")[:_BODY_MAX_LEN]
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 1)
status = response.status_code
client_ip = request.headers.get("X-Forwarded-For")
if client_ip:
client_ip = client_ip.split(",")[0].strip()
else:
client_ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
base_fields = {
"method": request.method,
"path": request.url.path,
"query": str(request.query_params) or None,
"client_ip": client_ip,
"status": status,
"duration_ms": duration_ms,
}
if status >= 500:
logger.bind(**base_fields, request_body=body).error(
"{method} {path}{status} ({duration_ms}ms)", **base_fields
)
elif status >= 400:
logger.bind(**base_fields, request_body=body).warning(
"{method} {path}{status} ({duration_ms}ms)", **base_fields
)
else:
logger.bind(**base_fields).info(
"{method} {path}{status} ({duration_ms}ms)", **base_fields
)
return response
+324 -31
View File
@@ -1,45 +1,338 @@
import pymongo
import os
import motor.motor_asyncio
from datetime import datetime
from dotenv import load_dotenv
from typing import Optional
from pymongo import ReturnDocument
from bson import ObjectId
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):
self.client = pymongo.MongoClient(host="127.0.0.1", port=27017)
"""Initializes the MongoDB connection and sets up collection references."""
load_dotenv()
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_data"]
self.game_data = self.db["game_data"]
self.users_data = self.db["users"]
self.visited_data = self.db["visited"]
self.counters = self.db["counters"]
self.game_data = self.db["cards"]
self.comments_data = self.db["comments"]
def get_mongodb_info(self) -> None:
print(self.client.list_database_names())
print(self.db.list_collection_names())
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 find_user(self, user_id: int) -> dict:
result = self.users_data.find_one({"user_id": user_id})
return result
def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_path: str) -> ObjectId:
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
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
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=ReturnDocument.AFTER,
)
return counter["counter"]
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:
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
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)
return None
async def get_random_cards(self, amount: int,active_status: bool,exclude_ids: Optional[set[int]] = None,) -> Optional[list[Card]]:
"""Returns random cards, excluding already visited ones (in a single query)."""
match_filter: dict = {"active_status": active_status}
if exclude_ids:
match_filter["card_id"] = {"$nin": list(exclude_ids)}
pipeline = [
{"$match": match_filter},
{"$sample": {"size": amount}},
]
raw_items = await self.game_data.aggregate(pipeline).to_list(length=amount)
if raw_items:
return [Card.model_validate(item) for item in raw_items]
return None
def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]:
"""Filters a list of random cards to exclude those already visited by the user."""
filtered_cards = [card for card in random_cards if card.card_id not in cards_visited]
filtered_cards_id = [card.card_id for card in filtered_cards]
return filtered_cards, filtered_cards_id
async def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card:
"""Creates a new card in the database with data received from the API."""
new_card = Card(
card_id=await self.get_and_update_counter(counter_name="card"),
choice_A=choice_A,
choice_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:
res = self.users_data.insert_one({"user_id": user_id,
"username": username,
"first_name": first_name,
"last_name": last_name,
"photo_path": photo_path,
"activity": 0,
"liked_post_ids": [],
"disliked_post_ids": [],
"comments_ids": [],
"registration_date": datetime.now().isoformat()
})
return res.inserted_id
except Exception as exception:
print(exception)
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_field = "count_choice_A"
elif choice == "B":
count_field = "count_choice_B"
else:
return BaseResponse(result="Wrong choice", error=True)
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)
return BaseResponse(result=True, error=False)
if __name__ == "__main__":
mongo = MongoWorker()
mongo.get_mongodb_info()
print(mongo.add_user(123, "VolochayIgor", "Igor", "Volochay", "path/to/img"))
print(mongo.find_user(123))
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)
# 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)
# 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)
+77
View File
@@ -0,0 +1,77 @@
import os
import json
import asyncio
from typing import Callable, Awaitable
import aio_pika
from aio_pika.abc import AbstractIncomingMessage
from dotenv import load_dotenv
from schemas.base_schemas import Card
from logger import logger
class RabbitWorker:
"""Handles RabbitMQ connections and message publishing/consuming for moderation."""
def __init__(self):
"""Initializes the RabbitWorker with connection credentials from environment variables."""
load_dotenv()
self.url = (
f"amqp://{os.getenv('RABBIT_USER')}:{os.getenv('RABBIT_PASS')}"
f"@{os.getenv('RABBIT_HOST')}:{os.getenv('RABBIT_PORT')}"
)
logger.info("RabbitWorker connection established.")
async def send_to_moderation(self, card: Card) -> None:
"""Publishes a card to the 'moderation' RabbitMQ queue."""
logger.debug("Preparing to send card {} to moderation queue...", card.card_id)
connection = await aio_pika.connect_robust(self.url)
async with connection:
channel = await connection.channel()
queue = await channel.declare_queue("moderation", durable=True)
await channel.default_exchange.publish(
aio_pika.Message(
body=card.model_dump_json().encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
),
routing_key="moderation",
)
logger.debug("Card {} successfully published to moderation queue", card.card_id)
logger.info("Card {} sent to moderation queue", card.card_id)
async def consume_moderation(
self,
callback: Callable[[Card], Awaitable[None]],
) -> None:
"""
Consumes messages from the 'moderation' queue and processes them using the provided callback.
Args:
callback: An async function that takes a Card object and processes it.
"""
connection = await aio_pika.connect_robust(self.url)
async with connection:
channel = await connection.channel()
await channel.set_qos(prefetch_count=1)
queue = await channel.declare_queue("moderation", durable=True)
logger.info("Started consuming moderation queue...")
async def on_message(message: AbstractIncomingMessage) -> None:
async with message.process():
try:
card_data = json.loads(message.body.decode())
card = Card.model_validate(card_data)
await callback(card)
except Exception as exc:
logger.error("Error processing moderation message: {}", exc)
await queue.consume(on_message)
# Keep consumer alive while allowing cancellation (Ctrl+C)
stop_event = asyncio.Event()
try:
await stop_event.wait()
except asyncio.CancelledError:
logger.info("Moderation consumer shutting down...")
raise
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.7
fastapi_guard==7.6.0
motor==3.7.0
python-dotenv==1.0.1
uvicorn==0.34.0
aio-pika==10.0.1
aiogram==3.18.0
aiohttp==3.11.18
loguru==0.7.3
View File
+40
View File
@@ -0,0 +1,40 @@
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: Optional[NonNegativeInt] = None
username: str
first_name: str
last_name: str
photo_url: str
class AddCardBody(BaseModel):
choice_A: str
choice_B: str
author_id: Optional[NonNegativeInt] = None
class SelectChoice(BaseModel):
user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
choice: typing.Literal["A", "B"]
class ReactionCard(BaseModel):
user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
class AddCommentBody(BaseModel):
author_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
comment_text: str
+48
View File
@@ -0,0 +1,48 @@
from pydantic import BaseModel
class User(BaseModel):
user_id: int
username: str
first_name: str
last_name: str
photo_url: str
activity: int = 0
liked_card_ids: list[int] = list()
disliked_card_ids: list[int] = list()
comments_ids: list[int] = list()
registration_date: str
class Visited(BaseModel):
user_id: int
cards_visited: set[int]
class Card(BaseModel):
card_id: int
choice_A: str
choice_B: str
count_choice_A: int = 0
count_choice_B: int = 0
count_total: int = 0
count_likes: int = 0
count_dislikes: int = 0
comments: list[int] = list()
author_id: int
creation_date: str
moderation_date: str = "Not moderated"
active_status: bool = False
class Comment(BaseModel):
comment_id: int
author_id: int
card_id: int
comment_text: str
creation_date: str
View File
+44
View File
@@ -0,0 +1,44 @@
"""
conftest.py — fixtures for resetting rate-limiter state and IP-ban
between tests so functional tests do not hit 429 errors.
"""
import pytest
from guard import ip_ban_manager
from guard_core.handlers.ratelimit_handler import RateLimitManager
def _reset_all():
"""Full reset of rate-limiter, IP-ban, and suspicious counts."""
# Rate limit timestamps
rl: RateLimitManager | None = RateLimitManager._instance
if rl is not None:
rl.request_timestamps.clear()
# IP bans
ip_ban_manager.banned_ips.clear()
ip_ban_manager.banned_networks.clear()
# Suspicious request counts via direct reference stored in app.state
# Because FastAPI's add_middleware creates a new instance internally,
# navigating app.state or app.middleware_stack is unreliable.
# We use gc to robustly find the active SecurityMiddleware instance(s) and clear them.
try:
import gc
from guard.middleware import SecurityMiddleware
for obj in gc.get_objects():
if isinstance(obj, SecurityMiddleware):
obj.suspicious_request_counts.clear()
except Exception:
pass
@pytest.fixture(autouse=True)
def reset_guard_state():
"""
Synchronous fixture (autouse) that resets guard middleware
state before and after each test.
"""
_reset_all()
yield
_reset_all()
+196
View File
@@ -0,0 +1,196 @@
import random
import asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
from schemas.base_schemas import *
from schemas.api_schemas import *
EXIST_AUTHOR = random.randint(100000000, 1000000000)
NON_EXIST_CARD_ID = 1000
# ---------- /add_card ----------
@pytest.mark.asyncio(loop_scope="session")
async def test_add_card_valid():
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",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
print(f"\nINPUT: endpoint=/add_card | payload={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code in (200, 201)
base_resp = BaseResponse.model_validate(response.json())
assert base_resp.error is False
card = Card.model_validate(base_resp.result)
assert card.choice_A == payload["choice_A"]
assert card.choice_B == payload["choice_B"]
assert card.author_id == payload["author_id"]
@pytest.mark.asyncio(loop_scope="session")
async def test_add_card_missing_field():
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",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
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(loop_scope="session")
async def test_add_card_wrong_type():
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",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
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(loop_scope="session")
async def test_add_card_empty_strings():
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client:
payload = {
"choice_A": "",
"choice_B": "",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
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(loop_scope="session")
async def test_add_card_long_strings():
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,
"choice_B": long_str,
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
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(loop_scope="session")
async def test_add_card_negative_author_id():
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",
"author_id": -10 # Negative id
}
response = await client.post("/add_card", json=payload)
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(loop_scope="session")
async def test_add_card_malformed_json():
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",
data=malformed_json,
headers={"Content-Type": "application/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(loop_scope="session")
async def test_async_card_creation():
"""
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 = 2 # at most 3 (decorator limit), leaving a margin
for i in range(num_cards):
payload = {
"choice_A": f"Async Option A {i}",
"choice_B": f"Async Option B {i}",
"author_id": EXIST_AUTHOR
}
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()}")
assert response.status_code in (200, 201)
base_resp = BaseResponse.model_validate(response.json())
assert base_resp.error is False
card = Card.model_validate(base_resp.result)
card_ids.append(card.card_id)
assert card.choice_A == f"Async Option A {idx}"
assert card.choice_B == f"Async Option B {idx}"
assert card.author_id == EXIST_AUTHOR
assert len(set(card_ids)) == num_cards
# ---------- /get_card ----------
@pytest.mark.asyncio(loop_scope="session")
async def test_get_card_valid():
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
base_resp = BaseResponse.model_validate(response.json())
assert base_resp.error is False
card_from_get = Card.model_validate(base_resp.result)
assert card_from_get.card_id == card_id
@pytest.mark.asyncio(loop_scope="session")
async def test_get_card_nonexistent():
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(loop_scope="session")
async def test_get_card_missing_param():
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(loop_scope="session")
async def test_get_card_wrong_type():
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(loop_scope="session")
async def test_get_card_negative():
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 == 404
+486
View File
@@ -0,0 +1,486 @@
"""
test_security.py — tests for checking rate limiting and penetration detection.
Rate limiting settings from main.py:
- Global: 10 requests / 3 sec (middleware)
- /add_user: 3 requests / 60 sec (decorator)
- /add_card: 3 requests / 60 sec (decorator)
- /get_random_cards: 5 requests / 60 sec (decorator)
- /comment: 5 requests / 20 sec (decorator)
Penetration detection:
- enable_penetration_detection=True
- auto_ban_threshold=3 (ban after 3 suspicious requests)
- auto_ban_duration=3600 (ban for 1 hour)
"""
import random
import asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
# Client IP and port
CLIENT = ("7.214.201.94", 50000)
# ========================================================================
# RATE LIMIT TESTS
# ========================================================================
class TestGlobalRateLimit:
"""Tests for global rate limit: 10 requests / 3 seconds."""
@pytest.mark.asyncio(loop_scope="session")
async def test_global_rate_limit_allows_under_threshold(self):
"""Requests within the limit (<=10) should pass."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
for i in range(9):
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 200, (
f"Request {i+1}/9 returned {resp.status_code}, expected 200: {resp.text}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_global_rate_limit_blocks_over_threshold(self):
"""
After exceeding global limit (10 requests/3s) -> 429.
/check_user does not have a rate_limit decorator, so only global limit applies.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Send 10 requests (fill the limit)
for i in range(10):
await client.get("/check_user", params={"user_id": 1})
# 11th request should return 429
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 429, (
f"Expected 429 after exceeding global limit, got {resp.status_code}"
)
assert "Too many requests" in resp.text
@pytest.mark.asyncio(loop_scope="session")
async def test_global_rate_limit_response_format(self):
"""Verify response format on rate limit."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Exhaust limit
for _ in range(10):
await client.get("/check_user", params={"user_id": 1})
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 429
assert resp.text == "Too many requests"
class TestDecoratorRateLimit:
"""Tests for rate limit via @guard_deco.rate_limit() decorator."""
@pytest.mark.asyncio(loop_scope="session")
async def test_add_user_rate_limit(self):
"""
/add_user: limit 3 requests / 60 sec.
First 3 requests pass (422 due to invalid data is OK, main point is not 429).
4th request -> 429.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
data = {
"user_id": random.randint(100000000, 999999999),
"username": "RateTest",
"first_name": "F",
"last_name": "L",
"photo_url": "http://test.test/photo.jpg"
}
# First 3 requests — not 429
for i in range(3):
resp = await client.post("/add_user", json=data)
assert resp.status_code != 429, (
f"Request {i+1}/3 returned 429, limit should not be exceeded yet"
)
# 4th request -> 429
resp = await client.post("/add_user", json=data)
assert resp.status_code == 429, (
f"Expected 429 after 3 requests to /add_user, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_add_card_rate_limit(self):
"""
/add_card: limit 3 requests / 60 sec.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
author_id = random.randint(100000000, 999999999)
for i in range(3):
payload = {
"choice_A": f"Rate A {i}",
"choice_B": f"Rate B {i}",
"author_id": author_id
}
resp = await client.post("/add_card", json=payload)
assert resp.status_code != 429, (
f"Request {i+1}/3 to /add_card returned 429 prematurely"
)
payload = {
"choice_A": "Rate A overflow",
"choice_B": "Rate B overflow",
"author_id": author_id
}
resp = await client.post("/add_card", json=payload)
assert resp.status_code == 429, (
f"Expected 429 after 3 requests to /add_card, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_get_random_cards_rate_limit(self):
"""
/get_random_cards: limit 5 requests / 60 sec.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
user_id = random.randint(100000000, 999999999)
for i in range(5):
resp = await client.get("/get_random_cards", params={"user_id": user_id})
# Can be 200 or 404 (if no cards/user), but not 429
assert resp.status_code != 429, (
f"Request {i+1}/5 to /get_random_cards returned 429 prematurely"
)
resp = await client.get("/get_random_cards", params={"user_id": user_id})
assert resp.status_code == 429, (
f"Expected 429 after 5 requests to /get_random_cards, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_comment_rate_limit(self):
"""
/comment: limit 5 requests / 20 sec.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
for i in range(5):
payload = {
"author_id": random.randint(100000000, 999999999),
"card_id": 1,
"comment_text": f"Rate test comment {i}"
}
resp = await client.post("/comment", json=payload)
# Can be 201, 400 (moderation), 404 (card/user not found) — but not 429
assert resp.status_code != 429, (
f"Request {i+1}/5 to /comment returned 429 prematurely"
)
payload = {
"author_id": random.randint(100000000, 999999999),
"card_id": 1,
"comment_text": "Overflow comment"
}
resp = await client.post("/comment", json=payload)
assert resp.status_code == 429, (
f"Expected 429 after 5 requests to /comment, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_different_endpoints_have_independent_limits(self):
"""
Decorator rate limit is tracked separately for each endpoint.
Requests to /check_user should not affect /add_card limit.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# 5 requests to /check_user (no decorator, but global limit 10/3s)
for _ in range(5):
await client.get("/check_user", params={"user_id": 1})
# First request to /add_card — should pass (its own separate limit)
payload = {
"choice_A": "IndepA",
"choice_B": "IndepB",
"author_id": random.randint(100000000, 999999999)
}
resp = await client.post("/add_card", json=payload)
assert resp.status_code != 429, (
f"Request to /add_card blocked after requests to /check_user: {resp.status_code}"
)
class TestRateLimitParallel:
"""Rate limit tests with parallel requests."""
@pytest.mark.asyncio(loop_scope="session")
async def test_parallel_requests_hit_rate_limit(self):
"""
Multiple parallel requests should lead to 429 for some of them.
Send 15 parallel requests with global limit of 10/3s.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
tasks = [
client.get("/check_user", params={"user_id": 1})
for _ in range(15)
]
responses = await asyncio.gather(*tasks)
statuses = [r.status_code for r in responses]
count_200 = statuses.count(200)
count_429 = statuses.count(429)
print(f"\nParallel requests: 200={count_200}, 429={count_429}")
assert count_429 > 0, (
f"No request received 429 during 15 parallel requests: {statuses}"
)
assert count_200 > 0, (
f"All requests were blocked, none passed: {statuses}"
)
# ========================================================================
# PENETRATION DETECTION TESTS
# ========================================================================
class TestPenetrationDetection:
"""
Tests for malicious request detection.
enable_penetration_detection=True
auto_ban_threshold=3
auto_ban_duration=3600
"""
@pytest.mark.asyncio(loop_scope="session")
async def test_sql_injection_detected(self):
"""SQL injection in query parameters should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/get_card",
params={"card_id": "1 OR 1=1; DROP TABLE users;--"}
)
print(f"\nSQL injection test: status={resp.status_code} | text={resp.text[:200]}")
# Expect: 400 (suspicious activity) or 422 (validation) — but NOT 200
assert resp.status_code in (400, 403, 422), (
f"SQL injection was not blocked, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_xss_in_query_params_detected(self):
"""XSS attack in query parameters should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/get_card",
params={"card_id": "<script>alert('XSS')</script>"}
)
print(f"\nXSS in params test: status={resp.status_code} | text={resp.text[:200]}")
assert resp.status_code in (400, 403, 422), (
f"XSS attack was not detected, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_path_traversal_detected(self):
"""Path traversal attack should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get("/get_card/../../../etc/passwd")
print(f"\nPath traversal test: status={resp.status_code} | text={resp.text[:200]}")
# Can be 400, 403, 404, or 422 — but MUST NOT expose file contents
assert resp.status_code != 200 or "root:" not in resp.text, (
"Path traversal not detected — system file accessed!"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_xss_in_post_body_detected(self):
"""XSS attack in POST body should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
payload = {
"choice_A": "<script>document.cookie</script>",
"choice_B": "Normal option",
"author_id": random.randint(100000000, 999999999)
}
resp = await client.post("/add_card", json=payload)
print(f"\nXSS in body test: status={resp.status_code} | text={resp.text[:200]}")
# 400 (suspicious), 403 (banned), or 422 — but not 201
assert resp.status_code in (400, 403, 422), (
f"XSS in request body was not detected, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_command_injection_detected(self):
"""Command injection attempt should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
payload = {
"choice_A": "; cat /etc/passwd; echo",
"choice_B": "$(whoami)",
"author_id": random.randint(100000000, 999999999)
}
resp = await client.post("/add_card", json=payload)
print(f"\nCommand injection test: status={resp.status_code} | text={resp.text[:200]}")
# 400 (suspicious), 403 (banned) — not 201
assert resp.status_code in (400, 403, 422), (
f"Command injection was not detected, got {resp.status_code}: {resp.text[:200]}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_sql_union_injection_detected(self):
"""UNION-based SQL injection should be detected."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/get_card",
params={"card_id": "1 UNION SELECT password FROM users"}
)
print(f"\nUNION SQL injection test: status={resp.status_code} | text={resp.text[:200]}")
assert resp.status_code in (400, 403, 422), (
f"UNION SQL injection was not blocked, got {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_legitimate_request_not_blocked(self):
"""Legitimate request with normal data should not be blocked as suspicious."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get("/get_card", params={"card_id": 1})
print(f"\nLegitimate request test: status={resp.status_code}")
# 200 (card found) or 404 (not found) — but not 400/403
assert resp.status_code in (200, 404), (
f"Legitimate request blocked: {resp.status_code}: {resp.text[:200]}"
)
class TestAutoIPBan:
"""
Tests for automatic IP banning after repeated suspicious requests.
auto_ban_threshold=3, auto_ban_duration=3600
"""
@pytest.mark.asyncio(loop_scope="session")
async def test_repeated_attacks_trigger_ip_ban(self):
"""
After auto_ban_threshold (3) suspicious requests, the IP should be banned.
Subsequent requests (even legitimate ones) should return 403.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Send suspicious requests sequentially (SQL injection variants)
injection_payloads = [
"1' OR '1'='1",
"1; DROP TABLE cards;--",
"1 UNION SELECT * FROM users;--",
"1' AND 1=CONVERT(int,(SELECT TOP 1 name FROM sysobjects));--",
]
detected_as_suspicious = 0
for payload in injection_payloads:
resp = await client.get("/get_card", params={"card_id": payload})
if resp.status_code in (400, 403):
detected_as_suspicious += 1
print(f" Attack attempt: status={resp.status_code} | payload={payload[:50]}")
print(f"\nSuspicious requests detected: {detected_as_suspicious}/{len(injection_payloads)}")
if detected_as_suspicious >= 3:
# Threshold reached — verify ban on legitimate request
resp = await client.get("/check_user", params={"user_id": 1})
print(f"Post-attack legitimate request: status={resp.status_code}")
assert resp.status_code == 403, (
f"IP should be banned after {detected_as_suspicious} suspicious requests, "
f"but legitimate request returned {resp.status_code}: {resp.text[:200]}"
)
else:
pytest.skip(
f"Only {detected_as_suspicious} of {len(injection_payloads)} attacks detected, "
f"ban threshold (3) not reached"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_banned_ip_returns_403_on_all_endpoints(self):
"""
If IP is banned, all endpoints should return 403.
"""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
# Send various attacks to guarantee hitting the threshold
attacks = [
"1' OR '1'='1; --",
"1; DROP TABLE cards; --",
"<script>alert(1)</script>",
"../../etc/shadow",
"1 UNION SELECT password FROM users",
]
detected_count = 0
for payload in attacks:
resp = await client.get("/get_card", params={"card_id": payload})
if resp.status_code in (400, 403):
detected_count += 1
print(f" [{payload[:40]}] status={resp.status_code}")
print(f"\nDetected: {detected_count}/{len(attacks)}")
if detected_count >= 3:
# Check ban on different endpoints
endpoints = [
("GET", "/check_user", {"user_id": 99999}),
("GET", "/get_user", {"user_id": 99999}),
("GET", "/get_card", {"card_id": 1}),
]
for method, path, params in endpoints:
resp = await client.get(path, params=params)
assert resp.status_code == 403, (
f"IP is banned, but {method} {path} returned {resp.status_code}"
)
else:
pytest.skip(
f"Only {detected_count} attacks detected, ban threshold (3) not reached"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_banned_ip_message(self):
"""Banned IP should receive 'IP address banned' message."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
attacks = [
"1' OR '1'='1; --",
"1; DROP TABLE cards; --",
"1 UNION SELECT password FROM users",
"<script>alert(1)</script>",
]
detected = 0
for payload in attacks:
resp = await client.get("/get_card", params={"card_id": payload})
if resp.status_code in (400, 403):
detected += 1
if detected >= 3:
resp = await client.get("/check_user", params={"user_id": 1})
assert resp.status_code == 403
assert "IP address banned" in resp.text, (
f"Expected message 'IP address banned', got: {resp.text[:200]}"
)
else:
pytest.skip(f"Only {detected} attacks detected, threshold not reached")
class TestSuspiciousHeaders:
"""Tests for suspicious header detection."""
@pytest.mark.asyncio(loop_scope="session")
async def test_suspicious_user_agent(self):
"""Request with suspicious User-Agent may be blocked."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/check_user",
params={"user_id": 1},
headers={"User-Agent": "sqlmap/1.6.12#stable (http://sqlmap.org)"}
)
print(f"\nSuspicious UA test: status={resp.status_code}")
# sqlmap is a known SQL injection tool
# Expect block (403) or pass (200 — if UA is not in blocklist)
assert resp.status_code in (200, 400, 403), (
f"Unexpected status code for suspicious User-Agent: {resp.status_code}"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_xss_in_headers(self):
"""XSS attack via custom headers."""
async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client:
resp = await client.get(
"/check_user",
params={"user_id": 1},
headers={"X-Forwarded-For": "<script>alert(1)</script>"}
)
print(f"\nXSS in headers test: status={resp.status_code}")
# Header may be ignored or detected as suspicious
assert resp.status_code in (200, 400, 403), (
f"Unexpected status code for XSS in headers: {resp.status_code}"
)
+151
View File
@@ -0,0 +1,151 @@
"""
Tests for Telegram initData HMAC-SHA256 validation (tg_auth module).
These tests directly exercise the ``validate_init_data`` function with
synthetic initData, covering happy-path and all failure modes.
"""
import hashlib
import hmac
import json
import time
from urllib.parse import urlencode
import pytest
from fastapi import HTTPException
from tg_auth import validate_init_data
import os
BOT_TOKEN = os.getenv("TG_BOT_TOKEN", "test:mock_token_for_testing_12345")
def _build_init_data(
bot_token: str,
user: dict,
auth_date: int | None = None,
tamper_hash: bool = False,
omit_hash: bool = False,
omit_user: bool = False,
) -> str:
"""Helper that constructs a valid (or intentionally broken) initData string."""
if auth_date is None:
auth_date = int(time.time())
params: dict[str, str] = {
"auth_date": str(auth_date),
}
if not omit_user:
params["user"] = json.dumps(user)
# Build data-check-string (sorted, \n-separated).
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items()))
# secret_key = HMAC-SHA256("WebAppData", bot_token)
secret_key = hmac.new(
key=b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
if tamper_hash:
computed_hash = "a" * 64 # obviously wrong
if not omit_hash:
params["hash"] = computed_hash
return urlencode(params)
VALID_USER = {
"id": 123456789,
"first_name": "Igor",
"last_name": "Volochay",
"username": "IgorVolochay",
"photo_url": "https://t.me/photo.jpg",
}
# ── Happy path ──────────────────────────────────────────────────────────
def test_valid_init_data():
raw = _build_init_data(BOT_TOKEN, VALID_USER)
result = validate_init_data(raw, BOT_TOKEN)
assert result["user_id"] == 123456789
assert result["username"] == "IgorVolochay"
assert result["first_name"] == "Igor"
assert result["last_name"] == "Volochay"
assert result["photo_url"] == "https://t.me/photo.jpg"
# ── Failure modes ───────────────────────────────────────────────────────
def test_empty_init_data():
with pytest.raises(HTTPException) as exc:
validate_init_data("", BOT_TOKEN)
assert exc.value.status_code == 403
def test_missing_hash():
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_hash=True)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "hash" in str(exc.value.detail).lower()
def test_tampered_hash():
raw = _build_init_data(BOT_TOKEN, VALID_USER, tamper_hash=True)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "signature" in str(exc.value.detail).lower()
def test_expired_auth_date():
old_date = int(time.time()) - 7200 # 2 hours ago
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=old_date)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN, max_age=3600)
assert exc.value.status_code == 403
assert "expired" in str(exc.value.detail).lower()
def test_missing_user():
raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_user=True)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "user" in str(exc.value.detail).lower()
def test_missing_user_id():
user_no_id = {"first_name": "Igor", "username": "test"}
raw = _build_init_data(BOT_TOKEN, user_no_id)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, BOT_TOKEN)
assert exc.value.status_code == 403
assert "user.id" in str(exc.value.detail).lower()
def test_wrong_bot_token():
raw = _build_init_data(BOT_TOKEN, VALID_USER)
with pytest.raises(HTTPException) as exc:
validate_init_data(raw, "wrong:token")
assert exc.value.status_code == 403
def test_fresh_auth_date_passes():
"""auth_date exactly 5 seconds ago should be fine with default max_age."""
recent = int(time.time()) - 5
raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=recent)
result = validate_init_data(raw, BOT_TOKEN)
assert result["user_id"] == 123456789
+154
View File
@@ -0,0 +1,154 @@
import random
import pytest
from main import app
from schemas.api_schemas import *
from schemas.base_schemas import *
from httpx import AsyncClient, ASGITransport
EXIST_USER = random.randint(100000000, 1000000000)
NON_EXIST_USER = random.randint(100000000, 1000000000)
# TEST ADD USERS UTILS #
@pytest.mark.asyncio(loop_scope="session")
async def test_add_user_non_full_data():
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 422
@pytest.mark.asyncio(loop_scope="session")
async def test_add_user_negative_int_id():
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": -1,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 422
@pytest.mark.asyncio(loop_scope="session")
async def test_add_new_user():
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 201
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert User.model_validate(response.result)
@pytest.mark.asyncio(loop_scope="session")
async def test_add_already_exist_user():
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 409
response = BaseResponse.model_validate(raw_response.json())
assert response.error == True
assert response.result == "User already exist"
# TEST CHECK USERS UTILS #
@pytest.mark.asyncio(loop_scope="session")
async def test_check_non_exist_user():
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}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 200
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert response.result == False
@pytest.mark.asyncio(loop_scope="session")
async def test_check_exist_user():
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}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 200
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert response.result == True
# TEST GET USERS UTILS #
@pytest.mark.asyncio(loop_scope="session")
async def test_get_non_exist_user():
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}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 404
response = BaseResponse.model_validate(raw_response.json())
assert response.error == True
assert response.result == "User doesn't exist"
@pytest.mark.asyncio(loop_scope="session")
async def test_get_exist_user():
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}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 200
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert User.model_validate(response.result)
+106
View File
@@ -0,0 +1,106 @@
import random
import asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
from schemas.base_schemas import *
from schemas.api_schemas import *
EXIST_USER = random.randint(100000000, 1000000000)
EXIST_AUTHOR = random.randint(100000000, 1000000000)
NO_ACTIVE_CARDS_STATUS = False
ACTIVE_CARDS_LESS_THAN_TEN = False
# ------------- /add_user ---------------
@pytest.mark.asyncio(loop_scope="session")
async def test_add_new_user():
async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 201
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert User.model_validate(response.result)
# ---------- /get_random_cards ----------
@pytest.mark.asyncio(loop_scope="session")
async def test_get_random_cards_valid():
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 "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")
else:
assert response.status_code == 200
result = response.json().get("result")
assert isinstance(result, list)
if len(result) == 10:
for card in result:
assert "choice_A" in card
assert "choice_B" in card
assert "author_id" in card
assert "card_id" in card
else:
global ACTIVE_CARDS_LESS_THAN_TEN
ACTIVE_CARDS_LESS_THAN_TEN = True
pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
@pytest.mark.asyncio(loop_scope="session")
async def test_get_random_cards_randomness():
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}
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
View File
@@ -0,0 +1,172 @@
"""
Telegram Mini App initData authentication module.
Validates initData from the Telegram WebApp using HMAC-SHA256
per the official specification:
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
In DEV_MODE (default) authentication is skipped — user_id is taken
from the request body / query parameters as-is.
"""
import hashlib
import hmac
import json
import os
import time
from typing import Optional
from urllib.parse import parse_qs
from dotenv import load_dotenv
from fastapi import HTTPException, Request, status
from logger import logger
load_dotenv()
DEV_MODE: bool = os.getenv("DEV_MODE", "false").lower() == "true"
TG_BOT_TOKEN: str = os.getenv("TG_BOT_TOKEN", "")
# Maximum allowed age of initData in seconds (1 hour).
INIT_DATA_MAX_AGE: int = int(os.getenv("INIT_DATA_MAX_AGE", "3600"))
def validate_init_data(
init_data_raw: str,
bot_token: str,
max_age: int = INIT_DATA_MAX_AGE,
) -> dict:
"""
Validates Telegram Mini App initData and returns the parsed ``user`` dict.
Algorithm (per Telegram docs):
1. Parse the query-string into key→value pairs.
2. Extract the ``hash`` value; build ``data-check-string`` from the
remaining fields sorted by key, joined with ``\\n``.
3. ``secret_key = HMAC-SHA256(bot_token, "WebAppData")``
4. ``computed = HMAC-SHA256(data_check_string, secret_key)``
5. Compare ``computed`` with ``hash`` using constant-time comparison.
6. Optionally verify ``auth_date`` freshness.
Returns a dict with keys: user_id, username, first_name, last_name, photo_url.
Raises ``HTTPException(403)`` on any validation failure.
"""
if not init_data_raw:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing initData",
)
parsed = parse_qs(init_data_raw, keep_blank_values=True)
# parse_qs returns lists — flatten to single values.
flat: dict[str, str] = {k: v[0] for k, v in parsed.items()}
received_hash = flat.pop("hash", None)
if not received_hash:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing hash in initData",
)
# Build data-check-string: sorted key=value pairs joined by \n.
data_check_string = "\n".join(
f"{k}={v}" for k, v in sorted(flat.items())
)
# secret_key = HMAC-SHA256("WebAppData", bot_token)
secret_key = hmac.new(
key=b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed_hash, received_hash):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid initData signature",
)
# Verify auth_date freshness.
auth_date_str = flat.get("auth_date")
if auth_date_str:
try:
auth_date = int(auth_date_str)
if time.time() - auth_date > max_age:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="initData expired",
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid auth_date",
)
# Extract user data.
user_raw = flat.get("user")
if not user_raw:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing user in initData",
)
try:
user = json.loads(user_raw)
except (json.JSONDecodeError, TypeError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid user JSON in initData",
)
user_id = user.get("id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing user.id in initData",
)
return {
"user_id": int(user_id),
"username": user.get("username", ""),
"first_name": user.get("first_name", ""),
"last_name": user.get("last_name", ""),
"photo_url": user.get("photo_url", ""),
}
async def get_current_user_id(request: Request) -> Optional[int]:
"""
FastAPI dependency that resolves the authenticated user_id.
- **DEV_MODE=true**: returns ``None`` — endpoints use user_id from
body/params as before (backward compatible).
- **DEV_MODE=false**: reads ``X-Init-Data`` header, validates it
via HMAC-SHA256, and returns the verified ``user_id``.
"""
if DEV_MODE:
return None
init_data_raw = request.headers.get("X-Init-Data", "")
if not init_data_raw:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="X-Init-Data header is required",
)
if not TG_BOT_TOKEN:
logger.error("TG_BOT_TOKEN is not set but DEV_MODE is disabled")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Server authentication misconfiguration",
)
user_data = validate_init_data(init_data_raw, TG_BOT_TOKEN)
return user_data["user_id"]
+188
View File
@@ -0,0 +1,188 @@
"""
Telegram card moderation bot.
Listens to the RabbitMQ "moderation" queue and sends cards
to the admin chat with inline buttons "Accept ✅" / "Reject ❌".
When a button is pressed, the bot calls protected endpoints
/card_accept or /card_reject with a secret header.
"""
import os
import json
import asyncio
from datetime import datetime
import aiohttp
from dotenv import load_dotenv
from aiogram import Bot, Dispatcher, F
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from schemas.base_schemas import Card
from rabbit_worker import RabbitWorker
from logger import logger, setup_logging
load_dotenv()
setup_logging()
# ── Configuration ──────────────────────────────────────────────
BOT_TOKEN = os.getenv("TG_BOT_TOKEN") or ""
ADMIN_CHAT_ID = int(os.getenv("TG_ADMIN_CHAT_ID", "0"))
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5000")
MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production")
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
rabbit = RabbitWorker()
# ── Sending card to admin ──────────────────────────
async def _get_author_username(author_id: int) -> str:
"""Fetches the author's username via API."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{API_BASE_URL}/get_user", params={"user_id": author_id}
) as resp:
if resp.status == 200:
data = await resp.json()
username = data.get("result", {}).get("username", "")
if username:
return f"@{username}"
except Exception as exc:
logger.warning("Failed to fetch username for {}: {}", author_id, exc)
return str(author_id)
def _format_date(iso_date: str) -> str:
"""Converts ISO date to DD.MM.YYYY HH:MM:SS format."""
try:
dt = datetime.fromisoformat(iso_date)
return dt.strftime("%d.%m.%Y %H:%M:%S")
except (ValueError, TypeError):
return iso_date
async def send_card_to_admin(card: Card) -> None:
"""Formats message and inline keyboard for a card."""
author_display = await _get_author_username(card.author_id)
date_display = _format_date(card.creation_date)
text = (
f"🆕 <b>Новая карточка #{card.card_id}</b>\n\n"
f"🅰️ {card.choice_A}\n"
f"🅱️ {card.choice_B}\n\n"
f"👤 Автор: {author_display}\n"
f"📅 Создана: {date_display}"
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="Принять ✅",
callback_data=f"accept:{card.card_id}",
),
InlineKeyboardButton(
text="Отклонить ❌",
callback_data=f"reject:{card.card_id}",
),
]
]
)
await bot.send_message(
chat_id=ADMIN_CHAT_ID,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info("Sent card {} to admin chat", card.card_id)
# ── Calling protected API endpoints ──────────────────────────
async def call_moderation_api(action: str, card_id: int) -> dict:
"""
Calls /card_accept or /card_reject with secret header.
action: 'accept' | 'reject'
"""
endpoint = f"{API_BASE_URL}/card_{action}"
headers = {"X-Moderation-Secret": MODERATION_SECRET}
params = {"card_id": card_id}
async with aiohttp.ClientSession() as session:
async with session.patch(endpoint, headers=headers, params=params) as resp:
data = await resp.json()
return data
# ── Callback button handlers ───────────────────────────────
@dp.callback_query(F.data.startswith("accept:"))
async def on_accept(callback: CallbackQuery) -> None:
"""Handles the 'Accept' inline button click for a card."""
if not callback.data or not isinstance(callback.message, Message):
return
card_id = int(callback.data.split(":")[1])
result = await call_moderation_api("accept", card_id)
if result.get("error"):
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
return
orig_text = callback.message.text or ""
await callback.message.edit_text(
orig_text + "\n\n✅ <b>ПРИНЯТА</b>",
parse_mode="HTML",
)
await callback.answer("Карточка принята!")
logger.info("Card {} accepted by admin", card_id)
@dp.callback_query(F.data.startswith("reject:"))
async def on_reject(callback: CallbackQuery) -> None:
"""Handles the 'Reject' inline button click for a card."""
if not callback.data or not isinstance(callback.message, Message):
return
card_id = int(callback.data.split(":")[1])
result = await call_moderation_api("reject", card_id)
if result.get("error"):
await callback.answer(f"Ошибка: {result['result']}", show_alert=True)
return
orig_text = callback.message.text or ""
await callback.message.edit_text(
orig_text + "\n\n❌ <b>ОТКЛОНЕНА</b>",
parse_mode="HTML",
)
await callback.answer("Карточка отклонена!")
logger.info("Card {} rejected by admin", card_id)
# ── aiogram Lifecycle hooks ────────────────────────────────────
_rabbit_task: asyncio.Task | None = None
@dp.startup()
async def on_startup() -> None:
"""Starts the RabbitMQ consumer task when the bot starts."""
global _rabbit_task
_rabbit_task = asyncio.create_task(
rabbit.consume_moderation(send_card_to_admin)
)
logger.info("Moderation bot started, RabbitMQ consumer running")
@dp.shutdown()
async def on_shutdown() -> None:
"""Cancels the RabbitMQ consumer task when the bot shuts down."""
if _rabbit_task:
_rabbit_task.cancel()
try:
await _rabbit_task
except asyncio.CancelledError:
pass
logger.info("Moderation bot stopped")
if __name__ == "__main__":
dp.run_polling(bot)
View File
+74
View File
@@ -0,0 +1,74 @@
import argparse
import sys
import json
from datetime import datetime
sys.path.append('..')
from mongo_worker import MongoWorker
from schemas.base_schemas import Card
def create_cards(num_cards=10, author_id=1):
cards_list = []
for card_id in range(1, num_cards + 1):
print(f"Creating Card {card_id}")
card = Card(
card_id=card_id,
choice_A=input("Choice A: "),
choice_B=input("Choice B: "),
author_id=author_id,
creation_date=datetime.now().isoformat(),
moderation_date=datetime.now().isoformat(),
active_status=True
)
cards_list.append(card)
return cards_list
def read_json(json_file):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return [Card(**card) for card in data]
except Exception as e:
print(f"Error reading JSON file: {e}")
return []
def write_json(cards_list, json_file):
try:
with open(json_file, 'w', encoding='utf-8') as f:
json.dump([card.model_dump() for card in cards_list], f, ensure_ascii=False, indent=4)
print(f"Successfully saved {len(cards_list)} cards to {json_file}")
except Exception as e:
print(f"Error writing to JSON file: {e}")
import asyncio
async def add_cards_to_mongodb(cards_list):
mongo = MongoWorker()
for card in cards_list:
await mongo.add_card_by_base_model(card)
print(f"Successfully added {len(cards_list)} cards to MongoDB")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('-a', '--action', type=int, required=True, choices=[0, 1, 2],
help="0 - Manual input & save to MongoDB, 1 - Manual input & save to JSON, 2 - Read JSON & save to MongoDB")
parser.add_argument('-f', '--file', type=str, default="cards.json", help="JSON file name for reading/writing")
parser.add_argument('-n', '--num', type=int, default=10, help="Number of cards to create")
parser.add_argument('-u', '--user', type=int, default=1, help="Author ID")
args = parser.parse_args()
if args.action == 0:
cards = create_cards(args.num, args.user)
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:
asyncio.run(add_cards_to_mongodb(cards))
else:
print("No valid cards found in JSON file.")
+24
View File
@@ -0,0 +1,24 @@
import re
from tools.data.dirty_words import dirty_words_set
def is_not_empty(text: str) -> bool:
"""Checks that the text is not empty."""
return bool(text.strip())
def has_no_links(text: str) -> bool:
"""Checks that there are no links in the text."""
url_pattern = re.compile(r'https?://\S+|www\.\S+')
return not bool(url_pattern.search(text))
def has_no_dirty_words(text: str) -> bool:
"""Checks that there are no forbidden words in the text."""
words = set(re.findall(r'\w+', text.lower()))
return not bool(words & dirty_words_set)
def text_lenght(text: str) -> bool:
"""Check max text lenght"""
return len(text) <= 150 # TODO: update in future
def moderate_text(text: str) -> bool:
return is_not_empty(text) and has_no_links(text) and has_no_dirty_words(text) and text_lenght(text)
+152
View File
@@ -0,0 +1,152 @@
[
{
"card_id": 1,
"choice_A": "Котики",
"choice_B": "Собачки",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:05.290110",
"moderation_date": "2025-03-04T16:56:05.290143",
"active_status": true
},
{
"card_id": 2,
"choice_A": "Чай",
"choice_B": "Кофе",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:11.108762",
"moderation_date": "2025-03-04T16:56:11.108793",
"active_status": true
},
{
"card_id": 3,
"choice_A": "Отпуск в горах",
"choice_B": "Отпуск на море",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:25.062542",
"moderation_date": "2025-03-04T16:56:25.062586",
"active_status": true
},
{
"card_id": 4,
"choice_A": "День",
"choice_B": "Ночь",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:34.148692",
"moderation_date": "2025-03-04T16:56:34.148725",
"active_status": true
},
{
"card_id": 5,
"choice_A": "Работать в офисе",
"choice_B": "Работать удалённо",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:57:00.723472",
"moderation_date": "2025-03-04T16:57:00.723518",
"active_status": true
},
{
"card_id": 6,
"choice_A": "Заниматься спортом",
"choice_B": "Играть в видеоигры",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:57:31.524721",
"moderation_date": "2025-03-04T16:57:31.524754",
"active_status": true
},
{
"card_id": 7,
"choice_A": "Гулять",
"choice_B": "Сидеть дома",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:57:52.022380",
"moderation_date": "2025-03-04T16:57:52.022413",
"active_status": true
},
{
"card_id": 8,
"choice_A": "Посмотреть фильм",
"choice_B": "Почитать книжку",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:58:15.032749",
"moderation_date": "2025-03-04T16:58:15.032762",
"active_status": true
},
{
"card_id": 9,
"choice_A": "Зима",
"choice_B": "Лето",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:58:24.800416",
"moderation_date": "2025-03-04T16:58:24.800450",
"active_status": true
},
{
"card_id": 10,
"choice_A": "Лучший друг/подруга",
"choice_B": "Любимый человек",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:59:05.265795",
"moderation_date": "2025-03-04T16:59:05.265829",
"active_status": true
}
]
File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
services:
mongodb:
image: mongo
container_name: tort-mongodb
restart: always
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: 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
rabbitmq:
condition: service_healthy
environment:
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}
TG_BOT_TOKEN: ${TG_BOT_TOKEN}
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_ADMIN_CHAT_ID: ${TG_ADMIN_CHAT_ID}
API_BASE_URL: http://backend:5000
MODERATION_SECRET: ${MODERATION_SECRET}
TG_BOT_TOKEN: ${TG_BOT_TOKEN}
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
+17346
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"cra-template": "1.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:5000",
"devDependencies": {
"web-vitals": "^4.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<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" />
<!-- Telegram WebApp SDK -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<title>This OR That</title>
</head>
<body>
<noscript>Для работы приложения необходим JavaScript.</noscript>
<div id="root"></div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+58
View File
@@ -0,0 +1,58 @@
/* ---------- App Layout ---------- */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
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 {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.3px;
user-select: none;
}
.app-logo-or {
display: inline-block;
margin: 0 3px;
padding: 1px 6px;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
border-radius: 6px;
font-size: 12px;
vertical-align: middle;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.3);
}
/* Hamburger menu */
.menu-toggle {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 36px;
height: 36px;
padding: 8px;
border-radius: var(--radius-sm);
transition: background var(--duration-fast) var(--ease-smooth);
}
.menu-toggle:active {
background: rgba(255, 255, 255, 0.08);
}
.menu-toggle-line {
display: block;
width: 100%;
height: 2px;
background: var(--color-text);
border-radius: 1px;
transition: transform var(--duration-normal) var(--ease-smooth);
}
+65
View File
@@ -0,0 +1,65 @@
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';
import CommentsPanel from './components/Comments/CommentsPanel';
import MenuPanel from './components/Menu/MenuPanel';
import './App.css';
function AppContent() {
const { isLoading, error, isBanned, handleRetryAfterBan, openMenu, toast } = useApp();
if (isBanned) {
return <BannedScreen onRetry={handleRetryAfterBan} />;
}
if (isLoading) {
return <LoadingScreen />;
}
if (error && !isLoading) {
return <LoadingScreen error={error} />;
}
return (
<div className="app">
{/* Header with menu button */}
<header className="app-header">
<div className="app-logo">
this<span className="app-logo-or">OR</span>that
</div>
<button className="menu-toggle" onClick={openMenu} aria-label="Меню">
<span className="menu-toggle-line" />
<span className="menu-toggle-line" />
<span className="menu-toggle-line" />
</button>
</header>
{/* Main content — card pair */}
<CardPair />
{/* Bottom bar — reactions */}
<BottomBar />
{/* Panels */}
<CommentsPanel />
<MenuPanel />
{/* Toast notifications */}
<Toast message={toast} />
</div>
);
}
function App() {
return (
<AppProvider>
<AppContent />
</AppProvider>
);
}
export default App;
+15
View File
@@ -0,0 +1,15 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
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();
});
@@ -0,0 +1,72 @@
.bottom-bar {
display: flex;
align-items: center;
justify-content: space-around;
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;
}
.bar-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
transition: color var(--duration-fast) var(--ease-smooth),
transform var(--duration-fast) var(--ease-smooth);
color: var(--color-muted);
}
.bar-btn:active:not(:disabled) {
transform: scale(0.92);
}
.bar-btn:disabled:not(.bar-btn--active) {
opacity: 0.35;
cursor: default;
}
.bar-btn--disabled.bar-btn--active,
.bar-btn:disabled.bar-btn--active {
opacity: 1;
cursor: default;
}
/* Active states */
.bar-btn--like.bar-btn--active {
color: var(--color-like);
animation: popIn 300ms var(--ease-spring);
}
.bar-btn--dislike.bar-btn--active {
color: var(--color-dislike);
animation: popIn 300ms var(--ease-spring);
}
.bar-btn--comments {
color: var(--color-text);
opacity: 0.9;
}
.bar-icon {
width: 22px;
height: 22px;
}
.bar-count {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
line-height: 1;
}
@keyframes popIn {
0% { transform: scale(1); }
40% { transform: scale(1.25); }
100% { transform: scale(1); }
}
@@ -0,0 +1,90 @@
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 [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 = alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null;
const handleLike = async () => {
if (!isRevealed || currentReaction || isSubmitting) return;
setIsSubmitting(true);
try {
await likeCard();
} finally {
setIsSubmitting(false);
}
};
const handleDislike = async () => {
if (!isRevealed || currentReaction || isSubmitting) return;
setIsSubmitting(true);
try {
await dislikeCard();
} finally {
setIsSubmitting(false);
}
};
const handleComments = () => {
hapticImpact('light');
setIsCommentsOpen(true);
};
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 || 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)' }}>
<path d="M17 4h2a2 2 0 012 2v7a2 2 0 01-2 2h-2.29a1 1 0 00-.71.3l-3.28 3.28a1 1 0 01-1.72-.7V15a1 1 0 00-1-1H7a2 2 0 01-2-2V6a2 2 0 012-2h10z" />
</svg>
<span className="bar-count">{formatCount(dislikes)}</span>
</button>
<button
className="bar-btn bar-btn--comments"
onClick={handleComments}
aria-label="Комментарии"
>
<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(commentsCount)}</span>
</button>
<button
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">
<path d="M17 4h2a2 2 0 012 2v7a2 2 0 01-2 2h-2.29a1 1 0 00-.71.3l-3.28 3.28a1 1 0 01-1.72-.7V15a1 1 0 00-1-1H7a2 2 0 01-2-2V6a2 2 0 012-2h10z" />
</svg>
<span className="bar-count">{formatCount(likes)}</span>
</button>
</div>
);
}
function formatCount(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
return String(n);
}
+112
View File
@@ -0,0 +1,112 @@
.card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-lg);
border-radius: var(--radius-card);
cursor: pointer;
transition: flex-grow var(--duration-card) var(--ease-spring),
opacity 400ms var(--ease-smooth),
filter 400ms var(--ease-smooth);
overflow: hidden;
min-height: 0;
}
/* Card A — Deep Violet gradient */
.card--a {
background: linear-gradient(160deg, var(--color-card-a-from), var(--color-card-a-to));
}
/* Card B — Electric Blue gradient */
.card--b {
background: linear-gradient(160deg, var(--color-card-b-from), var(--color-card-b-to));
}
/* Tap feedback */
.card:active {
transform: scale(0.98);
transition: transform 80ms ease;
}
/* After choice — faded (not chosen) */
.card--faded {
opacity: 0.65;
filter: brightness(0.8) saturate(0.85);
}
/* After choice — chosen */
.card--chosen {
opacity: 1;
}
/* Tap hint on chosen card */
.card--tap-next {
cursor: pointer;
}
/* ---------- Text ---------- */
.card-text {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
color: var(--color-text);
text-align: center;
line-height: 1.3;
word-break: break-word;
max-width: 280px;
}
/* ---------- Stats ---------- */
.card-stats {
position: absolute;
bottom: var(--space-md);
right: var(--space-md);
text-align: right;
animation: statsReveal 500ms var(--ease-spring) forwards;
}
.card-percent {
display: block;
font-family: var(--font-mono);
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.card--a .card-percent { color: var(--color-stat-a); }
.card--b .card-percent { color: var(--color-stat-b); }
.card-count {
display: block;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--color-muted);
margin-top: 2px;
}
/* ---------- Hint ---------- */
.card-hint {
position: absolute;
bottom: var(--space-sm);
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: var(--color-muted);
opacity: 0;
animation: hintFadeIn 600ms 800ms var(--ease-smooth) forwards;
white-space: nowrap;
}
/* ---------- Animations ---------- */
@keyframes statsReveal {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes hintFadeIn {
from { opacity: 0; }
to { opacity: 0.6; }
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import './Card.css';
export default function Card({ label, type, chosen, isChosen, percent, count, onClick }) {
const isRevealed = chosen !== null;
const isThis = chosen === type;
const isFaded = isRevealed && !isThis;
const cardClass = [
'card',
`card--${type.toLowerCase()}`,
isRevealed ? 'card--revealed' : '',
isThis ? 'card--chosen' : '',
isFaded ? 'card--faded' : '',
isChosen ? 'card--tap-next' : '',
].filter(Boolean).join(' ');
return (
<button className={cardClass} onClick={onClick} aria-label={label}>
<span className="card-text">{label}</span>
{isRevealed && (
<div className="card-stats">
<span className="card-percent">{percent}%</span>
<span className="card-count">{count}</span>
</div>
)}
{isChosen && isRevealed && (
<div className="card-hint">
нажми чтобы продолжить
</div>
)}
</button>
);
}
@@ -0,0 +1,123 @@
.card-pair {
flex: 1;
display: flex;
flex-direction: column;
padding: var(--space-md);
padding-bottom: var(--space-sm);
min-height: 0;
}
.card-pair-inner {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
min-height: 0;
}
.card-wrapper {
display: flex;
min-height: 0;
transition: flex-grow var(--duration-card) var(--ease-spring);
}
.card-wrapper .card {
flex: 1;
width: 100%;
}
/* Empty state */
.card-pair--empty {
align-items: center;
justify-content: center;
text-align: center;
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
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: 19px;
font-weight: 700;
color: var(--color-text);
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: 12px var(--space-md);
border-radius: var(--radius-sm);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
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 {
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);
}
.refresh-icon {
transition: transform 0.4s ease;
}
.refresh-icon--spinning {
animation: spin 0.8s linear infinite;
}
@@ -0,0 +1,155 @@
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, loadCards, isLoadingCards, openMenu, setMenuScreen } = useApp();
if (!currentCard) {
return (
<div className="card-pair card-pair--empty">
<div className="empty-content">
<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--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 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 empty-btn--donate"
onClick={() => {
hapticImpact('light');
openExternalLink('https://boosty.to/pseudodev/donate');
}}
>
Поддержать проект
</button>
</div>
</div>
</div>
);
}
// Calculate percentages
const total = currentCard.count_choice_A + currentCard.count_choice_B;
let percentA = 50;
let percentB = 50;
if (chosenCard) {
// 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;
if (newTotal > 0) {
percentA = Math.round((votesA / newTotal) * 100);
percentB = 100 - percentA;
}
// 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 smooth spring animation
const growA = chosenCard ? percentA : 50;
const growB = chosenCard ? percentB : 50;
// Actual display percentages
let displayPercentA = 50;
let displayPercentB = 50;
if (chosenCard && total >= 0) {
const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0);
const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0);
const newTotal = votesA + votesB;
if (newTotal > 0) {
displayPercentA = Math.round((votesA / newTotal) * 100);
displayPercentB = 100 - displayPercentA;
}
}
return (
<div className="card-pair">
<div className="card-pair-inner">
<div className="card-wrapper" style={{ flexGrow: growA }}>
<Card
label={currentCard.choice_A}
type="A"
chosen={chosenCard}
isChosen={chosenCard === 'A'}
percent={displayPercentA}
count={currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0)}
onClick={() => chooseCard('A')}
/>
</div>
<OrBadge visible={!chosenCard} />
<div className="card-wrapper" style={{ flexGrow: growB }}>
<Card
label={currentCard.choice_B}
type="B"
chosen={chosenCard}
isChosen={chosenCard === 'B'}
percent={displayPercentB}
count={currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0)}
onClick={() => chooseCard('B')}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
.or-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
transition: opacity var(--duration-normal) var(--ease-smooth),
transform var(--duration-card) var(--ease-spring);
}
.or-badge--hidden {
opacity: 0;
transform: translate(-50%, -50%) scale(0.7);
pointer-events: none;
}
.or-badge-text {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
font-family: var(--font-display);
font-size: 14px;
font-weight: 700;
color: var(--color-text);
letter-spacing: 0.5px;
}
@@ -0,0 +1,10 @@
import React from 'react';
import './OrBadge.css';
export default function OrBadge({ visible }) {
return (
<div className={`or-badge ${visible ? '' : 'or-badge--hidden'}`}>
<span className="or-badge-text">ИЛИ</span>
</div>
);
}
@@ -0,0 +1,101 @@
.comment-item {
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: 38px;
height: 38px;
border-radius: 50%;
overflow: hidden;
border: 1px solid var(--glass-border);
}
.comment-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.comment-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-card-a-from), var(--color-card-b-from));
font-family: var(--font-display);
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;
}
.comment-header {
display: flex;
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: 11px;
color: var(--color-muted);
flex-shrink: 0;
}
.comment-text {
font-size: 14px;
line-height: 1.45;
color: var(--color-text);
word-break: break-word;
}
@@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react';
import { currentUser } from '../../services/auth';
import './CommentItem.css';
export default function CommentItem({ comment, author }) {
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',
hour: '2-digit',
minute: '2-digit',
})
: '';
const initial = displayName.replace(/^@/, '')[0]?.toUpperCase() || '?';
return (
<div className={`comment-item ${isMe ? 'comment-item--me' : ''}`}>
<div className="comment-avatar">
{photoUrl && !imageError ? (
<img
src={photoUrl}
alt=""
className="comment-avatar-img"
referrerPolicy="no-referrer"
onError={() => setImageError(true)}
/>
) : (
<span className={`comment-avatar-fallback ${isMe ? 'comment-avatar-fallback--me' : ''}`}>
{initial}
</span>
)}
</div>
<div className="comment-body">
<div className="comment-header">
<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>
</div>
);
}
@@ -0,0 +1,183 @@
.comments-panel {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
flex-direction: column;
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 {
transform: translateY(0);
}
/* Header */
.comments-header {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.comments-close {
font-size: 20px;
padding: var(--space-xs);
color: var(--color-muted);
transition: color var(--duration-fast) var(--ease-smooth);
}
.comments-close:hover {
color: var(--color-text);
}
.comments-title {
font-family: var(--font-display);
font-size: 18px;
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;
overflow-y: auto;
padding: 0 var(--space-lg);
}
.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;
}
.comments-empty {
display: flex;
flex-direction: column;
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;
color: var(--color-muted);
}
.comments-empty-sub {
font-size: 13px;
color: var(--color-muted);
opacity: 0.6;
}
/* Input area */
.comments-input-area {
display: flex;
align-items: flex-end;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: var(--color-bg-elevated);
flex-shrink: 0;
}
.comments-input {
flex: 1;
resize: none;
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: 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 {
color: var(--color-muted);
}
.comments-send {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--glass-bg);
color: var(--color-muted);
transition: all var(--duration-fast) var(--ease-smooth);
flex-shrink: 0;
}
.comments-send--active {
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;
}
@@ -0,0 +1,205 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../../context/AppContext';
import { showBackButton, currentUser, hapticNotification, hapticImpact } from '../../services/auth';
import { api } from '../../services/api';
import CommentItem from './CommentItem';
import './CommentsPanel.css';
export default function CommentsPanel() {
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);
const listRef = useRef(null);
// Telegram BackButton
useEffect(() => {
if (isCommentsOpen) {
const cleanup = showBackButton(() => {
hapticImpact('light');
setIsCommentsOpen(false);
});
return cleanup;
}
}, [isCommentsOpen, setIsCommentsOpen]);
const cardId = currentCard?.card_id;
const loadComments = useCallback(async () => {
if (!cardId) return;
setIsLoading(true);
try {
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() {
const text = newComment.trim();
if (!text || isSending || !currentCard) return;
setIsSending(true);
hapticImpact('light');
try {
const result = await api.addComment(currentUser.id, currentCard.card_id, text);
handleApiResponse(result);
if (!result.error && result.result) {
setNewComment('');
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(typeof result.result === 'string' ? result.result : 'Ошибка отправки комментария');
hapticNotification('error');
}
} catch (err) {
console.error('Send comment error:', err);
showToast('Ошибка отправки');
hapticNotification('error');
} finally {
setIsSending(false);
}
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
const handleClose = () => {
hapticImpact('light');
setIsCommentsOpen(false);
};
return (
<div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}>
<div className="comments-header">
<button
className="comments-close"
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 ? (
<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>
</div>
) : (
comments.map((comment, i) => (
<CommentItem
key={comment.comment_id || i}
comment={comment}
author={authors[comment.author_id] || null}
/>
))
)}
</div>
<div className="comments-input-area">
<textarea
className="comments-input"
placeholder="Напишите комментарий..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={300}
rows={1}
disabled={isSending}
/>
<button
className={`comments-send ${newComment.trim() && !isSending ? 'comments-send--active' : ''}`}
onClick={handleSend}
disabled={!newComment.trim() || isSending}
aria-label="Отправить"
>
{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>
);
}
@@ -0,0 +1,61 @@
.about-page {
padding: var(--space-lg);
overflow-y: auto;
max-height: 80vh;
}
.about-back {
font-size: 14px;
color: var(--color-muted);
margin-bottom: var(--space-lg);
padding: var(--space-xs) 0;
transition: color var(--duration-fast) var(--ease-smooth);
}
.about-back:hover {
color: var(--color-text);
}
.about-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-bottom: var(--space-lg);
}
.about-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-bottom: var(--space-xl);
line-height: 1.65;
font-size: 14px;
color: var(--color-muted);
}
.about-content strong {
color: var(--color-text);
}
.about-links {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.about-link {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
border-radius: var(--radius-sm);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
font-size: 14px;
color: var(--color-stat-a);
transition: background var(--duration-fast) var(--ease-smooth);
}
.about-link:active {
background: rgba(255, 255, 255, 0.12);
}
@@ -0,0 +1,75 @@
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() {
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={handleBack} aria-label="Назад">
Назад
</button>
<h2 className="about-title">О проекте</h2>
<div className="about-content">
<p>
<strong>This OR That</strong> это open-source Telegram Mini App,
в которой ты выбираешь один из двух вариантов и смотришь,
что выбрали другие.
</p>
<p>
Проект создан для развлечения и исследования интересных
дилемм. Все карточки проходят модерацию перед публикацией.
</p>
<p>
Поддержи проект или загляни в исходный код
мы открыты для идей и контрибьюций!
</p>
</div>
<div className="about-links">
<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>
</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">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
<span>pseudo.developer.ru@gmail.com</span>
</button>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
.create-card {
padding: var(--space-lg);
overflow-y: auto;
max-height: 80vh;
}
.create-back {
font-size: 14px;
color: var(--color-muted);
margin-bottom: var(--space-lg);
padding: var(--space-xs) 0;
transition: color var(--duration-fast) var(--ease-smooth);
}
.create-back:hover {
color: var(--color-text);
}
.create-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
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.5;
color: #CBD5E1;
}
.create-fields {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: var(--space-lg);
}
.create-field {
position: relative;
border-radius: var(--radius-card);
padding: var(--space-lg);
min-height: 110px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.create-field--a {
background: linear-gradient(160deg, var(--color-card-a-from), var(--color-card-a-to));
}
.create-field--b {
background: linear-gradient(160deg, var(--color-card-b-from), var(--color-card-b-to));
}
.create-textarea {
width: 100%;
background: transparent;
color: var(--color-text);
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
text-align: center;
resize: none;
line-height: 1.4;
}
.create-textarea::placeholder {
color: rgba(255, 255, 255, 0.45);
}
.create-counter {
position: absolute;
bottom: var(--space-sm);
right: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
.create-submit {
width: 100%;
padding: 14px var(--space-lg);
border-radius: var(--radius-full);
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
color: var(--color-muted);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
transition: all var(--duration-normal) var(--ease-smooth);
}
.create-submit--active {
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.98);
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { useApp } from '../../context/AppContext';
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, handleApiResponse } = useApp();
const [choiceA, setChoiceA] = useState('');
const [choiceB, setChoiceB] = useState('');
const [isSending, setIsSending] = useState(false);
const canSubmit = choiceA.trim().length > 0 && choiceB.trim().length > 0 && !isSending;
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();
}, 1800);
} else {
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={handleBack} aria-label="Назад">
Назад
</button>
<h2 className="create-title">Создать карточку</h2>
<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="Вариант А"
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}
</span>
</div>
<div className="create-field create-field--b">
<textarea
className="create-textarea"
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}
</span>
</div>
</div>
<button
className={`create-submit ${canSubmit ? 'create-submit--active' : ''}`}
onClick={handleSubmit}
disabled={!canSubmit}
>
{isSending ? 'Отправка на модерацию...' : 'Отправить на модерацию'}
</button>
</div>
);
}
@@ -0,0 +1,59 @@
.menu-panel {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%) translateY(100%);
width: 100%;
max-width: 480px;
max-height: 85vh;
z-index: 60;
background: var(--color-bg-elevated);
border-radius: var(--radius-card) var(--radius-card) 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform var(--duration-normal) var(--ease-spring);
}
.menu-panel--open {
transform: translateX(-50%) translateY(0);
}
/* Menu list */
.menu-list {
display: flex;
flex-direction: column;
padding: var(--space-xl) var(--space-lg);
gap: var(--space-xs);
}
.menu-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-md);
border-radius: var(--radius-sm);
transition: background var(--duration-fast) var(--ease-smooth);
color: var(--color-text);
}
.menu-item:active {
background: rgba(255, 255, 255, 0.06);
}
.menu-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--glass-bg);
color: var(--color-muted);
flex-shrink: 0;
}
.menu-label {
font-size: 16px;
font-weight: 500;
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { showBackButton, hapticImpact, openExternalLink } from '../../services/auth';
import Overlay from '../common/Overlay';
import AboutPage from './AboutPage';
import CreateCard from './CreateCard';
import './MenuPanel.css';
export default function MenuPanel() {
const { isMenuOpen, closeMenu, menuScreen, setMenuScreen } = useApp();
// Telegram BackButton for sub-screens
useEffect(() => {
if (isMenuOpen && menuScreen !== 'menu') {
const cleanup = showBackButton(() => {
hapticImpact('light');
setMenuScreen('menu');
});
return cleanup;
}
if (isMenuOpen && menuScreen === 'menu') {
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={() => handleNavigate('menu')} />
</div>
</>
);
}
if (menuScreen === 'create') {
return (
<>
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<CreateCard onBack={() => handleNavigate('menu')} />
</div>
</>
);
}
return (
<>
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<nav className="menu-list">
<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" />
<path d="M12 16v-4M12 8h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</span>
<span className="menu-label">О проекте</span>
</button>
<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" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
<span className="menu-label">Создать карточку</span>
</button>
<button
className="menu-item"
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">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" />
</svg>
</span>
<span className="menu-label">Поддержать проект</span>
</button>
</nav>
</div>
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,58 @@
.loading-screen {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
}
.loading-content {
text-align: center;
}
.loading-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.5px;
}
.loading-or {
display: inline-block;
margin: 0 4px;
padding: 2px 10px;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
border-radius: var(--radius-sm);
font-size: 20px;
vertical-align: middle;
}
.loading-error {
margin-top: var(--space-lg);
color: var(--color-dislike);
font-size: 14px;
}
.loading-dots {
display: flex;
gap: 8px;
justify-content: center;
margin-top: var(--space-lg);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-muted);
animation: dotPulse 1.4s infinite ease-in-out both;
}
.dot:nth-child(2) { animation-delay: 0.16s; }
.dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes dotPulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
@@ -0,0 +1,23 @@
import React from 'react';
import './LoadingScreen.css';
export default function LoadingScreen({ error }) {
return (
<div className="loading-screen">
<div className="loading-content">
<h1 className="loading-title">
this<span className="loading-or">OR</span>that
</h1>
{error ? (
<p className="loading-error">{error}</p>
) : (
<div className="loading-dots">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,16 @@
.overlay {
position: fixed;
inset: 0;
background: var(--overlay-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 50;
opacity: 0;
pointer-events: none;
transition: opacity var(--duration-normal) var(--ease-smooth);
}
.overlay--visible {
opacity: 1;
pointer-events: auto;
}
@@ -0,0 +1,12 @@
import React from 'react';
import './Overlay.css';
export default function Overlay({ visible, onClick }) {
return (
<div
className={`overlay ${visible ? 'overlay--visible' : ''}`}
onClick={onClick}
aria-hidden="true"
/>
);
}
+43
View File
@@ -0,0 +1,43 @@
.toast {
position: fixed;
top: var(--space-lg);
left: 50%;
transform: translateX(-50%);
z-index: 1000;
animation: toastIn 300ms var(--ease-spring) forwards;
}
.toast-content {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.toast-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-like);
font-size: 11px;
}
.toast-text {
color: var(--color-text);
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(-50%) translateY(-12px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import './Toast.css';
export default function Toast({ message }) {
if (!message) return null;
return (
<div className="toast" role="status" aria-live="polite">
<div className="toast-content">
<span className="toast-icon"></span>
<span className="toast-text">{message}</span>
</div>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../services/api';
import { currentUser, initTelegramApp, hapticImpact, hapticNotification } from '../services/auth';
const AppContext = createContext(null);
export function AppProvider({ children }) {
// App state
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);
const [isCommentsOpen, setIsCommentsOpen] = useState(false);
const [menuScreen, setMenuScreen] = useState('menu'); // 'menu' | 'about' | 'create'
// 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;
// Toast helper
const showToast = useCallback((message, duration = 2500) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
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 (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 {
// Pool is empty or all cards seen
setCardQueue([]);
setCurrentCardIndex(0);
setChosenCard(null);
if (isManualRefresh) {
showToast('Новых карточек пока нет');
}
}
} catch (err) {
console.error('Load cards error:', err);
if (isManualRefresh) {
showToast('Ошибка загрузки карточек');
}
} finally {
setIsLoadingCards(false);
}
}, [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);
}
}, [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);
setChosenCard(null);
} else {
// Load next batch
await loadCards();
}
}, [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);
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);
handleApiResponse(userResult);
if (!userResult.error && userResult.result) {
setUser(userResult.result);
}
}
return result;
}, [currentCard, chosenCard, handleApiResponse]);
const dislikeCard = useCallback(async () => {
if (!currentCard || !chosenCard) return;
hapticImpact('light');
const result = await api.dislikeCard(currentUser.id, currentCard.card_id);
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);
handleApiResponse(userResult);
if (!userResult.error && userResult.result) {
setUser(userResult.result);
}
}
return result;
}, [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,
currentCardIndex,
isMenuOpen,
isCommentsOpen,
menuScreen,
toast,
// Actions
chooseCard,
goToNextCard,
loadCards,
likeCard,
dislikeCard,
getUserProfile,
handleApiResponse,
syncCardComments,
addCommentToCard,
openMenu,
closeMenu,
setMenuScreen,
setIsCommentsOpen,
showToast,
setError,
handleRetryAfterBan,
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
}
+150
View File
@@ -0,0 +1,150 @@
/* ============================================
This OR That — Design System
Palette: "Abyss Neon"
============================================ */
@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap');
/* ---------- Design Tokens ---------- */
:root {
/* Background */
--color-bg: #070711;
--color-bg-elevated: #0D0C1E;
/* Card A — Deep Violet */
--color-card-a-from: #1D0658;
--color-card-a-to: #7C3AED;
/* Card B — Electric Blue */
--color-card-b-from: #0F2027;
--color-card-b-to: #0284C7;
/* Text */
--color-text: #F8FAFC;
--color-muted: #94A3B8;
/* Stats accent per card */
--color-stat-a: #A78BFA;
--color-stat-b: #38BDF8;
/* Reactions */
--color-like: #7C3AED;
--color-dislike: #EF4444;
/* Glassmorphism surfaces */
--glass-bg: rgba(255, 255, 255, 0.07);
--glass-border: rgba(255, 255, 255, 0.12);
--glass-blur: 12px;
/* Overlay */
--overlay-bg: rgba(7, 7, 17, 0.75);
/* Typography */
--font-display: 'Unbounded', sans-serif;
--font-body: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Radius */
--radius-card: 20px;
--radius-sm: 10px;
--radius-full: 9999px;
/* Transitions */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--duration-card: 600ms;
--duration-fast: 200ms;
--duration-normal: 300ms;
/* Bottom bar height */
--bar-height: 64px;
}
/* ---------- Global Reset ---------- */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
user-select: none;
overscroll-behavior: none;
}
button {
font-family: inherit;
border: none;
background: none;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
input, textarea {
font-family: inherit;
border: none;
outline: none;
background: none;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
/* ---------- Scrollbar (for panels) ---------- */
.custom-scroll::-webkit-scrollbar {
width: 4px;
}
.custom-scroll::-webkit-scrollbar-track {
background: transparent;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: var(--radius-full);
}
/* ---------- App Shell ---------- */
.app {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
max-width: 480px;
margin: 0 auto;
}
/* ---------- Reduced Motion ---------- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+13
View File
@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+121
View File
@@ -0,0 +1,121 @@
/**
* API service — all backend requests for This OR That.
* Includes X-Init-Data header injection, rate limit handling, and IP ban detection.
* All endpoints return { result, error, status, isBanned }.
*/
import { getTelegramInitData } from './auth';
const BASE_URL = process.env.REACT_APP_API_URL || '/api';
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,
};
if (body) {
options.body = JSON.stringify(body);
}
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);
const POST = (path, body) => request('POST', path, body);
const PATCH = (path, body) => request('PATCH', path, body);
export const api = {
// Users
checkUser: (userId) =>
GET(`/check_user?user_id=${userId}`),
getUser: (userId) =>
GET(`/get_user?user_id=${userId}`),
addUser: ({ user_id, username, first_name, last_name, photo_url }) =>
POST('/add_user', { user_id, username, first_name, last_name, photo_url }),
// Cards
getCard: (cardId) =>
GET(`/get_card?card_id=${cardId}`),
getRandomCards: (userId) =>
GET(`/get_random_cards?user_id=${userId}`),
selectChoice: (userId, cardId, choice) =>
PATCH('/select_choice', { user_id: userId, card_id: cardId, choice }),
addCard: (choiceA, choiceB, authorId) =>
POST('/add_card', { choice_A: choiceA, choice_B: choiceB, author_id: authorId }),
// Reactions
likeCard: (userId, cardId) =>
PATCH('/like_card', { user_id: userId, card_id: cardId }),
dislikeCard: (userId, cardId) =>
PATCH('/dislike_card', { user_id: userId, card_id: cardId }),
// Comments
addComment: (authorId, cardId, commentText) =>
POST('/comment', { author_id: authorId, card_id: cardId, comment_text: commentText }),
getComments: (cardId) =>
GET(`/get_comments?card_id=${cardId}`),
};
+115
View File
@@ -0,0 +1,115 @@
/**
* Auth service — detects Telegram WebApp user or falls back to mock.
* Provides Telegram Mini App initialization, initData extraction, and Haptic Feedback.
*/
const MOCK_USER = {
id: 999995,
first_name: 'Dev',
last_name: 'User',
username: 'devuser',
photo_url: '',
};
/**
* 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;
if (user && user.id) {
return {
id: user.id,
first_name: user.first_name || '',
last_name: user.last_name || '',
username: user.username || '',
photo_url: user.photo_url || '',
};
}
} catch {
// Telegram SDK not available
}
return null;
}
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) {
tg.BackButton.show();
tg.BackButton.onClick(onBack);
return () => {
tg.BackButton.offClick(onBack);
tg.BackButton.hide();
};
}
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
View File
@@ -0,0 +1 @@
// setupTests.js
-2
View File
@@ -1,2 +0,0 @@
fastapi==0.115.7
pymongo==4.10.1