Set the admin password on a new server

This sets up the client state manager and state model. It has all of the
functions to support the set admin password endpoint, and some extras
which will be helpful in saving users generally.
This commit is contained in:
Savanni D'Gerinel 2024-12-17 23:43:36 -05:00
parent f6a45a9223
commit 2a616ef6c9
8 changed files with 243 additions and 116 deletions

View File

@ -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<Status, AppError, FatalError> {
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<Vec<User>, 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<Vec<Game>, 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<Option<serde_json::Value>, 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)]

View File

@ -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<UserId>, String, String, bool, bool),
}
#[derive(Debug)]
@ -49,6 +42,7 @@ enum DatabaseResponse {
Games(Vec<GameRow>),
User(Option<UserRow>),
Users(Vec<UserRow>),
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<Option<UserRow>, FatalError>;
async fn save_user(
&mut self,
_: UserId,
) -> result_extended::ResultExt<Option<UserRow>, Error, FatalError>;
user_id: Option<UserId>,
name: &str,
password: &str,
admin: bool,
enabled: bool,
) -> Result<UserId, FatalError>;
async fn users(&mut self) -> result_extended::ResultExt<Vec<UserRow>, Error, FatalError>;
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError>;
async fn games(&mut self) -> result_extended::ResultExt<Vec<GameRow>, Error, FatalError>;
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError>;
async fn character(
&mut self,
id: CharacterId,
) -> result_extended::ResultExt<Option<CharsheetRow>, Error, FatalError>;
async fn character(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError>;
}
pub struct DiskDb {
@ -268,27 +265,6 @@ impl DiskDb {
Ok(DiskDb { conn })
}
fn users(&self) -> Result<Vec<UserRow>, 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::<Result<Vec<UserRow>, rusqlite::Error>>()
.unwrap();
Ok(items)
}
fn user(&self, id: UserId) -> Result<Option<UserRow>, FatalError> {
let mut stmt = self
.conn
@ -314,6 +290,27 @@ impl DiskDb {
}
}
fn users(&self) -> Result<Vec<UserRow>, 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::<Result<Vec<UserRow>, rusqlite::Error>>()
.unwrap();
Ok(items)
}
fn save_user(
&self,
user_id: Option<UserId>,
@ -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<CharacterId>,
game: GameId,
character: serde_json::Value,
) -> std::result::Result<CharacterId, Error> {
) -> std::result::Result<CharacterId, FatalError> {
match char_id {
None => {
let char_id = CharacterId::new();
@ -451,6 +448,15 @@ async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
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<Option<UserRow>, Error, FatalError> {
async fn user(&mut self, uid: UserId) -> Result<Option<UserRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(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<Vec<UserRow>, Error, FatalError> {
async fn save_user(
&mut self,
user_id: Option<UserId>,
name: &str,
password: &str,
admin: bool,
enabled: bool,
) -> Result<UserId, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(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<Vec<UserRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(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<Vec<GameRow>, Error, FatalError> {
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(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<Option<CharsheetRow>, Error, FatalError> {
async fn character(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(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),
}
}
}

View File

@ -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
}

View File

@ -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);

View File

@ -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),

View File

@ -32,10 +32,10 @@ interface AuthedViewProps {
}
const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => {
const [state, dispatch] = useContext(StateContext);
const [state, manager] = useContext(StateContext);
return (
<Authentication onAdminPassword={(password) => {
dispatch({type: "SetAdminPassword", password });
manager.setAdminPassword(password);
}} onAuth={(username, password) => console.log(username, password)}>
{children}
</Authentication>

View File

@ -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());
}
}

View File

@ -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<any>]>([initialState(), () => { }]);
class StateManager {
client: Client | undefined;
dispatch: React.Dispatch<Action> | undefined;
constructor(client: Client | undefined, dispatch: React.Dispatch<any> | 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<StateProviderProps>) => {
const [state, dispatch] = useReducer(stateReducer, initialState());
return <StateContext.Provider value={[state, dispatch]}>
const stateManager = useRef(new StateManager(client, dispatch));
useEffect(() => {
stateManager.current.status();
}, [stateManager]);
return <StateContext.Provider value={[state, stateManager.current]}>
{children}
</StateContext.Provider>;
}