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_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

View File

@@ -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 #

View File

@@ -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`

View File

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

View File

@@ -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<T: UsbContext>(
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)
}

View File

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

View File

@@ -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()),
}

View File

@@ -50,15 +50,16 @@ async fn login(request: web::Json<LoginRequest>, 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<LoginRequest>, 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<LoginRequest>, 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),
);
}

View File

@@ -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<DateTime<Utc>>,
}
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<i64>) -> Self {
pub fn new(take: usize, id: &Uuid, ip_address: &str, ttl: Option<i64>) -> 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
}
}

View File

@@ -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<String>,
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<String>,
pub frequency_mhz: f32,
}
@@ -28,6 +31,8 @@ pub struct UpdateFrequency {
pub icao: Option<String>,
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
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")]
pub frequency_mhz: Option<f32>,
}
@@ -36,6 +41,7 @@ impl From<FrequencyRow> 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<Postgres> = 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);
});

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 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://{}:{}@{}:{}/{}",

View File

@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
);
}
let admin_user = User {
id: Uuid::new_v4(),
email,
email_verified: true,
password_hash,

View File

@@ -21,6 +21,7 @@ impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> {
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<String>,
pub email_verified: bool,
}
impl From<User> 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<User> {
pub async fn update(&self, id: &Uuid) -> ApiResult<User> {
let pool = db::pool();
let mut query_builder: QueryBuilder<Postgres> =
@@ -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::<User>();
@@ -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<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 user: Option<Self> = 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)

View File

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

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 {
name: Validate Session
name: Refresh Session
type: http
seq: 5
}

View File

@@ -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
}
]
},

View File

@@ -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

View File

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

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 '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(
// <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() {
const [airport, setAirport] = useState<Airport | null>(null);
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
@@ -172,31 +114,28 @@ function App() {
</LayersControl.BaseLayer>
))}
</LayersControl>
<ScaleControl />
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
<ZoomControl position={'bottomright'} />
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
<BaseLayerChangeHandler />
{/*<CustomControl*/}
{/* toggleRadar={toggleRadar}*/}
{/* showRadar={showRadar}*/}
{/* toggleShowNoMetar={toggleShowNoMetar}*/}
{/* showNoMetar={showNoMetar}*/}
{/*/>*/}
<GroupControl
buttons={[
{
title: 'Toggle radar',
active: showRadar,
onClick: toggleRadar,
icon: <IconRadar />
},
{
title: 'Toggle nonMETAR airports',
active: showNoMetar,
onClick: toggleShowNoMetar,
icon: <IconBuildingAirport />
}
]}
/>
</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>
);

View File

@@ -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 <Navigate to={'/'} />;
if (user == undefined || user.role != 'ADMIN') {
return <NotFound />;
}
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 { 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 }) {
</UnstyledButton>
</AirportInfoSlot>
</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 />
</div>
);

View File

@@ -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}
>
<div style={{ pointerEvents: 'none' }}>
<Group justify="center">
<Group justify='center'>
<Dropzone.Accept>
<IconDownload size={50} color={theme.colors.blue[6]} stroke={1.5} />
</Dropzone.Accept>
@@ -48,20 +48,20 @@ export function AirportDrop() {
</Dropzone.Idle>
</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.Reject>Json file less than 30mb</Dropzone.Reject>
<Dropzone.Idle>Upload JSON</Dropzone.Idle>
</Text>
<Text className={classes.description}>
Drag&apos;n&apos;drop files here to upload. We can accept only <i>.json</i> files that
are less than 30mb in size.
Drag&apos;n&apos;drop files here to upload. We can accept only <i>.json</i> files that are less than 30mb in
size.
</Text>
</div>
</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
</Button>
</div>

View File

@@ -13,7 +13,7 @@ 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);
});
@@ -43,14 +43,8 @@ export function AirportTable() {
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ScrollArea>
<Center mt="sm">
<Pagination
value={page}
onChange={setPage}
total={totalPages}
siblings={1}
boundaries={1}
/>
<Center mt='sm'>
<Pagination value={page} onChange={setPage} total={totalPages} siblings={1} boundaries={1} />
</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 { 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 <Navigate to={'/'} />;
return <NotFound />;
}
return (

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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(
<Route path='/' element={<App />} />
<Route path='/profile' element={<Profile />} />
<Route path='/administration' element={<Administration />} />
<Route path='*' element={<NotFound />} />
</Routes>
</UserProvider>
</MantineProvider>