User accounts, login before the title screen, own savegames per user, standalone download
- Service: accounts with Argon2 password hashes and session tokens; the first login creates the admin (shared savegames of before move to the admin); the admin creates and deletes users; everybody can change the password; savegames are stored and listed per user - Game: login page before the title (only when the service is reachable), with licence and repository link; account page (password, logout; users for the admin); text fields with Tab/Enter, masked passwords, browser dialog on touch screens; long status messages wrap - Download Standalone: DungeonCraft.html with the game embedded (built in the Docker image, served to logged-in users via nginx auth_request); works from file:// without server and opens downloaded savegames - Without the service (standalone, local files) there is no login, savegame files as before Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8853d84564
commit
c9848c8948
15 files changed
+1222
-73
No files matched your search
@@ -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 target/wasm32-unknown-unknown/release/dungeoncraft.wasm /out/ \
|
||||||
&& cp "$CARGO_HOME"/registry/src/*/macroquad-*/js/mq_js_bundle.js /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
|
# --- serve: nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY www/ /usr/share/nginx/html/
|
COPY www/ /usr/share/nginx/html/
|
||||||
COPY --from=build /out/ /usr/share/nginx/html/
|
COPY --from=build /out/ /usr/share/nginx/html/
|
||||||
|
COPY --from=standalone /out/standalone/ /usr/share/nginx/html/standalone/
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
@@ -88,26 +88,34 @@ The savegame name is entered with the browser's text dialog when the name field
|
|||||||
|
|
||||||
## Savegames
|
## 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.
|
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
|
### 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 |
|
| Request | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/health` | `ok` when the service runs |
|
| `GET /api/health` | `ok` when the service runs (`ok setup` while there is no account yet) |
|
||||||
| `GET /api/saves` | list (id, name, date, map size, wave, play time), newest first |
|
| `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) |
|
| `POST /api/saves?name=...` | store a new savegame (body: savegame JSON) |
|
||||||
| `PUT /api/saves/{id}?name=...` | overwrite a savegame |
|
| `PUT /api/saves/{id}?name=...` | overwrite a savegame |
|
||||||
| `GET /api/saves/{id}` | savegame JSON |
|
| `GET /api/saves/{id}` | savegame JSON |
|
||||||
| `DELETE /api/saves/{id}` | delete a savegame |
|
| `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
|
## Adding a language
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
hint.steel_where = Stahl lässt sich nur an Erdwände neben deinem Gebiet anbringen.
|
||||||
tile.steel = Stahlwand
|
tile.steel = Stahlwand
|
||||||
tile.steel_info = Helden brauchen ewig, um hindurchzukommen.
|
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.
|
||||||
@@ -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.
|
hint.steel_where = Steel can only be put on earth walls next to your own ground.
|
||||||
tile.steel = Steel wall
|
tile.steel = Steel wall
|
||||||
tile.steel_info = Heroes need ages to get through.
|
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.
|
||||||
@@ -12,6 +12,11 @@ server {
|
|||||||
default_type application/manifest+json;
|
default_type application/manifest+json;
|
||||||
try_files $uri =404;
|
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;
|
# savegame service (server/). Resolved at request time, so the game still starts without it;
|
||||||
# the game then falls back to savegame files.
|
# the game then falls back to savegame files.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
|||||||
Generated
+130
@@ -2,6 +2,18 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -60,17 +72,79 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
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]]
|
[[package]]
|
||||||
name = "dungeoncraft-saves"
|
name = "dungeoncraft-saves"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
"getrandom",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -109,6 +183,27 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -241,6 +336,17 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -271,6 +377,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -365,6 +477,12 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.9"
|
version = "3.0.9"
|
||||||
@@ -455,12 +573,24 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ axum = "0.8"
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "sync", "time"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
argon2 = "0.5"
|
||||||
|
getrandom = "0.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
+337
-46
@@ -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"
|
// The very first login creates the admin account with the entered name and password.
|
||||||
// GET /api/saves -> list of savegames (newest first)
|
// The admin creates further users; every user can change the password and only sees
|
||||||
|
// the own savegames. Requests (except health and login) need "Authorization: Bearer <token>".
|
||||||
|
//
|
||||||
|
// GET /api/health -> "ok" (or "ok setup" while there is no user yet)
|
||||||
|
// POST /api/login -> {name, password} -> {token, name, admin}
|
||||||
|
// POST /api/logout
|
||||||
|
// GET /api/me -> {name, admin}
|
||||||
|
// GET /api/auth -> 204 when logged in (nginx auth_request for the standalone file)
|
||||||
|
// POST /api/password -> {old, new}
|
||||||
|
// GET /api/users -> admin: [{name, admin}]
|
||||||
|
// POST /api/users -> admin: {name, password}
|
||||||
|
// DELETE /api/users/{name} -> admin: delete a user and the savegames
|
||||||
|
// GET /api/saves -> own savegames (newest first)
|
||||||
// GET /api/saves/{id} -> savegame JSON
|
// GET /api/saves/{id} -> savegame JSON
|
||||||
// POST /api/saves?name=.. -> store a new savegame (body = savegame JSON), returns its info
|
// POST /api/saves?name=.. -> store a new savegame (body = savegame JSON)
|
||||||
// PUT /api/saves/{id}?name -> overwrite a savegame
|
// PUT /api/saves/{id}?name= -> overwrite a savegame
|
||||||
// DELETE /api/saves/{id} -> delete a savegame
|
// DELETE /api/saves/{id}
|
||||||
//
|
//
|
||||||
// Storage is shared by everybody (no accounts). Limits: 1 MB per savegame, 500 MB in total,
|
// Limits: 1 MB per savegame, 500 MB in total, 30 write requests and 20 login attempts per minute.
|
||||||
// at most 30 write requests per minute.
|
|
||||||
|
|
||||||
|
use argon2::Argon2;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::{DefaultBodyLimit, Path, Query, State},
|
extract::{DefaultBodyLimit, Path, Query, State},
|
||||||
http::StatusCode,
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
@@ -29,23 +42,35 @@ use tokio::sync::Mutex;
|
|||||||
const MAX_SAVE: usize = 1024 * 1024;
|
const MAX_SAVE: usize = 1024 * 1024;
|
||||||
const MAX_TOTAL: u64 = 500 * 1024 * 1024;
|
const MAX_TOTAL: u64 = 500 * 1024 * 1024;
|
||||||
const WRITES_PER_MINUTE: usize = 30;
|
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)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
dir: PathBuf,
|
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)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
struct SaveInfo {
|
struct SaveInfo {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
saved_at: u64, // unix seconds
|
saved_at: u64,
|
||||||
size: usize,
|
size: usize,
|
||||||
map_w: u64,
|
map_w: u64,
|
||||||
map_h: u64,
|
map_h: u64,
|
||||||
wave: u64,
|
wave: u64,
|
||||||
time: f64, // play time in seconds
|
time: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -53,14 +78,55 @@ struct NameQuery {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Credentials {
|
||||||
|
name: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PasswordChange {
|
||||||
|
old: String,
|
||||||
|
new: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn now() -> u64 {
|
fn now() -> u64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
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 {
|
fn valid_id(id: &str) -> bool {
|
||||||
!id.is_empty() && id.len() <= 40 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
!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 {
|
fn clean_name(n: Option<String>) -> String {
|
||||||
let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect();
|
let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect();
|
||||||
if n.trim().is_empty() {
|
if n.trim().is_empty() {
|
||||||
@@ -74,36 +140,232 @@ fn err(code: StatusCode, msg: &str) -> Response {
|
|||||||
(code, msg.to_string()).into_response()
|
(code, msg.to_string()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn rate_ok(st: &AppState) -> bool {
|
async fn limited(list: &Mutex<Vec<u64>>, max: usize) -> bool {
|
||||||
let mut w = st.writes.lock().await;
|
let mut w = list.lock().await;
|
||||||
let t = now();
|
let t = now();
|
||||||
w.retain(|&x| t - x < 60);
|
w.retain(|&x| t - x < 60);
|
||||||
if w.len() >= WRITES_PER_MINUTE {
|
if w.len() >= max {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
w.push(t);
|
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 {
|
async fn total_size(dir: &PathBuf) -> u64 {
|
||||||
|
// all savegames of all users
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
if let Ok(mut rd) = tokio::fs::read_dir(dir).await {
|
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 {
|
while let Ok(Some(e)) = rd.next_entry().await {
|
||||||
if let Ok(m) = e.metadata().await {
|
if let Ok(m) = e.metadata().await {
|
||||||
|
if m.is_dir() {
|
||||||
|
stack.push(e.path());
|
||||||
|
} else {
|
||||||
total += m.len();
|
total += m.len();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> &'static str {
|
async fn health(State(st): State<AppState>) -> &'static str {
|
||||||
|
if st.users.lock().await.is_empty() {
|
||||||
|
"ok setup"
|
||||||
|
} else {
|
||||||
"ok"
|
"ok"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list(State(st): State<AppState>) -> Response {
|
async fn login(State(st): State<AppState>, Json(c): Json<Credentials>) -> Response {
|
||||||
let mut out: Vec<SaveInfo> = Vec::new();
|
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 {
|
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 {
|
while let Ok(Some(e)) = rd.next_entry().await {
|
||||||
let p = e.path();
|
let p = e.path();
|
||||||
if p.extension().and_then(|x| x.to_str()) == Some("meta") {
|
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()
|
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) {
|
if !valid_id(&id) {
|
||||||
return err(StatusCode::BAD_REQUEST, "bad 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(),
|
Ok(b) => ([("content-type", "application/json")], b).into_response(),
|
||||||
Err(_) => err(StatusCode::NOT_FOUND, "not found"),
|
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 {
|
if body.len() > MAX_SAVE {
|
||||||
return err(StatusCode::PAYLOAD_TOO_LARGE, "savegame too large");
|
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");
|
return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||||
}
|
}
|
||||||
if total_size(&st.dir).await + body.len() as u64 > MAX_TOTAL {
|
if total_size(&st.dir).await + body.len() as u64 > MAX_TOTAL {
|
||||||
return err(StatusCode::INSUFFICIENT_STORAGE, "storage full");
|
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) {
|
let v: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => return err(StatusCode::BAD_REQUEST, "not JSON"),
|
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),
|
wave: game["wave"].as_u64().unwrap_or(0),
|
||||||
time: game["time"].as_f64().unwrap_or(0.0),
|
time: game["time"].as_f64().unwrap_or(0.0),
|
||||||
};
|
};
|
||||||
let save_path = st.dir.join(format!("{}.json", id));
|
let dir = st.user_dir(&user.name);
|
||||||
let meta_path = st.dir.join(format!("{}.meta", id));
|
let _ = tokio::fs::create_dir_all(&dir).await;
|
||||||
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 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");
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed");
|
||||||
}
|
}
|
||||||
Json(info).into_response()
|
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);
|
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) {
|
if !valid_id(&id) {
|
||||||
return err(StatusCode::BAD_REQUEST, "bad 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) {
|
if !valid_id(&id) {
|
||||||
return err(StatusCode::BAD_REQUEST, "bad 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");
|
return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||||
}
|
}
|
||||||
let _ = tokio::fs::remove_file(st.dir.join(format!("{}.json", id))).await;
|
let dir = st.user_dir(&u.name);
|
||||||
let _ = tokio::fs::remove_file(st.dir.join(format!("{}.meta", id))).await;
|
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()
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let dir = PathBuf::from(std::env::var("SAVE_DIR").unwrap_or_else(|_| "/data".into()));
|
let dir = PathBuf::from(std::env::var("SAVE_DIR").unwrap_or_else(|_| "/data".into()));
|
||||||
tokio::fs::create_dir_all(&dir).await.expect("save directory");
|
tokio::fs::create_dir_all(dir.join("users")).await.expect("data directory");
|
||||||
let state = AppState { dir, writes: Arc::new(Mutex::new(Vec::new())) };
|
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()
|
let app = Router::new()
|
||||||
.route("/api/health", get(health))
|
.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", 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))
|
.layer(DefaultBodyLimit::max(MAX_SAVE + 1024))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
let addr = std::env::var("LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into());
|
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");
|
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");
|
axum::serve(listener, app).await.expect("server");
|
||||||
}
|
}
|
||||||
+161
-3
@@ -4,7 +4,7 @@
|
|||||||
// `online` becomes false and the game falls back to savegame files and the browser autosave.
|
// `online` becomes false and the game falls back to savegame files and the browser autosave.
|
||||||
|
|
||||||
use crate::platform;
|
use crate::platform;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const API: &str = "api"; // relative, so the game also works below a sub path
|
const API: &str = "api"; // relative, so the game also works below a sub path
|
||||||
|
|
||||||
@@ -37,22 +37,54 @@ enum Req {
|
|||||||
Save,
|
Save,
|
||||||
Load,
|
Load,
|
||||||
Delete,
|
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 {
|
pub enum CloudEvent {
|
||||||
None,
|
None,
|
||||||
Saved(String), // name of the stored savegame
|
Saved(String), // name of the stored savegame
|
||||||
Loaded(Vec<u8>), // savegame data
|
Loaded(Vec<u8>), // savegame data
|
||||||
Deleted,
|
Deleted,
|
||||||
Error(&'static str), // language key
|
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 struct Cloud {
|
||||||
pub online: Online,
|
pub online: Online,
|
||||||
|
pub setup: bool, // no account yet: the first login creates the admin
|
||||||
|
pub session: Option<Session>,
|
||||||
pub list: Vec<SaveInfo>,
|
pub list: Vec<SaveInfo>,
|
||||||
pub listed: bool, // the list has been fetched at least once
|
pub listed: bool, // the list has been fetched at least once
|
||||||
|
pub users: Vec<UserInfo>,
|
||||||
pending: Option<(u32, Req)>,
|
pending: Option<(u32, Req)>,
|
||||||
save_name: String,
|
save_name: String,
|
||||||
|
login_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// percent-encoding for a query parameter
|
// percent-encoding for a query parameter
|
||||||
@@ -71,6 +103,7 @@ fn encode(s: &str) -> String {
|
|||||||
fn error_key(status: i32) -> &'static str {
|
fn error_key(status: i32) -> &'static str {
|
||||||
match status {
|
match status {
|
||||||
0 => "cloud.err_net",
|
0 => "cloud.err_net",
|
||||||
|
401 => "login.err_session",
|
||||||
413 => "cloud.err_large",
|
413 => "cloud.err_large",
|
||||||
429 => "cloud.err_rate",
|
429 => "cloud.err_rate",
|
||||||
507 => "cloud.err_full",
|
507 => "cloud.err_full",
|
||||||
@@ -83,11 +116,57 @@ impl Cloud {
|
|||||||
// starts the availability check
|
// starts the availability check
|
||||||
pub fn new() -> Cloud {
|
pub fn new() -> Cloud {
|
||||||
let id = platform::http("GET", &format!("{}/health", API), &[]);
|
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 {
|
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 {
|
pub fn busy(&self) -> bool {
|
||||||
@@ -132,7 +211,86 @@ impl Cloud {
|
|||||||
match req {
|
match req {
|
||||||
Req::Health => {
|
Req::Health => {
|
||||||
self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No };
|
self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No };
|
||||||
|
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
|
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 => {
|
Req::List => {
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+136
-5
@@ -153,6 +153,61 @@ fn store_progress(p: &campaign::Progress) {
|
|||||||
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
|
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 {
|
fn next_seed(seed: u32) -> u32 {
|
||||||
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
|
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
|
||||||
}
|
}
|
||||||
@@ -378,22 +433,78 @@ async fn main() {
|
|||||||
CloudEvent::Saved(name) => app.set_status(i18n::trf("save.saved", &[name]), now),
|
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::Loaded(bytes) => app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now),
|
||||||
CloudEvent::Deleted => app.set_status(i18n::tr("cloud.deleted"), 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() {
|
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() {
|
while let Some(c) = get_char_pressed() {
|
||||||
|
if c == '\t' {
|
||||||
|
tab_char = true;
|
||||||
|
menu.next_field();
|
||||||
|
} else {
|
||||||
menu.type_char(c);
|
menu.type_char(c);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if is_key_pressed(KeyCode::Tab) && !tab_char {
|
||||||
|
menu.next_field();
|
||||||
|
}
|
||||||
if is_key_pressed(KeyCode::Backspace) {
|
if is_key_pressed(KeyCode::Backspace) {
|
||||||
menu.save_name.pop();
|
menu.backspace();
|
||||||
}
|
}
|
||||||
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
|
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
|
||||||
|
if matches!(menu.page, Some(Page::Saves { .. })) {
|
||||||
let name = menu.save_name.trim().to_string();
|
let name = menu.save_name.trim().to_string();
|
||||||
if cloud.save(None, &name, &app.autosave_json()) {
|
if cloud.save(None, &name, &app.autosave_json()) {
|
||||||
app.set_status(i18n::tr("saves.wait"), now);
|
app.set_status(i18n::tr("saves.wait"), now);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let a = menu.submit();
|
||||||
|
account_action(a, &mut cloud, &mut menu, &mut app, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if menu.scrollable() {
|
if menu.scrollable() {
|
||||||
@@ -680,6 +791,14 @@ async fn main() {
|
|||||||
autosave_t = 0.0;
|
autosave_t = 0.0;
|
||||||
}
|
}
|
||||||
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
|
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 => {
|
MenuAction::EditName => {
|
||||||
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) {
|
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();
|
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)
|
let on_title = menu.page == Some(Page::Title)
|
||||||
|| matches!(
|
|| matches!(
|
||||||
menu.page,
|
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));
|
music.update(&app.game, on_title, (app.cam.tx, app.cam.tz));
|
||||||
for m in app.game.msgs.drain(..) {
|
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);
|
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
|
||||||
let title_screen = matches!(
|
let title_screen = matches!(
|
||||||
menu.page,
|
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 {
|
if !title_screen {
|
||||||
let info = if over_ui || (touch && inp.inspect.is_none()) {
|
let info = if over_ui || (touch && inp.inspect.is_none()) {
|
||||||
|
|||||||
+237
-11
@@ -22,6 +22,8 @@ pub enum Page {
|
|||||||
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
|
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
|
||||||
Campaign, // list of the campaign missions
|
Campaign, // list of the campaign missions
|
||||||
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
|
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"];
|
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,
|
StartMission,
|
||||||
Goals,
|
Goals,
|
||||||
Repo,
|
Repo,
|
||||||
|
Field(usize),
|
||||||
|
DoLogin,
|
||||||
|
Account,
|
||||||
|
Standalone,
|
||||||
|
ChangePassword,
|
||||||
|
Logout,
|
||||||
|
CreateUser,
|
||||||
|
DeleteUser(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft";
|
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)
|
EditName, // the name field was tapped (touch: ask with a browser dialog)
|
||||||
StartMission(usize),
|
StartMission(usize),
|
||||||
OpenRepo,
|
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 {
|
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 save_name: String, // name for a new savegame (typed on the savegame page)
|
||||||
pub progress: Progress, // campaign progress
|
pub progress: Progress, // campaign progress
|
||||||
pub mission: Option<usize>, // mission being played (pause menu: goals)
|
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
|
confirm: Option<Btn>, // overwrite / delete waiting for a second click
|
||||||
scroll: usize,
|
scroll: usize,
|
||||||
rows_visible: usize,
|
rows_visible: usize,
|
||||||
@@ -254,6 +275,9 @@ impl Menu {
|
|||||||
save_name: String::new(),
|
save_name: String::new(),
|
||||||
progress: Progress::default(),
|
progress: Progress::default(),
|
||||||
mission: None,
|
mission: None,
|
||||||
|
fields: Default::default(),
|
||||||
|
focus: 0,
|
||||||
|
error: None,
|
||||||
confirm: None,
|
confirm: None,
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
rows_visible: 1,
|
rows_visible: 1,
|
||||||
@@ -275,6 +299,8 @@ impl Menu {
|
|||||||
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
|
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
|
||||||
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
|
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
|
||||||
Some(Page::Campaign) => Some(Page::Title),
|
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: true, .. }) => Some(Page::Pause),
|
||||||
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
|
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
|
||||||
Some(Page::Pause) => None,
|
Some(Page::Pause) => None,
|
||||||
@@ -291,17 +317,56 @@ impl Menu {
|
|||||||
self.scroll = 0;
|
self.scroll = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// the name field takes keyboard input
|
// pages with text fields take keyboard input
|
||||||
pub fn typing(&self) -> bool {
|
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) {
|
pub fn type_char(&mut self, c: char) {
|
||||||
if !c.is_control() && self.save_name.chars().count() < 40 {
|
let s = self.input();
|
||||||
self.save_name.push(c);
|
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> {
|
pub fn capturing(&self) -> Option<Action> {
|
||||||
match self.page {
|
match self.page {
|
||||||
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
|
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
|
||||||
@@ -408,6 +473,30 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
Some(Btn::NameField) => MenuAction::EditName,
|
Some(Btn::NameField) => MenuAction::EditName,
|
||||||
Some(Btn::Repo) => MenuAction::OpenRepo,
|
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) => {
|
Some(Btn::Campaign) => {
|
||||||
self.page = Some(Page::Campaign);
|
self.page = Some(Page::Campaign);
|
||||||
self.scroll = 0;
|
self.scroll = 0;
|
||||||
@@ -441,12 +530,14 @@ impl Menu {
|
|||||||
let sh = sh();
|
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 }));
|
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 {
|
match page {
|
||||||
Page::Title => self.draw_title(gfx),
|
Page::Title => self.draw_title(gfx, cloud),
|
||||||
Page::Pause => self.draw_pause(gfx),
|
Page::Pause => self.draw_pause(gfx),
|
||||||
Page::NewGame => self.draw_new_game(gfx, settings),
|
Page::NewGame => self.draw_new_game(gfx, settings),
|
||||||
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
||||||
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
|
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
|
||||||
Page::Campaign => self.draw_campaign(gfx),
|
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::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
|
||||||
Page::KeyCapture { action, .. } => {
|
Page::KeyCapture { action, .. } => {
|
||||||
self.draw_settings(gfx, settings, 4);
|
self.draw_settings(gfx, settings, 4);
|
||||||
@@ -463,12 +554,18 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(st) = status {
|
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 x = grid_center(sw, w);
|
||||||
let y = sh - CH() * 9.0;
|
let y = sh - CH() * 8.0 - h;
|
||||||
draw_rectangle(x, y, w, CH() * 2.0, Color::new(0.05, 0.03, 0.04, 0.95));
|
draw_rectangle(x, y, w, h, Color::new(0.05, 0.03, 0.04, 0.95));
|
||||||
draw_rectangle_lines(x, y, w, CH() * 2.0, 2.0, GOLD);
|
draw_rectangle_lines(x, y, w, h, 2.0, GOLD);
|
||||||
gfx.text(st, x + CW() * 2.0, y + CH() * 0.5, S(), 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 sw = sw();
|
||||||
let sh = sh();
|
let sh = sh();
|
||||||
let big = TITLE();
|
let big = TITLE();
|
||||||
@@ -499,6 +596,10 @@ impl Menu {
|
|||||||
items.push((Btn::New, tr("menu.new")));
|
items.push((Btn::New, tr("menu.new")));
|
||||||
items.push((Btn::Load, tr("menu.load")));
|
items.push((Btn::Load, tr("menu.load")));
|
||||||
items.push((Btn::Settings, tr("menu.settings")));
|
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")));
|
items.push((Btn::Help, tr("menu.help")));
|
||||||
let w = 26.0 * CW();
|
let w = 26.0 * CW();
|
||||||
let y = ty + GH * big + CH() * 4.0;
|
let y = ty + GH * big + CH() * 4.0;
|
||||||
@@ -921,6 +1022,131 @@ impl Menu {
|
|||||||
pub fn scroll_lines(&mut self, d: i32) {
|
pub fn scroll_lines(&mut self, d: i32) {
|
||||||
self.scroll = (self.scroll as i32 + d).max(0) as usize;
|
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)
|
// description of a mission goal (without progress)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ mod imp {
|
|||||||
fn dc_storage_take(dst: *mut u8);
|
fn dc_storage_take(dst: *mut u8);
|
||||||
fn dc_coarse_pointer() -> i32;
|
fn dc_coarse_pointer() -> i32;
|
||||||
fn dc_open_url(u: *const u8, ul: u32);
|
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_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_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);
|
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) }
|
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
|
// asks for a text with the browser's dialog; None when cancelled
|
||||||
pub fn prompt(title: &str, default: &str) -> Option<String> {
|
pub fn prompt(title: &str, default: &str) -> Option<String> {
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -128,6 +146,11 @@ mod imp {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
pub fn open_url(_url: &str) {}
|
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) {
|
pub fn storage_set(key: &str, val: &str) {
|
||||||
STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string()));
|
STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
var cache = {}; // key -> string, persisted to localStorage when the page is hidden/closed
|
var cache = {}; // key -> string, persisted to localStorage when the page is hidden/closed
|
||||||
var http = {}; // request id -> { done, status, body }
|
var http = {}; // request id -> { done, status, body }
|
||||||
var httpNext = 1;
|
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) {
|
function bytes(ptr, len) {
|
||||||
return new Uint8Array(wasm_memory.buffer, ptr, len);
|
return new Uint8Array(wasm_memory.buffer, ptr, len);
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 15000);
|
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 15000);
|
||||||
try {
|
try {
|
||||||
var opts = { method: method, cache: "no-store", headers: {} };
|
var opts = { method: method, cache: "no-store", headers: {} };
|
||||||
|
if (token) opts.headers["Authorization"] = "Bearer " + token;
|
||||||
if (ctrl) opts.signal = ctrl.signal;
|
if (ctrl) opts.signal = ctrl.signal;
|
||||||
if (body !== null) { opts.body = body; opts.headers["Content-Type"] = "application/json"; }
|
if (body !== null) { opts.body = body; opts.headers["Content-Type"] = "application/json"; }
|
||||||
fetch(url, opts).then(function (r) {
|
fetch(url, opts).then(function (r) {
|
||||||
@@ -69,6 +72,33 @@
|
|||||||
|
|
||||||
function register(importObject) {
|
function register(importObject) {
|
||||||
var env = importObject.env;
|
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) {
|
env.dc_http = function (mp, ml, up, ul, bp, bl) {
|
||||||
var body = bl > 0 ? bytes(bp, bl).slice() : null;
|
var body = bl > 0 ? bytes(bp, bl).slice() : null;
|
||||||
return httpStart(str(mp, ml), str(up, ul), body);
|
return httpStart(str(mp, ml), str(up, ul), body);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// DungeonCraft service worker: makes the game installable and playable offline.
|
// DungeonCraft service worker: makes the game installable and playable offline.
|
||||||
// Network first (updates arrive at once), the cache is the fallback when offline.
|
// Network first (updates arrive at once), the cache is the fallback when offline.
|
||||||
// The savegame service (/api/) is never cached.
|
// The savegame service (/api/) is never cached.
|
||||||
const CACHE = "dungeoncraft-v1";
|
const CACHE = "dungeoncraft-v2";
|
||||||
const CORE = [
|
const CORE = [
|
||||||
"./",
|
"./",
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -28,7 +28,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const req = event.request;
|
const req = event.request;
|
||||||
const url = new URL(req.url);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
|
|||||||
Reference in new issue
Block a user