Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ quinn-proto = "0.11.14"

[dev-dependencies]
tokio-test = "0.4"
actix-web = { version = "4.12.1", features = ["macros"] }
73 changes: 73 additions & 0 deletions backend/src/auth/extractor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use std::future::{ready, Future};
use std::pin::Pin;
use std::sync::Arc;

use actix_web::{
dev::Payload,
error::{ErrorForbidden, ErrorUnauthorized},
web, Error, FromRequest, HttpRequest,
};

use super::jwt_service::{Claims, JwtError, JwtService};

/// Actix-Web `FromRequest` extractor for JWT `Claims`.
///
/// Reads `Authorization: Bearer <token>` from the request, validates the token
/// using `JwtService` registered as `web::Data<Arc<JwtService>>`, and returns
/// the authenticated `Claims`.
///
/// # Usage
///
/// ```rust
/// pub async fn my_handler(claims: Claims) -> impl Responder { ... }
/// ```
///
/// Returns `401 Unauthorized` when the header is missing or the token is
/// invalid, `403 Forbidden` when the token has been revoked.
impl FromRequest for Claims {
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self, Error>>>>;

fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
// 1. Check extensions first — if AuthMiddleware already ran, reuse.
if let Some(claims) = req.extensions().get::<Claims>().cloned() {
return Box::pin(ready(Ok(claims)));
}

// 2. Extract Bearer token from Authorization header.
let token = match req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
Some(t) => t.to_owned(),
None => {
return Box::pin(ready(Err(ErrorUnauthorized(
"Missing or invalid Authorization header",
))))
}
};

// 3. Get JwtService from app data.
let jwt_service = match req.app_data::<web::Data<Arc<JwtService>>>() {
Some(svc) => svc.clone(),
None => {
tracing::error!("JwtService not registered in app_data");
return Box::pin(ready(Err(ErrorUnauthorized("Authentication unavailable"))));
}
};

Box::pin(async move {
match jwt_service.validate_token(&token).await {
Ok(claims) => Ok(claims),
Err(JwtError::TokenExpired) => Err(ErrorUnauthorized("Token expired")),
Err(JwtError::TokenBlacklisted) => Err(ErrorForbidden("Token has been revoked")),
Err(JwtError::SessionNotFound) => {
Err(ErrorUnauthorized("Session expired or invalid"))
}
Err(e) => Err(ErrorUnauthorized(format!("Invalid token: {e}"))),
}
})
}
}
1 change: 1 addition & 0 deletions backend/src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod device_service;
pub mod extractor;
pub mod jwt_service;
pub mod middleware;

Expand Down
79 changes: 79 additions & 0 deletions backend/src/http/matches.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
use actix_web::{web, HttpResponse};
use uuid::Uuid;

use crate::api_error::ApiError;
use crate::auth::Claims;
use crate::db::DbPool;
use crate::models::match_models::{CreateDisputeRequest, ReportScoreRequest};
use crate::service::MatchService;

/// POST /api/matches/{id}/report
/// Submit a score report for a match. Requires valid JWT.
pub async fn report_score(
pool: web::Data<DbPool>,
path: web::Path<Uuid>,
claims: Claims,
body: web::Json<ReportScoreRequest>,
) -> Result<HttpResponse, ApiError> {
let match_id = path.into_inner();
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| ApiError::unauthorized("Invalid user ID in token"))?;

let service = MatchService::new(pool.get_ref().clone());
let score = service.report_score(match_id, user_id, body.into_inner()).await?;

Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": score
})))
}

/// POST /api/matches/{id}/dispute
/// Dispute a match result. Requires valid JWT.
pub async fn dispute_match(
pool: web::Data<DbPool>,
path: web::Path<Uuid>,
claims: Claims,
body: web::Json<CreateDisputeRequest>,
) -> Result<HttpResponse, ApiError> {
let match_id = path.into_inner();
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| ApiError::unauthorized("Invalid user ID in token"))?;

let service = MatchService::new(pool.get_ref().clone());
let dispute = service.create_dispute(match_id, user_id, body.into_inner()).await?;

Ok(HttpResponse::Created().json(serde_json::json!({
"success": true,
"data": dispute
})))
}

/// GET /api/matches/{id}
/// Get match details. Requires valid JWT (user ID used for permission checks).
pub async fn get_match(
pool: web::Data<DbPool>,
path: web::Path<Uuid>,
claims: Claims,
) -> Result<HttpResponse, ApiError> {
let match_id = path.into_inner();
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| ApiError::unauthorized("Invalid user ID in token"))?;

let service = MatchService::new(pool.get_ref().clone());
let match_response = service.get_match(match_id, Some(user_id)).await?;

Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": match_response
})))
}

pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/matches")
.route("/{id}", web::get().to(get_match))
.route("/{id}/report", web::post().to(report_score))
.route("/{id}/dispute", web::post().to(dispute_match)),
);
}
2 changes: 2 additions & 0 deletions backend/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod idempotency_examples;
pub mod achievement_handler;
pub mod leaderboard_handler;
pub mod match_authority_handler;
pub mod matches;
pub mod matchmaking;
#[deprecated(note = "Use realtime::user_ws instead for authenticated WebSocket connections")]
pub mod match_ws_handler;
Expand All @@ -13,4 +14,5 @@ pub mod social_handler;
pub mod staking_handler;
pub mod analytics_handler;
pub mod tournament_handler;
pub mod tournaments;
pub mod wallet;
105 changes: 105 additions & 0 deletions backend/src/http/tournaments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
use actix_web::{web, HttpResponse};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;

use crate::api_error::ApiError;
use crate::auth::Claims;
use crate::models::{JoinTournamentRequest, TournamentStatus};
use crate::service::TournamentService;

#[derive(Deserialize)]
pub struct ListTournamentsQuery {
pub page: Option<i32>,
pub per_page: Option<i32>,
pub status: Option<String>,
pub game: Option<String>,
}

/// GET /api/tournaments
/// List tournaments. Requires valid JWT (user context used for `can_join`).
pub async fn list_tournaments(
svc: web::Data<Arc<TournamentService>>,
claims: Claims,
query: web::Query<ListTournamentsQuery>,
) -> Result<HttpResponse, ApiError> {
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| ApiError::unauthorized("Invalid user ID in token"))?;

let page = query.page.unwrap_or(1).max(1);
let per_page = query.per_page.unwrap_or(20).min(100).max(1);
let status_filter = query.status.as_deref().and_then(parse_tournament_status);
let game_filter = query.game.clone();

let list = svc
.get_tournaments(Some(user_id), page, per_page, status_filter, game_filter)
.await?;

Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": list
})))
}

/// GET /api/tournaments/{id}
/// Get tournament details. Requires valid JWT.
pub async fn get_tournament(
svc: web::Data<Arc<TournamentService>>,
path: web::Path<Uuid>,
claims: Claims,
) -> Result<HttpResponse, ApiError> {
let tournament_id = path.into_inner();
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| ApiError::unauthorized("Invalid user ID in token"))?;

let tournament = svc.get_tournament(tournament_id, Some(user_id)).await?;

Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": tournament
})))
}

/// POST /api/tournaments/{id}/join
/// Join a tournament. Requires valid JWT.
pub async fn join_tournament(
svc: web::Data<Arc<TournamentService>>,
path: web::Path<Uuid>,
claims: Claims,
body: web::Json<JoinTournamentRequest>,
) -> Result<HttpResponse, ApiError> {
let tournament_id = path.into_inner();
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| ApiError::unauthorized("Invalid user ID in token"))?;

let participant = svc
.join_tournament(user_id, tournament_id, body.into_inner())
.await?;

Ok(HttpResponse::Created().json(serde_json::json!({
"success": true,
"data": participant
})))
}

pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/tournaments")
.route("", web::get().to(list_tournaments))
.route("/{id}", web::get().to(get_tournament))
.route("/{id}/join", web::post().to(join_tournament)),
);
}

fn parse_tournament_status(s: &str) -> Option<TournamentStatus> {
match s {
"draft" => Some(TournamentStatus::Draft),
"upcoming" => Some(TournamentStatus::Upcoming),
"registration_open" => Some(TournamentStatus::RegistrationOpen),
"registration_closed" => Some(TournamentStatus::RegistrationClosed),
"in_progress" => Some(TournamentStatus::InProgress),
"completed" => Some(TournamentStatus::Completed),
"cancelled" => Some(TournamentStatus::Cancelled),
_ => None,
}
}
12 changes: 11 additions & 1 deletion backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,21 @@ async fn main() -> io::Result<()> {
.route("/platform", web::get().to(crate::http::analytics_handler::get_platform_metrics))
.route("/player/{user_id}", web::get().to(crate::http::analytics_handler::get_player_insights))
)
// Tournament endpoints
// Tournament endpoints (list, get, join require JWT via Claims extractor)
.service(
web::scope("/tournaments")
.route("", web::get().to(crate::http::tournaments::list_tournaments))
.route("/{id}", web::get().to(crate::http::tournaments::get_tournament))
.route("/{id}/join", web::post().to(crate::http::tournaments::join_tournament))
.route("/{id}/statistics", web::get().to(crate::http::tournament_handler::get_tournament_statistics))
)
// Match endpoints (report, dispute, get require JWT via Claims extractor)
.service(
web::scope("/matches")
.route("/{id}", web::get().to(crate::http::matches::get_match))
.route("/{id}/report", web::post().to(crate::http::matches::report_score))
.route("/{id}/dispute", web::post().to(crate::http::matches::dispute_match))
)
// User endpoints
.service(
web::scope("/users")
Expand Down
Loading