diff --git a/.env b/.env index 7e60806..c5f587b 100644 --- a/.env +++ b/.env @@ -11,7 +11,7 @@ NGINX_INTERNAL_HOST=host.docker.internal POSTGRES_HOST=localhost POSTGRES_USER=aviation POSTGRES_PASSWORD=changeme -POSTGRES_NAME=aviation +POSTGRES_DB=aviation_db POSTGRES_PORT=5432 REDIS_HOST=localhost @@ -37,7 +37,7 @@ SSL_CERT_KEY_PATH=../ssl/localhost.key VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api VITE_DEFAULT_LIMIT=200 -__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS:${NGINX_HOST} +__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST} ENVIRONMENT=development ADMIN_EMAIL=admin@example.com diff --git a/Makefile b/Makefile index 07dec26..78f4306 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ help: ## This info format: format-api format-ui format-adsb ## Format code psql: ## Connect to the PSQL DB - @docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -P pager=off + @docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off ################# # API Commands # diff --git a/adsb/README.md b/adsb/README.md index 262d729..170b451 100644 --- a/adsb/README.md +++ b/adsb/README.md @@ -4,9 +4,7 @@ Debug using `export LIBUSB_DEBUG=4` `lsusb -v -d 0bda:2832` ## Simulation Mode -`cargo run -p adsb_sim --` - -`cargo run -p adsb_recv -- --net` +`cargo run -- --connect` ## Decode -`cargo run -p adsb_recv -- --decode 8D4840D6202CC371C32CE0576098` +`cargo run -- --decode 8D4840D6202CC371C32CE0576098` diff --git a/adsb/src/constants.rs b/adsb/src/constants.rs index c23623e..0a58462 100644 --- a/adsb/src/constants.rs +++ b/adsb/src/constants.rs @@ -21,25 +21,42 @@ pub const DEVICE_RTL2832U: DeviceInfo = DeviceInfo { pid: 0x2832, }; -// Timeout pub const TIMEOUT: Duration = Duration::from_secs(1); - -// pub const DEFAULT_BUFFER_LENGTH: usize = 4096; -pub const DEFAULT_BUFFER_LENGTH: usize = 64; +pub const FIR_LENGTH: usize = 16; // Request Types pub const REQ_CTRL_OUT: u8 = rusb::constants::LIBUSB_ENDPOINT_OUT | rusb::constants::LIBUSB_REQUEST_TYPE_VENDOR; // Blocks +pub const BLOCK_DEMOD: u16 = 0; pub const BLOCK_USB: u16 = 1; +pub const BLOCK_SYS: u16 = 2; +pub const BLOCK_TUN: u16 = 3; +pub const BLOCK_ROM: u16 = 4; +pub const BLOCK_IRB: u16 = 5; +pub const BLOCK_IIC: u16 = 6; + +// Registers +pub const DEMOD_CTL: u16 = 0x3000; +pub const DEMOD_CTL_1: u16 = 0x300b; // USB pub const USB_EPA_CTL: u16 = 0x2148; pub const USB_SYSCTL: u16 = 0x2000; +pub const USB_EPA_MAXPKT: u16 = 0x2158; /// ADS-B downlink frequency (1090 MHz) pub const ADSB_FREQUENCY_HZ: u32 = 1_090_000_000; /// RTL-SDR sample rate in samples/second. pub const SAMPLE_RATE_HZ: u32 = 2_048_000; + +pub const DEFAULT_FIR: &'static [i32; FIR_LENGTH] = &[ + -54, -36, -41, -40, -32, -14, 14, 53, 101, 156, 215, 273, 327, 372, 404, 421, +]; +// pub const DEFAULT_BUFFER_LENGTH: usize = 4096; +pub const DEFAULT_BUFFER_LENGTH: usize = 64; +pub const DEFAULT_RTL_XTAL_FREQ: u32 = 28_800_000; +pub const MIN_RTL_XTAL_FREQ: u32 = DEFAULT_RTL_XTAL_FREQ - 1000; +pub const MAX_RTL_XTAL_FREQ: u32 = DEFAULT_RTL_XTAL_FREQ + 1000; diff --git a/adsb/src/device.rs b/adsb/src/device.rs index 8370e67..79b8fa2 100644 --- a/adsb/src/device.rs +++ b/adsb/src/device.rs @@ -7,8 +7,9 @@ use rusb::{ }; use crate::error::{Error, Result}; use crate::constants::{ - ADSB_FREQUENCY_HZ, BLOCK_USB, DEFAULT_BUFFER_LENGTH, REQ_CTRL_OUT, SAMPLE_RATE_HZ, TIMEOUT, - USB_EPA_CTL, USB_SYSCTL, + ADSB_FREQUENCY_HZ, BLOCK_SYS, BLOCK_USB, DEFAULT_BUFFER_LENGTH, DEFAULT_FIR, + DEFAULT_RTL_XTAL_FREQ, DEMOD_CTL, DEMOD_CTL_1, REQ_CTRL_OUT, SAMPLE_RATE_HZ, TIMEOUT, + USB_EPA_CTL, USB_EPA_MAXPKT, USB_SYSCTL, }; /// rusb/libusb implementation of `RtlSdrDevice` @@ -83,7 +84,7 @@ impl RtlSdrDevice { Some(e) => e, None => return Err(Error::new("Unable to find endpoint on device")), }; - log::debug!("Found readable endpoint: {:?}", endpoint.to_string()); + log::debug!("Found readable endpoint: {}", endpoint.to_string()); let mut sdr = Self::new(handle, endpoint); @@ -158,6 +159,10 @@ impl RtlSdrDevice { self.test_write()?; + self.initialize_baseband()?; + + self.set_i2c_repeater(true)?; + // Reset the internal USB buffer self.reset_buffer()?; @@ -191,6 +196,7 @@ impl RtlSdrDevice { .map_err(|err| Error::new(format!("Failed to set alternate setting: {:?}", err))) } + /// Attempt to write a test message, and reset the device on a failure fn test_write(&self) -> Result<()> { log::trace!("Testing write to device..."); let length = ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x89, 1)?; @@ -215,6 +221,66 @@ impl RtlSdrDevice { Ok(()) } + fn reset_demod(&self) -> Result<()> { + log::trace!("Resetting demod..."); + demod_ctrl_write_register(&self.handle, 1, 0x01, 0x14, 1) + .map_err(|err| Error::new(format!("Failed to reset the internal demod: {:?}", err)))?; + demod_ctrl_write_register(&self.handle, 1, 0x01, 0x10, 1) + .map_err(|err| Error::new(format!("Failed to reset the internal demod: {:?}", err)))?; + Ok(()) + } + + fn initialize_baseband(&self) -> Result<()> { + // Initialize the USB + ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x09, 1)?; + ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_MAXPKT, 0x0002, 2)?; + ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_CTL, 0x1002, 2)?; + + // Power on demod + ctrl_write_register(&self.handle, BLOCK_SYS, DEMOD_CTL_1, 0x22, 1)?; + ctrl_write_register(&self.handle, BLOCK_SYS, DEMOD_CTL, 0xe8, 1)?; + + // Reset demod + self.reset_demod()?; + + // Disable spectrum inversion and adjust channel rejection + ctrl_write_register(&self.handle, 1, 0x15, 0x00, 1)?; + ctrl_write_register(&self.handle, 1, 0x16, 0x00, 2)?; + + // Clear DDC shift and IF registers + for i in 0..5 { + demod_ctrl_write_register(&self.handle, 1, 0x16 + i, 0x00, 1)?; + } + self.set_fir(DEFAULT_FIR)?; + + // info!("Enable SDR mode, disable DAGC (bit 5)"); + demod_ctrl_write_register(&self.handle, 0, 0x19, 0x05, 1)?; + + // info!("Init FSM state-holding register"); + demod_ctrl_write_register(&self.handle, 1, 0x93, 0xf0, 1)?; + demod_ctrl_write_register(&self.handle, 1, 0x94, 0x0f, 1)?; + + // Disable AGC (en_dagc, bit 0) (seems to have no effect) + demod_ctrl_write_register(&self.handle, 1, 0x11, 0x00, 1)?; + + // Disable RF and IF AGC loop + demod_ctrl_write_register(&self.handle, 1, 0x04, 0x00, 1)?; + + // Disable PID filter + demod_ctrl_write_register(&self.handle, 0, 0x61, 0x60, 1)?; + + // opt_adc_iq = 0, default ADC_I/ADC_Q datapath + demod_ctrl_write_register(&self.handle, 0, 0x06, 0x80, 1)?; + + // Enable Zero-IF mode, DC cancellation, and IQ estimation/compensation + demod_ctrl_write_register(&self.handle, 1, 0xb1, 0x1b, 1)?; + + // Disable 4.096 MHz clock output on pin TP_CK0 + demod_ctrl_write_register(&self.handle, 0, 0x0d, 0x83, 1)?; + + Ok(()) + } + fn set_center_frequency(&mut self, frequency: u32) -> Result<()> { log::trace!("Setting center_frequency to {}Hz", frequency); self.frequency = frequency; @@ -223,7 +289,77 @@ impl RtlSdrDevice { fn set_sample_rate(&mut self, rate: u32) -> Result<()> { log::trace!("Setting sample_rate to {}Hz", rate); - self.rate = rate; + if rate <= 225_000 || rate > 3_200_000 || (rate > 300000 && rate <= 900000) { + return Err(Error::new(format!("Invalid sample rate: {} Hz", rate))); + } + + let rsamp_ratio = + ((DEFAULT_RTL_XTAL_FREQ as u128 * 2_u128.pow(22) / rate as u128) & 0x0ffffffc) as u128; + log::trace!( + "Sample rate: {}, xtal: {}, rsamp_ratio: {}", + rate, + DEFAULT_RTL_XTAL_FREQ, + rsamp_ratio + ); + let real_resamp_ratio = rsamp_ratio | ((rsamp_ratio & 0x08000000) << 1); + let real_rate = + (DEFAULT_RTL_XTAL_FREQ as u128 * 2_u128.pow(22)) as f64 / real_resamp_ratio as f64; + if rate as f64 != real_rate { + log::trace!("Exact sample rate is {} Hz", real_rate); + } + + self.rate = real_rate as u32; + + let mut tmp: u16 = (rsamp_ratio >> 16) as u16; + demod_ctrl_write_register(&self.handle, 1, 0x9f, tmp, 2)?; + tmp = (rsamp_ratio & 0xffff) as u16; + demod_ctrl_write_register(&self.handle, 1, 0xa1, tmp, 2)?; + + Ok(()) + } + + fn set_fir(&self, fir: &[i32; 16]) -> Result<()> { + log::trace!("Setting fir to {:?}", fir); + const TMP_LEN: usize = 20; + let mut tmp: [u8; TMP_LEN] = [0; TMP_LEN]; + // First 8 values are i8 + for i in 0..8 { + let val = fir[i]; + if val < -128 || val > 127 { + panic!("i8 FIR coefficient out of bounds! {}", val); + } + tmp[i] = val as u8; + } + // Next 12 are i12, so don't line up with byte boundaries and need to unpack + // 12 i12 values from 4 pairs of bytes in fir. Example: + // fir: 4b5, 7f8, 3e8, 619 + // tmp: 4b, 57, f8, 3e, 86, 19 + for i in (0..8).step_by(2) { + let val0 = fir[8 + i]; + let val1 = fir[8 + i + 1]; + if val0 < -2048 || val0 > 2047 { + panic!("i12 FIR coefficient out of bounds: {}", val0) + } else if val1 < -2048 || val1 > 2047 { + panic!("i12 FIR coefficient out of bounds: {}", val1) + } + tmp[8 + i * 3 / 2] = (val0 >> 4) as u8; + tmp[8 + i * 3 / 2 + 1] = ((val0 << 4) | ((val1 >> 8) & 0x0f)) as u8; + tmp[8 + i * 3 / 2 + 2] = val1 as u8; + } + + for i in 0..TMP_LEN { + demod_ctrl_write_register(&self.handle, 1, 0x1c + i as u16, tmp[i] as u16, 1)?; + } + Ok(()) + } + + fn set_i2c_repeater(&self, enabled: bool) -> Result<()> { + let value = match enabled { + true => 0x18, + false => 0x10, + }; + + demod_ctrl_write_register(&self.handle, 1, 0x01, value, 1)?; Ok(()) } @@ -397,5 +533,18 @@ fn demod_ctrl_write_register( let buffer = if length == 1 { &data[1..2] } else { &data }; let index = 0x10 | page; let address = (address << 8) | 0x20; + log::trace!( + "Received page {}, address 0x{:04X}, value 0x{:04X}, length {} \ + - writing control register: {} 0x{:04X} 0x{:04X} {:?}", + page, + address, + value, + length, + REQ_CTRL_OUT, + address, + index, + buffer + ); + handle.write_control(REQ_CTRL_OUT, 0x00, address, index, buffer, TIMEOUT) } diff --git a/api/migrations/10232024_initial.sql b/api/migrations/20250513_initial.sql similarity index 87% rename from api/migrations/10232024_initial.sql rename to api/migrations/20250513_initial.sql index 5d6f45c..b886d26 100644 --- a/api/migrations/10232024_initial.sql +++ b/api/migrations/20250513_initial.sql @@ -38,16 +38,20 @@ CREATE TABLE IF NOT EXISTS runways ( ); CREATE INDEX ON runways (icao); +CREATE INDEX ON runways (runway_id); CREATE INDEX ON runways (surface); CREATE TABLE IF NOT EXISTS frequencies ( id UUID PRIMARY KEY NOT NULL, icao TEXT NOT NULL, frequency_id TEXT NOT NULL, + frequency_name TEXT, frequency_mhz REAL NOT NULL ); CREATE INDEX ON frequencies (icao); +CREATE INDEX ON frequencies (frequency_id); +CREATE INDEX ON frequencies (frequency_name); CREATE INDEX ON frequencies (frequency_mhz); CREATE TABLE IF NOT EXISTS metars ( @@ -61,7 +65,8 @@ CREATE TABLE IF NOT EXISTS metars ( CREATE INDEX ON metars (observation_time DESC); CREATE TABLE IF NOT EXISTS users ( - email TEXT PRIMARY KEY NOT NULL, + id UUID NOT NULL, + email TEXT NOT NULL, email_verified BOOLEAN NOT NULL DEFAULT false, password_hash TEXT NOT NULL, role TEXT NOT NULL, @@ -69,5 +74,6 @@ CREATE TABLE IF NOT EXISTS users ( last_name TEXT NOT NULL, avatar TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY(email) ); \ No newline at end of file diff --git a/api/src/account/auth.rs b/api/src/account/auth.rs index 624aa94..532e4d6 100644 --- a/api/src/account/auth.rs +++ b/api/src/account/auth.rs @@ -34,13 +34,13 @@ impl FromRequest for Auth { return Err(Error::new(401, "API Key does not exist".to_string()).into()); } }; - match User::select(&api_key.email).await { + match User::select(&api_key.id).await { Some(user) => Ok(Auth { session_id: None, api_key: Some(key_id), user, }), - None => Err(Error::new(404, format!("User {} not found", api_key.email)).into()), + None => Err(Error::new(404, format!("User {} not found", api_key.id)).into()), } }; return Box::pin(fut); @@ -79,13 +79,13 @@ impl FromRequest for Auth { // Verify the session let fut = async move { match Session::verify(&session_id, &ip_address).await { - Ok(session) => match User::select(&session.email).await { + Ok(session) => match User::select(&session.id).await { Some(user) => Ok(Auth { session_id: Some(session_id), api_key: None, user, }), - None => Err(Error::new(404, format!("User {} not found", session.email)).into()), + None => Err(Error::new(404, format!("User {} not found", session.id)).into()), }, Err(err) => Err(err.into()), } diff --git a/api/src/account/routes.rs b/api/src/account/routes.rs index b865141..0eea37a 100644 --- a/api/src/account/routes.rs +++ b/api/src/account/routes.rs @@ -50,15 +50,16 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon let email = &request.email; let ip_address = req.peer_addr().unwrap().ip().to_string(); - let query_user = match User::select(&email).await { + let query_user = match User::select_by_email(&email).await { Some(query_user) => query_user, None => return HttpResponse::Unauthorized().finish(), }; if verify_hash(&request.password, &query_user.password_hash) { // Create a session - let session = Session::default(&email, &ip_address); + let session = Session::default(&query_user.id, &ip_address); let session_cookie = session.cookie(); + let session_exp_cookie = session.expiration_cookie(); // Save the session to the database if let Err(err) = session.store().await { log::error!( @@ -77,6 +78,7 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon let user_response: UserResponse = query_user.into(); HttpResponse::Ok() .cookie(session_cookie) + .cookie(session_exp_cookie) .json(user_response) } else { log::error!( @@ -84,7 +86,10 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon email, ip_address ); - HttpResponse::Unauthorized().finish() + HttpResponse::Unauthorized() + .cookie(Session::empty_cookie()) + .cookie(Session::empty_expiration_cookie()) + .finish() } } @@ -121,11 +126,68 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { email, ip_address ); - HttpResponse::Ok().cookie(Session::empty_cookie()).finish() + HttpResponse::Ok() + .cookie(Session::empty_cookie()) + .cookie(Session::empty_expiration_cookie()) + .finish() +} + +#[get("/profile")] +async fn get_profile(req: HttpRequest) -> HttpResponse { + let ip_address = req.peer_addr().unwrap().ip().to_string(); + // Verify a session cookie exists + match req.cookie(SESSION_COOKIE_NAME) { + // Validate the session + Some(cookie) => { + let session_id = cookie.value().to_string(); + let session = match Session::get(&session_id).await { + Ok(session) => session, + Err(_) => { + log::error!( + "Invalid profile attempt [Session: {}] [IP Address: {}]", + session_id, + ip_address + ); + return HttpResponse::Unauthorized() + .cookie(Session::empty_cookie()) + .cookie(Session::empty_expiration_cookie()) + .finish(); + } + }; + let id = &session.id; + let query_user = match User::select(&id).await { + Some(query_user) => query_user, + None => { + return HttpResponse::Unauthorized() + .cookie(Session::empty_cookie()) + .cookie(Session::empty_expiration_cookie()) + .finish(); + } + }; + + let user_response: UserResponse = query_user.into(); + let session_cookie = session.cookie(); + let session_exp_cookie = session.expiration_cookie(); + + log::info!( + "Successful profile attempt [ID: {}] [IP Address: {}]", + id, + ip_address + ); + HttpResponse::Ok() + .cookie(session_cookie) + .cookie(session_exp_cookie) + .json(user_response) + } + None => HttpResponse::Unauthorized() + .cookie(Session::empty_cookie()) + .cookie(Session::empty_expiration_cookie()) + .finish(), + } } #[get("/session")] -async fn validate_session(req: HttpRequest) -> HttpResponse { +async fn session_refresh(req: HttpRequest) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); // Verify a session cookie exists match req.cookie(SESSION_COOKIE_NAME) { @@ -142,33 +204,27 @@ async fn validate_session(req: HttpRequest) -> HttpResponse { ); return HttpResponse::Unauthorized() .cookie(Session::empty_cookie()) + .cookie(Session::empty_expiration_cookie()) .finish(); } }; - let email = &session.email; - let query_user = match User::select(&email).await { - Some(query_user) => query_user, - None => { - return HttpResponse::Unauthorized() - .cookie(Session::empty_cookie()) - .finish(); - } - }; - - let user_response: UserResponse = query_user.into(); + let id = &session.id; let session_cookie = session.cookie(); + let session_exp_cookie = session.expiration_cookie(); log::info!( - "Successful session validate attempt [Email: {}] [IP Address: {}]", - email, + "Successful session validate attempt [ID: {}] [IP Address: {}]", + id, ip_address ); HttpResponse::Ok() .cookie(session_cookie) - .json(user_response) + .cookie(session_exp_cookie) + .finish() } None => HttpResponse::Unauthorized() .cookie(Session::empty_cookie()) + .cookie(Session::empty_expiration_cookie()) .finish(), } } @@ -180,9 +236,9 @@ async fn change_password( auth: Auth, ) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); - let email = auth.user.email; + let id = auth.user.id; - if let None = User::select(&email).await { + if let None = User::select(&id).await { return HttpResponse::Unauthorized().finish(); }; @@ -196,20 +252,20 @@ async fn change_password( avatar: None, }; - match update_user.update(&email).await { + match update_user.update(&id).await { Ok(user) => { let response: UserResponse = user.into(); log::info!( - "Successful password change attempt [Email: {}] [IP Address: {}]", - &email, + "Successful password change attempt [ID: {}] [IP Address: {}]", + &id, ip_address ); HttpResponse::Ok().json(response) } Err(err) => { log::error!( - "Invalid password change attempt [Email: {}] [IP Address: {}]: {}", - &email, + "Invalid password change attempt [ID: {}] [IP Address: {}]: {}", + &id, ip_address, err ); @@ -231,6 +287,7 @@ pub fn init_routes(config: &mut web::ServiceConfig) { .service(login) .service(logout) .service(change_password) - .service(validate_session), + .service(get_profile) + .service(session_refresh), ); } diff --git a/api/src/account/session.rs b/api/src/account/session.rs index f8e6f70..3fce391 100644 --- a/api/src/account/session.rs +++ b/api/src/account/session.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use redis::{AsyncCommands, RedisResult}; use tokio::task; +use uuid::Uuid; use crate::{ db::redis_async_connection, error::{Error, ApiResult}, @@ -11,26 +12,27 @@ use super::{csprng, hash, verify_hash}; const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours pub const SESSION_COOKIE_NAME: &str = "session"; +pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration"; #[derive(Debug, Serialize, Deserialize)] pub struct Session { pub session_id: String, - pub email: String, + pub id: Uuid, pub ip_address: String, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, } impl Session { - pub fn default(email: &str, ip_address: &str) -> Self { - Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL)) + pub fn default(id: &Uuid, ip_address: &str) -> Self { + Self::new(64, id, ip_address, Some(DEFAULT_SESSION_TTL)) } - pub fn new(take: usize, email: &str, ip_address: &str, ttl: Option) -> Self { + pub fn new(take: usize, id: &Uuid, ip_address: &str, ttl: Option) -> Self { let now = Utc::now(); Self { session_id: csprng(take), - email: email.to_string(), + id: id.clone(), ip_address: hash(&ip_address).unwrap(), expires_at: match ttl { Some(ttl) => Some(now + chrono::Duration::seconds(ttl)), @@ -77,7 +79,7 @@ impl Session { ); }; }); - session = Session::default(&session.email, ip_address); + session = Session::default(&session.id, ip_address); session.store().await?; Ok(session) } @@ -118,8 +120,8 @@ impl Session { if let Ok(environment) = std::env::var("ENVIRONMENT") { if environment == "development" || environment == "dev" { log::trace!( - "Development cookie [Email: {}]: {}", - self.email, + "Development cookie [ID: {}]: {}", + self.id, self.session_id ); cookie.set_secure(false); @@ -130,6 +132,28 @@ impl Session { cookie } + pub fn expiration_cookie(&self) -> Cookie { + let expires_at = match self.expires_at { + Some(expires_at) => expires_at.timestamp(), + None => DEFAULT_SESSION_TTL, + }; + let ttl = expires_at - Utc::now().timestamp(); + let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, expires_at.to_string()) + .path("/") + .max_age(Duration::seconds(ttl)) + .secure(true) + .http_only(false) + .finish(); + + if let Ok(environment) = std::env::var("ENVIRONMENT") { + if environment == "development" || environment == "dev" { + cookie.set_secure(false); + } + } + + cookie + } + pub fn empty_cookie() -> Cookie<'static> { let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "") .path("/") @@ -147,4 +171,21 @@ impl Session { cookie } + + pub fn empty_expiration_cookie() -> Cookie<'static> { + let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, "") + .path("/") + .max_age(Duration::seconds(-1)) + .secure(true) + .http_only(false) + .finish(); + + if let Ok(environment) = std::env::var("ENVIRONMENT") { + if environment == "development" || environment == "dev" { + cookie.set_secure(false); + } + } + + cookie + } } diff --git a/api/src/airports/model/frequency.rs b/api/src/airports/model/frequency.rs index ad75b90..99b1e4b 100644 --- a/api/src/airports/model/frequency.rs +++ b/api/src/airports/model/frequency.rs @@ -11,6 +11,8 @@ const TABLE_NAME: &str = "frequencies"; pub struct Frequency { #[serde(rename = "id")] pub frequency_id: String, + #[serde(rename = "name")] + pub frequency_name: Option, pub frequency_mhz: f32, } @@ -19,6 +21,7 @@ pub struct FrequencyRow { pub id: Uuid, pub icao: String, pub frequency_id: String, + pub frequency_name: Option, pub frequency_mhz: f32, } @@ -28,6 +31,8 @@ pub struct UpdateFrequency { pub icao: Option, #[serde(rename = "id", skip_serializing_if = "Option::is_none")] pub frequency_id: Option, + #[serde(rename = "name", skip_serializing_if = "Option::is_none")] + pub frequency_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub frequency_mhz: Option, } @@ -36,6 +41,7 @@ impl From for Frequency { fn from(frequency: FrequencyRow) -> Self { Self { frequency_id: frequency.frequency_id.clone(), + frequency_name: frequency.frequency_name.clone(), frequency_mhz: frequency.frequency_mhz, } } @@ -47,6 +53,7 @@ impl Frequency { id: Uuid::new_v4(), icao: icao.to_string(), frequency_id: frequency.frequency_id.clone(), + frequency_name: frequency.frequency_name.clone(), frequency_mhz: frequency.frequency_mhz.clone(), } } @@ -96,13 +103,14 @@ impl Frequency { for chunk in frequencies.chunks(chunk_size) { let mut query_builder: QueryBuilder = QueryBuilder::new(&format!( - "INSERT INTO {} (id, icao, frequency_id, frequency_mhz) ", + "INSERT INTO {} (id, icao, frequency_id, frequency_name, frequency_mhz) ", TABLE_NAME )); query_builder.push_values(chunk, |mut b, row| { b.push_bind(&row.id) .push_bind(&row.icao) .push_bind(&row.frequency_id) + .push_bind(&row.frequency_name) .push_bind(&row.frequency_mhz); }); diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index db7a8c4..3f5ee21 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -18,7 +18,7 @@ pub async fn initialize() -> ApiResult<()> { let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set"); let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set"); let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string()); - let name = std::env::var("POSTGRES_NAME").unwrap_or("aviation".to_string()); + let name = std::env::var("POSTGRES_DB").unwrap_or("aviation_db".to_string()); let db_url = format!( "postgres://{}:{}@{}:{}/{}", diff --git a/api/src/main.rs b/api/src/main.rs index f5b164b..e9821f5 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -4,6 +4,7 @@ use actix_cors::Cors; use actix_web::{App, HttpServer, middleware::Logger, web}; use dotenv::from_filename; use reqwest::Certificate; +use uuid::Uuid; use crate::account::hash; use crate::users::{User, ADMIN_ROLE}; @@ -32,7 +33,7 @@ async fn main() -> Result<(), Box> { let admin_password = env::var("ADMIN_PASSWORD"); if admin_email.is_ok() && admin_password.is_ok() { let email = admin_email.unwrap(); - if User::select(&email).await.is_none() { + if User::select_by_email(&email).await.is_none() { log::debug!("Creating default administrator"); let password = admin_password.unwrap(); let password_hash = hash(&password)?; @@ -42,6 +43,7 @@ async fn main() -> Result<(), Box> { ); } let admin_user = User { + id: Uuid::new_v4(), email, email_verified: true, password_hash, diff --git a/api/src/users/model.rs b/api/src/users/model.rs index fef8b07..22b8e05 100644 --- a/api/src/users/model.rs +++ b/api/src/users/model.rs @@ -21,6 +21,7 @@ impl RegisterRequest { pub fn to_user(self) -> ApiResult { let password_hash = hash(&self.password)?; Ok(User { + id: Uuid::new_v4(), email: self.email.to_lowercase(), email_verified: false, password_hash, @@ -42,17 +43,19 @@ pub struct LoginRequest { #[derive(Debug, Serialize)] pub struct UserResponse { - pub email_verified: bool, + pub id: Uuid, pub role: String, pub first_name: String, pub last_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub avatar: Option, + pub email_verified: bool, } impl From for UserResponse { fn from(user: User) -> Self { UserResponse { + id: user.id, email_verified: user.email_verified, role: user.role, first_name: user.first_name, @@ -74,7 +77,7 @@ pub struct UpdateUser { } impl UpdateUser { - pub async fn update(&self, email: &str) -> ApiResult { + pub async fn update(&self, id: &Uuid) -> ApiResult { let pool = db::pool(); let mut query_builder: QueryBuilder = @@ -130,8 +133,8 @@ impl UpdateUser { query_builder.push("updated_at = "); query_builder.push_bind(Utc::now()); - query_builder.push(" WHERE email = "); - query_builder.push_bind(email.to_string()); + query_builder.push(" WHERE id = "); + query_builder.push_bind(id); query_builder.push(" RETURNING *"); let query = query_builder.build_query_as::(); @@ -143,6 +146,7 @@ impl UpdateUser { #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct User { + pub id: Uuid, pub email: String, pub email_verified: bool, pub password_hash: String, @@ -155,7 +159,26 @@ pub struct User { } impl User { - pub async fn select(email: &str) -> Option { + pub async fn select(id: &Uuid) -> Option { + let pool = db::pool(); + let user: Option = sqlx::query_as::<_, Self>(&format!( + r#" + SELECT * FROM {} WHERE id = $1 + "#, + TABLE_NAME + )) + .bind(id) + .fetch_optional(pool) + .await + .unwrap_or_else(|err| { + log::error!("Unable to find user by id '{}': {}", id, err); + None + }); + + user + } + + pub async fn select_by_email(email: &str) -> Option { let pool = db::pool(); let user: Option = sqlx::query_as::<_, Self>(&format!( r#" @@ -167,7 +190,7 @@ impl User { .fetch_optional(pool) .await .unwrap_or_else(|err| { - log::error!("Unable to find user '{}': {}", email, err); + log::error!("Unable to find user by email '{}': {}", email, err); None }); @@ -193,6 +216,7 @@ impl User { let user: User = sqlx::query_as::<_, Self>(&format!( r#" INSERT INTO {} ( + id, email, email_verified, password_hash, @@ -203,11 +227,12 @@ impl User { created_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * "#, TABLE_NAME, )) + .bind(&self.id) .bind(&self.email) .bind(&self.email_verified) .bind(&self.password_hash) diff --git a/bruno/Airports/Import Airports.bru b/bruno/Airports/Import Airports.bru index fd5ad96..69821c8 100644 --- a/bruno/Airports/Import Airports.bru +++ b/bruno/Airports/Import Airports.bru @@ -11,5 +11,5 @@ post { } body:multipart-form { - : @file(/Users/bsherriff/git/private/aviation/data/airports_2023-12-21.json) + : @file(/Users/bsherriff/git/private/aviation/data/2025-05-13_airports.json) } diff --git a/bruno/Users/Get Profile.bru b/bruno/Users/Get Profile.bru new file mode 100644 index 0000000..3c43de0 --- /dev/null +++ b/bruno/Users/Get Profile.bru @@ -0,0 +1,11 @@ +meta { + name: Get Profile + type: http + seq: 6 +} + +get { + url: {{API_URL}}/account/profile + body: none + auth: none +} diff --git a/bruno/Users/Validate Session.bru b/bruno/Users/Refresh Session.bru similarity index 80% rename from bruno/Users/Validate Session.bru rename to bruno/Users/Refresh Session.bru index 8a80f4e..10fdd75 100644 --- a/bruno/Users/Validate Session.bru +++ b/bruno/Users/Refresh Session.bru @@ -1,5 +1,5 @@ meta { - name: Validate Session + name: Refresh Session type: http seq: 5 } diff --git a/data/airports_2023-12-21.json b/data/2025-05-13_airports.json similarity index 99% rename from data/airports_2023-12-21.json rename to data/2025-05-13_airports.json index c9a7765..97acfe3 100644 --- a/data/airports_2023-12-21.json +++ b/data/2025-05-13_airports.json @@ -309321,7 +309321,7 @@ "iso_country": "US", "iso_region": "US-VA", "municipality": "Leesburg", - "elevation_ft": 389.0, + "elevation_ft": 389.5, "latitude": 39.077999, "longitude": -77.557503, "has_tower": true, @@ -309332,13 +309332,34 @@ "id": "17/35", "length_ft": 5500.0, "width_ft": 100.0, - "surface": "A" + "surface": "ASPH" } ], "frequencies": [ { "id": "LCL/P", + "name": "Tower", "frequency_mhz": 127.5 + }, + { + "id": "WX AWOS-3", + "name": "Weather", + "frequency_mhz": 125.225 + }, + { + "id": "GND/P", + "name": "Ground", + "frequency_mhz": 120.5 + }, + { + "id": "CD/P", + "name": "Potomac Clearance Delivery", + "frequency_mhz": 118.55 + }, + { + "id": "APCH/P DEP/P", + "name": "Potomac Approach", + "frequency_mhz": 125.05 } ] }, diff --git a/docker-compose.yml b/docker-compose.yml index 1589a1b..6afdea8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_NAME} + POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres:/var/lib/postgresql/data - postgres_logs:/var/log diff --git a/ui/src/App.css b/ui/src/App.css index 2a4dee6..37e711c 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -14,40 +14,15 @@ width: 100%; } -.map-button { - position: absolute; - right: 12px; - z-index: 1000; - - color: #000; - background: #fff; - border-radius: 3px; - border: 1px solid #ccc; - border-bottom-width: 2px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - - height: 30px; - width: 30px; - text-align: center; - line-height: 30px; /* Vertically center text */ - font-weight: bold; - - cursor: pointer; - user-select: none; - transition: - background-color 0.2s, - color 0.2s; -} - -.map-button.active { +.custom-control a.active { background-color: #228be6; - color: #fff; + color: white; } -.map-button.active:hover { - background-color: #187ed7; -} - -.map-button:hover { - background: #e6e6e6; +.custom-control a { + padding: 0; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 7178f14..9835f60 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { LayersControl, MapContainer, TileLayer, useMapEvents, ZoomControl } from 'react-leaflet'; +import { LayersControl, MapContainer, ScaleControl, TileLayer, useMapEvents, ZoomControl } from 'react-leaflet'; import '@mantine/core/styles.css'; import 'leaflet/dist/leaflet.css'; import './App.css'; @@ -13,9 +13,8 @@ import { Airport } from '@lib/airport.types.ts'; import Index from '@components/AirportDrawer'; import { getWeatherMapUrl } from '@lib/rainViewer.ts'; import Cookies from 'js-cookie'; -// import { createRoot } from 'react-dom/client'; -import { UnstyledButton } from '@mantine/core'; import { IconBuildingAirport, IconRadar } from '@tabler/icons-react'; +import { GroupControl } from '@components/GroupControl.tsx'; // Fix Leaflet's default icon path issues with Webpack // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -43,63 +42,6 @@ const layerMap: LayerInfo[] = [ const defaultZoom = 6; const defaultCenter: L.LatLngExpression = [38.944444, -77.455833]; -// function CustomControl({ toggleRadar, showRadar, toggleShowNoMetar, showNoMetar }) { -// const map = useMap(); -// -// useEffect(() => { -// const CustomLeafletControl = L.Control.extend({ -// options: { position: 'bottomright' }, -// onAdd: function () { -// // Create a container for the control -// const container = L.DomUtil.create('div', 'leaflet-bar custom-control'); -// -// // Radar button -// const radarButton = L.DomUtil.create('button', 'control-button radar-button', container); -// // radarButton.innerHTML = 'Radar'; -// // if (showRadar) { -// // radarButton.classList.add('active'); -// // } -// const radarRoot = createRoot(radarButton); -// radarRoot.render( -// -// ); -// // Prevent click events from propagating to the map -// L.DomEvent.disableClickPropagation(radarButton); -// L.DomEvent.on(radarButton, 'click', (e) => { -// L.DomEvent.stopPropagation(e); -// toggleRadar(); -// }); -// -// // Airports (no METARs) button -// const airportButton = L.DomUtil.create('button', 'control-button', container); -// airportButton.innerHTML = 'Airports'; -// if (showNoMetar) { -// airportButton.classList.add('active'); -// } -// L.DomEvent.disableClickPropagation(airportButton); -// L.DomEvent.on(airportButton, 'click', (e) => { -// L.DomEvent.stopPropagation(e); -// toggleShowNoMetar(); -// }); -// -// return container; -// } -// }); -// -// const customControl = new CustomLeafletControl(); -// customControl.addTo(map); -// -// // Remove control on cleanup -// return () => { -// customControl.remove(); -// }; -// }, [map, toggleRadar, toggleShowNoMetar, showRadar, showNoMetar]); -// -// return null; -// } - function App() { const [airport, setAirport] = useState(null); const [rainViewerUrl, setRainViewerUrl] = useState(null); @@ -172,31 +114,28 @@ function App() { ))} + {rainViewerUrl && showRadar && } - {/**/} + + }, + { + title: 'Toggle non‐METAR airports', + active: showNoMetar, + onClick: toggleShowNoMetar, + icon: + } + ]} + /> - - - - - - ); diff --git a/ui/src/components/Administration.tsx b/ui/src/components/Administration.tsx index 8c0b1d8..9cddae3 100644 --- a/ui/src/components/Administration.tsx +++ b/ui/src/components/Administration.tsx @@ -1,14 +1,14 @@ import { Header } from '@components/Header'; -import { Navigate } from 'react-router'; import { useUserContext } from '@components/context/UserContext.tsx'; import { AirportTable } from '@components/AirportTable'; import { AirportDrop } from '@components/AirportDrop'; +import { NotFound } from '@components/NotFound'; export function Administration() { const { user } = useUserContext(); - if (user == undefined) { - return ; + if (user == undefined || user.role != 'ADMIN') { + return ; } return ( diff --git a/ui/src/components/AirportDrawer/FrequencyTable.tsx b/ui/src/components/AirportDrawer/FrequencyTable.tsx new file mode 100644 index 0000000..7a6c043 --- /dev/null +++ b/ui/src/components/AirportDrawer/FrequencyTable.tsx @@ -0,0 +1,25 @@ +import { Table } from '@mantine/core'; +import { Frequency } from '@lib/airport.types.ts'; + +export default function FrequencyTable({ frequencies }: { frequencies: Frequency[] }) { + const rows = frequencies.map((frequency) => ( + + {frequency.id} + {frequency.name} + {frequency.frequency_mhz} + + )); + + return ( + + + + ID + Name + MHz + + + {rows} +
+ ); +} \ No newline at end of file diff --git a/ui/src/components/AirportDrawer/RunwayTable.tsx b/ui/src/components/AirportDrawer/RunwayTable.tsx new file mode 100644 index 0000000..f97848d --- /dev/null +++ b/ui/src/components/AirportDrawer/RunwayTable.tsx @@ -0,0 +1,27 @@ +import { Table } from '@mantine/core'; +import { Runway } from '@lib/airport.types.ts'; + +export default function RunwayTable({ runways }: { runways: Runway[] }) { + const rows = runways.map((runway) => ( + + {runway.id} + {runway.surface} + {runway.length_ft} + {runway.width_ft} + + )); + + return ( + + + + ID + Surface + Length (ft) + Width (ft) + + + {rows} +
+ ); +} \ No newline at end of file diff --git a/ui/src/components/AirportDrawer/index.tsx b/ui/src/components/AirportDrawer/index.tsx index 4d97588..ed618ba 100644 --- a/ui/src/components/AirportDrawer/index.tsx +++ b/ui/src/components/AirportDrawer/index.tsx @@ -1,10 +1,24 @@ -import { Badge, Box, Divider, Drawer, Group, Tabs, TabsList, Text, Tooltip, UnstyledButton } from '@mantine/core'; +import { + Accordion, + Badge, + Box, + Divider, + Drawer, + Group, + Tabs, + TabsList, + Text, + Tooltip, + UnstyledButton +} from '@mantine/core'; import { Airport, AirportCategory } from '@lib/airport.types.ts'; import { getMarkerColor, Metar } from '@lib/metar.types.ts'; import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react'; import { getMetars } from '@lib/metar.ts'; import { useMediaQuery } from '@mantine/hooks'; import { IconViewfinder } from '@tabler/icons-react'; +import RunwayTable from '@components/AirportDrawer/RunwayTable.tsx'; +import FrequencyTable from '@components/AirportDrawer/FrequencyTable.tsx'; export default function Index({ airport, @@ -155,6 +169,28 @@ function AirportInfo({ airport }: { airport: Airport }) { + + {airport.runways != null && airport.runways.length > 0 && ( + + + Runways + + + + + + )} + {airport.frequencies != null && airport.frequencies.length > 0 && ( + + + Frequencies + + + + + + )} + ); diff --git a/ui/src/components/AirportDrop/AirportDrop.module.css b/ui/src/components/AirportDrop/AirportDrop.module.css index ffbc5b5..4722753 100644 --- a/ui/src/components/AirportDrop/AirportDrop.module.css +++ b/ui/src/components/AirportDrop/AirportDrop.module.css @@ -25,4 +25,4 @@ font-size: var(--mantine-font-size-sm); color: var(--mantine-color-dimmed); margin-top: var(--mantine-spacing-xs); -} \ No newline at end of file +} diff --git a/ui/src/components/AirportDrop/index.tsx b/ui/src/components/AirportDrop/index.tsx index 7a25264..c6c6569 100644 --- a/ui/src/components/AirportDrop/index.tsx +++ b/ui/src/components/AirportDrop/index.tsx @@ -20,9 +20,9 @@ export function AirportDrop() { setLoading(true); try { const formData = new FormData(); - files.forEach(file => { + files.forEach((file) => { formData.append('files', file, file.name); - }) + }); await importAirports(formData); } catch (error) { console.error('Upload error:', error); @@ -31,12 +31,12 @@ export function AirportDrop() { } }} className={classes.dropzone} - radius="md" + radius='md' accept={['application/JSON']} maxSize={30 * 1024 ** 2} >
- + @@ -48,22 +48,22 @@ export function AirportDrop() { - + Drop files here Json file less than 30mb Upload JSON - Drag'n'drop files here to upload. We can accept only .json files that - are less than 30mb in size. + Drag'n'drop files here to upload. We can accept only .json files that are less than 30mb in + size.
- ); -} \ No newline at end of file +} diff --git a/ui/src/components/AirportTable/AirportTable.module.css b/ui/src/components/AirportTable/AirportTable.module.css index ffff651..46bc9bc 100644 --- a/ui/src/components/AirportTable/AirportTable.module.css +++ b/ui/src/components/AirportTable/AirportTable.module.css @@ -16,4 +16,4 @@ .scrolled { box-shadow: var(--mantine-shadow-sm); -} \ No newline at end of file +} diff --git a/ui/src/components/AirportTable/index.tsx b/ui/src/components/AirportTable/index.tsx index 72f638e..209c9b6 100644 --- a/ui/src/components/AirportTable/index.tsx +++ b/ui/src/components/AirportTable/index.tsx @@ -13,11 +13,11 @@ export function AirportTable() { useEffect(() => { const limit = 1000; - getAirports({ page, limit }).then(r => { + getAirports({ page, limit }).then((r) => { setData(r.data); setTotalPages(r.total / r.data.length); }); - },[page]); + }, [page]); const rows = data.map((row, idx) => ( @@ -43,14 +43,8 @@ export function AirportTable() { {rows} -
- +
+
); diff --git a/ui/src/components/CustomControl.tsx b/ui/src/components/CustomControl.tsx new file mode 100644 index 0000000..2e01faa --- /dev/null +++ b/ui/src/components/CustomControl.tsx @@ -0,0 +1,75 @@ +import { ReactNode, useEffect, useRef } from 'react'; +import * as L from 'leaflet'; +import { useMap } from 'react-leaflet'; +import { createRoot, Root } from 'react-dom/client'; + +interface Props { + position?: L.ControlPosition; + onClick: () => void; + active?: boolean; + title?: string; + children?: ReactNode; +} + +export function CustomControl({ position = 'bottomright', onClick, active = false, title = '', children }: Props) { + const map = useMap(); + + // Create references + const buttonRef = useRef(null); + const reactRootRef = useRef(null); + + useEffect(() => { + const ctrl = new L.Control({ position }); + + ctrl.onAdd = () => { + const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control'); + const button = L.DomUtil.create('a', '', container) as HTMLAnchorElement; + button.href = '#'; + button.title = title; + + // Prevent clicks/scrolls on the control from hitting the map + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + // Wire up the handler + L.DomEvent.on(button, 'click', L.DomEvent.stop); + L.DomEvent.on(button, 'click', L.DomEvent.preventDefault); + L.DomEvent.on(button, 'click', () => onClick()); + + buttonRef.current = button; + + // Initial active status + if (active) { + button.classList.add('active'); + } + + // Render children + if (children) { + reactRootRef.current = createRoot(button); + reactRootRef.current.render(children); + } + return container; + }; + + // Add component to the map + ctrl.addTo(map); + + // On unmount, remove component + return () => { + ctrl.remove(); + if (reactRootRef.current) { + reactRootRef.current.unmount(); + reactRootRef.current = null; + } + }; + }, [map, position, onClick, children, active, title]); + + useEffect(() => { + const btn = buttonRef.current; + if (!btn) return; + if (active) btn.classList.add('active'); + else btn.classList.remove('active'); + }, [active]); + + return null; +} diff --git a/ui/src/components/GroupControl.tsx b/ui/src/components/GroupControl.tsx new file mode 100644 index 0000000..cd21db8 --- /dev/null +++ b/ui/src/components/GroupControl.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react'; +import { ReactNode } from 'react'; +import * as L from 'leaflet'; +import { useMap } from 'react-leaflet'; +import { createRoot, Root } from 'react-dom/client'; + +export interface ButtonDef { + title: string; + active: boolean; + onClick: () => void; + icon: ReactNode; +} + +interface GroupControlProps { + position?: L.ControlPosition; + buttons: ButtonDef[]; +} + +export function GroupControl({ position = 'bottomright', buttons }: GroupControlProps) { + const map = useMap(); + // References + const buttonRefs = useRef([]); + const reactRootRefs = useRef([]); + + useEffect(() => { + const ctrl = new L.Control({ position }); + const current = reactRootRefs.current; + + ctrl.onAdd = () => { + const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control'); + buttons.forEach((btnDef, i) => { + const btn = L.DomUtil.create('a', '', container) as HTMLAnchorElement; + btn.href = '#'; + btn.title = btnDef.title; + + // standard leaflet click‐blocking magic + L.DomEvent.disableClickPropagation(btn); + L.DomEvent.disableScrollPropagation(btn); + L.DomEvent.on(btn, 'click', L.DomEvent.stop) + .on(btn, 'click', L.DomEvent.preventDefault) + .on(btn, 'click', btnDef.onClick); + + // Initial active status + if (btnDef.active) btn.classList.add('active'); + + // Render root + const rootRef = createRoot(btn); + rootRef.render(btnDef.icon); + reactRootRefs.current[i] = rootRef; + buttonRefs.current[i] = btn; + }); + return container; + }; + + ctrl.addTo(map); + + return () => { + ctrl.remove(); + // unmount React roots + current.forEach((r) => r.unmount()); + }; + }, [map, buttons, position]); + + // if you want to toggle “.active” live when props change + useEffect(() => { + buttons.forEach((b, i) => { + const btn = buttonRefs.current[i]; + if (!btn) return; + if (b.active) btn.classList.add('active'); + else btn.classList.remove('active'); + }); + }, [buttons]); + + return null; +} diff --git a/ui/src/components/NotFound/Illustration.tsx b/ui/src/components/NotFound/Illustration.tsx new file mode 100644 index 0000000..5d53b8a --- /dev/null +++ b/ui/src/components/NotFound/Illustration.tsx @@ -0,0 +1,12 @@ +import { ComponentPropsWithoutRef } from 'react'; + +export function Illustration(props: ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} diff --git a/ui/src/components/NotFound/NotFound.module.css b/ui/src/components/NotFound/NotFound.module.css new file mode 100644 index 0000000..e3507ec --- /dev/null +++ b/ui/src/components/NotFound/NotFound.module.css @@ -0,0 +1,43 @@ +.root { + padding-top: 80px; + padding-bottom: 80px; +} + +.inner { + position: relative; +} + +.image { + position: absolute; + inset: 0; + opacity: 0.75; + color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.content { + padding-top: 220px; + position: relative; + z-index: 1; + + @media (max-width: $mantine-breakpoint-sm) { + padding-top: 120px; + } +} + +.title { + font-family: Outfit, var(--mantine-font-family); + text-align: center; + font-weight: 500; + font-size: 38px; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: 32px; + } +} + +.description { + max-width: 540px; + margin: auto; + margin-top: var(--mantine-spacing-xl); + margin-bottom: calc(var(--mantine-spacing-xl) * 1.5); +} diff --git a/ui/src/components/NotFound/index.tsx b/ui/src/components/NotFound/index.tsx new file mode 100644 index 0000000..2508c73 --- /dev/null +++ b/ui/src/components/NotFound/index.tsx @@ -0,0 +1,27 @@ +import { Button, Container, Group, Text, Title } from '@mantine/core'; +import { Illustration } from './Illustration'; +import classes from './NotFound.module.css'; +import { useNavigate } from 'react-router'; + +export function NotFound() { + const navigate = useNavigate(); + return ( + +
+ +
+ Nothing to see here + + Page you are trying to open does not exist. You may have mistyped the address, or the page has been moved to + another URL. If you think this is an error contact support. + + + + +
+
+
+ ); +} diff --git a/ui/src/components/Profile.tsx b/ui/src/components/Profile.tsx index 31701c8..e7d914e 100644 --- a/ui/src/components/Profile.tsx +++ b/ui/src/components/Profile.tsx @@ -1,12 +1,12 @@ import { Header } from '@components/Header'; import { useUserContext } from '@components/context/UserContext.tsx'; -import { Navigate } from 'react-router'; +import { NotFound } from '@components/NotFound'; export function Profile() { const { user } = useUserContext(); if (user == undefined) { - return ; + return ; } return ( diff --git a/ui/src/components/context/UserProvider.tsx b/ui/src/components/context/UserProvider.tsx index 8fd0f03..a0e4e12 100644 --- a/ui/src/components/context/UserProvider.tsx +++ b/ui/src/components/context/UserProvider.tsx @@ -1,6 +1,6 @@ import { ReactNode, useEffect, useState } from 'react'; import { UserContext } from './UserContext.tsx'; -import { refresh } from '@lib/account.ts'; +import { profile } from '@lib/account.ts'; import { User } from '@lib/account.types.ts'; import { Center, Loader } from '@mantine/core'; @@ -9,7 +9,7 @@ export function UserProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); useEffect(() => { - refresh().then((refreshUser) => { + profile().then((refreshUser) => { if (refreshUser) { setUser(refreshUser); } else { diff --git a/ui/src/lib/account.ts b/ui/src/lib/account.ts index 573eb32..4dc24f4 100644 --- a/ui/src/lib/account.ts +++ b/ui/src/lib/account.ts @@ -23,8 +23,8 @@ export async function logout() { return await postRequest('account/logout', {}); } -export async function refresh(): Promise { - const response = await getRequest('account/session'); +export async function profile(): Promise { + const response = await getRequest('account/profile'); if (response?.status === 200) { return response.json(); } else { diff --git a/ui/src/lib/airport.types.ts b/ui/src/lib/airport.types.ts index 61f4ea4..75f2b88 100644 --- a/ui/src/lib/airport.types.ts +++ b/ui/src/lib/airport.types.ts @@ -50,6 +50,7 @@ export interface Runway { export interface Frequency { id: string; + name: string; frequency_mhz: number; } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index d3eebe7..6da2a56 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -8,6 +8,7 @@ import { UserProvider } from '@components/context/UserProvider.tsx'; import { BrowserRouter, Route, Routes } from 'react-router'; import { Profile } from '@components/Profile.tsx'; import { Administration } from '@components/Administration.tsx'; +import { NotFound } from '@components/NotFound'; const theme = createTheme({ fontFamily: 'Inter, sans-serif' @@ -23,6 +24,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } />