Updates to account, ui, etc

This commit is contained in:
2025-05-13 22:57:29 -04:00
parent a273d4134b
commit abfa6b534c
38 changed files with 781 additions and 215 deletions

4
.env
View File

@@ -11,7 +11,7 @@ NGINX_INTERNAL_HOST=host.docker.internal
POSTGRES_HOST=localhost POSTGRES_HOST=localhost
POSTGRES_USER=aviation POSTGRES_USER=aviation
POSTGRES_PASSWORD=changeme POSTGRES_PASSWORD=changeme
POSTGRES_NAME=aviation POSTGRES_DB=aviation_db
POSTGRES_PORT=5432 POSTGRES_PORT=5432
REDIS_HOST=localhost 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_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api
VITE_DEFAULT_LIMIT=200 VITE_DEFAULT_LIMIT=200
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS:${NGINX_HOST} __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST}
ENVIRONMENT=development ENVIRONMENT=development
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com

View File

@@ -15,7 +15,7 @@ help: ## This info
format: format-api format-ui format-adsb ## Format code format: format-api format-ui format-adsb ## Format code
psql: ## Connect to the PSQL DB 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 # # API Commands #

View File

@@ -4,9 +4,7 @@ Debug using `export LIBUSB_DEBUG=4`
`lsusb -v -d 0bda:2832` `lsusb -v -d 0bda:2832`
## Simulation Mode ## Simulation Mode
`cargo run -p adsb_sim --` `cargo run -- --connect`
`cargo run -p adsb_recv -- --net`
## Decode ## Decode
`cargo run -p adsb_recv -- --decode 8D4840D6202CC371C32CE0576098` `cargo run -- --decode 8D4840D6202CC371C32CE0576098`

View File

@@ -21,25 +21,42 @@ pub const DEVICE_RTL2832U: DeviceInfo = DeviceInfo {
pid: 0x2832, pid: 0x2832,
}; };
// Timeout
pub const TIMEOUT: Duration = Duration::from_secs(1); pub const TIMEOUT: Duration = Duration::from_secs(1);
pub const FIR_LENGTH: usize = 16;
// pub const DEFAULT_BUFFER_LENGTH: usize = 4096;
pub const DEFAULT_BUFFER_LENGTH: usize = 64;
// Request Types // Request Types
pub const REQ_CTRL_OUT: u8 = pub const REQ_CTRL_OUT: u8 =
rusb::constants::LIBUSB_ENDPOINT_OUT | rusb::constants::LIBUSB_REQUEST_TYPE_VENDOR; rusb::constants::LIBUSB_ENDPOINT_OUT | rusb::constants::LIBUSB_REQUEST_TYPE_VENDOR;
// Blocks // Blocks
pub const BLOCK_DEMOD: u16 = 0;
pub const BLOCK_USB: u16 = 1; 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 // USB
pub const USB_EPA_CTL: u16 = 0x2148; pub const USB_EPA_CTL: u16 = 0x2148;
pub const USB_SYSCTL: u16 = 0x2000; pub const USB_SYSCTL: u16 = 0x2000;
pub const USB_EPA_MAXPKT: u16 = 0x2158;
/// ADS-B downlink frequency (1090 MHz) /// ADS-B downlink frequency (1090 MHz)
pub const ADSB_FREQUENCY_HZ: u32 = 1_090_000_000; pub const ADSB_FREQUENCY_HZ: u32 = 1_090_000_000;
/// RTL-SDR sample rate in samples/second. /// RTL-SDR sample rate in samples/second.
pub const SAMPLE_RATE_HZ: u32 = 2_048_000; 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;

View File

@@ -7,8 +7,9 @@ use rusb::{
}; };
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::constants::{ use crate::constants::{
ADSB_FREQUENCY_HZ, BLOCK_USB, DEFAULT_BUFFER_LENGTH, REQ_CTRL_OUT, SAMPLE_RATE_HZ, TIMEOUT, ADSB_FREQUENCY_HZ, BLOCK_SYS, BLOCK_USB, DEFAULT_BUFFER_LENGTH, DEFAULT_FIR,
USB_EPA_CTL, USB_SYSCTL, 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` /// rusb/libusb implementation of `RtlSdrDevice`
@@ -83,7 +84,7 @@ impl RtlSdrDevice {
Some(e) => e, Some(e) => e,
None => return Err(Error::new("Unable to find endpoint on device")), 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); let mut sdr = Self::new(handle, endpoint);
@@ -158,6 +159,10 @@ impl RtlSdrDevice {
self.test_write()?; self.test_write()?;
self.initialize_baseband()?;
self.set_i2c_repeater(true)?;
// Reset the internal USB buffer // Reset the internal USB buffer
self.reset_buffer()?; self.reset_buffer()?;
@@ -191,6 +196,7 @@ impl RtlSdrDevice {
.map_err(|err| Error::new(format!("Failed to set alternate setting: {:?}", err))) .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<()> { fn test_write(&self) -> Result<()> {
log::trace!("Testing write to device..."); log::trace!("Testing write to device...");
let length = ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x89, 1)?; let length = ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x89, 1)?;
@@ -215,6 +221,66 @@ impl RtlSdrDevice {
Ok(()) 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<()> { fn set_center_frequency(&mut self, frequency: u32) -> Result<()> {
log::trace!("Setting center_frequency to {}Hz", frequency); log::trace!("Setting center_frequency to {}Hz", frequency);
self.frequency = frequency; self.frequency = frequency;
@@ -223,7 +289,77 @@ impl RtlSdrDevice {
fn set_sample_rate(&mut self, rate: u32) -> Result<()> { fn set_sample_rate(&mut self, rate: u32) -> Result<()> {
log::trace!("Setting sample_rate to {}Hz", rate); 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(()) Ok(())
} }
@@ -397,5 +533,18 @@ fn demod_ctrl_write_register<T: UsbContext>(
let buffer = if length == 1 { &data[1..2] } else { &data }; let buffer = if length == 1 { &data[1..2] } else { &data };
let index = 0x10 | page; let index = 0x10 | page;
let address = (address << 8) | 0x20; 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) handle.write_control(REQ_CTRL_OUT, 0x00, address, index, buffer, TIMEOUT)
} }

View File

@@ -38,16 +38,20 @@ CREATE TABLE IF NOT EXISTS runways (
); );
CREATE INDEX ON runways (icao); CREATE INDEX ON runways (icao);
CREATE INDEX ON runways (runway_id);
CREATE INDEX ON runways (surface); CREATE INDEX ON runways (surface);
CREATE TABLE IF NOT EXISTS frequencies ( CREATE TABLE IF NOT EXISTS frequencies (
id UUID PRIMARY KEY NOT NULL, id UUID PRIMARY KEY NOT NULL,
icao TEXT NOT NULL, icao TEXT NOT NULL,
frequency_id TEXT NOT NULL, frequency_id TEXT NOT NULL,
frequency_name TEXT,
frequency_mhz REAL NOT NULL frequency_mhz REAL NOT NULL
); );
CREATE INDEX ON frequencies (icao); CREATE INDEX ON frequencies (icao);
CREATE INDEX ON frequencies (frequency_id);
CREATE INDEX ON frequencies (frequency_name);
CREATE INDEX ON frequencies (frequency_mhz); CREATE INDEX ON frequencies (frequency_mhz);
CREATE TABLE IF NOT EXISTS metars ( CREATE TABLE IF NOT EXISTS metars (
@@ -61,7 +65,8 @@ CREATE TABLE IF NOT EXISTS metars (
CREATE INDEX ON metars (observation_time DESC); CREATE INDEX ON metars (observation_time DESC);
CREATE TABLE IF NOT EXISTS users ( 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, email_verified BOOLEAN NOT NULL DEFAULT false,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
@@ -69,5 +74,6 @@ CREATE TABLE IF NOT EXISTS users (
last_name TEXT NOT NULL, last_name TEXT NOT NULL,
avatar TEXT, avatar TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(email)
); );

View File

@@ -34,13 +34,13 @@ impl FromRequest for Auth {
return Err(Error::new(401, "API Key does not exist".to_string()).into()); 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 { Some(user) => Ok(Auth {
session_id: None, session_id: None,
api_key: Some(key_id), api_key: Some(key_id),
user, 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); return Box::pin(fut);
@@ -79,13 +79,13 @@ impl FromRequest for Auth {
// Verify the session // Verify the session
let fut = async move { let fut = async move {
match Session::verify(&session_id, &ip_address).await { 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 { Some(user) => Ok(Auth {
session_id: Some(session_id), session_id: Some(session_id),
api_key: None, api_key: None,
user, 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()), Err(err) => Err(err.into()),
} }

View File

@@ -50,15 +50,16 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
let email = &request.email; let email = &request.email;
let ip_address = req.peer_addr().unwrap().ip().to_string(); 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, Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(), None => return HttpResponse::Unauthorized().finish(),
}; };
if verify_hash(&request.password, &query_user.password_hash) { if verify_hash(&request.password, &query_user.password_hash) {
// Create a session // 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_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
// Save the session to the database // Save the session to the database
if let Err(err) = session.store().await { if let Err(err) = session.store().await {
log::error!( log::error!(
@@ -77,6 +78,7 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
let user_response: UserResponse = query_user.into(); let user_response: UserResponse = query_user.into();
HttpResponse::Ok() HttpResponse::Ok()
.cookie(session_cookie) .cookie(session_cookie)
.cookie(session_exp_cookie)
.json(user_response) .json(user_response)
} else { } else {
log::error!( log::error!(
@@ -84,7 +86,10 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
email, email,
ip_address 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, email,
ip_address 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")] #[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(); let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists // Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) { match req.cookie(SESSION_COOKIE_NAME) {
@@ -142,33 +204,27 @@ async fn validate_session(req: HttpRequest) -> HttpResponse {
); );
return HttpResponse::Unauthorized() return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie()) .cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish(); .finish();
} }
}; };
let email = &session.email; let id = &session.id;
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 session_cookie = session.cookie(); let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
log::info!( log::info!(
"Successful session validate attempt [Email: {}] [IP Address: {}]", "Successful session validate attempt [ID: {}] [IP Address: {}]",
email, id,
ip_address ip_address
); );
HttpResponse::Ok() HttpResponse::Ok()
.cookie(session_cookie) .cookie(session_cookie)
.json(user_response) .cookie(session_exp_cookie)
.finish()
} }
None => HttpResponse::Unauthorized() None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie()) .cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish(), .finish(),
} }
} }
@@ -180,9 +236,9 @@ async fn change_password(
auth: Auth, auth: Auth,
) -> HttpResponse { ) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string(); 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(); return HttpResponse::Unauthorized().finish();
}; };
@@ -196,20 +252,20 @@ async fn change_password(
avatar: None, avatar: None,
}; };
match update_user.update(&email).await { match update_user.update(&id).await {
Ok(user) => { Ok(user) => {
let response: UserResponse = user.into(); let response: UserResponse = user.into();
log::info!( log::info!(
"Successful password change attempt [Email: {}] [IP Address: {}]", "Successful password change attempt [ID: {}] [IP Address: {}]",
&email, &id,
ip_address ip_address
); );
HttpResponse::Ok().json(response) HttpResponse::Ok().json(response)
} }
Err(err) => { Err(err) => {
log::error!( log::error!(
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}", "Invalid password change attempt [ID: {}] [IP Address: {}]: {}",
&email, &id,
ip_address, ip_address,
err err
); );
@@ -231,6 +287,7 @@ pub fn init_routes(config: &mut web::ServiceConfig) {
.service(login) .service(login)
.service(logout) .service(logout)
.service(change_password) .service(change_password)
.service(validate_session), .service(get_profile)
.service(session_refresh),
); );
} }

View File

@@ -3,6 +3,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult}; use redis::{AsyncCommands, RedisResult};
use tokio::task; use tokio::task;
use uuid::Uuid;
use crate::{ use crate::{
db::redis_async_connection, db::redis_async_connection,
error::{Error, ApiResult}, error::{Error, ApiResult},
@@ -11,26 +12,27 @@ use super::{csprng, hash, verify_hash};
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session"; pub const SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Session { pub struct Session {
pub session_id: String, pub session_id: String,
pub email: String, pub id: Uuid,
pub ip_address: String, pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>, pub expires_at: Option<DateTime<Utc>>,
} }
impl Session { impl Session {
pub fn default(email: &str, ip_address: &str) -> Self { pub fn default(id: &Uuid, ip_address: &str) -> Self {
Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL)) Self::new(64, id, ip_address, Some(DEFAULT_SESSION_TTL))
} }
pub fn new(take: usize, email: &str, ip_address: &str, ttl: Option<i64>) -> Self { pub fn new(take: usize, id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now(); let now = Utc::now();
Self { Self {
session_id: csprng(take), session_id: csprng(take),
email: email.to_string(), id: id.clone(),
ip_address: hash(&ip_address).unwrap(), ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl { expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(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?; session.store().await?;
Ok(session) Ok(session)
} }
@@ -118,8 +120,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") { if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" { if environment == "development" || environment == "dev" {
log::trace!( log::trace!(
"Development cookie [Email: {}]: {}", "Development cookie [ID: {}]: {}",
self.email, self.id,
self.session_id self.session_id
); );
cookie.set_secure(false); cookie.set_secure(false);
@@ -130,6 +132,28 @@ impl Session {
cookie 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> { pub fn empty_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "") let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/") .path("/")
@@ -147,4 +171,21 @@ impl Session {
cookie 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
}
} }

View File

@@ -11,6 +11,8 @@ const TABLE_NAME: &str = "frequencies";
pub struct Frequency { pub struct Frequency {
#[serde(rename = "id")] #[serde(rename = "id")]
pub frequency_id: String, pub frequency_id: String,
#[serde(rename = "name")]
pub frequency_name: Option<String>,
pub frequency_mhz: f32, pub frequency_mhz: f32,
} }
@@ -19,6 +21,7 @@ pub struct FrequencyRow {
pub id: Uuid, pub id: Uuid,
pub icao: String, pub icao: String,
pub frequency_id: String, pub frequency_id: String,
pub frequency_name: Option<String>,
pub frequency_mhz: f32, pub frequency_mhz: f32,
} }
@@ -28,6 +31,8 @@ pub struct UpdateFrequency {
pub icao: Option<String>, pub icao: Option<String>,
#[serde(rename = "id", skip_serializing_if = "Option::is_none")] #[serde(rename = "id", skip_serializing_if = "Option::is_none")]
pub frequency_id: Option<String>, pub frequency_id: Option<String>,
#[serde(rename = "name", skip_serializing_if = "Option::is_none")]
pub frequency_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub frequency_mhz: Option<f32>, pub frequency_mhz: Option<f32>,
} }
@@ -36,6 +41,7 @@ impl From<FrequencyRow> for Frequency {
fn from(frequency: FrequencyRow) -> Self { fn from(frequency: FrequencyRow) -> Self {
Self { Self {
frequency_id: frequency.frequency_id.clone(), frequency_id: frequency.frequency_id.clone(),
frequency_name: frequency.frequency_name.clone(),
frequency_mhz: frequency.frequency_mhz, frequency_mhz: frequency.frequency_mhz,
} }
} }
@@ -47,6 +53,7 @@ impl Frequency {
id: Uuid::new_v4(), id: Uuid::new_v4(),
icao: icao.to_string(), icao: icao.to_string(),
frequency_id: frequency.frequency_id.clone(), frequency_id: frequency.frequency_id.clone(),
frequency_name: frequency.frequency_name.clone(),
frequency_mhz: frequency.frequency_mhz.clone(), frequency_mhz: frequency.frequency_mhz.clone(),
} }
} }
@@ -96,13 +103,14 @@ impl Frequency {
for chunk in frequencies.chunks(chunk_size) { for chunk in frequencies.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!( let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
"INSERT INTO {} (id, icao, frequency_id, frequency_mhz) ", "INSERT INTO {} (id, icao, frequency_id, frequency_name, frequency_mhz) ",
TABLE_NAME TABLE_NAME
)); ));
query_builder.push_values(chunk, |mut b, row| { query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.id) b.push_bind(&row.id)
.push_bind(&row.icao) .push_bind(&row.icao)
.push_bind(&row.frequency_id) .push_bind(&row.frequency_id)
.push_bind(&row.frequency_name)
.push_bind(&row.frequency_mhz); .push_bind(&row.frequency_mhz);
}); });

View File

@@ -18,7 +18,7 @@ pub async fn initialize() -> ApiResult<()> {
let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set"); 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 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 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!( let db_url = format!(
"postgres://{}:{}@{}:{}/{}", "postgres://{}:{}@{}:{}/{}",

View File

@@ -4,6 +4,7 @@ use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger, web}; use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename; use dotenv::from_filename;
use reqwest::Certificate; use reqwest::Certificate;
use uuid::Uuid;
use crate::account::hash; use crate::account::hash;
use crate::users::{User, ADMIN_ROLE}; use crate::users::{User, ADMIN_ROLE};
@@ -32,7 +33,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let admin_password = env::var("ADMIN_PASSWORD"); let admin_password = env::var("ADMIN_PASSWORD");
if admin_email.is_ok() && admin_password.is_ok() { if admin_email.is_ok() && admin_password.is_ok() {
let email = admin_email.unwrap(); 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"); log::debug!("Creating default administrator");
let password = admin_password.unwrap(); let password = admin_password.unwrap();
let password_hash = hash(&password)?; let password_hash = hash(&password)?;
@@ -42,6 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
} }
let admin_user = User { let admin_user = User {
id: Uuid::new_v4(),
email, email,
email_verified: true, email_verified: true,
password_hash, password_hash,

View File

@@ -21,6 +21,7 @@ impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> { pub fn to_user(self) -> ApiResult<User> {
let password_hash = hash(&self.password)?; let password_hash = hash(&self.password)?;
Ok(User { Ok(User {
id: Uuid::new_v4(),
email: self.email.to_lowercase(), email: self.email.to_lowercase(),
email_verified: false, email_verified: false,
password_hash, password_hash,
@@ -42,17 +43,19 @@ pub struct LoginRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct UserResponse { pub struct UserResponse {
pub email_verified: bool, pub id: Uuid,
pub role: String, pub role: String,
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>, pub avatar: Option<String>,
pub email_verified: bool,
} }
impl From<User> for UserResponse { impl From<User> for UserResponse {
fn from(user: User) -> Self { fn from(user: User) -> Self {
UserResponse { UserResponse {
id: user.id,
email_verified: user.email_verified, email_verified: user.email_verified,
role: user.role, role: user.role,
first_name: user.first_name, first_name: user.first_name,
@@ -74,7 +77,7 @@ pub struct UpdateUser {
} }
impl UpdateUser { impl UpdateUser {
pub async fn update(&self, email: &str) -> ApiResult<User> { pub async fn update(&self, id: &Uuid) -> ApiResult<User> {
let pool = db::pool(); let pool = db::pool();
let mut query_builder: QueryBuilder<Postgres> = let mut query_builder: QueryBuilder<Postgres> =
@@ -130,8 +133,8 @@ impl UpdateUser {
query_builder.push("updated_at = "); query_builder.push("updated_at = ");
query_builder.push_bind(Utc::now()); query_builder.push_bind(Utc::now());
query_builder.push(" WHERE email = "); query_builder.push(" WHERE id = ");
query_builder.push_bind(email.to_string()); query_builder.push_bind(id);
query_builder.push(" RETURNING *"); query_builder.push(" RETURNING *");
let query = query_builder.build_query_as::<User>(); let query = query_builder.build_query_as::<User>();
@@ -143,6 +146,7 @@ impl UpdateUser {
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User { pub struct User {
pub id: Uuid,
pub email: String, pub email: String,
pub email_verified: bool, pub email_verified: bool,
pub password_hash: String, pub password_hash: String,
@@ -155,7 +159,26 @@ pub struct User {
} }
impl User { impl User {
pub async fn select(email: &str) -> Option<Self> { pub async fn select(id: &Uuid) -> Option<Self> {
let pool = db::pool();
let user: Option<Self> = 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<Self> {
let pool = db::pool(); let pool = db::pool();
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!( let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
r#" r#"
@@ -167,7 +190,7 @@ impl User {
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
log::error!("Unable to find user '{}': {}", email, err); log::error!("Unable to find user by email '{}': {}", email, err);
None None
}); });
@@ -193,6 +216,7 @@ impl User {
let user: User = sqlx::query_as::<_, Self>(&format!( let user: User = sqlx::query_as::<_, Self>(&format!(
r#" r#"
INSERT INTO {} ( INSERT INTO {} (
id,
email, email,
email_verified, email_verified,
password_hash, password_hash,
@@ -203,11 +227,12 @@ impl User {
created_at, created_at,
updated_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 * RETURNING *
"#, "#,
TABLE_NAME, TABLE_NAME,
)) ))
.bind(&self.id)
.bind(&self.email) .bind(&self.email)
.bind(&self.email_verified) .bind(&self.email_verified)
.bind(&self.password_hash) .bind(&self.password_hash)

View File

@@ -11,5 +11,5 @@ post {
} }
body:multipart-form { 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)
} }

View File

@@ -0,0 +1,11 @@
meta {
name: Get Profile
type: http
seq: 6
}
get {
url: {{API_URL}}/account/profile
body: none
auth: none
}

View File

@@ -1,5 +1,5 @@
meta { meta {
name: Validate Session name: Refresh Session
type: http type: http
seq: 5 seq: 5
} }

View File

@@ -309321,7 +309321,7 @@
"iso_country": "US", "iso_country": "US",
"iso_region": "US-VA", "iso_region": "US-VA",
"municipality": "Leesburg", "municipality": "Leesburg",
"elevation_ft": 389.0, "elevation_ft": 389.5,
"latitude": 39.077999, "latitude": 39.077999,
"longitude": -77.557503, "longitude": -77.557503,
"has_tower": true, "has_tower": true,
@@ -309332,13 +309332,34 @@
"id": "17/35", "id": "17/35",
"length_ft": 5500.0, "length_ft": 5500.0,
"width_ft": 100.0, "width_ft": 100.0,
"surface": "A" "surface": "ASPH"
} }
], ],
"frequencies": [ "frequencies": [
{ {
"id": "LCL/P", "id": "LCL/P",
"name": "Tower",
"frequency_mhz": 127.5 "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
} }
] ]
}, },

View File

@@ -36,7 +36,7 @@ services:
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_NAME} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
- postgres_logs:/var/log - postgres_logs:/var/log

View File

@@ -14,40 +14,15 @@
width: 100%; width: 100%;
} }
.map-button { .custom-control a.active {
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 {
background-color: #228be6; background-color: #228be6;
color: #fff; color: white;
} }
.map-button.active:hover { .custom-control a {
background-color: #187ed7; padding: 0;
} line-height: 1;
display: flex;
.map-button:hover { align-items: center;
background: #e6e6e6; justify-content: center;
} }

View File

@@ -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 '@mantine/core/styles.css';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import './App.css'; import './App.css';
@@ -13,9 +13,8 @@ import { Airport } from '@lib/airport.types.ts';
import Index from '@components/AirportDrawer'; import Index from '@components/AirportDrawer';
import { getWeatherMapUrl } from '@lib/rainViewer.ts'; import { getWeatherMapUrl } from '@lib/rainViewer.ts';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
// import { createRoot } from 'react-dom/client';
import { UnstyledButton } from '@mantine/core';
import { IconBuildingAirport, IconRadar } from '@tabler/icons-react'; import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
import { GroupControl } from '@components/GroupControl.tsx';
// Fix Leaflet's default icon path issues with Webpack // Fix Leaflet's default icon path issues with Webpack
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
@@ -43,63 +42,6 @@ const layerMap: LayerInfo[] = [
const defaultZoom = 6; const defaultZoom = 6;
const defaultCenter: L.LatLngExpression = [38.944444, -77.455833]; 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(
// <IconRadar
// style={{ width: '24px', height: '24px', color: showRadar ? 'blue' : 'black' }}
// />
// );
// // 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() { function App() {
const [airport, setAirport] = useState<Airport | null>(null); const [airport, setAirport] = useState<Airport | null>(null);
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null); const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
@@ -172,31 +114,28 @@ function App() {
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
))} ))}
</LayersControl> </LayersControl>
<ScaleControl />
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />} {rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
<ZoomControl position={'bottomright'} /> <ZoomControl position={'bottomright'} />
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} /> <AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
<BaseLayerChangeHandler /> <BaseLayerChangeHandler />
{/*<CustomControl*/} <GroupControl
{/* toggleRadar={toggleRadar}*/} buttons={[
{/* showRadar={showRadar}*/} {
{/* toggleShowNoMetar={toggleShowNoMetar}*/} title: 'Toggle radar',
{/* showNoMetar={showNoMetar}*/} active: showRadar,
{/*/>*/} onClick: toggleRadar,
icon: <IconRadar />
},
{
title: 'Toggle nonMETAR airports',
active: showNoMetar,
onClick: toggleShowNoMetar,
icon: <IconBuildingAirport />
}
]}
/>
</MapContainer> </MapContainer>
<UnstyledButton
onClick={toggleShowNoMetar}
style={{ bottom: '120px' }}
className={`map-button ${showNoMetar ? 'active' : ''}`}
>
<IconBuildingAirport />
</UnstyledButton>
<UnstyledButton
onClick={toggleRadar}
style={{ bottom: '80px' }}
className={`map-button ${showRadar ? 'active' : ''}`}
>
<IconRadar />
</UnstyledButton>
</div> </div>
</div> </div>
); );

View File

@@ -1,14 +1,14 @@
import { Header } from '@components/Header'; import { Header } from '@components/Header';
import { Navigate } from 'react-router';
import { useUserContext } from '@components/context/UserContext.tsx'; import { useUserContext } from '@components/context/UserContext.tsx';
import { AirportTable } from '@components/AirportTable'; import { AirportTable } from '@components/AirportTable';
import { AirportDrop } from '@components/AirportDrop'; import { AirportDrop } from '@components/AirportDrop';
import { NotFound } from '@components/NotFound';
export function Administration() { export function Administration() {
const { user } = useUserContext(); const { user } = useUserContext();
if (user == undefined) { if (user == undefined || user.role != 'ADMIN') {
return <Navigate to={'/'} />; return <NotFound />;
} }
return ( return (

View File

@@ -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) => (
<Table.Tr key={frequency.id}>
<Table.Td>{frequency.id}</Table.Td>
<Table.Td>{frequency.name}</Table.Td>
<Table.Td>{frequency.frequency_mhz}</Table.Td>
</Table.Tr>
));
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>MHz</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
);
}

View File

@@ -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) => (
<Table.Tr key={runway.id}>
<Table.Td>{runway.id}</Table.Td>
<Table.Td>{runway.surface}</Table.Td>
<Table.Td>{runway.length_ft}</Table.Td>
<Table.Td>{runway.width_ft}</Table.Td>
</Table.Tr>
));
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Surface</Table.Th>
<Table.Th>Length (ft)</Table.Th>
<Table.Th>Width (ft)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
);
}

View File

@@ -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 { Airport, AirportCategory } from '@lib/airport.types.ts';
import { getMarkerColor, Metar } from '@lib/metar.types.ts'; import { getMarkerColor, Metar } from '@lib/metar.types.ts';
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react'; import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
import { getMetars } from '@lib/metar.ts'; import { getMetars } from '@lib/metar.ts';
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery } from '@mantine/hooks';
import { IconViewfinder } from '@tabler/icons-react'; import { IconViewfinder } from '@tabler/icons-react';
import RunwayTable from '@components/AirportDrawer/RunwayTable.tsx';
import FrequencyTable from '@components/AirportDrawer/FrequencyTable.tsx';
export default function Index({ export default function Index({
airport, airport,
@@ -155,6 +169,28 @@ function AirportInfo({ airport }: { airport: Airport }) {
</UnstyledButton> </UnstyledButton>
</AirportInfoSlot> </AirportInfoSlot>
</AirportInfoRow> </AirportInfoRow>
<Accordion chevronPosition={'right'} variant={'contained'}>
{airport.runways != null && airport.runways.length > 0 && (
<Accordion.Item value={'runways'}>
<Accordion.Control>
Runways
</Accordion.Control>
<Accordion.Panel>
<RunwayTable runways={airport.runways} />
</Accordion.Panel>
</Accordion.Item>
)}
{airport.frequencies != null && airport.frequencies.length > 0 && (
<Accordion.Item value={'frequencies'}>
<Accordion.Control>
Frequencies
</Accordion.Control>
<Accordion.Panel>
<FrequencyTable frequencies={airport.frequencies} />
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
<Divider /> <Divider />
</div> </div>
); );

View File

@@ -20,9 +20,9 @@ export function AirportDrop() {
setLoading(true); setLoading(true);
try { try {
const formData = new FormData(); const formData = new FormData();
files.forEach(file => { files.forEach((file) => {
formData.append('files', file, file.name); formData.append('files', file, file.name);
}) });
await importAirports(formData); await importAirports(formData);
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
@@ -31,12 +31,12 @@ export function AirportDrop() {
} }
}} }}
className={classes.dropzone} className={classes.dropzone}
radius="md" radius='md'
accept={['application/JSON']} accept={['application/JSON']}
maxSize={30 * 1024 ** 2} maxSize={30 * 1024 ** 2}
> >
<div style={{ pointerEvents: 'none' }}> <div style={{ pointerEvents: 'none' }}>
<Group justify="center"> <Group justify='center'>
<Dropzone.Accept> <Dropzone.Accept>
<IconDownload size={50} color={theme.colors.blue[6]} stroke={1.5} /> <IconDownload size={50} color={theme.colors.blue[6]} stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
@@ -48,20 +48,20 @@ export function AirportDrop() {
</Dropzone.Idle> </Dropzone.Idle>
</Group> </Group>
<Text ta="center" fw={700} fz="lg" mt="xl"> <Text ta='center' fw={700} fz='lg' mt='xl'>
<Dropzone.Accept>Drop files here</Dropzone.Accept> <Dropzone.Accept>Drop files here</Dropzone.Accept>
<Dropzone.Reject>Json file less than 30mb</Dropzone.Reject> <Dropzone.Reject>Json file less than 30mb</Dropzone.Reject>
<Dropzone.Idle>Upload JSON</Dropzone.Idle> <Dropzone.Idle>Upload JSON</Dropzone.Idle>
</Text> </Text>
<Text className={classes.description}> <Text className={classes.description}>
Drag&apos;n&apos;drop files here to upload. We can accept only <i>.json</i> files that Drag&apos;n&apos;drop files here to upload. We can accept only <i>.json</i> files that are less than 30mb in
are less than 30mb in size. size.
</Text> </Text>
</div> </div>
</Dropzone> </Dropzone>
<Button className={classes.control} size="md" radius="xl" onClick={() => openRef.current?.()}> <Button className={classes.control} size='md' radius='xl' onClick={() => openRef.current?.()}>
Select files Select files
</Button> </Button>
</div> </div>

View File

@@ -13,7 +13,7 @@ export function AirportTable() {
useEffect(() => { useEffect(() => {
const limit = 1000; const limit = 1000;
getAirports({ page, limit }).then(r => { getAirports({ page, limit }).then((r) => {
setData(r.data); setData(r.data);
setTotalPages(r.total / r.data.length); setTotalPages(r.total / r.data.length);
}); });
@@ -43,14 +43,8 @@ export function AirportTable() {
<Table.Tbody>{rows}</Table.Tbody> <Table.Tbody>{rows}</Table.Tbody>
</Table> </Table>
</ScrollArea> </ScrollArea>
<Center mt="sm"> <Center mt='sm'>
<Pagination <Pagination value={page} onChange={setPage} total={totalPages} siblings={1} boundaries={1} />
value={page}
onChange={setPage}
total={totalPages}
siblings={1}
boundaries={1}
/>
</Center> </Center>
</> </>
); );

View File

@@ -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<HTMLAnchorElement | null>(null);
const reactRootRef = useRef<Root | null>(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;
}

View File

@@ -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<HTMLAnchorElement[]>([]);
const reactRootRefs = useRef<Root[]>([]);
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 clickblocking 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;
}

View File

@@ -0,0 +1,12 @@
import { ComponentPropsWithoutRef } from 'react';
export function Illustration(props: ComponentPropsWithoutRef<'svg'>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 362 145' {...props}>
<path
fill='currentColor'
d='M62.6 142c-2.133 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2L58.2 4c.8-1.333 2.067-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .667.533 1 1.267 1 2.2v21.2c0 .933-.333 1.733-1 2.4-.667.533-1.467.8-2.4.8H93v20.8c0 2.133-1.067 3.2-3.2 3.2H62.6zM33 90.4h26.4V51.2L33 90.4zM181.67 144.6c-7.333 0-14.333-1.333-21-4-6.666-2.667-12.866-6.733-18.6-12.2-5.733-5.467-10.266-13-13.6-22.6-3.333-9.6-5-20.667-5-33.2 0-12.533 1.667-23.6 5-33.2 3.334-9.6 7.867-17.133 13.6-22.6 5.734-5.467 11.934-9.533 18.6-12.2 6.667-2.8 13.667-4.2 21-4.2 7.467 0 14.534 1.4 21.2 4.2 6.667 2.667 12.8 6.733 18.4 12.2 5.734 5.467 10.267 13 13.6 22.6 3.334 9.6 5 20.667 5 33.2 0 12.533-1.666 23.6-5 33.2-3.333 9.6-7.866 17.133-13.6 22.6-5.6 5.467-11.733 9.533-18.4 12.2-6.666 2.667-13.733 4-21.2 4zm0-31c9.067 0 15.6-3.733 19.6-11.2 4.134-7.6 6.2-17.533 6.2-29.8s-2.066-22.2-6.2-29.8c-4.133-7.6-10.666-11.4-19.6-11.4-8.933 0-15.466 3.8-19.6 11.4-4 7.6-6 17.533-6 29.8s2 22.2 6 29.8c4.134 7.467 10.667 11.2 19.6 11.2zM316.116 142c-2.134 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2l56.6-84.6c.8-1.333 2.066-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .666.533 1 1.267 1 2.2v21.2c0 .933-.334 1.733-1 2.4-.667.533-1.467.8-2.4.8h-11.2v20.8c0 2.133-1.067 3.2-3.2 3.2h-27.2zm-29.6-51.6h26.4V51.2l-26.4 39.2z'
/>
</svg>
);
}

View File

@@ -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);
}

View File

@@ -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 (
<Container className={classes.root}>
<div className={classes.inner}>
<Illustration className={classes.image} />
<div className={classes.content}>
<Title className={classes.title}>Nothing to see here</Title>
<Text c='dimmed' size='lg' ta='center' className={classes.description}>
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.
</Text>
<Group justify='center'>
<Button size='md' onClick={() => navigate('/')}>
Take me back to home page
</Button>
</Group>
</div>
</div>
</Container>
);
}

View File

@@ -1,12 +1,12 @@
import { Header } from '@components/Header'; import { Header } from '@components/Header';
import { useUserContext } from '@components/context/UserContext.tsx'; import { useUserContext } from '@components/context/UserContext.tsx';
import { Navigate } from 'react-router'; import { NotFound } from '@components/NotFound';
export function Profile() { export function Profile() {
const { user } = useUserContext(); const { user } = useUserContext();
if (user == undefined) { if (user == undefined) {
return <Navigate to={'/'} />; return <NotFound />;
} }
return ( return (

View File

@@ -1,6 +1,6 @@
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { UserContext } from './UserContext.tsx'; 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 { User } from '@lib/account.types.ts';
import { Center, Loader } from '@mantine/core'; import { Center, Loader } from '@mantine/core';
@@ -9,7 +9,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
refresh().then((refreshUser) => { profile().then((refreshUser) => {
if (refreshUser) { if (refreshUser) {
setUser(refreshUser); setUser(refreshUser);
} else { } else {

View File

@@ -23,8 +23,8 @@ export async function logout() {
return await postRequest('account/logout', {}); return await postRequest('account/logout', {});
} }
export async function refresh(): Promise<User | undefined> { export async function profile(): Promise<User | undefined> {
const response = await getRequest('account/session'); const response = await getRequest('account/profile');
if (response?.status === 200) { if (response?.status === 200) {
return response.json(); return response.json();
} else { } else {

View File

@@ -50,6 +50,7 @@ export interface Runway {
export interface Frequency { export interface Frequency {
id: string; id: string;
name: string;
frequency_mhz: number; frequency_mhz: number;
} }

View File

@@ -8,6 +8,7 @@ import { UserProvider } from '@components/context/UserProvider.tsx';
import { BrowserRouter, Route, Routes } from 'react-router'; import { BrowserRouter, Route, Routes } from 'react-router';
import { Profile } from '@components/Profile.tsx'; import { Profile } from '@components/Profile.tsx';
import { Administration } from '@components/Administration.tsx'; import { Administration } from '@components/Administration.tsx';
import { NotFound } from '@components/NotFound';
const theme = createTheme({ const theme = createTheme({
fontFamily: 'Inter, sans-serif' fontFamily: 'Inter, sans-serif'
@@ -23,6 +24,7 @@ createRoot(document.getElementById('root')!).render(
<Route path='/' element={<App />} /> <Route path='/' element={<App />} />
<Route path='/profile' element={<Profile />} /> <Route path='/profile' element={<Profile />} />
<Route path='/administration' element={<Administration />} /> <Route path='/administration' element={<Administration />} />
<Route path='*' element={<NotFound />} />
</Routes> </Routes>
</UserProvider> </UserProvider>
</MantineProvider> </MantineProvider>