526 lines
16 KiB
Rust
526 lines
16 KiB
Rust
use std::{collections::HashMap, sync::Arc};
|
|
|
|
use async_std::sync::RwLock;
|
|
use chrono::{DateTime, Duration, TimeDelta, Utc};
|
|
use mime::Mime;
|
|
use result_extended::{error, fatal, ok, result_as_fatal, return_error, ResultExt};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
|
use typeshare::typeshare;
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
asset_db::{self, AssetId, Assets},
|
|
database::{CharacterId, Database, GameId, SessionId, UserId},
|
|
types::AccountState,
|
|
types::{AppError, FatalError, GameOverview, Message, Rgb, Tabletop, User, UserOverview},
|
|
};
|
|
|
|
const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb {
|
|
red: 0xca,
|
|
green: 0xb9,
|
|
blue: 0xbb,
|
|
};
|
|
|
|
#[derive(Clone, Serialize)]
|
|
#[typeshare]
|
|
pub struct Status {
|
|
pub ok: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(tag = "type", content = "content")]
|
|
#[typeshare]
|
|
pub enum AuthResponse {
|
|
Success(SessionId),
|
|
PasswordReset(SessionId),
|
|
Locked,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct WebsocketClient {
|
|
sender: Option<UnboundedSender<Message>>,
|
|
}
|
|
|
|
pub struct AppState {
|
|
pub asset_store: Box<dyn Assets + Sync + Send + 'static>,
|
|
pub db: Box<dyn Database + Sync + Send + 'static>,
|
|
pub clients: HashMap<String, WebsocketClient>,
|
|
|
|
pub tabletop: Tabletop,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Core(Arc<RwLock<AppState>>);
|
|
|
|
impl Core {
|
|
pub fn new<A, DB>(assetdb: A, db: DB) -> Self
|
|
where
|
|
A: Assets + Sync + Send + 'static,
|
|
DB: Database + Sync + Send + 'static,
|
|
{
|
|
Self(Arc::new(RwLock::new(AppState {
|
|
asset_store: Box::new(assetdb),
|
|
db: Box::new(db),
|
|
clients: HashMap::new(),
|
|
tabletop: Tabletop {
|
|
background_color: DEFAULT_BACKGROUND_COLOR,
|
|
background_image: None,
|
|
},
|
|
})))
|
|
}
|
|
|
|
pub async fn status(&self) -> ResultExt<Status, AppError, FatalError> {
|
|
/*
|
|
let state = self.0.write().await;
|
|
let admin_user = return_error!(match state.db.user(&UserId::from("admin")).await {
|
|
Ok(Some(admin_user)) => ok(admin_user),
|
|
Ok(None) => {
|
|
return ok(Status {
|
|
admin_enabled: false,
|
|
});
|
|
}
|
|
Err(err) => fatal(err),
|
|
});
|
|
|
|
ok(Status {
|
|
ok: !admin_user.password.is_empty(),
|
|
})
|
|
*/
|
|
ok(Status { ok: true })
|
|
}
|
|
|
|
pub async fn register_client(&self) -> String {
|
|
let mut state = self.0.write().await;
|
|
let uuid = Uuid::new_v4().simple().to_string();
|
|
|
|
let client = WebsocketClient { sender: None };
|
|
|
|
state.clients.insert(uuid.clone(), client);
|
|
uuid
|
|
}
|
|
|
|
pub async fn unregister_client(&self, client_id: String) {
|
|
let mut state = self.0.write().await;
|
|
let _ = state.clients.remove(&client_id);
|
|
}
|
|
|
|
pub async fn connect_client(&self, client_id: String) -> UnboundedReceiver<Message> {
|
|
let mut state = self.0.write().await;
|
|
|
|
match state.clients.get_mut(&client_id) {
|
|
Some(client) => {
|
|
let (tx, rx) = unbounded_channel();
|
|
client.sender = Some(tx);
|
|
rx
|
|
}
|
|
None => {
|
|
unimplemented!();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn user_by_username(
|
|
&self,
|
|
username: &str,
|
|
) -> ResultExt<Option<User>, AppError, FatalError> {
|
|
let state = self.0.read().await;
|
|
match state.db.user_by_username(username).await {
|
|
Ok(Some(user_row)) => ok(Some(User::from(user_row))),
|
|
Ok(None) => ok(None),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
|
|
pub async fn list_users(&self) -> ResultExt<Vec<UserOverview>, AppError, FatalError> {
|
|
let users = self.0.write().await.db.users().await;
|
|
match users {
|
|
Ok(users) => ok(users
|
|
.into_iter()
|
|
.map(|user| UserOverview {
|
|
id: user.id,
|
|
name: user.name,
|
|
state: user.state,
|
|
is_admin: user.admin,
|
|
})
|
|
.collect()),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
|
|
pub async fn user(
|
|
&self,
|
|
user_id: UserId,
|
|
) -> ResultExt<Option<UserOverview>, AppError, FatalError> {
|
|
let users = return_error!(self.list_users().await);
|
|
match users.into_iter().find(|user| user.id == user_id) {
|
|
Some(user) => ok(Some(user)),
|
|
None => return ok(None),
|
|
}
|
|
}
|
|
|
|
pub async fn create_user(&self, username: &str) -> ResultExt<UserId, AppError, FatalError> {
|
|
let state = self.0.read().await;
|
|
match return_error!(self.user_by_username(username).await) {
|
|
Some(_) => error(AppError::UsernameUnavailable),
|
|
None => match state
|
|
.db
|
|
.create_user(username, "", false, AccountState::PasswordReset(Utc::now() + Duration::minutes(60)))
|
|
.await
|
|
{
|
|
Ok(user_id) => ok(user_id),
|
|
Err(err) => fatal(err),
|
|
},
|
|
}
|
|
}
|
|
|
|
pub async fn disable_user(&self, _userid: UserId) -> ResultExt<(), AppError, FatalError> {
|
|
unimplemented!();
|
|
}
|
|
|
|
pub async fn list_games(&self) -> ResultExt<Vec<GameOverview>, AppError, FatalError> {
|
|
let games = self.0.read().await.db.games().await;
|
|
match games {
|
|
// Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()),
|
|
Ok(games) => ok(games
|
|
.into_iter()
|
|
.map(|game| GameOverview {
|
|
id: game.id,
|
|
type_: "".to_owned(),
|
|
name: game.name,
|
|
gm: game.gm,
|
|
players: game.players,
|
|
})
|
|
.collect::<Vec<GameOverview>>()),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
|
|
pub async fn create_game(
|
|
&self,
|
|
gm: &UserId,
|
|
game_type: &str,
|
|
game_name: &str,
|
|
) -> ResultExt<GameId, AppError, FatalError> {
|
|
let state = self.0.read().await;
|
|
match state.db.create_game(gm, game_type, game_name).await {
|
|
Ok(game_id) => ok(game_id),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
|
|
pub async fn tabletop(&self) -> Tabletop {
|
|
self.0.read().await.tabletop.clone()
|
|
}
|
|
|
|
pub async fn get_asset(
|
|
&self,
|
|
asset_id: AssetId,
|
|
) -> ResultExt<(Mime, Vec<u8>), AppError, FatalError> {
|
|
ResultExt::from(
|
|
self.0
|
|
.read()
|
|
.await
|
|
.asset_store
|
|
.get(asset_id.clone())
|
|
.map_err(|err| match err {
|
|
asset_db::Error::NotFound => AppError::NotFound(format!("{}", asset_id)),
|
|
asset_db::Error::Inaccessible => {
|
|
AppError::Inaccessible(format!("{}", asset_id))
|
|
}
|
|
asset_db::Error::Unexpected(err) => AppError::Inaccessible(format!("{}", err)),
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub async fn available_images(&self) -> Vec<AssetId> {
|
|
self.0
|
|
.read()
|
|
.await
|
|
.asset_store
|
|
.assets()
|
|
.filter_map(
|
|
|(asset_id, value)| match mime_guess::from_path(value).first() {
|
|
Some(mime) if mime.type_() == mime::IMAGE => Some(asset_id.clone()),
|
|
_ => None,
|
|
},
|
|
)
|
|
.collect()
|
|
}
|
|
|
|
pub async fn set_background_image(
|
|
&self,
|
|
asset: AssetId,
|
|
) -> ResultExt<(), AppError, FatalError> {
|
|
let tabletop = {
|
|
let mut state = self.0.write().await;
|
|
state.tabletop.background_image = Some(asset.clone());
|
|
state.tabletop.clone()
|
|
};
|
|
self.publish(Message::UpdateTabletop(tabletop)).await;
|
|
ok(())
|
|
}
|
|
|
|
pub async fn get_charsheet(
|
|
&self,
|
|
id: CharacterId,
|
|
) -> ResultExt<Option<serde_json::Value>, AppError, FatalError> {
|
|
let state = self.0.write().await;
|
|
let cr = state.db.character(&id).await;
|
|
match cr {
|
|
Ok(Some(row)) => ok(Some(row.data)),
|
|
Ok(None) => ok(None),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
|
|
pub async fn publish(&self, message: Message) {
|
|
let state = self.0.read().await;
|
|
|
|
state.clients.values().for_each(|client| {
|
|
if let Some(ref sender) = client.sender {
|
|
let _ = sender.send(message.clone());
|
|
}
|
|
});
|
|
}
|
|
|
|
pub async fn save_user(
|
|
&self,
|
|
id: UserId,
|
|
name: &str,
|
|
password: &str,
|
|
admin: bool,
|
|
account_state: AccountState,
|
|
) -> ResultExt<UserId, AppError, FatalError> {
|
|
let state = self.0.read().await;
|
|
match state
|
|
.db
|
|
.save_user(User {
|
|
id,
|
|
name: name.to_owned(),
|
|
password: password.to_owned(),
|
|
admin,
|
|
state: account_state,
|
|
})
|
|
.await
|
|
{
|
|
Ok(uuid) => ok(uuid),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
|
|
pub async fn set_password(
|
|
&self,
|
|
uuid: UserId,
|
|
password: String,
|
|
) -> ResultExt<(), AppError, FatalError> {
|
|
let state = self.0.write().await;
|
|
let user = match state.db.user(&uuid).await {
|
|
Ok(Some(row)) => row,
|
|
Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())),
|
|
Err(err) => return fatal(err),
|
|
};
|
|
match state
|
|
.db
|
|
.save_user(User {
|
|
password,
|
|
state: AccountState::Normal,
|
|
..user
|
|
})
|
|
.await
|
|
{
|
|
Ok(_) => ok(()),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
|
|
pub async fn auth(
|
|
&self,
|
|
username: &str,
|
|
password: &str,
|
|
) -> ResultExt<AuthResponse, AppError, FatalError> {
|
|
let now = Utc::now();
|
|
let state = self.0.read().await;
|
|
let user = state.db.user_by_username(username).await.unwrap().unwrap();
|
|
let user_info = return_error!(match state.db.user_by_username(username).await {
|
|
Ok(Some(row)) if row.password == password => ok(row),
|
|
Ok(_) => error(AppError::AuthFailed),
|
|
Err(err) => fatal(err),
|
|
});
|
|
|
|
match user_info.state {
|
|
AccountState::Normal => result_as_fatal(state.db.create_session(&user_info.id).await)
|
|
.map(|session_id| AuthResponse::Success(session_id)),
|
|
|
|
AccountState::PasswordReset(exp) => {
|
|
if exp < now {
|
|
error(AppError::AuthFailed)
|
|
} else {
|
|
result_as_fatal(state.db.create_session(&user_info.id).await)
|
|
.map(|session_id| AuthResponse::PasswordReset(session_id))
|
|
}
|
|
}
|
|
|
|
AccountState::Locked => ok(AuthResponse::Locked),
|
|
}
|
|
}
|
|
|
|
pub async fn session(
|
|
&self,
|
|
session_id: &SessionId,
|
|
) -> ResultExt<Option<User>, AppError, FatalError> {
|
|
let state = self.0.read().await;
|
|
match state.db.session(session_id).await {
|
|
Ok(Some(user_row)) => ok(Some(User::from(user_row))),
|
|
Ok(None) => ok(None),
|
|
Err(fatal_error) => fatal(fatal_error),
|
|
}
|
|
}
|
|
|
|
pub async fn delete_session(&self, session_id: &SessionId) -> ResultExt<(), AppError, FatalError> {
|
|
let state = self.0.read().await;
|
|
match state.db.delete_session(session_id).await {
|
|
Ok(_) => ok(()),
|
|
Err(err) => fatal(err),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_expiration_date() -> DateTime<Utc> {
|
|
Utc::now() + TimeDelta::days(365)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use std::path::PathBuf;
|
|
|
|
use super::*;
|
|
|
|
use cool_asserts::assert_matches;
|
|
|
|
use crate::{asset_db::mocks::MemoryAssets, database::DbConn};
|
|
|
|
async fn test_core() -> Core {
|
|
let assets = MemoryAssets::new(vec![
|
|
(
|
|
AssetId::from("asset_1"),
|
|
"asset_1.png".to_owned(),
|
|
String::from("abcdefg").into_bytes(),
|
|
),
|
|
(
|
|
AssetId::from("asset_2"),
|
|
"asset_2.jpg".to_owned(),
|
|
String::from("abcdefg").into_bytes(),
|
|
),
|
|
(
|
|
AssetId::from("asset_3"),
|
|
"asset_3".to_owned(),
|
|
String::from("abcdefg").into_bytes(),
|
|
),
|
|
(
|
|
AssetId::from("asset_4"),
|
|
"asset_4".to_owned(),
|
|
String::from("abcdefg").into_bytes(),
|
|
),
|
|
(
|
|
AssetId::from("asset_5"),
|
|
"asset_5".to_owned(),
|
|
String::from("abcdefg").into_bytes(),
|
|
),
|
|
]);
|
|
let memory_db: Option<PathBuf> = None;
|
|
let conn = DbConn::new(memory_db);
|
|
conn.create_user("admin", "aoeu", true, AccountState::Normal)
|
|
.await
|
|
.unwrap();
|
|
conn.create_user(
|
|
"gm_1",
|
|
"aoeu",
|
|
false,
|
|
AccountState::PasswordReset(Utc::now()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
Core::new(assets, conn)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn it_lists_available_images() {
|
|
let core = test_core().await;
|
|
let image_paths = core.available_images().await;
|
|
assert_eq!(image_paths.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn it_retrieves_an_asset() {
|
|
let core = test_core().await;
|
|
assert_matches!(core.get_asset(AssetId::from("asset_1")).await, ResultExt::Ok((mime, data)) => {
|
|
assert_eq!(mime.type_(), mime::IMAGE);
|
|
assert_eq!(data, "abcdefg".as_bytes());
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn it_can_retrieve_the_default_tabletop() {
|
|
let core = test_core().await;
|
|
assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => {
|
|
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
|
assert_eq!(background_image, None);
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn it_can_change_the_tabletop_background() {
|
|
let core = test_core().await;
|
|
assert_matches!(
|
|
core.set_background_image(AssetId::from("asset_1")).await,
|
|
ResultExt::Ok(())
|
|
);
|
|
assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => {
|
|
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
|
assert_eq!(background_image, Some(AssetId::from("asset_1")));
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn it_sends_notices_to_clients_on_tabletop_change() {
|
|
let core = test_core().await;
|
|
let client_id = core.register_client().await;
|
|
let mut receiver = core.connect_client(client_id).await;
|
|
|
|
assert_matches!(
|
|
core.set_background_image(AssetId::from("asset_1")).await,
|
|
ResultExt::Ok(())
|
|
);
|
|
match receiver.recv().await {
|
|
Some(Message::UpdateTabletop(Tabletop {
|
|
background_color,
|
|
background_image,
|
|
})) => {
|
|
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
|
assert_eq!(background_image, Some(AssetId::from("asset_1")));
|
|
}
|
|
None => panic!("receiver did not get a message"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn it_creates_a_sessionid_on_successful_auth() {
|
|
let core = test_core().await;
|
|
match core.auth("admin", "aoeu").await {
|
|
ResultExt::Ok(AuthResponse::Success(session_id)) => {
|
|
let st = core.0.read().await;
|
|
match st.db.session(&session_id).await {
|
|
Ok(Some(user_row)) => assert_eq!(user_row.name, "admin"),
|
|
Ok(None) => panic!("no matching user row for the session id"),
|
|
Err(err) => panic!("{}", err),
|
|
}
|
|
}
|
|
ResultExt::Ok(AuthResponse::PasswordReset(_)) => panic!("user is in password reset state"),
|
|
ResultExt::Ok(AuthResponse::Locked) => panic!("user has been locked"),
|
|
ResultExt::Err(err) => panic!("{}", err),
|
|
ResultExt::Fatal(err) => panic!("{}", err),
|
|
}
|
|
}
|
|
}
|