Move the command invoker and the socket_manager into the app

This commit is contained in:
2026-05-06 01:01:46 -04:00
committed by savanni
parent f518468461
commit 1d2935efc2
23 changed files with 238 additions and 236 deletions

1
Cargo.lock generated
View File

@@ -5105,6 +5105,7 @@ dependencies = [
"axum-test",
"cool_asserts",
"fail",
"futures",
"rusqlite",
"rusqlite_migration",
"serde",

View File

@@ -51,6 +51,7 @@ cairo-rs = { version = "0.18" }
chrono = { version = "0.4" }
chrono-tz = { version = "0.8" }
dimensioned = { version = "0.8", features = [ "serde" ] }
futures = { version = "0.3" }
gdk = { version = "0.7", package = "gdk4" }
gio = { version = "0.18" }
glib = { version = "0.18" }

View File

@@ -1,9 +1,6 @@
use futures::future::join_all;
use visions_types::{CharacterId, GameId, GameMessage, UserId};
use crate::{
AppData, Error, Fatal, socket_manager::OutgoingMessage, substrate::Substrate, types::User,
};
use crate::{Error, Fatal, substrate::Substrate};
mod card;
pub use card::*;
@@ -28,8 +25,8 @@ pub enum ResourceId {
CharacterId(CharacterId),
}
struct CommandContext<'a> {
app: &'a Substrate,
pub struct CommandContext<'a> {
pub app: &'a Substrate,
}
#[derive(Debug)]
@@ -75,7 +72,7 @@ impl<A> From<Result<A, Error>> for CommandResult<A> {
}
#[derive(Debug)]
enum GrantedBy {
pub enum GrantedBy {
Admin,
AuthenticatedUser(UserId),
GameGm(GameId),
@@ -86,179 +83,14 @@ enum GrantedBy {
Anyone,
}
struct Requirement {
granted_by: GrantedBy,
pub struct Requirement {
pub granted_by: GrantedBy,
}
trait Command<A>: Send + Sync + std::fmt::Debug {
pub trait Command<A>: Send + Sync + std::fmt::Debug {
fn requirements(&self) -> Vec<Requirement>;
fn execute(
&self,
ctx: CommandContext,
) -> impl Future<Output = Result<CommandResult<A>, Fatal>> + Send;
}
#[allow(private_bounds)]
pub async fn invoke<T: Command<A>, A>(
app: &AppData,
command: T,
user: Option<&User>,
) -> Result<Result<A, Error>, Fatal> {
eprintln!("invoking {:?}", command);
let result = run_command(&app.substrate, command, user).await?;
match result.value {
Ok(_) => eprintln!("\tsuccess"),
Err(ref err) => eprintln!("\tfailure: {:?}", err),
}
for (user_id, message) in result.messages.into_iter() {
if let Err(error) = app
.socket_manager
.send(OutgoingMessage::SendToUser(
user_id.clone(),
message.clone(),
))
.await
{
eprintln!(
"Error sending to user {:?} {:?}: {:?}",
user_id, message, error
);
}
}
for (game_id, message) in result.broadcast_messages.into_iter() {
if let Err(error) = app
.socket_manager
.send(OutgoingMessage::BroadcastToGame(
game_id.clone(),
message.clone(),
))
.await
{
eprintln!(
"Error sending to game {:?} {:?}: {:?}",
game_id, message, error
);
}
}
Ok(result.value)
}
#[allow(private_bounds)]
pub async fn run_command<T: Command<A>, A>(
app: &Substrate,
command: T,
user: Option<&User>,
) -> Result<CommandResult<A>, Fatal> {
let permission_granted: Vec<bool> = join_all(
command
.requirements()
.iter()
.map(|req| Box::pin(permission_granted(app, req, user))),
)
.await;
if permission_granted.into_iter().any(|t| t) {
Ok(command.execute(CommandContext { app }).await?)
} else if user.is_some() {
Ok(CommandResult::new(Err(Error::Forbidden)))
} else {
Ok(CommandResult::new(Err(Error::Unauthorized)))
}
}
pub async fn permission_granted(app: &Substrate, req: &Requirement, user: Option<&User>) -> bool {
match req.granted_by {
GrantedBy::Admin => {
if let Some(user) = user {
user.admin
} else {
false
}
}
GrantedBy::AuthenticatedUser(ref req_user) => {
if let Some(auth) = user {
auth.id == *req_user
} else {
false
}
}
GrantedBy::GameGm(ref game_id) => {
let game = app.game(&game_id).await.unwrap().unwrap();
if let Some(user) = user {
game.gm == user.id
} else {
false
}
}
GrantedBy::GameParticipant(ref game_id) => {
let game = app.game(&game_id).await.unwrap().unwrap();
if let Some(user) = user {
game.contains_user(&user.id)
} else {
false
}
}
GrantedBy::GameParticipantOrSpectator(ref game_id) => {
let game = app.game(&game_id).await.unwrap().unwrap();
eprintln!(
"{:?} game open to spectators: {}",
game.id, game.open_to_spectators
);
if let Some(user) = user {
if game.contains_user(&user.id) {
true
} else {
game.open_to_spectators
}
} else {
game.open_to_spectators
}
}
GrantedBy::ResourceOwner(ref resource_id) => {
let Some(user) = user else {
return false;
};
match resource_id {
ResourceId::CharacterId(character_id) => {
let Ok(character) = app.charsheet(character_id).await.unwrap() else {
return false;
};
character.user_id == user.id
}
}
}
GrantedBy::ResourceOwnerOrGameGm(ref resource_id) => {
let Some(user) = user else {
return false;
};
eprintln!(
"GrantedBy::ResourceOwnerOrGameGm: {:?} {:?}",
user.id, resource_id
);
match resource_id {
ResourceId::CharacterId(character_id) => {
let Ok(character) = app.charsheet(character_id).await.unwrap() else {
return false;
};
let Ok(game) = app.game(&character.game_id).await.unwrap() else {
return false;
};
game.gm == user.id || character.user_id == user.id
}
}
}
GrantedBy::Anyone => false,
}
}

View File

@@ -2,14 +2,13 @@ use webauthn_rs::prelude::*;
pub mod commands;
pub mod database;
pub mod socket_manager;
pub mod substrate;
pub mod types;
pub mod utils;
pub use commands::{CreateInvitation, invoke, run_command};
pub use commands::CreateInvitation;
use crate::{database::CannotCreate, socket_manager::SocketManager, substrate::Substrate};
use crate::database::CannotCreate;
#[cfg(test)]
mod commands_test;
@@ -81,9 +80,3 @@ impl From<database::Fatal> for Fatal {
Self(format!("{}", value))
}
}
#[derive(Clone)]
pub struct AppData {
pub substrate: Substrate,
pub socket_manager: SocketManager,
}

View File

@@ -12,10 +12,7 @@ use std::{
time::SystemTime,
};
use tokio::sync::{
RwLock,
mpsc::{Receiver, Sender, channel},
};
use tokio::sync::RwLock;
use uuid::Uuid;
use visions_types::*;
use webauthn_rs::prelude::*;
@@ -23,7 +20,7 @@ use webauthn_rs::prelude::*;
use crate::{
Error, Fatal, clone,
database::{Database, DbHandle},
types::{Password, SocketId, TabletopRow, User},
types::{Password, TabletopRow, User},
};
pub struct App_ {

View File

@@ -1,9 +1,6 @@
use ring::{digest, rand::SecureRandom};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use visions_types::{AccountStatus, GameId, ImageId, SceneId, UserId, UserOverview, identifier};
identifier!(SocketId);
use visions_types::{AccountStatus, GameId, ImageId, SceneId, UserId, UserOverview};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct User {

View File

@@ -8,6 +8,7 @@ axum = { workspace = true }
axum-test = { version = "17.3.0", features = ["ws"] }
cool_asserts = "2.0.3"
fail = "0.5"
futures = { workspace = true }
rusqlite = "0.38"
rusqlite_migration = "2.4"
serde = { workspace = true }

View File

@@ -0,0 +1,178 @@
use visions_core::{
commands::{Command, CommandContext, CommandResult, GrantedBy, Requirement, ResourceId},
substrate::Substrate,
types::User,
Error, Fatal,
};
use crate::{socket_manager::OutgoingMessage, AppData};
use futures::future::join_all;
#[allow(private_bounds)]
pub async fn invoke<T: Command<A>, A>(
app: &AppData,
command: T,
user: Option<&User>,
) -> Result<Result<A, Error>, Fatal> {
eprintln!("invoking {:?}", command);
let result = run_command(&app, command, user).await?;
match result.value {
Ok(_) => eprintln!("\tsuccess"),
Err(ref err) => eprintln!("\tfailure: {:?}", err),
}
for (user_id, message) in result.messages.into_iter() {
if let Err(error) = app
.socket_manager
.send(OutgoingMessage::SendToUser(
user_id.clone(),
message.clone(),
))
.await
{
eprintln!(
"Error sending to user {:?} {:?}: {:?}",
user_id, message, error
);
}
}
for (game_id, message) in result.broadcast_messages.into_iter() {
if let Err(error) = app
.socket_manager
.send(OutgoingMessage::BroadcastToGame(
game_id.clone(),
message.clone(),
))
.await
{
eprintln!(
"Error sending to game {:?} {:?}: {:?}",
game_id, message, error
);
}
}
Ok(result.value)
}
#[allow(private_bounds)]
pub async fn run_command<T: Command<A>, A>(
app: &AppData,
command: T,
user: Option<&User>,
) -> Result<CommandResult<A>, Fatal> {
let permission_granted: Vec<bool> = join_all(
command
.requirements()
.iter()
.map(|req| Box::pin(permission_granted(&app.substrate, req, user))),
)
.await;
if permission_granted.into_iter().any(|t| t) {
Ok(command
.execute(CommandContext {
app: &app.substrate,
})
.await?)
} else if user.is_some() {
Ok(CommandResult::new(Err(Error::Forbidden)))
} else {
Ok(CommandResult::new(Err(Error::Unauthorized)))
}
}
pub async fn permission_granted(app: &Substrate, req: &Requirement, user: Option<&User>) -> bool {
match req.granted_by {
GrantedBy::Admin => {
if let Some(user) = user {
user.admin
} else {
false
}
}
GrantedBy::AuthenticatedUser(ref req_user) => {
if let Some(auth) = user {
auth.id == *req_user
} else {
false
}
}
GrantedBy::GameGm(ref game_id) => {
let game = app.game(&game_id).await.unwrap().unwrap();
if let Some(user) = user {
game.gm == user.id
} else {
false
}
}
GrantedBy::GameParticipant(ref game_id) => {
let game = app.game(&game_id).await.unwrap().unwrap();
if let Some(user) = user {
game.contains_user(&user.id)
} else {
false
}
}
GrantedBy::GameParticipantOrSpectator(ref game_id) => {
let game = app.game(&game_id).await.unwrap().unwrap();
eprintln!(
"{:?} game open to spectators: {}",
game.id, game.open_to_spectators
);
if let Some(user) = user {
if game.contains_user(&user.id) {
true
} else {
game.open_to_spectators
}
} else {
game.open_to_spectators
}
}
GrantedBy::ResourceOwner(ref resource_id) => {
let Some(user) = user else {
return false;
};
match resource_id {
ResourceId::CharacterId(character_id) => {
let Ok(character) = app.charsheet(character_id).await.unwrap() else {
return false;
};
character.user_id == user.id
}
}
}
GrantedBy::ResourceOwnerOrGameGm(ref resource_id) => {
let Some(user) = user else {
return false;
};
eprintln!(
"GrantedBy::ResourceOwnerOrGameGm: {:?} {:?}",
user.id, resource_id
);
match resource_id {
ResourceId::CharacterId(character_id) => {
let Ok(character) = app.charsheet(character_id).await.unwrap() else {
return false;
};
let Ok(game) = app.game(&character.game_id).await.unwrap() else {
return false;
};
game.gm == user.id || character.user_id == user.id
}
}
}
GrantedBy::Anyone => false,
}
}

View File

@@ -1,10 +1,9 @@
use axum::{body::Body, extract::State, http::Response, Json};
use visions_core::AppData;
use visions_types::{
FinishPasskeyAuthRequest, FinishRegisterPasskeyRequest, StartRegisterPasskeyRequest,
};
use crate::handlers::http_adapter;
use crate::{handlers::http_adapter, AppData};
pub async fn register_passkey_start(
app: State<AppData>,

View File

@@ -8,16 +8,15 @@ use axum::{
use visions_core::{
clone,
commands::{CreateCard, ListCards},
invoke, AppData,
};
use visions_types::{
CardId, CardsFilter, CharacterId, CreateCardRequest, GameId, Location, UpdateCardRequest,
};
use crate::{handlers::http_adapter, router::auth_required};
use crate::{executor::invoke, handlers::http_adapter, router::auth_required, AppData};
pub async fn get_card(
app: State<AppData>,
_app: State<AppData>,
_headers: HeaderMap,
_params: Path<CardId>,
) -> Response<Body> {

View File

@@ -8,11 +8,10 @@ use axum::{
use visions_core::{
clone,
commands::{CreateCharacter, GetCharacter, ListCharacters, UpdateCharacter},
invoke, AppData,
};
use visions_types::*;
use crate::{handlers::http_adapter, router::auth_required};
use crate::{executor::invoke, handlers::http_adapter, router::auth_required, AppData};
pub async fn get_self(app: State<AppData>, headers: HeaderMap) -> Response<Body> {
auth_required(app, headers, |user| {

View File

@@ -4,10 +4,10 @@ use axum::{
http::{HeaderMap, Response},
Json,
};
use visions_core::{clone, commands::ToggleOpenToSpectators, invoke, AppData, Error, Fatal};
use visions_core::{clone, commands::ToggleOpenToSpectators, Error, Fatal};
use visions_types::{CreateGameRequest, GameId, UserId};
use crate::{handlers::http_adapter, router::auth_required};
use crate::{executor::invoke, handlers::http_adapter, router::auth_required, AppData};
pub async fn get_game(
app: State<AppData>,

View File

@@ -4,10 +4,10 @@ use axum::{
http::{HeaderMap, Response},
Json,
};
use visions_core::{clone, AppData};
use visions_core::clone;
use visions_types::*;
use crate::{handlers::http_adapter, router::auth_required};
use crate::{handlers::http_adapter, router::auth_required, AppData};
pub async fn get_images(
app: State<AppData>,
@@ -18,7 +18,7 @@ pub async fn get_images(
auth_required(
app.clone(),
headers,
clone!((app, filter), move |user| {
clone!((app, filter), move |_user| {
clone!((app, filter), async move {
http_adapter(app.substrate.images(filter)).await
})

View File

@@ -6,14 +6,11 @@ use axum::{
};
use visions_core::{
clone,
commands::{invoke, CreateInvitation, ListInvitations},
AppData,
commands::{CreateInvitation, ListInvitations},
};
use visions_types::{CreateInvitationRequest, InvitationId};
use crate::router::auth_required;
use super::http_adapter;
use crate::{executor::invoke, handlers::http_adapter, router::auth_required, AppData};
pub async fn invitations(app: State<AppData>, headers: HeaderMap) -> Response<Body> {
auth_required(app.clone(), headers, move |user| {

View File

@@ -4,10 +4,10 @@ use axum::{
http::{HeaderMap, Response},
Json,
};
use visions_core::{clone, commands::SetScene, invoke, AppData};
use visions_core::{clone, commands::SetScene};
use visions_types::*;
use crate::{handlers::http_adapter, router::auth_required};
use crate::{executor::invoke, handlers::http_adapter, router::auth_required, AppData};
pub async fn get_scenes(
app: State<AppData>,

View File

@@ -5,13 +5,14 @@ use axum::{
response::IntoResponse,
Json,
};
use visions_core::{clone, commands::GetTabletop, invoke, AppData};
use visions_core::{clone, commands::GetTabletop};
use visions_types::{GameId, SetTabletopImageRequest};
use crate::{
executor::invoke,
handlers::http_adapter,
router::{auth_required, check_auth_headers},
ApiError,
ApiError, AppData,
};
pub async fn get_tabletop(

View File

@@ -4,12 +4,11 @@ use axum::{
http::{HeaderMap, Response, StatusCode},
Json,
};
use visions_core::AppData;
use visions_types::CreateUserRequest;
use crate::{
router::{auth_required, parse_session_header},
ApiError,
ApiError, AppData,
};
use super::http_adapter;

View File

@@ -3,9 +3,10 @@ use axum::extract::{
WebSocketUpgrade,
};
use thiserror::Error;
use visions_core::{clone, Fatal};
use visions_types::{GameMessage, GameRequest};
use visions_core::{clone, types::SocketId, AppData, Fatal};
use crate::{socket_manager::SocketId, AppData};
pub async fn websocket_handler(ws: WebSocketUpgrade, app: AppData) -> axum::response::Response {
ws.on_upgrade(clone!(app, move |socket| async {

View File

@@ -1,8 +1,12 @@
use thiserror::Error;
use visions_core::{socket_manager::SocketManager, substrate::Substrate};
use visions_core::substrate::Substrate;
use crate::socket_manager::SocketManager;
pub mod executor;
pub mod handlers;
pub mod router;
pub mod socket_manager;
#[derive(Debug, Error)]
pub enum ApiError {
@@ -13,3 +17,9 @@ pub enum ApiError {
#[error("bad request")]
BadRequest,
}
#[derive(Clone)]
pub struct AppData {
pub substrate: Substrate,
pub socket_manager: SocketManager,
}

View File

@@ -3,10 +3,8 @@ use std::path::PathBuf;
use axum::Router;
use tower_http::trace::{self, TraceLayer};
use tracing::Level;
use visions_core::{
database::Database, socket_manager::SocketManager, substrate::Substrate, AppData,
};
use visions_server_lib::router::api_routes;
use visions_core::{database::Database, substrate::Substrate};
use visions_server_lib::{router::api_routes, socket_manager::SocketManager, AppData};
use visions_types::SPAConfig;
#[tokio::main]

View File

@@ -1,5 +1,5 @@
use axum::{
extract::{Path, WebSocketUpgrade},
extract::WebSocketUpgrade,
http::{
header::{AUTHORIZATION, CONTENT_TYPE},
HeaderMap, HeaderValue, Method, StatusCode,
@@ -11,10 +11,10 @@ use axum::{
mod utils;
use tower_http::cors::{Any, CorsLayer};
pub use utils::*;
use visions_core::{clone, substrate::Substrate, AppData};
use visions_types::{CardId, SPAConfig};
use visions_core::clone;
use visions_types::SPAConfig;
use crate::handlers::*;
use crate::{handlers::*, AppData};
pub fn api_routes(app: AppData, spa_config: SPAConfig) -> Router {
Router::new()

View File

@@ -7,10 +7,10 @@ use axum::{
response::IntoResponse,
Json,
};
use visions_core::{types::User, AppData};
use visions_core::types::User;
use visions_types::{AuthRequest, AuthResponse, SessionId};
use crate::{handlers::http_adapter, ApiError};
use crate::{handlers::http_adapter, ApiError, AppData};
pub fn parse_session_header(headers: HeaderMap) -> Result<Option<SessionId>, ApiError> {
match headers.get("Authorization") {

View File

@@ -1,22 +1,21 @@
use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
use tokio::sync::{
mpsc::{channel, Receiver, Sender},
RwLock,
mpsc::{Receiver, Sender, channel},
};
use visions_types::{GameId, GameMessage, GameRequest, SessionId, UserId};
use crate::{
Fatal,
substrate::Substrate,
types::{SocketId, User},
};
use uuid::Uuid;
use visions_core::{substrate::Substrate, types::User, Fatal};
use visions_types::{identifier, GameId, GameMessage, GameRequest, SessionId, UserId};
pub enum OutgoingMessage {
SendToUser(UserId, GameMessage),
BroadcastToGame(GameId, GameMessage),
}
identifier!(SocketId);
#[derive(Clone)]
pub struct Socket {
pub id: SocketId,