- 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>
225 lines
9.6 KiB
Rust
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);
|
|
}
|