diff --git a/Cargo.lock b/Cargo.lock index f4cba53..375dd58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4394,6 +4394,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "bitflags 2.6.0", + "bytes", + "http 1.2.0", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4688,6 +4702,7 @@ dependencies = [ "thiserror 2.0.3", "tokio", "tokio-stream", + "tower-http", "typeshare", "urlencoding", "uuid 1.11.0", diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index 6d1cf60..619ae02 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -24,6 +24,7 @@ serde_json = { version = "*" } thiserror = { version = "2.0.3" } tokio = { version = "1", features = [ "full" ] } tokio-stream = { version = "0.1.16" } +tower-http = { version = "0.6.2", features = ["cors"] } typeshare = { version = "1.0.4" } urlencoding = { version = "2.1.3" } uuid = { version = "1.11.0", features = ["v4"] } diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index cd1be73..6a24996 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -2,9 +2,11 @@ use std::fmt; use rusqlite::types::{FromSql, FromSqlResult, ValueRef}; use serde::{Deserialize, Serialize}; +use typeshare::typeshare; use uuid::Uuid; #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct UserId(String); impl UserId { @@ -47,6 +49,7 @@ impl fmt::Display for UserId { } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct SessionId(String); impl SessionId { @@ -87,6 +90,7 @@ impl fmt::Display for SessionId { } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct GameId(String); impl GameId { @@ -121,6 +125,7 @@ impl FromSql for GameId { } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct CharacterId(String); impl CharacterId { diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index 0e1ad18..19950cd 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -16,7 +16,7 @@ use crate::{ #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct HealthCheck { - pub ok: bool, + pub admin_enabled: bool, } pub async fn wrap_handler(f: F) -> (StatusCode, Json>) @@ -46,10 +46,10 @@ where pub async fn healthcheck(core: Core) -> Vec { match core.status().await { ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck { - ok: s.admin_enabled, + admin_enabled: s.admin_enabled, }) .unwrap(), - ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { ok: false }).unwrap(), + ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { admin_enabled: false }).unwrap(), ResultExt::Fatal(err) => panic!("{}", err), } } diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 94369bd..bc51f23 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,9 +1,10 @@ use axum::{ extract::Path, - http::{HeaderMap, StatusCode}, + http::{header::CONTENT_TYPE, HeaderMap, Method, StatusCode}, routing::{get, post, put}, Json, Router, }; +use tower_http::cors::{Any, CorsLayer}; use crate::{ core::Core, @@ -21,60 +22,67 @@ pub fn routes(core: Core) -> Router { get({ let core = core.clone(); move || healthcheck(core) - }), + }) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_origin(Any), + ), ) .route( "/api/v1/auth", post({ let core = core.clone(); move |req: Json| wrap_handler(|| check_password(core, req)) + }).layer( + CorsLayer::new().allow_methods([Method::POST]).allow_headers([CONTENT_TYPE]).allow_origin(Any), + ), + ) + .route( + // By default, just get the self user. + "/api/v1/user", + get({ + let core = core.clone(); + move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None)) + }) + .put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_user(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/password", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| set_password(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/:user_id", + get({ + let core = core.clone(); + move |user_id: Path, headers: HeaderMap| { + let Path(user_id) = user_id; + wrap_handler(|| get_user(core, headers, Some(user_id))) + } + }), + ) + .route( + "/api/v1/games", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_game(core, headers, req)) + } }), ) - .route( - // By default, just get the self user. - "/api/v1/user", - get({ - let core = core.clone(); - move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None)) - }) - .put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - wrap_handler(|| create_user(core, headers, req)) - } - }), - ) - .route( - "/api/v1/user/password", - put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - wrap_handler(|| set_password(core, headers, req)) - } - }), - ) - .route( - "/api/v1/user/:user_id", - get({ - let core = core.clone(); - move |user_id: Path, headers: HeaderMap| { - let Path(user_id) = user_id; - wrap_handler(|| get_user(core, headers, Some(user_id))) - } - }), - ) - .route( - "/api/v1/games", - put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - wrap_handler(|| create_game(core, headers, req)) - } - }), - ) } #[cfg(test)] diff --git a/visions/ui/src/App.tsx b/visions/ui/src/App.tsx index c8afe75..9b98110 100644 --- a/visions/ui/src/App.tsx +++ b/visions/ui/src/App.tsx @@ -46,9 +46,9 @@ const App = ({ client }: AppProps) => { console.log("rendering app"); const [websocketUrl, setWebsocketUrl] = useState(undefined); - useEffect(() => { - client.registerWebsocket().then((url) => setWebsocketUrl(url)) - }, [client]); + // useEffect(() => { + // client.registerWebsocket().then((url) => setWebsocketUrl(url)) + // }, [client]); let router = createBrowserRouter([ diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 8fa92f9..37c3e9a 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -1,3 +1,5 @@ +import { SessionId } from "visions-types"; + export type PlayingField = { backgroundImage: string; } @@ -64,15 +66,15 @@ export class Client { return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) }); } - async auth(username: string, password: string) { + async auth(username: string, password: string): Promise { const url = new URL(this.base); - url.pathname = `api/v1/auth` - return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); + url.pathname = `/api/v1/auth` + return fetch(url, { method: 'POST', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); } - async status() { + async health() { const url = new URL(this.base); - url.pathname = `/api/v1/status`; + url.pathname = `/api/v1/health`; return fetch(url).then((response) => response.json()); } diff --git a/visions/ui/src/components/Tabletop/Tabletop.tsx b/visions/ui/src/components/Tabletop/Tabletop.tsx index efa21bf..55f9c3c 100644 --- a/visions/ui/src/components/Tabletop/Tabletop.tsx +++ b/visions/ui/src/components/Tabletop/Tabletop.tsx @@ -1,9 +1,9 @@ import React, { useContext } from 'react'; import './Tabletop.css'; -import { RGB } from 'visions-types'; +import { Rgb } from 'visions-types'; interface TabletopElementProps { - backgroundColor: RGB; + backgroundColor: Rgb; backgroundUrl: URL | undefined; } diff --git a/visions/ui/src/plugins/Candela/Charsheet.tsx b/visions/ui/src/plugins/Candela/Charsheet.tsx index 362a7df..5b194aa 100644 --- a/visions/ui/src/plugins/Candela/Charsheet.tsx +++ b/visions/ui/src/plugins/Candela/Charsheet.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { assertNever } from '.'; +import { assertNever } from '../../utils'; import './Charsheet.css'; import { DriveGuage } from './DriveGuage/DriveGuage'; import { Charsheet, Nerve, Cunning, Intuition } from './types'; diff --git a/visions/ui/src/plugins/Candela/CharsheetPanel.tsx b/visions/ui/src/plugins/Candela/CharsheetPanel.tsx index 109479b..f2c12fb 100644 --- a/visions/ui/src/plugins/Candela/CharsheetPanel.tsx +++ b/visions/ui/src/plugins/Candela/CharsheetPanel.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { assertNever } from '.'; import { SimpleGuage } from '../../components/Guages/SimpleGuage'; import { Charsheet, Nerve, Cunning, Intuition } from './types'; import './CharsheetPanel.css'; import classNames from 'classnames'; +import { assertNever } from '../../utils'; interface CharsheetPanelProps { sheet: Charsheet; diff --git a/visions/ui/src/plugins/Candela/index.tsx b/visions/ui/src/plugins/Candela/index.tsx index 818120b..fefbe14 100644 --- a/visions/ui/src/plugins/Candela/index.tsx +++ b/visions/ui/src/plugins/Candela/index.tsx @@ -1,9 +1,5 @@ import { CharsheetElement } from './Charsheet'; import { CharsheetPanelElement } from './CharsheetPanel'; -export function assertNever(value: never) { - throw new Error("Unexpected value: " + value); -} - export default { CharsheetElement, CharsheetPanelElement }; diff --git a/visions/ui/src/providers/StateProvider/StateProvider.tsx b/visions/ui/src/providers/StateProvider/StateProvider.tsx index e22ebe3..64a9dc8 100644 --- a/visions/ui/src/providers/StateProvider/StateProvider.tsx +++ b/visions/ui/src/providers/StateProvider/StateProvider.tsx @@ -1,26 +1,53 @@ 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"; +import { assertNever } from "../../utils"; type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string }; -type AppState = { - auth: AuthState; - tabletop: Tabletop; +type LoadingState = { type: "Loading" } + +type ReadyState = { + type: "Ready", + auth: AuthState, + tabletop: Tabletop, } +type AppState = LoadingState | ReadyState + type Action = { type: "SetAuthState", content: AuthState }; +/* const initialState = (): AppState => ( { auth: { type: "NoAdmin" }, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } } ); +*/ +const initialState = (): AppState => ({ type: "Loading" }) + +const loadingReducer = (state: LoadingState, action: Action): AppState => { + return { type: "Ready", auth: action.content, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } } +} + +const readyReducer = (state: ReadyState, action: Action): AppState => { + return { ...state, auth: action.content } +} const stateReducer = (state: AppState, action: Action): AppState => { - return { ...state, auth: action.content } + switch (state.type) { + case "Loading": { + return loadingReducer(state, action); + } + case "Ready": { + return readyReducer(state, action); + } + default: { + assertNever(state); + return { type: "Loading" }; + } + } } class StateManager { @@ -35,7 +62,7 @@ class StateManager { async status() { if (!this.client || !this.dispatch) return; - const { admin_enabled } = await this.client.status(); + const { admin_enabled } = await this.client.health(); if (!admin_enabled) { this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } }); } else { @@ -54,9 +81,9 @@ class StateManager { if (!this.client || !this.dispatch) return; let resp = await this.client.auth(username, password); - let userid = await resp.json(); - console.log("userid retrieved", userid); - this.dispatch({ type: "SetAuthState", content: { type: "Authed", userid } }); + let sessionid = await resp.json(); + console.log("sessionid retrieved", sessionid); + this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionid } }); } } diff --git a/visions/ui/src/utils.ts b/visions/ui/src/utils.ts new file mode 100644 index 0000000..559ae58 --- /dev/null +++ b/visions/ui/src/utils.ts @@ -0,0 +1,4 @@ +export function assertNever(value: never) { + throw new Error("Unexpected value: " + value); +} + diff --git a/visions/ui/src/views/Authentication/Authentication.tsx b/visions/ui/src/views/Authentication/Authentication.tsx index 7b0e322..95f4be2 100644 --- a/visions/ui/src/views/Authentication/Authentication.tsx +++ b/visions/ui/src/views/Authentication/Authentication.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, useContext, useState } from 'react'; import { StateContext } from '../../providers/StateProvider/StateProvider'; -import { assertNever } from '../../plugins/Candela'; +import { assertNever } from '../../utils'; import './Authentication.css'; interface AuthenticationProps { @@ -17,35 +17,48 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC let [pwField, setPwField] = useState(""); let [state, _] = useContext(StateContext); - switch (state.auth.type) { - case "NoAdmin": { - return
-
-

Welcome to your new Visions VTT Instance

-

Set your admin password:

- setPwField(evt.target.value)} /> - onAdminPassword(pwField)} /> -
-
; + switch (state.type) { + case "Loading": { + return
Loading
} - case "Unauthed": { - return
-
-

Welcome to Visions VTT

-
- setUserField(evt.target.value)} /> - setPwField(evt.target.value)} /> - onAuth(userField, pwField)} /> -
-
-
; - } - case "Authed": { - return
{children}
; + case "Ready": { + switch (state.auth.type) { + case "NoAdmin": { + return
+
+

Welcome to your new Visions VTT Instance

+

Set your admin password:

+ setPwField(evt.target.value)} /> + onAdminPassword(pwField)} /> +
+
; + } + case "Unauthed": { + return
+
+

Welcome to Visions VTT

+
+ setUserField(evt.target.value)} /> + setPwField(evt.target.value)} /> + onAuth(userField, pwField)} /> +
+
+
; + } + case "Authed": { + return
{children}
; + } + default: { + assertNever(state.auth); + return
; + } + } + } default: { - assertNever(state.auth); - return
; + assertNever(state); + return
} } + } diff --git a/visions/ui/src/views/GmView/GmView.tsx b/visions/ui/src/views/GmView/GmView.tsx index 948906e..8874f85 100644 --- a/visions/ui/src/views/GmView/GmView.tsx +++ b/visions/ui/src/views/GmView/GmView.tsx @@ -5,6 +5,7 @@ import { TabletopElement } from '../../components/Tabletop/Tabletop'; import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail'; import { WebsocketContext } from '../../components/WebsocketProvider'; import './GmView.css'; +import { assertNever } from '../../utils'; interface GmViewProps { client: Client @@ -18,12 +19,21 @@ export const GmView = ({ client }: GmViewProps) => { client.availableImages().then((images) => setImages(images)); }, [client]); - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - return (
-
- {images.map((imageName) => { client.setBackgroundImage(imageName); }} />)} -
- -
) + switch (state.type) { + case "Loading": return
; + case "Ready": { + const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; + return (
+
+ {images.map((imageName) => { client.setBackgroundImage(imageName); }} />)} +
+ +
) + } + default: { + assertNever(state); + return
; + } + } } diff --git a/visions/ui/src/views/PlayerView/PlayerView.tsx b/visions/ui/src/views/PlayerView/PlayerView.tsx index 3d3304c..017c2c0 100644 --- a/visions/ui/src/views/PlayerView/PlayerView.tsx +++ b/visions/ui/src/views/PlayerView/PlayerView.tsx @@ -5,6 +5,7 @@ import { Client } from '../../client'; import { TabletopElement } from '../../components/Tabletop/Tabletop'; import Candela from '../../plugins/Candela'; import { StateContext } from '../../providers/StateProvider/StateProvider'; +import { assertNever } from '../../utils'; const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; @@ -26,14 +27,23 @@ export const PlayerView = ({ client }: PlayerViewProps) => { [client, setCharsheet] ); - const backgroundColor = state.tabletop.backgroundColor; - const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`; - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; + switch (state.type) { + case "Loading": return
; + case "Ready": { + const backgroundColor = state.tabletop.backgroundColor; + const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`; + const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - return (
-
-
- {charsheet ? :
}
-
) + return (
+
+
+ {charsheet ? :
}
+
) + } + default: { + assertNever(state); + return
; + } + } }