diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index f86e8ec..79fbda9 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use async_std::sync::RwLock; use mime::Mime; -use result_extended::{fatal, ok, return_error, ResultExt}; +use result_extended::{error, fatal, ok, return_error, ResultExt}; use serde::Serialize; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use typeshare::typeshare; @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, - database::{CharacterId, Database, Error, UserId}, + database::{CharacterId, Database, UserId}, types::{AppError, FatalError, Game, Message, Tabletop, User, RGB}, }; @@ -23,7 +23,7 @@ const DEFAULT_BACKGROUND_COLOR: RGB = RGB { #[derive(Clone, Serialize)] #[typeshare] pub struct Status { - admin_enabled: bool, + pub admin_enabled: bool, } #[derive(Debug)] @@ -61,19 +61,15 @@ impl Core { pub async fn status(&self) -> ResultExt { let mut state = self.0.write().await; - let admin_user = match return_error!(state - .db - .user(UserId::from("admin")) - .await - .map_err(|_| AppError::Inaccessible("database stopped responding".to_owned()))) - { - Some(admin_user) => admin_user, - None => { + 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 { admin_enabled: !admin_user.password.is_empty(), @@ -113,29 +109,17 @@ impl Core { pub async fn list_users(&self) -> ResultExt, AppError, FatalError> { let users = self.0.write().await.db.users().await; match users { - ResultExt::Ok(users) => { - ResultExt::Ok(users.into_iter().map(|u| User::from(u)).collect()) - } - ResultExt::Err(err) => { - println!("Database error: {:?}", err); - ResultExt::Ok(vec![]) - } - ResultExt::Fatal(users) => ResultExt::Fatal(users), + Ok(users) => ok(users.into_iter().map(|u| User::from(u)).collect()), + Err(err) => fatal(err), } } pub async fn list_games(&self) -> ResultExt, AppError, FatalError> { let games = self.0.write().await.db.games().await; match games { - ResultExt::Ok(games) => { - // ResultExt::Ok(games.into_iter().map(|u| Game::from(u)).collect()) - unimplemented!(); - } - ResultExt::Err(err) => { - println!("Database error: {:?}", err); - ResultExt::Ok(vec![]) - } - ResultExt::Fatal(games) => ResultExt::Fatal(games), + // Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()), + Ok(games) => unimplemented!(), + Err(err) => fatal(err), } } @@ -199,10 +183,11 @@ impl Core { ) -> ResultExt, AppError, FatalError> { let mut state = self.0.write().await; let cr = state.db.character(id).await; - cr.map(|cr| cr.map(|cr| cr.data)).or_else(|err| { - println!("Database error: {:?}", err); - ResultExt::Ok(None) - }) + match cr { + Ok(Some(row)) => ok(Some(row.data)), + Ok(None) => ok(None), + Err(err) => fatal(err), + } } pub async fn publish(&self, message: Message) { @@ -214,6 +199,27 @@ impl Core { } }); } + + pub async fn set_password( + &self, + uuid: UserId, + password: String, + ) -> ResultExt<(), AppError, FatalError> { + let mut state = self.0.write().await; + let user = match state.db.user(uuid.clone()).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(Some(uuid), &user.name, &password, user.admin, user.enabled) + .await + { + Ok(_) => ok(()), + Err(err) => fatal(err), + } + } } #[cfg(test)] diff --git a/visions/server/src/database.rs b/visions/server/src/database.rs index 144c576..25b27b7 100644 --- a/visions/server/src/database.rs +++ b/visions/server/src/database.rs @@ -4,14 +4,12 @@ use async_std::channel::{bounded, Receiver, Sender}; use async_trait::async_trait; use include_dir::{include_dir, Dir}; use lazy_static::lazy_static; -use result_extended::{error, fatal, ok, return_error, ResultExt}; use rusqlite::{ types::{FromSql, FromSqlResult, ValueRef}, Connection, }; use rusqlite_migration::Migrations; use serde::{Deserialize, Serialize}; -use thiserror::Error; use uuid::Uuid; use crate::types::FatalError; @@ -23,18 +21,13 @@ lazy_static! { Migrations::from_directory(&MIGRATIONS_DIR).unwrap(); } -#[derive(Debug, Error)] -pub enum Error { - #[error("No response to request")] - NoResponse, -} - #[derive(Debug)] enum Request { Charsheet(CharacterId), Games, User(UserId), Users, + SaveUser(Option, String, String, bool, bool), } #[derive(Debug)] @@ -49,6 +42,7 @@ enum DatabaseResponse { Games(Vec), User(Option), Users(Vec), + SaveUser(UserId), } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] @@ -184,19 +178,22 @@ pub struct CharsheetRow { #[async_trait] pub trait Database: Send + Sync { - async fn user( + async fn user(&mut self, _: UserId) -> Result, FatalError>; + + async fn save_user( &mut self, - _: UserId, - ) -> result_extended::ResultExt, Error, FatalError>; + user_id: Option, + name: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> Result; - async fn users(&mut self) -> result_extended::ResultExt, Error, FatalError>; + async fn users(&mut self) -> Result, FatalError>; - async fn games(&mut self) -> result_extended::ResultExt, Error, FatalError>; + async fn games(&mut self) -> Result, FatalError>; - async fn character( - &mut self, - id: CharacterId, - ) -> result_extended::ResultExt, Error, FatalError>; + async fn character(&mut self, id: CharacterId) -> Result, FatalError>; } pub struct DiskDb { @@ -268,27 +265,6 @@ impl DiskDb { Ok(DiskDb { conn }) } - fn users(&self) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT * FROM users") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items = stmt - .query_map([], |row| { - Ok(UserRow { - id: row.get(0).unwrap(), - name: row.get(1).unwrap(), - password: row.get(2).unwrap(), - admin: row.get(3).unwrap(), - enabled: row.get(4).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - Ok(items) - } - fn user(&self, id: UserId) -> Result, FatalError> { let mut stmt = self .conn @@ -314,6 +290,27 @@ impl DiskDb { } } + fn users(&self) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT * FROM users") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items = stmt + .query_map([], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + Ok(items) + } + fn save_user( &self, user_id: Option, @@ -337,7 +334,7 @@ impl DiskDb { let mut stmt = self .conn .prepare( - "UPDATE users SET name=?, password=?, admin=?, enbabled=? WHERE uuid=?", + "UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?", ) .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; stmt.execute((name, password, admin, enabled, user_id.as_str())) @@ -398,7 +395,7 @@ impl DiskDb { char_id: Option, game: GameId, character: serde_json::Value, - ) -> std::result::Result { + ) -> std::result::Result { match char_id { None => { let char_id = CharacterId::new(); @@ -451,6 +448,15 @@ async fn db_handler(db: DiskDb, requestor: Receiver) { err => panic!("{:?}", err), } } + Request::SaveUser(user_id, username, password, admin, enabled) => { + let user_id = db.save_user(user_id, username.as_ref(), password.as_ref(), admin, enabled); + match user_id { + Ok(user_id) => { + tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap(); + } + err => panic!("{:?}", err), + } + } Request::Users => { let users = db.users(); match users { @@ -488,7 +494,7 @@ impl DbConn { #[async_trait] impl Database for DbConn { - async fn user(&mut self, uid: UserId) -> ResultExt, Error, FatalError> { + async fn user(&mut self, uid: UserId) -> Result, FatalError> { let (tx, rx) = bounded::(1); let request = DatabaseRequest { @@ -498,17 +504,50 @@ impl Database for DbConn { match self.conn.send(request).await { Ok(()) => (), - Err(_) => return fatal(FatalError::DatabaseConnectionLost), + Err(_) => return Err(FatalError::DatabaseConnectionLost), }; match rx.recv().await { - Ok(DatabaseResponse::User(user)) => ok(user), - Ok(_) => fatal(FatalError::MessageMismatch), - Err(_) => error(Error::NoResponse), + Ok(DatabaseResponse::User(user)) => Ok(user), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), } } - async fn users(&mut self) -> ResultExt, Error, FatalError> { + async fn save_user( + &mut self, + user_id: Option, + name: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> Result { + let (tx, rx) = bounded::(1); + + let request = DatabaseRequest { + tx, + req: Request::SaveUser( + user_id, + name.to_owned(), + password.to_owned(), + admin, + enabled, + ), + }; + + match self.conn.send(request).await { + Ok(()) => (), + Err(_) => return Err(FatalError::DatabaseConnectionLost), + }; + + match rx.recv().await { + Ok(DatabaseResponse::SaveUser(user_id)) => Ok(user_id), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), + } + } + + async fn users(&mut self) -> Result, FatalError> { let (tx, rx) = bounded::(1); let request = DatabaseRequest { @@ -518,17 +557,17 @@ impl Database for DbConn { match self.conn.send(request).await { Ok(()) => (), - Err(_) => return fatal(FatalError::DatabaseConnectionLost), + Err(_) => return Err(FatalError::DatabaseConnectionLost), }; match rx.recv().await { - Ok(DatabaseResponse::Users(lst)) => ok(lst), - Ok(_) => fatal(FatalError::MessageMismatch), - Err(_) => error(Error::NoResponse), + Ok(DatabaseResponse::Users(lst)) => Ok(lst), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), } } - async fn games(&mut self) -> result_extended::ResultExt, Error, FatalError> { + async fn games(&mut self) -> Result, FatalError> { let (tx, rx) = bounded::(1); let request = DatabaseRequest { @@ -538,20 +577,17 @@ impl Database for DbConn { match self.conn.send(request).await { Ok(()) => (), - Err(_) => return fatal(FatalError::DatabaseConnectionLost), + Err(_) => return Err(FatalError::DatabaseConnectionLost), }; match rx.recv().await { - Ok(DatabaseResponse::Games(lst)) => ok(lst), - Ok(_) => fatal(FatalError::MessageMismatch), - Err(_) => error(Error::NoResponse), + Ok(DatabaseResponse::Games(lst)) => Ok(lst), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), } } - async fn character( - &mut self, - id: CharacterId, - ) -> ResultExt, Error, FatalError> { + async fn character(&mut self, id: CharacterId) -> Result, FatalError> { let (tx, rx) = bounded::(1); let request = DatabaseRequest { @@ -561,13 +597,13 @@ impl Database for DbConn { match self.conn.send(request).await { Ok(()) => (), - Err(_) => return fatal(FatalError::DatabaseConnectionLost), + Err(_) => return Err(FatalError::DatabaseConnectionLost), }; match rx.recv().await { - Ok(DatabaseResponse::Charsheet(row)) => ok(row), - Ok(_) => fatal(FatalError::MessageMismatch), - Err(_err) => error(Error::NoResponse), + Ok(DatabaseResponse::Charsheet(row)) => Ok(row), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), } } } diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers.rs index 5f43c1f..4392f3a 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers.rs @@ -1,14 +1,14 @@ use std::future::Future; use futures::{SinkExt, StreamExt}; -use result_extended::{ok, return_error, ResultExt}; +use result_extended::{error, ok, return_error, ResultExt}; use serde::{Deserialize, Serialize}; use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message}; use crate::{ asset_db::AssetId, core::Core, - database::CharacterId, + database::{CharacterId, UserId}, types::{AppError, FatalError}, }; @@ -237,3 +237,21 @@ pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply { }) .await } + +pub async fn handle_set_admin_password(core: Core, password: String) -> impl Reply { + handler(async move { + let status = return_error!(core.status().await); + if status.admin_enabled { + return error(AppError::PermissionDenied); + } + + core.set_password(UserId::from("admin"), password).await; + ok(Response::builder() + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "*") + .header("Content-Type", "application/json") + .body(vec![]) + .unwrap()) + }) + .await +} diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 2f3a868..894e2cc 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -8,7 +8,7 @@ use asset_db::{AssetId, FsAssets}; use authdb::AuthError; use database::DbConn; use handlers::{ - handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_background_image, handle_unregister_client, RegisterRequest + handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_admin_password, handle_set_background_image, handle_unregister_client, RegisterRequest }; use warp::{ // header, @@ -104,7 +104,7 @@ pub async fn main() { let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); let log = warp::log("visions::api"); - let server_status = warp::path!("api" / "v1" / "status") + let route_server_status = warp::path!("api" / "v1" / "status") .and(warp::get()) .then({ let core = core.clone(); @@ -181,7 +181,28 @@ pub async fn main() { move |charid| handle_get_charsheet(core.clone(), charid) }); - let filter = server_status + let route_set_admin_password_options = warp::path!("api" / "v1" / "admin_password") + .and(warp::options()) + .map({ + move || { + Response::builder() + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "PUT") + .header("Access-Control-Allow-Headers", "content-type") + .header("Content-Type", "application/json") + .body("") + .unwrap() + } + }); + let route_set_admin_password = warp::path!("api" / "v1" / "admin_password") + .and(warp::put()) + .and(warp::body::json()) + .then({ + let core = core.clone(); + move |body| handle_set_admin_password(core.clone(), body) + }); + + let filter = route_server_status .or(route_register_client) .or(route_unregister_client) .or(route_websocket) @@ -191,6 +212,8 @@ pub async fn main() { .or(route_set_bg_image) .or(route_get_users) .or(route_get_charsheet) + .or(route_set_admin_password_options) + .or(route_set_admin_password) .recover(handle_rejection); let server = warp::serve(filter); diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index c453537..d70e387 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -33,6 +33,9 @@ pub enum AppError { #[error("object inaccessible {0}")] Inaccessible(String), + #[error("the requested operation is not allowed")] + PermissionDenied, + #[error("invalid json {0}")] JsonError(serde_json::Error), diff --git a/visions/ui/src/App.tsx b/visions/ui/src/App.tsx index 373750c..4e26c74 100644 --- a/visions/ui/src/App.tsx +++ b/visions/ui/src/App.tsx @@ -32,10 +32,10 @@ interface AuthedViewProps { } const AuthedView = ({ client, children }: PropsWithChildren) => { - const [state, dispatch] = useContext(StateContext); + const [state, manager] = useContext(StateContext); return ( { - dispatch({type: "SetAdminPassword", password }); + manager.setAdminPassword(password); }} onAuth={(username, password) => console.log(username, password)}> {children} diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 4656c55..274ec77 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -9,12 +9,6 @@ export class Client { this.base = new URL("http://localhost:8001"); } - status() { - const url = new URL(this.base); - url.pathname = `/api/v1/status`; - return fetch(url).then((response) => response.json()); - } - registerWebsocket() { const url = new URL(this.base); url.pathname = `api/v1/client`; @@ -62,4 +56,18 @@ export class Client { url.pathname = `/api/v1/charsheet/${id}`; return fetch(url).then((response) => response.json()); } + + async setAdminPassword(password: string) { + const url = new URL(this.base); + url.pathname = `/api/v1/admin_password`; + console.log("setting the admin password to: ", password); + return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) }); + } + + async status() { + const url = new URL(this.base); + url.pathname = `/api/v1/status`; + return fetch(url).then((response) => response.json()); + } + } diff --git a/visions/ui/src/providers/StateProvider/StateProvider.tsx b/visions/ui/src/providers/StateProvider/StateProvider.tsx index 9968cdd..8806702 100644 --- a/visions/ui/src/providers/StateProvider/StateProvider.tsx +++ b/visions/ui/src/providers/StateProvider/StateProvider.tsx @@ -1,16 +1,16 @@ -import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer } from "react"; +import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react"; import { Status, Tabletop } from "visions-types"; import { Client } from "../../client"; import { assertNever } from "../../plugins/Candela"; -type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", username: string }; +type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string }; type AppState = { auth: AuthState; tabletop: Tabletop; } -type Action = { type: "SetAdminPassword", password: string } | { type: "Auth", username: string, password: string }; +type Action = { type: "SetAuthState", content: AuthState }; const initialState = (): AppState => ( { @@ -20,18 +20,51 @@ const initialState = (): AppState => ( ); const stateReducer = (state: AppState, action: Action): AppState => { - console.log("reducer: ", state, action); - return state; + return { ...state, auth: action.content } } -export const StateContext = createContext<[AppState, React.Dispatch]>([initialState(), () => { }]); +class StateManager { + client: Client | undefined; + dispatch: React.Dispatch | undefined; + + constructor(client: Client | undefined, dispatch: React.Dispatch | undefined) { + this.client = client; + this.dispatch = dispatch; + } + + async status() { + if (!this.client || !this.dispatch) return; + + const { admin_enabled } = await this.client.status(); + if (!admin_enabled) { + this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } }); + } else { + this.dispatch({ type: "SetAuthState", content: { type: "Unauthed" } }); + } + } + + async setAdminPassword(password: string) { + if (!this.client || !this.dispatch) return; + + await this.client.setAdminPassword(password); + await this.status(); + } +} + +export const StateContext = createContext<[AppState, StateManager]>([initialState(), new StateManager(undefined, undefined)]); interface StateProviderProps { client: Client; } export const StateProvider = ({ client, children }: PropsWithChildren) => { const [state, dispatch] = useReducer(stateReducer, initialState()); - return + const stateManager = useRef(new StateManager(client, dispatch)); + + useEffect(() => { + stateManager.current.status(); + }, [stateManager]); + + return {children} ; }