MRPGI/mrpgi-vault/src/main.rs
type-two cd7f658444 Accounts + cloud saves: the front door for the web engine
- mrpgi-vault (standalone crate): tiny signup/login/world-save service.
  Argon2 password hashes, random session cookies, JSON-file storage,
  naive per-IP rate limit, 4MB body cap. tiny_http, no framework, no db.
  Runs as a container on the dealgod network; nginx proxies
  /engine/api/ to it.
- core: WorldBundle — a whole world (manifest + every room) as one JSON
  document, with World::to_bundle/apply_bundle.
- web bridge: mrpgi_request_save / mrpgi_alloc / mrpgi_world_in exports
  + an mrpgi_world_saved JS plugin import; the page's SAVE button pulls
  the live world out of the running engine, LOAD swaps it back in.
  SAVE_REQUESTED is an AtomicBool on purpose — wasm's thread-less
  Mutex<bool> is a plain static to LLVM and release builds const-folded
  the read (the only writer is JS-visible, not IR-visible).
- web UI: SIGN IN tab → create account (own username + password) /
  sign in / Save world / Load world / sign out. Session restores on
  revisit.

Verified live end-to-end through Cloudflare: signup → save → reload →
anonymous rejected; throwaway account removed after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:38:27 +10:00

225 lines
9.6 KiB
Rust

//! 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/<user>.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<String, String> = std::fs::read_to_string(&users_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let mut sessions: HashMap<String, String> = HashMap::new();
// ponytail: naive per-minute rate limit; a real limiter if this ever gets popular
let mut rate: HashMap<String, (u64, u32)> = 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::<Value>(&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<String, (u64, u32)>, 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<String> {
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<Header>) {
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<u8>) {
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);
}