This commit is contained in:
2026-03-26 00:51:25 +01:00
parent 99db0b3c67
commit ef4496aa5d
31 changed files with 4185 additions and 452 deletions

View File

@@ -33,9 +33,16 @@ from game_manager import (
queue, queue_lock, QueueEntry, try_match, handle_action, connections, active_games,
serialize_state, handle_disconnect, handle_timeout_claim, load_deck_cards, create_solo_game
)
from trade_manager import (
trade_queue, trade_queue_lock, TradeQueueEntry, try_trade_match,
handle_trade_action, active_trades, handle_trade_disconnect,
serialize_trade,
)
from card import compute_deck_type, _get_specific_card_async
from email_utils import send_password_reset_email
from config import CORS_ORIGINS
from email_utils import send_password_reset_email, send_verification_email
from config import CORS_ORIGINS, STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET, FRONTEND_URL
import stripe
stripe.api_key = STRIPE_SECRET_KEY
logger = logging.getLogger("app")
@@ -82,6 +89,11 @@ app.add_middleware(
allow_headers=["*"],
)
try:
from disposable_email_domains import blocklist as _disposable_blocklist
except ImportError:
_disposable_blocklist: set[str] = set()
def validate_register(username: str, email: str, password: str) -> str | None:
if not username.strip():
return "Username is required"
@@ -89,6 +101,9 @@ def validate_register(username: str, email: str, password: str) -> str | None:
return "Username must be 16 characters or fewer"
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
return "Please enter a valid email"
domain = email.split("@")[-1].lower()
if domain in _disposable_blocklist:
return "Disposable email addresses are not allowed"
if len(password) < 8:
return "Password must be at least 8 characters"
if len(password) > 256:
@@ -104,15 +119,23 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)):
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")
verification_token = secrets.token_urlsafe(32)
user = UserModel(
id=uuid.uuid4(),
username=req.username,
email=req.email,
password_hash=hash_password(req.password),
email_verified=False,
email_verification_token=verification_token,
email_verification_token_expires_at=datetime.now() + timedelta(hours=24),
)
db.add(user)
db.commit()
return {"message": "User created"}
try:
send_verification_email(req.email, req.username, verification_token)
except Exception as e:
logger.error(f"Failed to send verification email: {e}")
return {"message": "Account created. Please check your email to verify your account."}
@app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
@@ -126,8 +149,9 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
}
@app.get("/boosters")
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)) -> tuple[int,datetime|None]:
return check_boosters(user, db)
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
count, countdown = check_boosters(user, db)
return {"count": count, "countdown": countdown, "email_verified": user.email_verified}
@app.get("/cards")
def get_cards(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
@@ -142,6 +166,9 @@ def get_cards(user: UserModel = Depends(get_current_user), db: Session = Depends
@app.post("/open_pack")
@limiter.limit("10/minute")
async def open_pack(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if not user.email_verified:
raise HTTPException(status_code=403, detail="You must verify your email before opening packs")
check_boosters(user, db)
if user.boosters == 0:
@@ -322,6 +349,72 @@ async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depend
connections[game_id].pop(user_id, None)
asyncio.create_task(handle_disconnect(game_id, user_id))
@app.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)
if not user_id:
await websocket.close(code=1008)
return
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if not user:
await websocket.close(code=1008)
return
if not user.email_verified:
await websocket.send_json({"type": "error", "message": "You must verify your email before trading."})
await websocket.close(code=1008)
return
entry = TradeQueueEntry(user_id=user_id, username=user.username, websocket=websocket)
async with trade_queue_lock:
trade_queue.append(entry)
await websocket.send_json({"type": "queued"})
await try_trade_match()
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
async with trade_queue_lock:
trade_queue[:] = [e for e in trade_queue if e.user_id != user_id]
@app.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)
if not user_id:
await websocket.close(code=1008)
return
session = active_trades.get(trade_id)
if not session or user_id not in session.offers:
await websocket.close(code=1008)
return
session.connections[user_id] = websocket
await websocket.send_json({
"type": "state",
"state": serialize_trade(session, user_id),
})
try:
while True:
data = await websocket.receive_json()
await handle_trade_action(trade_id, user_id, data, db)
except WebSocketDisconnect:
session.connections.pop(user_id, None)
asyncio.create_task(handle_trade_disconnect(trade_id, user_id))
@app.get("/profile")
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
total_games = user.wins + user.losses
@@ -343,9 +436,11 @@ def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depen
return {
"username": user.username,
"email": user.email,
"email_verified": user.email_verified,
"created_at": user.created_at,
"wins": user.wins,
"losses": user.losses,
"shards": user.shards,
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
"most_played_deck": {
"name": most_played_deck.name,
@@ -360,6 +455,160 @@ def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depen
} if most_played_card else None,
}
class ShatterRequest(BaseModel):
card_ids: list[str]
@app.post("/shards/shatter")
def shatter_cards(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:
parsed_ids = [uuid.UUID(cid) for cid in req.card_ids]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid card IDs")
cards = db.query(CardModel).filter(
CardModel.id.in_(parsed_ids),
CardModel.user_id == user.id,
).all()
if len(cards) != len(parsed_ids):
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
total = sum(c.cost for c in cards)
for card in cards:
db.query(DeckCardModel).filter(DeckCardModel.card_id == card.id).delete()
db.delete(card)
user.shards += total
db.commit()
return {"shards": user.shards, "gained": total}
# Shard packages sold for real money.
# price_oere is in Danish øre (1 DKK = 100 øre). Stripe minimum is 250 øre.
SHARD_PACKAGES = {
"s1": {"base": 100, "bonus": 0, "shards": 100, "price_oere": 1000, "price_label": "10 DKK"},
"s2": {"base": 250, "bonus": 50, "shards": 300, "price_oere": 2500, "price_label": "25 DKK"},
"s3": {"base": 500, "bonus": 200, "shards": 700, "price_oere": 5000, "price_label": "50 DKK"},
"s4": {"base": 1000, "bonus": 600, "shards": 1600, "price_oere": 10000, "price_label": "100 DKK"},
"s5": {"base": 2500, "bonus": 2000, "shards": 4500, "price_oere": 25000, "price_label": "250 DKK"},
"s6": {"base": 5000, "bonus": 5000, "shards": 10000, "price_oere": 50000, "price_label": "500 DKK"},
}
class StripeCheckoutRequest(BaseModel):
package_id: str
@app.post("/store/stripe/checkout")
def create_stripe_checkout(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")
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "dkk",
"product_data": {"name": f"WikiTCG Shards — {package['price_label']}"},
"unit_amount": package["price_oere"],
},
"quantity": 1,
}],
mode="payment",
success_url=f"{FRONTEND_URL}/store?payment=success",
cancel_url=f"{FRONTEND_URL}/store",
metadata={"user_id": str(user.id), "shards": str(package["shards"])},
)
return {"url": session.url}
@app.post("/stripe/webhook")
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
payload = await request.body()
sig = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError: # type: ignore
raise HTTPException(status_code=400, detail="Invalid signature")
if event["type"] == "checkout.session.completed":
data = event["data"]["object"]
user_id = data.get("metadata", {}).get("user_id")
shards = data.get("metadata", {}).get("shards")
if user_id and shards:
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
if user:
user.shards += int(shards)
db.commit()
return {"ok": True}
@app.get("/store/config")
def store_config():
return {
"publishable_key": STRIPE_PUBLISHABLE_KEY,
"shard_packages": SHARD_PACKAGES,
}
STORE_PACKAGES = {
1: 15,
5: 65,
10: 120,
25: 260,
}
class StoreBuyRequest(BaseModel):
quantity: int
class BuySpecificCardRequest(BaseModel):
wiki_title: str
SPECIFIC_CARD_COST = 1000
@app.post("/store/buy-specific-card")
@limiter.limit("10/hour")
async def buy_specific_card(request: Request, req: BuySpecificCardRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if user.shards < SPECIFIC_CARD_COST:
raise HTTPException(status_code=400, detail="Not enough shards")
card = await _get_specific_card_async(req.wiki_title)
if card is None:
raise HTTPException(status_code=404, detail="Could not generate a card for that Wikipedia page")
db_card = CardModel(
name=card.name,
image_link=card.image_link,
card_rarity=card.card_rarity.name,
card_type=card.card_type.name,
text=card.text,
attack=card.attack,
defense=card.defense,
cost=card.cost,
user_id=user.id,
)
db.add(db_card)
user.shards -= SPECIFIC_CARD_COST
db.commit()
db.refresh(db_card)
return {
**{c.name: getattr(db_card, c.name) for c in db_card.__table__.columns},
"card_rarity": db_card.card_rarity,
"card_type": db_card.card_type,
"shards": user.shards,
}
@app.post("/store/buy")
def store_buy(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")
if user.shards < cost:
raise HTTPException(status_code=400, detail="Not enough shards")
user.shards -= cost
user.boosters += req.quantity
db.commit()
return {"shards": user.shards, "boosters": user.boosters}
@app.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(
@@ -382,8 +631,8 @@ async def refresh_card(request: Request, card_id: str, user: UserModel = Depends
if not card:
raise HTTPException(status_code=404, detail="Card not found")
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=48):
remaining = (user.last_refresh_at + timedelta(hours=48)) - datetime.now()
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(hours=2):
remaining = (user.last_refresh_at + timedelta(hours=2)) - datetime.now()
hours = int(remaining.total_seconds() // 3600)
minutes = int((remaining.total_seconds() % 3600) // 60)
raise HTTPException(
@@ -417,7 +666,7 @@ async def refresh_card(request: Request, card_id: str, user: UserModel = Depends
def refresh_status(user: UserModel = Depends(get_current_user)):
if not user.last_refresh_at:
return {"can_refresh": True, "next_refresh_at": None}
next_refresh = user.last_refresh_at + timedelta(hours=48)
next_refresh = user.last_refresh_at + timedelta(hours=2)
can_refresh = datetime.now() >= next_refresh
return {
"can_refresh": can_refresh,
@@ -516,6 +765,35 @@ def reset_password_with_token(req: ResetPasswordWithTokenRequest, db: Session =
db.commit()
return {"message": "Password updated"}
@app.get("/auth/verify-email")
def verify_email(token: str, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email_verification_token == token).first()
if not user or not user.email_verification_token_expires_at or user.email_verification_token_expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Invalid or expired verification link")
user.email_verified = True
user.email_verification_token = None
user.email_verification_token_expires_at = None
db.commit()
return {"message": "Email verified"}
class ResendVerificationRequest(BaseModel):
email: str
@app.post("/auth/resend-verification")
def resend_verification(req: ResendVerificationRequest, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.email == req.email).first()
# Always return success to prevent user enumeration
if user and not user.email_verified:
token = secrets.token_urlsafe(32)
user.email_verification_token = token
user.email_verification_token_expires_at = datetime.now() + timedelta(hours=24)
db.commit()
try:
send_verification_email(user.email, user.username, token)
except Exception as e:
logger.error(f"Failed to resend verification email: {e}")
return {"message": "If that email is registered and unverified, you will receive a new verification link shortly"}
class RefreshRequest(BaseModel):
refresh_token: str