// DungeonCraft service: user accounts and savegames stored as files. // // The very first login creates the admin account with the entered name and password. // The admin creates further users; every user can change the password and only sees // the own savegames. Requests (except health and login) need "Authorization: Bearer ". // // GET /api/health -> "ok" (or "ok setup" while there is no user yet) // POST /api/login -> {name, password} -> {token, name, admin} // POST /api/logout // GET /api/me -> {name, admin} // GET /api/auth -> 204 when logged in (nginx auth_request for the standalone file) // POST /api/password -> {old, new} // GET /api/users -> admin: [{name, admin}] // POST /api/users -> admin: {name, password} // DELETE /api/users/{name} -> admin: delete a user and the savegames // GET /api/saves -> own savegames (newest first) // GET /api/saves/{id} -> savegame JSON // POST /api/saves?name=.. -> store a new savegame (body = savegame JSON) // PUT /api/saves/{id}?name= -> overwrite a savegame // DELETE /api/saves/{id} // GET /api/campaigns -> own campaigns [{id, name, updated, published, size}] (newest first) // GET /api/campaigns/{id} -> campaign JSON (own) // POST /api/campaigns?name=.. -> store a new campaign (body = campaign JSON) -> {id, ..} // PUT /api/campaigns/{id}?name=.. -> overwrite an own campaign (keeps the published flag) // DELETE /api/campaigns/{id} // POST /api/campaigns/{id}/publish -> {published: true|false} // GET /api/public/campaigns -> published campaigns of all users [{owner, id, name, updated, size}] // GET /api/public/campaigns/{owner}/{id} -> JSON of a published campaign (404 when not published) // // Limits: 1 MB per savegame, 32 MB per campaign, 500 MB in total, 30 write requests and // 20 login attempts per minute. use argon2::Argon2; use axum::{ body::Bytes, extract::{DefaultBodyLimit, Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{delete, get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, path::PathBuf, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use tokio::sync::Mutex; mod netplay; use netplay::Netplay; const MAX_SAVE: usize = 1024 * 1024; const MAX_CAMPAIGN: usize = 32 * 1024 * 1024; const MAX_TOTAL: u64 = 500 * 1024 * 1024; const WRITES_PER_MINUTE: usize = 30; const LOGINS_PER_MINUTE: usize = 20; #[derive(Clone, Serialize, Deserialize)] struct User { name: String, salt: String, // hex hash: String, // hex, argon2id admin: bool, } #[derive(Clone)] struct AppState { dir: PathBuf, users: Arc>>, sessions: Arc>>, // token -> user name writes: Arc>>, logins: Arc>>, netplay: Arc>, } #[derive(Serialize, Deserialize, Clone)] struct SaveInfo { id: String, name: String, saved_at: u64, size: usize, map_w: u64, map_h: u64, wave: u64, time: f64, } // stored next to a campaign as .meta #[derive(Serialize, Deserialize, Clone)] struct CampaignInfo { id: String, name: String, updated: u64, #[serde(default)] published: bool, #[serde(default)] size: usize, } #[derive(Serialize)] struct PublicCampaignInfo { owner: String, id: String, name: String, updated: u64, size: usize, } #[derive(Deserialize)] struct PublishRequest { published: bool, } #[derive(Deserialize)] struct NameQuery { name: Option, } #[derive(Deserialize)] struct Credentials { name: String, password: String, } #[derive(Deserialize)] struct PasswordChange { old: String, new: String, } fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn now_nanos() -> u128 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) } fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{:02x}", x)).collect() } fn random_hex(n: usize) -> String { let mut b = vec![0u8; n]; getrandom::getrandom(&mut b).expect("random numbers"); hex(&b) } fn hash_password(password: &str, salt: &str) -> String { let mut out = [0u8; 32]; Argon2::default().hash_password_into(password.as_bytes(), salt.as_bytes(), &mut out).expect("argon2"); hex(&out) } // constant-time comparison of two hashes fn same(a: &str, b: &str) -> bool { a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 40 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') } fn valid_user(name: &str) -> bool { !name.is_empty() && name.len() <= 24 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } fn clean_name(n: Option) -> String { clean_name_or(n, "Savegame") } fn clean_name_or(n: Option, fallback: &str) -> String { let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect(); if n.trim().is_empty() { fallback.into() } else { n.trim().to_string() } } fn err(code: StatusCode, msg: &str) -> Response { (code, msg.to_string()).into_response() } async fn limited(list: &Mutex>, max: usize) -> bool { let mut w = list.lock().await; let t = now(); w.retain(|&x| t - x < 60); if w.len() >= max { return true; } w.push(t); false } impl AppState { fn user_dir(&self, name: &str) -> PathBuf { self.dir.join("users").join(name) } fn campaign_dir(&self, name: &str) -> PathBuf { self.user_dir(name).join("campaigns") } async fn store_users(&self) { let users = self.users.lock().await; let _ = tokio::fs::write(self.dir.join("users.json"), serde_json::to_vec_pretty(&*users).unwrap_or_default()).await; } async fn store_sessions(&self) { let s = self.sessions.lock().await; let _ = tokio::fs::write(self.dir.join("sessions.json"), serde_json::to_vec(&*s).unwrap_or_default()).await; } // the user behind the request's token async fn auth(&self, headers: &HeaderMap) -> Result { let token = headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .map(|t| t.trim().to_string()) .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "login needed"))?; let name = self.sessions.lock().await.get(&token).cloned().ok_or_else(|| err(StatusCode::UNAUTHORIZED, "login needed"))?; self.users.lock().await.iter().find(|u| u.name == name).cloned().ok_or_else(|| err(StatusCode::UNAUTHORIZED, "login needed")) } async fn admin(&self, headers: &HeaderMap) -> Result { let u = self.auth(headers).await?; if u.admin { Ok(u) } else { Err(err(StatusCode::FORBIDDEN, "admins only")) } } async fn new_session(&self, name: &str) -> String { let token = random_hex(32); self.sessions.lock().await.insert(token.clone(), name.to_string()); self.store_sessions().await; token } // the user behind a WebSocket query token (login is optional in a match lobby) pub async fn user_by_token(&self, token: &str) -> Option { let token = token.trim(); let name = self.sessions.lock().await.get(token).cloned()?; self.users.lock().await.iter().find(|u| u.name == name).map(|u| u.name.clone()) } } async fn total_size(dir: &PathBuf) -> u64 { // all savegames of all users let mut total = 0; let mut stack = vec![dir.clone()]; while let Some(d) = stack.pop() { if let Ok(mut rd) = tokio::fs::read_dir(&d).await { while let Ok(Some(e)) = rd.next_entry().await { if let Ok(m) = e.metadata().await { if m.is_dir() { stack.push(e.path()); } else { total += m.len(); } } } } } total } async fn health(State(st): State) -> &'static str { if st.users.lock().await.is_empty() { "ok setup" } else { "ok" } } async fn login(State(st): State, Json(c): Json) -> Response { if limited(&st.logins, LOGINS_PER_MINUTE).await { return err(StatusCode::TOO_MANY_REQUESTS, "too many attempts"); } let name = c.name.trim().to_string(); if !valid_user(&name) || c.password.len() < 4 { return err(StatusCode::BAD_REQUEST, "invalid name or password too short"); } let mut users = st.users.lock().await; let user = if users.is_empty() { // first login: this becomes the admin account let salt = random_hex(16); let u = User { name: name.clone(), hash: hash_password(&c.password, &salt), salt, admin: true }; users.push(u.clone()); drop(users); st.store_users().await; migrate_shared_saves(&st, &name).await; u } else { let Some(u) = users.iter().find(|u| u.name == name).cloned() else { return err(StatusCode::UNAUTHORIZED, "wrong name or password"); }; drop(users); if !same(&hash_password(&c.password, &u.salt), &u.hash) { return err(StatusCode::UNAUTHORIZED, "wrong name or password"); } u }; let token = st.new_session(&user.name).await; Json(serde_json::json!({ "token": token, "name": user.name, "admin": user.admin })).into_response() } // savegames of the time before user accounts belong to the admin async fn migrate_shared_saves(st: &AppState, admin: &str) { let target = st.user_dir(admin); let _ = tokio::fs::create_dir_all(&target).await; if let Ok(mut rd) = tokio::fs::read_dir(&st.dir).await { while let Ok(Some(e)) = rd.next_entry().await { let p = e.path(); let ext = p.extension().and_then(|x| x.to_str()).unwrap_or(""); let fname = p.file_name().and_then(|x| x.to_str()).unwrap_or("").to_string(); if (ext == "json" || ext == "meta") && fname != "users.json" && fname != "sessions.json" { let _ = tokio::fs::rename(&p, target.join(&fname)).await; } } } } async fn logout(State(st): State, headers: HeaderMap) -> Response { if let Some(t) = headers.get("authorization").and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")) { st.sessions.lock().await.remove(t.trim()); st.store_sessions().await; } StatusCode::NO_CONTENT.into_response() } async fn me(State(st): State, headers: HeaderMap) -> Response { match st.auth(&headers).await { Ok(u) => Json(serde_json::json!({ "name": u.name, "admin": u.admin })).into_response(), Err(r) => r, } } async fn auth_check(State(st): State, headers: HeaderMap) -> Response { match st.auth(&headers).await { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(r) => r, } } async fn change_password(State(st): State, headers: HeaderMap, Json(c): Json) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !same(&hash_password(&c.old, &u.salt), &u.hash) { return err(StatusCode::FORBIDDEN, "wrong password"); } if c.new.len() < 4 { return err(StatusCode::BAD_REQUEST, "password too short"); } { let mut users = st.users.lock().await; if let Some(x) = users.iter_mut().find(|x| x.name == u.name) { x.salt = random_hex(16); x.hash = hash_password(&c.new, &x.salt); } } st.store_users().await; StatusCode::NO_CONTENT.into_response() } async fn list_users(State(st): State, headers: HeaderMap) -> Response { if let Err(r) = st.admin(&headers).await { return r; } let users = st.users.lock().await; Json(users.iter().map(|u| serde_json::json!({ "name": u.name, "admin": u.admin })).collect::>()).into_response() } async fn create_user(State(st): State, headers: HeaderMap, Json(c): Json) -> Response { if let Err(r) = st.admin(&headers).await { return r; } let name = c.name.trim().to_string(); if !valid_user(&name) || c.password.len() < 4 { return err(StatusCode::BAD_REQUEST, "invalid name or password too short"); } { let mut users = st.users.lock().await; if users.iter().any(|u| u.name == name) { return err(StatusCode::CONFLICT, "user exists"); } let salt = random_hex(16); users.push(User { name: name.clone(), hash: hash_password(&c.password, &salt), salt, admin: false }); } st.store_users().await; StatusCode::CREATED.into_response() } async fn delete_user(State(st): State, headers: HeaderMap, Path(name): Path) -> Response { let admin = match st.admin(&headers).await { Ok(u) => u, Err(r) => return r, }; if name == admin.name || !valid_user(&name) { return err(StatusCode::BAD_REQUEST, "cannot delete this user"); } st.users.lock().await.retain(|u| u.name != name); st.sessions.lock().await.retain(|_, n| *n != name); st.store_users().await; st.store_sessions().await; // savegames and campaigns (users//campaigns) of the user let _ = tokio::fs::remove_dir_all(st.user_dir(&name)).await; StatusCode::NO_CONTENT.into_response() } async fn list(State(st): State, headers: HeaderMap) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; let mut out: Vec = Vec::new(); if let Ok(mut rd) = tokio::fs::read_dir(st.user_dir(&u.name)).await { while let Ok(Some(e)) = rd.next_entry().await { let p = e.path(); if p.extension().and_then(|x| x.to_str()) == Some("meta") { if let Ok(b) = tokio::fs::read(&p).await { if let Ok(info) = serde_json::from_slice::(&b) { out.push(info); } } } } } out.sort_by(|a, b| b.saved_at.cmp(&a.saved_at)); Json(out).into_response() } async fn get_save(State(st): State, headers: HeaderMap, Path(id): Path) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } match tokio::fs::read(st.user_dir(&u.name).join(format!("{}.json", id))).await { Ok(b) => ([("content-type", "application/json")], b).into_response(), Err(_) => err(StatusCode::NOT_FOUND, "not found"), } } async fn store(st: &AppState, user: &User, id: String, name: Option, body: Bytes) -> Response { if body.len() > MAX_SAVE { return err(StatusCode::PAYLOAD_TOO_LARGE, "savegame too large"); } if limited(&st.writes, WRITES_PER_MINUTE).await { return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); } if total_size(&st.dir).await + body.len() as u64 > MAX_TOTAL { return err(StatusCode::INSUFFICIENT_STORAGE, "storage full"); } let v: serde_json::Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(_) => return err(StatusCode::BAD_REQUEST, "not JSON"), }; if v.get("format").and_then(|f| f.as_str()) != Some("dungeoncraft-save") { return err(StatusCode::BAD_REQUEST, "not a DungeonCraft savegame"); } let game = &v["game"]; let info = SaveInfo { id: id.clone(), name: clean_name(name), saved_at: now(), size: body.len(), map_w: game["map"]["w"].as_u64().unwrap_or(64), map_h: game["map"]["h"].as_u64().unwrap_or(64), wave: game["wave"].as_u64().unwrap_or(0), time: game["time"].as_f64().unwrap_or(0.0), }; let dir = st.user_dir(&user.name); let _ = tokio::fs::create_dir_all(&dir).await; let ok = tokio::fs::write(dir.join(format!("{}.json", id)), &body).await.is_ok() && tokio::fs::write(dir.join(format!("{}.meta", id)), serde_json::to_vec(&info).unwrap_or_default()).await.is_ok(); if !ok { return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"); } Json(info).into_response() } async fn create(State(st): State, headers: HeaderMap, Query(q): Query, body: Bytes) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; store(&st, &u, new_id(), q.name, body).await } async fn overwrite(State(st): State, headers: HeaderMap, Path(id): Path, Query(q): Query, body: Bytes) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } store(&st, &u, id, q.name, body).await } async fn delete_save(State(st): State, headers: HeaderMap, Path(id): Path) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } if limited(&st.writes, WRITES_PER_MINUTE).await { return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); } let dir = st.user_dir(&u.name); let _ = tokio::fs::remove_file(dir.join(format!("{}.json", id))).await; let _ = tokio::fs::remove_file(dir.join(format!("{}.meta", id))).await; StatusCode::NO_CONTENT.into_response() } // ---------------- campaigns (users//campaigns/.json + .meta) fn new_id() -> String { format!("{}-{:04x}", now(), (now_nanos() % 0xffff) as u16) } async fn read_campaign_meta(dir: &std::path::Path, id: &str) -> Option { let b = tokio::fs::read(dir.join(format!("{}.meta", id))).await.ok()?; serde_json::from_slice(&b).ok() } async fn campaign_infos(dir: &std::path::Path) -> Vec { let mut out: Vec = Vec::new(); if let Ok(mut rd) = tokio::fs::read_dir(dir).await { while let Ok(Some(e)) = rd.next_entry().await { let p = e.path(); if p.extension().and_then(|x| x.to_str()) == Some("meta") { if let Ok(b) = tokio::fs::read(&p).await { if let Ok(info) = serde_json::from_slice::(&b) { out.push(info); } } } } } out.sort_by(|a, b| b.updated.cmp(&a.updated)); out } async fn list_campaigns(State(st): State, headers: HeaderMap) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; Json(campaign_infos(&st.campaign_dir(&u.name)).await).into_response() } // the player's profile: lifetime statistics and achievements (an opaque JSON object; the client // merges it with what it kept locally) const MAX_PROFILE: usize = 64 * 1024; async fn get_profile(State(st): State, headers: HeaderMap) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; match tokio::fs::read(st.user_dir(&u.name).join("profile.json")).await { Ok(b) => ([("content-type", "application/json")], b).into_response(), Err(_) => ([("content-type", "application/json")], b"{}".to_vec()).into_response(), } } async fn put_profile(State(st): State, headers: HeaderMap, body: Bytes) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if body.len() > MAX_PROFILE { return err(StatusCode::PAYLOAD_TOO_LARGE, "profile too large"); } if limited(&st.writes, WRITES_PER_MINUTE).await { return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); } if !serde_json::from_slice::(&body).map_or(false, |v| v.is_object()) { return err(StatusCode::BAD_REQUEST, "not a JSON object"); } let dir = st.user_dir(&u.name); let _ = tokio::fs::create_dir_all(&dir).await; match tokio::fs::write(dir.join("profile.json"), &body).await { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(_) => err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"), } } async fn get_campaign(State(st): State, headers: HeaderMap, Path(id): Path) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } match tokio::fs::read(st.campaign_dir(&u.name).join(format!("{}.json", id))).await { Ok(b) => ([("content-type", "application/json")], b).into_response(), Err(_) => err(StatusCode::NOT_FOUND, "not found"), } } // overwriting keeps the published flag of the existing campaign (new ones start unpublished) async fn store_campaign(st: &AppState, user: &User, id: String, name: Option, body: Bytes) -> Response { if body.len() > MAX_CAMPAIGN { return err(StatusCode::PAYLOAD_TOO_LARGE, "campaign too large"); } if limited(&st.writes, WRITES_PER_MINUTE).await { return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); } if total_size(&st.dir).await + body.len() as u64 > MAX_TOTAL { return err(StatusCode::INSUFFICIENT_STORAGE, "storage full"); } // the campaign document itself is opaque; it only has to be a JSON object match serde_json::from_slice::(&body) { Ok(v) if v.is_object() => {} _ => return err(StatusCode::BAD_REQUEST, "not JSON"), } let dir = st.campaign_dir(&user.name); let published = read_campaign_meta(&dir, &id).await.map(|m| m.published).unwrap_or(false); let info = CampaignInfo { id: id.clone(), name: clean_name_or(name, "Campaign"), updated: now(), published, size: body.len() }; let _ = tokio::fs::create_dir_all(&dir).await; let ok = tokio::fs::write(dir.join(format!("{}.json", id)), &body).await.is_ok() && tokio::fs::write(dir.join(format!("{}.meta", id)), serde_json::to_vec(&info).unwrap_or_default()).await.is_ok(); if !ok { return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"); } Json(info).into_response() } async fn create_campaign(State(st): State, headers: HeaderMap, Query(q): Query, body: Bytes) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; store_campaign(&st, &u, new_id(), q.name, body).await } async fn overwrite_campaign(State(st): State, headers: HeaderMap, Path(id): Path, Query(q): Query, body: Bytes) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } store_campaign(&st, &u, id, q.name, body).await } async fn delete_campaign(State(st): State, headers: HeaderMap, Path(id): Path) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } if limited(&st.writes, WRITES_PER_MINUTE).await { return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); } let dir = st.campaign_dir(&u.name); let _ = tokio::fs::remove_file(dir.join(format!("{}.json", id))).await; let _ = tokio::fs::remove_file(dir.join(format!("{}.meta", id))).await; StatusCode::NO_CONTENT.into_response() } async fn publish_campaign(State(st): State, headers: HeaderMap, Path(id): Path, Json(p): Json) -> Response { let u = match st.auth(&headers).await { Ok(u) => u, Err(r) => return r, }; if !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } if limited(&st.writes, WRITES_PER_MINUTE).await { return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); } let dir = st.campaign_dir(&u.name); let Some(mut info) = read_campaign_meta(&dir, &id).await else { return err(StatusCode::NOT_FOUND, "not found"); }; info.published = p.published; if tokio::fs::write(dir.join(format!("{}.meta", id)), serde_json::to_vec(&info).unwrap_or_default()).await.is_err() { return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"); } Json(info).into_response() } async fn list_public_campaigns(State(st): State, headers: HeaderMap) -> Response { if let Err(r) = st.auth(&headers).await { return r; } let owners: Vec = st.users.lock().await.iter().map(|u| u.name.clone()).collect(); let mut out: Vec = Vec::new(); for owner in owners { for c in campaign_infos(&st.campaign_dir(&owner)).await { if c.published { out.push(PublicCampaignInfo { owner: owner.clone(), id: c.id, name: c.name, updated: c.updated, size: c.size }); } } } out.sort_by(|a, b| b.updated.cmp(&a.updated)); Json(out).into_response() } async fn get_public_campaign(State(st): State, headers: HeaderMap, Path((owner, id)): Path<(String, String)>) -> Response { if let Err(r) = st.auth(&headers).await { return r; } if !valid_user(&owner) || !valid_id(&id) { return err(StatusCode::BAD_REQUEST, "bad id"); } let dir = st.campaign_dir(&owner); if !read_campaign_meta(&dir, &id).await.is_some_and(|m| m.published) { return err(StatusCode::NOT_FOUND, "not found"); } match tokio::fs::read(dir.join(format!("{}.json", id))).await { Ok(b) => ([("content-type", "application/json")], b).into_response(), Err(_) => err(StatusCode::NOT_FOUND, "not found"), } } // Maintenance: "dungeoncraft-saves set-password " sets a user's password // (stop the service first or restart it afterwards; the user's sessions end). fn set_password_cli(dir: &std::path::Path, name: &str, password: &str) { let path = dir.join("users.json"); let mut users: Vec = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); let Some(u) = users.iter_mut().find(|u| u.name == name) else { eprintln!("no user {}", name); std::process::exit(1); }; u.salt = random_hex(16); u.hash = hash_password(password, &u.salt); std::fs::write(&path, serde_json::to_vec_pretty(&users).unwrap_or_default()).expect("write users.json"); let spath = dir.join("sessions.json"); let mut sessions: HashMap = std::fs::read(&spath).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); sessions.retain(|_, n| n != name); let _ = std::fs::write(&spath, serde_json::to_vec(&sessions).unwrap_or_default()); println!("password of {} set", name); } #[tokio::main] async fn main() { let dir = PathBuf::from(std::env::var("SAVE_DIR").unwrap_or_else(|_| "/data".into())); let args: Vec = std::env::args().collect(); if args.get(1).map(|s| s.as_str()) == Some("set-password") && args.len() == 4 { set_password_cli(&dir, &args[2], &args[3]); return; } tokio::fs::create_dir_all(dir.join("users")).await.expect("data directory"); let users: Vec = tokio::fs::read(dir.join("users.json")).await.ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); let sessions: HashMap = tokio::fs::read(dir.join("sessions.json")).await.ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); let state = AppState { dir, users: Arc::new(Mutex::new(users)), sessions: Arc::new(Mutex::new(sessions)), writes: Arc::new(Mutex::new(Vec::new())), logins: Arc::new(Mutex::new(Vec::new())), netplay: Arc::new(Mutex::new(Netplay::new())), }; let app = router(state); let addr = std::env::var("LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into()); let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind"); println!("DungeonCraft service on {}", addr); axum::serve(listener, app).await.expect("server"); } fn router(state: AppState) -> Router { // campaigns embed map snapshots: their write routes get a larger body limit let campaign_limit = || DefaultBodyLimit::max(MAX_CAMPAIGN + 1024); Router::new() .route("/api/health", get(health)) .route("/api/login", post(login)) .route("/api/logout", post(logout)) .route("/api/me", get(me)) .route("/api/auth", get(auth_check)) .route("/api/password", post(change_password)) .route("/api/users", get(list_users).post(create_user)) .route("/api/users/{name}", delete(delete_user)) .route("/api/netplay/ws", get(netplay::netplay_ws)) .route("/api/profile", get(get_profile).put(put_profile)) .route("/api/saves", get(list).post(create)) .route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save)) .route("/api/campaigns", get(list_campaigns).post(create_campaign).layer(campaign_limit())) .route("/api/campaigns/{id}", get(get_campaign).put(overwrite_campaign).delete(delete_campaign).layer(campaign_limit())) .route("/api/campaigns/{id}/publish", post(publish_campaign)) .route("/api/public/campaigns", get(list_public_campaigns)) .route("/api/public/campaigns/{owner}/{id}", get(get_public_campaign)) .layer(DefaultBodyLimit::max(MAX_SAVE + 1024)) .with_state(state) } #[cfg(test)] mod tests { use super::*; use axum::body::Body; use axum::http::Request; use tower::ServiceExt; fn test_state(tag: &str) -> AppState { let dir = std::env::temp_dir().join(format!("dc-saves-test-{}-{}", tag, random_hex(6))); std::fs::create_dir_all(dir.join("users")).unwrap(); let mk = |name: &str, admin: bool| User { name: name.into(), salt: String::new(), hash: String::new(), admin }; let users = vec![mk("boss", true), mk("alice", false), mk("bob", false)]; let sessions: HashMap = [("tboss", "boss"), ("talice", "alice"), ("tbob", "bob")].iter().map(|(t, n)| (t.to_string(), n.to_string())).collect(); AppState { dir, users: Arc::new(Mutex::new(users)), sessions: Arc::new(Mutex::new(sessions)), writes: Arc::new(Mutex::new(Vec::new())), logins: Arc::new(Mutex::new(Vec::new())), netplay: Arc::new(Mutex::new(Netplay::new())), } } async fn call(st: &AppState, method: &str, uri: &str, token: &str, body: Vec) -> (StatusCode, Vec) { let req = Request::builder() .method(method) .uri(uri) .header("authorization", format!("Bearer {}", token)) .header("content-type", "application/json") .body(Body::from(body)) .unwrap(); let res = router(st.clone()).oneshot(req).await.unwrap(); let status = res.status(); let b = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, b.to_vec()) } fn json(b: &[u8]) -> serde_json::Value { serde_json::from_slice(b).unwrap() } #[tokio::test] async fn campaigns_store_publish_and_share() { let st = test_state("camp"); // a few MB: more than the savegame limit let big = format!("{{\"format\":\"dungeoncraft-campaign\",\"pad\":\"{}\"}}", "x".repeat(3 * 1024 * 1024)); let (s, b) = call(&st, "POST", "/api/campaigns?name=My%20Camp", "talice", big.clone().into_bytes()).await; assert_eq!(s, StatusCode::OK); let id = json(&b)["id"].as_str().unwrap().to_string(); // not JSON / no login assert_eq!(call(&st, "POST", "/api/campaigns", "talice", b"nope".to_vec()).await.0, StatusCode::BAD_REQUEST); assert_eq!(call(&st, "GET", "/api/campaigns", "wrong", vec![]).await.0, StatusCode::UNAUTHORIZED); let (s, b) = call(&st, "GET", "/api/campaigns", "talice", vec![]).await; assert_eq!(s, StatusCode::OK); let l = json(&b); assert_eq!(l[0]["name"], "My Camp"); assert_eq!(l[0]["published"], false); let (s, b) = call(&st, "GET", &format!("/api/campaigns/{}", id), "talice", vec![]).await; assert_eq!(s, StatusCode::OK); assert_eq!(b, big.as_bytes()); // other users do not see it, not even by id assert_eq!(json(&call(&st, "GET", "/api/campaigns", "tbob", vec![]).await.1).as_array().unwrap().len(), 0); assert_eq!(call(&st, "GET", &format!("/api/campaigns/{}", id), "tbob", vec![]).await.0, StatusCode::NOT_FOUND); let public = format!("/api/public/campaigns/alice/{}", id); assert_eq!(call(&st, "GET", &public, "tbob", vec![]).await.0, StatusCode::NOT_FOUND); // publish: listed and readable for everybody logged in let (s, _) = call(&st, "POST", &format!("/api/campaigns/{}/publish", id), "talice", br#"{"published":true}"#.to_vec()).await; assert_eq!(s, StatusCode::OK); let l = json(&call(&st, "GET", "/api/public/campaigns", "tbob", vec![]).await.1); assert_eq!(l.as_array().unwrap().len(), 1); assert_eq!(l[0]["owner"], "alice"); assert_eq!(l[0]["id"], id.as_str()); let (s, b) = call(&st, "GET", &public, "tbob", vec![]).await; assert_eq!(s, StatusCode::OK); assert_eq!(b.len(), big.len()); assert_eq!(call(&st, "GET", "/api/public/campaigns", "", vec![]).await.0, StatusCode::UNAUTHORIZED); // overwrite keeps the published flag and renames let (s, b) = call(&st, "PUT", &format!("/api/campaigns/{}?name=Renamed", id), "talice", b"{\"v\":2}".to_vec()).await; assert_eq!(s, StatusCode::OK); assert_eq!(json(&b)["published"], true); assert_eq!(json(&b)["name"], "Renamed"); // unpublish call(&st, "POST", &format!("/api/campaigns/{}/publish", id), "talice", br#"{"published":false}"#.to_vec()).await; assert_eq!(call(&st, "GET", &public, "tbob", vec![]).await.0, StatusCode::NOT_FOUND); assert_eq!(json(&call(&st, "GET", "/api/public/campaigns", "tbob", vec![]).await.1).as_array().unwrap().len(), 0); // savegames keep their small limit let (s, _) = call(&st, "POST", "/api/saves", "talice", big.into_bytes()).await; assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE); // delete assert_eq!(call(&st, "DELETE", &format!("/api/campaigns/{}", id), "talice", vec![]).await.0, StatusCode::NO_CONTENT); assert_eq!(call(&st, "GET", &format!("/api/campaigns/{}", id), "talice", vec![]).await.0, StatusCode::NOT_FOUND); let _ = std::fs::remove_dir_all(&st.dir); } #[tokio::test] async fn deleting_a_user_deletes_the_campaigns() { let st = test_state("deluser"); let (s, b) = call(&st, "POST", "/api/campaigns?name=C", "tbob", b"{}".to_vec()).await; assert_eq!(s, StatusCode::OK); let id = json(&b)["id"].as_str().unwrap().to_string(); call(&st, "POST", &format!("/api/campaigns/{}/publish", id), "tbob", br#"{"published":true}"#.to_vec()).await; assert!(st.campaign_dir("bob").join(format!("{}.json", id)).exists()); assert_eq!(call(&st, "DELETE", "/api/users/bob", "tboss", vec![]).await.0, StatusCode::NO_CONTENT); assert!(!st.campaign_dir("bob").exists()); assert_eq!(json(&call(&st, "GET", "/api/public/campaigns", "talice", vec![]).await.1).as_array().unwrap().len(), 0); let _ = std::fs::remove_dir_all(&st.dir); } }