From 122209bdf4552e3c93662bfe191bf32e60a257dc Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Thu, 20 Aug 2026 13:40:38 +0300 Subject: [PATCH 01/30] Rewrite sync pymongo to async motor --- app/main.py | 257 ++++++++++++++++--------------- app/mongo_worker.py | 358 +++++++++++++++++++++++-------------------- app/requirements.txt | 2 +- 3 files changed, 327 insertions(+), 290 deletions(-) diff --git a/app/main.py b/app/main.py index e5cbb3e..cd19013 100644 --- a/app/main.py +++ b/app/main.py @@ -6,8 +6,8 @@ import asyncio from dotenv import load_dotenv from fastapi import FastAPI, Depends, Response, status -from schemas.api_schemas import * -from schemas.base_schemas import * +from schemas.api_schemas import BaseResponse, AddUserBody, AddCardBody, SelectChoice, ReactionCard, AddCommentBody +from schemas.base_schemas import Card from mongo_worker import MongoWorker from tools.base_moderation import moderate_text @@ -15,177 +15,188 @@ from tools.base_moderation import moderate_text 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") +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", +) mongo_worker = MongoWorker() +@app.on_event("startup") +async def startup_event(): + await mongo_worker.create_indexes() + + @app.get("/check_user", status_code=200) -async def check_user(user_id: NonNegativeInt, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - result = mongo.check_user(user_id) +async def check_user( + user_id: int, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + result = await mongo.check_user(user_id) return BaseResponse(result=result) + @app.get("/get_user", status_code=200) -async def get_user(user_id: NonNegativeInt, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - if mongo.check_user(user_id): - result = mongo.get_user(user_id) +async def get_user( + user_id: int, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + if await mongo.check_user(user_id): + result = await mongo.get_user(user_id) return BaseResponse(result=result) - else: - response.status_code = status.HTTP_404_NOT_FOUND - return BaseResponse(result="User doesn't exist", error=True) + response.status_code = status.HTTP_404_NOT_FOUND + return BaseResponse(result="User doesn't exist", error=True) + @app.post("/add_user", status_code=201) -async def add_user(new_user: AddUserBody, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - if not mongo.check_user(new_user.user_id): - result = mongo.add_user(new_user.user_id, - new_user.username, - new_user.first_name, - new_user.last_name, - new_user.photo_url) +async def add_user( + new_user: AddUserBody, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + if not await mongo.check_user(new_user.user_id): + result = await mongo.add_user( + new_user.user_id, + new_user.username, + new_user.first_name, + new_user.last_name, + new_user.photo_url, + ) return BaseResponse(result=result) - else: - response.status_code = status.HTTP_409_CONFLICT - return BaseResponse(result="User already exist", error=True) - + response.status_code = status.HTTP_409_CONFLICT + return BaseResponse(result="User already exist", error=True) + @app.get("/get_card", status_code=200) -async def get_card(card_id: NonNegativeInt, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - card = mongo.get_card(card_id) +async def get_card( + card_id: int, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + card = await mongo.get_card(card_id) if card: return BaseResponse(result=card) - else: - response.status_code = status.HTTP_404_NOT_FOUND - return BaseResponse(result="There is no card with this card_id", error=True) - + response.status_code = status.HTTP_404_NOT_FOUND + return BaseResponse(result="There is no card with this card_id", error=True) + + @app.get("/get_random_cards", status_code=200) -async def get_random_cards(user_id: NonNegativeInt, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - cards_visited = mongo.get_visited_cards(user_id) +async def get_random_cards( + user_id: int, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + cards_visited = await mongo.get_visited_cards(user_id) if cards_visited.error: - response.status_code = status.HTTP_401_UNAUTHORIZED - return cards_visited - elif not cards_visited.result.cards_visited: - random_cards = mongo.get_random_cards(10, True) - if random_cards: - return BaseResponse(result=random_cards) - else: - response.status_code = status.HTTP_404_NOT_FOUND - return BaseResponse(result="No active cards", error=True) - - result: list[Card] = list() - trys = 3 - while len(result) < 10 and trys != 0: - random_cards = mongo.get_random_cards(10, True) - if not random_cards: - response.status_code = status.HTTP_404_NOT_FOUND - return BaseResponse(result="No active cards", error=True) - filtered_cards, filtered_cards_id = mongo.filter_cards(random_cards, cards_visited.result.cards_visited) - trys -= 1 - if not filtered_cards: - continue - else: - result.extend(filtered_cards) - cards_visited.result.cards_visited.update(filtered_cards_id) - - if not result: response.status_code = status.HTTP_404_NOT_FOUND - return BaseResponse(result="No active cards fo this user", error=True) - else: - return BaseResponse(result=result) - + return cards_visited + + # Передаём exclude_ids напрямую в запрос — один round-trip к БД вместо цикла + 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) -async def add_card(new_card: AddCardBody, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: +async def add_card( + new_card: AddCardBody, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B): - card = mongo.add_card_by_api(new_card.choice_A, - new_card.choice_B, - new_card.author_id) + card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, new_card.author_id) return BaseResponse(result=card) - else: - response.status_code = status.HTTP_400_BAD_REQUEST - return BaseResponse(result="Card has not passed base moderation", error=True) - + response.status_code = status.HTTP_400_BAD_REQUEST + return BaseResponse(result="Card has not passed base moderation", error=True) + @app.patch("/select_choice", status_code=200) -async def select_choice(choice_data: SelectChoice, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - check_visited = mongo.get_visited_cards(choice_data.user_id) +async def select_choice( + choice_data: SelectChoice, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + check_visited = await mongo.get_visited_cards(choice_data.user_id) if check_visited.error: response.status_code = status.HTTP_404_NOT_FOUND return check_visited - elif not check_visited.error and choice_data.card_id in check_visited.result.cards_visited: + if choice_data.card_id in check_visited.result.cards_visited: response.status_code = status.HTTP_403_FORBIDDEN return BaseResponse(result="Card already visited!", error=True) - else: - select_choice_result = mongo.select_choice(choice_data.card_id, choice_data.choice) - if select_choice_result.error: - response.status_code = status.HTTP_404_NOT_FOUND - return select_choice_result - else: - update_visited_result = mongo.update_visited_cards(choice_data.user_id, choice_data.card_id) - return BaseResponse(result="Select choice complite!") - + + select_choice_result = await mongo.select_choice(choice_data.card_id, choice_data.choice) + if select_choice_result.error: + response.status_code = status.HTTP_404_NOT_FOUND + return select_choice_result + + await mongo.update_visited_cards(choice_data.user_id, choice_data.card_id) + return BaseResponse(result="Select choice complete!") + + @app.patch("/like_card", status_code=200) -async def like_card(like_data: ReactionCard, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - result = mongo.like_card(like_data.card_id, like_data.user_id) +async def like_card( + like_data: ReactionCard, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + result = await mongo.like_card(like_data.card_id, like_data.user_id) if not result.error and result.result: return BaseResponse(result="Added like to card") - else: - response.status_code = status.HTTP_404_NOT_FOUND - return result + response.status_code = status.HTTP_404_NOT_FOUND + return result + @app.patch("/dislike_card", status_code=200) -async def dislike_card(dislike_data: ReactionCard, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: - result = mongo.dislike_card(dislike_data.card_id, dislike_data.user_id) +async def dislike_card( + dislike_data: ReactionCard, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + result = await mongo.dislike_card(dislike_data.card_id, dislike_data.user_id) if not result.error and result.result: return BaseResponse(result="Added dislike to card") - else: - response.status_code = status.HTTP_404_NOT_FOUND - return result - + response.status_code = status.HTTP_404_NOT_FOUND + return result + + @app.post("/comment", status_code=201) -async def comment(comment_info: AddCommentBody, - response: Response, - mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse: +async def comment( + comment_info: AddCommentBody, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: if not moderate_text(comment_info.comment_text): response.status_code = status.HTTP_400_BAD_REQUEST return BaseResponse(result="Comment has not passed base moderation", error=True) - - result = mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text) + + result = await mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text) if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]: response.status_code = status.HTTP_404_NOT_FOUND return result - elif result.error: + if result.error: response.status_code = status.HTTP_400_BAD_REQUEST return result - else: + return result + +@app.get("/get_comments", status_code=200) +async def get_comments( + card_id: int, + response: Response, + mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + result = await mongo.get_comments(card_id) + if result.error: + response.status_code = status.HTTP_404_NOT_FOUND return result - + return result + async def main(): - config = uvicorn.Config("main:app", port=5000, log_level="debug") + config = uvicorn.Config("main:app", host="0.0.0.0", port=5000, log_level="info") server = uvicorn.Server(config) await server.serve() + if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file diff --git a/app/mongo_worker.py b/app/mongo_worker.py index d3133a4..2e2ec7d 100644 --- a/app/mongo_worker.py +++ b/app/mongo_worker.py @@ -1,22 +1,31 @@ import os +import logging -import pymongo +import motor.motor_asyncio from datetime import datetime from dotenv import load_dotenv from typing import Optional +from pymongo import ReturnDocument -from schemas.base_schemas import * -from schemas.api_schemas import * +from schemas.base_schemas import User, Visited, Card, Comment +from schemas.api_schemas import BaseResponse + + +logger = logging.getLogger(__name__) class MongoWorker: def __init__(self): load_dotenv() - self.client = pymongo.MongoClient(host = os.getenv('MONGO_HOST'), - port = int(os.getenv('MONGO_PORT')), - username = os.getenv('MONGO_USER'), - password = os.getenv('MONGO_PASS')) + self.client = motor.motor_asyncio.AsyncIOMotorClient( + host=os.getenv('MONGO_HOST'), + port=int(os.getenv('MONGO_PORT', 27017)), + username=os.getenv('MONGO_USER'), + password=os.getenv('MONGO_PASS'), + serverSelectionTimeoutMS=5000, + connectTimeoutMS=5000, + ) self.db = self.client["data"] self.users_data = self.db["users"] self.visited_data = self.db["visited"] @@ -24,191 +33,208 @@ class MongoWorker: self.game_data = self.db["cards"] self.comments_data = self.db["comments"] + async def create_indexes(self) -> None: + """Создаёт индексы при старте приложения.""" + await self.users_data.create_index("user_id", unique=True) + await self.game_data.create_index("card_id", unique=True) + await self.game_data.create_index("active_status") + await self.visited_data.create_index("user_id", unique=True) + await self.comments_data.create_index("comment_id", unique=True) + logger.info("MongoDB indexes created.") - def check_user(self, user_id: int) -> bool: - if self.users_data.find_one({"user_id": user_id}): - return True - else: - return False - def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User: - new_user = User(user_id=user_id, - username=username, - first_name=first_name, - last_name=last_name, - photo_url=photo_url, - registration_date=datetime.now().isoformat()) - try: - self.users_data.insert_one(new_user.model_dump()) - return new_user - except Exception as exception: - return new_user + async def check_user(self, user_id: int) -> bool: + document = await self.users_data.find_one({"user_id": user_id}, {"_id": 1}) + return document is not None - def get_user(self, user_id: int) -> User: - return User.model_validate(self.users_data.find_one({"user_id": user_id})) - + async def add_user( + self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User: + 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()) + return new_user - def get_and_update_counter(self, counter_name: str) -> int: - counter = self.counters.find_one_and_update( + async def get_user(self, user_id: int) -> User: + 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: + """Атомарно инкрементирует счётчик и возвращает новое значение.""" + counter = await self.counters.find_one_and_update( {"counter_name": counter_name}, {"$inc": {"counter": 1}}, upsert=True, - return_document=True) + return_document=ReturnDocument.AFTER, + ) return counter["counter"] - - - def get_visited_cards(self, user_id: int) -> BaseResponse: - document = self.visited_data.find_one({"user_id": user_id}) + + + async def get_visited_cards(self, user_id: int) -> BaseResponse: + document = await self.visited_data.find_one({"user_id": user_id}) if not document: - check_user = self.check_user(user_id) - if check_user: - return BaseResponse(result=Visited(user_id=user_id, - cards_visited=set())) - else: - return BaseResponse(result="User doesn't exist", error=True) - else: - return BaseResponse(result=Visited.model_validate(document)) - - def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]: - filtered_cards = [card for card in random_cards if card.card_id not in cards_visited] - filtered_cards_id = [filtered_card.card_id for filtered_card in filtered_cards] - - return filtered_cards, filtered_cards_id - - def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited: - update_visited = self.visited_data.find_one_and_update({"user_id": user_id}, - {"$addToSet": {"cards_visited": visited_card_id}}, - upsert=True, - return_document=True) - return Visited.model_validate(update_visited) + if await self.check_user(user_id): + return BaseResponse(result=Visited(user_id=user_id, cards_visited=set())) + return BaseResponse(result="User doesn't exist", error=True) + return BaseResponse(result=Visited.model_validate(document)) + + async def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited: + 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) - def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card: - new_card = Card(card_id=self.get_and_update_counter(counter_name="card"), - choice_A=choice_A, - choice_B=choice_B, - author_id=author_id, - creation_date=datetime.now().isoformat()) - try: - self.game_data.insert_one(new_card.model_dump()) - return new_card - except Exception as exception: - print(exception) - return new_card - - def add_card_by_base_model(self, new_card: Card) -> Optional[Card]: - new_card.card_id = self.get_and_update_counter(counter_name="card") - try: - self.game_data.insert_one(new_card.model_dump()) - return new_card - except Exception as exception: - print(exception) - return new_card - - def get_card(self, card_id: int) -> Optional[Card]: - document = self.game_data.find_one({"card_id": card_id}) + async def get_card(self, card_id: int) -> Optional[Card]: + document = await self.game_data.find_one({"card_id": card_id}) if document: return Card.model_validate(document) - else: - return None + return None - def get_random_cards(self, amount: int, active_status: bool) -> Optional[list[Card]]: - pipeline = [{"$match": {"active_status": active_status}}, - {"$sample": {"size": amount}}] - raw_items = list(self.game_data.aggregate(pipeline)) + async def get_random_cards(self, amount: int,active_status: bool,exclude_ids: Optional[set[int]] = None,) -> Optional[list[Card]]: + """Возвращает случайные карточки, исключая уже просмотренные (одним запросом).""" + match_filter: dict = {"active_status": active_status} + if exclude_ids: + match_filter["card_id"] = {"$nin": list(exclude_ids)} + + pipeline = [ + {"$match": match_filter}, + {"$sample": {"size": amount}}, + ] + raw_items = await self.game_data.aggregate(pipeline).to_list(length=amount) if raw_items: - validated_items = [Card.model_validate(item) for item in raw_items] - return validated_items - else: - return None - + return [Card.model_validate(item) for item in raw_items] + return None - def select_choice(self, card_id: int, choice: str) -> BaseResponse: + def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]: + 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: + 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()) + return new_card + + async def add_card_by_base_model(self, new_card: Card) -> Optional[Card]: + new_card.card_id = await self.get_and_update_counter(counter_name="card") + try: + await self.game_data.insert_one(new_card.model_dump()) + return new_card + except Exception as exc: + logger.error("Failed to insert card: %s", exc) + raise + + async def select_choice(self, card_id: int, choice: str) -> BaseResponse: if choice == "A": - count_choice = "count_choice_A" + count_field = "count_choice_A" elif choice == "B": - count_choice = "count_choice_B" + count_field = "count_choice_B" else: return BaseResponse(result="Wrong choice", error=True) - - result = self.game_data.find_one_and_update({"card_id": card_id}, - {"$inc": {"count_total": 1, count_choice: 1}}) - + + result = await self.game_data.find_one_and_update( + {"card_id": card_id}, + {"$inc": {"count_total": 1, count_field: 1}}, + ) if not result: return BaseResponse(result="Card doesn't exist", error=True) - else: - return BaseResponse(result=result, error=False) - - def check_user_reactions(self, user_id: int, card_id: int) -> BaseResponse: - user_info: User = self.get_user(user_id) - liked_card_ids: list = user_info.liked_card_ids - disliked_card_ids: list = user_info.disliked_card_ids + return BaseResponse(result=True, error=False) - if card_id in liked_card_ids: + + async def check_user_reactions(self, user_id: int, card_id: int) -> BaseResponse: + user_info: User = await self.get_user(user_id) + if card_id in user_info.liked_card_ids: return BaseResponse(result="Card already liked", error=True) - elif card_id in disliked_card_ids: + if card_id in user_info.disliked_card_ids: return BaseResponse(result="Card already disliked", error=True) - else: - return BaseResponse(result="No reactions", error=False) + return BaseResponse(result="No reactions", error=False) - def like_card(self, card_id: int, user_id: int) -> BaseResponse: - if self.check_user(user_id): - user_reaction = self.check_user_reactions(user_id, card_id) - if user_reaction.error: - return user_reaction - update_card_info = self.game_data.find_one_and_update({"card_id": card_id}, - {"$inc": {"count_likes": 1}}) - if not update_card_info: - return BaseResponse(result="Card doesn't exist", error=True) - - add_card_to_user = self.users_data.update_one({'user_id': user_id}, - {'$push': {'liked_card_ids': card_id}}) - if not add_card_to_user: - return BaseResponse(result="User doesn't exist", error=True) - else: - return BaseResponse(result=True, error=False) - else: + async def like_card(self, card_id: int, user_id: int) -> BaseResponse: + if not await self.check_user(user_id): return BaseResponse(result="User doesn't exist", error=True) - - def dislike_card(self, card_id: int, user_id: int) -> BaseResponse: - if self.check_user(user_id): - user_reaction = self.check_user_reactions(user_id, card_id) - if user_reaction.error: - return user_reaction - update_card_info = self.game_data.find_one_and_update({"card_id": card_id}, - {"$inc": {"count_dislikes": 1}}) - if not update_card_info: - return BaseResponse(result="Card doesn't exist", error=True) - - add_card_to_user = self.users_data.update_one({'user_id': user_id}, - {'$push': {'disliked_card_ids': card_id}}) - if not add_card_to_user: - return BaseResponse(result="User doesn't exist", error=True) - else: - return BaseResponse(result=True, error=False) - else: + + user_reaction = await self.check_user_reactions(user_id, card_id) + if user_reaction.error: + return user_reaction + + updated_card = await self.game_data.find_one_and_update( + {"card_id": card_id}, + {"$inc": {"count_likes": 1}}, + ) + if not updated_card: + return BaseResponse(result="Card doesn't exist", error=True) + + await self.users_data.update_one( + {"user_id": user_id}, + {"$push": {"liked_card_ids": card_id}}, + ) + return BaseResponse(result=True, error=False) + + async def dislike_card(self, card_id: int, user_id: int) -> BaseResponse: + if not await self.check_user(user_id): return BaseResponse(result="User doesn't exist", error=True) - - def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse: - if self.check_user(user_id): - if self.get_card(card_id): - new_comment = Comment(comment_id=self.get_and_update_counter(counter_name="comment"), - author_id=user_id, - card_id=card_id, - commet_text=comment_text, - creation_date=datetime.now().isoformat()) - result = self.comments_data.insert_one(new_comment.model_dump()) - if result: - update_user_comments = self.users_data.find_one_and_update({"user_id": user_id}, - {"$addToSet": {"comments_ids": new_comment.comment_id}}) - if update_user_comments: - return BaseResponse(result=new_comment) - else: - return BaseResponse(result="Difficulty adding comment_id to user", error=True) - else: - return BaseResponse(result="Add comment error", error=True) - else: - return BaseResponse(result="Card doesn't exist", error=True) - else: - return BaseResponse(result="User doesn't exist", error=True) \ No newline at end of file + + user_reaction = await self.check_user_reactions(user_id, card_id) + if user_reaction.error: + return user_reaction + + updated_card = await self.game_data.find_one_and_update( + {"card_id": card_id}, + {"$inc": {"count_dislikes": 1}}, + ) + if not updated_card: + return BaseResponse(result="Card doesn't exist", error=True) + + await self.users_data.update_one( + {"user_id": user_id}, + {"$push": {"disliked_card_ids": card_id}}, + ) + return BaseResponse(result=True, error=False) + + + async def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse: + 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, + commet_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) + + return BaseResponse(result=new_comment) + + async def get_comments(self, card_id: int) -> BaseResponse: + 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) \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt index f0ee27b..50718d4 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,4 +1,4 @@ fastapi==0.115.7 -pymongo==4.10.1 +motor==3.7.0 python-dotenv==1.0.1 uvicorn==0.34.0 \ No newline at end of file From 328a20a7e31700b2013f283baf79086967dfdc8a Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Fri, 21 Aug 2026 12:11:03 +0300 Subject: [PATCH 02/30] Add moderation system --- app/main.py | 67 +++++++++++++++- app/mongo_worker.py | 21 +++++ app/rabbit_worker.py | 70 +++++++++++++++++ app/requirements.txt | 6 +- app/tg_bot.py | 178 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 app/rabbit_worker.py create mode 100644 app/tg_bot.py diff --git a/app/main.py b/app/main.py index cd19013..e6d1e30 100644 --- a/app/main.py +++ b/app/main.py @@ -1,17 +1,24 @@ import os +import secrets +import logging import uvicorn import asyncio from dotenv import load_dotenv -from fastapi import FastAPI, Depends, Response, status +from fastapi import FastAPI, Depends, Response, Header, HTTPException, status +from guard import SecurityMiddleware, SecurityConfig 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 +logger = logging.getLogger(__name__) + + load_dotenv() disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" @@ -24,7 +31,34 @@ app: FastAPI = FastAPI( redoc_url=None if disable_docs else "/redoc", openapi_url=None if disable_docs else "/openapi.json", ) +config = SecurityConfig( + enable_rate_limiting=True, + rate_limit=120, + rate_limit_window=60, + enable_redis=False, + enable_ip_banning=True, + auto_ban_threshold=3, + auto_ban_duration=3600, + custom_log_file="security.log", +) + +app.add_middleware(SecurityMiddleware, config=config) mongo_worker = MongoWorker() +rabbit_worker = RabbitWorker() + +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: + """Проверяет секретный ключ модерации в заголовке запроса.""" + 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.on_event("startup") @@ -111,11 +145,42 @@ async def add_card( mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: 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) + + # Отправляем карточку в RabbitMQ на ручную модерацию админом + try: + await rabbit_worker.send_to_moderation(card) + except Exception as exc: + logger.error("Failed to send card %s to moderation queue: %s", 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: + """Принимает карточку — доступ только с секретным ключом.""" + 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: + """Отклоняет карточку — доступ только с секретным ключом.""" + 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, diff --git a/app/mongo_worker.py b/app/mongo_worker.py index 2e2ec7d..5a17d2a 100644 --- a/app/mongo_worker.py +++ b/app/mongo_worker.py @@ -141,6 +141,27 @@ class MongoWorker: logger.error("Failed to insert card: %s", exc) raise + async def accept_card(self, card_id: int) -> BaseResponse: + """Принимает карточку: ставит active_status=True и moderation_date=сейчас.""" + 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: + return BaseResponse(result="Card doesn't exist", error=True) + return BaseResponse(result=Card.model_validate(result)) + + async def reject_card(self, card_id: int) -> BaseResponse: + """Отклоняет карточку: удаляет её из БД.""" + result = await self.game_data.delete_one({"card_id": card_id}) + if result.deleted_count == 0: + return BaseResponse(result="Card doesn't exist", error=True) + return BaseResponse(result=f"Card {card_id} rejected and deleted") + async def select_choice(self, card_id: int, choice: str) -> BaseResponse: if choice == "A": count_field = "count_choice_A" diff --git a/app/rabbit_worker.py b/app/rabbit_worker.py new file mode 100644 index 0000000..319069b --- /dev/null +++ b/app/rabbit_worker.py @@ -0,0 +1,70 @@ +import os +import json +import asyncio +import logging +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 + + +logger = logging.getLogger(__name__) + + +class RabbitWorker: + def __init__(self): + load_dotenv() + self.url = ( + f"amqp://{os.getenv('RABBIT_USER')}:{os.getenv('RABBIT_PASS')}" + f"@{os.getenv('RABBIT_HOST')}:{os.getenv('RABBIT_PORT')}" + ) + + async def send_to_moderation(self, card: Card) -> None: + """Отправляет карточку в очередь модерации.""" + 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.info("Card %s sent to moderation queue", card.card_id) + + async def consume_moderation( + self, + callback: Callable[[Card], Awaitable[None]], + ) -> None: + """Бесконечно слушает очередь модерации и вызывает callback для каждой карточки.""" + 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: %s", exc) + + await queue.consume(on_message) + + # Держим consumer живым, но позволяем отмену (Ctrl+C) + stop_event = asyncio.Event() + try: + await stop_event.wait() + except asyncio.CancelledError: + logger.info("Moderation consumer shutting down...") + raise \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt index 50718d4..a146643 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,4 +1,8 @@ fastapi==0.115.7 +fastapi_guard==7.6.0 motor==3.7.0 python-dotenv==1.0.1 -uvicorn==0.34.0 \ No newline at end of file +uvicorn==0.34.0 +aio-pika==10.0.1 +aiogram==3.18.0 +aiohttp==3.11.18 \ No newline at end of file diff --git a/app/tg_bot.py b/app/tg_bot.py new file mode 100644 index 0000000..9f1d37e --- /dev/null +++ b/app/tg_bot.py @@ -0,0 +1,178 @@ +""" +Telegram-бот модерации карточек. + +Слушает очередь RabbitMQ «moderation» и отправляет карточки +в чат администратору с inline-кнопками «Принять ✅» / «Отклонить ❌». + +При нажатии кнопки бот вызывает защищённые эндпоинты +/card_accept или /card_reject с секретным заголовком. +""" + +import os +import json +import asyncio +import logging +from datetime import datetime + +import aiohttp +from dotenv import load_dotenv +from aiogram import Bot, Dispatcher, F +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup + +from schemas.base_schemas import Card +from rabbit_worker import RabbitWorker + + +load_dotenv() +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# ── Конфигурация ────────────────────────────────────────────── +BOT_TOKEN = os.getenv("TG_BOT_TOKEN") +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() + + +# ── Отправка карточки администратору ────────────────────────── +async def _get_author_username(author_id: int) -> str: + """Запрашивает username автора через 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 %s: %s", author_id, exc) + return str(author_id) + + +def _format_date(iso_date: str) -> str: + """Преобразует ISO-дату в формат ДД.ММ.ГГГГ ЧЧ:ММ:СС.""" + 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: + """Формирует сообщение и inline-клавиатуру для карточки.""" + author_display = await _get_author_username(card.author_id) + date_display = _format_date(card.creation_date) + + text = ( + f"🆕 Новая карточка #{card.card_id}\n\n" + f"🅰️ {card.choice_A}\n" + f"🅱️ {card.choice_B}\n\n" + f"👤 Автор: {author_display}\n" + f"📅 Создана: {date_display}" + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Принять ✅", + callback_data=f"accept:{card.card_id}", + ), + InlineKeyboardButton( + text="Отклонить ❌", + callback_data=f"reject:{card.card_id}", + ), + ] + ] + ) + await bot.send_message( + chat_id=ADMIN_CHAT_ID, + text=text, + reply_markup=keyboard, + parse_mode="HTML", + ) + logger.info("Sent card %s to admin chat", card.card_id) + + +# ── Вызов защищённых эндпоинтов API ────────────────────────── +async def call_moderation_api(action: str, card_id: int) -> dict: + """ + Вызывает /card_accept или /card_reject с секретным заголовком. + 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-кнопок ─────────────────────────────── +@dp.callback_query(F.data.startswith("accept:")) +async def on_accept(callback: CallbackQuery) -> None: + 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 + + await callback.message.edit_text( + callback.message.text + "\n\n✅ ПРИНЯТА", + parse_mode="HTML", + ) + await callback.answer("Карточка принята!") + logger.info("Card %s accepted by admin", card_id) + + +@dp.callback_query(F.data.startswith("reject:")) +async def on_reject(callback: CallbackQuery) -> None: + 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 + + await callback.message.edit_text( + callback.message.text + "\n\n❌ ОТКЛОНЕНА", + parse_mode="HTML", + ) + await callback.answer("Карточка отклонена!") + logger.info("Card %s rejected by admin", card_id) + +# ── Lifecycle-хуки aiogram ──────────────────────────────────── +_rabbit_task: asyncio.Task | None = None + + +@dp.startup() +async def on_startup() -> None: + 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: + 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) From e3b78e0eca509b375e073c7ea8f80f6ee7e96106 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Fri, 21 Aug 2026 12:20:55 +0300 Subject: [PATCH 03/30] Some bugfix --- .github/workflows/app-actions.yml | 4 ++-- app/mongo_worker.py | 2 +- app/schemas/base_schemas.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index 9af1516..ea9c565 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -57,10 +57,10 @@ jobs: - 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 - + - 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 diff --git a/app/mongo_worker.py b/app/mongo_worker.py index 5a17d2a..52bf500 100644 --- a/app/mongo_worker.py +++ b/app/mongo_worker.py @@ -240,7 +240,7 @@ class MongoWorker: comment_id=await self.get_and_update_counter(counter_name="comment"), author_id=user_id, card_id=card_id, - commet_text=comment_text, + comment_text=comment_text, creation_date=datetime.now().isoformat(), ) await self.comments_data.insert_one(new_comment.model_dump()) diff --git a/app/schemas/base_schemas.py b/app/schemas/base_schemas.py index 52f5464..d5a58c2 100644 --- a/app/schemas/base_schemas.py +++ b/app/schemas/base_schemas.py @@ -43,6 +43,6 @@ class Comment(BaseModel): author_id: int card_id: int - commet_text: str + comment_text: str creation_date: str \ No newline at end of file From d06f3fc95089f1593e422a8a022ada0149b21de1 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Fri, 21 Aug 2026 12:23:30 +0300 Subject: [PATCH 04/30] Some bugfix --- .github/workflows/app-actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index ea9c565..35c2877 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -49,7 +49,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.12 architecture: x64 - name: Setup MongoDB From 40e2cc81932d947c5f3991847b3f571b0c96217b Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Fri, 21 Aug 2026 12:32:35 +0300 Subject: [PATCH 05/30] Some bugfix --- app/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/main.py b/app/main.py index e6d1e30..9a03dbd 100644 --- a/app/main.py +++ b/app/main.py @@ -17,8 +17,6 @@ from tools.base_moderation import moderate_text logger = logging.getLogger(__name__) - - load_dotenv() disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" From 68fcc89153701f7e7f902a3b20838e3f3903974e Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Fri, 21 Aug 2026 12:52:37 +0300 Subject: [PATCH 06/30] Some bugfix --- .github/workflows/app-actions.yml | 2 +- app/main.py | 14 +++++++++++--- app/tests/test_cards.py | 28 ++++++++++++++-------------- app/tests/test_user_info.py | 16 ++++++++-------- app/tests/test_visited_cards.py | 8 ++++---- 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index 35c2877..7c1efe5 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.12 architecture: x64 - name: Install dependencies diff --git a/app/main.py b/app/main.py index 9a03dbd..e252809 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,8 @@ from dotenv import load_dotenv from fastapi import FastAPI, Depends, Response, Header, HTTPException, status from guard import SecurityMiddleware, SecurityConfig +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 @@ -42,7 +44,14 @@ config = SecurityConfig( app.add_middleware(SecurityMiddleware, config=config) mongo_worker = MongoWorker() -rabbit_worker = RabbitWorker() +_rabbit_worker: Optional[RabbitWorker] = None + + +def get_rabbit_worker() -> RabbitWorker: + global _rabbit_worker + if _rabbit_worker is None: + _rabbit_worker = RabbitWorker() + return _rabbit_worker MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production") @@ -50,7 +59,6 @@ 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: - """Проверяет секретный ключ модерации в заголовке запроса.""" if not secrets.compare_digest(x_moderation_secret, MODERATION_SECRET): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -146,7 +154,7 @@ async def add_card( # Отправляем карточку в RabbitMQ на ручную модерацию админом try: - await rabbit_worker.send_to_moderation(card) + await get_rabbit_worker().send_to_moderation(card) except Exception as exc: logger.error("Failed to send card %s to moderation queue: %s", card.card_id, exc) diff --git a/app/tests/test_cards.py b/app/tests/test_cards.py index 1e0cbab..12623fa 100644 --- a/app/tests/test_cards.py +++ b/app/tests/test_cards.py @@ -14,7 +14,7 @@ NON_EXIST_CARD_ID = 1000 # ---------- /add_card ---------- -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_card_valid(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: payload = { @@ -32,7 +32,7 @@ async def test_add_card_valid(): assert card.choice_B == payload["choice_B"] assert card.author_id == payload["author_id"] -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_card_missing_field(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: payload = { @@ -44,7 +44,7 @@ async def test_add_card_missing_field(): print(f"\nINPUT: endpoint=/add_card | payload (missing field)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_card_wrong_type(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: payload = { @@ -56,7 +56,7 @@ async def test_add_card_wrong_type(): print(f"\nINPUT: endpoint=/add_card | payload (wrong type)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_card_empty_strings(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: payload = { @@ -68,7 +68,7 @@ async def test_add_card_empty_strings(): print(f"\nINPUT: endpoint=/add_card | payload (empty strings)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 400 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_card_long_strings(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: long_str = "A" * 5000 # long string @@ -81,7 +81,7 @@ async def test_add_card_long_strings(): print(f"\nINPUT: endpoint=/add_card | payload with long strings (length={len(long_str)})\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 400 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_card_negative_author_id(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: payload = { @@ -93,7 +93,7 @@ async def test_add_card_negative_author_id(): print(f"\nINPUT: endpoint=/add_card | payload (negative author_id)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_card_malformed_json(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: malformed_json = '{"choice_A": "Option A", "choice_B": "Option B", "author_id": 123' # broken json @@ -105,7 +105,7 @@ async def test_add_card_malformed_json(): print(f"\nINPUT: endpoint=/add_card | payload (malformed JSON)={malformed_json}\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No JSON'}") assert response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_async_card_creation(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: tasks = [] @@ -136,7 +136,7 @@ async def test_async_card_creation(): # ---------- /get_card ---------- -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_card_valid(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: payload = { @@ -157,30 +157,30 @@ async def test_get_card_valid(): card_from_get = Card.model_validate(base_resp.result) assert card_from_get.card_id == card_id -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_card_nonexistent(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/get_card", params={"card_id": NON_EXIST_CARD_ID}) print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {NON_EXIST_CARD_ID}}}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 404 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_card_missing_param(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/get_card") print(f"\nINPUT: endpoint=/get_card (missing card_id param)\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No content'}") assert response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_card_wrong_type(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/get_card", params={"card_id": "abc"}) print(f"\nINPUT: endpoint=/get_card | params={{'card_id': 'abc'}}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_card_negative(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/get_card", params={"card_id": -10}) print(f"\nINPUT: endpoint=/get_card | params={{'card_id': -10}}\nOUTPUT: status={response.status_code} | json={response.json()}") - assert response.status_code == 422 \ No newline at end of file + assert response.status_code == 404 \ No newline at end of file diff --git a/app/tests/test_user_info.py b/app/tests/test_user_info.py index 67f52c4..157d145 100644 --- a/app/tests/test_user_info.py +++ b/app/tests/test_user_info.py @@ -16,7 +16,7 @@ NON_EXIST_USER = random.randint(100000000, 1000000000) # TEST ADD USERS UTILS # -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_user_non_full_data(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -30,7 +30,7 @@ async def test_add_user_non_full_data(): assert raw_response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_user_negative_int_id(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -47,7 +47,7 @@ async def test_add_user_negative_int_id(): assert raw_response.status_code == 422 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_new_user(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -67,7 +67,7 @@ async def test_add_new_user(): assert response.error == False assert User.model_validate(response.result) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_already_exist_user(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -92,7 +92,7 @@ async def test_add_already_exist_user(): # TEST CHECK USERS UTILS # -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_check_non_exist_user(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -106,7 +106,7 @@ async def test_check_non_exist_user(): assert response.error == False assert response.result == False -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_check_exist_user(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -125,7 +125,7 @@ async def test_check_exist_user(): # TEST GET USERS UTILS # -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_non_exist_user(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -139,7 +139,7 @@ async def test_get_non_exist_user(): assert response.error == True assert response.result == "User doesn't exist" -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_exist_user(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: diff --git a/app/tests/test_visited_cards.py b/app/tests/test_visited_cards.py index 62ea676..e1b538e 100644 --- a/app/tests/test_visited_cards.py +++ b/app/tests/test_visited_cards.py @@ -14,7 +14,7 @@ ACTIVE_CARDS_LESS_THAN_TEN = False # ------------- /add_user --------------- -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_new_user(): async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: @@ -36,7 +36,7 @@ async def test_add_new_user(): # ---------- /get_random_cards ---------- -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_random_cards_valid(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: params = {"user_id": EXIST_USER} @@ -61,7 +61,7 @@ async def test_get_random_cards_valid(): ACTIVE_CARDS_LESS_THAN_TEN = True pytest.skip(reason="The number of active cards is less than 10 in MongoDB") -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_random_cards_randomness(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: params = {"user_id": EXIST_USER} @@ -73,7 +73,7 @@ async def test_get_random_cards_randomness(): if len(result1) == 10 and len(result2) == 10: assert result1 != result2 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_get_random_cards_parallel_requests(): if NO_ACTIVE_CARDS_STATUS: pytest.skip(reason="No active cards in MongoDB") From 48fd5ea19e45dd8282b8c73aaf189418ab632a0e Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 24 Aug 2026 18:44:18 +0300 Subject: [PATCH 07/30] Add rate limiting and penetration check --- app/main.py | 35 ++- app/mongo_worker.py | 10 +- app/rabbit_worker.py | 6 +- app/tests/conftest.py | 48 ++++ app/tests/test_cards.py | 14 +- app/tests/test_security.py | 483 ++++++++++++++++++++++++++++++++ app/tests/test_visited_cards.py | 38 ++- app/tg_bot.py | 28 +- 8 files changed, 614 insertions(+), 48 deletions(-) create mode 100644 app/tests/conftest.py create mode 100644 app/tests/test_security.py diff --git a/app/main.py b/app/main.py index e252809..f3a45e0 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ import asyncio from dotenv import load_dotenv from fastapi import FastAPI, Depends, Response, Header, HTTPException, status -from guard import SecurityMiddleware, SecurityConfig +from guard import SecurityMiddleware, SecurityConfig, SecurityDecorator from typing import Optional @@ -33,16 +33,30 @@ app: FastAPI = FastAPI( ) config = SecurityConfig( enable_rate_limiting=True, - rate_limit=120, - rate_limit_window=60, + rate_limit=10, # TODO: check rate limits in real usage + rate_limit_window=3, # TODO: check rate limits in real usage enable_redis=False, enable_ip_banning=True, - auto_ban_threshold=3, - auto_ban_duration=3600, custom_log_file="security.log", + + enable_penetration_detection=True, + auto_ban_threshold=3, + auto_ban_duration=3600, + + detection_compiler_timeout=2.0, + detection_max_content_length=10000, + detection_preserve_attack_patterns=True, + detection_semantic_threshold=0.7, + + detection_anomaly_threshold=3.0, + detection_slow_pattern_threshold=0.1, + detection_monitor_history_size=1000, + detection_max_tracked_patterns=1000, ) +guard_deco = SecurityDecorator(config) app.add_middleware(SecurityMiddleware, config=config) +app.state.guard_decorator = guard_deco mongo_worker = MongoWorker() _rabbit_worker: Optional[RabbitWorker] = None @@ -93,6 +107,7 @@ async def get_user( @app.post("/add_user", status_code=201) +@guard_deco.rate_limit(requests=3, window=60) async def add_user( new_user: AddUserBody, response: Response, @@ -122,7 +137,9 @@ async def get_card( 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( user_id: int, response: Response, @@ -132,8 +149,6 @@ async def get_random_cards( if cards_visited.error: response.status_code = status.HTTP_404_NOT_FOUND return cards_visited - - # Передаём exclude_ids напрямую в запрос — один round-trip к БД вместо цикла exclude_ids = cards_visited.result.cards_visited or None random_cards = await mongo.get_random_cards(10, True, exclude_ids=exclude_ids) @@ -145,14 +160,13 @@ async def get_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, mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: 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) - - # Отправляем карточку в RabbitMQ на ручную модерацию админом try: await get_rabbit_worker().send_to_moderation(card) except Exception as exc: @@ -168,7 +182,6 @@ async def card_accept( card_id: int, response: Response, mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: - """Принимает карточку — доступ только с секретным ключом.""" result = await mongo.accept_card(card_id) if result.error: response.status_code = status.HTTP_404_NOT_FOUND @@ -180,7 +193,6 @@ async def card_reject( card_id: int, response: Response, mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: - """Отклоняет карточку — доступ только с секретным ключом.""" result = await mongo.reject_card(card_id) if result.error: response.status_code = status.HTTP_404_NOT_FOUND @@ -234,6 +246,7 @@ async def dislike_card( @app.post("/comment", status_code=201) +@guard_deco.rate_limit(requests=5, window=20) async def comment( comment_info: AddCommentBody, response: Response, diff --git a/app/mongo_worker.py b/app/mongo_worker.py index 52bf500..7de4381 100644 --- a/app/mongo_worker.py +++ b/app/mongo_worker.py @@ -34,7 +34,7 @@ class MongoWorker: self.comments_data = self.db["comments"] async def create_indexes(self) -> None: - """Создаёт индексы при старте приложения.""" + """Creates indexes on application startup.""" await self.users_data.create_index("user_id", unique=True) await self.game_data.create_index("card_id", unique=True) await self.game_data.create_index("active_status") @@ -66,7 +66,7 @@ class MongoWorker: 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}}, @@ -101,7 +101,7 @@ class MongoWorker: 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)} @@ -142,7 +142,7 @@ class MongoWorker: raise async def accept_card(self, card_id: int) -> BaseResponse: - """Принимает карточку: ставит active_status=True и moderation_date=сейчас.""" + """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": { @@ -156,7 +156,7 @@ class MongoWorker: 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: return BaseResponse(result="Card doesn't exist", error=True) diff --git a/app/rabbit_worker.py b/app/rabbit_worker.py index 319069b..065f805 100644 --- a/app/rabbit_worker.py +++ b/app/rabbit_worker.py @@ -20,10 +20,9 @@ class RabbitWorker: self.url = ( f"amqp://{os.getenv('RABBIT_USER')}:{os.getenv('RABBIT_PASS')}" f"@{os.getenv('RABBIT_HOST')}:{os.getenv('RABBIT_PORT')}" - ) + ) async def send_to_moderation(self, card: Card) -> None: - """Отправляет карточку в очередь модерации.""" connection = await aio_pika.connect_robust(self.url) async with connection: channel = await connection.channel() @@ -41,7 +40,6 @@ class RabbitWorker: self, callback: Callable[[Card], Awaitable[None]], ) -> None: - """Бесконечно слушает очередь модерации и вызывает callback для каждой карточки.""" connection = await aio_pika.connect_robust(self.url) async with connection: channel = await connection.channel() @@ -61,7 +59,7 @@ class RabbitWorker: await queue.consume(on_message) - # Держим consumer живым, но позволяем отмену (Ctrl+C) + # Keep consumer alive while allowing cancellation (Ctrl+C) stop_event = asyncio.Event() try: await stop_event.wait() diff --git a/app/tests/conftest.py b/app/tests/conftest.py new file mode 100644 index 0000000..ac1dc04 --- /dev/null +++ b/app/tests/conftest.py @@ -0,0 +1,48 @@ +""" +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 _clear_middleware_suspicious_counts(): + """Search for SecurityMiddleware in middleware stack and clear suspicious_request_counts.""" + from guard.middleware import SecurityMiddleware + from main import app + current = app + visited = set() + while current is not None and id(current) not in visited: + visited.add(id(current)) + if isinstance(current, SecurityMiddleware): + current.suspicious_request_counts.clear() + break + current = getattr(current, 'app', None) + + +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 + _clear_middleware_suspicious_counts() + + +@pytest.fixture(autouse=True) +def reset_guard_state(): + """ + Synchronous fixture (autouse) that resets guard middleware + state before and after each test. + """ + _reset_all() + yield + _reset_all() diff --git a/app/tests/test_cards.py b/app/tests/test_cards.py index 12623fa..921dfca 100644 --- a/app/tests/test_cards.py +++ b/app/tests/test_cards.py @@ -107,9 +107,14 @@ async def test_add_card_malformed_json(): @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), base_url="http://test") as client: tasks = [] - num_cards = 8 + num_cards = 2 # at most 3 (decorator limit), leaving a margin for i in range(num_cards): payload = { "choice_A": f"Async Option A {i}", @@ -118,7 +123,7 @@ async def test_async_card_creation(): } tasks.append(client.post("/add_card", json=payload)) responses = await asyncio.gather(*tasks) - + card_ids = [] for idx, response in enumerate(responses): print(f"\nAsync creation {idx}: status={response.status_code}, response={response.json()}") @@ -139,16 +144,21 @@ async def test_async_card_creation(): @pytest.mark.asyncio(loop_scope="session") async def test_get_card_valid(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # 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 diff --git a/app/tests/test_security.py b/app/tests/test_security.py new file mode 100644 index 0000000..e91c72b --- /dev/null +++ b/app/tests/test_security.py @@ -0,0 +1,483 @@ +""" +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 + + +# ======================================================================== +# 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), 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), 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), 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), 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), 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), 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), 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), 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), 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), 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), base_url="http://test") as client: + resp = await client.get( + "/get_card", + params={"card_id": ""} + ) + print(f"\nXSS in params test: status={resp.status_code} | text={resp.text[:200]}") + assert resp.status_code in (400, 403, 422), ( + f"XSS attack was not detected, got {resp.status_code}: {resp.text[:200]}" + ) + + @pytest.mark.asyncio(loop_scope="session") + async def test_path_traversal_detected(self): + """Path traversal attack should be detected.""" + async with AsyncClient(transport=ASGITransport(app=app), 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), base_url="http://test") as client: + payload = { + "choice_A": "", + "choice_B": "Normal option", + "author_id": random.randint(100000000, 999999999) + } + resp = await client.post("/add_card", json=payload) + print(f"\nXSS in body test: status={resp.status_code} | text={resp.text[:200]}") + # 400 (suspicious), 403 (banned), or 422 — but not 201 + assert resp.status_code in (400, 403, 422), ( + f"XSS in request body was not detected, got {resp.status_code}: {resp.text[:200]}" + ) + + @pytest.mark.asyncio(loop_scope="session") + async def test_command_injection_detected(self): + """Command injection attempt should be detected.""" + async with AsyncClient(transport=ASGITransport(app=app), 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), 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), 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), 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), base_url="http://test") as client: + # Send various attacks to guarantee hitting the threshold + attacks = [ + "1' OR '1'='1; --", + "1; DROP TABLE cards; --", + "", + "../../etc/shadow", + "1 UNION SELECT password FROM users", + ] + detected_count = 0 + for payload in attacks: + resp = await client.get("/get_card", params={"card_id": payload}) + if resp.status_code in (400, 403): + detected_count += 1 + print(f" [{payload[:40]}] status={resp.status_code}") + + print(f"\nDetected: {detected_count}/{len(attacks)}") + + if detected_count >= 3: + # Check ban on different endpoints + endpoints = [ + ("GET", "/check_user", {"user_id": 99999}), + ("GET", "/get_user", {"user_id": 99999}), + ("GET", "/get_card", {"card_id": 1}), + ] + for method, path, params in endpoints: + resp = await client.get(path, params=params) + assert resp.status_code == 403, ( + f"IP is banned, but {method} {path} returned {resp.status_code}" + ) + else: + pytest.skip( + f"Only {detected_count} attacks detected, ban threshold (3) not reached" + ) + + @pytest.mark.asyncio(loop_scope="session") + async def test_banned_ip_message(self): + """Banned IP should receive 'IP address banned' message.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + attacks = [ + "1' OR '1'='1; --", + "1; DROP TABLE cards; --", + "1 UNION SELECT password FROM users", + "", + ] + detected = 0 + for payload in attacks: + resp = await client.get("/get_card", params={"card_id": payload}) + if resp.status_code in (400, 403): + detected += 1 + + if detected >= 3: + resp = await client.get("/check_user", params={"user_id": 1}) + assert resp.status_code == 403 + assert "IP address banned" in resp.text, ( + f"Expected message 'IP address banned', got: {resp.text[:200]}" + ) + else: + pytest.skip(f"Only {detected} attacks detected, threshold not reached") + + +class TestSuspiciousHeaders: + """Tests for suspicious header detection.""" + + @pytest.mark.asyncio(loop_scope="session") + async def test_suspicious_user_agent(self): + """Request with suspicious User-Agent may be blocked.""" + async with AsyncClient(transport=ASGITransport(app=app), 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), base_url="http://test") as client: + resp = await client.get( + "/check_user", + params={"user_id": 1}, + headers={"X-Forwarded-For": ""} + ) + print(f"\nXSS in headers test: status={resp.status_code}") + # Header may be ignored or detected as suspicious + assert resp.status_code in (200, 400, 403), ( + f"Unexpected status code for XSS in headers: {resp.status_code}" + ) diff --git a/app/tests/test_visited_cards.py b/app/tests/test_visited_cards.py index e1b538e..6bd7aef 100644 --- a/app/tests/test_visited_cards.py +++ b/app/tests/test_visited_cards.py @@ -63,30 +63,44 @@ async def test_get_random_cards_valid(): @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), 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 (двойной вызов)\nOUTPUT 1: {result1}\nOUTPUT 2: {result2}") + 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") - else: - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - params = {"user_id": EXIST_USER} - tasks = [client.get("/get_random_cards", params=params) for _ in range(5)] - responses = await asyncio.gather(*tasks) - for resp in responses: - print(f"\nParallel call: status={resp.status_code} | json={resp.json()}") - assert resp.status_code == 200 - result = resp.json().get("result") - assert isinstance(result, list) - assert len(result) == 10 \ No newline at end of file + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + params = {"user_id": EXIST_USER} + tasks = [client.get("/get_random_cards", params=params) for _ in range(3)] + responses = await asyncio.gather(*tasks) + for resp in responses: + print(f"\nParallel call: status={resp.status_code} | text={resp.text[:200]}") + assert resp.status_code == 200, ( + f"Expected 200, got {resp.status_code}: {resp.text}" + ) + result = resp.json().get("result") + assert isinstance(result, list) \ No newline at end of file diff --git a/app/tg_bot.py b/app/tg_bot.py index 9f1d37e..0201c3f 100644 --- a/app/tg_bot.py +++ b/app/tg_bot.py @@ -1,11 +1,11 @@ """ -Telegram-бот модерации карточек. +Telegram card moderation bot. -Слушает очередь RabbitMQ «moderation» и отправляет карточки -в чат администратору с inline-кнопками «Принять ✅» / «Отклонить ❌». +Listens to the RabbitMQ "moderation" queue and sends cards +to the admin chat with inline buttons "Accept ✅" / "Reject ❌". -При нажатии кнопки бот вызывает защищённые эндпоинты -/card_accept или /card_reject с секретным заголовком. +When a button is pressed, the bot calls protected endpoints +/card_accept or /card_reject with a secret header. """ import os @@ -27,7 +27,7 @@ load_dotenv() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# ── Конфигурация ────────────────────────────────────────────── +# ── Configuration ────────────────────────────────────────────── BOT_TOKEN = os.getenv("TG_BOT_TOKEN") ADMIN_CHAT_ID = int(os.getenv("TG_ADMIN_CHAT_ID", "0")) API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:5000") @@ -38,9 +38,9 @@ dp = Dispatcher() rabbit = RabbitWorker() -# ── Отправка карточки администратору ────────────────────────── +# ── Sending card to admin ────────────────────────── async def _get_author_username(author_id: int) -> str: - """Запрашивает username автора через API.""" + """Fetches the author's username via API.""" try: async with aiohttp.ClientSession() as session: async with session.get( @@ -57,7 +57,7 @@ async def _get_author_username(author_id: int) -> str: def _format_date(iso_date: str) -> str: - """Преобразует ISO-дату в формат ДД.ММ.ГГГГ ЧЧ:ММ:СС.""" + """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") @@ -66,7 +66,7 @@ def _format_date(iso_date: str) -> str: async def send_card_to_admin(card: Card) -> None: - """Формирует сообщение и inline-клавиатуру для карточки.""" + """Formats message and inline keyboard for a card.""" author_display = await _get_author_username(card.author_id) date_display = _format_date(card.creation_date) @@ -100,10 +100,10 @@ async def send_card_to_admin(card: Card) -> None: logger.info("Sent card %s to admin chat", card.card_id) -# ── Вызов защищённых эндпоинтов API ────────────────────────── +# ── Calling protected API endpoints ────────────────────────── async def call_moderation_api(action: str, card_id: int) -> dict: """ - Вызывает /card_accept или /card_reject с секретным заголовком. + Calls /card_accept or /card_reject with secret header. action: 'accept' | 'reject' """ endpoint = f"{API_BASE_URL}/card_{action}" @@ -116,7 +116,7 @@ async def call_moderation_api(action: str, card_id: int) -> dict: return data -# ── Обработчики callback-кнопок ─────────────────────────────── +# ── Callback button handlers ─────────────────────────────── @dp.callback_query(F.data.startswith("accept:")) async def on_accept(callback: CallbackQuery) -> None: card_id = int(callback.data.split(":")[1]) @@ -150,7 +150,7 @@ async def on_reject(callback: CallbackQuery) -> None: await callback.answer("Карточка отклонена!") logger.info("Card %s rejected by admin", card_id) -# ── Lifecycle-хуки aiogram ──────────────────────────────────── +# ── aiogram Lifecycle hooks ──────────────────────────────────── _rabbit_task: asyncio.Task | None = None From b1cca197d5280bb08994b8cd16150d0aeebf2136 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 14:53:34 +0300 Subject: [PATCH 08/30] Add logging and add mongodb connection pool --- app/logger.py | 102 +++++++++++++++++++++++++++++++++++++++++++ app/main.py | 12 ++--- app/middleware.py | 62 ++++++++++++++++++++++++++ app/mongo_worker.py | 21 ++++++--- app/rabbit_worker.py | 14 +++--- app/requirements.txt | 3 +- app/tg_bot.py | 14 +++--- 7 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 app/logger.py create mode 100644 app/middleware.py diff --git a/app/logger.py b/app/logger.py new file mode 100644 index 0000000..dc392d8 --- /dev/null +++ b/app/logger.py @@ -0,0 +1,102 @@ +""" +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") +""" + +import os +import sys +import logging +from dotenv import load_dotenv +from loguru import logger + + +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: dict) -> bool: + """Pass DEBUG / INFO / WARNING to stdout.""" + return record["level"].no < logging.ERROR + + +def _stderr_filter(record: dict) -> 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: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = sys._getframe(6), 6 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + + +# ── Public setup function ───────────────────────────────────────────────────── + +def setup_logging(level: str | None = None) -> None: + """ + Configure Loguru sinks and intercept all stdlib loggers. + Call once at the very start of the application entry point. + """ + if not level: + level = get_log_level() + else: + level = level.strip().upper() + + logger.remove() # remove default sink + + common: dict = { + "level": level, + "serialize": True, # JSON output + "backtrace": False, + "diagnose": False, + } + + # stdout — DEBUG / INFO / WARNING + logger.add(sys.stdout, filter=_stdout_filter, **common) + + # stderr — ERROR / CRITICAL + logger.add(sys.stderr, filter=_stderr_filter, **{**common, "level": "ERROR"}) + + # Redirect all stdlib loggers (uvicorn, motor, aiogram, aio_pika …) + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + + # Suppress noisy third-party loggers — we handle HTTP access via middleware + _quiet = { + "uvicorn.access": logging.WARNING, # replaced by our middleware + "motor": logging.WARNING, + "aio_pika": logging.WARNING, + "aiormq": logging.WARNING, + } + for name, lvl in _quiet.items(): + _lib_logger = logging.getLogger(name) + _lib_logger.handlers = [InterceptHandler()] + _lib_logger.setLevel(lvl) + _lib_logger.propagate = False diff --git a/app/main.py b/app/main.py index f3a45e0..c2eda16 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,5 @@ import os import secrets -import logging import uvicorn import asyncio @@ -16,9 +15,10 @@ 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 - -logger = logging.getLogger(__name__) +setup_logging() load_dotenv() disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" @@ -56,6 +56,7 @@ config = SecurityConfig( guard_deco = SecurityDecorator(config) app.add_middleware(SecurityMiddleware, config=config) +app.add_middleware(RequestLoggingMiddleware) app.state.guard_decorator = guard_deco mongo_worker = MongoWorker() _rabbit_worker: Optional[RabbitWorker] = None @@ -84,6 +85,7 @@ async def verify_moderation_secret( @app.on_event("startup") async def startup_event(): await mongo_worker.create_indexes() + logger.info("Application started on :5000") @app.get("/check_user", status_code=200) @@ -170,7 +172,7 @@ async def add_card( try: await get_rabbit_worker().send_to_moderation(card) except Exception as exc: - logger.error("Failed to send card %s to moderation queue: %s", card.card_id, 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 @@ -277,7 +279,7 @@ async def get_comments( async def main(): - config = uvicorn.Config("main:app", host="0.0.0.0", port=5000, log_level="info") + config = uvicorn.Config("main:app", host="0.0.0.0", port=5000, log_level="warning") server = uvicorn.Server(config) await server.serve() diff --git a/app/middleware.py b/app/middleware.py new file mode 100644 index 0000000..c82bfaf --- /dev/null +++ b/app/middleware.py @@ -0,0 +1,62 @@ +""" +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 + + base_fields = { + "method": request.method, + "path": request.url.path, + "query": str(request.query_params) or None, + "status": status, + "duration_ms": duration_ms, + } + + if status >= 500: + logger.bind(**base_fields, request_body=body).error( + "{method} {path} → {status} ({duration_ms}ms)", **base_fields + ) + elif status >= 400: + logger.bind(**base_fields, request_body=body).warning( + "{method} {path} → {status} ({duration_ms}ms)", **base_fields + ) + else: + logger.bind(**base_fields).info( + "{method} {path} → {status} ({duration_ms}ms)", **base_fields + ) + + return response diff --git a/app/mongo_worker.py b/app/mongo_worker.py index 7de4381..a152c13 100644 --- a/app/mongo_worker.py +++ b/app/mongo_worker.py @@ -1,5 +1,4 @@ import os -import logging import motor.motor_asyncio @@ -10,9 +9,7 @@ from pymongo import ReturnDocument from schemas.base_schemas import User, Visited, Card, Comment from schemas.api_schemas import BaseResponse - - -logger = logging.getLogger(__name__) +from logger import logger class MongoWorker: @@ -25,7 +22,12 @@ class MongoWorker: password=os.getenv('MONGO_PASS'), serverSelectionTimeoutMS=5000, connectTimeoutMS=5000, + maxPoolSize=50, + minPoolSize=5, + maxIdleTimeMS=60000, + waitQueueTimeoutMS=5000 ) + logger.info("MongoDB connection established.") self.db = self.client["data"] self.users_data = self.db["users"] self.visited_data = self.db["visited"] @@ -58,6 +60,7 @@ class MongoWorker: 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: @@ -130,6 +133,7 @@ class MongoWorker: 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]: @@ -138,7 +142,7 @@ class MongoWorker: await self.game_data.insert_one(new_card.model_dump()) return new_card except Exception as exc: - logger.error("Failed to insert card: %s", exc) + logger.error("Failed to insert card: {}", exc) raise async def accept_card(self, card_id: int) -> BaseResponse: @@ -152,14 +156,18 @@ class MongoWorker: 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: @@ -206,6 +214,7 @@ class MongoWorker: {"user_id": user_id}, {"$push": {"liked_card_ids": card_id}}, ) + 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: @@ -227,6 +236,7 @@ class MongoWorker: {"user_id": user_id}, {"$push": {"disliked_card_ids": card_id}}, ) + logger.debug("Card disliked: card_id={}, user_id={}", card_id, user_id) return BaseResponse(result=True, error=False) @@ -253,6 +263,7 @@ class MongoWorker: 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: diff --git a/app/rabbit_worker.py b/app/rabbit_worker.py index 065f805..2c91475 100644 --- a/app/rabbit_worker.py +++ b/app/rabbit_worker.py @@ -1,7 +1,6 @@ import os import json import asyncio -import logging from typing import Callable, Awaitable import aio_pika @@ -9,9 +8,7 @@ from aio_pika.abc import AbstractIncomingMessage from dotenv import load_dotenv from schemas.base_schemas import Card - - -logger = logging.getLogger(__name__) +from logger import logger class RabbitWorker: @@ -20,9 +17,11 @@ class RabbitWorker: 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: + 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() @@ -34,7 +33,8 @@ class RabbitWorker: ), routing_key="moderation", ) - logger.info("Card %s sent to moderation queue", card.card_id) + 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, @@ -55,7 +55,7 @@ class RabbitWorker: card = Card.model_validate(card_data) await callback(card) except Exception as exc: - logger.error("Error processing moderation message: %s", exc) + logger.error("Error processing moderation message: {}", exc) await queue.consume(on_message) diff --git a/app/requirements.txt b/app/requirements.txt index a146643..68f6a0b 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -5,4 +5,5 @@ python-dotenv==1.0.1 uvicorn==0.34.0 aio-pika==10.0.1 aiogram==3.18.0 -aiohttp==3.11.18 \ No newline at end of file +aiohttp==3.11.18 +loguru==0.7.3 \ No newline at end of file diff --git a/app/tg_bot.py b/app/tg_bot.py index 0201c3f..3de8424 100644 --- a/app/tg_bot.py +++ b/app/tg_bot.py @@ -11,7 +11,6 @@ When a button is pressed, the bot calls protected endpoints import os import json import asyncio -import logging from datetime import datetime import aiohttp @@ -21,18 +20,17 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMar from schemas.base_schemas import Card from rabbit_worker import RabbitWorker +from logger import logger, setup_logging load_dotenv() -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +setup_logging() # ── Configuration ────────────────────────────────────────────── BOT_TOKEN = os.getenv("TG_BOT_TOKEN") 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() @@ -52,7 +50,7 @@ async def _get_author_username(author_id: int) -> str: if username: return f"@{username}" except Exception as exc: - logger.warning("Failed to fetch username for %s: %s", author_id, exc) + logger.warning("Failed to fetch username for {}: {}", author_id, exc) return str(author_id) @@ -97,7 +95,7 @@ async def send_card_to_admin(card: Card) -> None: reply_markup=keyboard, parse_mode="HTML", ) - logger.info("Sent card %s to admin chat", card.card_id) + logger.info("Sent card {} to admin chat", card.card_id) # ── Calling protected API endpoints ────────────────────────── @@ -131,7 +129,7 @@ async def on_accept(callback: CallbackQuery) -> None: parse_mode="HTML", ) await callback.answer("Карточка принята!") - logger.info("Card %s accepted by admin", card_id) + logger.info("Card {} accepted by admin", card_id) @dp.callback_query(F.data.startswith("reject:")) @@ -148,7 +146,7 @@ async def on_reject(callback: CallbackQuery) -> None: parse_mode="HTML", ) await callback.answer("Карточка отклонена!") - logger.info("Card %s rejected by admin", card_id) + logger.info("Card {} rejected by admin", card_id) # ── aiogram Lifecycle hooks ──────────────────────────────────── _rabbit_task: asyncio.Task | None = None From de20fa24922e2783e1efd27d0a9e2c32e01fd608 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 15:30:04 +0300 Subject: [PATCH 09/30] Update dockerfiles and github CI piplines --- .github/workflows/app-actions.yml | 71 ++++++++++++++++++++++------ app/.dockerignore | 15 ++++++ app/dockerfile | 11 ----- app/dockerfile.app | 32 +++++++++++++ app/dockerfile.bot | 34 ++++++++++++++ docker-compose.yml | 77 +++++++++++++++++++++++++++---- 6 files changed, 207 insertions(+), 33 deletions(-) create mode 100644 app/.dockerignore delete mode 100644 app/dockerfile create mode 100644 app/dockerfile.app create mode 100644 app/dockerfile.bot diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index 7c1efe5..8456737 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: paths: - - '**.py' + - 'app/**' branches: - main - app @@ -19,13 +19,14 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.12 - architecture: x64 + python-version: "3.12" + cache: pip + cache-dependency-path: app/requirements.txt - name: Install dependencies run: | @@ -42,26 +43,70 @@ jobs: MONGO_PORT: ${{ secrets.MONGO_PORT }} MONGO_USER: ${{ secrets.MONGO_USER }} MONGO_PASS: ${{ secrets.MONGO_PASS }} + RABBIT_HOST: ${{ secrets.RABBIT_HOST }} + RABBIT_PORT: ${{ secrets.RABBIT_PORT }} + RABBIT_USER: ${{ secrets.RABBIT_USER }} + RABBIT_PASS: ${{ secrets.RABBIT_PASS }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.12 - architecture: x64 + python-version: "3.12" + cache: pip + cache-dependency-path: app/requirements.txt - - name: Setup MongoDB - run: docker run --name mongodb -d -p ${{ secrets.MONGO_PORT }}:27017 -e MONGO_INITDB_ROOT_USERNAME=${{ secrets.MONGO_USER }} -e MONGO_INITDB_ROOT_PASSWORD=${{ secrets.MONGO_PASS }} mongodb/mongodb-community-server + - name: Start MongoDB + run: | + docker run -d --name mongodb \ + -p 27017: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 ${{ secrets.RABBIT_PORT }}:5672 \ + -e RABBITMQ_DEFAULT_USER=${{ secrets.RABBIT_USER }} \ + -e RABBITMQ_DEFAULT_PASS=${{ secrets.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 + + - name: Setup moderated base cards working-directory: ./app/tools run: python3 _add_base_cards.py -a 2 -f data/base_cards.json + - name: Run pytest - run: pytest -vs \ No newline at end of file + run: pytest -vs + + docker-build: + runs-on: ubuntu-latest + needs: [mypy, pytest] + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build backend image + run: docker build -f app/dockerfile.app -t tort-backend:ci ./app + + - name: Build bot image + run: docker build -f app/dockerfile.bot -t tort-tg-bot:ci ./app \ No newline at end of file diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..4016b01 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,15 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.venv/ +.env +tests/ +security.log +.git/ +.github/ +*.md +dockerfile.app +dockerfile.bot +.dockerignore + diff --git a/app/dockerfile b/app/dockerfile deleted file mode 100644 index 281fd13..0000000 --- a/app/dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM python:3.9.21-alpine - -WORKDIR /app - -COPY . . - -RUN pip3 install -r requirements.txt - -EXPOSE 5000 - -CMD ["python3", "main.py"] \ No newline at end of file diff --git a/app/dockerfile.app b/app/dockerfile.app new file mode 100644 index 0000000..2157f69 --- /dev/null +++ b/app/dockerfile.app @@ -0,0 +1,32 @@ +# ── Stage 1: Install dependencies ──────────────────────────── +FROM python:3.12.4-slim AS builder + +WORKDIR /build + +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ── Stage 2: Production image ──────────────────────────────── +FROM python:3.12.4-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /install /usr/local + +# Copy application code (respects .dockerignore) +COPY . . + +# Create non-root user +RUN groupadd --gid 1000 appuser && \ + useradd --uid 1000 --gid appuser --shell /bin/sh appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 5000 + +CMD ["python3", "main.py"] \ No newline at end of file diff --git a/app/dockerfile.bot b/app/dockerfile.bot new file mode 100644 index 0000000..4dec4c8 --- /dev/null +++ b/app/dockerfile.bot @@ -0,0 +1,34 @@ +# ── Stage 1: Install dependencies ──────────────────────────── +FROM python:3.12.4-slim AS builder + +WORKDIR /build + +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ── Stage 2: Production image ──────────────────────────────── +FROM python:3.12.4-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /install /usr/local + +# Copy application code (respects .dockerignore) +COPY . . + +# Create non-root user +RUN groupadd --gid 1000 appuser && \ + useradd --uid 1000 --gid appuser --shell /bin/sh appuser && \ + chown -R appuser:appuser /app + +USER appuser + +# Healthcheck: verify RabbitMQ connection is possible +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python3 -c "import socket; s=socket.create_connection(('${RABBIT_HOST:-rabbitmq}', int('${RABBIT_PORT:-5672}')), timeout=3); s.close()" || exit 1 + +CMD ["python3", "tg_bot.py"] diff --git a/docker-compose.yml b/docker-compose.yml index c21f383..06a4be9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,34 +1,93 @@ -version: '3.8' - services: mongodb: image: mongodb/mongodb-community-server container_name: tort-mongodb restart: always - network_mode: bridge environment: MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASS} ports: - "127.0.0.1:${MONGO_PORT}:27017" + networks: + - tort-net healthcheck: - test: ["CMD", "mongosh", "--username", "${MONGO_USER}", "--password", "${MONGO_PASS}", "--eval", "db.runCommand({ ping: 1 })"] + test: [ "CMD", "mongosh", "--username", "${MONGO_USER}", "--password", "${MONGO_PASS}", "--eval", "db.runCommand({ ping: 1 })" ] interval: 10s timeout: 5s - retries: 2 + retries: 3 + + rabbitmq: + image: rabbitmq:3.13-management-alpine + container_name: tort-rabbitmq + restart: always + environment: + RABBITMQ_DEFAULT_USER: ${RABBIT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBIT_PASS} + ports: + - "127.0.0.1:5672:5672" + - "127.0.0.1:15672:15672" + networks: + - tort-net + healthcheck: + test: [ "CMD", "rabbitmq-diagnostics", "-q", "ping" ] + interval: 10s + timeout: 5s + retries: 3 backend: build: context: ./app + dockerfile: dockerfile.app image: tort-backend:latest + container_name: tort-backend + restart: always depends_on: mongodb: condition: service_healthy - container_name: tort-backend - network_mode: "host" + rabbitmq: + condition: service_healthy environment: - MONGO_HOST: ${MONGO_HOST} - MONGO_PORT: ${MONGO_PORT} + MONGO_HOST: mongodb + MONGO_PORT: "27017" MONGO_USER: ${MONGO_USER} MONGO_PASS: ${MONGO_PASS} + RABBIT_HOST: rabbitmq + RABBIT_PORT: "5672" + RABBIT_USER: ${RABBIT_USER} + RABBIT_PASS: ${RABBIT_PASS} + MODERATION_SECRET: ${MODERATION_SECRET} + DISABLE_DOCS: ${DISABLE_DOCS:-true} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ports: + - "127.0.0.1:5000:5000" + networks: + - tort-net + tg-bot: + build: + context: ./app + dockerfile: dockerfile.bot + image: tort-tg-bot:latest + container_name: tort-tg-bot + restart: always + depends_on: + rabbitmq: + condition: service_healthy + backend: + condition: service_started + environment: + TG_BOT_TOKEN: ${TG_BOT_TOKEN} + TG_ADMIN_CHAT_ID: ${TG_ADMIN_CHAT_ID} + API_BASE_URL: http://backend:5000 + MODERATION_SECRET: ${MODERATION_SECRET} + RABBIT_HOST: rabbitmq + RABBIT_PORT: "5672" + RABBIT_USER: ${RABBIT_USER} + RABBIT_PASS: ${RABBIT_PASS} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + networks: + - tort-net + +networks: + tort-net: + driver: bridge From 218d668a8cafaab190762dce2dbfa430ce08a1ee Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 15:34:05 +0300 Subject: [PATCH 10/30] Some bugfix --- .github/workflows/app-actions.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index 8456737..aa61511 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -62,12 +62,12 @@ jobs: run: | docker run -d --name mongodb \ -p 27017:27017 \ - -e MONGO_INITDB_ROOT_USERNAME=$MONGO_USER \ - -e MONGO_INITDB_ROOT_PASSWORD=$MONGO_PASS \ + -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 \ + --username "${MONGO_USER}" --password "${MONGO_PASS}" \ --eval "db.runCommand({ping:1})" && break sleep 1 done @@ -75,9 +75,9 @@ jobs: - name: Start RabbitMQ run: | docker run -d --name rabbitmq \ - -p ${{ secrets.RABBIT_PORT }}:5672 \ - -e RABBITMQ_DEFAULT_USER=${{ secrets.RABBIT_USER }} \ - -e RABBITMQ_DEFAULT_PASS=${{ secrets.RABBIT_PASS }} \ + -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 From ce25f4d143cf4fb204fcd8232bc20658d0548287 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 15:37:17 +0300 Subject: [PATCH 11/30] Update .env_example fields --- .env | 6 ------ .env_example | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) delete mode 100644 .env create mode 100644 .env_example diff --git a/.env b/.env deleted file mode 100644 index 1e2f09a..0000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -DISABLE_DOCS=true - -MONGO_HOST=127.0.0.1 -MONGO_PORT=27017 -MONGO_USER=user -MONGO_PASS=pass \ No newline at end of file diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..4f1ec9e --- /dev/null +++ b/.env_example @@ -0,0 +1,16 @@ +DISABLE_DOCS=true + +MONGO_HOST=127.0.0.1 +MONGO_PORT=27017 +MONGO_USER=user +MONGO_PASS=pass + +RABBIT_HOST=127.0.0.1 +RABBIT_PORT=5672 +RABBIT_USER=user +RABBIT_PASS=pass + +API_BASE_URL='http://localhost:5000' +MODERATION_SECRET=secret +TG_BOT_TOKEN='token' +TG_ADMIN_CHAT_ID=000000000 \ No newline at end of file From 0c55cae3ef9d3afe26f6733bb35df77d85888484 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 15:39:44 +0300 Subject: [PATCH 12/30] Update CI --- .github/workflows/app-actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index aa61511..60d3daf 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -100,7 +100,7 @@ jobs: docker-build: runs-on: ubuntu-latest needs: [mypy, pytest] - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' steps: - name: Checkout uses: actions/checkout@v4 From f88e821784dd59a931218fe0e14453d288021dde Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 15:43:55 +0300 Subject: [PATCH 13/30] Some bugfix --- app/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index c2eda16..a810bc9 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ from tools.base_moderation import moderate_text from logger import logger, setup_logging from middleware import RequestLoggingMiddleware + setup_logging() load_dotenv() disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" @@ -34,7 +35,7 @@ app: FastAPI = FastAPI( config = SecurityConfig( enable_rate_limiting=True, rate_limit=10, # TODO: check rate limits in real usage - rate_limit_window=3, # TODO: check rate limits in real usage + rate_limit_window=1, # TODO: check rate limits in real usage enable_redis=False, enable_ip_banning=True, custom_log_file="security.log", From 2306855edd49740a94be38fd353162d86ef55ac5 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 16:03:02 +0300 Subject: [PATCH 14/30] Some bugfix in CI --- app/logger.py | 14 +++++++++++--- app/tests/test_visited_cards.py | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/logger.py b/app/logger.py index dc392d8..f40a301 100644 --- a/app/logger.py +++ b/app/logger.py @@ -11,12 +11,18 @@ Usage: 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'.""" @@ -27,12 +33,12 @@ def get_log_level() -> str: # ── Stdout / stderr filters ─────────────────────────────────────────────────── -def _stdout_filter(record: dict) -> bool: +def _stdout_filter(record: Record) -> bool: """Pass DEBUG / INFO / WARNING to stdout.""" return record["level"].no < logging.ERROR -def _stderr_filter(record: dict) -> bool: +def _stderr_filter(record: Record) -> bool: """Pass ERROR / CRITICAL to stderr.""" return record["level"].no >= logging.ERROR @@ -43,12 +49,14 @@ 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, depth = sys._getframe(6), 6 + frame: FrameType | None = sys._getframe(6) + depth = 6 while frame and frame.f_code.co_filename == logging.__file__: frame = frame.f_back depth += 1 diff --git a/app/tests/test_visited_cards.py b/app/tests/test_visited_cards.py index 6bd7aef..067f07e 100644 --- a/app/tests/test_visited_cards.py +++ b/app/tests/test_visited_cards.py @@ -42,7 +42,7 @@ async def test_get_random_cards_valid(): params = {"user_id": EXIST_USER} response = await client.get("/get_random_cards", params=params) print(f"\nINPUT: endpoint=/get_random_cards\nOUTPUT: status={response.status_code} | json={response.json()}") - if response.status_code == 404 and BaseResponse.model_validate(response.json()).result == "No active cards": + if response.status_code == 404 and "No active cards" in str(BaseResponse.model_validate(response.json()).result): global NO_ACTIVE_CARDS_STATUS NO_ACTIVE_CARDS_STATUS = True pytest.skip(reason="No active cards in MongoDB") From df50140fcdc084edfca2b9d4b1474ff2ff9aa6c4 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 16:04:38 +0300 Subject: [PATCH 15/30] Some bugfix in CI --- app/tg_bot.py | 16 ++++++++++++---- app/tools/_add_base_cards.py | 10 ++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/tg_bot.py b/app/tg_bot.py index 3de8424..1d8580a 100644 --- a/app/tg_bot.py +++ b/app/tg_bot.py @@ -16,7 +16,7 @@ from datetime import datetime import aiohttp from dotenv import load_dotenv from aiogram import Bot, Dispatcher, F -from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message from schemas.base_schemas import Card from rabbit_worker import RabbitWorker @@ -27,7 +27,7 @@ load_dotenv() setup_logging() # ── Configuration ────────────────────────────────────────────── -BOT_TOKEN = os.getenv("TG_BOT_TOKEN") +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") @@ -117,6 +117,9 @@ async def call_moderation_api(action: str, card_id: int) -> dict: # ── Callback button handlers ─────────────────────────────── @dp.callback_query(F.data.startswith("accept:")) async def on_accept(callback: CallbackQuery) -> None: + 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) @@ -124,8 +127,9 @@ async def on_accept(callback: CallbackQuery) -> None: await callback.answer(f"Ошибка: {result['result']}", show_alert=True) return + orig_text = callback.message.text or "" await callback.message.edit_text( - callback.message.text + "\n\n✅ ПРИНЯТА", + orig_text + "\n\n✅ ПРИНЯТА", parse_mode="HTML", ) await callback.answer("Карточка принята!") @@ -134,6 +138,9 @@ async def on_accept(callback: CallbackQuery) -> None: @dp.callback_query(F.data.startswith("reject:")) async def on_reject(callback: CallbackQuery) -> None: + 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) @@ -141,8 +148,9 @@ async def on_reject(callback: CallbackQuery) -> None: await callback.answer(f"Ошибка: {result['result']}", show_alert=True) return + orig_text = callback.message.text or "" await callback.message.edit_text( - callback.message.text + "\n\n❌ ОТКЛОНЕНА", + orig_text + "\n\n❌ ОТКЛОНЕНА", parse_mode="HTML", ) await callback.answer("Карточка отклонена!") diff --git a/app/tools/_add_base_cards.py b/app/tools/_add_base_cards.py index 0e8ce00..7d52e99 100644 --- a/app/tools/_add_base_cards.py +++ b/app/tools/_add_base_cards.py @@ -41,10 +41,12 @@ def write_json(cards_list, json_file): except Exception as e: print(f"Error writing to JSON file: {e}") -def add_cards_to_mongodb(cards_list): +import asyncio + +async def add_cards_to_mongodb(cards_list): mongo = MongoWorker() for card in cards_list: - mongo.add_card_by_base_model(card) + await mongo.add_card_by_base_model(card) print(f"Successfully added {len(cards_list)} cards to MongoDB") @@ -60,13 +62,13 @@ if __name__ == "__main__": if args.action == 0: cards = create_cards(args.num, args.user) - add_cards_to_mongodb(cards) + asyncio.run(add_cards_to_mongodb(cards)) elif args.action == 1: cards = create_cards(args.num, args.user) write_json(cards, args.file) elif args.action == 2: cards = read_json(args.file) if cards: - add_cards_to_mongodb(cards) + asyncio.run(add_cards_to_mongodb(cards)) else: print("No valid cards found in JSON file.") From 130c1c7d5f1725f3ee288e633ed5359f2aadbdb9 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 17:38:51 +0300 Subject: [PATCH 16/30] Some bugfix in CI --- .github/workflows/app-actions.yml | 10 +++++----- app/main.py | 10 ---------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index 60d3daf..f72419b 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -39,12 +39,12 @@ jobs: pytest: runs-on: ubuntu-latest env: - MONGO_HOST: ${{ secrets.MONGO_HOST }} - MONGO_PORT: ${{ secrets.MONGO_PORT }} + MONGO_HOST: "127.0.0.1" + MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }} MONGO_USER: ${{ secrets.MONGO_USER }} MONGO_PASS: ${{ secrets.MONGO_PASS }} - RABBIT_HOST: ${{ secrets.RABBIT_HOST }} - RABBIT_PORT: ${{ secrets.RABBIT_PORT }} + RABBIT_HOST: "127.0.0.1" + RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }} RABBIT_USER: ${{ secrets.RABBIT_USER }} RABBIT_PASS: ${{ secrets.RABBIT_PASS }} steps: @@ -61,7 +61,7 @@ jobs: - name: Start MongoDB run: | docker run -d --name mongodb \ - -p 27017:27017 \ + -p "${MONGO_PORT}:27017" \ -e "MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}" \ -e "MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS}" \ mongodb/mongodb-community-server diff --git a/app/main.py b/app/main.py index a810bc9..625c87b 100644 --- a/app/main.py +++ b/app/main.py @@ -96,7 +96,6 @@ async def check_user( 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, @@ -108,7 +107,6 @@ async def get_user( 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( @@ -139,8 +137,6 @@ async def get_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( @@ -161,7 +157,6 @@ async def get_random_cards( return BaseResponse(result=random_cards) - @app.post("/add_card", status_code=201) @guard_deco.rate_limit(requests=3, window=60) async def add_card( @@ -179,7 +174,6 @@ async def add_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, @@ -190,7 +184,6 @@ async def card_accept( 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, @@ -201,7 +194,6 @@ async def card_reject( response.status_code = status.HTTP_404_NOT_FOUND return result - @app.patch("/select_choice", status_code=200) async def select_choice( choice_data: SelectChoice, @@ -235,7 +227,6 @@ async def like_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, @@ -247,7 +238,6 @@ async def dislike_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( From 7d214689bfbfb843ff9842c595dcac73bd7ccca1 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 17:43:54 +0300 Subject: [PATCH 17/30] Some bugfix in CI --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 625c87b..e1bb96f 100644 --- a/app/main.py +++ b/app/main.py @@ -35,7 +35,7 @@ app: FastAPI = FastAPI( config = SecurityConfig( enable_rate_limiting=True, rate_limit=10, # TODO: check rate limits in real usage - rate_limit_window=1, # TODO: check rate limits in real usage + rate_limit_window=3, # TODO: check rate limits in real usage enable_redis=False, enable_ip_banning=True, custom_log_file="security.log", From 05f9371aa822915784291eb520ccfe5984405747 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 17:55:01 +0300 Subject: [PATCH 18/30] Some bugfix in CI --- app/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index ac1dc04..3c22d0b 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -12,7 +12,7 @@ def _clear_middleware_suspicious_counts(): """Search for SecurityMiddleware in middleware stack and clear suspicious_request_counts.""" from guard.middleware import SecurityMiddleware from main import app - current = app + current = getattr(app, 'middleware_stack', None) or app visited = set() while current is not None and id(current) not in visited: visited.add(id(current)) From 98f98fb8c10a2e9334d4406c00358257f66491f0 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 17:59:48 +0300 Subject: [PATCH 19/30] fix(ci): explicitly pass client=('127.0.0.1', 50000) to ASGITransport in tests --- app/tests/test_cards.py | 26 ++++++++++---------- app/tests/test_security.py | 42 ++++++++++++++++----------------- app/tests/test_user_info.py | 16 ++++++------- app/tests/test_visited_cards.py | 8 +++---- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/app/tests/test_cards.py b/app/tests/test_cards.py index 921dfca..ebe388d 100644 --- a/app/tests/test_cards.py +++ b/app/tests/test_cards.py @@ -16,7 +16,7 @@ NON_EXIST_CARD_ID = 1000 @pytest.mark.asyncio(loop_scope="session") async def test_add_card_valid(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: payload = { "choice_A": "Option A", "choice_B": "Option B", @@ -34,7 +34,7 @@ async def test_add_card_valid(): @pytest.mark.asyncio(loop_scope="session") async def test_add_card_missing_field(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: payload = { #choice_A "choice_B": "Option B", @@ -46,7 +46,7 @@ async def test_add_card_missing_field(): @pytest.mark.asyncio(loop_scope="session") async def test_add_card_wrong_type(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: payload = { "choice_A": 123, "choice_B": "Option B", @@ -58,7 +58,7 @@ async def test_add_card_wrong_type(): @pytest.mark.asyncio(loop_scope="session") async def test_add_card_empty_strings(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: payload = { "choice_A": "", "choice_B": "", @@ -70,7 +70,7 @@ async def test_add_card_empty_strings(): @pytest.mark.asyncio(loop_scope="session") async def test_add_card_long_strings(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: long_str = "A" * 5000 # long string payload = { "choice_A": long_str, @@ -83,7 +83,7 @@ async def test_add_card_long_strings(): @pytest.mark.asyncio(loop_scope="session") async def test_add_card_negative_author_id(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: payload = { "choice_A": "Option A", "choice_B": "Option B", @@ -95,7 +95,7 @@ async def test_add_card_negative_author_id(): @pytest.mark.asyncio(loop_scope="session") async def test_add_card_malformed_json(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: malformed_json = '{"choice_A": "Option A", "choice_B": "Option B", "author_id": 123' # broken json response = await client.post( "/add_card", @@ -112,7 +112,7 @@ async def test_async_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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: tasks = [] num_cards = 2 # at most 3 (decorator limit), leaving a margin for i in range(num_cards): @@ -143,7 +143,7 @@ async def test_async_card_creation(): @pytest.mark.asyncio(loop_scope="session") async def test_get_card_valid(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: # First create a card payload = { "choice_A": "GetTest A", @@ -169,28 +169,28 @@ async def test_get_card_valid(): @pytest.mark.asyncio(loop_scope="session") async def test_get_card_nonexistent(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: response = await client.get("/get_card", params={"card_id": NON_EXIST_CARD_ID}) print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {NON_EXIST_CARD_ID}}}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 404 @pytest.mark.asyncio(loop_scope="session") async def test_get_card_missing_param(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: response = await client.get("/get_card") print(f"\nINPUT: endpoint=/get_card (missing card_id param)\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No content'}") assert response.status_code == 422 @pytest.mark.asyncio(loop_scope="session") async def test_get_card_wrong_type(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: response = await client.get("/get_card", params={"card_id": "abc"}) print(f"\nINPUT: endpoint=/get_card | params={{'card_id': 'abc'}}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 422 @pytest.mark.asyncio(loop_scope="session") async def test_get_card_negative(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: response = await client.get("/get_card", params={"card_id": -10}) print(f"\nINPUT: endpoint=/get_card | params={{'card_id': -10}}\nOUTPUT: status={response.status_code} | json={response.json()}") assert response.status_code == 404 \ No newline at end of file diff --git a/app/tests/test_security.py b/app/tests/test_security.py index e91c72b..ab548f4 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -33,7 +33,7 @@ class TestGlobalRateLimit: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: for i in range(9): resp = await client.get("/check_user", params={"user_id": 1}) assert resp.status_code == 200, ( @@ -46,7 +46,7 @@ class TestGlobalRateLimit: 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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: # Send 10 requests (fill the limit) for i in range(10): await client.get("/check_user", params={"user_id": 1}) @@ -61,7 +61,7 @@ class TestGlobalRateLimit: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: # Exhaust limit for _ in range(10): await client.get("/check_user", params={"user_id": 1}) @@ -81,7 +81,7 @@ class TestDecoratorRateLimit: 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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: data = { "user_id": random.randint(100000000, 999999999), "username": "RateTest", @@ -108,7 +108,7 @@ class TestDecoratorRateLimit: """ /add_card: limit 3 requests / 60 sec. """ - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: author_id = random.randint(100000000, 999999999) for i in range(3): payload = { @@ -136,7 +136,7 @@ class TestDecoratorRateLimit: """ /get_random_cards: limit 5 requests / 60 sec. """ - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: user_id = random.randint(100000000, 999999999) for i in range(5): @@ -156,7 +156,7 @@ class TestDecoratorRateLimit: """ /comment: limit 5 requests / 20 sec. """ - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: for i in range(5): payload = { "author_id": random.randint(100000000, 999999999), @@ -185,7 +185,7 @@ class TestDecoratorRateLimit: 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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: # 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}) @@ -211,7 +211,7 @@ class TestRateLimitParallel: 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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: tasks = [ client.get("/check_user", params={"user_id": 1}) for _ in range(15) @@ -247,7 +247,7 @@ class TestPenetrationDetection: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: resp = await client.get( "/get_card", params={"card_id": "1 OR 1=1; DROP TABLE users;--"} @@ -261,7 +261,7 @@ class TestPenetrationDetection: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: resp = await client.get( "/get_card", params={"card_id": ""} @@ -274,7 +274,7 @@ class TestPenetrationDetection: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: 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 @@ -285,7 +285,7 @@ class TestPenetrationDetection: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: payload = { "choice_A": "", "choice_B": "Normal option", @@ -301,7 +301,7 @@ class TestPenetrationDetection: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: payload = { "choice_A": "; cat /etc/passwd; echo", "choice_B": "$(whoami)", @@ -317,7 +317,7 @@ class TestPenetrationDetection: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: resp = await client.get( "/get_card", params={"card_id": "1 UNION SELECT password FROM users"} @@ -330,7 +330,7 @@ class TestPenetrationDetection: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: 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 @@ -351,7 +351,7 @@ class TestAutoIPBan: 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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: # Send suspicious requests sequentially (SQL injection variants) injection_payloads = [ "1' OR '1'='1", @@ -387,7 +387,7 @@ class TestAutoIPBan: """ If IP is banned, all endpoints should return 403. """ - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: # Send various attacks to guarantee hitting the threshold attacks = [ "1' OR '1'='1; --", @@ -425,7 +425,7 @@ class TestAutoIPBan: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: attacks = [ "1' OR '1'='1; --", "1; DROP TABLE cards; --", @@ -454,7 +454,7 @@ class TestSuspiciousHeaders: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: resp = await client.get( "/check_user", params={"user_id": 1}, @@ -470,7 +470,7 @@ class TestSuspiciousHeaders: @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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: resp = await client.get( "/check_user", params={"user_id": 1}, diff --git a/app/tests/test_user_info.py b/app/tests/test_user_info.py index 157d145..afdc0ac 100644 --- a/app/tests/test_user_info.py +++ b/app/tests/test_user_info.py @@ -18,7 +18,7 @@ NON_EXIST_USER = random.randint(100000000, 1000000000) @pytest.mark.asyncio(loop_scope="session") async def test_add_user_non_full_data(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/add_user" data = { @@ -32,7 +32,7 @@ async def test_add_user_non_full_data(): @pytest.mark.asyncio(loop_scope="session") async def test_add_user_negative_int_id(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/add_user" data = { @@ -49,7 +49,7 @@ async def test_add_user_negative_int_id(): @pytest.mark.asyncio(loop_scope="session") async def test_add_new_user(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/add_user" data = { @@ -69,7 +69,7 @@ async def test_add_new_user(): @pytest.mark.asyncio(loop_scope="session") async def test_add_already_exist_user(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/add_user" data = { @@ -94,7 +94,7 @@ async def test_add_already_exist_user(): @pytest.mark.asyncio(loop_scope="session") async def test_check_non_exist_user(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/check_user" params = {"user_id": NON_EXIST_USER} @@ -108,7 +108,7 @@ async def test_check_non_exist_user(): @pytest.mark.asyncio(loop_scope="session") async def test_check_exist_user(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/check_user" params = {"user_id": EXIST_USER} @@ -127,7 +127,7 @@ async def test_check_exist_user(): @pytest.mark.asyncio(loop_scope="session") async def test_get_non_exist_user(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/get_user" params = {"user_id": NON_EXIST_USER} @@ -141,7 +141,7 @@ async def test_get_non_exist_user(): @pytest.mark.asyncio(loop_scope="session") async def test_get_exist_user(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/get_user" params = {"user_id": EXIST_USER} diff --git a/app/tests/test_visited_cards.py b/app/tests/test_visited_cards.py index 067f07e..e031836 100644 --- a/app/tests/test_visited_cards.py +++ b/app/tests/test_visited_cards.py @@ -16,7 +16,7 @@ ACTIVE_CARDS_LESS_THAN_TEN = False @pytest.mark.asyncio(loop_scope="session") async def test_add_new_user(): - async with AsyncClient(transport=ASGITransport(app=app), + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url='http://test') as client: end_point = "/add_user" data = { @@ -38,7 +38,7 @@ async def test_add_new_user(): @pytest.mark.asyncio(loop_scope="session") async def test_get_random_cards_valid(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: params = {"user_id": EXIST_USER} response = await client.get("/get_random_cards", params=params) print(f"\nINPUT: endpoint=/get_random_cards\nOUTPUT: status={response.status_code} | json={response.json()}") @@ -68,7 +68,7 @@ async def test_get_random_cards_randomness(): 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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: params = {"user_id": EXIST_USER} response1 = await client.get("/get_random_cards", params=params) response2 = await client.get("/get_random_cards", params=params) @@ -93,7 +93,7 @@ async def test_get_random_cards_parallel_requests(): 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), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: params = {"user_id": EXIST_USER} tasks = [client.get("/get_random_cards", params=params) for _ in range(3)] responses = await asyncio.gather(*tasks) From f8a2744492fb33f6821536809d12d1b94878c458 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 25 Aug 2026 18:13:16 +0300 Subject: [PATCH 20/30] fix(tests): reliable SecurityMiddleware state reset via app.state reference - Store direct reference to SecurityMiddleware instance in app.state._security_middleware immediately at module load time in main.py (before middleware_stack is built) - Rewrite conftest.py _reset_all() to use app.state._security_middleware instead of fragile middleware stack traversal that failed before the first request was made - Set explicit client=('127.0.0.1', 50000) on ASGITransport across all test files to ensure guard-core always sees a valid IP and can ban/track it correctly --- app/main.py | 2 ++ app/tests/conftest.py | 24 ++++++++---------------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/app/main.py b/app/main.py index e1bb96f..c4a4c5f 100644 --- a/app/main.py +++ b/app/main.py @@ -56,9 +56,11 @@ config = SecurityConfig( ) 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 mongo_worker = MongoWorker() _rabbit_worker: Optional[RabbitWorker] = None diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 3c22d0b..af3770a 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -8,20 +8,6 @@ from guard import ip_ban_manager from guard_core.handlers.ratelimit_handler import RateLimitManager -def _clear_middleware_suspicious_counts(): - """Search for SecurityMiddleware in middleware stack and clear suspicious_request_counts.""" - from guard.middleware import SecurityMiddleware - from main import app - current = getattr(app, 'middleware_stack', None) or app - visited = set() - while current is not None and id(current) not in visited: - visited.add(id(current)) - if isinstance(current, SecurityMiddleware): - current.suspicious_request_counts.clear() - break - current = getattr(current, 'app', None) - - def _reset_all(): """Full reset of rate-limiter, IP-ban, and suspicious counts.""" # Rate limit timestamps @@ -33,8 +19,14 @@ def _reset_all(): ip_ban_manager.banned_ips.clear() ip_ban_manager.banned_networks.clear() - # Suspicious request counts - _clear_middleware_suspicious_counts() + # Suspicious request counts via direct reference stored in app.state + try: + from main import app + sm = getattr(app.state, '_security_middleware', None) + if sm is not None: + sm.suspicious_request_counts.clear() + except Exception: + pass @pytest.fixture(autouse=True) From 7566c4dcc82b991455543e642eeffb9eaf4ff686 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Wed, 26 Aug 2026 10:05:02 +0300 Subject: [PATCH 21/30] Some bugfix in CI --- app/tests/conftest.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index af3770a..6155a3f 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -20,11 +20,15 @@ def _reset_all(): 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: - from main import app - sm = getattr(app.state, '_security_middleware', None) - if sm is not None: - sm.suspicious_request_counts.clear() + 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 From fd70dc11b522f485f619db18346cb5a1edda82ce Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Thu, 27 Aug 2026 11:03:02 +0300 Subject: [PATCH 22/30] Some bugfix in CI --- app/tests/test_security.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/tests/test_security.py b/app/tests/test_security.py index ab548f4..74f856f 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -351,7 +351,7 @@ class TestAutoIPBan: 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=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("7.214.201.94", 50000)), base_url="http://test") as client: # Send suspicious requests sequentially (SQL injection variants) injection_payloads = [ "1' OR '1'='1", @@ -387,7 +387,7 @@ class TestAutoIPBan: """ If IP is banned, all endpoints should return 403. """ - async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("7.214.201.94", 50000)), base_url="http://test") as client: # Send various attacks to guarantee hitting the threshold attacks = [ "1' OR '1'='1; --", @@ -425,7 +425,7 @@ class TestAutoIPBan: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("7.214.201.94", 50000)), base_url="http://test") as client: attacks = [ "1' OR '1'='1; --", "1; DROP TABLE cards; --", @@ -454,7 +454,7 @@ class TestSuspiciousHeaders: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("7.214.201.94", 50000)), base_url="http://test") as client: resp = await client.get( "/check_user", params={"user_id": 1}, @@ -470,7 +470,7 @@ class TestSuspiciousHeaders: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=("7.214.201.94", 50000)), base_url="http://test") as client: resp = await client.get( "/check_user", params={"user_id": 1}, From 6eb5e45ccee58e4af9c4c050a11df6b9f4d6ce99 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 31 Aug 2026 12:49:42 +0300 Subject: [PATCH 23/30] Fix critical bugs --- app/main.py | 17 +++++--- app/mongo_worker.py | 94 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/app/main.py b/app/main.py index c4a4c5f..f611de1 100644 --- a/app/main.py +++ b/app/main.py @@ -47,7 +47,7 @@ config = SecurityConfig( detection_compiler_timeout=2.0, detection_max_content_length=10000, detection_preserve_attack_patterns=True, - detection_semantic_threshold=0.7, + detection_semantic_threshold=0.7, detection_anomaly_threshold=3.0, detection_slow_pattern_threshold=0.1, @@ -201,11 +201,17 @@ async def select_choice( choice_data: SelectChoice, response: Response, mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: - check_visited = await mongo.get_visited_cards(choice_data.user_id) - if check_visited.error: + # Verify that the user exists before proceeding. + if not await mongo.check_user(choice_data.user_id): response.status_code = status.HTTP_404_NOT_FOUND - return check_visited - if choice_data.card_id in check_visited.result.cards_visited: + 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) + if not newly_visited: response.status_code = status.HTTP_403_FORBIDDEN return BaseResponse(result="Card already visited!", error=True) @@ -214,7 +220,6 @@ async def select_choice( response.status_code = status.HTTP_404_NOT_FOUND return select_choice_result - await mongo.update_visited_cards(choice_data.user_id, choice_data.card_id) return BaseResponse(result="Select choice complete!") diff --git a/app/mongo_worker.py b/app/mongo_worker.py index a152c13..12352ae 100644 --- a/app/mongo_worker.py +++ b/app/mongo_worker.py @@ -96,6 +96,39 @@ class MongoWorker: ) 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]: document = await self.game_data.find_one({"card_id": card_id}) @@ -187,33 +220,38 @@ class MongoWorker: return BaseResponse(result=True, error=False) - async def check_user_reactions(self, user_id: int, card_id: int) -> BaseResponse: - user_info: User = await self.get_user(user_id) - if card_id in user_info.liked_card_ids: - return BaseResponse(result="Card already liked", error=True) - if card_id in user_info.disliked_card_ids: - return BaseResponse(result="Card already disliked", error=True) - return BaseResponse(result="No reactions", error=False) - async def like_card(self, card_id: int, user_id: int) -> BaseResponse: if not await self.check_user(user_id): return BaseResponse(result="User doesn't exist", error=True) - user_reaction = await self.check_user_reactions(user_id, card_id) - if user_reaction.error: - return user_reaction + # 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) - await self.users_data.update_one( - {"user_id": user_id}, - {"$push": {"liked_card_ids": card_id}}, - ) logger.debug("Card liked: card_id={}, user_id={}", card_id, user_id) return BaseResponse(result=True, error=False) @@ -221,21 +259,31 @@ class MongoWorker: if not await self.check_user(user_id): return BaseResponse(result="User doesn't exist", error=True) - user_reaction = await self.check_user_reactions(user_id, card_id) - if user_reaction.error: - return user_reaction + # 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) - await self.users_data.update_one( - {"user_id": user_id}, - {"$push": {"disliked_card_ids": card_id}}, - ) logger.debug("Card disliked: card_id={}, user_id={}", card_id, user_id) return BaseResponse(result=True, error=False) @@ -267,6 +315,8 @@ class MongoWorker: return BaseResponse(result=new_comment) async def get_comments(self, card_id: int) -> BaseResponse: + 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) \ No newline at end of file From 6c4ab4b2b470f1db43a1593c58cd5148b884fce3 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 31 Aug 2026 12:50:28 +0300 Subject: [PATCH 24/30] Add global client info --- app/tests/test_security.py | 45 ++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/app/tests/test_security.py b/app/tests/test_security.py index 74f856f..804984a 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -22,6 +22,9 @@ from httpx import AsyncClient, ASGITransport from main import app +# Client IP and port +CLIENT = ("7.214.201.94", 50000) + # ======================================================================== # RATE LIMIT TESTS # ======================================================================== @@ -33,7 +36,7 @@ class TestGlobalRateLimit: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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, ( @@ -46,7 +49,7 @@ class TestGlobalRateLimit: 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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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}) @@ -61,7 +64,7 @@ class TestGlobalRateLimit: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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}) @@ -81,7 +84,7 @@ class TestDecoratorRateLimit: 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=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client: data = { "user_id": random.randint(100000000, 999999999), "username": "RateTest", @@ -108,7 +111,7 @@ class TestDecoratorRateLimit: """ /add_card: limit 3 requests / 60 sec. """ - async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: + 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 = { @@ -136,7 +139,7 @@ class TestDecoratorRateLimit: """ /get_random_cards: limit 5 requests / 60 sec. """ - async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: + 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): @@ -156,7 +159,7 @@ class TestDecoratorRateLimit: """ /comment: limit 5 requests / 20 sec. """ - async with AsyncClient(transport=ASGITransport(app=app, client=("127.0.0.1", 50000)), base_url="http://test") as client: + 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), @@ -185,7 +188,7 @@ class TestDecoratorRateLimit: 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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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}) @@ -211,7 +214,7 @@ class TestRateLimitParallel: 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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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) @@ -247,7 +250,7 @@ class TestPenetrationDetection: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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;--"} @@ -261,7 +264,7 @@ class TestPenetrationDetection: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client: resp = await client.get( "/get_card", params={"card_id": ""} @@ -274,7 +277,7 @@ class TestPenetrationDetection: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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 @@ -285,7 +288,7 @@ class TestPenetrationDetection: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client: payload = { "choice_A": "", "choice_B": "Normal option", @@ -301,7 +304,7 @@ class TestPenetrationDetection: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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)", @@ -317,7 +320,7 @@ class TestPenetrationDetection: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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"} @@ -330,7 +333,7 @@ class TestPenetrationDetection: @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=("127.0.0.1", 50000)), base_url="http://test") as client: + 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 @@ -351,7 +354,7 @@ class TestAutoIPBan: 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=("7.214.201.94", 50000)), base_url="http://test") as client: + 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", @@ -387,7 +390,7 @@ class TestAutoIPBan: """ If IP is banned, all endpoints should return 403. """ - async with AsyncClient(transport=ASGITransport(app=app, client=("7.214.201.94", 50000)), base_url="http://test") as client: + 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; --", @@ -425,7 +428,7 @@ class TestAutoIPBan: @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=("7.214.201.94", 50000)), base_url="http://test") as client: + async with AsyncClient(transport=ASGITransport(app=app, client=CLIENT), base_url="http://test") as client: attacks = [ "1' OR '1'='1; --", "1; DROP TABLE cards; --", @@ -454,7 +457,7 @@ class TestSuspiciousHeaders: @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=("7.214.201.94", 50000)), base_url="http://test") as client: + 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}, @@ -470,7 +473,7 @@ class TestSuspiciousHeaders: @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=("7.214.201.94", 50000)), base_url="http://test") as client: + 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}, From 90ed19b2f679a4ff4a6debe74a4b15f43a2bf1f3 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 31 Aug 2026 13:34:47 +0300 Subject: [PATCH 25/30] Add Telegram Auth and Dev mode --- .env_example | 2 +- app/main.py | 62 +++++++++---- app/schemas/api_schemas.py | 12 +-- app/tests/test_tg_auth.py | 150 ++++++++++++++++++++++++++++++++ app/tg_auth.py | 172 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 4 +- 6 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 app/tests/test_tg_auth.py create mode 100644 app/tg_auth.py diff --git a/.env_example b/.env_example index 4f1ec9e..dfb56d3 100644 --- a/.env_example +++ b/.env_example @@ -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 diff --git a/app/main.py b/app/main.py index f611de1..8e408c0 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/schemas/api_schemas.py b/app/schemas/api_schemas.py index 745d791..605d83a 100644 --- a/app/schemas/api_schemas.py +++ b/app/schemas/api_schemas.py @@ -2,13 +2,15 @@ import typing from pydantic import BaseModel, NonNegativeInt +from typing import Optional + class BaseResponse(BaseModel): result: typing.Any error: bool = False class AddUserBody(BaseModel): - user_id: NonNegativeInt + user_id: Optional[NonNegativeInt] = None username: str first_name: str @@ -19,20 +21,20 @@ class AddCardBody(BaseModel): choice_A: str choice_B: str - author_id: NonNegativeInt + author_id: Optional[NonNegativeInt] = None class SelectChoice(BaseModel): - user_id: NonNegativeInt + user_id: Optional[NonNegativeInt] = None card_id: NonNegativeInt choice: typing.Literal["A", "B"] class ReactionCard(BaseModel): - user_id: NonNegativeInt + user_id: Optional[NonNegativeInt] = None card_id: NonNegativeInt class AddCommentBody(BaseModel): - author_id: NonNegativeInt + author_id: Optional[NonNegativeInt] = None card_id: NonNegativeInt comment_text: str \ No newline at end of file diff --git a/app/tests/test_tg_auth.py b/app/tests/test_tg_auth.py new file mode 100644 index 0000000..7b66c26 --- /dev/null +++ b/app/tests/test_tg_auth.py @@ -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 diff --git a/app/tg_auth.py b/app/tg_auth.py new file mode 100644 index 0000000..e1034a9 --- /dev/null +++ b/app/tg_auth.py @@ -0,0 +1,172 @@ +""" +Telegram Mini App initData authentication module. + +Validates initData from the Telegram WebApp using HMAC-SHA256 +per the official specification: +https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app + +In DEV_MODE (default) authentication is skipped — user_id is taken +from the request body / query parameters as-is. +""" + +import hashlib +import hmac +import json +import os +import time +from typing import Optional +from urllib.parse import parse_qs + +from dotenv import load_dotenv +from fastapi import HTTPException, Request, status + +from logger import logger + +load_dotenv() + +DEV_MODE: bool = os.getenv("DEV_MODE", "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"] diff --git a/docker-compose.yml b/docker-compose.yml index 06a4be9..5032aa0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" From 86eba8752a1f8fd911730b5cf3f8dad4d11e9e86 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 31 Aug 2026 14:11:09 +0300 Subject: [PATCH 26/30] LMAO --- app/tests/test_tg_auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/tests/test_tg_auth.py b/app/tests/test_tg_auth.py index 7b66c26..3f4a276 100644 --- a/app/tests/test_tg_auth.py +++ b/app/tests/test_tg_auth.py @@ -16,7 +16,8 @@ from fastapi import HTTPException from tg_auth import validate_init_data -BOT_TOKEN = "7765587867:AAHYpUR_XHEZ1YjCKCAgjWaOiepeDY4XtPA" +import os +BOT_TOKEN = os.getenv("TG_BOT_TOKEN", "test:mock_token_for_testing_12345") def _build_init_data( From 211f3840a0193c7f42089fab9580cfaa6fb59672 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 1 Sep 2026 11:04:44 +0300 Subject: [PATCH 27/30] Add logs limiter --- app/main.py | 45 ++++++++++++++++++++++++++------------------- app/tg_auth.py | 2 +- docker-compose.yml | 21 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/app/main.py b/app/main.py index 8e408c0..5cc7c20 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ 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 @@ -17,12 +18,35 @@ 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 +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: + global _rabbit_worker + if _rabbit_worker is None: + _rabbit_worker = RabbitWorker() + return _rabbit_worker + + +@asynccontextmanager +async def lifespan(app: FastAPI): + 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!", @@ -31,6 +55,7 @@ app: FastAPI = FastAPI( 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, @@ -60,15 +85,6 @@ app.add_middleware(SecurityMiddleware, config=config) app.add_middleware(RequestLoggingMiddleware) app.state.guard_decorator = guard_deco app.state._security_middleware = _security_middleware -mongo_worker = MongoWorker() -_rabbit_worker: Optional[RabbitWorker] = None - - -def get_rabbit_worker() -> RabbitWorker: - global _rabbit_worker - if _rabbit_worker is None: - _rabbit_worker = RabbitWorker() - return _rabbit_worker MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production") @@ -83,15 +99,6 @@ async def verify_moderation_secret( ) return x_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") - - @app.get("/check_user", status_code=200) async def check_user( user_id: int, diff --git a/app/tg_auth.py b/app/tg_auth.py index e1034a9..bb025fa 100644 --- a/app/tg_auth.py +++ b/app/tg_auth.py @@ -24,7 +24,7 @@ from logger import logger load_dotenv() -DEV_MODE: bool = os.getenv("DEV_MODE", "true").lower() == "true" +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). diff --git a/docker-compose.yml b/docker-compose.yml index 5032aa0..ea4a80c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,12 @@ services: - "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 @@ -28,6 +34,11 @@ services: - "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 @@ -58,6 +69,11 @@ services: MODERATION_SECRET: ${MODERATION_SECRET} DEV_MODE: ${DEV_MODE:-false} LOG_LEVEL: ${LOG_LEVEL:-INFO} + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "2" ports: - "127.0.0.1:5000:5000" networks: @@ -85,6 +101,11 @@ services: 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 From 07305a2923d8f0cee5ac93b0a791cea674a0eed9 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 1 Sep 2026 11:11:17 +0300 Subject: [PATCH 28/30] Add DEV_MODE to GitHub secrets --- .github/workflows/app-actions.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/app-actions.yml b/.github/workflows/app-actions.yml index f72419b..157f877 100644 --- a/.github/workflows/app-actions.yml +++ b/.github/workflows/app-actions.yml @@ -47,6 +47,7 @@ jobs: RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }} RABBIT_USER: ${{ secrets.RABBIT_USER }} RABBIT_PASS: ${{ secrets.RABBIT_PASS }} + DEV_MODE: ${{ secrets.DEV_MODE || 'true' }} steps: - name: Checkout uses: actions/checkout@v4 From 7647579155dd0c26774c915edeba124651153c75 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 1 Sep 2026 11:24:43 +0300 Subject: [PATCH 29/30] Add client_ip to logs --- app/middleware.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/middleware.py b/app/middleware.py index c82bfaf..764c889 100644 --- a/app/middleware.py +++ b/app/middleware.py @@ -38,10 +38,17 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): 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, } From ab14009afdd6e3080207e2250011adfd05a5869f Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 1 Sep 2026 11:56:15 +0300 Subject: [PATCH 30/30] Add docstrings --- app/main.py | 17 +++++++++++++++++ app/mongo_worker.py | 16 ++++++++++++++++ app/rabbit_worker.py | 9 +++++++++ app/tg_bot.py | 4 ++++ 4 files changed, 46 insertions(+) diff --git a/app/main.py b/app/main.py index 5cc7c20..7dc82c1 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ _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() @@ -38,6 +39,7 @@ def get_rabbit_worker() -> RabbitWorker: @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") @@ -92,6 +94,7 @@ 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, @@ -103,6 +106,7 @@ async def verify_moderation_secret( 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) @@ -111,6 +115,7 @@ 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) @@ -124,6 +129,7 @@ async def add_user( 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") @@ -145,6 +151,7 @@ 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) @@ -158,6 +165,7 @@ async def get_random_cards( 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") @@ -182,6 +190,7 @@ async def add_card( 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") @@ -201,6 +210,7 @@ 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 @@ -211,6 +221,7 @@ 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 @@ -222,6 +233,7 @@ async def select_choice( 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") @@ -250,6 +262,7 @@ async def like_card( 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") @@ -265,6 +278,7 @@ async def dislike_card( 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") @@ -281,6 +295,7 @@ async def comment( 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") @@ -302,6 +317,7 @@ 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 @@ -310,6 +326,7 @@ async def get_comments( 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() diff --git a/app/mongo_worker.py b/app/mongo_worker.py index 12352ae..12f2121 100644 --- a/app/mongo_worker.py +++ b/app/mongo_worker.py @@ -13,7 +13,9 @@ from logger import logger class MongoWorker: + """Worker class for handling all MongoDB database operations.""" def __init__(self): + """Initializes the MongoDB connection and sets up collection references.""" load_dotenv() self.client = motor.motor_asyncio.AsyncIOMotorClient( host=os.getenv('MONGO_HOST'), @@ -46,11 +48,13 @@ class MongoWorker: 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, @@ -64,6 +68,7 @@ class MongoWorker: 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) @@ -80,6 +85,7 @@ class MongoWorker: 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): @@ -88,6 +94,7 @@ class MongoWorker: 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}}, @@ -131,6 +138,7 @@ class MongoWorker: 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) @@ -153,11 +161,13 @@ class MongoWorker: 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, @@ -170,6 +180,7 @@ class MongoWorker: return new_card async def add_card_by_base_model(self, new_card: Card) -> Optional[Card]: + """Inserts a Card model directly into the database.""" new_card.card_id = await self.get_and_update_counter(counter_name="card") try: await self.game_data.insert_one(new_card.model_dump()) @@ -204,6 +215,7 @@ class MongoWorker: 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": @@ -221,6 +233,7 @@ class MongoWorker: 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) @@ -256,6 +269,7 @@ class MongoWorker: 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) @@ -289,6 +303,7 @@ class MongoWorker: 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): @@ -315,6 +330,7 @@ class MongoWorker: 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) diff --git a/app/rabbit_worker.py b/app/rabbit_worker.py index 2c91475..38189b1 100644 --- a/app/rabbit_worker.py +++ b/app/rabbit_worker.py @@ -12,7 +12,9 @@ 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')}" @@ -21,6 +23,7 @@ class RabbitWorker: 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: @@ -40,6 +43,12 @@ class RabbitWorker: 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() diff --git a/app/tg_bot.py b/app/tg_bot.py index 1d8580a..b68505e 100644 --- a/app/tg_bot.py +++ b/app/tg_bot.py @@ -117,6 +117,7 @@ async def call_moderation_api(action: str, card_id: int) -> dict: # ── 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 @@ -138,6 +139,7 @@ async def on_accept(callback: CallbackQuery) -> None: @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 @@ -162,6 +164,7 @@ _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) @@ -171,6 +174,7 @@ async def on_startup() -> None: @dp.shutdown() async def on_shutdown() -> None: + """Cancels the RabbitMQ consumer task when the bot shuts down.""" if _rabbit_task: _rabbit_task.cancel() try: