922 lines
36 KiB
Rust
922 lines
36 KiB
Rust
// 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 <token>".
|
|
//
|
|
// 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<Mutex<Vec<User>>>,
|
|
sessions: Arc<Mutex<HashMap<String, String>>>, // token -> user name
|
|
writes: Arc<Mutex<Vec<u64>>>,
|
|
logins: Arc<Mutex<Vec<u64>>>,
|
|
netplay: Arc<Mutex<Netplay>>,
|
|
}
|
|
|
|
#[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 <id>.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<String>,
|
|
}
|
|
|
|
#[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>) -> String {
|
|
clean_name_or(n, "Savegame")
|
|
}
|
|
|
|
fn clean_name_or(n: Option<String>, 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<Vec<u64>>, 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<User, Response> {
|
|
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<User, Response> {
|
|
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<String> {
|
|
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<AppState>) -> &'static str {
|
|
if st.users.lock().await.is_empty() {
|
|
"ok setup"
|
|
} else {
|
|
"ok"
|
|
}
|
|
}
|
|
|
|
async fn login(State(st): State<AppState>, Json(c): Json<Credentials>) -> 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<AppState>, 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<AppState>, 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<AppState>, headers: HeaderMap) -> Response {
|
|
match st.auth(&headers).await {
|
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(r) => r,
|
|
}
|
|
}
|
|
|
|
async fn change_password(State(st): State<AppState>, headers: HeaderMap, Json(c): Json<PasswordChange>) -> 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<AppState>, 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::<Vec<_>>()).into_response()
|
|
}
|
|
|
|
async fn create_user(State(st): State<AppState>, headers: HeaderMap, Json(c): Json<Credentials>) -> 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<AppState>, headers: HeaderMap, Path(name): Path<String>) -> 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/<name>/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<AppState>, headers: HeaderMap) -> Response {
|
|
let u = match st.auth(&headers).await {
|
|
Ok(u) => u,
|
|
Err(r) => return r,
|
|
};
|
|
let mut out: Vec<SaveInfo> = 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::<SaveInfo>(&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<AppState>, headers: HeaderMap, Path(id): Path<String>) -> 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<String>, 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<AppState>, headers: HeaderMap, Query(q): Query<NameQuery>, 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<AppState>, headers: HeaderMap, Path(id): Path<String>, Query(q): Query<NameQuery>, 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<AppState>, headers: HeaderMap, Path(id): Path<String>) -> 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/<name>/campaigns/<id>.json + <id>.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<CampaignInfo> {
|
|
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<CampaignInfo> {
|
|
let mut out: Vec<CampaignInfo> = 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::<CampaignInfo>(&b) {
|
|
out.push(info);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out.sort_by(|a, b| b.updated.cmp(&a.updated));
|
|
out
|
|
}
|
|
|
|
async fn list_campaigns(State(st): State<AppState>, 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<AppState>, 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<AppState>, 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::<serde_json::Value>(&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<AppState>, headers: HeaderMap, Path(id): Path<String>) -> 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<String>, 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::<serde_json::Value>(&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<AppState>, headers: HeaderMap, Query(q): Query<NameQuery>, 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<AppState>, headers: HeaderMap, Path(id): Path<String>, Query(q): Query<NameQuery>, 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<AppState>, headers: HeaderMap, Path(id): Path<String>) -> 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<AppState>, headers: HeaderMap, Path(id): Path<String>, Json(p): Json<PublishRequest>) -> 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<AppState>, headers: HeaderMap) -> Response {
|
|
if let Err(r) = st.auth(&headers).await {
|
|
return r;
|
|
}
|
|
let owners: Vec<String> = st.users.lock().await.iter().map(|u| u.name.clone()).collect();
|
|
let mut out: Vec<PublicCampaignInfo> = 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<AppState>, 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 <name> <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<User> = 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<String, String> = 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<String> = 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<User> = tokio::fs::read(dir.join("users.json")).await.ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
|
let sessions: HashMap<String, String> = 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<String, String> =
|
|
[("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<u8>) -> (StatusCode, Vec<u8>) {
|
|
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);
|
|
}
|
|
}
|