Merge app and frontend #3

Merged
IgorVolochay merged 32 commits from app into frontend 2026-09-01 09:00:36 +00:00
6 changed files with 376 additions and 26 deletions
Showing only changes of commit 90ed19b2f6 - Show all commits
+1 -1
View File
@@ -1,4 +1,4 @@
DISABLE_DOCS=true
DEV_MODE=true # true = docs enabled + auth disabled; false = production mode
MONGO_HOST=127.0.0.1
MONGO_PORT=27017
+44 -18
View File
@@ -17,20 +17,20 @@ 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, DEV_MODE
setup_logging()
load_dotenv()
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
app: FastAPI = FastAPI(
title="This OR That",
summary="OpenAPI schema for \"This OR That\" project!",
version="0.1",
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
docs_url=None if disable_docs else "/docs",
redoc_url=None if disable_docs else "/redoc",
openapi_url=None if disable_docs else "/openapi.json",
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,
)
config = SecurityConfig(
enable_rate_limiting=True,
@@ -38,7 +38,6 @@ config = SecurityConfig(
rate_limit_window=3, # TODO: check rate limits in real usage
enable_redis=False,
enable_ip_banning=True,
custom_log_file="security.log",
enable_penetration_detection=True,
auto_ban_threshold=3,
@@ -88,6 +87,8 @@ async def verify_moderation_secret(
@app.on_event("startup")
async def startup_event():
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")
@@ -114,10 +115,14 @@ async def get_user(
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:
if not await mongo.check_user(new_user.user_id):
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(
new_user.user_id,
user_id,
new_user.username,
new_user.first_name,
new_user.last_name,
@@ -142,10 +147,14 @@ async def get_card(
@app.get("/get_random_cards", status_code=200)
@guard_deco.rate_limit(requests=5, window=60)
async def get_random_cards(
user_id: int,
response: Response,
user_id: Optional[int] = None,
auth_user_id: Optional[int] = Depends(get_current_user_id),
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
cards_visited = await mongo.get_visited_cards(user_id)
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
@@ -164,9 +173,13 @@ async def get_random_cards(
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:
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, new_card.author_id)
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:
@@ -200,17 +213,18 @@ async def card_reject(
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:
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(choice_data.user_id):
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.
# try_mark_visited uses a conditional MongoDB filter ($ne) so that only one
# concurrent request can "win" — eliminating the TOCTOU race condition where
# two parallel requests both pass the visited-check before either writes.
newly_visited = await mongo.try_mark_visited(choice_data.user_id, choice_data.card_id)
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)
@@ -227,8 +241,12 @@ async def select_choice(
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:
result = await mongo.like_card(like_data.card_id, like_data.user_id)
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
@@ -238,8 +256,12 @@ async def like_card(
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:
result = await mongo.dislike_card(dislike_data.card_id, dislike_data.user_id)
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
@@ -250,12 +272,16 @@ async def dislike_card(
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:
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(comment_info.author_id, comment_info.card_id, comment_info.comment_text)
result = await mongo.add_comment(author_id, comment_info.card_id, comment_info.comment_text)
if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]:
response.status_code = status.HTTP_404_NOT_FOUND
return result
+7 -5
View File
@@ -2,13 +2,15 @@ import typing
from pydantic import BaseModel, NonNegativeInt
from typing import Optional
class BaseResponse(BaseModel):
result: typing.Any
error: bool = False
class AddUserBody(BaseModel):
user_id: NonNegativeInt
user_id: Optional[NonNegativeInt] = None
username: str
first_name: str
@@ -19,20 +21,20 @@ class AddCardBody(BaseModel):
choice_A: str
choice_B: str
author_id: NonNegativeInt
author_id: Optional[NonNegativeInt] = None
class SelectChoice(BaseModel):
user_id: NonNegativeInt
user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
choice: typing.Literal["A", "B"]
class ReactionCard(BaseModel):
user_id: NonNegativeInt
user_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
class AddCommentBody(BaseModel):
author_id: NonNegativeInt
author_id: Optional[NonNegativeInt] = None
card_id: NonNegativeInt
comment_text: str
+150
View File
@@ -0,0 +1,150 @@
"""
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
BOT_TOKEN = "7765587867:AAHYpUR_XHEZ1YjCKCAgjWaOiepeDY4XtPA"
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
+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", "true").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"]
+2 -2
View File
@@ -1,6 +1,6 @@
services:
mongodb:
image: mongodb/mongodb-community-server
image: mongo
container_name: tort-mongodb
restart: always
environment:
@@ -56,7 +56,7 @@ services:
RABBIT_USER: ${RABBIT_USER}
RABBIT_PASS: ${RABBIT_PASS}
MODERATION_SECRET: ${MODERATION_SECRET}
DISABLE_DOCS: ${DISABLE_DOCS:-true}
DEV_MODE: ${DEV_MODE:-false}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports:
- "127.0.0.1:5000:5000"