Compare commits

11 Commits

Author SHA1 Message Date
ccab4b2590 you don't exist no more 2024-10-31 00:22:31 +01:00
6e4a52c9c2 don't really need this anymore, but since its in git now i can always get it back if i need something 2024-10-31 00:19:39 +01:00
6d65463286 kinda stuck at this point so making a commit 2024-10-31 00:19:02 +01:00
cd7d9fa3b7 pre simconnect 2024-10-19 22:00:34 +02:00
b94b3cf44f websocket protocol 2024-10-19 16:00:31 +02:00
5e651b382d websocket 2024-10-18 18:20:44 +02:00
ea88c755b5 Minor cleanup 2024-10-17 22:04:36 +02:00
4c88ca0685 Logging and Notifications 2024-10-17 12:49:56 +02:00
4e8ef3c0b4 cargo sort 2024-10-17 10:07:14 +02:00
d5bc2a700f Logout without Exit 2024-10-17 01:11:16 +02:00
f93eb3c429 avam-client and oauth2 2024-10-17 00:56:02 +02:00
69 changed files with 50629 additions and 416 deletions

117
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,117 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'avam'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=avam"
],
"filter": {
"name": "avam",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'avam'",
"cargo": {
"args": [
"build",
"--bin=avam",
"--package=avam"
],
"filter": {
"name": "avam",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'avam'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=avam",
"--package=avam"
],
"filter": {
"name": "avam",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'avam-client'",
"cargo": {
"args": [
"build",
"--bin=avam-client",
"--package=avam-client"
],
"filter": {
"name": "avam-client",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'avam-client'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=avam-client",
"--package=avam-client"
],
"filter": {
"name": "avam-client",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'avam_wasm'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=avam-wasm"
],
"filter": {
"name": "avam_wasm",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

View File

@@ -3,7 +3,7 @@
"leptos_macro": [ "leptos_macro": [
// optional: // optional:
// "component", // "component",
"server" "server",
], ],
}, },
// if code that is cfg-gated for the `ssr` feature is shown as inactive, // if code that is cfg-gated for the `ssr` feature is shown as inactive,

3283
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = [".", "avam-wasm"] members = [".", "avam-client", "avam-protocol", "avam-wasm"]
resolver = "2" resolver = "2"
[package] [package]
@@ -28,19 +28,31 @@ panic = "abort"
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [ ssr = [
"dep:avam-protocol",
"dep:argon2", "dep:argon2",
"dep:dotenvy", "dep:dashmap",
"dep:rand",
"dep:tokio",
"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",
@@ -51,14 +63,18 @@ ssr = [
] ]
[dependencies] [dependencies]
avam-protocol = { path = "./avam-protocol", optional = true }
# Utilities # Utilities
anyhow = { version = "1.0.89", optional = false } 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 }
dashmap = { version = "6.1.0", optional = true }
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 }
time = { version = "0.3.36", optional = true }
tokio = { version = "1.40.0", features = ["full"], optional = true } tokio = { version = "1.40.0", features = ["full"], optional = true }
tracing = { version = "0.1.40", optional = false } tracing = { version = "0.1.40", optional = false }
tracing-subscriber = { version = "0.3.18", features = [ tracing-subscriber = { version = "0.3.18", features = [
@@ -97,23 +113,32 @@ 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 }
http = "1"
tower = { version = "0.4", optional = true, features = ["util"] } tower = { version = "0.4", optional = true, features = ["util"] }
tower-http = { version = "0.6.1", features = ["trace", "fs"], optional = true } tower-http = { version = "0.6.1", features = ["trace", "fs"], optional = true }
tower-layer = { version = "0.3.3", optional = true } tower-layer = { version = "0.3.3", optional = true }
http = "1"
validator = "0.18.1" validator = "0.18.1"
# OAuth2
base64 = { version = "0.22.1", default-features = false }
jsonwebtoken = { version = "9.3.0", optional = true }
serde_qs = "0.13.0"
sha256 = { version = "1.5.0", optional = true } # this fucker has a dependency on tokio?!
sqids = "0.4.1"
hash-ids = "0.3.1"
[[workspace.metadata.leptos]] [[workspace.metadata.leptos]]
name = "avam" name = "avam"
site-root = "target/site" site-root = "target/site"
site-pkg-dir = "pkg" site-pkg-dir = "pkg"
style-file = "style/main.scss" style-file = "style/main.scss"
assets-dir = "public" assets-dir = "public"
site-addr = "0.0.0.0:3000" site-addr = "192.168.1.100:3000"
reload-port = 3001 reload-port = 3001
browserquery = "defaults" browserquery = "defaults"
watch = false watch = false

BIN
SimConnect.dll Normal file

Binary file not shown.

50
avam-client/Cargo.toml Normal file
View File

@@ -0,0 +1,50 @@
[package]
name = "avam-client"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { version = "1.0" }
avam-protocol = { path = "../avam-protocol" }
base64 = { version = "0.22.1", default-features = false }
clap = { version = "4.5.20", features = ["derive"] }
config = "0.14.0"
ctrlc = "3.4.5"
derive_more = { version = "1.0", features = ["full"] }
directories = "5.0"
dotenvy = "0.15.7"
futures-util = { version = "0.3.31", default-features = false, features = [
"sink",
"std",
] }
image = "0.25"
interprocess = { version = "2.2.1", features = ["tokio"] }
open = "5.3.0"
rand = "0.8.5"
reqwest = { version = "0.12.8", default-features = false, features = [
"rustls-tls",
"json",
] }
serde = { version = "1", features = ["derive"] }
serde_qs = "0.13.0"
sha256 = "1.5.0"
simconnect-sdk = { path = "D:/source/MSFS/simconnect-sdk-rs/simconnect-sdk", features = [
"derive",
] }
tauri-winrt-notification = "0.6.0"
thiserror = { version = "1.0" }
time = "0.3.36"
tokio = { version = "1.40.0", features = ["full"] }
tokio-tungstenite = { version = "0.24.0", features = [
"rustls-tls-webpki-roots",
] }
toml = "0.8"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["time", "fmt", "std"] }
tray-icon = "0.19"
uuid = { version = "1.10.0", features = ["fast-rng", "serde", "v4"] }
winit = "0.30"
winreg = "0.52.0"
[build-dependencies]
windres = "0.2"

27
avam-client/build.rs Normal file
View File

@@ -0,0 +1,27 @@
fn main() -> std::io::Result<()> {
println!("cargo:rerun-if-changed=build.rs");
let target_os = std::env::var("CARGO_CFG_TARGET_OS");
if target_os.as_deref() == Ok("windows") {
let name = "Avii's Virtual Airline Manager";
let version = env!("CARGO_PKG_VERSION");
let mut sv = version.split('.').collect::<Vec<_>>();
while sv.len() < 4 {
sv.push("0");
}
let file_version = format!("{}, {}, {}, {}", sv[0], sv[1], sv[2], sv[3]);
windres::Build::new()
.define(
"THE_FILE",
Some(format!(r#""{name} Client Module""#).as_str()),
)
.define("THE_PROJECT", Some(format!(r#""{name}""#).as_str()))
.define("THE_VERSION", Some(format!(r#""{version}""#).as_str()))
.define("THE_FILEVERSION", Some(file_version.as_str()))
.compile("res/resource.rc")?;
for entry in std::fs::read_dir("res")? {
let entry = entry?;
println!("cargo:rerun-if-changed={}", entry.path().display());
}
}
Ok(())
}

BIN
avam-client/res/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,28 @@
#pragma code_page(65001)
1 VERSIONINFO
FILEVERSION THE_FILEVERSION
PRODUCTVERSION THE_FILEVERSION
FILEFLAGSMASK 0x0000003Fl //VS_FFI_FILEFLAGSMASK
FILEFLAGS 0x0
FILEOS 0x00040004l //VOS_NT_WINDOWS32
FILETYPE 0x00000001l //VFT_APP
FILESUBTYPE 0x00000000l //VFT2_UNKNOWN
{
BLOCK "StringFileInfo"
{
BLOCK "040904B0"
{
VALUE "FileDescription", THE_FILE
VALUE "FileVersion", THE_VERSION
VALUE "ProductVersion", THE_VERSION
VALUE "ProductName", THE_PROJECT
}
}
BLOCK "VarFileInfo"
{
VALUE "Translation", 0x409, 0x4B0
}
}
1 ICON "res/icon.ico"

173
avam-client/src/app.rs Normal file
View File

@@ -0,0 +1,173 @@
use std::collections::HashMap;
use tauri_winrt_notification::Toast;
use tokio::sync::broadcast::{Receiver, Sender};
use tray_icon::menu::{MenuId, MenuItem};
use winit::{
application::ApplicationHandler,
event::StartCause,
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
};
use crate::{config::Config, icon::TrayIcon, oauth, state_machine::Event, BASE_URL};
pub async fn start(
config: Config,
sender: Sender<Event>,
receiver: Receiver<Event>,
) -> Result<(), anyhow::Error> {
let mut app = App::new(config, sender, receiver)?;
let event_loop = EventLoop::new()?;
event_loop.run_app(&mut app)?;
tracing::info!("EventLoop Shutdown");
Ok(())
}
pub struct App {
config: Config,
tray_icon: TrayIcon,
sender: Sender<Event>,
receiver: Receiver<Event>,
items: HashMap<&'static str, MenuId>,
}
impl App {
pub fn new(
config: Config,
sender: Sender<Event>,
receiver: Receiver<Event>,
) -> Result<Self, anyhow::Error> {
let mut tray_icon = TrayIcon::new(load_icon(), crate::PROJECT_NAME);
let login = MenuItem::new("Login", true, None);
let forget = MenuItem::new("Logout", false, None);
let quit = MenuItem::new("Exit", true, None);
let mut items = HashMap::new();
let c = config.clone();
let login_id = tray_icon.add_menu_item(&login, move |item| {
// ..
let item = item.as_menuitem().unwrap();
if item.text() != "Login" {
open::that(BASE_URL)?;
return Ok(());
}
oauth::open_browser(c.clone())?;
Ok(())
})?;
let c = config.clone();
let s = sender.clone();
let forget_id = tray_icon.add_menu_item(&forget, move |_| {
c.set_token(None)?;
c.set_open_browser(false)?;
let _ = s.send(Event::Logout);
Ok(())
})?;
tray_icon.add_seperator()?;
let s = sender.clone();
tray_icon.add_menu_item(&quit, move |_| {
s.send(Event::Quit)?;
Ok(())
})?;
items.insert("login", login_id);
items.insert("forget", forget_id);
Ok(Self {
config,
tray_icon,
sender,
receiver,
items,
})
}
}
impl ApplicationHandler for App {
fn new_events(&mut self, event_loop: &ActiveEventLoop, _: StartCause) {
event_loop.set_control_flow(ControlFlow::WaitUntil(
std::time::Instant::now() + std::time::Duration::from_millis(16),
));
if let Ok(event) = tray_icon::menu::MenuEvent::receiver().try_recv() {
self.tray_icon.handle(event.id());
}
if let Ok(event) = self.receiver.try_recv() {
match event {
Event::Logout | Event::Ready => {
self.tray_icon
.set_text(self.items.get("login").unwrap(), "Login")
.unwrap();
self.tray_icon
.set_enabled(self.items.get("forget").unwrap(), false)
.unwrap();
}
Event::TokenReceived { .. } | Event::Connected => {
self.tray_icon
.set_text(self.items.get("login").unwrap(), "Open Avam")
.unwrap();
self.tray_icon
.set_enabled(self.items.get("forget").unwrap(), true)
.unwrap();
}
Event::Quit => {
tracing::info!("Shutting down EventLoop");
event_loop.exit()
}
_ => {}
}
}
}
fn resumed(&mut self, _: &winit::event_loop::ActiveEventLoop) {
let _ = self.tray_icon.build();
let _ = self.sender.send(Event::Ready);
if !self.config.toast_shown() {
let _ = Toast::new(crate::AVAM_APP_ID)
.title("Avam is running in your system tray!")
.show();
}
let _ = self.config.set_toast_shown(true);
}
fn window_event(
&mut self,
_: &winit::event_loop::ActiveEventLoop,
_: winit::window::WindowId,
_: winit::event::WindowEvent,
) {
// We don't have a window
}
}
fn load_icon() -> tray_icon::Icon {
let icon = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../public/favicon-32x32.png"
));
let (icon_rgba, icon_width, icon_height) = {
let image = image::load_from_memory(icon)
.expect("Failed to open icon path")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
(rgba, width, height)
};
tray_icon::Icon::from_rgba(icon_rgba, icon_width, icon_height).expect("Failed to open icon")
}

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

@@ -0,0 +1,187 @@
use std::time::Duration;
use avam_protocol::{Packet, Packets, SimConnectPacket, SystemPacket};
use futures_util::{SinkExt, StreamExt};
use reqwest::StatusCode;
use tokio::{
sync::broadcast::{channel, Receiver, Sender},
task::JoinSet,
time::{sleep, timeout},
};
use tokio_tungstenite::{
connect_async,
tungstenite::{
self,
protocol::{frame::coding::CloseCode, CloseFrame},
ClientRequestBuilder, Message,
},
};
use crate::{config::Config, state_machine::Event, BASE_URL};
pub async fn start(
config: Config,
simconnect_sender: Sender<SimConnectPacket>,
socket_receiver: Receiver<SimConnectPacket>,
event_sender: Sender<Event>,
mut event_receiver: Receiver<Event>,
) -> Result<(), anyhow::Error> {
let uri: tungstenite::http::Uri = format!("{}/ws", BASE_URL.replace("https", "wss")).parse()?;
let mut futures = JoinSet::new();
let (ia_sender, ia_receiver) = channel(10);
loop {
if let Ok(event) = &event_receiver.try_recv() {
match event {
Event::TokenReceived => {
let Some(token) = config.token() else {
let _ = event_sender.send(Event::Logout);
sleep(Duration::from_millis(100)).await;
continue;
};
let builder = ClientRequestBuilder::new(uri.clone())
.with_header("Authorization", format!("Bearer {}", token));
tracing::info!("Connecting");
let Ok(Ok((socket, response))) =
timeout(Duration::from_secs(10), connect_async(builder)).await
else {
tracing::error!("Unable to connect");
let _ = event_sender.send(Event::Disconnected);
sleep(Duration::from_millis(100)).await;
continue;
};
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
tracing::error!("Unable to connect: {:#?}", response);
let _ = event_sender.send(Event::Disconnected);
sleep(Duration::from_millis(100)).await;
continue;
}
let (mut write, mut read) = socket.split();
let mut ia_receiver: Receiver<Packets> = ia_receiver.resubscribe();
futures.spawn(async move {
loop {
if let Ok(d) = ia_receiver.try_recv() {
let message = match d {
Packets::System(SystemPacket::Close { reason }) => {
Message::Close(Some(CloseFrame {
code: CloseCode::Normal,
reason: reason.into(),
}))
}
Packets::System(SystemPacket::Pong) => {
let Ok(encoded_message) = d.encode() else {
tracing::error!(
"Unable to encode message for sending: {:#?}",
d
);
sleep(Duration::from_millis(100)).await;
continue;
};
Message::Binary(encoded_message)
}
d => {
// tracing::info!("sending packet: {:#?}", &d);
let Ok(encoded_message) = d.encode() else {
tracing::error!(
"Unable to encode message for sending: {:#?}",
d
);
sleep(Duration::from_millis(100)).await;
continue;
};
Message::Binary(encoded_message)
}
};
match write.send(message).await {
Err(tungstenite::Error::AlreadyClosed) => break,
Err(e) => {
tracing::error!("Error writing to socket: {:?}", e);
break;
}
Ok(()) => {}
};
}
sleep(Duration::from_millis(100)).await;
}
});
let ias = ia_sender.clone();
let es = event_sender.clone();
let scs = simconnect_sender.clone();
futures.spawn(async move {
loop {
let message = match read.next().await {
Some(data) => match data {
Ok(message) => message,
Err(e) => {
tracing::error!("{:?}", e);
let _ = es.send(Event::Disconnected);
sleep(Duration::from_millis(100)).await;
continue;
}
},
None => break,
};
if let Ok(data) = Packets::decode(&message.into_data()) {
if data == Packets::System(SystemPacket::Ping) {
let _ = ias.send(Packets::System(SystemPacket::Pong));
continue;
}
// From Socket -> SimConnect
if let Packets::SimConnect(sim_connect_packet) = data {
tracing::info!("packet received: {:?}", &sim_connect_packet);
let _ = scs.send(sim_connect_packet);
}
}
sleep(Duration::from_millis(100)).await;
}
});
// Data from simconnect -> Socket
let mut socket_receiver = socket_receiver.resubscribe();
let ias = ia_sender.clone();
futures.spawn(async move {
loop {
if let Ok(message) = socket_receiver.try_recv() {
let _ = ias.send(Packets::SimConnect(message));
}
sleep(Duration::from_millis(100)).await;
}
});
tracing::info!("Connected");
let _ = event_sender.send(Event::Connected);
}
Event::Logout => {
let _ = ia_sender.send(Packets::System(SystemPacket::Close {
reason: "User Logout".to_string(),
}));
sleep(Duration::from_millis(200)).await;
futures.abort_all();
}
Event::Quit => {
tracing::info!("Shutting down Client");
let _ = ia_sender.send(Packets::System(SystemPacket::Close {
reason: "Quit".to_string(),
}));
sleep(Duration::from_millis(200)).await;
futures.abort_all();
break;
}
_ => {}
}
}
sleep(Duration::from_millis(100)).await;
}
tracing::info!("Client Shutdown");
Ok(())
}

167
avam-client/src/config.rs Normal file
View File

@@ -0,0 +1,167 @@
use std::{
io::Write,
sync::{Arc, RwLock},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
dirs::{Dirs, DirsError},
models::{CodeChallengeMethod, CodeVerifier},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(with = "arc_rwlock_serde")]
token: Arc<RwLock<Option<String>>>,
#[serde(with = "arc_rwlock_serde")]
toast_shown: Arc<RwLock<bool>>,
#[serde(skip)]
code_verifier: Arc<RwLock<Option<CodeVerifier>>>,
#[serde(skip)]
code_challenge_method: Arc<RwLock<Option<CodeChallengeMethod>>>,
#[serde(skip)]
open_browser: Arc<RwLock<bool>>,
}
impl Default for Config {
fn default() -> Self {
Self {
token: Default::default(),
toast_shown: Default::default(),
code_verifier: Default::default(),
code_challenge_method: Default::default(),
open_browser: Arc::new(RwLock::new(true)),
}
}
}
impl PartialEq for Config {
fn eq(&self, _: &Self) -> bool {
true
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Dirs(#[from] DirsError),
#[error(transparent)]
Toml(#[from] toml::ser::Error),
#[error(transparent)]
Config(#[from] config::ConfigError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
impl Config {
pub fn set_token(&self, token: Option<String>) -> Result<(), ConfigError> {
*self.token.write().unwrap() = token;
self.write()?;
Ok(())
}
pub fn token(&self) -> Option<String> {
self.token.read().unwrap().clone()
}
}
impl Config {
pub(crate) fn toast_shown(&self) -> bool {
*self.toast_shown.read().unwrap()
}
pub(crate) fn set_toast_shown(&self, value: bool) -> Result<(), ConfigError> {
*self.toast_shown.write().unwrap() = value;
self.write()?;
Ok(())
}
}
impl Config {
pub fn set_code_verifier(
&self,
code_verifier: Option<CodeVerifier>,
) -> Result<(), ConfigError> {
*self.code_verifier.write().unwrap() = code_verifier;
Ok(())
}
pub fn set_code_challenge_method(
&self,
code_challenge_method: Option<CodeChallengeMethod>,
) -> Result<(), ConfigError> {
*self.code_challenge_method.write().unwrap() = code_challenge_method;
Ok(())
}
pub fn code_verifier(&self) -> Option<CodeVerifier> {
self.code_verifier.read().unwrap().clone()
}
}
impl Config {
pub fn open_browser(&self) -> bool {
*self.open_browser.read().unwrap()
}
pub fn set_open_browser(&self, value: bool) -> Result<(), ConfigError> {
*self.open_browser.write().unwrap() = value;
Ok(())
}
}
impl Config {
pub(crate) fn new() -> Result<Self, ConfigError> {
let config = config::Config::builder()
.add_source(
config::File::with_name(Dirs::get_config_file()?.to_str().unwrap()).required(false),
)
.build()
.unwrap();
let token = config.get_string("token").ok();
let toast_shown = config.get_bool("toast_shown").unwrap_or_default();
let config = Self::default();
config.set_token(token)?;
config.set_toast_shown(toast_shown)?;
config.write()?;
Ok(config)
}
fn write(&self) -> Result<(), ConfigError> {
let toml = toml::to_string_pretty(self)?;
let mut file = std::fs::File::create(&Dirs::get_config_file()?)?;
file.write_all(toml.as_bytes())?;
Ok(())
}
}
mod arc_rwlock_serde {
use serde::de::Deserializer;
use serde::ser::Serializer;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
pub fn serialize<S, T>(val: &Arc<RwLock<T>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
T: Serialize,
{
T::serialize(&*val.read().unwrap(), s)
}
pub fn deserialize<'de, D, T>(d: D) -> Result<Arc<RwLock<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Arc::new(RwLock::new(T::deserialize(d)?)))
}
}

62
avam-client/src/dirs.rs Normal file
View File

@@ -0,0 +1,62 @@
use std::path::PathBuf;
use directories::ProjectDirs;
use thiserror::Error;
pub struct Dirs {}
#[derive(Debug, Error)]
pub enum DirsError {
#[error("Unable to get Project Directories")]
ProjectDirs,
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
impl Dirs {
pub fn get_config_dir() -> Result<PathBuf, DirsError> {
let Some(proj_dirs) = ProjectDirs::from("nl", "Avii", "Avam") else {
return Err(DirsError::ProjectDirs);
};
let config_dir = proj_dirs.config_local_dir();
if !config_dir.exists() {
std::fs::create_dir_all(config_dir)?;
}
Ok(config_dir.to_path_buf())
}
pub fn get_config_file() -> Result<PathBuf, DirsError> {
let mut c = Self::get_config_dir()?;
c.push("config.toml");
Ok(c)
}
pub fn get_lock_file() -> Result<PathBuf, DirsError> {
let mut c = Self::get_config_dir()?;
c.push(".lock");
Ok(c)
}
#[cfg(not(debug_assertions))]
pub fn get_log_dir() -> Result<PathBuf, DirsError> {
let c = Self::get_config_dir()?;
let mut log_dir = c.parent().unwrap().to_path_buf();
log_dir.push("logs");
if !log_dir.exists() {
std::fs::create_dir_all(&log_dir)?;
}
Ok(log_dir.to_path_buf())
}
#[cfg(not(debug_assertions))]
pub fn get_log_file() -> Result<PathBuf, DirsError> {
let mut l = Self::get_log_dir()?;
l.push("avam.log");
Ok(l)
}
}

117
avam-client/src/icon.rs Normal file
View File

@@ -0,0 +1,117 @@
use std::collections::HashMap;
use tray_icon::{
menu::{IsMenuItem, Menu, MenuId, MenuItemKind, PredefinedMenuItem},
TrayIconBuilder,
};
// The app winit thing can listen in on the same events
// coming from the state machine to give feedback to the user
// changing the icon, the alt texts, and the context menu
// based on the last received event
type Callback = Box<dyn Fn(&MenuItemKind) -> Result<(), anyhow::Error> + 'static>;
pub struct TrayIcon {
icon: tray_icon::Icon,
title: String,
menu_items: HashMap<MenuId, Callback>,
menu: Menu,
tray_icon: Option<tray_icon::TrayIcon>,
}
#[allow(unused)]
impl TrayIcon {
pub fn new(icon: tray_icon::Icon, title: &str) -> Self {
Self {
icon,
title: title.to_string(),
menu_items: HashMap::new(),
menu: Menu::new(),
tray_icon: None,
}
}
pub fn add_seperator(&mut self) -> Result<(), anyhow::Error> {
self.menu.append(&PredefinedMenuItem::separator())?;
Ok(())
}
pub fn add_menu_item<F>(
&mut self,
item: &dyn IsMenuItem,
callback: F,
) -> Result<MenuId, anyhow::Error>
where
F: Fn(&MenuItemKind) -> Result<(), anyhow::Error> + 'static,
{
let id = item.id().clone();
self.menu.append(item)?;
self.menu_items
.insert(id.clone(), Box::new(callback) as Callback);
Ok(id)
}
pub fn set_enabled(&self, id: &MenuId, enabled: bool) -> Result<(), anyhow::Error> {
if let Some(item) = self.menu.items().iter().find(|i| i.id() == id) {
if let Some(menuitem) = item.as_menuitem() {
menuitem.set_enabled(enabled);
}
if let Some(menuitem) = item.as_check_menuitem() {
menuitem.set_enabled(enabled);
}
if let Some(menuitem) = item.as_icon_menuitem() {
menuitem.set_enabled(enabled);
}
if let Some(menuitem) = item.as_submenu() {
menuitem.set_enabled(enabled);
}
}
Ok(())
}
pub fn set_text(&self, id: &MenuId, text: &str) -> Result<(), anyhow::Error> {
if let Some(item) = self.menu.items().iter().find(|i| i.id() == id) {
if let Some(menuitem) = item.as_menuitem() {
menuitem.set_text(text);
}
if let Some(menuitem) = item.as_check_menuitem() {
menuitem.set_text(text);
}
if let Some(menuitem) = item.as_icon_menuitem() {
menuitem.set_text(text);
}
if let Some(menuitem) = item.as_predefined_menuitem() {
menuitem.set_text(text);
}
if let Some(menuitem) = item.as_submenu() {
menuitem.set_text(text);
}
}
Ok(())
}
pub fn handle(&self, id: &MenuId) {
if let Some(item) = self.menu_items.get(id) {
if let Some(i) = self.menu.items().iter().find(|i| i.id() == id) {
if let Err(e) = item(i) {
tracing::error!("{:#?}", e);
}
}
}
}
pub fn build(&mut self) -> Result<(), anyhow::Error> {
self.tray_icon = Some(
TrayIconBuilder::new()
.with_tooltip(self.title.clone())
.with_menu(Box::new(self.menu.clone()))
.with_icon(self.icon.clone())
.build()?,
);
Ok(())
}
}

48
avam-client/src/lock.rs Normal file
View File

@@ -0,0 +1,48 @@
use std::{fs, path::PathBuf};
use crate::dirs::{Dirs, DirsError};
use thiserror::Error;
pub struct Lock {
force: bool,
lock_file: PathBuf,
}
#[derive(Debug, Error)]
pub enum LockError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Dirs(#[from] DirsError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
impl Lock {
pub fn new(force: bool) -> Result<Self, LockError> {
let lock_file = Dirs::get_lock_file()?;
Ok(Self { lock_file, force })
}
pub fn lock() {
if let Ok(lock_file) = Dirs::get_lock_file() {
if !fs::exists(&lock_file).unwrap() {
fs::write(&lock_file, "").expect("there to not be issues with the filesystem");
}
}
}
pub fn unlock() {
if let Ok(lock_file) = Dirs::get_lock_file() {
if fs::exists(&lock_file).unwrap() {
let _ = fs::remove_file(lock_file);
}
}
}
pub fn is_locked(&self) -> bool {
!self.force
&& fs::exists(&self.lock_file).expect("there to not be issues with the filesystem")
}
}

277
avam-client/src/main.rs Normal file
View File

@@ -0,0 +1,277 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![allow(clippy::needless_return)]
mod app;
mod client;
mod config;
mod dirs;
mod icon;
mod lock;
mod models;
mod oauth;
mod pipe;
mod simconnect;
mod state_machine;
use crate::config::Config;
use clap::Parser;
use lock::Lock;
use oauth::{start_code_listener, start_code_to_token};
use pipe::Pipe;
use state_machine::Event;
use tokio::{sync::broadcast::channel, task::JoinSet};
use tracing::Level;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
pub static AVAM_APP_ID: &str = "AvamToast-ECEB71694A5E6105";
pub static BASE_URL: &str = "https://avam.avii.nl";
pub static PROJECT_NAME: &str = "Avii's Virtual Airline Manager";
pub static COPYRIGHT: &str = "Avii's Virtual Airline Manager © 2024";
pub static CLIENT_ID: uuid::Uuid = uuid::uuid!("f9525060-0a34-4233-87e2-0f9990b7c6db");
pub static REDIRECT_URI: &str = "avam:token";
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Arguments {
#[arg(short, long)]
code: Option<String>,
#[arg(short, long, action)]
force: bool,
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
dotenvy::dotenv().ok();
init_logging()?;
let (event_sender, event_receiver) = channel(10);
let args = Arguments::parse();
if handle_single_instance(&args).await? {
return Ok(());
}
register_url_scheme()?;
register_notification_handler()?;
let config = Config::new()?;
let mut futures = JoinSet::new();
// Register Quit handler
let sender = event_sender.clone();
let mut ctrl_c_counter = 0;
ctrlc::set_handler(move || {
tracing::info!("CTRL_C: Quit singal sent");
ctrl_c_counter += 1;
if ctrl_c_counter >= 3 {
let _ = unregister_url_scheme();
Lock::unlock();
std::process::exit(1);
}
if let Err(e) = sender.send(Event::Quit) {
tracing::error!("{:#?}", e)
};
})?;
// Start the State Machine
let c = config.clone();
let sender = event_sender.clone();
let receiver = event_receiver.resubscribe();
futures.spawn(state_machine::start(c, sender, receiver));
// // Start the code listener
let receiver = event_receiver.resubscribe();
let (pipe_sender, pipe_receiver) = tokio::sync::broadcast::channel(10);
futures.spawn(start_code_listener(pipe_sender, receiver));
// Start token listener
let c = config.clone();
let sender = event_sender.clone();
let receiver = event_receiver.resubscribe();
futures.spawn(start_code_to_token(c, pipe_receiver, sender, receiver));
// Prepare channels for socket <-> simconnect
let (simconnect_sender, simconnect_receiver) = channel(10);
let (socket_sender, socket_receiver) = channel(10);
// Start the websocket client
let c = config.clone();
let sender = event_sender.clone();
let receiver = event_receiver.resubscribe();
futures.spawn(client::start(
c,
simconnect_sender,
socket_receiver,
sender,
receiver,
));
// 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
let sender = event_sender.clone();
let receiver = event_receiver.resubscribe();
futures.spawn(simconnect::start(
simconnect_receiver,
socket_sender,
sender,
receiver,
));
// Start the Tray Icon
let c = config.clone();
let sender = event_sender.clone();
let receiver = event_receiver.resubscribe();
app::start(c, sender, receiver).await?;
// Wait for everything to finish
while let Some(result) = futures.join_next().await {
if let Ok(Err(e)) = result {
panic!("{:#?}", e);
}
}
// Cleanup
unregister_url_scheme()?;
unregister_notification_handler()?;
Lock::unlock();
Ok(())
}
//
//
//
//
//
//
//
//
//
//
//
//
/// returns `Ok(true)` if we need to quit gracefully
/// returns `Err(e)` if we're already running
async fn handle_single_instance(args: &Arguments) -> Result<bool, anyhow::Error> {
let lock = Lock::new(args.force)?;
if lock.is_locked() && args.code.is_none() {
return Err(anyhow::anyhow!("Lockfile exists, exiting."));
}
if let Some(code) = &args.code {
Pipe::send(code).await?;
return Ok(true);
}
Lock::lock();
Ok(false)
}
fn register_url_scheme() -> Result<(), anyhow::Error> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let avam_schema_root = hkcu.create_subkey("Software\\Classes\\avam")?;
avam_schema_root.0.set_value("URL Protocol", &"")?;
let command = avam_schema_root.0.create_subkey("shell\\open\\command")?;
let current_exec = std::env::current_exe()?;
command.0.set_value(
"",
&format!("\"{}\" -c \"%1\"", current_exec.to_str().unwrap()),
)?;
Ok(())
}
fn unregister_url_scheme() -> Result<(), anyhow::Error> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
hkcu.delete_subkey_all("Software\\Classes\\avam").ok();
Ok(())
}
fn register_notification_handler() -> Result<(), anyhow::Error> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let avam_schema_root = hkcu.create_subkey(format!(
"Software\\Classes\\AppUserModelId\\{}",
AVAM_APP_ID
))?;
let current_exec = std::env::current_exe()?;
let mut icon = current_exec.parent().unwrap().to_path_buf();
icon.push("icon.png");
avam_schema_root
.0
.set_value("DisplayName", &crate::PROJECT_NAME)?;
avam_schema_root.0.set_value("IconBackgroundColor", &"0")?;
avam_schema_root
.0
.set_value("IconUri", &icon.to_str().unwrap())?;
Ok(())
}
fn unregister_notification_handler() -> Result<(), anyhow::Error> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
hkcu.delete_subkey_all(format!(
"Software\\Classes\\AppUserModelId\\{}",
AVAM_APP_ID
))
.ok();
Ok(())
}
fn init_logging() -> Result<(), anyhow::Error> {
#[cfg(not(debug_assertions))]
use dirs::Dirs;
#[cfg(not(debug_assertions))]
use std::{
fs::{self, File},
sync::Arc,
};
#[cfg(not(debug_assertions))]
let log_file = Dirs::get_log_file()?;
#[cfg(not(debug_assertions))]
if !log_file.exists() {
fs::write(&log_file, "")?;
}
#[cfg(not(debug_assertions))]
let file = File::options().append(true).open(&log_file)?;
let fmt = tracing_subscriber::fmt::layer();
#[cfg(not(debug_assertions))]
let fmt = fmt.with_ansi(false).with_writer(Arc::new(file));
let fmt = fmt.with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
metadata.level() < &Level::TRACE
}));
tracing_subscriber::registry().with(fmt).init();
Ok(())
}

272
avam-client/src/models.rs Normal file
View File

@@ -0,0 +1,272 @@
use std::str::FromStr;
use derive_more::{derive::From, Display};
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::REDIRECT_URI;
#[derive(
Clone, Debug, Display, From, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
pub struct RedirectUri(String);
impl RedirectUri {
pub fn new(uri: &str) -> Self {
Self(uri.to_string())
}
}
impl From<&str> for RedirectUri {
fn from(value: &str) -> Self {
Self::new(value)
}
}
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(from = "String")]
pub struct CodeVerifier(String);
impl From<String> for CodeVerifier {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for CodeVerifier {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl CodeVerifier {
pub fn new() -> Self {
let token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(120)
.map(char::from)
.collect();
Self(token)
}
}
impl Default for CodeVerifier {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Error)]
pub enum CodeChallengeMethodError {
#[error("Code challenge method is not valid.")]
Invalid,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum CodeChallengeMethod {
#[serde(rename = "plain")]
Plain,
#[serde(rename = "S256")]
Sha256,
}
impl FromStr for CodeChallengeMethod {
type Err = CodeChallengeMethodError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"plain" => Ok(Self::Plain),
"S256" => Ok(Self::Sha256),
_ => Err(CodeChallengeMethodError::Invalid),
}
}
}
#[derive(Debug, Error)]
pub enum ResponseTypeError {
#[error("The response type is not valid.")]
Invalid,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ResponseType {
#[serde(rename = "code")]
Code,
}
impl Display for ResponseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
ResponseType::Code => "code",
}
)
}
}
impl FromStr for ResponseType {
type Err = ResponseTypeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"code" => Ok(Self::Code),
_ => Err(ResponseTypeError::Invalid),
}
}
}
#[derive(Debug, Error)]
pub enum GrantTypeError {
#[error("The grant type is not valid.")]
Invalid,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum GrantType {
#[serde(rename = "authorization_code")]
AuthorizationCode,
}
impl Display for GrantType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
GrantType::AuthorizationCode => "authorization_code",
}
)
}
}
impl FromStr for GrantType {
type Err = GrantTypeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"authorization_code" => Ok(Self::AuthorizationCode),
_ => Err(GrantTypeError::Invalid),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizeRequest {
client_id: uuid::Uuid,
response_type: ResponseType, // Make type (enum:code,)
state: Option<String>, // random string for CSRF protection
code_challenge: String, // pkce
code_challenge_method: Option<CodeChallengeMethod>, // Make type (enum:sha256,) hashing algo
redirect_uri: RedirectUri, // Make type
scope: Option<String>, // space seperated string with permissions
}
impl AuthorizeRequest {
pub fn new(
client_id: uuid::Uuid,
response_type: ResponseType,
state: Option<String>,
code_challenge: String,
code_challenge_method: Option<CodeChallengeMethod>,
redirect_uri: RedirectUri,
scope: Option<String>,
) -> Self {
Self {
client_id,
response_type,
state,
code_challenge,
code_challenge_method,
redirect_uri,
scope,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizationResponse {
code: String,
state: Option<String>,
}
impl TryFrom<String> for AuthorizationResponse {
type Error = anyhow::Error;
fn try_from(s: String) -> Result<Self, Self::Error> {
let mut parts = s.split('?');
let Some(protocol) = parts.next() else {
return Err(anyhow::anyhow!("Invalid AuthorizationResponse"));
};
if protocol != REDIRECT_URI {
return Err(anyhow::anyhow!("Invalid AuthorizationResponse"));
}
let Some(qs) = parts.next() else {
return Err(anyhow::anyhow!("Invalid AuthorizationResponse"));
};
if parts.count() > 0 {
return Err(anyhow::anyhow!("Invalid AuthorizationResponse"));
}
Ok(serde_qs::from_str(qs)?)
}
}
impl AuthorizationResponse {
pub fn code(&self) -> String {
self.code.clone()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizationCodeRequest {
grant_type: GrantType,
code: String,
redirect_uri: RedirectUri,
client_id: uuid::Uuid,
code_verifier: CodeVerifier,
}
impl AuthorizationCodeRequest {
pub fn new(
grant_type: GrantType,
code: String,
redirect_uri: RedirectUri,
client_id: uuid::Uuid,
code_verifier: CodeVerifier,
) -> Self {
Self {
grant_type,
code,
redirect_uri,
client_id,
code_verifier,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizationCodeResponse {
token: String,
}
impl AuthorizationCodeResponse {
pub fn token(&self) -> String {
self.token.clone()
}
}

116
avam-client/src/oauth.rs Normal file
View File

@@ -0,0 +1,116 @@
use std::time::Duration;
use thiserror::Error;
use tokio::{
sync::broadcast::{Receiver, Sender},
time::sleep,
};
use crate::{
config::{Config, ConfigError},
models::*,
pipe::Pipe,
state_machine::Event,
BASE_URL, CLIENT_ID, REDIRECT_URI,
};
#[derive(Debug, Error)]
pub enum OpenBrowserError {
#[error(transparent)]
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 {
CodeChallengeMethod::Plain => {
use base64::prelude::*;
BASE64_URL_SAFE_NO_PAD.encode(code_verifier.to_string())
}
CodeChallengeMethod::Sha256 => {
use base64::prelude::*;
BASE64_URL_SAFE_NO_PAD.encode(sha256::digest(code_verifier.to_string()))
}
};
let request = AuthorizeRequest::new(
CLIENT_ID,
ResponseType::Code,
None,
code_challenge.clone(),
Some(code_challenge_method.clone()),
RedirectUri::new(REDIRECT_URI),
None,
);
let qs = serde_qs::to_string(&request)?;
open::that(format!("{}/oauth2/authorize?{}", BASE_URL, qs))?;
Ok(())
}
pub async fn start_code_listener(
pipe_sender: Sender<AuthorizationResponse>,
event_receiver: Receiver<Event>,
) -> Result<(), anyhow::Error> {
let pipe = Pipe::new(event_receiver.resubscribe());
pipe.listen(pipe_sender).await?;
Ok(())
}
pub async fn start_code_to_token(
config: Config,
mut pipe_receiver: Receiver<AuthorizationResponse>,
event_sender: Sender<Event>,
mut event_receiver: Receiver<Event>,
) -> Result<(), anyhow::Error> {
loop {
tokio::select! {
_ = sleep(Duration::from_millis(100)) => {
if let Ok(Event::Quit) = event_receiver.try_recv() {
tracing::info!("Shutting down Code Transformer");
break;
}
}
Ok(response) = pipe_receiver.recv() => {
let r = AuthorizationCodeRequest::new(
GrantType::AuthorizationCode,
response.code(),
REDIRECT_URI.into(),
CLIENT_ID,
config.code_verifier().unwrap()
);
let qs = serde_qs::to_string(&r)?;
let client = reqwest::Client::new();
let response = client
.post(format!("{}/oauth2/token", BASE_URL))
.body(qs)
.send()
.await?;
let response: AuthorizationCodeResponse = response.json().await?;
let token = response.token();
config.set_token(Some(token.clone()))?;
event_sender.send(Event::TokenReceived)?;
}
}
}
tracing::info!("Code Transformer Shutdown");
Ok(())
}

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

@@ -0,0 +1,97 @@
use interprocess::os::windows::named_pipe::{pipe_mode, tokio::*, PipeListenerOptions};
use std::path::Path;
use std::time::Duration;
use thiserror::Error;
use tokio::sync::broadcast::{Receiver, Sender};
use tokio::time::sleep;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
select,
};
use crate::models::AuthorizationResponse;
use crate::state_machine::Event;
#[derive(Debug, Error)]
pub enum PipeError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
pub struct Pipe {
quit_signal: Receiver<Event>,
}
const PIPE_NAME: &str = "AvamICP";
impl Pipe {
pub fn new(quit_signal: Receiver<Event>) -> Self {
Self { quit_signal }
}
pub async fn send(msg: &str) -> Result<(), PipeError> {
let conn = DuplexPipeStream::<pipe_mode::Bytes>::connect_by_path(format!(
r"\\.\pipe\{}",
PIPE_NAME
))
.await?;
let (_, mut sender) = conn.split();
sender.write_all(msg.as_bytes()).await?;
sender.shutdown().await?;
Ok(())
}
pub async fn listen(
&self,
pipe_sender: Sender<AuthorizationResponse>,
) -> Result<(), PipeError> {
// todo: give state so we can gracefully shutdown the pipe
let mut quit_signal = self.quit_signal.resubscribe();
let listener = PipeListenerOptions::new()
.path(Path::new(&format!(r"\\.\pipe\{}", PIPE_NAME)))
.create_tokio_duplex::<pipe_mode::Bytes>()?;
loop {
select! {
Ok(conn) = listener.accept() => {
let new_sender = pipe_sender.clone();
tokio::spawn(async move {
if let Err(e) = Self::handle_conn(conn, new_sender).await {
tracing::error!("error while handling connection: {e}");
}
});
},
_ = sleep(Duration::from_millis(100)) => {
if let Ok(Event::Quit) = quit_signal.try_recv() {
tracing::info!("Shutting down Code Listener");
break;
}
}
}
}
tracing::info!("Code Listener Shutdown");
Ok(())
}
async fn handle_conn(
conn: DuplexPipeStream<pipe_mode::Bytes>,
pipe_sender: Sender<AuthorizationResponse>,
) -> Result<(), PipeError> {
let (mut recver, _) = conn.split();
let mut buffer = Vec::with_capacity(512);
recver.read_buf(&mut buffer).await?;
let as_string = String::from_utf8_lossy(&buffer).to_string();
let response: AuthorizationResponse = as_string.try_into()?;
let _ = pipe_sender.send(response);
Ok(())
}
}

View File

@@ -0,0 +1,312 @@
mod models;
use avam_protocol::SimConnectPacket;
use core::ops::ControlFlow;
use models::*;
use simconnect_sdk::{Notification, Object, SimConnect as SC, SystemEvent, SystemEventRequest};
use std::{sync::Arc, time::Duration};
use tokio::{
sync::{
broadcast::{Receiver, Sender},
RwLock,
},
time::sleep,
};
use crate::state_machine::Event;
pub struct SimState {
atc_id: String,
// gps location
// fuel state
// cargo state
// is landed
// is stopped
// etc
// basically, all the data we wanna send to socket
// we also need to know if we're in a flight here, and what flight,
// mission parameters etc maybe
// unless we just wanna keep that purely serverside..
// and only process incoming packets if there is an activem mission.
// the thing i _dont_ want, is to be invasive with alerts and notifications
// that the user is "forgetting" to start a flight etc with AVAM running
// basically, avam should be able to be running always without the user noticing
// any interaction is done via the dashboard
// starting, stopping, status etc
// eventually even with "realistic" flightplans, from time of takeoff, estimate time of arrival,
// and even have time-critical jobs that need to get finished before a set time
}
impl Default for SimState {
fn default() -> Self {
Self {
atc_id: String::from("AVAM"),
}
}
}
pub struct SimConnect {
simconnect_receiver: Receiver<SimConnectPacket>, // Data from the socket
socket_sender: Sender<SimConnectPacket>, // Data to the socket
sender: Sender<Event>,
receiver: Receiver<Event>,
state: Arc<RwLock<SimState>>,
}
impl SimConnect {
async fn open(&self, client: &mut SC) -> Result<(), anyhow::Error> {
client.subscribe_to_system_event(SystemEventRequest::AircraftLoaded)?;
client.subscribe_to_system_event(SystemEventRequest::SimStart)?;
let id = client.register_object::<AtcID>()?;
client.register_object::<Airplane>()?;
client.register_object::<Fuel>()?;
client.register_object::<Gps>()?;
client.register_object::<OnGround>()?;
client.register_object::<IsParked>()?;
let value = self.state.read().await.atc_id.clone();
tracing::info!("Updating ATC_ID: {}", &value);
client.set_data_on_sim_object_with_id(id, &mut atc_id(&value)?)?;
let _ = self.sender.send(Event::SimConnected);
Ok(())
}
async fn object(&self, _: &mut SC, object: &Object) -> Result<(), anyhow::Error> {
if let Ok(data) = Airplane::try_from(object) {
self.socket_sender
.send(SimConnectPacket::Airplane(avam_protocol::Airplane {
atc_type: data.atc_type,
atc_model: data.atc_model,
title: data.title,
category: data.category,
}))?;
// We've already got our data, there's no point in trying another in this iteration
return Ok(());
}
if let Ok(data) = Fuel::try_from(object) {
self.socket_sender
.send(SimConnectPacket::Fuel(avam_protocol::Fuel {
weight: data.weight,
center_quantity: data.center_quantity,
center_capacity: data.center_capacity,
center2_quantity: data.center2_quantity,
center2_capacity: data.center2_capacity,
center3_quantity: data.center3_quantity,
center3_capacity: data.center3_capacity,
left_main_quantity: data.left_main_quantity,
left_main_capacity: data.left_main_capacity,
left_aux_quantity: data.left_aux_quantity,
left_aux_capacity: data.left_aux_capacity,
left_tip_quantity: data.left_tip_quantity,
left_tip_capacity: data.left_tip_capacity,
right_main_quantity: data.right_main_quantity,
right_main_capacity: data.right_main_capacity,
right_aux_quantity: data.right_aux_quantity,
right_aux_capacity: data.right_aux_capacity,
right_tip_quantity: data.right_tip_quantity,
right_tip_capacity: data.right_tip_capacity,
external1_quantity: data.external1_quantity,
external1_capacity: data.external1_capacity,
external2_quantity: data.external2_quantity,
external2_capacity: data.external2_capacity,
}))?;
// We've already got our data, there's no point in trying another in this iteration
return Ok(());
}
if let Ok(data) = Gps::try_from(object) {
self.socket_sender
.send(SimConnectPacket::Gps(avam_protocol::Gps {
lat: data.lat,
lon: data.lon,
alt: data.alt,
}))?;
// We've already got our data, there's no point in trying another in this iteration
return Ok(());
}
if let Ok(data) = OnGround::try_from(object) {
self.socket_sender
.send(SimConnectPacket::OnGround(avam_protocol::OnGround {
sim_on_ground: data.sim_on_ground,
}))?;
// We've already got our data, there's no point in trying another in this iteration
return Ok(());
}
// Change to parking break state or something, unless we're a helicopter, then we dont have one
if let Ok(data) = IsParked::try_from(object) {
self.socket_sender
.send(SimConnectPacket::IsParked(avam_protocol::IsParked {
is_parked: data.is_parked,
}))?;
// We've already got our data, there's no point in trying another in this iteration
return Ok(());
}
Ok(())
}
async fn system_event(
&self,
client: &mut SC,
event: &SystemEvent,
) -> Result<(), anyhow::Error> {
match event {
SystemEvent::AircraftLoaded { .. } => {
tracing::info!("Aircraft Loaded!");
let id = client.get_object_id::<AtcID>()?;
let value = self.state.read().await.atc_id.clone();
tracing::info!("Updating ATC_ID: {}", &value);
for _ in 1..=10 {
client.set_data_on_sim_object_with_id(id, &mut atc_id(&value)?)?;
sleep(Duration::from_millis(50)).await;
}
}
SystemEvent::SimStart => {
tracing::info!("Sim Start");
}
// SystemEvent::OneSecond => todo!(),
// SystemEvent::FourSeconds => todo!(),
// SystemEvent::SixTimesPerSecond => todo!(),
// SystemEvent::AircraftLoaded { file_name } => todo!(),
// SystemEvent::Crashed => todo!(),
// SystemEvent::CrashReset => todo!(),
// SystemEvent::FlightSaved { file_name } => todo!(),
// SystemEvent::FlightPlanActivated { file_name } => todo!(),
// SystemEvent::FlightPlanDeactivated => todo!(),
// SystemEvent::Frame {
// frame_rate,
// sim_speed,
// } => todo!(),
// SystemEvent::Pause { state } => todo!(),
// SystemEvent::Paused => todo!(),
// SystemEvent::PauseFrame {
// frame_rate,
// sim_speed,
// } => todo!(),
// SystemEvent::PositionChanged => todo!(),
// SystemEvent::Sim { state } => todo!(),
// SystemEvent::SimStart => todo!(),
// SystemEvent::SimStop => todo!(),
// SystemEvent::Sound { state } => todo!(),
// SystemEvent::Unpaused => todo!(),
// SystemEvent::View { view } => todo!(),
_ => todo!(),
}
Ok(())
}
}
impl SimConnect {
pub fn new(
simconnect_receiver: Receiver<SimConnectPacket>,
socket_sender: Sender<SimConnectPacket>,
sender: Sender<Event>,
receiver: Receiver<Event>,
) -> Self {
Self {
simconnect_receiver,
socket_sender,
sender,
receiver,
state: Default::default(),
}
}
pub async fn run(&self) -> Result<(), anyhow::Error> {
let mut receiver = self.receiver.resubscribe();
let mut simconnect_receiver = self.simconnect_receiver.resubscribe();
let state = self.state.clone();
let mut sc: Option<SC> = None;
loop {
if let Ok(Event::Quit) = receiver.try_recv() {
break;
}
if let Ok(packet) = simconnect_receiver.try_recv() {
match packet {
SimConnectPacket::AtcID(value) => {
state.write().await.atc_id = value.clone();
if let Some(client) = sc.as_mut() {
if let Ok(id) = client.get_object_id::<AtcID>() {
tracing::info!("Updating ATC_ID: {}", &value);
client.set_data_on_sim_object_with_id(id, &mut atc_id(&value)?)?;
}
}
}
SimConnectPacket::Fuel(_) => unreachable!(), // Outgoing packet, not handled here (yet) we probably need to set fuel state somehow
SimConnectPacket::Airplane(_) => unreachable!(),
SimConnectPacket::Gps(_) => unreachable!(), // Outgoing packet, not handled here (yet) we probably need to set position somehow
SimConnectPacket::OnGround(_) => unreachable!(), // Outgoing packet, not handled here
SimConnectPacket::IsParked(_) => unreachable!(), // Outgoing packet, not handled here
}
}
if sc.is_none() {
if let Ok(simconnect) = SC::new("Avii's Virtual Airline Manager") {
sc = Some(simconnect);
tracing::info!("Connected");
}
}
if let Some(client) = sc.as_mut() {
if self.handle(client).await.is_break() {
tracing::info!("Disconnected");
sc = None;
}
}
sleep(Duration::from_millis(100)).await;
}
Ok(())
}
async fn handle(&self, client: &mut SC) -> ControlFlow<(), ()> {
if let Ok(Some(event)) = client.get_next_dispatch() {
if let Err(e) = match event {
Notification::Open => self.open(client).await,
Notification::Object(ref object) => self.object(client, object).await,
Notification::SystemEvent(ref system_event) => {
self.system_event(client, system_event).await
}
// Notification::ClientEvent(ref client_event) => todo!(),
// Notification::AirportList(ref vec) => todo!(),
// Notification::WaypointList(ref vec) => todo!(),
// Notification::NdbList(ref vec) => todo!(),
// Notification::VorList(ref vec) => todo!(),
Notification::Quit => {
let _ = self.sender.send(Event::SimDisconnected);
return ControlFlow::Break(());
}
_ => todo!(),
} {
tracing::error!("Error handling SimConnect::Notification: {:#?}", e);
};
}
ControlFlow::Continue(())
}
}
pub async fn start(
simconnect_receiver: Receiver<SimConnectPacket>,
socket_sender: Sender<SimConnectPacket>,
sender: Sender<Event>,
receiver: Receiver<Event>,
) -> Result<(), anyhow::Error> {
SimConnect::new(simconnect_receiver, socket_sender, sender, receiver)
.run()
.await?;
Ok(())
}

View File

@@ -0,0 +1,123 @@
use std::ffi::CString;
use simconnect_sdk::SimConnectObject;
#[allow(unused)]
#[derive(Debug, Clone, SimConnectObject)]
#[simconnect(period = "second", condition = "changed")]
pub(super) struct AtcID {
#[simconnect(name = "ATC ID")]
pub value: String,
}
pub(super) fn atc_id(value: &str) -> Result<AtcIDCPacked, anyhow::Error> {
if value.len() > 10 {
return Err(anyhow::anyhow!("ATC_ID exceeding 10 characters"));
}
let cs = CString::new(value.to_uppercase())?;
let mut buffer = [0i8; 256];
for (i, b) in cs.to_bytes_with_nul().iter().enumerate() {
buffer[i] = *b as i8;
}
Ok(AtcIDCPacked { value: buffer })
}
#[derive(Debug, Clone, SimConnectObject)]
#[simconnect(period = "second", condition = "changed")]
pub(super) struct Airplane {
#[simconnect(name = "ATC TYPE")]
pub atc_type: String,
#[simconnect(name = "ATC MODEL")]
pub atc_model: String,
#[simconnect(name = "TITLE")]
pub title: String,
#[simconnect(name = "CATEGORY")]
pub category: String,
}
#[derive(Debug, Clone, SimConnectObject)]
#[simconnect(period = "second", condition = "changed")]
pub(super) struct Fuel {
#[simconnect(name = "FUEL TANK CENTER QUANTITY", unit = "gallons")]
pub center_quantity: f64,
#[simconnect(name = "FUEL TANK CENTER CAPACITY", unit = "gallons")]
pub center_capacity: f64,
#[simconnect(name = "FUEL TANK CENTER2 QUANTITY", unit = "gallons")]
pub center2_quantity: f64,
#[simconnect(name = "FUEL TANK CENTER2 CAPACITY", unit = "gallons")]
pub center2_capacity: f64,
#[simconnect(name = "FUEL TANK CENTER3 QUANTITY", unit = "gallons")]
pub center3_quantity: f64,
#[simconnect(name = "FUEL TANK CENTER3 CAPACITY", unit = "gallons")]
pub center3_capacity: f64,
#[simconnect(name = "FUEL TANK LEFT MAIN QUANTITY", unit = "gallons")]
pub left_main_quantity: f64,
#[simconnect(name = "FUEL TANK LEFT MAIN CAPACITY", unit = "gallons")]
pub left_main_capacity: f64,
#[simconnect(name = "FUEL TANK LEFT AUX QUANTITY", unit = "gallons")]
pub left_aux_quantity: f64,
#[simconnect(name = "FUEL TANK LEFT AUX CAPACITY", unit = "gallons")]
pub left_aux_capacity: f64,
#[simconnect(name = "FUEL TANK LEFT TIP QUANTITY", unit = "gallons")]
pub left_tip_quantity: f64,
#[simconnect(name = "FUEL TANK LEFT TIP CAPACITY", unit = "gallons")]
pub left_tip_capacity: f64,
#[simconnect(name = "FUEL TANK RIGHT MAIN QUANTITY", unit = "gallons")]
pub right_main_quantity: f64,
#[simconnect(name = "FUEL TANK RIGHT MAIN CAPACITY", unit = "gallons")]
pub right_main_capacity: f64,
#[simconnect(name = "FUEL TANK RIGHT AUX QUANTITY", unit = "gallons")]
pub right_aux_quantity: f64,
#[simconnect(name = "FUEL TANK RIGHT AUX CAPACITY", unit = "gallons")]
pub right_aux_capacity: f64,
#[simconnect(name = "FUEL TANK RIGHT TIP QUANTITY", unit = "gallons")]
pub right_tip_quantity: f64,
#[simconnect(name = "FUEL TANK RIGHT TIP CAPACITY", unit = "gallons")]
pub right_tip_capacity: f64,
#[simconnect(name = "FUEL TANK EXTERNAL1 QUANTITY", unit = "gallons")]
pub external1_quantity: f64,
#[simconnect(name = "FUEL TANK EXTERNAL1 CAPACITY", unit = "gallons")]
pub external1_capacity: f64,
#[simconnect(name = "FUEL TANK EXTERNAL2 QUANTITY", unit = "gallons")]
pub external2_quantity: f64,
#[simconnect(name = "FUEL TANK EXTERNAL2 CAPACITY", unit = "gallons")]
pub external2_capacity: f64,
#[simconnect(name = "FUEL WEIGHT PER GALLON", unit = "pounds")]
pub weight: f64,
}
#[derive(Debug, Clone, SimConnectObject)]
#[simconnect(period = "second", condition = "changed")]
pub(super) struct Gps {
#[simconnect(name = "PLANE LATITUDE", unit = "degrees")]
pub lat: f64,
#[simconnect(name = "PLANE LONGITUDE", unit = "degrees")]
pub lon: f64,
#[simconnect(name = "PLANE ALTITUDE", unit = "feet")]
pub alt: f64,
}
#[derive(Debug, Clone, SimConnectObject)]
#[simconnect(period = "second", condition = "changed")]
pub(super) struct OnGround {
#[simconnect(name = "SIM ON GROUND")]
pub sim_on_ground: bool,
}
#[derive(Debug, Clone, SimConnectObject)]
#[simconnect(period = "second", condition = "changed")]
pub(super) struct IsParked {
#[simconnect(name = "PLANE IN PARKING STATE")]
pub is_parked: bool,
}

View File

@@ -0,0 +1,110 @@
use std::time::Duration;
use tokio::{
sync::broadcast::{Receiver, Sender},
time::sleep,
};
use crate::{config::Config, oauth};
#[derive(Debug, Clone, PartialEq)]
pub enum State {
Init,
AppStart,
Shutdown,
Authenticate,
Connect,
WaitForSim,
InSim,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
Ready,
StartAuthenticate, // should not be string
TokenReceived, // 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
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
SimDisconnected, // SimConnect is disconnected, we've finished the flight and exited back to the menu, transition back to WaitForSim
Logout,
Quit,
}
impl State {
pub async fn next(self, event: Event) -> State {
match (self.clone(), event.clone()) {
// (Current State, SomeEvent) => NextState
(_, Event::Ready) => State::AppStart,
(_, Event::Logout) => State::AppStart,
(_, Event::StartAuthenticate) => Self::Authenticate, // Goto Authenticate
(_, Event::TokenReceived) => State::Connect,
(_, Event::Connected) => State::WaitForSim, // Goto WaitForSim
(_, Event::Disconnected) => {
sleep(Duration::from_secs(5)).await; // wait 5 seconds before reconnecting
tracing::info!("Attempting reconnect");
State::AppStart // Goto Connect
}
(_, Event::SimConnected) => State::InSim, // Goto InSim
(_, Event::SimDisconnected) => State::WaitForSim, // Goto WaitForSim
(_, Event::Quit) => State::Shutdown, // All events can go into quit, to shutdown the application
}
}
pub async fn run(&self, signal: Sender<Event>, config: Config) -> Result<(), anyhow::Error> {
match self {
State::Init => Ok(()),
State::AppStart => {
if config.token().is_some() {
signal.send(Event::TokenReceived)?;
} else {
signal.send(Event::StartAuthenticate)?;
}
Ok(())
}
State::Authenticate => {
if config.open_browser() {
oauth::open_browser(config.clone())?;
}
Ok(())
}
State::Connect => Ok(()),
State::WaitForSim => Ok(()),
State::InSim => Ok(()),
State::Shutdown => Ok(()),
}
}
}
pub async fn start(
config: Config,
event_sender: Sender<Event>,
mut event_receiver: Receiver<Event>,
) -> Result<(), anyhow::Error> {
let mut state = State::Init;
state.run(event_sender.clone(), config.clone()).await?;
loop {
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 {
tracing::info!("Shutting down State Machine");
break;
}
}
sleep(Duration::from_millis(100)).await;
}
tracing::info!("State Machine Shutdown");
Ok(())
}

12
avam-protocol/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "avam-protocol"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.90"
bincode = "1.3.3"
derive_more = "1.0.0"
flate2 = { version = "1.0.34", features = ["zlib-rs"] }
serde = { version = "1.0.210", features = ["derive"] }
thiserror = "1.0.64"

194
avam-protocol/src/lib.rs Normal file
View File

@@ -0,0 +1,194 @@
use derive_more::derive::Display;
use serde::{de, Deserialize, Serialize};
#[derive(Debug, Display, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub enum SystemPacket {
Ping,
Pong,
Close { reason: String },
}
#[derive(Debug, Display, Clone, Deserialize, Serialize)]
pub enum SimConnectPacket {
AtcID(String),
Airplane(Airplane),
Fuel(Fuel),
Gps(Gps),
OnGround(OnGround),
IsParked(IsParked),
}
#[derive(Debug, Clone, Display, Deserialize, Serialize)]
#[display("[{atc_type} - {atc_model}] {title} ({category})")]
pub struct Airplane {
pub atc_type: String,
pub atc_model: String,
pub title: String,
pub category: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Fuel {
pub center_quantity: f64,
pub center_capacity: f64,
pub center2_quantity: f64,
pub center2_capacity: f64,
pub center3_quantity: f64,
pub center3_capacity: f64,
pub left_main_quantity: f64,
pub left_main_capacity: f64,
pub left_aux_quantity: f64,
pub left_aux_capacity: f64,
pub left_tip_quantity: f64,
pub left_tip_capacity: f64,
pub right_main_quantity: f64,
pub right_main_capacity: f64,
pub right_aux_quantity: f64,
pub right_aux_capacity: f64,
pub right_tip_quantity: f64,
pub right_tip_capacity: f64,
pub external1_quantity: f64,
pub external1_capacity: f64,
pub external2_quantity: f64,
pub external2_capacity: f64,
pub weight: f64,
}
impl Fuel {
pub fn total_quantity(&self) -> f64 {
self.center_quantity
+ self.center2_quantity
+ self.center3_quantity
+ self.left_main_quantity
+ self.left_aux_quantity
+ self.left_tip_quantity
+ self.right_main_quantity
+ self.right_aux_quantity
+ self.right_tip_quantity
+ self.external1_quantity
+ self.external2_quantity
}
pub fn total_capacity(&self) -> f64 {
self.center_capacity
+ self.center2_capacity
+ self.center3_capacity
+ self.left_main_capacity
+ self.left_aux_capacity
+ self.left_tip_capacity
+ self.right_main_capacity
+ self.right_aux_capacity
+ self.right_tip_capacity
+ self.external1_capacity
+ self.external2_capacity
}
}
impl core::fmt::Display for Fuel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let percent = (100.0 / self.total_capacity()) * self.total_quantity();
write!(
f,
"{}GL/{}GL ({}%)",
self.total_quantity(),
self.total_capacity(),
percent
)
}
}
#[derive(Debug, Clone, Display, Deserialize, Serialize)]
#[display("{lat}, {lon} at {alt}ft")]
pub struct Gps {
pub lat: f64,
pub lon: f64,
pub alt: f64,
}
#[derive(Debug, Clone, Display, Deserialize, Serialize)]
pub struct OnGround {
pub sim_on_ground: bool,
}
#[derive(Debug, Clone, Display, Deserialize, Serialize)]
pub struct IsParked {
pub is_parked: bool,
}
// #[derive(Debug, Display, Clone, Deserialize, Serialize)]
// #[display("[{icao}] {lat} {lon} {alt}")]
// pub struct Airport {
// pub icao: String,
// pub region: String,
// pub lat: f64,
// pub lon: f64,
// pub alt: f64,
// }
impl PartialEq for SimConnectPacket {
fn eq(&self, other: &Self) -> bool {
matches!(
(self, other),
(Self::AtcID(_), Self::AtcID(_)) | (Self::Fuel(_), Self::Fuel(_))
)
}
}
impl Eq for SimConnectPacket {}
#[derive(Debug, Display, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub enum Packets {
System(SystemPacket),
SimConnect(SimConnectPacket),
}
impl Packet for Packets {}
pub trait Packet: Serialize + de::DeserializeOwned + Sized {
fn encode(&self) -> Result<Vec<u8>, anyhow::Error> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let encoded = bincode::serialize(&self)?;
let mut compressor = GzEncoder::new(Vec::new(), Compression::best());
compressor.write_all(&encoded)?;
Ok(compressor.finish()?)
}
fn decode(data: &[u8]) -> Result<Self, anyhow::Error> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decompressor = GzDecoder::new(data);
let mut buffer = Vec::new();
decompressor.read_to_end(&mut buffer)?;
Ok(bincode::deserialize(&buffer)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let msg = Packets::System(SystemPacket::Ping);
let decoded = Packet::decode(&msg.encode().unwrap()).unwrap();
assert_eq!(msg, decoded);
}
}

View File

@@ -0,0 +1,5 @@
-- Add down migration script here
DROP TABLE "refresh_tokens";
DROP TABLE "authorized_clients";
DROP TABLE "authorization_code";
DROP TABLE "clients";

View File

@@ -0,0 +1,40 @@
-- Add up migration script here
CREATE TABLE "clients" (
"id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"secret" text NOT NULL,
"redirect_uri" text NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "clients_name_key" UNIQUE ("name"),
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
) WITH (oids = false);
CREATE TABLE "authorization_code" (
"code" text NOT NULL,
"user_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"code_challenge" text NOT NULL,
"code_challenge_method" text NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "authorization_code_pkey" PRIMARY KEY ("code")
) WITH (oids = false);
CREATE TABLE "authorized_clients" (
"user_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP, -- last accessed
CONSTRAINT "authorized_clients_pkey" PRIMARY KEY ("user_id", "client_id")
) WITH (oids = false);
CREATE TABLE "refresh_tokens" (
"token" text NOT NULL,
"user_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"expires_at" timestamp DEFAULT CURRENT_TIMESTAMP + interval '30' day,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("token")
) WITH (oids = false);

View File

@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE "airports";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE "ower_history";
DROP TABLE "aircrafts";

View File

@@ -0,0 +1,66 @@
-- Add up migration script here
CREATE TABLE "aircrafts" (
"id" uuid NOT NULL,
"registration" text NOT NULL,
"category" text NOT NULL,
"manufacturer" text NOT NULL,
"model" text NOT NULL,
"on_ground" boolean NOT NULL DEFAULT 1,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "aircrafts_pkey" PRIMARY KEY ("id"),
CONSTRAINT "aircrafts_registration_unique" UNIQUE ("registration")
) WITH (oids = false);
CREATE TABLE "gps" (
"aircraft_id" uuid NOT NULL,
"latitude_deg" numeric NOT NULL,
"longitude_deg" numeric NOT NULL,
"elevation_ft" numeric NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gps_pkey" PRIMARY KEY ("aircraft_id")
)
CREATE TABLE "fuel" (
"aircraft_id" uuid NOT NULL,
"center_quantity" numeric,
"center_capacity" numeric,
"center2_quantity" numeric,
"center2_capacity" numeric,
"center3_quantity" numeric,
"center3_capacity" numeric,
"left_main_quantity" numeric,
"left_main_capacity" numeric,
"left_aux_quantity" numeric,
"left_aux_capacity" numeric,
"left_tip_quantity" numeric,
"left_tip_capacity" numeric,
"right_main_quantity" numeric,
"right_main_capacity" numeric,
"right_aux_quantity" numeric,
"right_aux_capacity" numeric,
"right_tip_quantity" numeric,
"right_tip_capacity" numeric,
"external1_quantity" numeric,
"external1_capacity" numeric,
"external2_quantity" numeric,
"external2_capacity" numeric,
"fuel_weight" numeric,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fuel_pkey" PRIMARY KEY ("aircraft_id")
)
-- If a player is in an aircraft that doesnt exist on our end, ask them to "purchace" it from the factory
-- What's the pricing going to look like though... hmm
-- If a pilot buys a new aircraft they need to get in it at least once to get all the fuel-cell data, we don't know beforehand how many tanks it has
-- after updating, the capacity should be > 0 to indicate which ones are used

View File

@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE "pilots";

View File

@@ -0,0 +1,25 @@
-- Add up migration script here
CREATE TABLE "pilots" (
"id" uuid NOT NULL, -- use the random-person-generator's id?
"user_id" uuid NOT NULL,
"full_name" text NOT NULL,
"date_of_birth" timestamp NOT NULL,
"gender" text NOT NULL,
"nationality" text NOT NULL,
"photo" text NOT NULL, -- base64 encoded image from RPG?
-- Pilot also needs to keep track of career progress
-- Flight lessons etc?
-- current location, i think we can use lat/lon to infer an airport location
"latitude_deg" numeric NOT NULL,
"longitude_deg" numeric NOT NULL,
CONSTRAINT "pilots_pkey" PRIMARY KEY ("id")
) WITH (oids = false);
-- Creating a new pilot from the following inputs:
-- Age (will determine year, but pick a random date) - depending if the RPG's API supports this
-- Gender
-- Nationality

View File

@@ -0,0 +1,4 @@
-- Add down migration script here
DROP TABLE "owner_history";
DROP TABLE "pilot_aircraft";
DROP TABLE "flight_log";

View File

@@ -0,0 +1,31 @@
-- Add up migration script here
CREATE TABLE "owner_history" (
"id" BIGSERIAL NOT NULL,
"aircraft_id" BIGSERIAL NOT NULL,
"pilot_id" uuid NOT NULL,
"since" timestamp DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "owner_history_pkey" PRIMARY KEY ("id")
);
-- Pilot currently occupying/flying aircraft
CREATE TABLE "pilot_current_aircraft" (
"pilot_id" uuid NOT NULL,
"aircraft_id" uuid NOT NULL,
CONSTRAINT "pilot_aircraft_pkey" PRIMARY KEY ("pilot_id", "aircraft_id"),
) WITH (oids = false);
CREATE TABLE "flight_log" (
"id" BIGSERIAL NOT NULL,
"pilot_id" uuid NOT NULL,
"aircraft_id" uuid NOT NULL,
"takeoff_time" timestamp DEFAULT CURRENT_TIMESTAMP,
"takeoff_latitude_deg" NUMERIC NOT NULL,
"takeoff_longitude_deg" NUMERIC NOT NULL,
"takeoff_elevation_ft" NUMERIC NOT NULL DEFAULT 0,
"landing" timestamp,
"landing_latitude_deg" NUMERIC NOT NULL,
"landing_longitude_deg" NUMERIC NOT NULL,
"landing_elevation_ft" NUMERIC NOT NULL DEFAULT 0,
CONSTRAINT "flight_log_pkey" PRIMARY KEY ("id")
);

View File

@@ -1,4 +1,5 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[allow(clippy::needless_return)]
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
use avam::config::Config; use avam::config::Config;
@@ -29,15 +30,16 @@ 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;
let http_server = HttpServer::new(app_state, postgres.pool()).await?; HttpServer::new(app_state, postgres.pool())
http_server.run().await .await?
.run()
.await
} }
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
pub fn main() { pub fn main() {
println!("Do run this main?!");
// no client-side main function // no client-side main function
// unless we want this to work with e.g., Trunk for a purely client-side app // unless we want this to work with e.g., Trunk for a purely client-side app
// see lib.rs for hydration function instead // see lib.rs for hydration function instead

View File

@@ -8,6 +8,8 @@ const SMTP_PORT: &str = "SMTP_PORT";
const SMTP_USERNAME: &str = "SMTP_USERNAME"; const SMTP_USERNAME: &str = "SMTP_USERNAME";
const SMTP_PASSWORD: &str = "SMTP_PASSWORD"; const SMTP_PASSWORD: &str = "SMTP_PASSWORD";
const SMTP_SENDER: &str = "SMTP_SENDER"; const SMTP_SENDER: &str = "SMTP_SENDER";
const JWT_SECRET: &str = "JWT_SECRET";
const HASH_ID: &str = "HASH_ID";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config { pub struct Config {
@@ -18,6 +20,9 @@ pub struct Config {
pub smtp_username: String, pub smtp_username: String,
pub smtp_password: String, pub smtp_password: String,
pub smtp_sender: String, pub smtp_sender: String,
pub jwt_secret: String,
pub hash_id: String,
} }
impl Config { impl Config {
@@ -28,6 +33,8 @@ impl Config {
let smtp_username = load_env(SMTP_USERNAME)?; let smtp_username = load_env(SMTP_USERNAME)?;
let smtp_password = load_env(SMTP_PASSWORD)?; let smtp_password = load_env(SMTP_PASSWORD)?;
let smtp_sender = load_env(SMTP_SENDER)?; let smtp_sender = load_env(SMTP_SENDER)?;
let jwt_secret = load_env(JWT_SECRET)?;
let hash_id = load_env(HASH_ID)?;
Ok(Config { Ok(Config {
database_url, database_url,
@@ -36,6 +43,8 @@ impl Config {
smtp_username, smtp_username,
smtp_password, smtp_password,
smtp_sender, smtp_sender,
jwt_secret,
hash_id,
}) })
} }
} }

View File

@@ -18,20 +18,26 @@ pub mod prelude {
// But so far, this is the only thing that actually works // But so far, this is the only thing that actually works
pub type AppService = std::sync::Arc<Service<Postgres, DangerousLettre>>; pub type AppService = std::sync::Arc<Service<Postgres, DangerousLettre>>;
pub use super::models::oauth::*;
pub use super::models::user::*; pub use super::models::user::*;
pub use super::ports::*; pub use super::ports::*;
pub use super::service::*; pub use super::service::*;
pub use crate::domain::leptos::check_user;
pub use crate::domain::leptos::flashbag::Alert; pub use crate::domain::leptos::flashbag::Alert;
pub use crate::domain::leptos::flashbag::Flash; pub use crate::domain::leptos::flashbag::Flash;
pub use crate::domain::leptos::flashbag::FlashBag; pub use crate::domain::leptos::flashbag::FlashBag;
pub use crate::domain::leptos::flashbag::FlashMessage; pub use crate::domain::leptos::flashbag::FlashMessage;
pub use axum_session::SessionAnySession as Session; pub use axum_session::SessionAnySession as Session;
pub use avam_protocol::*;
} }
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
pub mod prelude { pub mod prelude {
pub use super::models::oauth::*;
pub use super::models::user::*; pub use super::models::user::*;
pub use crate::domain::leptos::flashbag::Alert; pub use crate::domain::leptos::flashbag::Alert;
pub use crate::domain::leptos::flashbag::Flash; pub use crate::domain::leptos::flashbag::Flash;

View File

@@ -0,0 +1,32 @@
// max limit: 1.336.335
// Ok so i kinda wanna go all out on this one
// The basic things like
// Aircraft Manufacturer
// Aircraft Type
// Registration Number (up to 1.336.335, generated with auto incremented ids and hash_id)
// - Custom registration nr should be possible, "auto" increment will be a manual thing, check if it exists, if so, skip
// Build date
// Usage things
// Current fuel level in all possible tanks
// Current location and altitude
// Passangers and Cargo
// But also Logs
// Owner history
// Flight Logs which contains weights and balances, to and from etc
// Crashes and maintanance logs
// Most of these things can and will be automated / generated without the need for user input
// Maybe maintanance can be a thing where a plane can't be used for x time
// -----------------------
// The thing we actually need first is a factory
// Using the webinterface, we actually need to go to a factory to build us a new aircraft
// Hangars
// Hangars are on airports
// so we need those too

View File

@@ -1 +1,3 @@
pub mod oauth;
pub mod pilot;
pub mod user; pub mod user;

View File

@@ -0,0 +1,377 @@
use std::{fmt::Display, str::FromStr};
use derive_more::derive::{Display, From};
#[cfg(feature = "ssr")]
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::user::User;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Client {
id: uuid::Uuid,
user_id: uuid::Uuid,
name: ClientName,
secret: ClientSecret,
redirect_uri: RedirectUri,
}
impl Client {
pub fn new(
id: uuid::Uuid,
user_id: uuid::Uuid,
name: ClientName,
secret: ClientSecret,
redirect_uri: RedirectUri,
) -> Self {
Self {
id,
user_id,
name,
secret,
redirect_uri,
}
}
pub fn id(&self) -> uuid::Uuid {
self.id
}
pub fn name(&self) -> &ClientName {
&self.name
}
pub fn redirect_uri(&self) -> &RedirectUri {
&self.redirect_uri
}
}
#[derive(Clone, Debug, Display, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ClientName(String);
impl ClientName {
pub fn new(name: &str) -> Self {
Self(name.to_string())
}
}
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(from = "String")]
pub struct ClientSecret(String);
impl From<String> for ClientSecret {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for ClientSecret {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl ClientSecret {
pub fn new() -> Self {
let token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect();
Self(token)
}
}
#[cfg(feature = "ssr")]
impl Default for ClientSecret {
fn default() -> Self {
Self::new()
}
}
#[derive(
Clone, Debug, Display, From, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
pub struct RedirectUri(String);
impl RedirectUri {
pub fn new(uri: &str) -> Self {
Self(uri.to_string())
}
}
#[cfg(feature = "ssr")]
#[derive(Debug, Error)]
pub enum CreateAuthorizationCodeError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(from = "String")]
pub struct AuthorizationCode(String);
impl From<String> for AuthorizationCode {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for AuthorizationCode {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl AuthorizationCode {
pub fn new() -> Self {
let token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(64)
.map(char::from)
.collect();
Self(token)
}
}
#[cfg(feature = "ssr")]
impl Default for AuthorizationCode {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct AuthorizedClient {
client: Client,
user: User,
}
#[derive(Debug, Error)]
pub enum CodeChallengeMethodError {
#[error("Code challenge method is not valid.")]
Invalid,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum CodeChallengeMethod {
#[default]
#[serde(rename = "plain")]
Plain,
#[serde(rename = "S256")]
Sha256,
}
impl Display for CodeChallengeMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
CodeChallengeMethod::Plain => "plain",
CodeChallengeMethod::Sha256 => "S256",
}
)
}
}
impl FromStr for CodeChallengeMethod {
type Err = crate::domain::api::models::oauth::CodeChallengeMethodError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"plain" => Ok(Self::Plain),
"S256" => Ok(Self::Sha256),
_ => Err(crate::domain::api::models::oauth::CodeChallengeMethodError::Invalid),
}
}
}
impl TryFrom<String> for CodeChallengeMethod {
type Error = crate::domain::api::models::oauth::CodeChallengeMethodError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_str(&value)
}
}
#[derive(Debug, Error)]
pub enum ResponseTypeError {
#[error("The response type is not valid.")]
Invalid,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ResponseType {
#[serde(rename = "code")]
Code,
}
impl Display for ResponseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
ResponseType::Code => "code",
}
)
}
}
impl FromStr for ResponseType {
type Err = crate::domain::api::models::oauth::ResponseTypeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"code" => Ok(Self::Code),
_ => Err(crate::domain::api::models::oauth::ResponseTypeError::Invalid),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizeRequest {
client_id: uuid::Uuid,
response_type: ResponseType, // Make type (enum:code,)
state: Option<String>, // random string for CSRF protection
code_challenge: String, // pkce
code_challenge_method: Option<CodeChallengeMethod>, // Make type (enum:sha256,) hashing algo
redirect_uri: RedirectUri, // Make type
scope: Option<String>, // space seperated string with permissions
}
impl AuthorizeRequest {
pub fn new(
client_id: uuid::Uuid,
response_type: ResponseType,
state: Option<String>,
code_challenge: String,
code_challenge_method: Option<CodeChallengeMethod>,
redirect_uri: RedirectUri,
scope: Option<String>,
) -> Self {
Self {
client_id,
response_type,
state,
code_challenge,
code_challenge_method,
redirect_uri,
scope,
}
}
pub fn client_id(&self) -> uuid::Uuid {
self.client_id
}
pub fn response_type(&self) -> ResponseType {
self.response_type.clone()
}
pub fn state(&self) -> Option<String> {
self.state.clone()
}
pub fn code_challenge(&self) -> String {
self.code_challenge.clone()
}
pub fn code_challenge_method(&self) -> Option<CodeChallengeMethod> {
self.code_challenge_method.clone()
}
pub fn redirect_uri(&self) -> RedirectUri {
self.redirect_uri.clone()
}
pub fn scope(&self) -> Option<String> {
self.scope.clone()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizationResponse {
code: AuthorizationCode,
state: Option<String>,
}
impl AuthorizationResponse {
pub fn new(code: AuthorizationCode, state: Option<String>) -> Self {
Self { code, state }
}
pub fn code(&self) -> AuthorizationCode {
self.code.clone()
}
pub fn state(&self) -> Option<String> {
self.state.clone()
}
}
#[derive(Debug, Error)]
pub enum TokenError {
#[error("Invalid Token Request")]
InvalidRequest,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TokenSubject {
#[serde(rename = "a")]
user_id: uuid::Uuid,
#[serde(rename = "b")]
client_id: uuid::Uuid,
#[serde(skip)]
code_challenge: String,
#[serde(skip)]
code_challenge_method: CodeChallengeMethod,
}
impl TokenSubject {
pub fn new(
user_id: uuid::Uuid,
client_id: uuid::Uuid,
code_challenge: String,
code_challenge_method: CodeChallengeMethod,
) -> Self {
Self {
user_id,
client_id,
code_challenge,
code_challenge_method,
}
}
pub fn user_id(&self) -> uuid::Uuid {
self.user_id
}
pub fn client_id(&self) -> uuid::Uuid {
self.client_id
}
pub fn code_challenge(&self) -> String {
self.code_challenge.clone()
}
pub fn code_challenge_method(&self) -> CodeChallengeMethod {
self.code_challenge_method.clone()
}
}

View File

View File

@@ -40,8 +40,8 @@ impl User {
} }
} }
pub fn id(&self) -> &uuid::Uuid { pub fn id(&self) -> uuid::Uuid {
&self.id self.id
} }
pub fn email(&self) -> &EmailAddress { pub fn email(&self) -> &EmailAddress {
@@ -103,6 +103,13 @@ impl ActivationToken {
} }
} }
#[cfg(feature = "ssr")]
impl Default for ActivationToken {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] #[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
#[serde(from = "String")] #[serde(from = "String")]
@@ -135,6 +142,13 @@ impl PasswordResetToken {
} }
} }
#[cfg(feature = "ssr")]
impl Default for PasswordResetToken {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ResetPasswordError { pub enum ResetPasswordError {
@@ -410,6 +424,13 @@ impl UpdateUserRequest {
} }
} }
#[cfg(feature = "ssr")]
impl Default for UpdateUserRequest {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum UpdateUserError { pub enum UpdateUserError {

View File

@@ -1,133 +1,9 @@
/* mod api_service;
Module `ports` specifies the API by which external modules interact with the user domain. mod oauth_repository;
mod user_notifier;
mod user_repository;
All traits are bounded by `Send + Sync + 'static`, since their implementations must be shareable pub use api_service::ApiService;
between request-handling threads. pub use oauth_repository::OAuthRepository;
pub use user_notifier::UserNotifier;
Trait methods are explicitly asynchronous, including `Send` bounds on response types, pub use user_repository::UserRepository;
since the application is expected to always run in a multithreaded environment.
*/
use std::future::Future;
use super::models::user::*;
/// `ApiService` is the public API for the user domain.
///
/// External modules must conform to this contract the domain is not concerned with the
/// implementation details or underlying technology of any external code.
pub trait ApiService: Clone + Send + Sync + 'static {
/// Asynchronously create a new [User].
///
/// # Errors
///
/// - [CreateUserError::Duplicate] if an [User] with the same [EmailAddress] already exists.
fn create_user(
&self,
req: CreateUserRequest,
) -> impl Future<Output = Result<User, CreateUserError>> + Send;
// fn activate_user
fn get_user_session(
&self,
session: &axum_session::SessionAnySession, // TODO: Get rid of this and make cleaner
) -> impl Future<Output = Option<User>> + Send;
fn activate_user_account(
&self,
token: ActivationToken,
) -> impl Future<Output = Result<User, ActivateUserError>> + Send;
fn user_login(
&self,
req: UserLoginRequest,
) -> impl Future<Output = Result<User, UserLoginError>> + Send;
fn forgot_password(&self, email: &EmailAddress) -> impl Future<Output = ()> + Send;
fn reset_password(
&self,
token: &PasswordResetToken,
password: &Password,
) -> impl Future<Output = Result<User, ResetPasswordError>> + Send;
fn find_user_by_password_reset_token(
&self,
token: &PasswordResetToken,
) -> impl Future<Output = Option<User>> + Send;
// These shouldnt be here, _why_ are they here, and implement that here instead
// fn find_user_by_email(self, email: EmailAddress) -> impl Future<Output = Option<User>> + Send;
// fn find_user_by_id(&self, user_id: uuid::Uuid) -> impl Future<Output = Option<User>> + Send;
}
pub trait UserRepository: Clone + Send + Sync + 'static {
// Create
fn create_user(
&self,
req: CreateUserRequest,
) -> impl Future<Output = Result<User, CreateUserError>> + Send;
fn create_activation_token(
&self,
ent: &User,
) -> impl Future<Output = Result<ActivationToken, anyhow::Error>> + Send;
fn create_password_reset_token(
&self,
ent: &User,
) -> impl Future<Output = Result<PasswordResetToken, anyhow::Error>> + Send;
// Read
fn all_users(&self) -> impl Future<Output = Vec<User>> + Send;
fn find_user_by_id(
&self,
id: uuid::Uuid,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
fn find_user_by_email(
&self,
email: &EmailAddress,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
fn find_user_by_activation_token(
&self,
token: &ActivationToken,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
fn find_user_by_password_reset_token(
&self,
token: &PasswordResetToken,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
// // Update
fn update_user(
&self,
ent: &User,
req: UpdateUserRequest,
) -> impl Future<Output = Result<(User, User), UpdateUserError>> + Send;
// Delete
// fn delete_user(&self, ent: User) -> impl Future<Output = Result<User, DeleteUserError>> + Send;
fn delete_activation_token_for_user(
&self,
ent: &User,
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
fn delete_password_reset_tokens_for_user(
&self,
ent: &User,
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
}
pub trait UserNotifier: Clone + Send + Sync + 'static {
fn user_created(&self, user: &User, token: &ActivationToken)
-> impl Future<Output = ()> + Send;
fn forgot_password(
&self,
user: &User,
token: &PasswordResetToken,
) -> impl Future<Output = ()> + Send;
}

View File

@@ -0,0 +1,87 @@
use avam_protocol::Packets;
use tokio::sync::mpsc::Sender;
use crate::{
domain::api::models::oauth::*,
inbound::http::handlers::oauth::{AuthorizationCodeRequest, VerifyClientAuthorizationRequest},
};
use super::super::models::user::*;
use std::future::Future;
pub trait ApiService: Clone + Send + Sync + 'static {
// -- -
// Websocket
// ---
fn add_connection(&self, user: &User, sender: Sender<Packets>);
fn has_connection(&self, user: &User) -> bool;
fn remove_connection(&self, user: &User);
fn send(
&self,
user: &User,
packet: Packets,
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
// ---
// USER
// ---
fn create_user(
&self,
req: CreateUserRequest,
) -> impl Future<Output = Result<User, CreateUserError>> + Send;
fn get_user_session(
&self,
session: &axum_session::SessionAnySession, // TODO: Get rid of this and make cleaner
) -> impl Future<Output = Option<User>> + Send;
fn activate_user_account(
&self,
token: ActivationToken,
) -> impl Future<Output = Result<User, ActivateUserError>> + Send;
fn user_login(
&self,
req: UserLoginRequest,
) -> impl Future<Output = Result<User, UserLoginError>> + Send;
fn forgot_password(&self, email: &EmailAddress) -> impl Future<Output = ()> + Send;
fn reset_password(
&self,
token: &PasswordResetToken,
password: &Password,
) -> impl Future<Output = Result<User, ResetPasswordError>> + Send;
fn find_user_by_password_reset_token(
&self,
token: &PasswordResetToken,
) -> impl Future<Output = Option<User>> + Send;
// ---
// OAUTH
// ---
fn find_client_by_id(&self, id: uuid::Uuid) -> impl Future<Output = Option<Client>> + Send;
fn generate_authorization_code(
&self,
user: &User,
req: AuthorizeRequest,
) -> impl Future<Output = Result<AuthorizationResponse, anyhow::Error>> + Send;
fn create_token(
&self,
req: AuthorizationCodeRequest,
) -> 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

@@ -0,0 +1,33 @@
use super::super::models::oauth::*;
use std::future::Future;
pub trait OAuthRepository: Clone + Send + Sync + 'static {
fn find_client_by_id(
&self,
id: uuid::Uuid,
) -> impl Future<Output = Result<Option<Client>, anyhow::Error>> + Send;
fn create_authorization_code(
&self,
user_id: uuid::Uuid,
client_id: uuid::Uuid,
code_challenge: String,
code_challenge_method: CodeChallengeMethod,
) -> impl Future<Output = Result<AuthorizationCode, anyhow::Error>> + Send;
fn is_authorized_client(
&self,
user_id: uuid::Uuid,
client_id: uuid::Uuid,
) -> impl Future<Output = Result<bool, anyhow::Error>> + Send;
fn get_token_subject(
&self,
code: AuthorizationCode,
) -> impl Future<Output = Result<Option<TokenSubject>, anyhow::Error>> + Send;
fn delete_token(
&self,
code: AuthorizationCode,
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
}

View File

@@ -0,0 +1,12 @@
use super::super::models::user::*;
use std::future::Future;
pub trait UserNotifier: Clone + Send + Sync + 'static {
fn user_created(&self, user: &User, token: &ActivationToken)
-> impl Future<Output = ()> + Send;
fn forgot_password(
&self,
user: &User,
token: &PasswordResetToken,
) -> impl Future<Output = ()> + Send;
}

View File

@@ -0,0 +1,62 @@
use super::super::models::user::*;
use std::future::Future;
pub trait UserRepository: Clone + Send + Sync + 'static {
// Create
fn create_user(
&self,
req: CreateUserRequest,
) -> impl Future<Output = Result<User, CreateUserError>> + Send;
fn create_activation_token(
&self,
ent: &User,
) -> impl Future<Output = Result<ActivationToken, anyhow::Error>> + Send;
fn create_password_reset_token(
&self,
ent: &User,
) -> impl Future<Output = Result<PasswordResetToken, anyhow::Error>> + Send;
// Read
fn all_users(&self) -> impl Future<Output = Vec<User>> + Send;
fn find_user_by_id(
&self,
id: uuid::Uuid,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
fn find_user_by_email(
&self,
email: &EmailAddress,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
fn find_user_by_activation_token(
&self,
token: &ActivationToken,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
fn find_user_by_password_reset_token(
&self,
token: &PasswordResetToken,
) -> impl Future<Output = Result<Option<User>, anyhow::Error>> + Send;
// // Update
fn update_user(
&self,
ent: &User,
req: UpdateUserRequest,
) -> impl Future<Output = Result<(User, User), UpdateUserError>> + Send;
// Delete
// fn delete_user(&self, ent: User) -> impl Future<Output = Result<User, DeleteUserError>> + Send;
fn delete_activation_token_for_user(
&self,
ent: &User,
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
fn delete_password_reset_tokens_for_user(
&self,
ent: &User,
) -> impl Future<Output = Result<(), anyhow::Error>> + Send;
}

View File

@@ -2,43 +2,77 @@
Module `service` provides the canonical implementation of the [ApiService] port. All Module `service` provides the canonical implementation of the [ApiService] port. All
user-domain logic is defined here. user-domain logic is defined here.
*/ */
use avam_protocol::Packets;
use axum_session::SessionAnySession; use axum_session::SessionAnySession;
use dashmap::DashMap;
use tokio::sync::mpsc::Sender;
use crate::inbound::http::handlers::oauth::AuthorizationCodeRequest;
use crate::inbound::http::handlers::oauth::GrantType;
use crate::inbound::http::handlers::oauth::VerifyClientAuthorizationRequest;
use super::models::oauth::Client;
use super::models::oauth::*;
use super::models::user::*; use super::models::user::*;
use super::ports::{ApiService, UserNotifier, UserRepository}; use super::ports::{ApiService, OAuthRepository, UserNotifier, UserRepository};
pub trait Repository = UserRepository; // pub trait Repository = UserRepository + OAuthRepository;
pub trait Email = UserNotifier; pub trait Email = UserNotifier;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Service<R, N> pub struct Service<R, N>
where where
R: Repository, R: UserRepository + OAuthRepository,
N: Email, N: Email,
{ {
repo: R, repo: R,
notifier: N, notifier: N,
connections: DashMap<uuid::Uuid, Sender<Packets>>,
} }
impl<R, N> Service<R, N> impl<R, N> Service<R, N>
where where
R: Repository, R: UserRepository + OAuthRepository,
N: Email, N: Email,
{ {
pub fn new(repo: R, notifier: N) -> Self { pub fn new(repo: R, notifier: N) -> Self {
Self { repo, notifier } Self {
repo,
notifier,
connections: DashMap::new(),
}
} }
} }
impl<R, N> ApiService for Service<R, N> impl<R, N> ApiService for Service<R, N>
where where
R: UserRepository, R: UserRepository + OAuthRepository,
N: Email, N: Email,
{ {
fn add_connection(&self, user: &User, sender: Sender<Packets>) {
self.connections.insert(user.id(), sender);
}
fn has_connection(&self, user: &User) -> bool {
self.connections.contains_key(&user.id())
}
fn remove_connection(&self, user: &User) {
self.connections.remove(&user.id());
}
async fn send(&self, user: &User, packet: Packets) -> Result<(), anyhow::Error> {
if let Some(sender) = self.connections.get(&user.id()) {
sender.send(packet).await?;
}
Ok(())
}
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError> { async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError> {
let result = self.repo.create_user(req).await; let result = self.repo.create_user(req).await;
#[allow(clippy::question_mark)]
if result.is_err() { if result.is_err() {
// something went wrong, log the error // something went wrong, log the error
// but keep passing on the result to the requester (http server) // but keep passing on the result to the requester (http server)
@@ -56,9 +90,7 @@ where
} }
async fn get_user_session(&self, session: &SessionAnySession) -> Option<User> { async fn get_user_session(&self, session: &SessionAnySession) -> Option<User> {
let Some(user_id) = session.get("user") else { let user_id = session.get("user")?;
return None;
};
self.repo.find_user_by_id(user_id).await.unwrap_or(None) self.repo.find_user_by_id(user_id).await.unwrap_or(None)
} }
@@ -69,7 +101,7 @@ where
) -> Result<User, ActivateUserError> { ) -> Result<User, ActivateUserError> {
let user = match self.repo.find_user_by_activation_token(&token).await { let user = match self.repo.find_user_by_activation_token(&token).await {
Ok(u) => u, Ok(u) => u,
Err(e) => return Err(ActivateUserError::Unknown(e.into())), Err(e) => return Err(ActivateUserError::Unknown(e)),
}; };
let Some(user) = user else { let Some(user) = user else {
@@ -98,7 +130,7 @@ where
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
tracing::error!("{:#?}", e); tracing::error!("{:#?}", e);
return Err(UserLoginError::Unknown(e.into())); return Err(UserLoginError::Unknown(e));
} }
}; };
@@ -162,7 +194,7 @@ where
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
tracing::error!("{:#?}", e); tracing::error!("{:#?}", e);
return Err(ResetPasswordError::Unknown(e.into())); return Err(ResetPasswordError::Unknown(e));
} }
}; };
@@ -190,4 +222,95 @@ where
.await .await
.unwrap_or(None) .unwrap_or(None)
} }
async fn find_client_by_id(&self, id: uuid::Uuid) -> Option<Client> {
self.repo.find_client_by_id(id).await.ok().flatten()
}
async fn generate_authorization_code(
&self,
user: &User,
req: AuthorizeRequest,
) -> Result<AuthorizationResponse, anyhow::Error> {
let Some(client) = self.repo.find_client_by_id(req.client_id()).await? else {
return Err(anyhow::anyhow!("Client not found"));
};
if client.redirect_uri() != &req.redirect_uri() {
return Err(anyhow::anyhow!("Invalid redirect uri"));
}
let code = self
.repo
.create_authorization_code(
user.id(),
client.id(),
req.code_challenge(),
req.code_challenge_method().unwrap_or_default(),
)
.await?;
Ok(AuthorizationResponse::new(code, req.state()))
}
async fn create_token(
&self,
req: AuthorizationCodeRequest,
) -> Result<TokenSubject, TokenError> {
if req.grant_type() != GrantType::AuthorizationCode {
return Err(TokenError::InvalidRequest);
}
let code = req.code();
let Some(token) = self.repo.get_token_subject(code).await? else {
return Err(TokenError::InvalidRequest);
};
let code_verifier = req.code_verifier();
let code_challenge = match token.code_challenge_method() {
CodeChallengeMethod::Plain => {
use base64::prelude::*;
BASE64_URL_SAFE_NO_PAD.encode(code_verifier.to_string())
}
CodeChallengeMethod::Sha256 => {
use base64::prelude::*;
BASE64_URL_SAFE_NO_PAD.encode(sha256::digest(code_verifier.to_string()))
}
};
if token.code_challenge() != code_challenge {
return Err(TokenError::InvalidRequest);
}
let Some(client) = self.repo.find_client_by_id(token.client_id()).await? else {
return Err(TokenError::InvalidRequest); // no such client
};
if &req.redirect_uri() != client.redirect_uri() {
return Err(TokenError::InvalidRequest); // invalid redirect uri
}
let _ = self.repo.delete_token(req.code()).await;
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

@@ -11,16 +11,23 @@ use pages::{
}, },
dashboard::DashboardPage, dashboard::DashboardPage,
error::{AppError, ErrorTemplate}, error::{AppError, ErrorTemplate},
oauth2::authorize::AuthorizePage,
}; };
use crate::domain::api::prelude::User;
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
provide_meta_context(); provide_meta_context();
let trigger_user = create_rw_signal(true); let trigger_update = create_rw_signal(false);
let trigger_direct = create_rw_signal(None::<String>);
let user = create_resource(trigger_user, move |_| async move { let user_signal = create_rw_signal(None::<User>);
super::check_user().await.unwrap()
let user_resource = create_local_resource(trigger_update, move |_| async move {
let user = super::check_user().await.unwrap();
user_signal.set(user);
}); });
view! { view! {
@@ -47,17 +54,17 @@ pub fn App() -> impl IntoView {
}> }>
<main class="h-screen overflow-auto dark:base-100 dark:text-white"> <main class="h-screen overflow-auto dark:base-100 dark:text-white">
<Routes> <Routes>
<Route path="/auth" view=move || { <Route path="auth" view=move || {
view! { view! {
<Suspense> <Suspense>
<Show when=move || user().is_some_and(|u| u.is_some())> <Show when=move || user_resource().is_some_and(|_|user_signal().is_some())>
<Redirect path="/" /> <Redirect path={ trigger_direct().unwrap_or(String::from("/")) } />
</Show> </Show>
</Suspense> </Suspense>
<Outlet /> <Outlet />
} }
}> }>
<Route path="login" view=move || view! { <LoginPage user_signal=trigger_user /> } /> <Route path="login" view=move || view! { <LoginPage trigger_signal=trigger_update direct_signal=trigger_direct /> } />
<Route path="register" view=RegisterPage /> <Route path="register" view=RegisterPage />
<Route path="forgot" view=ForgotPage /> <Route path="forgot" view=ForgotPage />
<Route path="reset/:token" view=ResetPage /> <Route path="reset/:token" view=ResetPage />
@@ -66,8 +73,11 @@ pub fn App() -> impl IntoView {
<Route path="" view=move || { <Route path="" view=move || {
view! { view! {
<Suspense> <Suspense>
<Show when=move || user().is_some_and(|u| u.is_none())> <Show when=move || user_resource().is_some_and(|_|user_signal().is_none())>
<Redirect path="/auth/login" /> <Redirect path={
use base64::prelude::*;
format!("/auth/login?c={}", BASE64_URL_SAFE_NO_PAD.encode(format!("{}{}", (leptos_router::use_location().pathname)(), (leptos_router::use_location().query)().to_query_string())))
} />
</Show> </Show>
</Suspense> </Suspense>
<Outlet /> <Outlet />
@@ -75,13 +85,22 @@ pub fn App() -> impl IntoView {
}> }>
<Route path="" view=move || view! { <Route path="" view=move || view! {
<Suspense> <Suspense>
<Show when=move || user().is_some_and(|u| u.is_some())> <Show when=move || user_signal().is_some()>
<DashboardPage user={ user().unwrap().unwrap() } /> <DashboardPage user={ user_signal().unwrap() } />
</Show> </Show>
</Suspense> </Suspense>
} /> } />
</Route> // dashboard
<Route path="/auth/logout" view=move || view! { <LogoutPage user_signal=trigger_user /> } /> <Route path="oauth2/authorize" view=move || view! {
<Suspense>
<Show when=move || user_signal().is_some()>
<AuthorizePage user={ user_signal } />
</Show>
</Suspense>
} />
</Route> // Logged in
<Route path="auth/logout" view=move || view! { <LogoutPage trigger_signal=trigger_update user_signal /> } />
</Routes> </Routes>
</main> </main>
</Router> </Router>

View File

@@ -1,10 +1,12 @@
use leptos::*; use leptos::*;
use leptos_router::*; use leptos_router::*;
use crate::domain::leptos::app::components::alert::Alert; use crate::domain::api::prelude::*;
use super::auth_base::AuthBase; use super::auth_base::AuthBase;
use crate::domain::leptos::app::components::alert::Alert as AlertView;
#[server] #[server]
async fn login_action(email: String, password: String) -> Result<(), ServerFnError<String>> { async fn login_action(email: String, password: String) -> Result<(), ServerFnError<String>> {
use crate::domain::api::prelude::*; use crate::domain::api::prelude::*;
@@ -26,13 +28,27 @@ async fn login_action(email: String, password: String) -> Result<(), ServerFnErr
/// Renders the home page of your application. /// Renders the home page of your application.
#[component] #[component]
pub fn LoginPage(user_signal: RwSignal<bool>) -> impl IntoView { pub fn LoginPage(trigger_signal: RwSignal<bool>, direct_signal: RwSignal<Option<String>>) -> impl IntoView {
let submit = Action::<LoginAction, _>::server(); let submit = Action::<LoginAction, _>::server();
let response = submit.value().read_only(); let response = submit.value().read_only();
let callback = move || {
let query = use_query_map();
let Some(c) = query.with(|q| q.get("c").cloned()) else {
return Some("/".to_string());
};
use base64::prelude::*;
let dec = BASE64_URL_SAFE_NO_PAD.decode(c).unwrap_or_default();
let c = String::from_utf8_lossy(&dec);
Some(c.to_string())
};
create_effect(move |_| { create_effect(move |_| {
if response.get().is_some() { if response.get().is_some() {
user_signal.set(!user_signal.get_untracked()); trigger_signal.set(!trigger_signal.get_untracked());
direct_signal.set(callback());
} }
}); });
@@ -50,25 +66,22 @@ pub fn LoginPage(user_signal: RwSignal<bool>) -> impl IntoView {
<Suspense> <Suspense>
<Show when=move || response.get().is_some_and(|e| e.is_err())> <Show when=move || response.get().is_some_and(|e| e.is_err())>
<div role="alert" class="alert alert-error my-2"> <AlertView alert={Alert::Error}>
<i class="fas fa-exclamation-circle"></i>
<span>
{ move || if let Some(Err(e)) = response.get() { { move || if let Some(Err(e)) = response.get() {
{format!("{}", e)}.into_view() {format!("{}", e)}.into_view()
} else { } else {
().into_view() ().into_view()
}} }}
</span> </AlertView>
</div>
</Show> </Show>
</Suspense> </Suspense>
<Suspense> <Suspense>
<Show when=move || flash().is_some_and(|u| u.is_some())> <Show when=move || flash().is_some_and(|u| u.is_some())>
<Alert alert={ flash().unwrap().unwrap().alert() }> <AlertView alert={ flash().unwrap().unwrap().alert() }>
{ flash().unwrap().unwrap().message() } { flash().unwrap().unwrap().message() }
</Alert> </AlertView>
</Show> </Show>
</Suspense> </Suspense>

View File

@@ -1,5 +1,7 @@
use leptos::*; use leptos::*;
use leptos_router::Redirect; use leptos_router::{use_query_map, Redirect};
use crate::domain::api::prelude::User;
#[server] #[server]
async fn logout_action() -> Result<(), ServerFnError<String>> { async fn logout_action() -> Result<(), ServerFnError<String>> {
@@ -12,22 +14,31 @@ async fn logout_action() -> Result<(), ServerFnError<String>> {
} }
#[component] #[component]
pub fn LogoutPage(user_signal: RwSignal<bool>) -> impl IntoView { pub fn LogoutPage(
let submit = Action::<LogoutAction, _>::server(); trigger_signal: RwSignal<bool>,
let response = submit.value().read_only(); user_signal: RwSignal<Option<User>>,
) -> impl IntoView {
let direct_signal = create_rw_signal(None::<String>);
create_effect(move |_| { create_local_resource(
if response.get().is_some() { || (),
user_signal.set(!user_signal.get_untracked()); move |_| async move {
} logout_action().await.unwrap();
}); trigger_signal.set(!trigger_signal.get_untracked());
submit.dispatch(LogoutAction {}); let query = use_query_map();
if let Some(c) = query.with_untracked(|q| q.get("c").cloned()) {
direct_signal.set(Some(c));
} else {
direct_signal.set(Some("Lw".to_string()));
}
},
);
view! { view! {
<Suspense> <Suspense>
<Show when=move || response().is_some()> <Show when=move || (direct_signal().is_some() && user_signal().is_none())>
<Redirect path="/" /> <Redirect path={ format!("/auth/login?c={}", direct_signal().unwrap()) } />
</Show> </Show>
</Suspense> </Suspense>
} }

View File

@@ -19,9 +19,7 @@ async fn reset_action(token: String, password: String, confirm_password: String)
app.reset_password(&token.into(), &password).await.map_err(|e| format!("{}", e))?; app.reset_password(&token.into(), &password).await.map_err(|e| format!("{}", e))?;
let flash = FlashMessage::new("login", let flash = FlashMessage::new("login",
format!( "Your password has been reset.".to_string()).with_alert(Alert::Success);
"Your password has been reset."
)).with_alert(Alert::Success);
flashbag.set(flash); flashbag.set(flash);

View File

@@ -1,46 +1,54 @@
use leptos::*; use leptos::*;
use leptos_router::ActionForm;
use crate::domain::api::prelude::User; use crate::domain::api::prelude::*;
#[server]
async fn login_action(ident: String) -> Result<(), ServerFnError<String>> {
use crate::domain::api::prelude::*;
if ident.len() > 10 {
return Err(ServerFnError::WrappedServerError(
"ATC_ID exceeding 10 characters".into(),
));
}
let Some(user) = check_user().await? else {
return Err(ServerFnError::WrappedServerError("No user".into()));
};
let app = use_context::<AppService>().unwrap();
app.send(&user, Packets::SimConnect(SimConnectPacket::AtcID(ident)))
.await
.map_err(|e| format!("{:#?}", e))?;
Ok(())
}
/// Renders the home page of your application. /// Renders the home page of your application.
#[component] #[component]
pub fn DashboardPage(user: User) -> impl IntoView { pub fn DashboardPage(user: User) -> impl IntoView {
let submit = Action::<LoginAction, _>::server();
// let response = submit.value().read_only();
view! { view! {
<section class="login is-fullheight"> <div class="p-10">
<div class="columns is-fullheight"> <pre>Hello, { user.email().to_string() }!</pre>
<div class="column is-one-third-fullhd is-half-widescreen"> <div class="content has-text-centered link">
<div class="login_container"> <a href="/auth/logout">Logout</a>
<div style="margin: auto 0">
<div class="has-text-centered">
<img src="/android-chrome-192x192.png" alt={ crate::PROJECT_NAME }/>
</div>
<pre>Hello, { user.email().to_string() }!</pre>
<div class="content has-text-centered">
<a href="/auth/logout">Logout</a>
</div>
</div>
<footer>
<div class="content has-text-centered">
<p>
{ crate::PROJECT_NAME }
</p>
<p>
<span class="icon"><a href="https://git.avii.nl/AVAM/avam" class="is-link" target="_BLANK"><i class="fab fa-git-alt"></i></a></span>
<span class="icon"><a href="#" class="is-link" target="_BLANK"><i class="fab fa-discord"></i></a></span>
</p>
</div>
</footer>
</div>
</div>
<div class="column is-fullheight background is-hidden-mobile has-background-primary has-text-primary-invert">
</div>
</div> </div>
</section> <div>
}.into_view() <ActionForm action=submit>
<label class="input input-bordered flex items-center gap-2">
<i class="fas fa-envelope"></i>
<input type="text" placeholder="Registration Number" maxlength="10" name="ident" />
</label>
<div>
<input type="submit" value="Update" class="btn btn-block" />
</div>
</ActionForm>
</div>
</div>
}
.into_view()
} }

View File

@@ -1,3 +1,4 @@
pub mod auth; pub mod auth;
pub mod dashboard; pub mod dashboard;
pub mod error; pub mod error;
pub mod oauth2;

View File

@@ -0,0 +1,279 @@
use std::str::FromStr;
use std::sync::Arc;
use leptos::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
use crate::domain::api::prelude::*;
use crate::domain::leptos::app::components::alert::Alert as AlertView;
use crate::domain::leptos::app::pages::auth::auth_base::AuthBase;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AuthorizationResponse {
code: String,
state: String,
}
#[server]
async fn authorize_action(form: AuthorizeQuery) -> Result<(), ServerFnError<String>> {
use crate::domain::api::prelude::*;
use crate::domain::leptos::check_user;
let app = use_context::<AppService>().unwrap();
let Some(client) = app.find_client_by_id(form.client_id()).await else {
return Err(ServerFnError::WrappedServerError(
"Invalid Client ID".to_string(),
));
};
let Some(user) = check_user().await? else {
return Err(ServerFnError::WrappedServerError("No user".to_string()));
};
let response: AuthorizationResponse = app
.generate_authorization_code(&user, form.into())
.await
.map_err(|e| format!("{}", e))?;
let qs = serde_qs::to_string(&response).map_err(|e| format!("{}", e))?;
leptos_axum::redirect(&format!("{}?{}", client.redirect_uri(), qs));
Ok(())
}
impl IntoAttribute for CodeChallengeMethod {
fn into_attribute(self) -> Attribute {
Attribute::String(self.to_string().into())
}
fn into_attribute_boxed(self: Box<Self>) -> Attribute {
Attribute::String(self.to_string().into())
}
}
// Make the types and validators for specific shit like response type and code challenge method
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct AuthorizeQuery {
client_id: uuid::Uuid,
response_type: ResponseType, // Make type (enum:code,)
state: Option<String>, // random string for CSRF protection
code_challenge: String, // pkce
code_challenge_method: Option<CodeChallengeMethod>, // Make type (enum:sha256,) hashing algo
redirect_uri: RedirectUri, // Make type
scope: Option<String>, // space seperated string with permissions
}
impl From<AuthorizeQuery> for AuthorizeRequest {
fn from(value: AuthorizeQuery) -> Self {
Self::new(
value.client_id(),
value.response_type(),
value.state(),
value.code_challenge(),
value.code_challenge_method(),
value.redirect_uri(),
value.scope(),
)
}
}
impl AuthorizeQuery {
pub fn client_id(&self) -> uuid::Uuid {
self.client_id
}
pub fn response_type(&self) -> ResponseType {
self.response_type.clone()
}
pub fn state(&self) -> Option<String> {
self.state.clone()
}
pub fn code_challenge(&self) -> String {
self.code_challenge.clone()
}
pub fn code_challenge_method(&self) -> Option<CodeChallengeMethod> {
self.code_challenge_method.clone()
}
pub fn redirect_uri(&self) -> RedirectUri {
self.redirect_uri.clone()
}
pub fn scope(&self) -> Option<String> {
self.scope.clone()
}
}
impl Params for AuthorizeQuery {
fn from_map(map: &ParamsMap) -> Result<Self, ParamsError> {
let client_id: uuid::Uuid = match map.get("client_id") {
Some(c) => uuid::Uuid::from_str(c).map_err(|e| ParamsError::Params(Arc::new(e)))?,
None => return Err(ParamsError::MissingParam("client_id".to_string())),
};
let response_type: ResponseType = match map.get("response_type") {
Some(c) => ResponseType::from_str(c).map_err(|e| ParamsError::Params(Arc::new(e)))?,
None => return Err(ParamsError::MissingParam("response_type".to_string())),
};
let state = map.get("state").cloned();
let Some(code_challenge) = map.get("code_challenge").cloned() else {
return Err(ParamsError::MissingParam("code_challenge".to_string()));
};
let code_challenge_method = map
.get("code_challenge_method")
.map(|c| CodeChallengeMethod::from_str(c).map_err(|e| ParamsError::Params(Arc::new(e))))
.transpose()?;
let redirect_uri: RedirectUri = match map.get("redirect_uri") {
Some(c) => RedirectUri::new(c),
None => return Err(ParamsError::MissingParam("redirect_uri".to_string())),
};
let scope = map.get("scope").cloned();
Ok(Self {
client_id,
response_type,
state,
code_challenge,
code_challenge_method,
redirect_uri,
scope,
})
}
}
#[server]
async fn get_client(client_id: uuid::Uuid) -> Result<Client, ServerFnError<String>> {
use crate::domain::api::prelude::*;
let app = use_context::<AppService>().unwrap();
let Some(client) = app.find_client_by_id(client_id).await else {
return Err(ServerFnError::WrappedServerError(
"Invalid Client ID".to_string(),
));
};
Ok(client)
}
/// Renders the home page of your application.
#[component]
pub fn AuthorizePage(user: RwSignal<Option<User>>) -> impl IntoView {
let submit = Action::<AuthorizeAction, _>::server();
let response = submit.value().read_only();
let query = use_query::<AuthorizeQuery>();
let client_signal = create_rw_signal(None::<Client>);
let client_resource = create_local_resource(
|| (),
move |_| async move {
let query = query.get_untracked().map_err(|e| format!("{}", e))?;
let client = get_client(query.client_id())
.await
.map_err(|e| format!("{}", e))?;
if client.redirect_uri() != &query.redirect_uri() {
return Err("Invalid redirect uri".to_string());
}
client_signal.set(Some(client));
Ok::<(), String>(())
},
);
view! {
<AuthBase>
<Suspense>
<Show when=move || response.get().is_some_and(|e| e.is_err())>
<AlertView alert={Alert::Error}>
{ move || if let Some(Err(e)) = response.get() {
{format!("{}", e)}.into_view()
} else {
().into_view()
}}
</AlertView>
</Show>
</Suspense>
<Show when=move || query.get().is_err()>
<AlertView alert={Alert::Error}>
{ move || if let Err(e) = query.get().map_err(|e| { leptos::logging::warn!("{:#?}", e); "Invalid parameters".to_string() }) {
{e.to_string()}.into_view()
} else {
().into_view()
}}
</AlertView>
</Show>
<Suspense>
<Show when=move || client_resource.get().is_some_and(|e| e.is_err())>
<AlertView alert={Alert::Error}>
{ move || if let Some(Err(e)) = client_resource.get() {
{e.to_string()}.into_view()
} else {
().into_view()
}}
</AlertView>
</Show>
</Suspense>
<Show when=move || query.get().is_ok() && client_resource.get().is_some_and(|e| e.is_ok())>
<ActionForm action=submit class="w-full">
<div class="flex flex-col gap-2">
<div class="text-center text-sm">
"Signed in as "{move || user.get().unwrap().email().to_string()}
</div>
<Show when=move || response().is_some_and(|e| e.is_ok())>
<AlertView alert={Alert::Success}>
"You can now close this window."
</AlertView>
</Show>
<Show when=move || response().is_none()>
<div class="text-center text-sm">
<strong>{move || client_signal().unwrap().name().to_string() }</strong>" is requesting access to your account"
</div>
<div>
<input type="hidden" name="form[client_id]" value=move || query().unwrap().client_id().to_string() />
<input type="hidden" name="form[response_type]" value=move || query().unwrap().response_type().to_string() />
<input type="hidden" name="form[state]" value=move || query().unwrap().state() />
<input type="hidden" name="form[code_challenge]" value=move || query().unwrap().code_challenge().to_string() />
<input type="hidden" name="form[code_challenge_method]" value=move || query().unwrap().code_challenge_method() />
<input type="hidden" name="form[redirect_uri]" value=move || query().unwrap().redirect_uri().to_string() />
<input type="hidden" name="form[scope]" value=move || query().unwrap().scope() />
<input type="submit" value="Authorize" class="btn btn-primary btn-block" />
</div>
<div class="text-center text-sm">
"Not "{move || user.get().unwrap().email().to_string()}"? "<a href={move || {
use base64::prelude::*;
format!("/auth/logout?c={}", BASE64_URL_SAFE_NO_PAD.encode(format!("{}{}", (leptos_router::use_location().pathname)(), (leptos_router::use_location().query)().to_query_string())))
}} class="link">"Logout"</a>"!"
</div>
</Show>
</div>
</ActionForm>
</Show>
</AuthBase>
}
}

View File

@@ -0,0 +1 @@
pub mod authorize;

View File

@@ -46,7 +46,7 @@ where
pub fn new(name: &str, message: S) -> Self { pub fn new(name: &str, message: S) -> Self {
Self { Self {
name: name.to_string(), name: name.to_string(),
message: message.into(), message,
alert: Alert::None, alert: Alert::None,
} }
} }
@@ -82,14 +82,12 @@ impl FlashBag {
pub fn get(&self, flash_name: &str) -> Option<Flash> { pub fn get(&self, flash_name: &str) -> Option<Flash> {
let name = format!("__flash:{}__", flash_name); let name = format!("__flash:{}__", flash_name);
let Some(message) = self.session.get(&name) else { let message = self.session.get(&name)?;
return None;
};
let alert_name = format!("__flash_alert:{}__", flash_name); let alert_name = format!("__flash_alert:{}__", flash_name);
let alert = self.session.get(&alert_name).unwrap_or(Alert::None); let alert = self.session.get(&alert_name).unwrap_or(Alert::None);
self.clear(&flash_name); self.clear(flash_name);
Some(Flash { Some(Flash {
name, name,

View File

@@ -5,16 +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, fileserv::file_and_error_handler, leptos::{leptos_routes_handler, server_fn_handler}, oauth, user::activate_account, websocket::ws_handler
leptos::{leptos_routes_handler, server_fn_handler},
user::activate_account,
}; };
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use state::AppState; use state::AppState;
@@ -59,6 +55,8 @@ impl HttpServer {
); );
let router = axum::Router::new() let router = axum::Router::new()
.route("/ws", any(ws_handler))
.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))
.leptos_routes_with_handler(generate_route_list(App), get(leptos_routes_handler)) .leptos_routes_with_handler(generate_route_list(App), get(leptos_routes_handler))
@@ -88,3 +86,4 @@ impl HttpServer {
Ok(()) Ok(())
} }
} }

View File

@@ -1,3 +1,5 @@
pub mod fileserv; pub mod fileserv;
pub mod leptos; pub mod leptos;
pub mod oauth;
pub mod user; pub mod user;
pub mod websocket;

View File

@@ -0,0 +1,216 @@
use std::{fmt::Display, str::FromStr};
use axum::{extract::State, routing::post, Json, Router};
use derive_more::derive::Display;
use http::StatusCode;
use jsonwebtoken::{encode, EncodingKey, Header};
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{config::Config, domain::api::prelude::*, inbound::http::state::AppState};
pub fn routes<S>() -> axum::Router<AppState<S>>
where
S: ApiService,
{
Router::new().route("/token", post(token))
}
#[derive(Debug, Error)]
pub enum GrantTypeError {
#[error("The grant type is not valid.")]
Invalid,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
// to be extended as new error scenarios are introduced
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum GrantType {
#[serde(rename = "authorization_code")]
AuthorizationCode,
}
impl Display for GrantType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
GrantType::AuthorizationCode => "authorization_code",
}
)
}
}
impl FromStr for GrantType {
type Err = GrantTypeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"authorization_code" => Ok(Self::AuthorizationCode),
_ => Err(GrantTypeError::Invalid),
}
}
}
#[derive(Clone, Display, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(from = "String")]
pub struct CodeVerifier(String);
impl From<String> for CodeVerifier {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for CodeVerifier {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl CodeVerifier {
pub fn new() -> Self {
let token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(120)
.map(char::from)
.collect();
Self(token)
}
}
impl Default for CodeVerifier {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizationCodeRequest {
grant_type: GrantType,
code: AuthorizationCode,
redirect_uri: RedirectUri,
client_id: uuid::Uuid,
code_verifier: CodeVerifier,
}
impl AuthorizationCodeRequest {
pub fn grant_type(&self) -> GrantType {
self.grant_type.clone()
}
pub fn code(&self) -> AuthorizationCode {
self.code.clone()
}
pub fn redirect_uri(&self) -> RedirectUri {
self.redirect_uri.clone()
}
pub fn client_id(&self) -> uuid::Uuid {
self.client_id
}
pub fn code_verifier(&self) -> CodeVerifier {
self.code_verifier.clone()
}
}
#[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)]
pub struct TokenClaims<T>
where
T: Serialize,
{
pub sub: T,
pub iat: usize,
pub exp: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorizationCodeResponse {
token: String,
}
async fn token<S: ApiService>(
State(app_state): State<AppState<S>>,
body: String,
) -> Result<Json<AuthorizationCodeResponse>, (StatusCode, String)> {
let request: AuthorizationCodeRequest = serde_qs::from_str(&body).map_err(|e| {
tracing::error!("{:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
String::from("Internal Server Error"),
)
})?;
let app = app_state.api_service();
let token = app.create_token(request).await.map_err(|e| {
tracing::error!("{:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
String::from("Internal Server Error"),
)
})?;
let token = create_token(token).map_err(|e| {
tracing::error!("{:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
String::from("Internal Server Error"),
)
})?;
Ok(Json(AuthorizationCodeResponse { token }))
}
fn create_token<T>(sub: T) -> Result<String, anyhow::Error>
where
T: Serialize,
{
let config = Config::from_env()?;
let now = time::OffsetDateTime::now_utc();
let iat = now.unix_timestamp() as usize;
let exp = (now + time::Duration::days(30)).unix_timestamp() as usize;
let claims = TokenClaims {
sub: serde_qs::to_string(&sub)?,
iat,
exp,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
)?;
Ok(token)
}

View File

@@ -0,0 +1,250 @@
use std::{net::SocketAddr, ops::ControlFlow, sync::Arc, time::Duration};
use avam_protocol::{Packet, Packets, SimConnectPacket, SystemPacket};
use axum::{
extract::{
ws::{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 tokio::{
sync::mpsc,
time::{sleep, Instant},
};
use tracing::info;
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 => {
tracing::error!("No Authorization Header Supplied");
return Err(StatusCode::UNAUTHORIZED);
}
};
let Some(auth_token) = auth_token else {
tracing::error!("No Token Supplied");
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 {
tracing::error!("Client {} not authorized by {}", client_id, user_id);
return Err(StatusCode::UNAUTHORIZED);
};
if app_state.api_service().has_connection(&user) {
return Err(StatusCode::CONFLICT);
}
Ok(ws.on_upgrade(move |socket| handle_socket(app_state.clone(), socket, user, addr)))
}
/// Actual websocket statemachine (one will be spawned per connection)
async fn handle_socket<S: ApiService>(
app_state: AppState<S>,
socket: WebSocket,
user: User,
who: SocketAddr,
) {
// AppState needs to store user to channel handles or something
// It'd know who's connected at all times and be able to send messages
let (sender, mut receiver) = mpsc::channel(10);
app_state.api_service().add_connection(&user, sender);
// This can probably be hella-abstracted away and be made a lot cleaner
let (mut writer, mut reader) = socket.split();
// This probably needs a (mpsc?) channel, then a dedicated thread to send ping on that channel
let mut ping_timer = Instant::now();
ping_timer.checked_add(Duration::from_secs(15)); // add 15 seconds so we instantly trigger a ping
let u = user.clone();
let writer_handle = tokio::spawn(async move {
loop {
let msg = match receiver.try_recv() {
Ok(msg) => Some(msg),
Err(mpsc::error::TryRecvError::Disconnected) => break,
_ => None,
};
if let Some(msg) = msg {
let Ok(encoded_message) = msg.encode() else {
tracing::error!("Unable to encode message for sending: {:#?}", msg);
sleep(Duration::from_millis(100)).await;
continue;
};
tracing::info!("> [{}]: {:?}", &u.email(), msg);
let _ = writer.send(Message::Binary(encoded_message)).await;
}
if ping_timer.elapsed() >= Duration::from_secs(15) {
tracing::trace!(
"> [{}]: {:?}",
&u.email(),
Packets::System(SystemPacket::Ping)
);
let _ = writer
.send(Message::Binary(
Packets::System(SystemPacket::Ping).encode().unwrap(),
))
.await;
ping_timer = Instant::now();
}
sleep(Duration::from_millis(100)).await;
}
});
let u = user.clone();
let a_s = app_state.clone();
let reader_handle = tokio::spawn(async move {
loop {
if let Some(Ok(data)) = reader.next().await {
let Ok(packet) = Packets::decode(&data.clone().into_data()) else {
if let Message::Close(Some(c)) = data {
tracing::info!("{} disconnected: {}", u.email(), c.reason);
break;
}
tracing::error!("Invalid packet received {:#?}", data);
sleep(Duration::from_millis(100)).await;
continue;
};
match packet {
Packets::System(system_packet) => {
process_system_packet(a_s.clone(), &u, system_packet).await;
}
Packets::SimConnect(sim_connect_packet) => {
process_message(a_s.api_service().clone(), &u, sim_connect_packet).await;
}
}
}
sleep(Duration::from_millis(100)).await;
}
});
tokio::select! {
_ = reader_handle => {
tracing::debug!("reader closed");
}
_ = writer_handle => {
tracing::debug!("writer closed");
}
};
// remove the user/channel from AppState
app_state.api_service().remove_connection(&user);
// returning from the handler closes the websocket connection
tracing::debug!("Websocket context {who} destroyed");
}
async fn process_system_packet<S>(
app_state: AppState<S>,
user: &User,
packet: SystemPacket,
) -> ControlFlow<(), ()>
where
S: ApiService,
{
tracing::trace!("< [{}]: {:?}", user.email(), packet);
match packet {
SystemPacket::Ping => {
// send back pong
let _ = app_state
.api_service()
.send(user, Packets::System(SystemPacket::Pong))
.await;
}
SystemPacket::Pong => {
// noop
}
SystemPacket::Close { reason } => {
tracing::debug!("{} disconnect: {}", user.email(), reason);
return ControlFlow::Break(()); // whatever this means
}
};
ControlFlow::Continue(())
}
async fn process_message<S>(
api_service: Arc<S>,
user: &User,
packet: SimConnectPacket,
) -> ControlFlow<(), ()>
where
S: ApiService,
{
// This needs to be abstracted away from here like actually
match packet {
SimConnectPacket::AtcID(id) => info!("[{}] Registration to {}", user.email(), id),
SimConnectPacket::Fuel(fuel) => info!("[{}] Fuel state: {}", user.email(), fuel),
SimConnectPacket::Airplane(airplane) => info!("[{}] Airplane: {}", user.email(), airplane),
SimConnectPacket::Gps(gps) => info!("[{}] Location: {}", user.email(), gps),
SimConnectPacket::OnGround(on_ground) => {
info!("[{}] On Ground: {}", user.email(), on_ground)
}
SimConnectPacket::IsParked(is_parked) => {
info!("[{}] Is Parked: {}", user.email(), is_parked)
}
}
// The airplane variant checks if the player is in the correct airplane for the selected job etc
// Send error if not and don't handle the flight
// tracing::info!("< [{}]: {:?}", user.email(), packet);
// On incoming packets, we use the internal api to store stuff to the database
// We'll use Server Side Events (SSE) to keep the dashboard up to date with the state of the database
ControlFlow::Continue(())
}

View File

@@ -3,15 +3,16 @@ 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(Clone)]
/// The global application state shared between all request handlers. /// The global application state shared between all request handlers.
pub struct AppState<S> pub struct AppState<S>
where 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

@@ -1,7 +1,9 @@
#![feature(trait_alias)] #![feature(trait_alias)]
pub static BASE_URL: &str = "https://avam.avii.nl";
pub static PROJECT_NAME: &str = "Avii's Virtual Airline Manager"; pub static PROJECT_NAME: &str = "Avii's Virtual Airline Manager";
pub static COPYRIGHT: &str = "Avii's Virtual Airline Manager © 2024"; pub static COPYRIGHT: &str = "Avii's Virtual Airline Manager © 2024";
pub mod domain; pub mod domain;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]

View File

@@ -3,6 +3,7 @@ use lettre::AsyncTransport;
use crate::domain::api::ports::UserNotifier; use crate::domain::api::ports::UserNotifier;
use crate::domain::api::models::user::*; use crate::domain::api::models::user::*;
use crate::BASE_URL;
use super::DangerousLettre; use super::DangerousLettre;
@@ -10,7 +11,7 @@ impl UserNotifier for DangerousLettre {
async fn user_created(&self, user: &User, token: &ActivationToken) { async fn user_created(&self, user: &User, token: &ActivationToken) {
let mut context = tera::Context::new(); let mut context = tera::Context::new();
let url = format!("http://127.0.0.1:3000/auth/activate/{}", token); // Move base url to env let url = format!("{}/auth/activate/{}", BASE_URL, token); // Move base url to env
context.insert("activate_url", &url); context.insert("activate_url", &url);
@@ -28,7 +29,7 @@ impl UserNotifier for DangerousLettre {
async fn forgot_password(&self, user: &User, token: &PasswordResetToken) { async fn forgot_password(&self, user: &User, token: &PasswordResetToken) {
let mut context = tera::Context::new(); let mut context = tera::Context::new();
let url = format!("http://127.0.0.1:3000/auth/reset/{}", token); // Move base url to env let url = format!("{}/auth/reset/{}", BASE_URL, token); // Move base url to env
context.insert("reset_url", &url); context.insert("reset_url", &url);

View File

@@ -1,3 +1,4 @@
pub mod oauth_repository;
pub mod user_repository; pub mod user_repository;
use std::str::FromStr; use std::str::FromStr;

View File

@@ -0,0 +1,179 @@
use anyhow::Context;
use sqlx::Executor;
// use sqlx::QueryBuilder;
use sqlx::Row;
use crate::domain::api::models::oauth::*;
use crate::domain::api::ports::OAuthRepository;
use super::Postgres;
impl OAuthRepository for Postgres {
async fn find_client_by_id(&self, id: uuid::Uuid) -> Result<Option<Client>, anyhow::Error> {
let query = sqlx::query(
r#"
SELECT
id,
user_id,
name,
secret,
redirect_uri
FROM clients WHERE id = $1
"#,
)
.bind(id);
let row = self
.pool
.fetch_optional(query)
.await
.context("failed to execute SQL transaction")?;
let Some(row) = row else {
return Ok(None);
};
let id = row.get("id");
let user_id = row.get("user_id");
let name = ClientName::new(row.get("name"));
let secret = ClientSecret::from(row.get::<&str, &str>("secret"));
let redirect_uri = RedirectUri::new(row.get("redirect_uri"));
Ok(Some(Client::new(id, user_id, name, secret, redirect_uri)))
}
async fn create_authorization_code(
&self,
user_id: uuid::Uuid,
client_id: uuid::Uuid,
code_challenge: String,
code_challenge_method: CodeChallengeMethod,
) -> Result<AuthorizationCode, anyhow::Error> {
let mut tx = self
.pool
.begin()
.await
.context("Failed to start sql transaction")?;
let code = AuthorizationCode::new();
let query = sqlx::query(
r#"
INSERT INTO authorization_code (code, user_id, client_id, code_challenge, code_challenge_method)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(code.to_string())
.bind(user_id)
.bind(client_id)
.bind(code_challenge)
.bind(code_challenge_method.to_string());
tx.execute(query)
.await
.map_err(|e| CreateAuthorizationCodeError::Unknown(e.into()))?;
let query = sqlx::query(
r#"
INSERT INTO authorized_clients (user_id, client_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING
"#,
)
.bind(user_id)
.bind(client_id);
tx.execute(query)
.await
.map_err(|e| CreateAuthorizationCodeError::Unknown(e.into()))?;
tx.commit()
.await
.context("failed to commit SQL transaction")?;
Ok(code)
}
async fn is_authorized_client(
&self,
user_id: uuid::Uuid,
client_id: uuid::Uuid,
) -> Result<bool, anyhow::Error> {
let query = sqlx::query(
r#"
SELECT
*
FROM authorized_clients WHERE user_id = $1 AND client_id = $2
"#,
)
.bind(user_id)
.bind(client_id);
Ok(self
.pool
.fetch_optional(query)
.await
.context("failed to execute SQL transaction")?
.is_some())
}
async fn get_token_subject(
&self,
code: AuthorizationCode,
) -> Result<Option<TokenSubject>, anyhow::Error> {
let query = sqlx::query(
r#"
SELECT
user_id,
client_id,
code_challenge,
code_challenge_method
FROM authorization_code WHERE code = $1
"#,
)
.bind(code.to_string());
let Some(row) = self
.pool
.fetch_optional(query)
.await
.context("failed to execute SQL transaction")?
else {
return Ok(None);
};
let user_id: uuid::Uuid = row.get("user_id");
let client_id: uuid::Uuid = row.get("client_id");
let code_challenge: String = row.get("code_challenge");
let code_challenge_method: CodeChallengeMethod = row
.get::<String, &str>("code_challenge_method")
.try_into()?;
Ok(Some(TokenSubject::new(
user_id,
client_id,
code_challenge,
code_challenge_method,
)))
}
async fn delete_token(&self, code: AuthorizationCode) -> Result<(), anyhow::Error> {
let mut tx = self
.pool
.begin()
.await
.context("Failed to start sql transaction")?;
let query =
sqlx::query("DELETE FROM authorization_code WHERE code = $1").bind(code.to_string());
tx.execute(query)
.await
.context("failed to execute SQL transaction")?;
tx.commit()
.await
.context("failed to commit SQL transaction")?;
Ok(())
}
}

View File

@@ -216,7 +216,7 @@ impl UserRepository for Postgres {
let id = row.get("user_id"); let id = row.get("user_id");
Ok(self.find_user_by_id(id).await?) self.find_user_by_id(id).await
} }
async fn find_user_by_password_reset_token( async fn find_user_by_password_reset_token(
@@ -244,7 +244,7 @@ impl UserRepository for Postgres {
let id = row.get("user_id"); let id = row.get("user_id");
Ok(self.find_user_by_id(id).await?) self.find_user_by_id(id).await
} }
async fn update_user( async fn update_user(
@@ -295,10 +295,10 @@ impl UserRepository for Postgres {
Ok(( Ok((
ent.clone(), ent.clone(),
User::new( User::new(
ent.id().clone(), ent.id(),
new_email.clone(), new_email.clone(),
new_password.clone(), new_password.clone(),
new_verified.clone(), *new_verified,
), ),
)) ))
} }

View File

@@ -968,34 +968,6 @@ html {
} }
} }
.footer {
display: grid;
width: 100%;
grid-auto-flow: row;
place-items: start;
-moz-column-gap: 1rem;
column-gap: 1rem;
row-gap: 2.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.footer > * {
display: grid;
place-items: start;
gap: 0.5rem;
}
@media (min-width: 48rem) {
.footer {
grid-auto-flow: column;
}
.footer-center {
grid-auto-flow: row dense;
}
}
.label { .label {
display: flex; display: flex;
-webkit-user-select: none; -webkit-user-select: none;
@@ -1500,6 +1472,10 @@ html {
background-position: center; background-position: center;
} }
.p-10 {
padding: 2.5rem;
}
.px-1 { .px-1 {
padding-left: 0.25rem; padding-left: 0.25rem;
padding-right: 0.25rem; padding-right: 0.25rem;

View File

@@ -1,50 +0,0 @@
use lettre::AsyncTransport;
use crate::domain::api::ports::UserNotifier;
use crate::domain::api::models::user::*;
use super::DangerousLettre;
impl UserNotifier for DangerousLettre {
async fn user_created(&self, user: &User, token: &ActivationToken) {
let mut context = tera::Context::new();
let url = format!("http://127.0.0.1:3000/auth/activate/{}", token); // Move base url to env
context.insert("activate_url", &url);
let to = format!("{}", user.email()).parse().unwrap();
let message = self
.template("user/created", "Welcome to AVAM!", &context, to)
.unwrap();
if let Err(e) = self.mailer.send(message).await {
eprintln!("{:#?}", e);
};
}
async fn forgot_password(&self, user: &User, token: &ForgotPasswordToken) {
let mut context = tera::Context::new();
let url = format!("http://127.0.0.1:3000/auth/reset/{}", token); // Move base url to env
context.insert("reset_url", &url);
let to = format!("{}", user.email()).parse().unwrap();
let message = self
.template(
"user/password_reset",
"Password reset request",
&context,
to,
)
.unwrap();
if let Err(e) = self.mailer.send(message).await {
eprintln!("{:#?}", e);
};
}
}