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