#![feature(try_trait_v2)] use rocket::fs::FileServer; use rocket::futures::lock::Mutex; use backend::auth; use backend::items::{self, Game}; use backend::proto_utils; use backend::store::{self, User, save_state}; #[macro_use] extern crate rocket; #[get("/")] async fn get_user( _token: auth::Token, user_list: &rocket::State>>, name: &str, ) -> Option { let users = user_list.lock().await; users .iter() .find(|user| user.person.name == name) .map(|u| u.person.clone()) } #[get("/")] async fn get_users( _token: auth::Token, user_list: &rocket::State>>, ) -> items::PersonList { let users = user_list.lock().await; items::PersonList { person: users.iter().map(|u| u.person.clone()).collect(), } } #[get("/game/")] async fn get_game( _token: auth::Token, game_list: &rocket::State<Mutex<Vec<Game>>>, title: &str, ) -> Option<items::Game> { let games = game_list.lock().await; games.iter().find(|g| g.title == title).cloned() } #[post("/games/batch", data = "<req>")] async fn get_games_batch( _token: auth::Token, game_list: &rocket::State<Mutex<Vec<Game>>>, req: proto_utils::Proto<items::GetGameInfoRequest>, ) -> items::GameList { let games = game_list.lock().await; let req = req.into_inner(); let mut games = games.clone(); games.retain(|g| req.games.contains(&g.title)); items::GameList { games } } #[get("/games")] async fn get_games( _token: auth::Token, game_list: &rocket::State<Mutex<Vec<Game>>>, ) -> items::GameList { let games = game_list.lock().await; items::GameList { games: games.clone(), } } #[post("/game", data = "<game>")] async fn add_game( _token: auth::Token, game_list: &rocket::State<Mutex<Vec<Game>>>, user_list: &rocket::State<Mutex<Vec<User>>>, game: proto_utils::Proto<items::Game>, ) -> Option<items::Game> { let mut games = game_list.lock().await; let mut game = game.into_inner(); if games.iter().any(|g| { g.title == game.title || (g.remote_id == game.remote_id && g.source == game.source) }) { return None; } game.title = game.title.trim().to_string(); games.push(game.clone()); games.sort_unstable_by(|g1, g2| g1.title.cmp(&g2.title)); let users = user_list.lock().await; save_state(&games, &users); Some(game) } #[post("/opinion", data = "<req>")] async fn add_opinion( token: auth::Token, user_list: &rocket::State<Mutex<Vec<User>>>, game_list: &rocket::State<Mutex<Vec<Game>>>, req: proto_utils::Proto<items::AddOpinionRequest>, ) -> Option<items::Person> { let mut users = user_list.lock().await; let games = game_list.lock().await; let mut result = None; // Validate game exists if !games.iter().any(|g| g.title == req.game_title) { return None; } if let Some(user) = users.iter_mut().find(|u| u.person.name == token.username) { let req = req.into_inner(); let opinion = items::Opinion { title: req.game_title.clone(), would_play: req.would_play, }; if let Some(existing) = user .person .opinion .iter_mut() .find(|o| o.title == req.game_title) { existing.would_play = req.would_play; } else { user.person.opinion.push(opinion); user.person .opinion .sort_unstable_by(|o1, o2| o1.title.cmp(&o2.title)); } result = Some(user.person.clone()); } if result.is_some() { save_state(&games, &users); } result } #[patch("/opinion", data = "<req>")] async fn remove_opinion( token: auth::Token, user_list: &rocket::State<Mutex<Vec<User>>>, game_list: &rocket::State<Mutex<Vec<Game>>>, req: proto_utils::Proto<items::RemoveOpinionRequest>, ) -> Option<items::Person> { let mut users = user_list.lock().await; let games = game_list.lock().await; let mut result = None; if let Some(user) = users.iter_mut().find(|u| u.person.name == token.username) { let req = req.into_inner(); if let Some(existing) = user .person .opinion .iter() .position(|o| o.title == req.game_title) { user.person.opinion.remove(existing); } result = Some(user.person.clone()); } if result.is_some() { save_state(&games, &users); } result } mod cached_option; use cached_option::CachedOption; #[get("/game_thumbnail/<title>")] async fn get_game_thumbnail( title: &str, game_list: &rocket::State<Mutex<Vec<Game>>>, ) -> CachedOption { // 1. Sanitize title for filename let safe_title: String = title .chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); let cache_dir = "cache"; let _ = std::fs::create_dir_all(cache_dir); let cache_path_bin = format!("{}/{}.bin", cache_dir, safe_title); let cache_path_type = format!("{}/{}.type", cache_dir, safe_title); // 2. Check cache if let (Ok(bytes), Ok(type_str)) = ( std::fs::read(&cache_path_bin), std::fs::read_to_string(&cache_path_type), ) && let Some(ct) = rocket::http::ContentType::parse_flexible(&type_str) { return Some((ct, bytes)).into(); } let games = game_list.lock().await; let game = games.iter().find(|g| g.title == title)?; let url = match items::Source::try_from(game.source).ok()? { items::Source::Steam => format!( "https://cdn.cloudflare.steamstatic.com/steam/apps/{}/header.jpg", game.remote_id ), items::Source::Roblox => { let universe_id = { let api_url = format!( "https://apis.roblox.com/universes/v1/places/{}/universe", game.remote_id ); reqwest::get(&api_url) .await .ok()? .json::<serde_json::Value>() .await .ok()? .get("universeId")? .as_u64() .unwrap() }; let api_url = format!( "https://thumbnails.roblox.com/v1/games/icons?universeIds={}&size=512x512&format=Webp&isCircular=false", universe_id ); match reqwest::get(&api_url).await { Ok(resp) => { if let Ok(json) = resp.json::<serde_json::Value>().await { json["data"][0]["imageUrl"].as_str()?.to_string() } else { return None.into(); } } Err(_) => return None.into(), } } }; match reqwest::get(&url).await { Ok(resp) => { let content_type = resp .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .and_then(rocket::http::ContentType::parse_flexible) .unwrap_or(rocket::http::ContentType::Binary); let bytes = resp.bytes().await.ok()?.to_vec(); // 3. Write to cache let _ = std::fs::write(&cache_path_bin, &bytes); let _ = std::fs::write(&cache_path_type, content_type.to_string()); Some((content_type, bytes)).into() } Err(_) => None.into(), } } #[get("/<_..>", rank = 20)] async fn index_fallback() -> Option<rocket::fs::NamedFile> { // Try multiple paths for robustness let paths = ["../frontend/dist/index.html", "frontend/dist/index.html"]; for path in paths { if let Ok(file) = rocket::fs::NamedFile::open(path).await { return Some(file); } } None } #[rocket::main] async fn main() -> Result<(), std::io::Error> { let (game_list, user_list) = store::load_state().unwrap_or_else(|| { let mut game_list: Vec<Game> = Vec::new(); let mut user_list: Vec<User> = Vec::new(); game_list.push(Game { title: "Naramo Nuclear Plant V2".to_string(), source: items::Source::Roblox.into(), min_players: 1, max_players: 90, price: 0, remote_id: 6032337657, // Universe ID for Naramo Nuclear Plant V2 }); game_list.push(Game { title: "Terraria".to_string(), source: items::Source::Steam.into(), min_players: 1, max_players: 8, price: 999, remote_id: 105600, // App ID for Terraria }); user_list.push(User { person: items::Person { name: "John".to_string(), opinion: vec![ items::Opinion { title: "Naramo Nuclear Plant V2".to_string(), would_play: true, }, items::Opinion { title: "Terraria".to_string(), would_play: true, }, ], }, password_hash: bcrypt::hash("password123", bcrypt::DEFAULT_COST).unwrap(), }); (game_list, user_list) }); rocket::build() .manage(Mutex::new(user_list)) .manage(auth::AuthState::new()) .manage(Mutex::new(game_list)) .mount( "/api", routes![ get_users, get_user, get_game, get_games, add_opinion, remove_opinion, add_game, get_game_thumbnail, get_games_batch ], ) .mount( "/auth", routes![auth::login, auth::logout, auth::get_auth_status], ) .mount("/", routes![index_fallback]) .mount("/", FileServer::new("frontend/dist")) .launch() .await .map_err(|e| std::io::Error::other(e.to_string()))?; Ok(()) }