websocket

This commit is contained in:
2024-10-18 18:20:44 +02:00
parent ea88c755b5
commit 5e651b382d
19 changed files with 654 additions and 131 deletions

102
Cargo.lock generated
View File

@@ -375,12 +375,14 @@ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"axum", "axum",
"axum-extra",
"axum-macros", "axum-macros",
"axum_session", "axum_session",
"axum_session_sqlx", "axum_session_sqlx",
"base64 0.22.1", "base64 0.22.1",
"derive_more", "derive_more",
"dotenvy", "dotenvy",
"futures",
"http 1.1.0", "http 1.1.0",
"jsonwebtoken", "jsonwebtoken",
"leptos", "leptos",
@@ -418,6 +420,7 @@ dependencies = [
"derive_more", "derive_more",
"directories", "directories",
"dotenvy", "dotenvy",
"futures-util",
"image", "image",
"interprocess", "interprocess",
"open", "open",
@@ -430,6 +433,7 @@ dependencies = [
"thiserror", "thiserror",
"time", "time",
"tokio", "tokio",
"tokio-tungstenite",
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -469,6 +473,7 @@ checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core",
"base64 0.22.1",
"bytes", "bytes",
"futures-util", "futures-util",
"http 1.1.0", "http 1.1.0",
@@ -488,8 +493,10 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sha1",
"sync_wrapper 1.0.1", "sync_wrapper 1.0.1",
"tokio", "tokio",
"tokio-tungstenite",
"tower 0.5.1", "tower 0.5.1",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -517,6 +524,29 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9"
dependencies = [
"axum",
"axum-core",
"bytes",
"futures-util",
"headers",
"http 1.1.0",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"serde",
"tower 0.5.1",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "axum-macros" name = "axum-macros"
version = "0.4.2" version = "0.4.2"
@@ -1338,6 +1368,12 @@ dependencies = [
"parking_lot_core", "parking_lot_core",
] ]
[[package]]
name = "data-encoding"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.9" version = "0.7.9"
@@ -2319,6 +2355,30 @@ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.14.5",
] ]
[[package]]
name = "headers"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
dependencies = [
"base64 0.21.7",
"bytes",
"headers-core",
"http 1.1.0",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http 1.1.0",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -5962,6 +6022,22 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.12" version = "0.7.12"
@@ -6207,6 +6283,26 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5902c5d130972a0000f60860bfbf46f7ca3db5391eddfedd1b8728bd9dc96c0e" checksum = "5902c5d130972a0000f60860bfbf46f7ca3db5391eddfedd1b8728bd9dc96c0e"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.1.0",
"httparse",
"log",
"rand",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror",
"utf-8",
]
[[package]] [[package]]
name = "typed-builder" name = "typed-builder"
version = "0.18.2" version = "0.18.2"
@@ -6370,6 +6466,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf16_iter" name = "utf16_iter"
version = "1.0.5" version = "1.0.5"

View File

@@ -29,22 +29,27 @@ hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [ ssr = [
"dep:argon2", "dep:argon2",
"dep:dotenvy",
"dep:rand",
"dep:sha256",
"dep:jsonwebtoken",
"dep:tokio",
"dep:time",
"dep:tracing-subscriber",
"dep:leptos_axum",
"dep:lettre",
"dep:tera",
"dep:sqlx",
"dep:axum", "dep:axum",
"dep:axum-extra",
"dep:axum-macros", "dep:axum-macros",
"dep:axum_session", "dep:axum_session",
"dep:axum_session_sqlx", "dep:axum_session_sqlx",
"dep:dotenvy",
"dep:futures",
"dep:jsonwebtoken",
"dep:leptos_axum",
"dep:lettre",
"dep:rand",
"dep:sha256",
"dep:sqlx",
"dep:tokio",
"dep:time",
"dep:tracing-subscriber",
"dep:tera",
"dep:tower", "dep:tower",
"dep:tower-http", "dep:tower-http",
"dep:tower-layer", "dep:tower-layer",
@@ -60,6 +65,7 @@ anyhow = { version = "1.0.89", optional = false }
argon2 = { version = "0.5.3", optional = true } argon2 = { version = "0.5.3", optional = true }
derive_more = { version = "1.0.0", features = ["full"], optional = false } derive_more = { version = "1.0.0", features = ["full"], optional = false }
dotenvy = { version = "0.15.7", optional = true } dotenvy = { version = "0.15.7", optional = true }
futures = { version = "0.3.31", optional = true }
rand = { version = "0.8.5", optional = true } rand = { version = "0.8.5", optional = true }
serde = { version = "1.0.210", features = ["std", "derive"], optional = false } serde = { version = "1.0.210", features = ["std", "derive"], optional = false }
thiserror = { version = "1.0.64", optional = false } thiserror = { version = "1.0.64", optional = false }
@@ -102,7 +108,8 @@ sqlx = { version = "0.8.2", default-features = false, features = [
], optional = true } ], optional = true }
# Web # Web
axum = { version = "0.7.7", optional = true } axum = { version = "0.7.7", optional = true, features = ["ws"] }
axum-extra = { version = "0.9.4", optional = true, features = ["typed-header"] }
axum-macros = { version = "0.4.2", optional = true } axum-macros = { version = "0.4.2", optional = true }
axum_session = { version = "0.14.0", optional = true } axum_session = { version = "0.14.0", optional = true }
axum_session_sqlx = { version = "0.3.0", optional = true } axum_session_sqlx = { version = "0.3.0", optional = true }

View File

@@ -12,9 +12,12 @@ ctrlc = "3.4.5"
derive_more = { version = "1.0", features = ["full"] } derive_more = { version = "1.0", features = ["full"] }
directories = "5.0" directories = "5.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
futures-util = { version = "0.3.31", default-features = false, features = [
"sink",
"std",
] }
image = "0.25" image = "0.25"
interprocess = { version = "2.2.1", features = ["tokio"] } interprocess = { version = "2.2.1", features = ["tokio"] }
tauri-winrt-notification = "0.6.0"
open = "5.3.0" open = "5.3.0"
rand = "0.8.5" rand = "0.8.5"
reqwest = { version = "0.12.8", default-features = false, features = [ reqwest = { version = "0.12.8", default-features = false, features = [
@@ -24,9 +27,13 @@ reqwest = { version = "0.12.8", default-features = false, features = [
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_qs = "0.13.0" serde_qs = "0.13.0"
sha256 = "1.5.0" sha256 = "1.5.0"
tauri-winrt-notification = "0.6.0"
thiserror = { version = "1.0" } thiserror = { version = "1.0" }
time = "0.3.36" time = "0.3.36"
tokio = { version = "1.40.0", features = ["full"] } tokio = { version = "1.40.0", features = ["full"] }
tokio-tungstenite = { version = "0.24.0", features = [
"rustls-tls-webpki-roots",
] }
toml = "0.8" toml = "0.8"
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["time"] } tracing-subscriber = { version = "0.3.18", features = ["time"] }

View File

@@ -56,10 +56,7 @@ impl App {
return Ok(()); return Ok(());
} }
oauth::open_browser( oauth::open_browser(c.clone())?;
c.code_verifier().unwrap(),
c.code_challenge_method().unwrap(),
)?;
Ok(()) Ok(())
})?; })?;
@@ -70,7 +67,7 @@ impl App {
c.set_token(None)?; c.set_token(None)?;
c.set_open_browser(false)?; c.set_open_browser(false)?;
let _ = s.send(Event::Ready { config: c.clone() }); let _ = s.send(Event::Logout);
Ok(()) Ok(())
})?; })?;
@@ -108,7 +105,7 @@ impl ApplicationHandler for App {
if let Ok(event) = self.receiver.try_recv() { if let Ok(event) = self.receiver.try_recv() {
match event { match event {
Event::Ready { .. } => { Event::Logout | Event::Ready => {
self.tray_icon self.tray_icon
.set_text(self.items.get("login").unwrap(), "Login") .set_text(self.items.get("login").unwrap(), "Login")
.unwrap(); .unwrap();
@@ -117,7 +114,7 @@ impl ApplicationHandler for App {
.set_enabled(self.items.get("forget").unwrap(), false) .set_enabled(self.items.get("forget").unwrap(), false)
.unwrap(); .unwrap();
} }
Event::TokenReceived { .. } => { Event::TokenReceived { .. } | Event::Connected => {
self.tray_icon self.tray_icon
.set_text(self.items.get("login").unwrap(), "Open Avam") .set_text(self.items.get("login").unwrap(), "Open Avam")
.unwrap(); .unwrap();
@@ -138,9 +135,7 @@ impl ApplicationHandler for App {
fn resumed(&mut self, _: &winit::event_loop::ActiveEventLoop) { fn resumed(&mut self, _: &winit::event_loop::ActiveEventLoop) {
let _ = self.tray_icon.build(); let _ = self.tray_icon.build();
let _ = self.sender.send(Event::Ready { let _ = self.sender.send(Event::Ready);
config: self.config.clone(),
});
if !self.config.toast_shown() { if !self.config.toast_shown() {
let _ = Toast::new(crate::AVAM_APP_ID) let _ = Toast::new(crate::AVAM_APP_ID)

97
avam-client/src/client.rs Normal file
View File

@@ -0,0 +1,97 @@
use std::{borrow::Cow, time::Duration};
use futures_util::{SinkExt, StreamExt};
use reqwest::StatusCode;
use tokio::{
sync::broadcast::{Receiver, Sender},
time::sleep,
};
use tokio_tungstenite::{
connect_async,
tungstenite::{
self,
protocol::{frame::coding::CloseCode, CloseFrame},
ClientRequestBuilder,
},
};
use crate::{state_machine::Event, BASE_URL};
pub async fn start(
event_sender: Sender<Event>,
mut event_receiver: Receiver<Event>,
) -> Result<(), anyhow::Error> {
let mut writer = None;
let uri: tungstenite::http::Uri = format!("{}/ws", BASE_URL.replace("https", "wss")).parse()?;
loop {
if let Ok(event) = &event_receiver.try_recv() {
match event {
Event::TokenReceived { token } => {
let builder = ClientRequestBuilder::new(uri.clone())
.with_header("Authorization", format!("Bearer {}", token));
let (socket, response) = connect_async(builder).await?;
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
tracing::error!("{:#?}", response);
continue;
}
let (write, mut read) = socket.split();
writer = Some(write);
tokio::spawn(async move {
let message = match read.next().await {
Some(data) => match data {
Ok(message) => message,
Err(e) => {
tracing::error!("{:?}", e);
return;
}
},
None => return,
};
let data = message.to_text();
tracing::debug!("{:?}", data);
});
tracing::info!("Connected");
let _ = event_sender.send(Event::Connected);
}
Event::Logout => {
if let Some(mut write) = writer {
write
.send(tungstenite::Message::Close(Some(CloseFrame {
code: CloseCode::Normal,
reason: Cow::from("User Logout"),
})))
.await?;
writer = None;
tracing::debug!("Disconnected");
event_sender.send(Event::Disconnected)?;
}
}
Event::Quit => {
tracing::info!("Shutting down Client");
if let Some(mut write) = writer {
write
.send(tungstenite::Message::Close(Some(CloseFrame {
code: CloseCode::Normal,
reason: Cow::from("Application Shutdown"),
})))
.await?;
tracing::debug!("Disconnected");
}
break;
}
_ => {}
}
}
sleep(Duration::from_millis(100)).await;
}
tracing::info!("Client Shutdown");
Ok(())
}

View File

@@ -101,10 +101,6 @@ impl Config {
pub fn code_verifier(&self) -> Option<CodeVerifier> { pub fn code_verifier(&self) -> Option<CodeVerifier> {
self.code_verifier.read().unwrap().clone() self.code_verifier.read().unwrap().clone()
} }
pub fn code_challenge_method(&self) -> Option<CodeChallengeMethod> {
self.code_challenge_method.read().unwrap().clone()
}
} }
impl Config { impl Config {

View File

@@ -2,6 +2,7 @@
#![allow(clippy::needless_return)] #![allow(clippy::needless_return)]
mod app; mod app;
mod client;
mod config; mod config;
mod dirs; mod dirs;
mod icon; mod icon;
@@ -18,7 +19,8 @@ use oauth::{start_code_listener, start_code_to_token};
use pipe::Pipe; use pipe::Pipe;
use state_machine::Event; use state_machine::Event;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing::Level;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
pub static AVAM_APP_ID: &str = "AvamToast-ECEB71694A5E6105"; pub static AVAM_APP_ID: &str = "AvamToast-ECEB71694A5E6105";
pub static BASE_URL: &str = "https://avam.avii.nl"; pub static BASE_URL: &str = "https://avam.avii.nl";
@@ -42,7 +44,8 @@ async fn main() -> Result<(), anyhow::Error> {
init_logging()?; init_logging()?;
let (event_sender, event_receiver) = tokio::sync::broadcast::channel(1); // let (socket_sender, socket_receiver) = tokio::sync::broadcast::channel(1);
let (event_sender, event_receiver) = tokio::sync::broadcast::channel(10);
let args = Arguments::parse(); let args = Arguments::parse();
if handle_single_instance(&args).await? { if handle_single_instance(&args).await? {
@@ -81,7 +84,7 @@ async fn main() -> Result<(), anyhow::Error> {
// // Start the code listener // // Start the code listener
let receiver = event_receiver.resubscribe(); let receiver = event_receiver.resubscribe();
let (pipe_sender, pipe_receiver) = tokio::sync::broadcast::channel(100); let (pipe_sender, pipe_receiver) = tokio::sync::broadcast::channel(10);
futures.spawn(start_code_listener(pipe_sender, receiver)); futures.spawn(start_code_listener(pipe_sender, receiver));
// Start token listener // Start token listener
@@ -90,6 +93,20 @@ async fn main() -> Result<(), anyhow::Error> {
let receiver = event_receiver.resubscribe(); let receiver = event_receiver.resubscribe();
futures.spawn(start_code_to_token(c, pipe_receiver, sender, receiver)); futures.spawn(start_code_to_token(c, pipe_receiver, sender, receiver));
// Start the websocket client
// The socket client will just sit there until TokenReceivedEvent comes in to authenticate with the socket server
// The server needs to not accept any messages until the authentication is verified
let sender = event_sender.clone();
let receiver = event_receiver.resubscribe();
futures.spawn(client::start(sender, receiver));
// We need 2 way channels (2 channels, both with tx/rx) to send data from the socket to simconnect and back
// Start the simconnect listener
// The simconnect sends data to the webscoket
// It also receives data from the websocket to do things like set plane id and fuel and such things
// If possible even position
// Start the Tray Icon // Start the Tray Icon
let c = config.clone(); let c = config.clone();
let sender = event_sender.clone(); let sender = event_sender.clone();
@@ -230,7 +247,9 @@ fn init_logging() -> Result<(), anyhow::Error> {
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let file = File::options().append(true).open(&log_file)?; let file = File::options().append(true).open(&log_file)?;
let fmt = tracing_subscriber::fmt::layer(); let fmt = tracing_subscriber::fmt::layer().with_filter(tracing_subscriber::filter::filter_fn(
|metadata| metadata.level() < &Level::TRACE,
));
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let fmt = fmt.with_ansi(false).with_writer(Arc::new(file)); let fmt = fmt.with_ansi(false).with_writer(Arc::new(file));

View File

@@ -1,4 +1,5 @@
use std::time::Duration; use std::time::Duration;
use thiserror::Error;
use tokio::{ use tokio::{
sync::broadcast::{Receiver, Sender}, sync::broadcast::{Receiver, Sender},
@@ -6,13 +7,30 @@ use tokio::{
}; };
use crate::{ use crate::{
config::Config, models::*, pipe::Pipe, state_machine::Event, BASE_URL, CLIENT_ID, REDIRECT_URI, config::{Config, ConfigError},
models::*,
pipe::Pipe,
state_machine::Event,
BASE_URL, CLIENT_ID, REDIRECT_URI,
}; };
pub fn open_browser( #[derive(Debug, Error)]
code_verifier: CodeVerifier, pub enum OpenBrowserError {
code_challenge_method: CodeChallengeMethod, #[error(transparent)]
) -> Result<(), anyhow::Error> { SerdeQs(#[from] serde_qs::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Config(#[from] ConfigError),
}
pub fn open_browser(config: Config) -> Result<(), OpenBrowserError> {
let code_verifier = CodeVerifier::new();
let code_challenge_method = CodeChallengeMethod::Sha256;
config.set_code_verifier(Some(code_verifier.clone()))?;
config.set_code_challenge_method(Some(code_challenge_method.clone()))?;
let code_challenge = match code_challenge_method { let code_challenge = match code_challenge_method {
CodeChallengeMethod::Plain => { CodeChallengeMethod::Plain => {
use base64::prelude::*; use base64::prelude::*;
@@ -84,8 +102,11 @@ pub async fn start_code_to_token(
.await?; .await?;
let response: AuthorizationCodeResponse = response.json().await?; let response: AuthorizationCodeResponse = response.json().await?;
let token = response.token();
event_sender.send(Event::TokenReceived { token: response.token() })?; config.set_token(Some(token.clone()))?;
event_sender.send(Event::TokenReceived { token })?;
} }
} }
} }

View File

@@ -0,0 +1,29 @@
pub struct Client {
// whatever we need
}
impl Client {
pub fn new() -> Self {
Self {
// websocket receiver
// websocket sender
// simconnect client handle
}
}
pub fn run() -> Result<(), anyhow::Error> {
loop {
tokio::select! {
// we can either get a message from the websocket to pass to simconnect
// we can get a message from simconnect to pass to the websocket
// or we get a quit event from the event channel
}
}
}
}
pub async fn start() -> Result<(), anyhow::Error> {
Client::new().run().await?
}

View File

@@ -1,4 +1,9 @@
use tokio::sync::broadcast::{Receiver, Sender}; use std::time::Duration;
use tokio::{
sync::broadcast::{Receiver, Sender},
time::sleep,
};
use crate::{ use crate::{
config::Config, config::Config,
@@ -6,117 +11,75 @@ use crate::{
oauth, oauth,
}; };
#[derive(Debug, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum State { pub enum State {
Init, Init,
AppStart { AppStart,
config: Config, Authenticate,
}, Connect { token: String },
Authenticate {
open_browser: bool,
code_verifier: CodeVerifier,
code_challenge_method: CodeChallengeMethod,
},
Connect {
token: String,
},
WaitForSim, WaitForSim,
InSim, InSim,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Event { pub enum Event {
Ready { Ready,
config: Config, StartAuthenticate, // should not be string
}, TokenReceived { token: String }, // AppStart and Authenticate can fire off TokenReceived to transition into Connect
StartAuthenticate {
open_browser: bool,
code_verifier: CodeVerifier,
code_challenge_method: CodeChallengeMethod,
}, // should not be string
TokenReceived {
token: String,
}, // AppStart and Authenticate can fire off TokenReceived to transition into Connect
Connected, // Once connected to the socket, and properly authenticated, fire off Connected to transition to WaitForSim Connected, // Once connected to the socket, and properly authenticated, fire off Connected to transition to WaitForSim
Disconnected, // If for whatever reason we're disconnected from the backend, we need to transition back to Connect Disconnected, // If for whatever reason we're disconnected from the backend, we need to transition back to Connect
SimConnected, // SimConnect is connected, we're in the world and ready to send data, transition to Running SimConnected, // SimConnect is connected, we're in the world and ready to send data, transition to Running
SimDisconnected, // SimConnect is disconnected, we've finished the flight and exited back to the menu, transition back to WaitForSim SimDisconnected, // SimConnect is disconnected, we've finished the flight and exited back to the menu, transition back to WaitForSim
Logout,
Quit, Quit,
} }
impl State { impl State {
pub async fn next(self, event: Event) -> State { pub async fn next(self, event: Event) -> State {
match (self, event) { match (self.clone(), event.clone()) {
// (Current State, SomeEvent) => NextState // (Current State, SomeEvent) => NextState
(_, Event::Ready { config }) => State::AppStart { config }, (_, Event::Ready) => State::AppStart,
( (_, Event::Logout) => State::AppStart,
State::AppStart { .. }, (_, Event::StartAuthenticate) => Self::Authenticate, // Goto Authenticate
Event::StartAuthenticate {
open_browser,
code_verifier,
code_challenge_method,
},
) => Self::Authenticate {
open_browser,
code_verifier,
code_challenge_method,
}, // Goto Authenticate
(State::AppStart { .. }, Event::TokenReceived { token }) => State::Connect { token }, (_, Event::TokenReceived { token }) => State::Connect { token },
(State::Authenticate { .. }, Event::TokenReceived { token }) => {
State::Connect { token }
}
(State::Connect { .. }, Event::Connected) => todo!(), // Goto WaitForSim (_, Event::Connected) => State::WaitForSim, // Goto WaitForSim
(State::WaitForSim, Event::SimConnected) => todo!(), // Goto InSim (_, Event::SimConnected) => todo!(), // Goto InSim
(_, Event::Disconnected) => todo!(), // Goto Connect (_, Event::Disconnected) => State::AppStart, // Goto Connect
(State::InSim, Event::SimDisconnected) => todo!(), // Goto WaitForSim (_, Event::SimDisconnected) => State::WaitForSim, // Goto WaitForSim
(_, Event::Quit) => todo!(), // All events can go into quit, to shutdown the application (_, Event::Quit) => todo!(), // All events can go into quit, to shutdown the application
_ => panic!("Invalid state transition"),
} }
} }
pub async fn run(&self, signal: Sender<Event>) -> Result<(), anyhow::Error> { pub async fn run(&self, signal: Sender<Event>, config: Config) -> Result<(), anyhow::Error> {
match self { match self {
State::Init => Ok(()), State::Init => Ok(()),
State::AppStart { config } => { State::AppStart => {
if let Some(token) = config.token() { if let Some(token) = config.token() {
signal.send(Event::TokenReceived { signal.send(Event::TokenReceived {
token: token.to_string(), token: token.to_string(),
})?; })?;
} else { } else {
let open_browser = config.open_browser(); signal.send(Event::StartAuthenticate)?;
let code_verifier = CodeVerifier::new();
let code_challenge_method = CodeChallengeMethod::Sha256;
config.set_code_verifier(Some(code_verifier.clone()))?;
config.set_code_challenge_method(Some(code_challenge_method.clone()))?;
signal.send(Event::StartAuthenticate {
open_browser,
code_verifier,
code_challenge_method,
})?;
} }
Ok(()) Ok(())
} }
State::Authenticate { State::Authenticate => {
open_browser, if config.open_browser() {
code_verifier, oauth::open_browser(config.clone())?;
code_challenge_method,
} => {
if *open_browser {
oauth::open_browser(code_verifier.clone(), code_challenge_method.clone())?;
} }
Ok(()) Ok(())
} }
State::Connect { .. } => Ok(()), State::Connect { .. } => Ok(()),
State::WaitForSim => Ok(()), State::WaitForSim => {
tracing::info!("Waiting for sim!");
Ok(())
}
State::InSim => Ok(()), State::InSim => Ok(()),
} }
} }
@@ -129,25 +92,20 @@ pub async fn start(
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let mut state = State::Init; let mut state = State::Init;
state.run(event_sender.clone()).await?; state.run(event_sender.clone(), config.clone()).await?;
loop { loop {
if let Ok(event) = event_receiver.recv().await { if let Ok(event) = event_receiver.try_recv() {
state = state.next(event.clone()).await;
state.run(event_sender.clone(), config.clone()).await?;
if event == Event::Quit { if event == Event::Quit {
tracing::info!("Shutting down State Machine"); tracing::info!("Shutting down State Machine");
break; break;
} }
state = state.next(event).await;
// before run
if let State::Connect { token } = &state {
// before run Connect, save the given token in config
config.set_token(Some(token.clone()))?;
}
state.run(event_sender.clone()).await?;
} }
sleep(Duration::from_millis(100)).await;
} }
tracing::info!("State Machine Shutdown"); tracing::info!("State Machine Shutdown");

View File

@@ -30,7 +30,7 @@ async fn main() -> anyhow::Result<()> {
let api_service = api::Service::new(postgres.clone(), dangerous_lettre); let api_service = api::Service::new(postgres.clone(), dangerous_lettre);
let app_state = AppState::new(api_service).await; let app_state = AppState::new(api_service, config.clone()).await;
HttpServer::new(app_state, postgres.pool()) HttpServer::new(app_state, postgres.pool())
.await? .await?

View File

@@ -1,5 +1,6 @@
use crate::{ use crate::{
domain::api::models::oauth::*, inbound::http::handlers::oauth::AuthorizationCodeRequest, domain::api::models::oauth::*,
inbound::http::handlers::oauth::{AuthorizationCodeRequest, VerifyClientAuthorizationRequest},
}; };
use super::super::models::user::*; use super::super::models::user::*;
@@ -56,5 +57,13 @@ pub trait ApiService: Clone + Send + Sync + 'static {
fn create_token( fn create_token(
&self, &self,
req: AuthorizationCodeRequest, req: AuthorizationCodeRequest,
) -> impl Future<Output = Result<Option<TokenSubject>, TokenError>> + Send; ) -> impl Future<Output = Result<TokenSubject, TokenError>> + Send;
/// ---
/// WS
/// ---
fn verify_client_authorization(
&self,
req: VerifyClientAuthorizationRequest,
) -> impl Future<Output = Result<User, anyhow::Error>> + Send;
} }

View File

@@ -6,6 +6,7 @@ use axum_session::SessionAnySession;
use crate::inbound::http::handlers::oauth::AuthorizationCodeRequest; use crate::inbound::http::handlers::oauth::AuthorizationCodeRequest;
use crate::inbound::http::handlers::oauth::GrantType; use crate::inbound::http::handlers::oauth::GrantType;
use crate::inbound::http::handlers::oauth::VerifyClientAuthorizationRequest;
use super::models::oauth::Client; use super::models::oauth::Client;
use super::models::oauth::*; use super::models::oauth::*;
@@ -228,7 +229,7 @@ where
async fn create_token( async fn create_token(
&self, &self,
req: AuthorizationCodeRequest, req: AuthorizationCodeRequest,
) -> Result<Option<TokenSubject>, TokenError> { ) -> Result<TokenSubject, TokenError> {
if req.grant_type() != GrantType::AuthorizationCode { if req.grant_type() != GrantType::AuthorizationCode {
return Err(TokenError::InvalidRequest); return Err(TokenError::InvalidRequest);
} }
@@ -265,6 +266,24 @@ where
let _ = self.repo.delete_token(req.code()).await; let _ = self.repo.delete_token(req.code()).await;
Ok(Some(token)) Ok(token)
}
async fn verify_client_authorization(
&self,
req: VerifyClientAuthorizationRequest,
) -> Result<User, anyhow::Error> {
let user_id = req.user_id();
let client_id = req.client_id();
if !self.repo.is_authorized_client(user_id, client_id).await? {
return Err(anyhow::anyhow!("Unauthorized"));
}
let Some(user) = self.repo.find_user_by_id(user_id).await? else {
return Err(anyhow::anyhow!("Unauthorized"));
};
Ok(user)
} }
} }

View File

@@ -5,14 +5,12 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
extract::ConnectInfo, extract::ConnectInfo, routing::{any, get, post}, Extension
routing::{get, post},
Extension,
}; };
use axum_session::{SessionAnyPool, SessionConfig, SessionLayer, SessionStore}; use axum_session::{SessionAnyPool, SessionConfig, SessionLayer, SessionStore};
use axum_session_sqlx::SessionPgPool; use axum_session_sqlx::SessionPgPool;
use handlers::{ use handlers::{
fileserv::file_and_error_handler, leptos::{leptos_routes_handler, server_fn_handler}, oauth, user::activate_account fileserv::file_and_error_handler, leptos::{leptos_routes_handler, server_fn_handler}, oauth, user::activate_account, websocket::ws_handler
}; };
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use state::AppState; use state::AppState;
@@ -57,6 +55,7 @@ impl HttpServer {
); );
let router = axum::Router::new() let router = axum::Router::new()
.route("/ws", any(ws_handler))
.nest("/oauth2", oauth::routes()) .nest("/oauth2", oauth::routes())
.route("/auth/activate/:token", get(activate_account)) .route("/auth/activate/:token", get(activate_account))
.route("/api/*fn_name", post(server_fn_handler)) .route("/api/*fn_name", post(server_fn_handler))
@@ -87,3 +86,4 @@ impl HttpServer {
Ok(()) Ok(())
} }
} }

View File

@@ -2,3 +2,4 @@ pub mod fileserv;
pub mod leptos; pub mod leptos;
pub mod oauth; pub mod oauth;
pub mod user; pub mod user;
pub mod websocket;

View File

@@ -120,6 +120,26 @@ impl AuthorizationCodeRequest {
} }
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VerifyClientAuthorizationRequest {
user_id: uuid::Uuid,
client_id: uuid::Uuid,
}
impl VerifyClientAuthorizationRequest {
pub fn new(user_id: uuid::Uuid, client_id: uuid::Uuid) -> Self {
Self { client_id, user_id }
}
pub fn user_id(&self) -> uuid::Uuid {
self.user_id
}
pub fn client_id(&self) -> uuid::Uuid {
self.client_id
}
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims<T> pub struct TokenClaims<T>
where where
@@ -180,7 +200,11 @@ where
let iat = now.unix_timestamp() as usize; let iat = now.unix_timestamp() as usize;
let exp = (now + time::Duration::days(30)).unix_timestamp() as usize; let exp = (now + time::Duration::days(30)).unix_timestamp() as usize;
let claims = TokenClaims { sub, iat, exp }; let claims = TokenClaims {
sub: serde_qs::to_string(&sub)?,
iat,
exp,
};
let token = encode( let token = encode(
&Header::default(), &Header::default(),

View File

@@ -0,0 +1,225 @@
use std::{borrow::Cow, net::SocketAddr, ops::ControlFlow};
use axum::{
extract::{
ws::{CloseFrame, Message, WebSocket},
ConnectInfo, State, WebSocketUpgrade,
},
response::IntoResponse,
};
use axum_extra::{
headers::{self, authorization::Bearer},
TypedHeader,
};
use futures::{SinkExt, StreamExt};
use http::StatusCode;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use crate::{
domain::api::{
ports::ApiService,
prelude::{TokenSubject, User},
},
inbound::http::{
handlers::oauth::{TokenClaims, VerifyClientAuthorizationRequest},
state::AppState,
},
};
pub async fn ws_handler<S: ApiService>(
State(app_state): State<AppState<S>>,
ws: WebSocketUpgrade,
auth_token: Option<TypedHeader<headers::Authorization<Bearer>>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<impl IntoResponse, StatusCode> {
let auth_token = match auth_token {
Some(TypedHeader(token)) => Some(token.token().to_string()),
None => return Err(StatusCode::UNAUTHORIZED),
};
let Some(auth_token) = auth_token else {
return Err(StatusCode::UNAUTHORIZED);
};
let jwt_secret = &app_state.config().jwt_secret;
let claims = decode::<TokenClaims<String>>(
&auth_token,
&DecodingKey::from_secret(jwt_secret.as_ref()),
&Validation::new(Algorithm::HS256),
)
.map_err(|e| {
tracing::error!("Unable to decode token: {}\n{:?}", auth_token, e);
StatusCode::UNAUTHORIZED
})?
.claims;
let token_subject: TokenSubject = serde_qs::from_str(&claims.sub).map_err(|e| {
tracing::error!("Unable to parse Token Subject: {}\n{:?}", &claims.sub, e);
StatusCode::BAD_REQUEST
})?;
let user_id = token_subject.user_id();
let client_id = token_subject.client_id();
let app = app_state.api_service();
let Ok(user) = app
.verify_client_authorization(VerifyClientAuthorizationRequest::new(user_id, client_id))
.await
else {
return Err(StatusCode::UNAUTHORIZED);
};
Ok(ws.on_upgrade(move |socket| handle_socket(socket, user, addr)))
}
/// Actual websocket statemachine (one will be spawned per connection)
async fn handle_socket(mut socket: WebSocket, user: User, who: SocketAddr) {
// send a ping (unsupported by some browsers) just to kick things off and get a response
if socket
.send(Message::Text(format!("Hello {}!", user.email())))
.await
.is_ok()
{
tracing::debug!("Pinged {who}...");
} else {
tracing::debug!("Could not send ping {who}!");
// no Error here since the only thing we can do is to close the connection.
// If we can not send messages, there is no way to salvage the statemachine anyway.
return;
}
// receive single message from a client (we can either receive or send with socket).
// this will likely be the Pong for our Ping or a hello message from client.
// waiting for message from a client will block this task, but will not block other client's
// connections.
if let Some(msg) = socket.recv().await {
if let Ok(msg) = msg {
if process_message(msg, who).is_break() {
return;
}
} else {
tracing::debug!("client {who} abruptly disconnected");
return;
}
}
// Since each client gets individual statemachine, we can pause handling
// when necessary to wait for some external event (in this case illustrated by sleeping).
// Waiting for this client to finish getting its greetings does not prevent other clients from
// connecting to server and receiving their greetings.
for i in 1..5 {
if socket
.send(Message::Text(format!("Hi {i} times!")))
.await
.is_err()
{
tracing::debug!("client {who} abruptly disconnected");
return;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
// By splitting socket we can send and receive at the same time. In this example we will send
// unsolicited messages to client based on some sort of server's internal event (i.e .timer).
let (mut sender, mut receiver) = socket.split();
// Spawn a task that will push several messages to the client (does not matter what client does)
let mut send_task = tokio::spawn(async move {
let n_msg = 20;
for i in 0..n_msg {
// In case of any websocket error, we exit.
if sender
.send(Message::Text(format!("Server message {i} ...")))
.await
.is_err()
{
return i;
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
tracing::debug!("Sending close to {who}...");
if let Err(e) = sender
.send(Message::Close(Some(CloseFrame {
code: axum::extract::ws::close_code::NORMAL,
reason: Cow::from("Goodbye"),
})))
.await
{
tracing::debug!("Could not send Close due to {e}, probably it is ok?");
}
n_msg
});
// This second task will receive messages from client and print them on server console
let mut recv_task = tokio::spawn(async move {
let mut cnt = 0;
while let Some(Ok(msg)) = receiver.next().await {
cnt += 1;
// print message and break if instructed to do so
if process_message(msg, who).is_break() {
break;
}
}
cnt
});
// If any one of the tasks exit, abort the other.
tokio::select! {
rv_a = (&mut send_task) => {
match rv_a {
Ok(a) => tracing::debug!("{a} messages sent to {who}"),
Err(a) => tracing::debug!("Error sending messages {a:?}")
}
recv_task.abort();
},
rv_b = (&mut recv_task) => {
match rv_b {
Ok(b) => tracing::debug!("Received {b} messages"),
Err(b) => tracing::debug!("Error receiving messages {b:?}")
}
send_task.abort();
}
}
// returning from the handler closes the websocket connection
tracing::debug!("Websocket context {who} destroyed");
}
/// helper to print contents of messages to stdout. Has special treatment for Close.
fn process_message(msg: Message, who: SocketAddr) -> ControlFlow<(), ()> {
match msg {
Message::Text(t) => {
tracing::debug!(">>> {who} sent str: {t:?}");
}
Message::Binary(d) => {
tracing::debug!(">>> {} sent {} bytes: {:?}", who, d.len(), d);
}
Message::Close(c) => {
if let Some(cf) = c {
tracing::debug!(
">>> {} sent close with code {} and reason `{}`",
who,
cf.code,
cf.reason
);
} else {
tracing::debug!(">>> {who} somehow sent close message without CloseFrame");
}
return ControlFlow::Break(());
}
Message::Pong(v) => {
tracing::debug!(">>> {who} sent pong with {v:?}");
}
// You should never need to manually handle Message::Ping, as axum's websocket library
// will do so for you automagically by replying with Pong and copying the v according to
// spec. But if you need the contents of the pings you can see them here.
Message::Ping(v) => {
tracing::debug!(">>> {who} sent ping with {v:?}");
}
}
ControlFlow::Continue(())
}

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use axum::extract::FromRef; use axum::extract::FromRef;
use leptos::get_configuration; use leptos::get_configuration;
use crate::domain::api::ports::ApiService; use crate::{config::Config, domain::api::ports::ApiService};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
/// The global application state shared between all request handlers. /// The global application state shared between all request handlers.
@@ -12,6 +12,7 @@ where
S: ApiService, S: ApiService,
{ {
pub leptos_options: leptos::LeptosOptions, pub leptos_options: leptos::LeptosOptions,
config: Arc<Config>,
api_service: Arc<S>, api_service: Arc<S>,
} }
@@ -19,13 +20,18 @@ impl<S> AppState<S>
where where
S: ApiService, S: ApiService,
{ {
pub async fn new(api_service: S) -> Self { pub async fn new(api_service: S, config: Config) -> Self {
Self { Self {
config: Arc::new(config),
leptos_options: get_configuration(None).await.unwrap().leptos_options, leptos_options: get_configuration(None).await.unwrap().leptos_options,
api_service: Arc::new(api_service), api_service: Arc::new(api_service),
} }
} }
pub fn config(&self) -> Arc<Config> {
self.config.clone()
}
pub fn api_service(&self) -> Arc<S> { pub fn api_service(&self) -> Arc<S> {
self.api_service.clone() self.api_service.clone()
} }

View File

@@ -1444,10 +1444,18 @@ html {
margin-top: auto; margin-top: auto;
} }
.block {
display: block;
}
.flex { .flex {
display: flex; display: flex;
} }
.contents {
display: contents;
}
.hidden { .hidden {
display: none; display: none;
} }