337 lines
9.6 KiB
Rust
337 lines
9.6 KiB
Rust
#![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("/<name>")]
|
|
async fn get_user(
|
|
_token: auth::Token,
|
|
user_list: &rocket::State<Mutex<Vec<User>>>,
|
|
name: &str,
|
|
) -> Option<items::Person> {
|
|
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<Mutex<Vec<User>>>,
|
|
) -> items::PersonList {
|
|
let users = user_list.lock().await;
|
|
items::PersonList {
|
|
person: users.iter().map(|u| u.person.clone()).collect(),
|
|
}
|
|
}
|
|
|
|
#[get("/game/<title>")]
|
|
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()
|
|
}
|
|
|
|
#[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
|
|
],
|
|
)
|
|
.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(())
|
|
}
|