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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 13:33:22 +01:00
1 parent 8853d84564
commit c9848c8948
15 files changed
+1232 -83

No files matched your search

+9
View File
@@ -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
+14 -6
View File
@@ -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 <token>`. 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
+33
View File
@@ -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.
+33
View File
@@ -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.
+5
View File
@@ -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/ {
+130
View File
@@ -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"
+2
View File
@@ -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
View File
@@ -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");
}
+162 -4
View File
@@ -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<u8>), // 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<Session>,
pub list: Vec<SaveInfo>,
pub listed: bool, // the list has been fetched at least once
pub users: Vec<UserInfo>,
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<Session> = 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::<Answer>(&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 {
+140 -9
View File
@@ -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()) {
+237 -11
View File
@@ -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<usize>, // 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<String>, // message on the login and account pages
confirm: Option<Btn>, // 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<Action> {
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<char> = 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)
+23
View File
@@ -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<String> {
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()));
}
+70
View File
@@ -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 <www dir with dungeoncraft.wasm and mq_js_bundle.js> <output.html>
"""
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 "</script>" inside the code from ending the script element
def safe(js: str) -> str:
return js.replace("</script", "<\\/script")
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>DungeonCraft</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#080608">
<link rel="icon" type="image/png" href="data:image/png;base64,{icon}">
<style>
html, body {{ margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #080608; }}
canvas {{ display: block; width: 100%; height: 100%; outline: none; image-rendering: pixelated; }}
</style>
</head>
<body>
<canvas id="glcanvas" tabindex="1" oncontextmenu="return false"></canvas>
<!-- DungeonCraft standalone - MIT License - (c) 2026 Richard Zink - https://git.familie-zink.org/zink/dungeoncraft -->
<script>var register_plugin; /* expected by the quad_net plugin in the bundle */</script>
<script>
// the game module is embedded: fetching it is answered from these bytes
var DC_WASM = "{wasm}";
(function () {{
var realFetch = window.fetch ? window.fetch.bind(window) : null;
window.fetch = function (url, opts) {{
if (typeof url === "string" && url.indexOf("dungeoncraft.wasm") >= 0) {{
var bin = atob(DC_WASM);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return Promise.resolve(new Response(bytes, {{ headers: {{ "Content-Type": "application/wasm" }} }}));
}}
return realFetch ? realFetch(url, opts) : Promise.reject(new Error("offline"));
}};
}})();
</script>
<script>{safe(bundle)}</script>
<script>{safe(plugin)}</script>
<script>
document.getElementById("glcanvas").focus();
load("dungeoncraft.wasm");
</script>
</body>
</html>
"""
out.write_text(html)
print(f"{out}: {len(html) // 1024} KB")
+30
View File
@@ -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);
+2 -2
View File
@@ -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(