Fix critical bugs

This commit is contained in:
IgorVolochay
2026-08-31 12:49:42 +03:00
parent fd70dc11b5
commit 6eb5e45cce
2 changed files with 83 additions and 28 deletions
+11 -6
View File
@@ -47,7 +47,7 @@ config = SecurityConfig(
detection_compiler_timeout=2.0, detection_compiler_timeout=2.0,
detection_max_content_length=10000, detection_max_content_length=10000,
detection_preserve_attack_patterns=True, detection_preserve_attack_patterns=True,
detection_semantic_threshold=0.7, detection_semantic_threshold=0.7,
detection_anomaly_threshold=3.0, detection_anomaly_threshold=3.0,
detection_slow_pattern_threshold=0.1, detection_slow_pattern_threshold=0.1,
@@ -201,11 +201,17 @@ async def select_choice(
choice_data: SelectChoice, choice_data: SelectChoice,
response: Response, response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
check_visited = await mongo.get_visited_cards(choice_data.user_id) # Verify that the user exists before proceeding.
if check_visited.error: if not await mongo.check_user(choice_data.user_id):
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return check_visited return BaseResponse(result="User doesn't exist", error=True)
if choice_data.card_id in check_visited.result.cards_visited:
# 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 response.status_code = status.HTTP_403_FORBIDDEN
return BaseResponse(result="Card already visited!", error=True) return BaseResponse(result="Card already visited!", error=True)
@@ -214,7 +220,6 @@ async def select_choice(
response.status_code = status.HTTP_404_NOT_FOUND response.status_code = status.HTTP_404_NOT_FOUND
return select_choice_result return select_choice_result
await mongo.update_visited_cards(choice_data.user_id, choice_data.card_id)
return BaseResponse(result="Select choice complete!") return BaseResponse(result="Select choice complete!")
+72 -22
View File
@@ -96,6 +96,39 @@ class MongoWorker:
) )
return Visited.model_validate(updated) 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]: async def get_card(self, card_id: int) -> Optional[Card]:
document = await self.game_data.find_one({"card_id": card_id}) document = await self.game_data.find_one({"card_id": card_id})
@@ -187,33 +220,38 @@ class MongoWorker:
return BaseResponse(result=True, error=False) 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: async def like_card(self, card_id: int, user_id: int) -> BaseResponse:
if not await self.check_user(user_id): if not await self.check_user(user_id):
return BaseResponse(result="User doesn't exist", error=True) return BaseResponse(result="User doesn't exist", error=True)
user_reaction = await self.check_user_reactions(user_id, card_id) # Atomically add card_id to liked_card_ids ONLY IF it is not already
if user_reaction.error: # present in liked_card_ids OR disliked_card_ids.
return user_reaction # 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( updated_card = await self.game_data.find_one_and_update(
{"card_id": card_id}, {"card_id": card_id},
{"$inc": {"count_likes": 1}}, {"$inc": {"count_likes": 1}},
) )
if not updated_card: 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) 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) logger.debug("Card liked: card_id={}, user_id={}", card_id, user_id)
return BaseResponse(result=True, error=False) return BaseResponse(result=True, error=False)
@@ -221,21 +259,31 @@ class MongoWorker:
if not await self.check_user(user_id): if not await self.check_user(user_id):
return BaseResponse(result="User doesn't exist", error=True) return BaseResponse(result="User doesn't exist", error=True)
user_reaction = await self.check_user_reactions(user_id, card_id) # Same atomic test-and-set pattern as like_card.
if user_reaction.error: user_update = await self.users_data.find_one_and_update(
return user_reaction {
"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( updated_card = await self.game_data.find_one_and_update(
{"card_id": card_id}, {"card_id": card_id},
{"$inc": {"count_dislikes": 1}}, {"$inc": {"count_dislikes": 1}},
) )
if not updated_card: 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) 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) logger.debug("Card disliked: card_id={}, user_id={}", card_id, user_id)
return BaseResponse(result=True, error=False) return BaseResponse(result=True, error=False)
@@ -267,6 +315,8 @@ class MongoWorker:
return BaseResponse(result=new_comment) return BaseResponse(result=new_comment)
async def get_comments(self, card_id: int) -> BaseResponse: 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 = 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] comments = [Comment.model_validate(comment) for comment in comments]
return BaseResponse(result=comments) return BaseResponse(result=comments)