🐐 Several security fixes
This commit is contained in:
+31
-10
@@ -2,16 +2,17 @@ import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.auth import (
|
||||
create_access_token, create_refresh_token,
|
||||
decode_refresh_token, hash_password, verify_password,
|
||||
DUMMY_PASSWORD_HASH, create_access_token, create_refresh_token,
|
||||
decode_refresh_token_payload, hash_password, token_issued_before, verify_password,
|
||||
)
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user, limiter
|
||||
@@ -58,7 +59,9 @@ def validate_register(username: str, email: str, password: str) -> str | None:
|
||||
return "Username must be at least 2 characters"
|
||||
if len(username) > 16:
|
||||
return "Username must be 16 characters or fewer"
|
||||
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
|
||||
# Permissive by design — unicode names are fine. Safety comes from escaping at the
|
||||
# point of use: html.escape in email_utils, and exact comparison instead of ILIKE.
|
||||
if len(email) > 254 or not re.match(r"^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$", email):
|
||||
return "Please enter a valid email"
|
||||
domain = email.split("@")[-1].lower()
|
||||
if domain in _disposable_blocklist:
|
||||
@@ -76,7 +79,9 @@ def register(request: Request, req: RegisterRequest, db: Session = Depends(get_d
|
||||
err = validate_register(req.username, req.email, req.password)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
if db.query(UserModel).filter(UserModel.username.ilike(req.username)).first():
|
||||
# Equality, not ilike: as a LIKE *pattern* an unescaped '%' matches every row and
|
||||
# 'a_c' matches 'abc', so registration could be made to reject valid names.
|
||||
if db.query(UserModel).filter(func.lower(UserModel.username) == req.username.lower()).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
if db.query(UserModel).filter(UserModel.email == req.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
@@ -106,8 +111,12 @@ def register(request: Request, req: RegisterRequest, db: Session = Depends(get_d
|
||||
@router.post("/login")
|
||||
@limiter.limit("10/minute")
|
||||
def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.username.ilike(form.username)).first()
|
||||
if not user or not verify_password(form.password, user.password_hash):
|
||||
user = db.query(UserModel).filter(func.lower(UserModel.username) == form.username.lower()).first()
|
||||
# Verify unconditionally: short-circuiting on a missing user would return before
|
||||
# bcrypt runs, and that timing gap enumerates usernames just as well as an explicit
|
||||
# "no such user" would — which the other handlers here deliberately avoid.
|
||||
password_ok = verify_password(form.password, user.password_hash if user else DUMMY_PASSWORD_HASH)
|
||||
if not user or not password_ok:
|
||||
raise HTTPException(status_code=400, detail="Invalid username or password")
|
||||
user.last_active_at = datetime.now()
|
||||
db.commit()
|
||||
@@ -130,6 +139,7 @@ def reset_password(request: Request, req: ResetPasswordRequest, user: UserModel
|
||||
if req.current_password == req.new_password:
|
||||
raise HTTPException(status_code=400, detail="New password must be different from current password")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
user.token_valid_after = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"message": "Password updated"}
|
||||
|
||||
@@ -166,6 +176,9 @@ def reset_password_with_token(request: Request, req: ResetPasswordWithTokenReque
|
||||
if len(req.new_password) > 256:
|
||||
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
# This is the flow a locked-out or compromised user reaches for, so it must evict
|
||||
# whoever else is holding a token for this account.
|
||||
user.token_valid_after = datetime.now(timezone.utc)
|
||||
user.reset_token = None
|
||||
user.reset_token_expires_at = None
|
||||
db.commit()
|
||||
@@ -209,12 +222,20 @@ def resend_verification(request: Request, req: ResendVerificationRequest, db: Se
|
||||
@router.post("/auth/refresh")
|
||||
@limiter.limit("20/minute")
|
||||
def refresh(request: Request, req: RefreshRequest, db: Session = Depends(get_db)):
|
||||
user_id = decode_refresh_token(req.refresh_token)
|
||||
if not user_id:
|
||||
payload = decode_refresh_token_payload(req.refresh_token)
|
||||
if not payload or not payload.get("sub"):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
try:
|
||||
user_uuid = uuid.UUID(payload["sub"])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
user = db.query(UserModel).filter(UserModel.id == user_uuid).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
# Refresh tokens live 30 days, so this is what actually stops a stolen one from
|
||||
# being replayed into a fresh token after the owner resets their password.
|
||||
if token_issued_before(payload, user.token_valid_after):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
user.last_active_at = datetime.now()
|
||||
db.commit()
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
||||
from game.card import _get_specific_card_async
|
||||
from core.database import get_db
|
||||
from services.database_functions import check_boosters, fill_card_pool, BOOSTER_MAX
|
||||
from core.dependencies import get_current_user, limiter
|
||||
from core.dependencies import escape_like, get_current_user, limiter, parse_uuid
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Deck as DeckModel
|
||||
from core.models import DeckCard as DeckCardModel
|
||||
@@ -43,7 +43,7 @@ def get_cards(
|
||||
q = db.query(CardModel).filter(CardModel.user_id == user.id)
|
||||
|
||||
if search:
|
||||
q = q.filter(CardModel.name.ilike(f"%{search}%"))
|
||||
q = q.filter(CardModel.name.ilike(f"%{escape_like(search)}%", escape="\\"))
|
||||
if rarities:
|
||||
q = q.filter(CardModel.card_rarity.in_(rarities))
|
||||
if types:
|
||||
@@ -149,7 +149,7 @@ async def open_pack(request: Request, user: UserModel = Depends(get_current_user
|
||||
@router.post("/cards/{card_id}/report")
|
||||
def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.id == parse_uuid(card_id, "card_id"),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
@@ -163,7 +163,7 @@ def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: S
|
||||
@limiter.limit("5/hour")
|
||||
async def refresh_card(request: Request, card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.id == parse_uuid(card_id, "card_id"),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
@@ -206,7 +206,7 @@ async def refresh_card(request: Request, card_id: str, user: UserModel = Depends
|
||||
@router.post("/cards/{card_id}/favorite")
|
||||
def toggle_favorite(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.id == parse_uuid(card_id, "card_id"),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
@@ -219,7 +219,7 @@ def toggle_favorite(card_id: str, user: UserModel = Depends(get_current_user), d
|
||||
@router.post("/cards/{card_id}/willing-to-trade")
|
||||
def toggle_willing_to_trade(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.id == parse_uuid(card_id, "card_id"),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
|
||||
+34
-10
@@ -1,13 +1,13 @@
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from game.card import compute_deck_type
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user
|
||||
from core.dependencies import get_current_user, limiter, parse_uuid
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Deck as DeckModel
|
||||
from core.models import DeckCard as DeckCardModel
|
||||
@@ -18,7 +18,10 @@ router = APIRouter()
|
||||
|
||||
class DeckUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=64)
|
||||
card_ids: Optional[List[str]] = None
|
||||
# The real constraint is the 50-cost rule enforced at game start, but cost is
|
||||
# per-card so an unbounded list is still a cheap way to make us do arbitrary
|
||||
# work. Cheapest cards are 1 cost, so 60 can't hide a legal deck.
|
||||
card_ids: Optional[List[str]] = Field(None, max_length=60)
|
||||
|
||||
|
||||
@router.get("/decks")
|
||||
@@ -46,7 +49,8 @@ def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends
|
||||
|
||||
|
||||
@router.post("/decks")
|
||||
def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@limiter.limit("30/minute")
|
||||
def create_deck(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
count = db.query(DeckModel).filter(DeckModel.user_id == user.id).count()
|
||||
deck = DeckModel(id=uuid.uuid4(), user_id=user.id, name=f"Deck #{count + 1}")
|
||||
db.add(deck)
|
||||
@@ -55,16 +59,36 @@ def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depen
|
||||
|
||||
|
||||
@router.patch("/decks/{deck_id}")
|
||||
def update_deck(deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
|
||||
# Generous: the deck editor saves on every change.
|
||||
@limiter.limit("120/minute")
|
||||
def update_deck(request: Request, deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == parse_uuid(deck_id, "deck_id"), DeckModel.user_id == user.id).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
if body.name is not None:
|
||||
deck.name = body.name
|
||||
if body.card_ids is not None:
|
||||
try:
|
||||
parsed_ids = [uuid.UUID(cid) for cid in body.card_ids]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid card IDs")
|
||||
|
||||
# deck_cards is keyed on (deck_id, card_id), so a repeated id would blow up
|
||||
# on flush. dict.fromkeys dedupes while keeping the submitted order.
|
||||
unique_ids = list(dict.fromkeys(parsed_ids))
|
||||
|
||||
# Without this the only constraint is the FK, so any card UUID that exists
|
||||
# is accepted — including another user's. Public profiles hand those out.
|
||||
owned = db.query(CardModel.id).filter(
|
||||
CardModel.id.in_(unique_ids),
|
||||
CardModel.user_id == user.id,
|
||||
).all()
|
||||
if len(owned) != len(unique_ids):
|
||||
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
|
||||
|
||||
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
|
||||
for card_id in body.card_ids:
|
||||
db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id)))
|
||||
for card_id in unique_ids:
|
||||
db.add(DeckCardModel(deck_id=deck.id, card_id=card_id))
|
||||
if deck.times_played > 0:
|
||||
deck.wins = 0
|
||||
deck.losses = 0
|
||||
@@ -75,7 +99,7 @@ def update_deck(deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_cu
|
||||
|
||||
@router.delete("/decks/{deck_id}")
|
||||
def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == parse_uuid(deck_id, "deck_id"), DeckModel.user_id == user.id).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
if deck.times_played > 0:
|
||||
@@ -88,7 +112,7 @@ def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: S
|
||||
|
||||
@router.get("/decks/{deck_id}/cards")
|
||||
def get_deck_cards(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == parse_uuid(deck_id, "deck_id"), DeckModel.user_id == user.id).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
deck_cards = db.query(DeckCardModel).options(
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from services import notification_manager
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user, get_user_id_from_request, limiter
|
||||
from core.dependencies import get_current_user, limiter, parse_uuid
|
||||
from core.models import Friendship as FriendshipModel
|
||||
from core.models import Notification as NotificationModel
|
||||
from core.models import User as UserModel
|
||||
@@ -15,7 +15,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/users/{username}/friend-request")
|
||||
@limiter.limit("10/minute", key_func=get_user_id_from_request)
|
||||
@limiter.limit("10/minute")
|
||||
async def send_friend_request(request: Request, username: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
addressee = db.query(UserModel).filter(UserModel.username == username).first()
|
||||
if not addressee:
|
||||
@@ -52,8 +52,9 @@ async def send_friend_request(request: Request, username: str, user: UserModel =
|
||||
|
||||
|
||||
@router.post("/friendships/{friendship_id}/accept")
|
||||
def accept_friend_request(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
|
||||
@limiter.limit("30/minute")
|
||||
def accept_friend_request(request: Request, friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == parse_uuid(friendship_id, "friendship_id")).first()
|
||||
if not friendship:
|
||||
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||
if friendship.addressee_id != user.id:
|
||||
@@ -66,8 +67,9 @@ def accept_friend_request(friendship_id: str, user: UserModel = Depends(get_curr
|
||||
|
||||
|
||||
@router.post("/friendships/{friendship_id}/decline")
|
||||
def decline_friend_request(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
|
||||
@limiter.limit("30/minute")
|
||||
def decline_friend_request(request: Request, friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == parse_uuid(friendship_id, "friendship_id")).first()
|
||||
if not friendship:
|
||||
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||
if friendship.addressee_id != user.id:
|
||||
@@ -124,7 +126,7 @@ def get_friendship_status(username: str, user: UserModel = Depends(get_current_u
|
||||
|
||||
@router.delete("/friendships/{friendship_id}")
|
||||
def remove_friend(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == parse_uuid(friendship_id, "friendship_id")).first()
|
||||
if not friendship:
|
||||
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||
if friendship.requester_id != user.id and friendship.addressee_id != user.id:
|
||||
|
||||
+52
-23
@@ -8,10 +8,9 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from services import notification_manager
|
||||
from core.auth import decode_access_token
|
||||
from core.database import get_db
|
||||
from services.database_functions import fill_card_pool
|
||||
from core.dependencies import get_current_user, get_user_id_from_request, limiter
|
||||
from core.dependencies import accept_and_authenticate_ws, get_current_user, limiter, parse_uuid
|
||||
from game.manager import (
|
||||
QueueEntry, active_games, connections, create_challenge_game, create_solo_game,
|
||||
handle_action, handle_disconnect, handle_timeout_claim, load_deck_cards,
|
||||
@@ -28,6 +27,23 @@ from routers.notifications import _serialize_notification
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
DECK_MAX_COST = 50
|
||||
|
||||
|
||||
def deck_total_cost(deck_id: uuid.UUID, db: Session) -> int:
|
||||
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck_id).all()]
|
||||
return db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
|
||||
|
||||
|
||||
def deck_cost_error(deck_id: uuid.UUID, db: Session, label: str = "Deck") -> str | None:
|
||||
"""The 50-cost rule. Enforced at game start rather than at deck save, so every path
|
||||
into a game has to check it — the challenge-accept path previously didn't."""
|
||||
total = deck_total_cost(deck_id, db)
|
||||
if total == 0 or total > DECK_MAX_COST:
|
||||
return f"{label} total cost must be between 1 and {DECK_MAX_COST}"
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_challenge(c: GameChallengeModel, current_user_id: uuid.UUID) -> dict:
|
||||
deck = c.challenger_deck
|
||||
return {
|
||||
@@ -47,16 +63,22 @@ def _serialize_challenge(c: GameChallengeModel, current_user_id: uuid.UUID) -> d
|
||||
|
||||
@router.websocket("/ws/queue")
|
||||
async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depends(get_db)):
|
||||
await websocket.accept()
|
||||
|
||||
token = await websocket.receive_text()
|
||||
user_id = decode_access_token(token)
|
||||
user_id = await accept_and_authenticate_ws(websocket)
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
# deck_id is a client-supplied query param. parse_uuid raises HTTPException, which
|
||||
# means nothing on an open socket, so validate here and report it the way this
|
||||
# handler reports everything else.
|
||||
try:
|
||||
parsed_deck_id = uuid.UUID(deck_id)
|
||||
except ValueError:
|
||||
await websocket.send_json({"type": "error", "message": "Invalid deck_id"})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
deck = db.query(DeckModel).filter(
|
||||
DeckModel.id == uuid.UUID(deck_id),
|
||||
DeckModel.id == parsed_deck_id,
|
||||
DeckModel.user_id == uuid.UUID(user_id)
|
||||
).first()
|
||||
|
||||
@@ -65,10 +87,9 @@ async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depen
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
|
||||
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
|
||||
if total_cost == 0 or total_cost > 50:
|
||||
await websocket.send_json({"type": "error", "message": "Deck total cost must be between 1 and 50"})
|
||||
cost_err = deck_cost_error(deck.id, db)
|
||||
if cost_err:
|
||||
await websocket.send_json({"type": "error", "message": cost_err})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
@@ -91,12 +112,8 @@ async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depen
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depends(get_db)):
|
||||
await websocket.accept()
|
||||
|
||||
token = await websocket.receive_text()
|
||||
user_id = decode_access_token(token)
|
||||
user_id = await accept_and_authenticate_ws(websocket)
|
||||
if not user_id:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
if game_id not in active_games:
|
||||
@@ -136,7 +153,7 @@ class AcceptGameChallengeRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/users/{username}/challenge")
|
||||
@limiter.limit("10/minute", key_func=get_user_id_from_request)
|
||||
@limiter.limit("10/minute")
|
||||
async def create_game_challenge(
|
||||
request: Request,
|
||||
username: str,
|
||||
@@ -197,7 +214,9 @@ async def create_game_challenge(
|
||||
|
||||
|
||||
@router.post("/challenges/{challenge_id}/accept")
|
||||
@limiter.limit("20/minute")
|
||||
async def accept_game_challenge(
|
||||
request: Request,
|
||||
challenge_id: str,
|
||||
req: AcceptGameChallengeRequest,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
@@ -239,6 +258,12 @@ async def accept_game_challenge(
|
||||
if not challenger_deck:
|
||||
raise HTTPException(status_code=400, detail="The challenger's deck no longer exists")
|
||||
|
||||
# Both decks, because neither was cost-checked when the challenge was created.
|
||||
for d, label in ((deck, "Your deck"), (challenger_deck, "The challenger's deck")):
|
||||
cost_err = deck_cost_error(d.id, db, label)
|
||||
if cost_err:
|
||||
raise HTTPException(status_code=400, detail=cost_err)
|
||||
|
||||
try:
|
||||
game_id = create_challenge_game(
|
||||
str(challenge.challenger_id), str(challenge.challenger_deck_id),
|
||||
@@ -282,7 +307,9 @@ async def accept_game_challenge(
|
||||
|
||||
|
||||
@router.post("/challenges/{challenge_id}/decline")
|
||||
@limiter.limit("20/minute")
|
||||
async def decline_game_challenge(
|
||||
request: Request,
|
||||
challenge_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -367,21 +394,23 @@ async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_
|
||||
|
||||
|
||||
@router.post("/game/solo")
|
||||
async def start_solo_game(deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Tightest limit in the app: every call reserves 500 pool cards and leaves a game in
|
||||
# memory that nothing reaps, so a loop here drains the shared pool and grows the heap.
|
||||
@limiter.limit("10/hour")
|
||||
async def start_solo_game(request: Request, deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if difficulty < 1 or difficulty > 10:
|
||||
raise HTTPException(status_code=400, detail="Difficulty must be between 1 and 10")
|
||||
|
||||
deck = db.query(DeckModel).filter(
|
||||
DeckModel.id == uuid.UUID(deck_id),
|
||||
DeckModel.id == parse_uuid(deck_id, "deck_id"),
|
||||
DeckModel.user_id == user.id
|
||||
).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
|
||||
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
|
||||
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
|
||||
if total_cost == 0 or total_cost > 50:
|
||||
raise HTTPException(status_code=400, detail="Deck total cost must be between 1 and 50")
|
||||
cost_err = deck_cost_error(deck.id, db)
|
||||
if cost_err:
|
||||
raise HTTPException(status_code=400, detail=cost_err)
|
||||
|
||||
player_cards = load_deck_cards(deck_id, str(user.id), db)
|
||||
if player_cards is None:
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services import notification_manager
|
||||
from core.auth import decode_access_token
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user
|
||||
from core.dependencies import accept_and_authenticate_ws, get_current_user, limiter, parse_uuid
|
||||
from core.models import Notification as NotificationModel
|
||||
from core.models import User as UserModel
|
||||
|
||||
@@ -27,12 +26,8 @@ def _serialize_notification(n: NotificationModel) -> dict:
|
||||
|
||||
@router.websocket("/ws/notifications")
|
||||
async def notifications_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
|
||||
await websocket.accept()
|
||||
|
||||
token = await websocket.receive_text()
|
||||
user_id = decode_access_token(token)
|
||||
user_id = await accept_and_authenticate_ws(websocket)
|
||||
if not user_id:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
@@ -82,13 +77,15 @@ def get_notifications(user: UserModel = Depends(get_current_user), db: Session =
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read")
|
||||
@limiter.limit("120/minute")
|
||||
def mark_notification_read(
|
||||
request: Request,
|
||||
notification_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
n = db.query(NotificationModel).filter(
|
||||
NotificationModel.id == uuid.UUID(notification_id),
|
||||
NotificationModel.id == parse_uuid(notification_id, "notification_id"),
|
||||
NotificationModel.user_id == user.id,
|
||||
).first()
|
||||
if not n:
|
||||
@@ -99,13 +96,15 @@ def mark_notification_read(
|
||||
|
||||
|
||||
@router.delete("/notifications/{notification_id}")
|
||||
@limiter.limit("120/minute")
|
||||
def delete_notification(
|
||||
request: Request,
|
||||
notification_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
n = db.query(NotificationModel).filter(
|
||||
NotificationModel.id == uuid.UUID(notification_id),
|
||||
NotificationModel.id == parse_uuid(notification_id, "notification_id"),
|
||||
NotificationModel.user_id == user.id,
|
||||
).first()
|
||||
if not n:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user
|
||||
from core.dependencies import escape_like, get_current_user, limiter
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Deck as DeckModel
|
||||
from core.models import User as UserModel
|
||||
@@ -96,13 +96,14 @@ def update_profile(req: UpdateProfileRequest, user: UserModel = Depends(get_curr
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def search_users(q: str, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@limiter.limit("60/minute")
|
||||
def search_users(request: Request, q: str, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Require auth to prevent scraping
|
||||
if len(q) < 2:
|
||||
return []
|
||||
results = (
|
||||
db.query(UserModel)
|
||||
.filter(UserModel.username.ilike(f"%{q}%"))
|
||||
.filter(UserModel.username.ilike(f"%{escape_like(q)}%", escape="\\"))
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
@@ -117,8 +118,17 @@ def search_users(q: str, current_user: UserModel = Depends(get_current_user), db
|
||||
]
|
||||
|
||||
|
||||
# Cap on cards returned per section. These lists are unbounded per user, and the
|
||||
# frontend only shows one row until you expand it.
|
||||
PUBLIC_PROFILE_CARD_LIMIT = 60
|
||||
|
||||
|
||||
@router.get("/users/{username}")
|
||||
def get_public_profile(username: str, db: Session = Depends(get_db)):
|
||||
# Auth alone doesn't stop enumeration — one account can still walk every profile.
|
||||
@limiter.limit("60/minute")
|
||||
def get_public_profile(request: Request, username: str, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Require auth to prevent scraping, same as /users?q= above. This returns
|
||||
# strictly more than the search endpoint does, including card ids.
|
||||
user = db.query(UserModel).filter(UserModel.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@@ -127,12 +137,14 @@ def get_public_profile(username: str, db: Session = Depends(get_db)):
|
||||
db.query(CardModel)
|
||||
.filter(CardModel.user_id == user.id, CardModel.is_favorite == True)
|
||||
.order_by(CardModel.received_at.desc())
|
||||
.limit(PUBLIC_PROFILE_CARD_LIMIT)
|
||||
.all()
|
||||
)
|
||||
wtt_cards = (
|
||||
db.query(CardModel)
|
||||
.filter(CardModel.user_id == user.id, CardModel.willing_to_trade == True)
|
||||
.order_by(CardModel.received_at.desc())
|
||||
.limit(PUBLIC_PROFILE_CARD_LIMIT)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -52,7 +52,8 @@ class BuySpecificCardRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/shards/shatter")
|
||||
def shatter_cards(req: ShatterRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@limiter.limit("30/minute")
|
||||
def shatter_cards(request: Request, req: ShatterRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not req.card_ids:
|
||||
raise HTTPException(status_code=400, detail="No cards selected")
|
||||
try:
|
||||
@@ -79,7 +80,9 @@ def shatter_cards(req: ShatterRequest, user: UserModel = Depends(get_current_use
|
||||
|
||||
|
||||
@router.post("/store/stripe/checkout")
|
||||
def create_stripe_checkout(req: StripeCheckoutRequest, user: UserModel = Depends(get_current_user)):
|
||||
# Each call creates a Stripe session, so this is spend against our Stripe account.
|
||||
@limiter.limit("10/minute")
|
||||
def create_stripe_checkout(request: Request, req: StripeCheckoutRequest, user: UserModel = Depends(get_current_user)):
|
||||
package = SHARD_PACKAGES.get(req.package_id)
|
||||
if not package:
|
||||
raise HTTPException(status_code=400, detail="Invalid package")
|
||||
@@ -122,6 +125,13 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
if event["type"] == "checkout.session.completed":
|
||||
data = event["data"]["object"]
|
||||
# checkout.session.completed also fires for asynchronous payment methods, where
|
||||
# payment_status is "unpaid" until the funds actually settle. Sessions are created
|
||||
# card-only today so that can't happen yet, but crediting shards for money that
|
||||
# may never arrive is not something to leave resting on a Stripe dashboard toggle.
|
||||
if data.get("payment_status") != "paid":
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
user_id = data.get("metadata", {}).get("user_id")
|
||||
shards = data.get("metadata", {}).get("shards")
|
||||
if user_id and shards:
|
||||
@@ -177,7 +187,8 @@ async def buy_specific_card(request: Request, req: BuySpecificCardRequest, user:
|
||||
|
||||
|
||||
@router.post("/store/buy")
|
||||
def store_buy(req: StoreBuyRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@limiter.limit("30/minute")
|
||||
def store_buy(request: Request, req: StoreBuyRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
cost = STORE_PACKAGES.get(req.quantity)
|
||||
if cost is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid package")
|
||||
|
||||
+11
-19
@@ -6,9 +6,8 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services import notification_manager
|
||||
from core.auth import decode_access_token
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user, get_user_id_from_request, limiter
|
||||
from core.dependencies import accept_and_authenticate_ws, get_current_user, limiter, parse_uuid
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Notification as NotificationModel
|
||||
from core.models import TradeProposal as TradeProposalModel
|
||||
@@ -64,12 +63,8 @@ def _serialize_proposal(p: TradeProposalModel, current_user_id: uuid.UUID, card_
|
||||
|
||||
@router.websocket("/ws/trade/queue")
|
||||
async def trade_queue_endpoint(websocket: WebSocket, db: Session = Depends(get_db)):
|
||||
await websocket.accept()
|
||||
|
||||
token = await websocket.receive_text()
|
||||
user_id = decode_access_token(token)
|
||||
user_id = await accept_and_authenticate_ws(websocket)
|
||||
if not user_id:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
@@ -99,12 +94,8 @@ async def trade_queue_endpoint(websocket: WebSocket, db: Session = Depends(get_d
|
||||
|
||||
@router.websocket("/ws/trade/{trade_id}")
|
||||
async def trade_endpoint(websocket: WebSocket, trade_id: str, db: Session = Depends(get_db)):
|
||||
await websocket.accept()
|
||||
|
||||
token = await websocket.receive_text()
|
||||
user_id = decode_access_token(token)
|
||||
user_id = await accept_and_authenticate_ws(websocket)
|
||||
if not user_id:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
session = active_trades.get(trade_id)
|
||||
@@ -138,7 +129,7 @@ class CreateTradeProposalRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/trade-proposals")
|
||||
@limiter.limit("10/minute", key_func=get_user_id_from_request)
|
||||
@limiter.limit("10/minute")
|
||||
async def create_trade_proposal(
|
||||
request: Request,
|
||||
req: CreateTradeProposalRequest,
|
||||
@@ -222,10 +213,7 @@ def get_trade_proposal(
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
pid = uuid.UUID(proposal_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid proposal ID")
|
||||
pid = parse_uuid(proposal_id, "proposal_id")
|
||||
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == pid).first()
|
||||
if not proposal:
|
||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||
@@ -241,12 +229,14 @@ def get_trade_proposal(
|
||||
|
||||
|
||||
@router.post("/trade-proposals/{proposal_id}/accept")
|
||||
@limiter.limit("30/minute")
|
||||
async def accept_trade_proposal(
|
||||
request: Request,
|
||||
proposal_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == uuid.UUID(proposal_id)).with_for_update().first()
|
||||
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == parse_uuid(proposal_id, "proposal_id")).with_for_update().first()
|
||||
if not proposal:
|
||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||
if proposal.recipient_id != user.id:
|
||||
@@ -339,12 +329,14 @@ async def accept_trade_proposal(
|
||||
|
||||
|
||||
@router.post("/trade-proposals/{proposal_id}/decline")
|
||||
@limiter.limit("30/minute")
|
||||
async def decline_trade_proposal(
|
||||
request: Request,
|
||||
proposal_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == uuid.UUID(proposal_id)).first()
|
||||
proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == parse_uuid(proposal_id, "proposal_id")).first()
|
||||
if not proposal:
|
||||
raise HTTPException(status_code=404, detail="Proposal not found")
|
||||
if proposal.proposer_id != user.id and proposal.recipient_id != user.id:
|
||||
|
||||
Reference in New Issue
Block a user