User accounts, login before the title screen, own savegames per user, standalone download
- Service: accounts with Argon2 password hashes and session tokens; the first login creates the admin (shared savegames of before move to the admin); the admin creates and deletes users; everybody can change the password; savegames are stored and listed per user - Game: login page before the title (only when the service is reachable), with licence and repository link; account page (password, logout; users for the admin); text fields with Tab/Enter, masked passwords, browser dialog on touch screens; long status messages wrap - Download Standalone: DungeonCraft.html with the game embedded (built in the Docker image, served to logged-in users via nginx auth_request); works from file:// without server and opens downloaded savegames - Without the service (standalone, local files) there is no login, savegame files as before Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8853d84564
commit
c9848c8948
15 files changed
+1232
-83
No files matched your search
Generated
+130
@@ -2,6 +2,18 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -60,17 +72,79 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dungeoncraft-saves"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
"getrandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -109,6 +183,27 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -241,6 +336,17 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -271,6 +377,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -365,6 +477,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
@@ -455,12 +573,24 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
||||
@@ -10,6 +10,8 @@ axum = "0.8"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "sync", "time"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
argon2 = "0.5"
|
||||
getrandom = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
+342
-51
@@ -1,25 +1,38 @@
|
||||
// DungeonCraft savegame service: a small HTTP API that stores savegames as files.
|
||||
// DungeonCraft service: user accounts and savegames stored as files.
|
||||
//
|
||||
// GET /api/health -> "ok"
|
||||
// GET /api/saves -> list of savegames (newest first)
|
||||
// GET /api/saves/{id} -> savegame JSON
|
||||
// POST /api/saves?name=.. -> store a new savegame (body = savegame JSON), returns its info
|
||||
// PUT /api/saves/{id}?name -> overwrite a savegame
|
||||
// DELETE /api/saves/{id} -> delete a savegame
|
||||
// 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>".
|
||||
//
|
||||
// Storage is shared by everybody (no accounts). Limits: 1 MB per savegame, 500 MB in total,
|
||||
// at most 30 write requests per minute.
|
||||
// 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}
|
||||
//
|
||||
// Limits: 1 MB per savegame, 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::StatusCode,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
@@ -29,23 +42,35 @@ use tokio::sync::Mutex;
|
||||
const MAX_SAVE: usize = 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,
|
||||
writes: Arc<Mutex<Vec<u64>>>, // timestamps of recent writes
|
||||
users: Arc<Mutex<Vec<User>>>,
|
||||
sessions: Arc<Mutex<HashMap<String, String>>>, // token -> user name
|
||||
writes: Arc<Mutex<Vec<u64>>>,
|
||||
logins: Arc<Mutex<Vec<u64>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SaveInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
saved_at: u64, // unix seconds
|
||||
saved_at: u64,
|
||||
size: usize,
|
||||
map_w: u64,
|
||||
map_h: u64,
|
||||
wave: u64,
|
||||
time: f64, // play time in seconds
|
||||
time: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -53,14 +78,55 @@ 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 {
|
||||
let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect();
|
||||
if n.trim().is_empty() {
|
||||
@@ -74,36 +140,232 @@ fn err(code: StatusCode, msg: &str) -> Response {
|
||||
(code, msg.to_string()).into_response()
|
||||
}
|
||||
|
||||
async fn rate_ok(st: &AppState) -> bool {
|
||||
let mut w = st.writes.lock().await;
|
||||
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() >= WRITES_PER_MINUTE {
|
||||
return false;
|
||||
if w.len() >= max {
|
||||
return true;
|
||||
}
|
||||
w.push(t);
|
||||
true
|
||||
false
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn user_dir(&self, name: &str) -> PathBuf {
|
||||
self.dir.join("users").join(name)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async fn total_size(dir: &PathBuf) -> u64 {
|
||||
// all savegames of all users
|
||||
let mut total = 0;
|
||||
if let Ok(mut rd) = tokio::fs::read_dir(dir).await {
|
||||
while let Ok(Some(e)) = rd.next_entry().await {
|
||||
if let Ok(m) = e.metadata().await {
|
||||
total += m.len();
|
||||
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() -> &'static str {
|
||||
"ok"
|
||||
async fn health(State(st): State<AppState>) -> &'static str {
|
||||
if st.users.lock().await.is_empty() {
|
||||
"ok setup"
|
||||
} else {
|
||||
"ok"
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(State(st): State<AppState>) -> Response {
|
||||
let mut out: Vec<SaveInfo> = Vec::new();
|
||||
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;
|
||||
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") {
|
||||
@@ -119,27 +381,30 @@ async fn list(State(st): State<AppState>) -> Response {
|
||||
Json(out).into_response()
|
||||
}
|
||||
|
||||
async fn get_save(State(st): State<AppState>, Path(id): Path<String>) -> 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.dir.join(format!("{}.json", id))).await {
|
||||
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, id: String, name: Option<String>, body: Bytes) -> Response {
|
||||
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 !rate_ok(st).await {
|
||||
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");
|
||||
}
|
||||
// must be a DungeonCraft savegame; a few values are kept for the list
|
||||
let v: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return err(StatusCode::BAD_REQUEST, "not JSON"),
|
||||
@@ -158,55 +423,81 @@ async fn store(st: &AppState, id: String, name: Option<String>, body: Bytes) ->
|
||||
wave: game["wave"].as_u64().unwrap_or(0),
|
||||
time: game["time"].as_f64().unwrap_or(0.0),
|
||||
};
|
||||
let save_path = st.dir.join(format!("{}.json", id));
|
||||
let meta_path = st.dir.join(format!("{}.meta", id));
|
||||
if tokio::fs::write(&save_path, &body).await.is_err() || tokio::fs::write(&meta_path, serde_json::to_vec(&info).unwrap_or_default()).await.is_err() {
|
||||
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>, Query(q): Query<NameQuery>, body: Bytes) -> 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,
|
||||
};
|
||||
let id = format!("{}-{:04x}", now(), (now_nanos() % 0xffff) as u16);
|
||||
store(&st, id, q.name, body).await
|
||||
store(&st, &u, id, q.name, body).await
|
||||
}
|
||||
|
||||
async fn overwrite(State(st): State<AppState>, Path(id): Path<String>, Query(q): Query<NameQuery>, body: Bytes) -> Response {
|
||||
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, id, q.name, body).await
|
||||
store(&st, &u, id, q.name, body).await
|
||||
}
|
||||
|
||||
async fn delete(State(st): State<AppState>, Path(id): Path<String>) -> Response {
|
||||
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 !rate_ok(&st).await {
|
||||
if limited(&st.writes, WRITES_PER_MINUTE).await {
|
||||
return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||
}
|
||||
let _ = tokio::fs::remove_file(st.dir.join(format!("{}.json", id))).await;
|
||||
let _ = tokio::fs::remove_file(st.dir.join(format!("{}.meta", id))).await;
|
||||
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()
|
||||
}
|
||||
|
||||
fn now_nanos() -> u128 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let dir = PathBuf::from(std::env::var("SAVE_DIR").unwrap_or_else(|_| "/data".into()));
|
||||
tokio::fs::create_dir_all(&dir).await.expect("save directory");
|
||||
let state = AppState { dir, writes: Arc::new(Mutex::new(Vec::new())) };
|
||||
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())),
|
||||
};
|
||||
let app = 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/saves", get(list).post(create))
|
||||
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete))
|
||||
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
|
||||
.layer(DefaultBodyLimit::max(MAX_SAVE + 1024))
|
||||
.with_state(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 savegame service on {}", addr);
|
||||
println!("DungeonCraft service on {}", addr);
|
||||
axum::serve(listener, app).await.expect("server");
|
||||
}
|
||||
Reference in new issue
Block a user