From af0ab5d020f8c42198915b4dac680d8db4fc6ec4 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Mon, 16 Dec 2024 00:27:55 -0500 Subject: [PATCH] Create a status endpoint that shows the onboarding UI if there's no admin password --- result-extended/src/lib.rs | 2 +- visions/server/migrations/01-charsheet/up.sql | 12 --- .../{02-users => 01-initial-db}/up.sql | 16 ++++ visions/server/src/core.rs | 31 ++++++- visions/server/src/database.rs | 91 +++++++++++++------ visions/server/src/handlers.rs | 14 ++- visions/server/src/main.rs | 12 ++- visions/ui/src/App.tsx | 4 +- visions/ui/src/client.ts | 6 ++ visions/ui/src/components/StateProvider.tsx | 28 ++++-- .../views/Authentication/Authentication.tsx | 6 +- 11 files changed, 167 insertions(+), 55 deletions(-) delete mode 100644 visions/server/migrations/01-charsheet/up.sql rename visions/server/migrations/{02-users => 01-initial-db}/up.sql (53%) diff --git a/result-extended/src/lib.rs b/result-extended/src/lib.rs index dc8736a..e4aba62 100644 --- a/result-extended/src/lib.rs +++ b/result-extended/src/lib.rs @@ -99,7 +99,7 @@ impl ResultExt { } } -/// Convert from a normal `Result` type to a `Result` type. The error condition for a `Result` will +/// Convert from a normal `Result` type to a `ResultExt` type. The error condition for a `Result` will /// be treated as `Result::Err`, never `Result::Fatal`. impl From> for ResultExt { fn from(r: std::result::Result) -> Self { diff --git a/visions/server/migrations/01-charsheet/up.sql b/visions/server/migrations/01-charsheet/up.sql deleted file mode 100644 index 7f538ec..0000000 --- a/visions/server/migrations/01-charsheet/up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE games( - uuid TEXT PRIMARY KEY, - name TEXT -); - -CREATE TABLE characters( - uuid TEXT PRIMARY KEY, - game TEXT, - data TEXT, - - FOREIGN KEY(game) REFERENCES games(uuid) -); diff --git a/visions/server/migrations/02-users/up.sql b/visions/server/migrations/01-initial-db/up.sql similarity index 53% rename from visions/server/migrations/02-users/up.sql rename to visions/server/migrations/01-initial-db/up.sql index e8043e8..3e822af 100644 --- a/visions/server/migrations/02-users/up.sql +++ b/visions/server/migrations/01-initial-db/up.sql @@ -6,6 +6,19 @@ CREATE TABLE users( enabled BOOLEAN ); +CREATE TABLE games( + uuid TEXT PRIMARY KEY, + name TEXT +); + +CREATE TABLE characters( + uuid TEXT PRIMARY KEY, + game TEXT, + data TEXT, + + FOREIGN KEY(game) REFERENCES games(uuid) +); + CREATE TABLE roles( user_id TEXT, game_id TEXT, @@ -14,3 +27,6 @@ CREATE TABLE roles( FOREIGN KEY(user_id) REFERENCES users(uuid), FOREIGN KEY(game_id) REFERENCES games(uuid) ); + +INSERT INTO users VALUES ("admin", "admin", "", true, true); + diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 158be86..f86e8ec 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -2,8 +2,10 @@ use std::{collections::HashMap, sync::Arc}; use async_std::sync::RwLock; use mime::Mime; -use result_extended::{fatal, ok, ResultExt}; +use result_extended::{fatal, ok, return_error, ResultExt}; +use serde::Serialize; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; +use typeshare::typeshare; use uuid::Uuid; use crate::{ @@ -18,6 +20,12 @@ const DEFAULT_BACKGROUND_COLOR: RGB = RGB { blue: 0xbb, }; +#[derive(Clone, Serialize)] +#[typeshare] +pub struct Status { + admin_enabled: bool, +} + #[derive(Debug)] struct WebsocketClient { sender: Option>, @@ -51,6 +59,27 @@ 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 => { + return ok(Status { + admin_enabled: false, + }); + } + }; + + ok(Status { + admin_enabled: !admin_user.password.is_empty(), + }) + } + pub async fn register_client(&self) -> String { let mut state = self.0.write().await; let uuid = Uuid::new_v4().simple().to_string(); diff --git a/visions/server/src/database.rs b/visions/server/src/database.rs index be4b75b..144c576 100644 --- a/visions/server/src/database.rs +++ b/visions/server/src/database.rs @@ -5,7 +5,10 @@ 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::{ + types::{FromSql, FromSqlResult, ValueRef}, + Connection, +}; use rusqlite_migration::Migrations; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -30,6 +33,7 @@ pub enum Error { enum Request { Charsheet(CharacterId), Games, + User(UserId), Users, } @@ -43,6 +47,7 @@ struct DatabaseRequest { enum DatabaseResponse { Charsheet(Option), Games(Vec), + User(Option), Users(Vec), } @@ -179,6 +184,11 @@ pub struct CharsheetRow { #[async_trait] pub trait Database: Send + Sync { + async fn user( + &mut self, + _: UserId, + ) -> result_extended::ResultExt, Error, FatalError>; + async fn users(&mut self) -> result_extended::ResultExt, Error, FatalError>; async fn games(&mut self) -> result_extended::ResultExt, Error, FatalError>; @@ -193,6 +203,7 @@ pub struct DiskDb { conn: Connection, } +/* fn setup_test_database(conn: &Connection) -> Result<(), FatalError> { let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap(); let mut count = gamecount_stmt.query([]).unwrap(); @@ -236,6 +247,7 @@ fn setup_test_database(conn: &Connection) -> Result<(), FatalError> { Ok(()) } +*/ impl DiskDb { pub fn new

(path: Option

) -> Result @@ -251,30 +263,36 @@ impl DiskDb { .to_latest(&mut conn) .map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?; - setup_test_database(&conn)?; + // setup_test_database(&conn)?; Ok(DiskDb { conn }) } fn users(&self) -> Result, FatalError> { - let mut stmt = self.conn.prepare("SELECT * FROM USERS") + 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(), + 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(); + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); Ok(items) } fn user(&self, id: UserId) -> Result, FatalError> { let mut stmt = self .conn - .prepare("SELECT uuid, name, password, admin, enabled WHERE uuid=?") + .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; let items: Vec = stmt .query_map([id.as_str()], |row| { @@ -329,11 +347,7 @@ impl DiskDb { } } - fn save_game( - &self, - game_id: Option, - name: &str, - ) -> Result { + fn save_game(&self, game_id: Option, name: &str) -> Result { match game_id { None => { let game_id = GameId::new(); @@ -341,19 +355,15 @@ impl DiskDb { .conn .prepare("INSERT INTO games VALUES (?, ?)") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((game_id.as_str(), name)) - .unwrap(); + stmt.execute((game_id.as_str(), name)).unwrap(); Ok(game_id) } Some(game_id) => { let mut stmt = self .conn - .prepare( - "UPDATE games SET name=? WHERE uuid=?", - ) + .prepare("UPDATE games SET name=? WHERE uuid=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((name, game_id.as_str())) - .unwrap(); + stmt.execute((name, game_id.as_str())).unwrap(); Ok(game_id) } } @@ -432,6 +442,15 @@ async fn db_handler(db: DiskDb, requestor: Receiver) { Request::Games => { unimplemented!(); } + Request::User(uid) => { + let user = db.user(uid); + match user { + Ok(user) => { + tx.send(DatabaseResponse::User(user)).await.unwrap(); + } + err => panic!("{:?}", err), + } + } Request::Users => { let users = db.users(); match users { @@ -469,6 +488,26 @@ impl DbConn { #[async_trait] impl Database for DbConn { + async fn user(&mut self, uid: UserId) -> ResultExt, Error, FatalError> { + let (tx, rx) = bounded::(1); + + let request = DatabaseRequest { + tx, + req: Request::User(uid), + }; + + match self.conn.send(request).await { + Ok(()) => (), + Err(_) => return fatal(FatalError::DatabaseConnectionLost), + }; + + match rx.recv().await { + Ok(DatabaseResponse::User(user)) => ok(user), + Ok(_) => fatal(FatalError::MessageMismatch), + Err(_) => error(Error::NoResponse), + } + } + async fn users(&mut self) -> ResultExt, Error, FatalError> { let (tx, rx) = bounded::(1); @@ -559,9 +598,7 @@ mod test { assert_matches!(db.character(CharacterId::from("1")), Ok(None)); let js: serde_json::Value = serde_json::from_str(soren).unwrap(); - let soren_id = db - .save_character(None, game_id, js.clone()) - .unwrap(); + let soren_id = db.save_character(None, game_id, js.clone()).unwrap(); assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); } diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers.rs index 548eba6..5f43c1f 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers.rs @@ -56,11 +56,23 @@ where } } +pub async fn handle_server_status(core: Core) -> impl Reply { + handler(async move { + let status = return_error!(core.status().await); + ok(Response::builder() + .header("Access-Control-Allow-Origin", "*") + .header("Content-Type", "application/json") + .body(serde_json::to_vec(&status).unwrap()) + .unwrap()) + }) + .await +} + pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply { handler(async move { let (mime, bytes) = return_error!(core.get_asset(asset_id).await); ok(Response::builder() - .header("application-type", mime.to_string()) + .header("content-type", mime.to_string()) .body(bytes) .unwrap()) }) diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 71376ff..2f3a868 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_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_background_image, handle_unregister_client, RegisterRequest }; use warp::{ // header, @@ -104,6 +104,13 @@ 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") + .and(warp::get()) + .then({ + let core = core.clone(); + move || handle_server_status(core.clone()) + }); + let route_image = warp::path!("api" / "v1" / "image" / String) .and(warp::get()) .then({ @@ -174,7 +181,8 @@ pub async fn main() { move |charid| handle_get_charsheet(core.clone(), charid) }); - let filter = route_register_client + let filter = server_status + .or(route_register_client) .or(route_unregister_client) .or(route_websocket) .or(route_image) diff --git a/visions/ui/src/App.tsx b/visions/ui/src/App.tsx index 635f326..59220e2 100644 --- a/visions/ui/src/App.tsx +++ b/visions/ui/src/App.tsx @@ -9,6 +9,7 @@ import { PlayerView } from './views/PlayerView/PlayerView'; import { Admin } from './views/Admin/Admin'; import Candela from './plugins/Candela'; import { Authentication } from './views/Authentication/Authentication'; +import { StateProvider } from './components/StateProvider'; const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; @@ -38,7 +39,8 @@ const App = ({ client }: AppProps) => { createBrowserRouter([ { path: "/", - element: websocketUrl ? :

+ element: + }, { path: "/gm", diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 3b8473f..4656c55 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -9,6 +9,12 @@ 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`; diff --git a/visions/ui/src/components/StateProvider.tsx b/visions/ui/src/components/StateProvider.tsx index 0d9f1fb..990b0ba 100644 --- a/visions/ui/src/components/StateProvider.tsx +++ b/visions/ui/src/components/StateProvider.tsx @@ -1,5 +1,6 @@ -import React, { createContext, PropsWithChildren, useEffect, useReducer } from "react"; -import { Tabletop } from "visions-types"; +import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer } 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 }; @@ -10,7 +11,7 @@ type TabletopState = { } type StateAction = { type: "SetAuthState", state: AuthState } - | { type: "HandleMessage" }; + | { type: "HandleMessage" }; const initialState = (): TabletopState => ( { @@ -21,12 +22,27 @@ const initialState = (): TabletopState => ( export const AppContext = createContext(initialState()); -interface StateProviderProps { } +interface StateProviderProps { client: Client; } -export const StateProvider = ({ children }: PropsWithChildren) => { +export const StateProvider = ({ client, children }: PropsWithChildren) => { + console.log("StateProvider"); const [state, dispatch] = useReducer(stateReducer, initialState()); - return + useEffect(() => { + console.log("useCallback"); + client.status().then((status: Status) => { + console.log("status: ", status); + if (status.admin_enabled) { + dispatch({ type: "SetAuthState", state: { type: "Unauthed" } }); + } else { + dispatch({ type: "SetAuthState", state: { type: "NoAdmin" } }); + } + }) + }, + [client] + ); + + return {children} ; } diff --git a/visions/ui/src/views/Authentication/Authentication.tsx b/visions/ui/src/views/Authentication/Authentication.tsx index 0315a8e..743ca1b 100644 --- a/visions/ui/src/views/Authentication/Authentication.tsx +++ b/visions/ui/src/views/Authentication/Authentication.tsx @@ -1,14 +1,12 @@ -import React, { PropsWithChildren, ReactNode, useContext, useEffect, useState } from 'react'; -import { Client } from '../../client'; +import React, { PropsWithChildren, useContext } from 'react'; import { AppContext } from '../../components/StateProvider'; import { assertNever } from '../../plugins/Candela'; import './Authentication.css'; interface AuthenticationProps { - client: Client; } -export const Authentication = ({ client, children }: PropsWithChildren) => { +export const Authentication = ({ children }: PropsWithChildren) => { // No admin password set: prompt for the admin password // Password set, nobody logged in: prompt for login // User logged in: show the children