//! mrpgi-vault — the "front door" for the web engine: signup/login and one //! saved world per user. //! //! POST /api/signup {"user","pass"} create account, start a session //! POST /api/login {"user","pass"} start a session //! POST /api/logout end the session //! GET /api/me {"user":"matt"} or 401 //! GET /api/world the user's saved world JSON (404 if none) //! PUT /api/world save (body = WorldBundle JSON, ≤ 4 MB) //! GET /api/ping health check //! //! Storage is the filesystem: VAULT_DATA/users.json + VAULT_DATA/worlds/.json. //! Sessions are in-memory (a restart signs everyone out — they log back in). //! Single-threaded on purpose: requests are tiny, ordering is trivial, and //! there is nothing to lock. use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::Argon2; use serde_json::{json, Value}; use std::collections::HashMap; use std::io::Read; use std::path::PathBuf; use tiny_http::{Header, Method, Response, Server}; const MAX_BODY: u64 = 4 * 1024 * 1024; const SESSION_COOKIE: &str = "mrpgi_session"; fn main() { let data: PathBuf = std::env::var("VAULT_DATA").unwrap_or_else(|_| "vault-data".into()).into(); std::fs::create_dir_all(data.join("worlds")).expect("can't create data dir"); let addr = std::env::var("VAULT_ADDR").unwrap_or_else(|_| "0.0.0.0:8200".into()); let server = Server::http(&addr).unwrap_or_else(|e| panic!("can't bind {}: {}", addr, e)); eprintln!("mrpgi-vault listening on {} (data: {})", addr, data.display()); let users_path = data.join("users.json"); let mut users: HashMap = std::fs::read_to_string(&users_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let mut sessions: HashMap = HashMap::new(); // ponytail: naive per-minute rate limit; a real limiter if this ever gets popular let mut rate: HashMap = HashMap::new(); for mut req in server.incoming_requests() { let method = req.method().clone(); let path = req.url().split('?').next().unwrap_or("").to_string(); let ip = req.remote_addr().map(|a| a.ip().to_string()).unwrap_or_default(); let session_user = cookie_token(&req).and_then(|t| sessions.get(&t).cloned()); match (method, path.as_str()) { (Method::Get, "/api/ping") => respond(req, 200, json!({"ok": true}), None), (Method::Post, "/api/signup") | (Method::Post, "/api/login") => { if !allow(&mut rate, &ip) { respond(req, 429, json!({"error": "slow down a little"}), None); continue; } let body = read_body(&mut req); let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null); let user = v["user"].as_str().unwrap_or("").trim().to_lowercase(); let pass = v["pass"].as_str().unwrap_or(""); if !valid_username(&user) { respond(req, 400, json!({"error": "username: 2-20 chars, a-z 0-9 _ -"}), None); continue; } if pass.len() < 4 || pass.len() > 100 { respond(req, 400, json!({"error": "password: at least 4 characters"}), None); continue; } let signup = path == "/api/signup"; if signup { if users.contains_key(&user) { respond(req, 409, json!({"error": "that name is taken"}), None); continue; } let salt = SaltString::encode_b64(&random_bytes()).expect("salt"); let hash = Argon2::default().hash_password(pass.as_bytes(), &salt).expect("hash").to_string(); users.insert(user.clone(), hash); let j = serde_json::to_string_pretty(&users).expect("users json"); if let Err(e) = std::fs::write(&users_path, j) { users.remove(&user); eprintln!("users.json write failed: {}", e); respond(req, 500, json!({"error": "storage hiccup, try again"}), None); continue; } } else { let ok = users .get(&user) .and_then(|h| PasswordHash::new(h).ok()) .map(|h| Argon2::default().verify_password(pass.as_bytes(), &h).is_ok()) .unwrap_or(false); if !ok { respond(req, 401, json!({"error": "wrong name or password"}), None); continue; } } let token = hex(&random_bytes()); sessions.insert(token.clone(), user.clone()); respond(req, 200, json!({"user": user}), Some(set_cookie(&token, false))); } (Method::Post, "/api/logout") => { if let Some(t) = cookie_token(&req) { sessions.remove(&t); } respond(req, 200, json!({"ok": true}), Some(set_cookie("", true))); } (Method::Get, "/api/me") => match session_user { Some(u) => respond(req, 200, json!({"user": u}), None), None => respond(req, 401, json!({"error": "not signed in"}), None), }, (Method::Get, "/api/world") => match session_user { Some(u) => match std::fs::read_to_string(data.join("worlds").join(format!("{}.json", u))) { Ok(w) => respond_raw(req, 200, w.into_bytes()), Err(_) => respond(req, 404, json!({"error": "no saved world yet"}), None), }, None => respond(req, 401, json!({"error": "not signed in"}), None), }, (Method::Put, "/api/world") => match session_user { Some(u) => { let body = read_body(&mut req); // must at least parse as JSON — the vault stores worlds, not mystery bytes if serde_json::from_str::(&body).is_err() { respond(req, 400, json!({"error": "not valid JSON"}), None); continue; } match std::fs::write(data.join("worlds").join(format!("{}.json", u)), &body) { Ok(()) => respond(req, 200, json!({"ok": true, "bytes": body.len()}), None), Err(e) => { eprintln!("world write failed for {}: {}", u, e); respond(req, 500, json!({"error": "storage hiccup, try again"}), None) } } } None => respond(req, 401, json!({"error": "not signed in"}), None), }, _ => respond(req, 404, json!({"error": "no such route"}), None), } } } /// ponytail: 20 auth attempts per IP per minute, forgotten on restart. fn allow(rate: &mut HashMap, ip: &str) -> bool { let min = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() / 60) .unwrap_or(0); let count = { let e = rate.entry(ip.to_string()).or_insert((min, 0)); if e.0 != min { *e = (min, 0); } e.1 += 1; e.1 }; if rate.len() > 10_000 { rate.retain(|_, v| v.0 == min); } count <= 20 } fn valid_username(u: &str) -> bool { (2..=20).contains(&u.len()) && u.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') } fn random_bytes() -> [u8; 32] { let mut b = [0u8; 32]; getrandom::getrandom(&mut b).expect("os rng"); b } fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{:02x}", x)).collect() } fn read_body(req: &mut tiny_http::Request) -> String { let mut body = String::new(); let _ = req.as_reader().take(MAX_BODY).read_to_string(&mut body); body } fn cookie_token(req: &tiny_http::Request) -> Option { req.headers() .iter() .find(|h| h.field.equiv("Cookie")) .and_then(|h| { h.value .as_str() .split(';') .map(|c| c.trim()) .find_map(|c| c.strip_prefix(&format!("{}=", SESSION_COOKIE))) .map(|t| t.to_string()) }) } fn set_cookie(token: &str, clear: bool) -> Header { let v = if clear { format!("{}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax", SESSION_COOKIE) } else { // Secure is fine even though the origin hop is http: the browser only // ever sees this through Cloudflare's https. format!("{}={}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax", SESSION_COOKIE, token) }; Header::from_bytes("Set-Cookie", v).expect("cookie header") } fn respond(req: tiny_http::Request, code: u16, v: Value, cookie: Option
) { let mut resp = Response::from_data(v.to_string().into_bytes()).with_status_code(code); resp.add_header(Header::from_bytes("Content-Type", "application/json").expect("header")); if let Some(c) = cookie { resp.add_header(c); } let _ = req.respond(resp); } fn respond_raw(req: tiny_http::Request, code: u16, bytes: Vec) { let mut resp = Response::from_data(bytes).with_status_code(code); resp.add_header(Header::from_bytes("Content-Type", "application/json").expect("header")); let _ = req.respond(resp); }