diff --git a/Dockerfile b/Dockerfile index 2cfeb77..f9e7e6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,18 @@ RUN touch src/main.rs && cargo build --release --target wasm32-unknown-unknown \ && cp target/wasm32-unknown-unknown/release/dungeoncraft.wasm /out/ \ && cp "$CARGO_HOME"/registry/src/*/macroquad-*/js/mq_js_bundle.js /out/ +# --- standalone: the whole game in one HTML file (downloadable for logged-in users) +FROM python:3-alpine AS standalone +WORKDIR /w +COPY www/ www/ +COPY --from=build /out/ www/ +COPY tools/make_standalone.py . +RUN mkdir -p /out/standalone && python3 make_standalone.py www /out/standalone/DungeonCraft.html + # --- serve: nginx FROM nginx:alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY www/ /usr/share/nginx/html/ COPY --from=build /out/ /usr/share/nginx/html/ +COPY --from=standalone /out/standalone/ /usr/share/nginx/html/standalone/ EXPOSE 80 diff --git a/README.md b/README.md index 556c6af..cacdf7c 100644 --- a/README.md +++ b/README.md @@ -88,26 +88,34 @@ The savegame name is entered with the browser's text dialog when the name field ## Savegames -When the game is served together with the savegame service (`server/`, started by `docker compose`), *Save Game* and *Load Game* open a list of savegames stored on the server: save under a new name (type it into the name field), overwrite, load or delete (overwrite and delete ask for a second click). The list is shared by everybody who can reach the game - protect a public installation, for example with a password on the reverse proxy. +When the game is served together with the service (`server/`, started by `docker compose`), it starts with a **login**: -If the service cannot be reached (for example when `www/` is opened as local files or served by a plain web server), the game falls back to savegame files: *Save Game* downloads a `.dcsave` file (JSON), *Load Game* opens the file picker; dropping the file onto the game window works too. Savegame files are also available from the server list (*Download as file* / *Load from file*). +- The very first login creates the **admin account** with the entered name and password - do this right after installing. +- The admin creates further users (title screen -> *Account*); every user can change the password there and log out. +- Every user only sees the **own savegames** on the server: *Save Game* and *Load Game* open the list (save under a new name, overwrite, load, delete). +- *Download Standalone* (title screen) downloads `DungeonCraft.html`: the whole game in one file, without login and without server. Savegame files (*Download as file*) open there as well. + +If the service cannot be reached (for example the standalone file, `www/` opened as local files or a plain web server), there is no login and the game uses savegame files: *Save Game* downloads a `.dcsave` file (JSON), *Load Game* opens the file picker; dropping the file onto the game window works too. The game also autosaves into the browser every two minutes and when the tab is closed; *Continue* on the title screen resumes from there. ### Savegame service -A small Rust service (axum) in `server/` stores savegames as files in the Docker volume `savegames`. nginx forwards `/api/` to it; the service itself is not published. +A small Rust service (axum) in `server/` keeps the user accounts (Argon2 password hashes) and the savegames as files in the Docker volume `savegames` (one folder per user). nginx forwards `/api/` to it; the service itself is not published. The standalone file is served under `/standalone/` only for logged-in users (nginx `auth_request`). | Request | Meaning | |---|---| -| `GET /api/health` | `ok` when the service runs | -| `GET /api/saves` | list (id, name, date, map size, wave, play time), newest first | +| `GET /api/health` | `ok` when the service runs (`ok setup` while there is no account yet) | +| `POST /api/login` | `{name, password}` -> `{token, name, admin}`; the first login creates the admin | +| `POST /api/logout`, `GET /api/me`, `POST /api/password` | session, own account, change password | +| `GET/POST /api/users`, `DELETE /api/users/{name}` | admin: list, create, delete users | +| `GET /api/saves` | own savegames (id, name, date, map size, wave, play time), newest first | | `POST /api/saves?name=...` | store a new savegame (body: savegame JSON) | | `PUT /api/saves/{id}?name=...` | overwrite a savegame | | `GET /api/saves/{id}` | savegame JSON | | `DELETE /api/saves/{id}` | delete a savegame | -Limits: 1 MB per savegame, 500 MB in total, 30 write requests per minute. Settings: `SAVE_DIR` (default `/data`), `LISTEN` (default `0.0.0.0:8080`). +All requests except health and login need `Authorization: Bearer `. Limits: 1 MB per savegame, 500 MB in total, 30 write requests and 20 login attempts per minute. Settings: `SAVE_DIR` (default `/data`), `LISTEN` (default `0.0.0.0:8080`). ## Adding a language diff --git a/lang/de.lang b/lang/de.lang index 6414449..d79694b 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -538,3 +538,36 @@ cost.steel = {0} Gold/Wand, {1} für befestigte Wände hint.steel_where = Stahl lässt sich nur an Erdwände neben deinem Gebiet anbringen. tile.steel = Stahlwand tile.steel_info = Helden brauchen ewig, um hindurchzukommen. + +# accounts and standalone +login.title = Anmelden +login.setup = Admin-Konto anlegen +login.setup_hint = Erster Start des Servers: Dieser Name und dieses Passwort werden das Admin-Konto. +login.name = Name: +login.password = Passwort: +login.button = Anmelden +login.create_admin = Admin-Konto anlegen +login.err_invalid = Name: Buchstaben, Ziffern, - und _ (max. 24); Passwort mindestens 4 Zeichen. +login.err_wrong = Name oder Passwort falsch. +login.err_session = Bitte melde dich neu an. +login.welcome = Angemeldet als {0}. +menu.account = Konto: {0} +menu.standalone = Standalone herunterladen +account.title = Konto von {0} +account.title_admin = Konto von {0} (Admin) +account.change_pw = Passwort ändern +account.old_pw = Altes Passwort: +account.new_pw = Neues Passwort: +account.save_pw = Ändern +account.users = Benutzer +account.user_name = Neuer Benutzer: +account.user_pw = Passwort: +account.create = Benutzer anlegen +account.logout = Abmelden +account.err_old = Das alte Passwort stimmt nicht. +account.err_exists = Diesen Benutzer gibt es schon. +account.pw_changed = Passwort geändert. +account.users_changed = Benutzer aktualisiert. +standalone.running = Die Standalone-Version wird vorbereitet ... +standalone.done = DungeonCraft.html heruntergeladen: In jedem Browser öffnen, kein Server nötig. Deine heruntergeladenen Spielstände lassen sich dort auch laden. +standalone.failed = Die Standalone-Version konnte nicht heruntergeladen werden. diff --git a/lang/en.lang b/lang/en.lang index 39b5676..4183fc1 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -539,3 +539,36 @@ cost.steel = {0} gold/wall, {1} for reinforced walls hint.steel_where = Steel can only be put on earth walls next to your own ground. tile.steel = Steel wall tile.steel_info = Heroes need ages to get through. + +# accounts and standalone +login.title = Login +login.setup = Create the admin account +login.setup_hint = First start of the server: this name and password become the admin account. +login.name = Name: +login.password = Password: +login.button = Log in +login.create_admin = Create admin account +login.err_invalid = Name: letters, digits, - and _ (max. 24); password at least 4 characters. +login.err_wrong = Wrong name or password. +login.err_session = Please log in again. +login.welcome = Logged in as {0}. +menu.account = Account: {0} +menu.standalone = Download Standalone +account.title = Account of {0} +account.title_admin = Account of {0} (admin) +account.change_pw = Change password +account.old_pw = Old password: +account.new_pw = New password: +account.save_pw = Change +account.users = Users +account.user_name = New user: +account.user_pw = Password: +account.create = Create user +account.logout = Log out +account.err_old = The old password is wrong. +account.err_exists = This user already exists. +account.pw_changed = Password changed. +account.users_changed = Users updated. +standalone.running = Preparing the standalone version ... +standalone.done = DungeonCraft.html downloaded: open it in any browser, no server needed. Your downloaded savegames load there too. +standalone.failed = The standalone version could not be downloaded. diff --git a/nginx.conf b/nginx.conf index bd349a0..7de6c98 100644 --- a/nginx.conf +++ b/nginx.conf @@ -12,6 +12,11 @@ server { default_type application/manifest+json; try_files $uri =404; } + # the standalone file only for logged-in users (checked by the service) + location /standalone/ { + auth_request /api/auth; + try_files $uri =404; + } # savegame service (server/). Resolved at request time, so the game still starts without it; # the game then falls back to savegame files. location /api/ { diff --git a/server/Cargo.lock b/server/Cargo.lock index f0eb1ed..ab1e1b5 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -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" diff --git a/server/Cargo.toml b/server/Cargo.toml index d52b67c..303aa7a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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 diff --git a/server/src/main.rs b/server/src/main.rs index a980771..e213903 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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 ". // -// 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>>, // timestamps of recent writes + users: Arc>>, + sessions: Arc>>, // token -> user name + writes: Arc>>, + logins: Arc>>, } #[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, } +#[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 { 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>, 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 { + 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 + } } 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) -> &'static str { + if st.users.lock().await.is_empty() { + "ok setup" + } else { + "ok" + } } -async fn list(State(st): State) -> Response { - let mut out: Vec = Vec::new(); +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; + 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") { @@ -119,27 +381,30 @@ async fn list(State(st): State) -> Response { Json(out).into_response() } -async fn get_save(State(st): State, Path(id): Path) -> 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.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, body: Bytes) -> Response { +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 !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, 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, Query(q): Query, body: Bytes) -> 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, + }; 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, Path(id): Path, Query(q): Query, body: Bytes) -> Response { +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, id, q.name, body).await + store(&st, &u, id, q.name, body).await } -async fn delete(State(st): State, Path(id): Path) -> Response { +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 !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 = 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())), + }; 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"); } diff --git a/src/cloud.rs b/src/cloud.rs index 4983e0b..dc3fed5 100644 --- a/src/cloud.rs +++ b/src/cloud.rs @@ -4,7 +4,7 @@ // `online` becomes false and the game falls back to savegame files and the browser autosave. use crate::platform; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; const API: &str = "api"; // relative, so the game also works below a sub path @@ -37,22 +37,54 @@ enum Req { Save, Load, Delete, + Login, + Me, + Logout, + Password, + Users, + CreateUser, + DeleteUser, } +// the logged-in account +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Session { + pub token: String, + pub name: String, + pub admin: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UserInfo { + pub name: String, + pub admin: bool, +} + +const SESSION_KEY: &str = "dc.session"; + pub enum CloudEvent { None, Saved(String), // name of the stored savegame Loaded(Vec), // savegame data Deleted, Error(&'static str), // language key + LoggedIn, // login accepted (or the stored session is still valid) + LoginNeeded, // the service is there but nobody is logged in + LoginFailed(&'static str), + PasswordChanged, + UsersChanged, } pub struct Cloud { pub online: Online, + pub setup: bool, // no account yet: the first login creates the admin + pub session: Option, pub list: Vec, pub listed: bool, // the list has been fetched at least once + pub users: Vec, pending: Option<(u32, Req)>, save_name: String, + login_name: String, } // percent-encoding for a query parameter @@ -71,6 +103,7 @@ fn encode(s: &str) -> String { fn error_key(status: i32) -> &'static str { match status { 0 => "cloud.err_net", + 401 => "login.err_session", 413 => "cloud.err_large", 429 => "cloud.err_rate", 507 => "cloud.err_full", @@ -83,11 +116,57 @@ impl Cloud { // starts the availability check pub fn new() -> Cloud { let id = platform::http("GET", &format!("{}/health", API), &[]); - Cloud { online: Online::Unknown, list: Vec::new(), listed: false, pending: Some((id, Req::Health)), save_name: String::new() } + let session: Option = platform::storage_get(SESSION_KEY).and_then(|s| serde_json::from_str(&s).ok()); + if let Some(s) = &session { + platform::set_token(&s.token); + } + Cloud { + online: Online::Unknown, + setup: false, + session, + list: Vec::new(), + listed: false, + users: Vec::new(), + pending: Some((id, Req::Health)), + save_name: String::new(), + login_name: String::new(), + } } + // savegames on the server: the service is there and somebody is logged in pub fn available(&self) -> bool { - self.online == Online::Yes + self.online == Online::Yes && self.session.is_some() + } + + pub fn login(&mut self, name: &str, password: &str) -> bool { + self.login_name = name.to_string(); + let body = serde_json::json!({ "name": name, "password": password }).to_string(); + self.start("POST", "login", body.as_bytes(), Req::Login) + } + + pub fn logout(&mut self) { + self.start("POST", "logout", &[], Req::Logout); + self.session = None; + platform::set_token(""); + platform::storage_set(SESSION_KEY, ""); + } + + pub fn change_password(&mut self, old: &str, new: &str) -> bool { + let body = serde_json::json!({ "old": old, "new": new }).to_string(); + self.start("POST", "password", body.as_bytes(), Req::Password) + } + + pub fn load_users(&mut self) -> bool { + self.start("GET", "users", &[], Req::Users) + } + + pub fn create_user(&mut self, name: &str, password: &str) -> bool { + let body = serde_json::json!({ "name": name, "password": password }).to_string(); + self.start("POST", "users", body.as_bytes(), Req::CreateUser) + } + + pub fn delete_user(&mut self, name: &str) -> bool { + self.start("DELETE", &format!("users/{}", encode(name)), &[], Req::DeleteUser) } pub fn busy(&self) -> bool { @@ -132,7 +211,86 @@ impl Cloud { match req { Req::Health => { self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No }; - CloudEvent::None + self.setup = res.body.ends_with(b"setup"); + if self.online != Online::Yes { + return CloudEvent::None; + } + // a stored session: still valid? + if self.session.is_some() && !self.setup { + self.start("GET", "me", &[], Req::Me); + return CloudEvent::None; + } + self.session = None; + CloudEvent::LoginNeeded + } + Req::Me => { + if ok { + CloudEvent::LoggedIn + } else if res.status == 0 { + self.online = Online::No; + CloudEvent::None + } else { + self.session = None; + platform::set_token(""); + platform::storage_set(SESSION_KEY, ""); + CloudEvent::LoginNeeded + } + } + Req::Login => { + #[derive(Deserialize)] + struct Answer { + token: String, + name: String, + admin: bool, + } + match (ok, serde_json::from_slice::(&res.body)) { + (true, Ok(a)) => { + let s = Session { token: a.token, name: a.name, admin: a.admin }; + platform::set_token(&s.token); + platform::storage_set(SESSION_KEY, &serde_json::to_string(&s).unwrap_or_default()); + self.session = Some(s); + self.setup = false; + CloudEvent::LoggedIn + } + _ => CloudEvent::LoginFailed(match res.status { + 0 => "cloud.err_net", + 400 => "login.err_invalid", + 429 => "cloud.err_rate", + _ => "login.err_wrong", + }), + } + } + Req::Logout => CloudEvent::None, + Req::Password => { + if ok { + CloudEvent::PasswordChanged + } else { + CloudEvent::Error(match res.status { + 403 => "account.err_old", + 400 => "login.err_invalid", + _ => error_key(res.status), + }) + } + } + Req::Users => { + if ok { + self.users = serde_json::from_slice(&res.body).unwrap_or_default(); + CloudEvent::None + } else { + CloudEvent::Error(error_key(res.status)) + } + } + Req::CreateUser | Req::DeleteUser => { + if ok { + self.load_users(); + CloudEvent::UsersChanged + } else { + CloudEvent::Error(match res.status { + 409 => "account.err_exists", + 400 => "login.err_invalid", + _ => error_key(res.status), + }) + } } Req::List => { if !ok { diff --git a/src/main.rs b/src/main.rs index c6bdb46..dda8dca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -153,6 +153,61 @@ fn store_progress(p: &campaign::Progress) { platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default()); } +// login, account and standalone actions of the menu +fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) { + match a { + MenuAction::Login(name, pw) => { + if name.is_empty() || pw.is_empty() { + menu.error = Some(i18n::tr("login.err_invalid")); + } else if cloud.login(&name, &pw) { + menu.error = Some(i18n::tr("saves.wait")); + } + } + MenuAction::Logout => { + cloud.logout(); + menu.open_login(); + menu.error = None; + } + MenuAction::OpenAccount => { + if cloud.session.as_ref().map_or(false, |s| s.admin) { + cloud.load_users(); + } + } + MenuAction::ChangePassword(old, new) => { + if new.chars().count() < 4 { + menu.error = Some(i18n::tr("login.err_invalid")); + } else { + cloud.change_password(&old, &new); + } + } + MenuAction::CreateUser(name, pw) => { + if name.is_empty() || pw.chars().count() < 4 { + menu.error = Some(i18n::tr("login.err_invalid")); + } else { + cloud.create_user(&name, &pw); + } + } + MenuAction::DeleteUser(name) => { + cloud.delete_user(&name); + } + MenuAction::DownloadStandalone => { + platform::fetch_download("standalone/DungeonCraft.html", "DungeonCraft.html"); + app.set_status(i18n::tr("standalone.running"), now); + } + MenuAction::EditField(i) => { + // touch screens have no keyboard in the canvas: the browser asks + if ui::touch_ui() { + let secret = matches!((menu.page, i), (Some(Page::Login), 1) | (Some(Page::Account), 0 | 1 | 3)); + let title = i18n::tr(if secret { "login.password" } else { "login.name" }); + if let Some(t) = platform::prompt(&title, if secret { "" } else { &menu.fields[i] }) { + menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(40).collect(); + } + } + } + _ => {} + } +} + fn next_seed(seed: u32) -> u32 { seed.wrapping_mul(1103515245).wrapping_add(12345) | 1 } @@ -378,21 +433,77 @@ async fn main() { CloudEvent::Saved(name) => app.set_status(i18n::trf("save.saved", &[name]), now), CloudEvent::Loaded(bytes) => app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now), CloudEvent::Deleted => app.set_status(i18n::tr("cloud.deleted"), now), - CloudEvent::Error(key) => app.set_status(i18n::tr(key), now), + CloudEvent::Error(key) => { + if matches!(menu.page, Some(Page::Account)) { + menu.error = Some(i18n::tr(key)); + } else { + app.set_status(i18n::tr(key), now); + } + } + // accounts: the login page comes before the title screen + CloudEvent::LoginNeeded => { + if matches!(menu.page, Some(Page::Title) | None) { + menu.open_login(); + menu.error = None; + } + } + CloudEvent::LoggedIn => { + if menu.page == Some(Page::Login) { + menu.page = Some(Page::Title); + } + menu.error = None; + if let Some(s) = &cloud.session { + app.set_status(i18n::trf("login.welcome", &[s.name.clone()]), now); + } + } + CloudEvent::LoginFailed(key) => menu.error = Some(i18n::tr(key)), + CloudEvent::PasswordChanged => { + menu.fields[0].clear(); + menu.fields[1].clear(); + menu.error = None; + app.set_status(i18n::tr("account.pw_changed"), now); + } + CloudEvent::UsersChanged => { + menu.fields[2].clear(); + menu.fields[3].clear(); + menu.error = None; + app.set_status(i18n::tr("account.users_changed"), now); + } + } + // download of the standalone file + match platform::fetch_download_state() { + 2 => app.set_status(i18n::tr("standalone.done"), now), + 3 => app.set_status(i18n::tr("standalone.failed"), now), + _ => {} } - // ---------------- name field of the savegame page + // ---------------- text fields (savegame name, login, account) if menu.typing() { + // Tab switches the field right away (the following characters go to the next one) + let mut tab_char = false; while let Some(c) = get_char_pressed() { - menu.type_char(c); + if c == '\t' { + tab_char = true; + menu.next_field(); + } else { + menu.type_char(c); + } + } + if is_key_pressed(KeyCode::Tab) && !tab_char { + menu.next_field(); } if is_key_pressed(KeyCode::Backspace) { - menu.save_name.pop(); + menu.backspace(); } if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { - let name = menu.save_name.trim().to_string(); - if cloud.save(None, &name, &app.autosave_json()) { - app.set_status(i18n::tr("saves.wait"), now); + if matches!(menu.page, Some(Page::Saves { .. })) { + let name = menu.save_name.trim().to_string(); + if cloud.save(None, &name, &app.autosave_json()) { + app.set_status(i18n::tr("saves.wait"), now); + } + } else { + let a = menu.submit(); + account_action(a, &mut cloud, &mut menu, &mut app, now); } } } @@ -680,6 +791,14 @@ async fn main() { autosave_t = 0.0; } MenuAction::OpenRepo => platform::open_url(menu::REPO_URL), + a @ (MenuAction::Login(..) + | MenuAction::Logout + | MenuAction::OpenAccount + | MenuAction::ChangePassword(..) + | MenuAction::CreateUser(..) + | MenuAction::DeleteUser(..) + | MenuAction::DownloadStandalone + | MenuAction::EditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now), MenuAction::EditName => { if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) { menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect(); @@ -865,7 +984,13 @@ async fn main() { let on_title = menu.page == Some(Page::Title) || matches!( menu.page, - Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. }) + Some(Page::Settings { from_title: true, .. }) + | Some(Page::KeyCapture { from_title: true, .. }) + | Some(Page::NewGame) + | Some(Page::Campaign) + | Some(Page::Briefing { ingame: false, .. }) + | Some(Page::Login) + | Some(Page::Account) ); music.update(&app.game, on_title, (app.cam.tx, app.cam.tz)); for m in app.game.msgs.drain(..) { @@ -914,7 +1039,13 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M r3d.draw(&app.game, gfx, &app.cam, now, &ov); let title_screen = matches!( menu.page, - Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. }) + Some(Page::Title) + | Some(Page::Settings { from_title: true, .. }) + | Some(Page::KeyCapture { from_title: true, .. }) + | Some(Page::Campaign) + | Some(Page::Briefing { ingame: false, .. }) + | Some(Page::Login) + | Some(Page::Account) ); if !title_screen { let info = if over_ui || (touch && inp.inspect.is_none()) { diff --git a/src/menu.rs b/src/menu.rs index bbc467b..bc8614a 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -22,6 +22,8 @@ pub enum Page { Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode) Campaign, // list of the campaign missions Briefing { mission: usize, ingame: bool }, // story and goals of a mission + Login, // login to the savegame service (before the title) + Account, // password, logout, users (admin) } pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"]; @@ -202,6 +204,14 @@ enum Btn { StartMission, Goals, Repo, + Field(usize), + DoLogin, + Account, + Standalone, + ChangePassword, + Logout, + CreateUser, + DeleteUser(usize), } pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft"; @@ -225,6 +235,14 @@ pub enum MenuAction { EditName, // the name field was tapped (touch: ask with a browser dialog) StartMission(usize), OpenRepo, + Login(String, String), + Logout, + OpenAccount, + ChangePassword(String, String), + CreateUser(String, String), + DeleteUser(String), + DownloadStandalone, + EditField(usize), // touch: ask for the text with the browser dialog } pub struct Menu { @@ -233,6 +251,9 @@ pub struct Menu { pub save_name: String, // name for a new savegame (typed on the savegame page) pub progress: Progress, // campaign progress pub mission: Option, // mission being played (pause menu: goals) + pub fields: [String; 4], // text fields of the login and account pages + pub focus: usize, // field that gets the typed text + pub error: Option, // message on the login and account pages confirm: Option, // overwrite / delete waiting for a second click scroll: usize, rows_visible: usize, @@ -254,6 +275,9 @@ impl Menu { save_name: String::new(), progress: Progress::default(), mission: None, + fields: Default::default(), + focus: 0, + error: None, confirm: None, scroll: 0, rows_visible: 1, @@ -275,6 +299,8 @@ impl Menu { Some(Page::Saves { from_title: true, .. }) => Some(Page::Title), Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause), Some(Page::Campaign) => Some(Page::Title), + Some(Page::Login) => Some(Page::Login), + Some(Page::Account) => Some(Page::Title), Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause), Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign), Some(Page::Pause) => None, @@ -291,17 +317,56 @@ impl Menu { self.scroll = 0; } - // the name field takes keyboard input + // pages with text fields take keyboard input pub fn typing(&self) -> bool { - matches!(self.page, Some(Page::Saves { save: true, .. })) + matches!(self.page, Some(Page::Saves { save: true, .. }) | Some(Page::Login) | Some(Page::Account)) + } + + fn input(&mut self) -> &mut String { + if matches!(self.page, Some(Page::Saves { .. })) { + &mut self.save_name + } else { + &mut self.fields[self.focus.min(3)] + } } pub fn type_char(&mut self, c: char) { - if !c.is_control() && self.save_name.chars().count() < 40 { - self.save_name.push(c); + let s = self.input(); + if !c.is_control() && s.chars().count() < 40 { + s.push(c); } } + pub fn backspace(&mut self) { + self.input().pop(); + } + + // Tab: next text field + pub fn next_field(&mut self) { + let n = match self.page { + Some(Page::Login) => 2, + Some(Page::Account) => 4, + _ => 1, + }; + self.focus = (self.focus + 1) % n; + } + + // Enter on the login page logs in, on the account page it uses the focused group + pub fn submit(&mut self) -> MenuAction { + match self.page { + Some(Page::Login) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()), + Some(Page::Account) if self.focus < 2 => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()), + Some(Page::Account) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()), + _ => MenuAction::None, + } + } + + pub fn open_login(&mut self) { + self.page = Some(Page::Login); + self.fields = Default::default(); + self.focus = 0; + } + pub fn capturing(&self) -> Option { match self.page { Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(), @@ -408,6 +473,30 @@ impl Menu { } Some(Btn::NameField) => MenuAction::EditName, Some(Btn::Repo) => MenuAction::OpenRepo, + Some(Btn::Field(i)) => { + self.focus = i; + MenuAction::EditField(i) + } + Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()), + Some(Btn::Account) => { + self.page = Some(Page::Account); + self.fields = Default::default(); + self.focus = 0; + self.error = None; + MenuAction::OpenAccount + } + Some(Btn::Standalone) => MenuAction::DownloadStandalone, + Some(Btn::ChangePassword) => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()), + Some(Btn::Logout) => MenuAction::Logout, + Some(Btn::CreateUser) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()), + Some(b @ Btn::DeleteUser(i)) => match cloud.users.get(i) { + Some(u) if confirm == Some(b) => MenuAction::DeleteUser(u.name.clone()), + Some(_) => { + self.confirm = Some(b); + MenuAction::Consumed + } + None => MenuAction::Consumed, + }, Some(Btn::Campaign) => { self.page = Some(Page::Campaign); self.scroll = 0; @@ -441,12 +530,14 @@ impl Menu { let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 })); match page { - Page::Title => self.draw_title(gfx), + Page::Title => self.draw_title(gfx, cloud), Page::Pause => self.draw_pause(gfx), Page::NewGame => self.draw_new_game(gfx, settings), Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab), Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save), Page::Campaign => self.draw_campaign(gfx), + Page::Login => self.draw_login(gfx, cloud), + Page::Account => self.draw_account(gfx, cloud), Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), Page::KeyCapture { action, .. } => { self.draw_settings(gfx, settings, 4); @@ -463,12 +554,18 @@ impl Menu { } } if let Some(st) = status { - let w = gfx.measure(st, S()) + CW() * 4.0; + // long messages wrap onto several lines + let maxc = ((sw / CW()) as usize).saturating_sub(8).clamp(10, 70); + let lines = wrap(st, maxc); + let w = lines.iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 4.0; + let h = CH() * (lines.len() as f32 + 1.0); let x = grid_center(sw, w); - let y = sh - CH() * 9.0; - draw_rectangle(x, y, w, CH() * 2.0, Color::new(0.05, 0.03, 0.04, 0.95)); - draw_rectangle_lines(x, y, w, CH() * 2.0, 2.0, GOLD); - gfx.text(st, x + CW() * 2.0, y + CH() * 0.5, S(), GOLD); + let y = sh - CH() * 8.0 - h; + draw_rectangle(x, y, w, h, Color::new(0.05, 0.03, 0.04, 0.95)); + draw_rectangle_lines(x, y, w, h, 2.0, GOLD); + for (k, l) in lines.iter().enumerate() { + gfx.text(l, x + CW() * 2.0, y + CH() * (0.5 + k as f32), S(), GOLD); + } } } @@ -481,7 +578,7 @@ impl Menu { } } - fn draw_title(&mut self, gfx: &Gfx) { + fn draw_title(&mut self, gfx: &Gfx, cloud: &Cloud) { let sw = sw(); let sh = sh(); let big = TITLE(); @@ -499,6 +596,10 @@ impl Menu { items.push((Btn::New, tr("menu.new"))); items.push((Btn::Load, tr("menu.load"))); items.push((Btn::Settings, tr("menu.settings"))); + if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) { + items.push((Btn::Account, trf("menu.account", &[s.name.clone()]))); + items.push((Btn::Standalone, tr("menu.standalone"))); + } items.push((Btn::Help, tr("menu.help"))); let w = 26.0 * CW(); let y = ty + GH * big + CH() * 4.0; @@ -921,6 +1022,131 @@ impl Menu { pub fn scroll_lines(&mut self, d: i32) { self.scroll = (self.scroll as i32 + d).max(0) as usize; } + + // a text field; passwords are shown as stars + fn field(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, label: &str, i: usize, secret: bool) { + gfx.text(label, x, y + CH() * 0.5, S(), TXT); + let lw = 16.0 * CW(); + let r = Rect::new(x + lw, y, w - lw, CH() * 2.0); + let focused = self.focus == i; + draw_rectangle(r.x, r.y, r.w, r.h, Color::new(0.02, 0.01, 0.02, 1.0)); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if focused { FRAME_HI } else { FRAME }); + let shown: String = if secret { "*".repeat(self.fields[i].chars().count()) } else { self.fields[i].clone() }; + let maxc = ((r.w - CW() * 2.0) / CW()).max(1.0) as usize; + let all: Vec = shown.chars().collect(); + let mut text: String = all[all.len().saturating_sub(maxc - 1)..].iter().collect(); + if focused && (get_time() * 2.0) as i64 % 2 == 0 { + text.push('_'); + } + gfx.text(&text, r.x + CW(), r.y + CH() * 0.5, S(), GOLD); + self.buttons.push((r, Btn::Field(i))); + } + + // Login to the server (the first login creates the admin account) + fn draw_login(&mut self, gfx: &Gfx, cloud: &Cloud) { + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); + let big = TITLE(); + let title = "DungeonCraft"; + let ty = (sh * 0.12 / CH()).floor() * CH(); + gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8)); + gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI); + let cols = ((sw / CW()) as i32 - 2).min(50); + let rows = 13; + let x = grid_center(sw, cols as f32 * CW()).max(0.0); + let y = ty + GH * big + CH() * 3.0; + let head = tr(if cloud.setup { "login.setup" } else { "login.title" }); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI); + let w = (cols - 6) as f32 * CW(); + if cloud.setup { + gfx.text(&tr("login.setup_hint"), x + CW() * 3.0, y + CH() * 1.5, SMALL(), DIM); + } + self.field(gfx, x + CW() * 3.0, y + CH() * 3.0, w, &tr("login.name"), 0, false); + self.field(gfx, x + CW() * 3.0, y + CH() * 5.5, w, &tr("login.password"), 1, true); + if let Some(e) = &self.error { + let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize; + for (k, l) in wrap(e, maxc).iter().take(2).enumerate() { + gfx.text(l, x + CW() * 3.0, y + CH() * 7.9 + k as f32 * GH * SMALL() * 1.2, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0)); + } + } + let label = tr(if cloud.setup { "login.create_admin" } else { "login.button" }); + let bw = gfx.measure(&label, S()) + CW() * 6.0; + let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 9.5, bw, CH() * 2.0); + button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI); + self.buttons.push((br, Btn::DoLogin)); + self.draw_license(gfx); + } + + // Account: change the password, log out; the admin manages the users + fn draw_account(&mut self, gfx: &Gfx, cloud: &Cloud) { + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); + let admin = cloud.session.as_ref().map_or(false, |s| s.admin); + let cols = ((sw / CW()) as i32 - 2).min(60); + let rows = ((sh / CH()) as i32 - 2).min(if admin { 38 } else { 18 }); + let x = grid_center(sw, cols as f32 * CW()).max(0.0); + let y = grid_center_y(sh, rows as f32 * CH()).max(0.0); + let who = cloud.session.as_ref().map(|s| s.name.clone()).unwrap_or_default(); + let head = trf(if admin { "account.title_admin" } else { "account.title" }, &[who]); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI); + let ix = x + CW() * 3.0; + let w = (cols - 6) as f32 * CW(); + gfx.text(&tr("account.change_pw"), ix, y + CH() * 2.0, S(), GOLD); + self.field(gfx, ix, y + CH() * 3.5, w, &tr("account.old_pw"), 0, true); + self.field(gfx, ix, y + CH() * 6.0, w, &tr("account.new_pw"), 1, true); + let cl = tr("account.save_pw"); + let cw2 = gfx.measure(&cl, S()) + CW() * 4.0; + let cr = Rect::new(ix + w - cw2, y + CH() * 8.5, cw2, CH() * 2.0); + button(gfx, cr, &cl, cr.contains(vec2(mx, my)), FRAME); + self.buttons.push((cr, Btn::ChangePassword)); + let mut yy = y + CH() * 11.5; + if admin { + gfx.text(&tr("account.users"), ix, yy, S(), GOLD); + yy += CH() * 1.5; + self.field(gfx, ix, yy, w, &tr("account.user_name"), 2, false); + self.field(gfx, ix, yy + CH() * 2.5, w, &tr("account.user_pw"), 3, true); + let nl = tr("account.create"); + let nw = gfx.measure(&nl, S()) + CW() * 4.0; + let nr = Rect::new(ix + w - nw, yy + CH() * 5.0, nw, CH() * 2.0); + button(gfx, nr, &nl, nr.contains(vec2(mx, my)), FRAME); + self.buttons.push((nr, Btn::CreateUser)); + yy += CH() * 8.0; + let del = tr("saves.delete"); + let sure = tr("saves.sure"); + let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0; + let bottom = y + (rows - 5) as f32 * CH(); + for (k, u) in cloud.users.iter().enumerate() { + let ry = yy + k as f32 * CH() * 2.5; + if ry + CH() * 2.0 > bottom { + break; + } + let label = if u.admin { format!("{} (admin)", u.name) } else { u.name.clone() }; + gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT); + if !u.admin { + let r = Rect::new(ix + w - dw, ry, dw, CH() * 2.0); + let c = self.confirm == Some(Btn::DeleteUser(k)); + button(gfx, r, if c { &sure } else { &del }, r.contains(vec2(mx, my)) || c, Color::new(0.6, 0.2, 0.15, 1.0)); + self.buttons.push((r, Btn::DeleteUser(k))); + } + } + } + let _ = yy; + if let Some(e) = &self.error { + gfx.text(e, ix, y + (rows - 5) as f32 * CH() + CH() * 0.5, S(), Color::new(0.95, 0.4, 0.35, 1.0)); + } + let by = y + (rows - 4) as f32 * CH(); + let back = tr("menu.back"); + let br = Rect::new(ix - CW(), by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0); + button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME); + self.buttons.push((br, Btn::Back)); + let lo = tr("account.logout"); + let lw = gfx.measure(&lo, S()) + CW() * 4.0; + let lr = Rect::new(ix + w - lw, by, lw, CH() * 2.0); + button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME); + self.buttons.push((lr, Btn::Logout)); + } } // description of a mission goal (without progress) diff --git a/src/platform.rs b/src/platform.rs index 332cc47..2252fb8 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -16,6 +16,9 @@ mod imp { fn dc_storage_take(dst: *mut u8); fn dc_coarse_pointer() -> i32; fn dc_open_url(u: *const u8, ul: u32); + fn dc_set_token(t: *const u8, tl: u32); + fn dc_fetch_download(u: *const u8, ul: u32, n: *const u8, nl: u32); + fn dc_fetch_download_state() -> i32; fn dc_prompt(t: *const u8, tl: u32, d: *const u8, dl: u32) -> i32; fn dc_storage_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32); fn dc_cache_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32); @@ -67,6 +70,21 @@ mod imp { unsafe { dc_open_url(url.as_ptr(), url.len() as u32) } } + // login session sent with every request to the service + pub fn set_token(token: &str) { + unsafe { dc_set_token(token.as_ptr(), token.len() as u32) } + } + + // downloads a file of the server (with the login) straight to the user's disk + pub fn fetch_download(url: &str, name: &str) { + unsafe { dc_fetch_download(url.as_ptr(), url.len() as u32, name.as_ptr(), name.len() as u32) } + } + + // 0 idle, 1 running, 2 done, 3 failed (2 and 3 are reported once) + pub fn fetch_download_state() -> i32 { + unsafe { dc_fetch_download_state() } + } + // asks for a text with the browser's dialog; None when cancelled pub fn prompt(title: &str, default: &str) -> Option { unsafe { @@ -128,6 +146,11 @@ mod imp { false } pub fn open_url(_url: &str) {} + pub fn set_token(_token: &str) {} + pub fn fetch_download(_url: &str, _name: &str) {} + pub fn fetch_download_state() -> i32 { + 0 + } pub fn storage_set(key: &str, val: &str) { STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string())); } diff --git a/tools/make_standalone.py b/tools/make_standalone.py new file mode 100644 index 0000000..ffd080f --- /dev/null +++ b/tools/make_standalone.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Builds DungeonCraft as one self-contained HTML file (no server needed, works from file://). + +The JavaScript bundle, the plugin and the WASM module (base64) are embedded; fetching +"dungeoncraft.wasm" is answered from the embedded bytes. Service worker and manifest are left +out. Without the savegame service the game uses savegame files (download / file picker), so +savegames downloaded from the server open in the standalone version as well. + +usage: make_standalone.py +""" +import base64 +import pathlib +import sys + +www = pathlib.Path(sys.argv[1]) +out = pathlib.Path(sys.argv[2]) + +wasm = base64.b64encode((www / "dungeoncraft.wasm").read_bytes()).decode() +bundle = (www / "mq_js_bundle.js").read_text() +plugin = (www / "dc_plugin.js").read_text() +icon = base64.b64encode((www / "icons" / "icon-192.png").read_bytes()).decode() + +# keep a "" inside the code from ending the script element +def safe(js: str) -> str: + return js.replace(" + + + + DungeonCraft + + + + + + + + + + + + + + + +""" +out.write_text(html) +print(f"{out}: {len(html) // 1024} KB") diff --git a/www/dc_plugin.js b/www/dc_plugin.js index 05896e1..35c532c 100644 --- a/www/dc_plugin.js +++ b/www/dc_plugin.js @@ -7,6 +7,8 @@ var cache = {}; // key -> string, persisted to localStorage when the page is hidden/closed var http = {}; // request id -> { done, status, body } var httpNext = 1; + var token = ""; // login session of the savegame service (sent with every request) + var fetched = { state: 0 }; // download of a file from the server: 0 idle, 1 running, 2 done, 3 failed function bytes(ptr, len) { return new Uint8Array(wasm_memory.buffer, ptr, len); @@ -47,6 +49,7 @@ var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 15000); try { var opts = { method: method, cache: "no-store", headers: {} }; + if (token) opts.headers["Authorization"] = "Bearer " + token; if (ctrl) opts.signal = ctrl.signal; if (body !== null) { opts.body = body; opts.headers["Content-Type"] = "application/json"; } fetch(url, opts).then(function (r) { @@ -69,6 +72,33 @@ function register(importObject) { var env = importObject.env; + env.dc_set_token = function (tp, tl) { + token = tl > 0 ? str(tp, tl) : ""; + }; + // fetches a file (with the login) and offers it as a download, without copying it into the game + env.dc_fetch_download = function (up, ul, np, nl) { + var url = str(up, ul), name = str(np, nl); + fetched.state = 1; + var opts = { cache: "no-store", headers: {} }; + if (token) opts.headers["Authorization"] = "Bearer " + token; + fetch(url, opts).then(function (r) { + if (!r.ok) throw new Error("status " + r.status); + return r.blob(); + }).then(function (blob) { + var a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = name; + document.body.appendChild(a); + a.click(); + setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 2000); + fetched.state = 2; + }).catch(function () { fetched.state = 3; }); + }; + env.dc_fetch_download_state = function () { + var s = fetched.state; + if (s >= 2) fetched.state = 0; + return s; + }; env.dc_http = function (mp, ml, up, ul, bp, bl) { var body = bl > 0 ? bytes(bp, bl).slice() : null; return httpStart(str(mp, ml), str(up, ul), body); diff --git a/www/sw.js b/www/sw.js index ce67d7b..9be2d85 100644 --- a/www/sw.js +++ b/www/sw.js @@ -1,7 +1,7 @@ // DungeonCraft service worker: makes the game installable and playable offline. // Network first (updates arrive at once), the cache is the fallback when offline. // The savegame service (/api/) is never cached. -const CACHE = "dungeoncraft-v1"; +const CACHE = "dungeoncraft-v2"; const CORE = [ "./", "index.html", @@ -28,7 +28,7 @@ self.addEventListener("activate", (event) => { self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); - if (req.method !== "GET" || url.origin !== self.location.origin || url.pathname.includes("/api/")) { + if (req.method !== "GET" || url.origin !== self.location.origin || url.pathname.includes("/api/") || url.pathname.includes("/standalone/")) { return; } event.respondWith(