User accounts, login before the title screen, own savegames per user, standalone download
- Service: accounts with Argon2 password hashes and session tokens; the first login creates the admin (shared savegames of before move to the admin); the admin creates and deletes users; everybody can change the password; savegames are stored and listed per user - Game: login page before the title (only when the service is reachable), with licence and repository link; account page (password, logout; users for the admin); text fields with Tab/Enter, masked passwords, browser dialog on touch screens; long status messages wrap - Download Standalone: DungeonCraft.html with the game embedded (built in the Docker image, served to logged-in users via nginx auth_request); works from file:// without server and opens downloaded savegames - Without the service (standalone, local files) there is no login, savegame files as before Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8853d84564
commit
c9848c8948
15 files changed
+1232
-83
No files matched your search
+162
-4
@@ -4,7 +4,7 @@
|
||||
// `online` becomes false and the game falls back to savegame files and the browser autosave.
|
||||
|
||||
use crate::platform;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const API: &str = "api"; // relative, so the game also works below a sub path
|
||||
|
||||
@@ -37,22 +37,54 @@ enum Req {
|
||||
Save,
|
||||
Load,
|
||||
Delete,
|
||||
Login,
|
||||
Me,
|
||||
Logout,
|
||||
Password,
|
||||
Users,
|
||||
CreateUser,
|
||||
DeleteUser,
|
||||
}
|
||||
|
||||
// the logged-in account
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub token: String,
|
||||
pub name: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub name: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
const SESSION_KEY: &str = "dc.session";
|
||||
|
||||
pub enum CloudEvent {
|
||||
None,
|
||||
Saved(String), // name of the stored savegame
|
||||
Loaded(Vec<u8>), // savegame data
|
||||
Deleted,
|
||||
Error(&'static str), // language key
|
||||
LoggedIn, // login accepted (or the stored session is still valid)
|
||||
LoginNeeded, // the service is there but nobody is logged in
|
||||
LoginFailed(&'static str),
|
||||
PasswordChanged,
|
||||
UsersChanged,
|
||||
}
|
||||
|
||||
pub struct Cloud {
|
||||
pub online: Online,
|
||||
pub setup: bool, // no account yet: the first login creates the admin
|
||||
pub session: Option<Session>,
|
||||
pub list: Vec<SaveInfo>,
|
||||
pub listed: bool, // the list has been fetched at least once
|
||||
pub users: Vec<UserInfo>,
|
||||
pending: Option<(u32, Req)>,
|
||||
save_name: String,
|
||||
login_name: String,
|
||||
}
|
||||
|
||||
// percent-encoding for a query parameter
|
||||
@@ -71,6 +103,7 @@ fn encode(s: &str) -> String {
|
||||
fn error_key(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "cloud.err_net",
|
||||
401 => "login.err_session",
|
||||
413 => "cloud.err_large",
|
||||
429 => "cloud.err_rate",
|
||||
507 => "cloud.err_full",
|
||||
@@ -83,11 +116,57 @@ impl Cloud {
|
||||
// starts the availability check
|
||||
pub fn new() -> Cloud {
|
||||
let id = platform::http("GET", &format!("{}/health", API), &[]);
|
||||
Cloud { online: Online::Unknown, list: Vec::new(), listed: false, pending: Some((id, Req::Health)), save_name: String::new() }
|
||||
let session: Option<Session> = platform::storage_get(SESSION_KEY).and_then(|s| serde_json::from_str(&s).ok());
|
||||
if let Some(s) = &session {
|
||||
platform::set_token(&s.token);
|
||||
}
|
||||
Cloud {
|
||||
online: Online::Unknown,
|
||||
setup: false,
|
||||
session,
|
||||
list: Vec::new(),
|
||||
listed: false,
|
||||
users: Vec::new(),
|
||||
pending: Some((id, Req::Health)),
|
||||
save_name: String::new(),
|
||||
login_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// savegames on the server: the service is there and somebody is logged in
|
||||
pub fn available(&self) -> bool {
|
||||
self.online == Online::Yes
|
||||
self.online == Online::Yes && self.session.is_some()
|
||||
}
|
||||
|
||||
pub fn login(&mut self, name: &str, password: &str) -> bool {
|
||||
self.login_name = name.to_string();
|
||||
let body = serde_json::json!({ "name": name, "password": password }).to_string();
|
||||
self.start("POST", "login", body.as_bytes(), Req::Login)
|
||||
}
|
||||
|
||||
pub fn logout(&mut self) {
|
||||
self.start("POST", "logout", &[], Req::Logout);
|
||||
self.session = None;
|
||||
platform::set_token("");
|
||||
platform::storage_set(SESSION_KEY, "");
|
||||
}
|
||||
|
||||
pub fn change_password(&mut self, old: &str, new: &str) -> bool {
|
||||
let body = serde_json::json!({ "old": old, "new": new }).to_string();
|
||||
self.start("POST", "password", body.as_bytes(), Req::Password)
|
||||
}
|
||||
|
||||
pub fn load_users(&mut self) -> bool {
|
||||
self.start("GET", "users", &[], Req::Users)
|
||||
}
|
||||
|
||||
pub fn create_user(&mut self, name: &str, password: &str) -> bool {
|
||||
let body = serde_json::json!({ "name": name, "password": password }).to_string();
|
||||
self.start("POST", "users", body.as_bytes(), Req::CreateUser)
|
||||
}
|
||||
|
||||
pub fn delete_user(&mut self, name: &str) -> bool {
|
||||
self.start("DELETE", &format!("users/{}", encode(name)), &[], Req::DeleteUser)
|
||||
}
|
||||
|
||||
pub fn busy(&self) -> bool {
|
||||
@@ -132,7 +211,86 @@ impl Cloud {
|
||||
match req {
|
||||
Req::Health => {
|
||||
self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No };
|
||||
CloudEvent::None
|
||||
self.setup = res.body.ends_with(b"setup");
|
||||
if self.online != Online::Yes {
|
||||
return CloudEvent::None;
|
||||
}
|
||||
// a stored session: still valid?
|
||||
if self.session.is_some() && !self.setup {
|
||||
self.start("GET", "me", &[], Req::Me);
|
||||
return CloudEvent::None;
|
||||
}
|
||||
self.session = None;
|
||||
CloudEvent::LoginNeeded
|
||||
}
|
||||
Req::Me => {
|
||||
if ok {
|
||||
CloudEvent::LoggedIn
|
||||
} else if res.status == 0 {
|
||||
self.online = Online::No;
|
||||
CloudEvent::None
|
||||
} else {
|
||||
self.session = None;
|
||||
platform::set_token("");
|
||||
platform::storage_set(SESSION_KEY, "");
|
||||
CloudEvent::LoginNeeded
|
||||
}
|
||||
}
|
||||
Req::Login => {
|
||||
#[derive(Deserialize)]
|
||||
struct Answer {
|
||||
token: String,
|
||||
name: String,
|
||||
admin: bool,
|
||||
}
|
||||
match (ok, serde_json::from_slice::<Answer>(&res.body)) {
|
||||
(true, Ok(a)) => {
|
||||
let s = Session { token: a.token, name: a.name, admin: a.admin };
|
||||
platform::set_token(&s.token);
|
||||
platform::storage_set(SESSION_KEY, &serde_json::to_string(&s).unwrap_or_default());
|
||||
self.session = Some(s);
|
||||
self.setup = false;
|
||||
CloudEvent::LoggedIn
|
||||
}
|
||||
_ => CloudEvent::LoginFailed(match res.status {
|
||||
0 => "cloud.err_net",
|
||||
400 => "login.err_invalid",
|
||||
429 => "cloud.err_rate",
|
||||
_ => "login.err_wrong",
|
||||
}),
|
||||
}
|
||||
}
|
||||
Req::Logout => CloudEvent::None,
|
||||
Req::Password => {
|
||||
if ok {
|
||||
CloudEvent::PasswordChanged
|
||||
} else {
|
||||
CloudEvent::Error(match res.status {
|
||||
403 => "account.err_old",
|
||||
400 => "login.err_invalid",
|
||||
_ => error_key(res.status),
|
||||
})
|
||||
}
|
||||
}
|
||||
Req::Users => {
|
||||
if ok {
|
||||
self.users = serde_json::from_slice(&res.body).unwrap_or_default();
|
||||
CloudEvent::None
|
||||
} else {
|
||||
CloudEvent::Error(error_key(res.status))
|
||||
}
|
||||
}
|
||||
Req::CreateUser | Req::DeleteUser => {
|
||||
if ok {
|
||||
self.load_users();
|
||||
CloudEvent::UsersChanged
|
||||
} else {
|
||||
CloudEvent::Error(match res.status {
|
||||
409 => "account.err_exists",
|
||||
400 => "login.err_invalid",
|
||||
_ => error_key(res.status),
|
||||
})
|
||||
}
|
||||
}
|
||||
Req::List => {
|
||||
if !ok {
|
||||
|
||||
Reference in new issue
Block a user