Compare commits

..

1 Commits

Author SHA1 Message Date
Renovate Bot
8f0fd29f3a Update dependency @types/node to v24.10.7 2026-01-12 12:03:58 +00:00
10 changed files with 277 additions and 877 deletions

809
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,19 +6,17 @@ default-run = "backend"
[dependencies]
prost = "0.14"
prost-types = "0.14"
prost = "0.14.1"
prost-types = "0.14.1"
rocket = { git = "https://github.com/rwf2/Rocket", rev = "504efef179622df82ba1dbd37f2e0d9ed2b7c9e4" }
bytes = "1"
rocket_prost_responder_derive = { path = "rocket_prost_responder_derive" }
uuid = { version = "1.19", features = ["v4"] }
uuid = { version = "1.19.0", features = ["v4"] }
bcrypt = "0.17.1"
bincode = "2.0"
bincode = "2.0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.13", features = ["json"] }
aes-gcm = "0.10"
base64 = "0.22"
reqwest = { version = "0.12.24", features = ["json"] }
[build-dependencies]
prost-build = "0.14"
prost-build = "0.14.1"

View File

@ -1,4 +1,3 @@
use crate::auth_persistence::AuthStorage;
use crate::items;
use crate::proto_utils::Proto;
use rocket::State;
@ -9,16 +8,12 @@ use uuid::Uuid;
pub struct AuthState {
// Map token -> username
tokens: Mutex<HashMap<String, String>>,
storage: AuthStorage,
}
impl AuthState {
pub fn new() -> Self {
let storage = AuthStorage::new();
let tokens = storage.load_tokens();
Self {
tokens: Mutex::new(tokens),
storage,
tokens: Mutex::new(HashMap::new()),
}
}
}
@ -143,8 +138,7 @@ pub async fn login(
{
let token = Uuid::new_v4().to_string();
let mut tokens = state.tokens.lock().await;
tokens.insert(token.clone(), req.username.clone());
state.storage.save_tokens(&tokens);
tokens.insert(token.clone(), req.username);
return items::LoginResponse {
token,
@ -169,7 +163,6 @@ pub async fn logout(
let mut tokens = state.tokens.lock().await;
if tokens.remove(&req.token).is_some() {
state.storage.save_tokens(&tokens);
items::LogoutResponse {
success: true,
message: "Logged out successfully".to_string(),

View File

@ -1,261 +0,0 @@
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use bincode::{config, decode_from_slice, encode_to_vec};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Write};
use std::os::unix::fs::PermissionsExt;
#[derive(Debug, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub struct TokenEntry {
pub token: String,
pub username: String,
pub created_at: u64,
}
const AUTH_KEY_FILE: &str = ".auth_key";
const TOKENS_FILE: &str = "tokens.bin";
const NONCE_SIZE: usize = 12;
const KEY_SIZE: usize = 32;
pub struct AuthStorage {
cipher: Aes256Gcm,
}
impl Default for AuthStorage {
fn default() -> Self {
Self::new()
}
}
impl AuthStorage {
pub fn new() -> Self {
let key = Self::load_or_create_key();
let cipher = Aes256Gcm::new_from_slice(&key).expect("Invalid key length");
Self { cipher }
}
fn load_or_create_key() -> Vec<u8> {
if let Ok(existing_key) = Self::load_key_from_file() {
return existing_key;
}
let key = Self::generate_key();
if let Err(e) = Self::save_key_to_file(&key) {
eprintln!("Warning: Failed to save auth key to file: {}", e);
}
key
}
fn generate_key() -> Vec<u8> {
use std::time::{SystemTime, UNIX_EPOCH};
let mut key = [0u8; KEY_SIZE];
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let mut seed = timestamp as u64;
for byte in key.iter_mut() {
seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
*byte = (seed >> (seed % 8)) as u8;
}
key.to_vec()
}
fn load_key_from_file() -> io::Result<Vec<u8>> {
let file = File::open(AUTH_KEY_FILE)?;
let reader = BufReader::new(file);
let encoded = std::io::read_to_string(reader)?;
let key = STANDARD.decode(encoded).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Base64 decode error: {}", e),
)
})?;
if key.len() != KEY_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Invalid key length",
));
}
Ok(key)
}
fn save_key_to_file(key: &[u8]) -> io::Result<()> {
let encoded = STANDARD.encode(key);
let mut file = File::create(AUTH_KEY_FILE)?;
let mut permissions = file.metadata()?.permissions();
permissions.set_mode(0o600);
file.set_permissions(permissions)?;
file.write_all(encoded.as_bytes())?;
file.sync_all()?;
Ok(())
}
pub fn load_tokens(&self) -> HashMap<String, String> {
let file = match File::open(TOKENS_FILE) {
Ok(f) => f,
Err(_) => {
eprintln!("Warning: No existing tokens file, starting with empty token list");
return HashMap::new();
}
};
let reader = BufReader::new(file);
let encrypted_data = match std::io::read_to_string(reader) {
Ok(data) => data,
Err(_) => {
eprintln!("Warning: Failed to read tokens file, starting with empty token list");
return HashMap::new();
}
};
let decoded = match STANDARD.decode(&encrypted_data) {
Ok(data) => data,
Err(_) => {
eprintln!("Warning: Failed to decode tokens file, starting with empty token list");
return HashMap::new();
}
};
if decoded.len() <= NONCE_SIZE {
eprintln!("Warning: Invalid tokens file format, starting with empty token list");
return HashMap::new();
}
let (nonce_bytes, ciphertext) = decoded.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = match self.cipher.decrypt(nonce, ciphertext.as_ref()) {
Ok(p) => p,
Err(_) => {
eprintln!("Warning: Failed to decrypt tokens file, starting with empty token list");
return HashMap::new();
}
};
let config = config::standard();
let (entries, _): (Vec<TokenEntry>, usize) = match decode_from_slice(&plaintext, config) {
Ok(e) => e,
Err(_) => {
eprintln!("Warning: Failed to deserialize tokens, starting with empty token list");
return HashMap::new();
}
};
entries
.into_iter()
.map(|entry| (entry.token, entry.username))
.collect()
}
pub fn save_tokens(&self, tokens: &HashMap<String, String>) {
use std::time::{SystemTime, UNIX_EPOCH};
let entries: Vec<TokenEntry> = tokens
.iter()
.map(|(token, username)| TokenEntry {
token: token.clone(),
username: username.clone(),
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
})
.collect();
let config = config::standard();
let plaintext: Vec<u8> = match encode_to_vec(&entries, config) {
Ok(data) => data,
Err(e) => {
eprintln!("Warning: Failed to serialize tokens: {}", e);
return;
}
};
let nonce_bytes: [u8; NONCE_SIZE] = rand::thread_rng().generate_bytes();
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = match self.cipher.encrypt(nonce, plaintext.as_ref()) {
Ok(encrypted) => encrypted,
Err(e) => {
eprintln!("Warning: Failed to encrypt tokens: {}", e);
return;
}
};
let mut encrypted_data = nonce_bytes.to_vec();
encrypted_data.extend_from_slice(&ciphertext);
let encoded = STANDARD.encode(&encrypted_data);
match File::create(TOKENS_FILE) {
Ok(file) => {
if let Ok(metadata) = file.metadata() {
let mut permissions = metadata.permissions();
permissions.set_mode(0o600);
if let Err(e) = file.set_permissions(permissions) {
eprintln!("Warning: Failed to set permissions on tokens file: {}", e);
}
}
let mut writer = BufWriter::new(file);
if let Err(e) = writer.write_all(encoded.as_bytes()) {
eprintln!("Warning: Failed to write tokens file: {}", e);
}
if let Err(e) = writer.flush() {
eprintln!("Warning: Failed to flush tokens file: {}", e);
}
}
Err(e) => {
eprintln!("Warning: Failed to create tokens file: {}", e);
}
}
}
}
mod rand {
use std::cell::Cell;
thread_local! {
static STATE: Cell<u64> = Cell::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
);
}
pub struct ThreadRng;
pub fn thread_rng() -> ThreadRng {
ThreadRng
}
impl ThreadRng {
pub fn generate_bytes<const N: usize>(&mut self) -> [u8; N] {
let mut result = [0u8; N];
STATE.with(|state| {
let mut s = state.get();
for byte in result.iter_mut() {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
*byte = (s >> (s % 8)) as u8;
}
state.set(s);
});
result
}
}
}

View File

@ -6,7 +6,6 @@ pub mod items {
}
pub mod auth;
pub mod auth_persistence;
pub mod proto_utils;
pub mod store;

View File

@ -3,7 +3,6 @@ use rocket::fs::FileServer;
use rocket::futures::lock::Mutex;
use backend::auth;
use backend::auth::AdminState;
use backend::items::{self, Game};
use backend::proto_utils;
use backend::store::{self, User, save_state};
@ -112,7 +111,6 @@ async fn update_game(
game: proto_utils::Proto<items::Game>,
) -> Option<items::Game> {
let mut games = game_list.lock().await;
let mut users = user_list.lock().await;
let mut game = game.into_inner();
game.title = game.title.trim().to_string();
@ -127,14 +125,13 @@ async fn update_game(
(g.remote_id == game.remote_id && g.source == game.source) || (g.title == game.title)
}) {
if existing.title != game.title {
let old_title = existing.title.clone();
// Update title for every opinion
for person in users.iter_mut() {
for person in user_list.lock().await.iter_mut() {
let opinion = person
.person
.opinion
.iter_mut()
.find(|o| o.title == old_title);
.find(|o| o.title == existing.title);
if let Some(opinion) = opinion {
opinion.title = game.title.clone();
}
@ -152,6 +149,7 @@ async fn update_game(
games.sort_unstable_by(|g1, g2| g1.title.cmp(&g2.title));
let users = user_list.lock().await;
save_state(&games, &users);
r_existing
@ -191,16 +189,13 @@ async fn refresh_state(
_token: auth::AdminToken,
game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
admin_state: &rocket::State<AdminState>,
) -> items::RefreshResponse {
if let Some((new_games, new_users)) = store::load_state() {
let mut games = game_list.lock().await;
let mut users = user_list.lock().await;
let mut admins = admin_state.admins.lock().await;
*games = new_games;
*users = new_users;
*admins = store::load_admins();
items::RefreshResponse {
success: true,
@ -217,12 +212,12 @@ async fn refresh_state(
#[post("/opinion", data = "<req>")]
async fn add_opinion(
token: auth::Token,
game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
game_list: &rocket::State<Mutex<Vec<Game>>>,
req: proto_utils::Proto<items::AddOpinionRequest>,
) -> Option<items::Person> {
let games = game_list.lock().await;
let mut users = user_list.lock().await;
let games = game_list.lock().await;
let mut result = None;
// Validate game exists
@ -266,12 +261,12 @@ async fn add_opinion(
#[patch("/opinion", data = "<req>")]
async fn remove_opinion(
token: auth::Token,
game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
game_list: &rocket::State<Mutex<Vec<Game>>>,
req: proto_utils::Proto<items::RemoveOpinionRequest>,
) -> Option<items::Person> {
let games = game_list.lock().await;
let mut users = user_list.lock().await;
let games = game_list.lock().await;
let mut result = None;
if let Some(user) = users
@ -345,13 +340,9 @@ async fn get_game_thumbnail(
.json::<serde_json::Value>()
.await
.ok()?
.get("universeId")
.and_then(|v| v.as_u64())
};
let universe_id = match universe_id {
Some(id) => id,
None => return None.into(),
.get("universeId")?
.as_u64()
.unwrap()
};
let api_url = format!(

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { Person, PersonList as PersonListProto } from "../items";
import { Login } from "./Login";
import { PersonList } from "./PersonList";
@ -62,21 +62,10 @@ function App() {
}
}, [theme]);
useEffect(() => {
const handleUnauthorized = () => {
setToken("");
setPeople([]);
addToast("Session expired. Please log in again.", "info");
};
window.addEventListener("unauthorized", handleUnauthorized);
return () => window.removeEventListener("unauthorized", handleUnauthorized);
}, [addToast]);
const addToast = useCallback((message: string, type: ToastType = "info") => {
const addToast = (message: string, type: ToastType = "info") => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
};
const removeToast = (id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));

View File

@ -67,7 +67,7 @@ export function GameDetails({ onShowToast }: Props) {
};
if (loading) return <LoadingState message="Loading game details..." />;
if (error) return <ErrorState message={error} onRetry={() => navigate(0)} />;
if (error) return <ErrorState message={error} onRetry={() => window.location.reload()} />;
if (!game) return <EmptyState icon="🎮" title="Game not found" description="This game doesn't exist or has been deleted" />;
const getExternalLink = () => {

View File

@ -22,7 +22,7 @@ export const apiFetch = async (
if (response.status == 401) {
localStorage.removeItem("token");
localStorage.removeItem("isAdmin");
window.dispatchEvent(new CustomEvent("unauthorized"));
window.location.href = "/";
}
throw new Error(`Request failed with status ${response.status}`);
}

View File

@ -17,12 +17,6 @@ export function useGameFilter(
const [fetchedTitles, setFetchedTitles] = useState<string[]>([]);
const metaDataRef = useRef<{ [key: string]: GameProto }>({});
useEffect(() => {
return () => {
metaDataRef.current = {};
};
}, []);
const { gameToNegative, gameToPositiveOpinion } = useMemo(() => {
const gameToNegative = new Map<string, Set<string>>();
const gameToPositiveOpinion = new Map<string, Set<string>>();
@ -114,7 +108,7 @@ export function useGameFilter(
const gamesMap = useMemo(() => {
return new Map(Object.entries(metaDataRef.current));
}, []);
}, [fetchedTitles]);
return { filteredGames, gameToPositive: gameToPositiveOpinion, games: gamesMap };
}