🐐 Several security fixes

This commit is contained in:
2026-07-29 16:30:14 +02:00
parent b42abe5f5e
commit c56498239f
21 changed files with 660 additions and 204 deletions
+31 -10
View File
@@ -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 {
+6 -6
View File
@@ -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
View File
@@ -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(
+9 -7
View File
@@ -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
View File
@@ -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:
+9 -10
View File
@@ -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:
+17 -5
View File
@@ -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 {
+14 -3
View File
@@ -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
View File
@@ -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: