From 263c33fd5acfbcf401ea2bea52bc10951bafaada Mon Sep 17 00:00:00 2001 From: Ben Sherriff Date: Mon, 2 Jun 2025 16:54:53 -0400 Subject: [PATCH] Working on fixing metars, airport layout, etc --- api/migrations/20250513_initial.sql | 6 + api/src/account/auth.rs | 3 +- api/src/account/mod.rs | 7 +- api/src/account/routes.rs | 70 +++- api/src/{users/model.rs => account/user.rs} | 0 api/src/account/user_favorites.rs | 67 +++ api/src/airports/model/airport.rs | 28 +- api/src/airports/routes.rs | 2 +- api/src/error.rs | 38 +- api/src/main.rs | 17 +- api/src/metars/mod.rs | 1 + api/src/metars/model.rs | 439 +++++++++----------- api/src/metars/utils.rs | 113 +++++ api/src/scheduler.rs | 20 +- api/src/users/mod.rs | 5 - api/src/users/routes.rs | 167 -------- ui/src/components/AirportDrawer/index.tsx | 68 +-- ui/src/components/AirportSearch.tsx | 63 +++ ui/src/components/Header/index.tsx | 24 +- ui/src/components/context/UserContext.tsx | 2 +- ui/src/components/context/UserProvider.tsx | 31 +- ui/src/lib/account.ts | 19 +- ui/src/lib/airport.ts | 4 +- ui/src/lib/index.ts | 7 +- 24 files changed, 691 insertions(+), 510 deletions(-) rename api/src/{users/model.rs => account/user.rs} (100%) create mode 100644 api/src/account/user_favorites.rs create mode 100644 api/src/metars/utils.rs delete mode 100644 api/src/users/mod.rs delete mode 100644 api/src/users/routes.rs create mode 100644 ui/src/components/AirportSearch.tsx diff --git a/api/migrations/20250513_initial.sql b/api/migrations/20250513_initial.sql index 88e7cae..ff6a682 100644 --- a/api/migrations/20250513_initial.sql +++ b/api/migrations/20250513_initial.sql @@ -76,3 +76,9 @@ CREATE TABLE IF NOT EXISTS users ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + +CREATE TABLE IF NOT EXISTS user_airport_favorites ( + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE, + PRIMARY KEY (username, icao) +); diff --git a/api/src/account/auth.rs b/api/src/account/auth.rs index 1fb23cd..39f2a3c 100644 --- a/api/src/account/auth.rs +++ b/api/src/account/auth.rs @@ -2,9 +2,10 @@ use std::future::Future; use std::pin::Pin; use super::{SESSION_COOKIE_NAME, Session}; -use crate::{error::Error, users::User}; +use crate::error::Error; use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http}; use serde::{Deserialize, Serialize}; +use crate::account::user::User; #[derive(Debug, Serialize, Deserialize)] pub struct Auth { diff --git a/api/src/account/mod.rs b/api/src/account/mod.rs index 024a6a4..0db7c1c 100644 --- a/api/src/account/mod.rs +++ b/api/src/account/mod.rs @@ -1,6 +1,6 @@ use argon2::{ - Argon2, PasswordHash, PasswordHasher, PasswordVerifier, - password_hash::{SaltString, rand_core::OsRng}, + password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHash, PasswordHasher, + PasswordVerifier, }; use rand::distr::Alphanumeric; use rand::prelude::*; @@ -11,10 +11,13 @@ mod email_token; mod model; mod routes; mod session; +mod user; +mod user_favorites; pub use auth::*; pub use routes::init_routes; pub use session::*; +pub use user::*; use crate::error::{ApiResult, Error}; diff --git a/api/src/account/routes.rs b/api/src/account/routes.rs index bbbd103..87765a8 100644 --- a/api/src/account/routes.rs +++ b/api/src/account/routes.rs @@ -1,9 +1,8 @@ use crate::{ account::{SESSION_COOKIE_NAME, Session, verify_hash}, error::Error, - users::{LoginRequest, RegisterRequest, User, UserResponse}, }; -use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web}; +use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web, delete}; use serde::Deserialize; use utoipa::ToSchema; use utoipa_actix_web::scope; @@ -11,7 +10,8 @@ use utoipa_actix_web::service_config::ServiceConfig; use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email}; use crate::account::{Auth, csprng}; -use crate::users::UpdateUser; +use crate::account::user::{LoginRequest, RegisterRequest, UpdateUser, User, UserResponse}; +use crate::account::user_favorites::UserFavorite; #[utoipa::path( tag = "account", @@ -168,7 +168,7 @@ async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse None => return HttpResponse::Unauthorized().finish(), }; - // Cannot reverify if user is already verified + // Cannot reverify if the user is already verified if user.email_verified { return HttpResponse::Conflict().finish(); } @@ -547,6 +547,63 @@ async fn confirm_password_reset( HttpResponse::Ok().finish() } +#[utoipa::path( + tag = "account", + responses( + (status = 200, description = "Successful Response"), + (status = 401, description = "Unauthorized"), + ), + security( + ("session_auth" = []) + ) +)] +#[get("/profile/favorites")] +async fn get_favorites(auth: Auth) -> HttpResponse { + let username = auth.user.username; + match UserFavorite::select_all(&username).await { + Ok(favorites) => HttpResponse::Ok().json(favorites), + Err(err) => ResponseError::error_response(&err), + } +} + +#[utoipa::path( + tag = "account", + responses( + (status = 200, description = "Successful Response"), + (status = 401, description = "Unauthorized"), + ), + security( + ("session_auth" = []) + ) +)] +#[post("/profile/favorites/{icao}")] +async fn add_favorite(icao: web::Path, auth: Auth) -> HttpResponse { + let username = auth.user.username; + match UserFavorite::insert(&username, &icao.into_inner()).await { + Ok(_) => HttpResponse::Ok().finish(), + Err(err) => ResponseError::error_response(&err), + } +} + +#[utoipa::path( + tag = "account", + responses( + (status = 200, description = "Successful Response"), + (status = 401, description = "Unauthorized"), + ), + security( + ("session_auth" = []) + ) +)] +#[delete("/profile/favorites/{icao}")] +async fn remove_favorite(icao: web::Path, auth: Auth) -> HttpResponse { + let username = auth.user.username; + match UserFavorite::delete(&username, &icao.into_inner()).await { + Ok(_) => HttpResponse::Ok().finish(), + Err(err) => ResponseError::error_response(&err), + } +} + pub fn init_routes(config: &mut ServiceConfig) { config.service( scope::scope("/account") @@ -559,6 +616,9 @@ pub fn init_routes(config: &mut ServiceConfig) { .service(session_refresh) .service(change_password) .service(reset_password) - .service(confirm_password_reset), + .service(confirm_password_reset) + .service(get_favorites) + .service(add_favorite) + .service(remove_favorite), ); } diff --git a/api/src/users/model.rs b/api/src/account/user.rs similarity index 100% rename from api/src/users/model.rs rename to api/src/account/user.rs diff --git a/api/src/account/user_favorites.rs b/api/src/account/user_favorites.rs new file mode 100644 index 0000000..9fa7fd8 --- /dev/null +++ b/api/src/account/user_favorites.rs @@ -0,0 +1,67 @@ +use serde::Deserialize; +use crate::db; +use crate::error::ApiResult; + +const TABLE_NAME: &str = "user_airport_favorites"; + +#[derive(Debug, Deserialize, sqlx::FromRow)] +pub struct UserFavorite { + pub username: String, + pub icao: String, +} + +impl UserFavorite { + pub async fn select_all(username: &str) -> ApiResult> { + let pool = db::pool(); + let user_favorites: Vec = sqlx::query_as::<_, UserFavorite>(&format!( + r#" + SELECT * FROM {} WHERE username = $1 + "#, + TABLE_NAME + )) + .bind(username) + .fetch_all(pool) + .await?; + + let favorites = user_favorites + .iter() + .map(|uf| uf.icao.clone()) + .collect(); + + Ok(favorites) + } + + pub async fn insert(username: &str, icao: &str) -> ApiResult<()> { + let pool = db::pool(); + sqlx::query(&format!( + r#" + INSERT INTO {} ( + username, icao + ) VALUES ($1, $2) + "#, + TABLE_NAME + )) + .bind(username) + .bind(icao) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn delete(username: &str, icao: &str) -> ApiResult<()> { + let pool = db::pool(); + sqlx::query(&format!( + r#" + DELETE FROM {} WHERE username = $1 AND icao = $2 + "#, + TABLE_NAME + )) + .bind(username) + .bind(icao) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index 32a21a2..2efb552 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -8,7 +8,7 @@ use crate::metars::Metar; use chrono::{DateTime, Utc}; use futures_util::try_join; use serde::{Deserialize, Serialize}; -use sqlx::{Postgres, QueryBuilder}; +use sqlx::{Execute, Postgres, QueryBuilder}; use std::collections::HashMap; use std::str::FromStr; use utoipa::{IntoParams, ToSchema}; @@ -263,7 +263,7 @@ impl Airport { "SELECT {} FROM {} WHERE icao = $1", DEFAULT_COLUMNS, TABLE_NAME )) - .bind(icao) + .bind(icao.to_uppercase()) .fetch_optional(pool) .await }; @@ -340,8 +340,16 @@ impl Airport { QueryBuilder::::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME)); let mut has_where = false; - Self::push_condition_array(&mut builder, &mut has_where, "icao", &query.icaos); - Self::push_condition_array(&mut builder, &mut has_where, "iata", &query.iatas); + let icaos = match &query.icaos { + Some(icaos) => Some(icaos.to_uppercase()), + None => None, + }; + Self::push_condition_array(&mut builder, &mut has_where, "icao", &icaos); + let iatas = match &query.iatas { + Some(iatas) => Some(iatas.to_uppercase()), + None => None, + }; + Self::push_condition_array(&mut builder, &mut has_where, "iata", &iatas); Self::push_condition_array( &mut builder, &mut has_where, @@ -360,7 +368,11 @@ impl Airport { "municipality", &query.municipalities, ); - Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals); + let locals = match &query.locals { + Some(locals) => Some(locals.to_uppercase()), + None => None, + }; + Self::push_condition_array(&mut builder, &mut has_where, "local", &locals); Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories); Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name); Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?; @@ -395,7 +407,7 @@ impl Airport { return Ok(airports); } - // Bulk update airport sub-fields + // Bulk update airport subfields let icaos: Vec = airports.iter().map(|a| a.icao.to_uppercase()).collect(); let runway_future = Runway::select_all_map(&icaos); @@ -550,9 +562,9 @@ impl Airport { for chunk in airport_rows.chunks(chunk_size) { let mut query_builder: QueryBuilder = QueryBuilder::new( - "INSERT INTO airports (icao, iata, local, name, category, \ + format!("INSERT INTO {} (icao, iata, local, name, category, \ iso_country, iso_region, municipality, elevation_ft, \ - longitude, latitude, geometry, has_tower, has_beacon, public) ", + longitude, latitude, geometry, has_tower, has_beacon, public) ", TABLE_NAME), ); query_builder.push_values(chunk, |mut b, row| { b.push_bind(&row.icao) diff --git a/api/src/airports/routes.rs b/api/src/airports/routes.rs index be5f1a8..42dbb69 100644 --- a/api/src/airports/routes.rs +++ b/api/src/airports/routes.rs @@ -1,7 +1,6 @@ use futures_util::stream::StreamExt as _; use crate::airports::{AirportQuery, UpdateAirport}; -use crate::users::ADMIN_ROLE; use crate::{ account::{Auth, verify_role}, airports::Airport, @@ -12,6 +11,7 @@ use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put use utoipa::ToSchema; use utoipa_actix_web::scope; use utoipa_actix_web::service_config::ServiceConfig; +use crate::account::ADMIN_ROLE; #[derive(ToSchema)] #[allow(unused)] diff --git a/api/src/error.rs b/api/src/error.rs index ac19e10..70b925f 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -64,25 +64,25 @@ impl ResponseError for Error { impl From for Error { fn from(error: std::io::Error) -> Self { - Self::new(500, format!("Unknown IO error: {}", error)) + Self::new(500, format!("Unknown IO error: {:?}", error)) } } impl From for Error { fn from(error: chrono::ParseError) -> Self { - Self::new(500, format!("Parse error: {}", error)) + Self::new(500, format!("Chrono parse error: {:?}", error)) } } impl From for Error { fn from(error: core::num::ParseIntError) -> Self { - Self::new(500, format!("Parse error: {}", error)) + Self::new(500, format!("Integer parse error: {:?}", error)) } } impl From for Error { fn from(error: core::num::ParseFloatError) -> Self { - Self::new(500, format!("Parse error: {}", error)) + Self::new(500, format!("Float parse error: {:?}", error)) } } @@ -90,7 +90,7 @@ impl From for Error { fn from(error: std::env::VarError) -> Self { Self::new( 500, - format!("Unknown environment variable error: {}", error), + format!("Unknown environment variable error: {:?}", error), ) } } @@ -100,9 +100,9 @@ impl From for Error { match error.status() { Some(status_code) => { if status_code.is_client_error() { - Self::new(500, format!("Client reqwest error: {}", error)) + Self::new(500, format!("Client reqwest error: {:?}", error)) } else if status_code.is_server_error() { - Self::new(500, format!("Server reqwest error: {}", error)) + Self::new(500, format!("Server reqwest error: {:?}", error)) } else { Self::new(500, format!("Unknown reqwest error: {:?}", error)) } @@ -114,19 +114,19 @@ impl From for Error { impl From for Error { fn from(error: serde_json::Error) -> Self { - Self::new(500, format!("Unknown serde_json error: {}", error)) + Self::new(500, format!("Unknown serde_json error: {:?}", error)) } } impl From for Error { fn from(error: argon2::password_hash::Error) -> Self { - Self::new(500, format!("Unknown argon2 error: {}", error)) + Self::new(500, format!("Unknown argon2 error: {:?}", error)) } } impl From for Error { fn from(error: redis::RedisError) -> Self { - Self::new(500, format!("Unknown redis error: {}", error)) + Self::new(500, format!("Unknown redis error: {:?}", error)) } } @@ -134,18 +134,18 @@ impl From for Error { fn from(error: s3::error::S3Error) -> Self { match error { s3::error::S3Error::Credentials(err) => { - Self::new(500, format!("Unknown s3 credentials error: {}", err)) + Self::new(500, format!("Unknown s3 credentials error: {:?}", err)) } s3::error::S3Error::FromUtf8(err) => { - Self::new(500, format!("Unknown s3 from utf8 error: {}", err)) + Self::new(500, format!("Unknown s3 from utf8 error: {:?}", err)) } - s3::error::S3Error::FmtError(err) => Self::new(500, format!("Unknown s3 fmt error: {}", err)), + s3::error::S3Error::FmtError(err) => Self::new(500, format!("Unknown s3 fmt error: {:?}", err)), s3::error::S3Error::HeaderToStr(err) => { - Self::new(500, format!("Unknown s3 header to str error: {}", err)) + Self::new(500, format!("Unknown s3 header to str error: {:?}", err)) } s3::error::S3Error::HmacInvalidLength(err) => Self::new( 500, - format!("Unknown s3 hmac invalid length error: {}", err), + format!("Unknown s3 hmac invalid length error: {:?}", err), ), s3::error::S3Error::Http(error) => Self::new(error.status_code().as_u16(), error.to_string()), _ => { @@ -158,7 +158,7 @@ impl From for Error { } } } - Self::new(500, format!("Unknown s3 error: {}", error)) + Self::new(500, format!("Unknown s3 error: {:?}", error)) } } } @@ -228,3 +228,9 @@ impl From for Error { Self::new(500, error) } } + +impl From for Error { + fn from(error: regex::Error) -> Self { + Self::new(500, error.to_string()) + } +} diff --git a/api/src/main.rs b/api/src/main.rs index b03a86a..cdf159b 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,6 +1,5 @@ -use crate::account::hash; +use crate::account::{hash, ADMIN_ROLE}; use crate::http_client::HttpClient; -use crate::users::{ADMIN_ROLE, User}; use actix_cors::Cors; use actix_web::{App, HttpServer, middleware::Logger, web}; use dotenv::from_filename; @@ -10,6 +9,7 @@ use utoipa::openapi::SecurityRequirement; use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; use utoipa_actix_web::{AppExt, scope}; use utoipa_swagger_ui::{Config, SwaggerUi}; +use crate::account::User; mod account; mod airports; @@ -20,7 +20,6 @@ mod metars; mod scheduler; mod smtp; mod system; -mod users; #[derive(Debug, Clone)] struct AppState { @@ -31,15 +30,7 @@ struct AppState { async fn main() -> Result<(), Box> { initialize_environment()?; db::initialize().await?; - - let client = Arc::new(HttpClient::default()?); - - let scheduler_client = client.clone(); - let interval = env::var("METAR_INTERVAL") - .unwrap_or("300".to_string()) - .parse::() - .unwrap_or(300); - scheduler::update_metars(scheduler_client, interval); + scheduler::run(); // Initialize admin user let admin_username = env::var("ADMIN_USERNAME"); @@ -78,6 +69,7 @@ async fn main() -> Result<(), Box> { } } + let client = Arc::new(HttpClient::default()?); let state = AppState { client }; let host = "0.0.0.0"; let port = env::var("API_PORT").unwrap_or("5000".to_string()); @@ -99,7 +91,6 @@ async fn main() -> Result<(), Box> { .configure(airports::init_routes) .configure(metars::init_routes) .configure(account::init_routes) - .configure(users::init_routes) .configure(system::init_routes), ) .split_for_parts(); diff --git a/api/src/metars/mod.rs b/api/src/metars/mod.rs index 41f14b8..810a57a 100644 --- a/api/src/metars/mod.rs +++ b/api/src/metars/mod.rs @@ -1,6 +1,7 @@ mod metar_check; mod model; mod routes; +mod utils; pub use metar_check::*; pub use model::*; diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index 691ad55..9ae4240 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -2,8 +2,9 @@ use crate::airports::{Airport, UpdateAirport}; use crate::error::Error; use crate::http_client::HttpClient; use crate::metars::MetarCheck; +use crate::metars::utils::parse_metar_time; use crate::{db, error::ApiResult}; -use chrono::{DateTime, Datelike, NaiveDate, Utc}; +use chrono::{DateTime, Utc}; use flate2::read::GzDecoder; use reqwest::header::ETAG; use serde::{Deserialize, Serialize}; @@ -13,6 +14,7 @@ use std::fmt::Display; use std::io::{Cursor, Read}; use std::str::FromStr; use std::sync::OnceLock; +use sqlx::{Postgres, QueryBuilder}; use utoipa::ToSchema; static TIME_OFFSET: OnceLock = OnceLock::new(); @@ -302,6 +304,39 @@ impl MetarRow { Ok(()) } + + async fn insert_all(metars: Vec) -> ApiResult<()> { + let pool = db::pool(); + let chunk_size = 1000; + + for chunk in metars.chunks(chunk_size) { + let mut query_builder: QueryBuilder = QueryBuilder::new( + format!("INSERT INTO {} (icao, observation_time, raw_text, data) ", TABLE_NAME)); + query_builder.push_values(chunk, |mut b, metar | { + let row: Self = match metar.to_row() { + Ok(row) => row, + Err(e) => { + log::warn!("Failed to serialize METAR data: {}", e); + return; + } + }; + b.push_bind(row.icao) + .push_bind(row.observation_time) + .push_bind(row.raw_text) + .push_bind(row.data); + }); + query_builder.push( + " ON CONFLICT (icao, observation_time) DO UPDATE SET \ + raw_text = EXCLUDED.raw_text, \ + data = EXCLUDED.data", + ); + + let query = query_builder.build(); + query.execute(pool).await?; + } + + Ok(()) + } } impl Metar { @@ -342,7 +377,7 @@ impl Metar { )); } - // Remove METAR at start of text + // Remove METAR at the start of the text if metar_parts[0].to_string() == "METAR".to_string() { metar_parts.remove(0); } @@ -354,10 +389,18 @@ impl Metar { // Date/Time let observation_time = metar_parts[0]; metar_parts.remove(0); - let observation_time = Self::parse_time(observation_time)?; - metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) { - Ok(datetime) => datetime.with_timezone(&Utc), - Err(err) => return Err(err.into()), + match parse_metar_time(observation_time) { + Ok(observation_time) => { + metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) { + Ok(datetime) => datetime.with_timezone(&Utc), + Err(err) => return Err(err.into()), + }; + }, + Err(err) => { + return Err(Error::new( + err.status, + format!("Unexpected observation time field '{}': {}; {}", observation_time, metar_string, err))); + } }; loop { @@ -375,9 +418,8 @@ impl Metar { } // Wind Direction and Speed - let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}(?:KT|MPS)$").unwrap(); - let wind_gust_re = - regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}(?:KT|MPS)$").unwrap(); + let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}(?:KT|MPS)$")?; + let wind_gust_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}(?:KT|MPS)$")?; // Handle input error where there is a space between the numbers and units let mut value: Option = None; if metar_parts.len() >= 2 @@ -411,9 +453,9 @@ impl Metar { let mut wind_speed_kt = wind[3..5].to_string(); // Convert m/s to kt if wind.len() == 8 { - wind_speed_kt = (wind_speed_kt.parse::().unwrap() * 1.94384).to_string(); + wind_speed_kt = (wind_speed_kt.parse::()? * 1.94384).to_string(); } - metar.wind_speed_kt = Some(wind_speed_kt.parse::().unwrap()); + metar.wind_speed_kt = Some(wind_speed_kt.parse::()?); } else if wind_gust_re.is_match(&wind) { let wind_dir_degrees = &wind[0..3]; metar.wind_dir_degrees = Some(wind_dir_degrees.to_string()); @@ -421,26 +463,26 @@ impl Metar { let mut wind_gust_kt = wind[6..8].to_string(); // Convert m/s to kt if wind.len() == 9 { - wind_speed_kt = (wind_speed_kt.parse::().unwrap() * 1.94384).to_string(); - wind_gust_kt = (wind_gust_kt.parse::().unwrap() * 1.94384).to_string(); + wind_speed_kt = (wind_speed_kt.parse::()? * 1.94384).to_string(); + wind_gust_kt = (wind_gust_kt.parse::()? * 1.94384).to_string(); } - metar.wind_speed_kt = Some(wind_speed_kt.parse::().unwrap()); - metar.wind_gust_kt = Some(wind_gust_kt.parse::().unwrap()); + metar.wind_speed_kt = Some(wind_speed_kt.parse::()?); + metar.wind_gust_kt = Some(wind_gust_kt.parse::()?); } } None => {} } // Variable Wind Direction - let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$").unwrap(); + let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$")?; if !metar_parts.is_empty() && variable_wind_re.is_match(metar_parts[0]) { metar.variable_wind_dir_degrees = Some(metar_parts[0].to_string()); metar_parts.remove(0); } // Visibility - let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM$").unwrap(); - let visibility_re_m = regex::Regex::new(r"^[0-9]{4}(:?N|NE|NW|S|SE|SW)?$").unwrap(); + let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM$")?; + let visibility_re_m = regex::Regex::new(r"^[0-9]{4}(:?N|NE|NW|S|SE|SW)?$")?; if !metar_parts.is_empty() && visibility_re.is_match(metar_parts[0]) { let visibility_str = &metar_parts[0][0..metar_parts[0].len() - 2]; metar_parts.remove(0); @@ -474,59 +516,68 @@ impl Metar { metar_parts.remove(0); let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect(); metar_parts.remove(0); - let visibility_left = visibility_parts[0]; - // Parse the right-hand of visibility, with or without an SM suffix - let visibility_right_string = match visibility_parts[1].strip_suffix("SM") { - Some(s) => s, - None => { - if visibility_parts[1].chars().all(|c| c.is_numeric() || c == '.') { - visibility_parts[1] - } else { - log::warn!( - "Skipping invalid visibility field '{}' ({})", - metar_parts[0], - metar_string - ); - continue; + if visibility_parts.len() == 1 { + metar.visibility_statute_mi = Some(visibility_parts[0].to_string()); + } else if visibility_parts.len() == 2 { + let visibility_left = visibility_parts[0]; + // Parse the right-hand of visibility, with or without an SM suffix + let visibility_right_string = match visibility_parts[1].strip_suffix("SM") { + Some(s) => s, + None => { + if visibility_parts[1] + .chars() + .all(|c| c.is_numeric() || c == '.') + { + visibility_parts[1] + } else { + log::warn!( + "Skipping unexpected visibility field '{:?}' ({})", + visibility_parts, + metar_string + ); + continue; + } } + }; + let visibility_right = visibility_right_string.parse::()?; + let visibility = if visibility_left.starts_with("M") { + format!( + "M{}", + visibility_whole + + (visibility_left[1..visibility_left.len()].parse::()? / visibility_right) + ) + } else if visibility_left.starts_with("P") { + format!( + "P{}", + visibility_whole + + (visibility_left[1..visibility_left.len()].parse::()? / visibility_right) + ) + } else { + format!( + "{}", + visibility_whole + (visibility_left.parse::()? / visibility_right) + ) + }; + metar.visibility_statute_mi = Some(visibility); + } else if !metar_parts.is_empty() && visibility_re_m.is_match(metar_parts[0]) { + // Convert meters to statute miles + let visibility = metar_parts[0]; + metar_parts.remove(0); + if &visibility[0..4] == "9999" { + metar.visibility_statute_mi = Some("P10".to_string()); + } else { + let visibility = visibility[0..4].parse::()? * 0.000621371; + metar.visibility_statute_mi = Some(format!("{:.2}", visibility)); } - }; - let visibility_right = visibility_right_string.parse::()?; - let visibility = if visibility_left.starts_with("M") { - format!( - "M{}", - visibility_whole - + (visibility_left[1..visibility_left.len()].parse::()? / visibility_right) - ) - } else if visibility_left.starts_with("P") { - format!( - "P{}", - visibility_whole - + (visibility_left[1..visibility_left.len()].parse::()? / visibility_right) - ) } else { - format!( - "{}", - visibility_whole + (visibility_left.parse::()? / visibility_right) - ) - }; - metar.visibility_statute_mi = Some(visibility); - } else if !metar_parts.is_empty() && visibility_re_m.is_match(metar_parts[0]) { - // Convert meters to statute miles - let visibility = metar_parts[0]; - metar_parts.remove(0); - if &visibility[0..4] == "9999" { - metar.visibility_statute_mi = Some("P10".to_string()); - } else { - let visibility = visibility[0..4].parse::()? * 0.000621371; - metar.visibility_statute_mi = Some(format!("{:.2}", visibility)); + log::warn!("Skipping unexpected visibility field '{}' ({})", metar_parts[0], metar_string); } } // Runway Visual Range - let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$").unwrap(); + let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$")?; let variable_rvr_re = - regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$").unwrap(); + regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$")?; while !metar_parts.is_empty() && (rvr_re.is_match(metar_parts[0]) || variable_rvr_re.is_match(metar_parts[0])) { @@ -567,63 +618,10 @@ impl Metar { metar_parts.remove(0); } - // Sky Condition - if !metar_parts.is_empty() && metar_parts[0] == "CAVOK" { - metar.sky_condition.push(SkyCondition { - sky_cover: "CLR".to_string(), - cloud_base_ft_agl: None, - significant_convective_clouds: None, - }); - metar_parts.remove(0); - } - let sky_condition_re = - regex::Regex::new(r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$") - .unwrap(); - while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) { - let mut sky_condition_string = metar_parts[0]; - metar_parts.remove(0); - - if sky_condition_string.ends_with("///") { - sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3]; - } - - let mut sky_condition = SkyCondition::default(); - let mut vv_offset = 0; - if &sky_condition_string[0..2] == "VV" { - sky_condition.sky_cover = "VV".to_string(); - vv_offset = 1; - } else { - sky_condition.sky_cover = sky_condition_string[0..3].to_string(); - } - if sky_condition_string.len() > 3 - vv_offset { - // Parse out the next three digits - let cloud_base_ft_agl = &sky_condition_string[3 - vv_offset..6 - vv_offset]; - if cloud_base_ft_agl == "///" { - sky_condition.cloud_base_ft_agl = None; - } else { - sky_condition.cloud_base_ft_agl = match cloud_base_ft_agl.parse::() { - Ok(c) => Some(c * 100), - Err(err) => { - log::warn!( - "Unable to parse cloud base in {}: {}", - sky_condition_string, - err - ); - None - } - }; - } - if sky_condition_string.len() > 6 - vv_offset { - // Parse out the next two digits - let scc = &sky_condition_string[6 - vv_offset..8 - vv_offset]; - sky_condition.significant_convective_clouds = Some(scc.to_string()); - } - } - metar.sky_condition.push(sky_condition); - } + metar.parse_sky_condition(&mut metar_parts); // Temperature and Dewpoint - let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$").unwrap(); + let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$")?; if !metar_parts.is_empty() && temp_re.is_match(metar_parts[0]) { let temp_string = metar_parts[0]; metar_parts.remove(0); @@ -665,7 +663,7 @@ impl Metar { } // Altimeter - let altim_re = regex::Regex::new(r"^A[0-9]{4}$").unwrap(); + let altim_re = regex::Regex::new(r"^A[0-9]{4}$")?; if !metar_parts.is_empty() && altim_re.is_match(metar_parts[0]) { let altim = metar_parts[0]; metar_parts.remove(0); @@ -673,7 +671,7 @@ impl Metar { } // Pressure - let pressure_re = regex::Regex::new(r"^Q[0-9]{4}$").unwrap(); + let pressure_re = regex::Regex::new(r"^Q[0-9]{4}$")?; if !metar_parts.is_empty() && pressure_re.is_match(metar_parts[0]) { let pressure = metar_parts[0]; metar_parts.remove(0); @@ -705,8 +703,8 @@ impl Metar { if metar_parts.is_empty() { break; } - let slp_re = regex::Regex::new(r"^SLP([0-9]{3})$").unwrap(); - let hourly_temp_re = regex::Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$").unwrap(); + let slp_re = regex::Regex::new(r"^SLP([0-9]{3})$")?; + let hourly_temp_re = regex::Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$")?; let remark = metar_parts[0]; metar_parts.remove(0); if remark == "AO1" || remark == "AO2" { @@ -801,7 +799,7 @@ impl Metar { // Skip unexpected fields if !metar_parts.is_empty() { - log::warn!( + log::trace!( "Skipping unexpected field: '{}' ({})", metar_parts[0], metar_string @@ -909,76 +907,68 @@ impl Metar { Ok(metar) } - fn parse_time(observation_time: &str) -> ApiResult { - if observation_time.len() != 7 { - return Err(Error::new( - 500, - format!("Unable to parse observation time in {}", observation_time), - )); + fn parse_sky_condition(&mut self, metar_parts: &mut Vec<&str>) { + // Check if sky condition is CAVOK + if !metar_parts.is_empty() && metar_parts[0] == "CAVOK" { + self.sky_condition.push(SkyCondition { + sky_cover: "CLR".to_string(), + cloud_base_ft_agl: None, + significant_convective_clouds: None, + }); + metar_parts.remove(0); } - let observation_day = match observation_time[0..2].parse::() { - Ok(day) => day, - Err(err) => return Err(err.into()), - }; - let observation_hour = match observation_time[2..4].parse::() { - Ok(hour) => hour, - Err(err) => return Err(err.into()), - }; - let observation_minute = match observation_time[4..6].parse::() { - Ok(minute) => minute, - Err(err) => return Err(err.into()), - }; - let current_time = Utc::now().naive_utc(); - let current_year = current_time.year(); - let current_month = current_time.month(); - let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day) - .ok_or_else(|| { - Error::new( - 500, - format!( - "Invalid date with day {} for current month", - observation_day - ), - ) - })?; - let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) { - Some(date) => date, - None => { - return Err(Error::new( - 500, - format!( - "Invalid time for time '{}': hour {}, minute {}", - observation_time, observation_hour, observation_minute - ), - )); + + let sky_condition_re = regex::Regex::new( + r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$", + ) + .unwrap(); + + while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) { + // Get the next METAR part + let mut sky_condition_string = metar_parts[0]; + metar_parts.remove(0); + + // Remove trailing slashes + if sky_condition_string.ends_with("///") { + sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3]; } - }; - let obs_datetime = if candidate_date > current_time { - // Subtract one month. (Handle year rollover carefully.) - let (month, year) = if current_month == 1 { - (12, current_year - 1) + let mut sky_condition = SkyCondition::default(); + // Handle sky cover and optionally vertical visibility + let mut vv_offset = 0; + if &sky_condition_string[0..2] == "VV" { + sky_condition.sky_cover = "VV".to_string(); + vv_offset = 1; } else { - (current_month - 1, current_year) - }; + sky_condition.sky_cover = sky_condition_string[0..3].to_string(); + } + if sky_condition_string.len() > 3 - vv_offset { + if sky_condition_string.len() < 6 - vv_offset { + // Parse out the significant convective clouds + let scc = &sky_condition_string[3 - vv_offset..]; + sky_condition.significant_convective_clouds = Some(scc.to_string()); + } else { + // Parse out the next three digits + let cloud_base_ft_agl = &sky_condition_string[3 - vv_offset..6 - vv_offset]; + sky_condition.cloud_base_ft_agl = match cloud_base_ft_agl.parse::() { + Ok(c) => Some(c * 100), + Err(err) => { + log::warn!( + "Unable to parse cloud base in {}: {}", + sky_condition_string, + err + ); + None + } + }; - let adjusted_date = - NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| { - Error::new( - 500, - format!( - "Invalid date with day {} for month {}", - observation_day, month - ), - ) - })?; - adjusted_date - .and_hms_opt(observation_hour, observation_minute, 0) - .unwrap() - } else { - candidate_date - }; - Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string()) + // Parse out the significant convective clouds + let scc = &sky_condition_string[6 - vv_offset..]; + sky_condition.significant_convective_clouds = Some(scc.to_string()); + } + } + self.sky_condition.push(sky_condition); + } } pub async fn get_cached_remote_metars( @@ -1004,7 +994,7 @@ impl Metar { let mut output: Vec = Vec::new(); for line in text.lines() { - // Split off first column + // Split off the first column let raw_text = line.splitn(2, ',').next().unwrap(); match Metar::parse(raw_text) { Ok(m) => output.push(m), @@ -1017,7 +1007,7 @@ impl Metar { match new_etag { Some(etag) => Ok((output, etag)), None => match etag { - Some(etag) => Ok((output, etag)), + Some(etag) => Ok((output, etag.to_string())), None => Ok((output, String::new())), }, } @@ -1212,9 +1202,7 @@ impl Metar { log::warn!("Unable to get cached remote METAR data; {}", err); (vec![], String::new()) }); - for remote_metar in remote_metars.clone() { - remote_metar.insert().await?; - } + MetarRow::insert_all(remote_metars).await?; Ok(etag) } @@ -1234,51 +1222,20 @@ impl Metar { #[cfg(test)] mod tests { use super::*; - use chrono::NaiveDateTime; - - #[test] - fn test_parse_time() { - for day in 1..=31 { - for hour in 0..24 { - for minute in 0..60 { - // METAR form "DDHHMMZ" - let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute); - let result = Metar::parse_time(&obs_time); - match result { - Ok(datetime_str) => { - // "YYYY-MM-DDTHH:MM:00Z" - assert_eq!( - datetime_str.len(), - 20, - "Unexpected length for input {} yielded {}", - obs_time, - datetime_str - ); - // Remove the trailing 'Z' and parse - let trimmed = &datetime_str[..19]; - NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| { - panic!( - "Parsing '{}' from input {} failed: {}", - trimmed, obs_time, e - ) - }); - } - Err(_err) => {} - } - } - } - } - } #[tokio::test] - async fn test_metar() { - let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990 -RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR -SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string(); + async fn test_metar_parse() { + let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT \ + -RA BR BKN015 OVC025 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 \ + RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 \ + TSNO $" + .to_string(); let metar = Metar::parse(&metar_string).unwrap(); dbg!(&metar.observation_time); - metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string(); + metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 \ + SLP126 T02500217 $" + .to_string(); let metar = Metar::parse(&metar_string).unwrap(); dbg!(&metar.observation_time); @@ -1288,12 +1245,24 @@ SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string(); let metar = Metar::parse(&metar_string).unwrap(); dbg!(&metar.observation_time); - metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string(); + metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 \ + 10133 20078 53002 PNO $" + .to_string(); let metar = Metar::parse(&metar_string).unwrap(); dbg!(&metar.observation_time); - metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 SLP090 P0001 60004 T00001017 10000 21011 53026".to_string(); + metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 \ + SLP090 P0001 60004 T00001017 10000 21011 53026" + .to_string(); let metar = Metar::parse(&metar_string).unwrap(); dbg!(&metar.observation_time); + + metar_string = "KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 \ + SCTCB FEW123TCU 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 \ + RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $" + .to_string(); + let metar = Metar::parse(&metar_string).unwrap(); + dbg!(&metar.observation_time); + dbg!(&metar.sky_condition); } } diff --git a/api/src/metars/utils.rs b/api/src/metars/utils.rs new file mode 100644 index 0000000..edd50f0 --- /dev/null +++ b/api/src/metars/utils.rs @@ -0,0 +1,113 @@ +use crate::error::{ApiResult, Error}; +use chrono::{Datelike, NaiveDate, Utc}; + +pub fn parse_metar_time(observation_time: &str) -> ApiResult { + if observation_time.len() != 7 { + return Err(Error::new( + 500, + format!("Unable to parse observation time in {}", observation_time), + )); + } + let observation_day = match observation_time[0..2].parse::() { + Ok(day) => day, + Err(err) => return Err(err.into()), + }; + let observation_hour = match observation_time[2..4].parse::() { + Ok(hour) => hour, + Err(err) => return Err(err.into()), + }; + let observation_minute = match observation_time[4..6].parse::() { + Ok(minute) => minute, + Err(err) => return Err(err.into()), + }; + let current_time = Utc::now().naive_utc(); + let current_year = current_time.year(); + let current_month = current_time.month(); + let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day) + .ok_or_else(|| { + Error::new( + 500, + format!( + "Invalid date with day {} for current month", + observation_day + ), + ) + })?; + let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) { + Some(date) => date, + None => { + return Err(Error::new( + 500, + format!( + "Invalid time for time '{}': hour {}, minute {}", + observation_time, observation_hour, observation_minute + ), + )); + } + }; + + let obs_datetime = if candidate_date > current_time { + // Subtract one month. (Handle year rollover carefully.) + let (month, year) = if current_month == 1 { + (12, current_year - 1) + } else { + (current_month - 1, current_year) + }; + + let adjusted_date = NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| { + Error::new( + 500, + format!( + "Invalid date with day {} for month {}", + observation_day, month + ), + ) + })?; + adjusted_date + .and_hms_opt(observation_hour, observation_minute, 0) + .unwrap() + } else { + candidate_date + }; + Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDateTime; + + #[test] + fn test_parse_metar_time() { + for day in 1..=31 { + for hour in 0..24 { + for minute in 0..60 { + // METAR form "DDHHMMZ" + let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute); + let result = parse_metar_time(&obs_time); + match result { + Ok(datetime_str) => { + // "YYYY-MM-DDTHH:MM:00Z" + assert_eq!( + datetime_str.len(), + 20, + "Unexpected length for input {} yielded {}", + obs_time, + datetime_str + ); + // Remove the trailing 'Z' and parse + let trimmed = &datetime_str[..19]; + NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| { + panic!( + "Parsing '{}' from input {} failed: {}", + trimmed, obs_time, e + ) + }); + } + Err(_err) => {} + } + } + } + } + } +} diff --git a/api/src/scheduler.rs b/api/src/scheduler.rs index 433a73b..0d20692 100644 --- a/api/src/scheduler.rs +++ b/api/src/scheduler.rs @@ -1,13 +1,25 @@ +use std::env; use crate::http_client::HttpClient; use crate::metars::Metar; use chrono::{DateTime, Utc}; -use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::time::interval; -pub fn update_metars(client: Arc, seconds: u64) { - tokio::spawn(async move { - // Create interval ticker +pub fn run() { + tokio::spawn(async { + let client = match HttpClient::default() { + Ok(client) => client, + Err(err) => { + log::error!("Failed to create HTTP client: {}", err); + return; + } + }; + let seconds = env::var("METAR_INTERVAL") + .unwrap_or("300".to_string()) + .parse::() + .unwrap_or(300); + + // Create an interval ticker let mut interval = interval(Duration::from_secs(seconds)); let mut etag = None; diff --git a/api/src/users/mod.rs b/api/src/users/mod.rs deleted file mode 100644 index 6fbb137..0000000 --- a/api/src/users/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod model; -mod routes; - -pub use model::*; -pub use routes::init_routes; diff --git a/api/src/users/routes.rs b/api/src/users/routes.rs deleted file mode 100644 index 9700f7c..0000000 --- a/api/src/users/routes.rs +++ /dev/null @@ -1,167 +0,0 @@ -// use actix_multipart::Multipart; -// use actix_web::{get, post, delete, web, HttpResponse, ResponseError}; -// use futures_util::StreamExt; - -// use crate::{ -// auth::Auth, -// db::{delete_file, get_file, upload_file}, -// error::ServiceError, -// users::User, -// }; - -// #[get("/favorites")] -// async fn get_favorites(auth: Auth) -> HttpResponse { -// match User::get_by_email(&auth.user.email) { -// Ok(user) => return HttpResponse::Ok().json(user.favorites), -// Err(err) => return ResponseError::error_response(&err), -// } -// } - -// #[post("/favorites/{icao}")] -// async fn add_favorite(icao: web::Path, auth: Auth) -> HttpResponse { -// match User::get_by_email(&auth.user.email) { -// Ok(user) => { -// if user.favorites.contains(&icao) { -// // Check if the airport ICAO is already in the user's favorites -// return HttpResponse::Conflict().finish(); -// } else { -// // Add the airport ICAO to the user's favorites -// let mut favorites = user.favorites; -// favorites.push(icao.into_inner()); -// match User::update_favorites(&user.email, favorites) { -// Ok(_) => return HttpResponse::Ok().finish(), -// Err(err) => return ResponseError::error_response(&err), -// } -// } -// } -// Err(err) => return ResponseError::error_response(&err), -// } -// } - -// #[delete("/favorites/{icao}")] -// async fn delete_favorite(icao: web::Path, auth: Auth) -> HttpResponse { -// let icao: String = icao.into_inner(); -// match User::get_by_email(&auth.user.email) { -// Ok(user) => { -// if user.favorites.contains(&icao) { -// // Check if the airport ICAO is already in the user's favorites -// let mut favorites = user.favorites; -// favorites.retain(|x| x != &icao); -// match User::update_favorites(&user.email, favorites) { -// Ok(_) => return HttpResponse::Ok().finish(), -// Err(err) => return ResponseError::error_response(&err), -// } -// } else { -// // Remove the airport ICAO from the user's favorites -// return HttpResponse::Conflict().finish(); -// } -// } -// Err(err) => return ResponseError::error_response(&err), -// } -// } - -// #[post("/picture")] -// async fn set_picture(mut payload: Multipart, auth: Auth) -> HttpResponse { -// while let Some(item) = payload.next().await { -// let mut bytes = web::BytesMut::new(); -// let mut field = match item { -// Ok(field) => field, -// Err(err) => return ResponseError::error_response(&err), -// }; -// let content_type = field.content_disposition(); -// let filename = match content_type.unwrap().get_filename() { -// Some(name) => match name.split(".").last() { -// Some(ext) => match ext { -// "apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg" -// | "webp" => name, -// _ => { -// return ResponseError::error_response(&ServiceError::new( -// 400, -// "File extension is not supported".to_string(), -// )) -// } -// }, -// None => { -// return ResponseError::error_response(&ServiceError::new( -// 400, -// "Unknown file extension".to_string(), -// )) -// } -// }, -// None => { -// return ResponseError::error_response(&ServiceError::new( -// 400, -// "File name is not provided".to_string(), -// )) -// } -// }; -// let path = format!("users/{}/{}", auth.user.email, filename); - -// while let Some(chunk) = field.next().await { -// let data = match chunk { -// Ok(data) => data, -// Err(err) => return ResponseError::error_response(&err), -// }; -// bytes.extend_from_slice(&data); -// } -// match upload_file(&path, &bytes).await { -// Ok(_) => { -// match User::update_profile_picture(&auth.user.email, Some(&path)) { -// Ok(_) => {} -// Err(err) => return ResponseError::error_response(&err), -// }; -// } -// Err(err) => return ResponseError::error_response(&err), -// }; -// } -// HttpResponse::Ok().finish() -// } - -// #[get("/picture")] -// async fn get_picture(auth: Auth) -> HttpResponse { -// let user = match User::get_by_email(&auth.user.email) { -// Ok(user) => user, -// Err(err) => return ResponseError::error_response(&err), -// }; -// if let Some(path) = user.profile_picture { -// match get_file(&path).await { -// Ok(bytes) => HttpResponse::Ok().body(bytes), -// Err(err) => ResponseError::error_response(&err), -// } -// } else { -// HttpResponse::NotFound().finish() -// } -// } - -// #[delete("/picture")] -// async fn delete_picture(auth: Auth) -> HttpResponse { -// let user = match User::get_by_email(&auth.user.email) { -// Ok(user) => user, -// Err(err) => return ResponseError::error_response(&err), -// }; -// if let Some(path) = user.profile_picture { -// match delete_file(&path).await { -// Ok(_) => match User::update_profile_picture(&auth.user.email, None) { -// Ok(_) => HttpResponse::Ok().finish(), -// Err(err) => ResponseError::error_response(&err), -// }, -// Err(err) => ResponseError::error_response(&err), -// } -// } else { -// HttpResponse::NotFound().finish() -// } -// } - -use utoipa_actix_web::service_config::ServiceConfig; - -pub fn init_routes(_config: &mut ServiceConfig) { - // config.service( - // web::scope("users") - // .service(get_favorites) - // .service(add_favorite) - // .service(delete_favorite) - // .service(set_picture) - // .service(get_picture) - // .service(delete_picture), - // ); -} diff --git a/ui/src/components/AirportDrawer/index.tsx b/ui/src/components/AirportDrawer/index.tsx index 0a82baa..33b5edf 100644 --- a/ui/src/components/AirportDrawer/index.tsx +++ b/ui/src/components/AirportDrawer/index.tsx @@ -1,10 +1,12 @@ import { Accordion, Badge, - Box, Button, + Box, + Button, Divider, Drawer, - Group, Stack, + Group, + Stack, Tabs, TabsList, Text, @@ -77,15 +79,15 @@ export function AirportDrawer({ > - toggleFavorite(airport.icao)} - aria-label={isFavorite ? 'Unfavorite airport' : 'Favorite airport'} - style={{ padding: 4 }} - > - {isFavorite - ? - : } - + {user && ( + toggleFavorite(airport.icao)} + aria-label={isFavorite ? 'Unfavorite airport' : 'Favorite airport'} + style={{ padding: 4 }} + > + {isFavorite ? : } + + )} {airport.name} @@ -117,7 +119,7 @@ export function AirportDrawer({ Info Weather - { user && Manage } + {user && Manage} @@ -128,17 +130,17 @@ export function AirportDrawer({ {user && ( {isAdmin ? ( - - - - - + + + + + ) : ( - - - + + + )} )} @@ -236,7 +238,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) { function WeatherInfo({ metar }: { metar?: Metar }) { if (!metar) { - return <>No METAR observation available/ + return <>No METAR observation available/; } return ( @@ -262,7 +264,7 @@ function WeatherInfo({ metar }: { metar?: Metar }) { {metar.wind_dir_degrees && metar.wind_speed_kt != null && ( - + Wind: {metar.wind_dir_degrees}° at {metar.wind_speed_kt} kt {metar.wind_gust_kt && `, gusts ${metar.wind_gust_kt} kt`} {metar.variable_wind_dir_degrees && ` (variable ${metar.variable_wind_dir_degrees})`} @@ -270,20 +272,20 @@ function WeatherInfo({ metar }: { metar?: Metar }) { )} {metar.visibility_statute_mi && ( - + Visibility: {metar.visibility_statute_mi} statute miles )} {(metar.temp_c != null || metar.dew_point_c != null) && ( - + Temp / Dew Point: {metar.temp_c}°C / {metar.dew_point_c}°C {metar.estimated_humidity != null && ` (${metar.estimated_humidity}% RH)`} )} {(metar.altimeter_in_hg != null || metar.sea_level_pressure_mb != null) && ( - + Pressure: {metar.altimeter_in_hg != null && ` Alt ${metar.altimeter_in_hg} inHg`} {metar.sea_level_pressure_mb != null && `, SLP ${metar.sea_level_pressure_mb} mb`} @@ -291,13 +293,13 @@ function WeatherInfo({ metar }: { metar?: Metar }) { )} {metar.weather_phenomena.length > 0 && ( - + Weather: {metar.weather_phenomena.join(', ')} )} {metar.sky_condition.length > 0 && ( - + Sky:{' '} {metar.sky_condition .map((s) => `${s.sky_cover}${s.cloud_base_ft_agl ? ` at ${s.cloud_base_ft_agl} ft` : ''}`) @@ -305,19 +307,19 @@ function WeatherInfo({ metar }: { metar?: Metar }) { )} - {(metar.max_temp_c != null && metar.min_temp_c != null) && ( - + {metar.max_temp_c != null && metar.min_temp_c != null && ( + Max / Min: {metar.max_temp_c}°C / {metar.min_temp_c}°C )} {metar.density_altutude != null && ( - + Density Altitude: {metar.density_altutude} ft )} - ) + ); } function airportCategoryToText(category: AirportCategory): string { diff --git a/ui/src/components/AirportSearch.tsx b/ui/src/components/AirportSearch.tsx new file mode 100644 index 0000000..4a285ca --- /dev/null +++ b/ui/src/components/AirportSearch.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import { useDebouncedValue } from '@mantine/hooks'; +import { getAirports } from '@lib/airport.ts'; +import { Autocomplete } from '@mantine/core'; + +export interface AirportSearchProps { + limit?: number; +} + +export function AirportSearch({ limit = 5 }: AirportSearchProps) { + const [search, setSearch] = useState(''); + const [debounced] = useDebouncedValue(search, 300); + const [data, setData] = useState<{ key: string; value: string; label: string }[]>([]); + + useEffect(() => { + if (!debounced) { + setData([]); + return; + } + + async function fetch(): Promise<{ key: string; value: string; label: string }[]> { + try { + const icaoResponse = await getAirports({ + icaos: [debounced], + limit: 1 + }); + const nameResponse = await getAirports({ + name: debounced, + limit: limit - 1, + }); + let combined = [...icaoResponse.data, ...nameResponse.data]; + combined = combined.slice(0, limit); + return combined.map((airport) => ({ + key: airport.icao, + value: airport.icao, + label: `${airport.icao} - ${airport.name}`, + })); + } catch (err) { + console.error('airport search failed', err); + return [] + } + } + + fetch().then(d => { + setData(d); + console.log(d) + }); + }, [debounced, limit]); + + return ( + {}} + radius={'xl'} + onBlur={() => setSearch('')} + + /> + ); +} \ No newline at end of file diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index 6cb1d35..63c9689 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, Avatar, Box, Burger, Button, Group, Text } from '@mantine/core'; +import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core'; import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks'; import classes from './Header.module.css'; import { HeaderModal } from '@components/Header/HeaderModal.tsx'; @@ -6,7 +6,8 @@ import { notifications } from '@mantine/notifications'; import { login, logout, register } from '@lib/account.ts'; import HeaderUser from '@components/Header/HeaderUser.tsx'; import { useUserContext } from '@components/context/UserContext.tsx'; -import { Link } from 'react-router'; +import { Link, matchPath, useLocation, useNavigate } from 'react-router'; +import { AirportSearch } from '@components/AirportSearch.tsx'; // const links = [ // { link: '/', label: 'Map' }, @@ -14,11 +15,18 @@ import { Link } from 'react-router'; // { link: '/metars', label: 'Metars' } // ]; +const protectedPages = [ + '/administration', + '/profile' +] + export function Header() { const { user, setUser } = useUserContext(); const [opened, { toggle }] = useDisclosure(false); const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']); const isMobile = useMediaQuery('(max-width: 768px)'); + const { pathname } = useLocation(); + const navigate = useNavigate(); // const [active, setActive] = useState(links[0].link); // const navItems = links.map((link) => ( @@ -63,7 +71,14 @@ export function Header() { async function logoutUser(): Promise { await logout(); setUser(undefined); - window.location.reload(); + + // See if the current page is a protected page + const isProtected = protectedPages.some(pattern => + matchPath(pattern, pathname) + ) + if (isProtected) { + navigate('/', { replace: true }) + } } async function registerUser({ @@ -145,7 +160,8 @@ export function Header() { {/**/} {!isMobile && ( - + + {/**/} {user ? ( ) : ( diff --git a/ui/src/components/context/UserContext.tsx b/ui/src/components/context/UserContext.tsx index eae6f82..bd95a7e 100644 --- a/ui/src/components/context/UserContext.tsx +++ b/ui/src/components/context/UserContext.tsx @@ -14,7 +14,7 @@ export const UserContext = createContext({ setUser: () => {}, loading: true, favorites: [], - toggleFavorite: () => {}, + toggleFavorite: () => {} }); export function useUserContext(): UserContextType { diff --git a/ui/src/components/context/UserProvider.tsx b/ui/src/components/context/UserProvider.tsx index 0fbe245..b9d1afd 100644 --- a/ui/src/components/context/UserProvider.tsx +++ b/ui/src/components/context/UserProvider.tsx @@ -1,6 +1,6 @@ import { ReactNode, useEffect, useState } from 'react'; import { UserContext } from './UserContext.tsx'; -import { profile } from '@lib/account.ts'; +import { addFavorite, getFavorites, profile, removeFavorite } from '@lib/account.ts'; import { User } from '@lib/account.types.ts'; import { Center, Loader } from '@mantine/core'; import Cookies from 'js-cookie'; @@ -9,19 +9,26 @@ const sessionExpirationName = 'session_expiration'; export function UserProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(undefined); - const [favorites, setFavorites] = useState(() => { - return JSON.parse(localStorage.getItem('favorites') || '[]') - }) + const [favorites, setFavorites] = useState([]); const [loading, setLoading] = useState(true); - const toggleFavorite = (icao: string) => { + async function toggleFavorite(icao: string) { setFavorites((prev) => { - const next = prev.includes(icao) + const isFav = prev.includes(icao) + const next = isFav ? prev.filter((i) => i !== icao) : [...prev, icao] - localStorage.setItem('favorites', JSON.stringify(next)) + + ;(isFav + ? removeFavorite(icao) + : addFavorite(icao) + ).catch((err) => { + console.error('Sync failed, rolling back', err) + setFavorites(prev) + }) + return next - }) + }); } useEffect(() => { @@ -48,6 +55,14 @@ export function UserProvider({ children }: { children: ReactNode }) { } }, []); + useEffect(() => { + if (user != undefined) { + getFavorites().then(f => { + setFavorites(f) + }) + } + }, [user]); + return ( {loading ? ( diff --git a/ui/src/lib/account.ts b/ui/src/lib/account.ts index 81017ea..d10346b 100644 --- a/ui/src/lib/account.ts +++ b/ui/src/lib/account.ts @@ -1,4 +1,4 @@ -import { getRequest, postRequest } from '.'; +import { deleteRequest, getRequest, postRequest } from '.'; import { RegisterUser, User } from './account.types'; export async function login(username: string, password: string): Promise { @@ -31,3 +31,20 @@ export async function profile(): Promise { return undefined; } } + +export async function getFavorites(): Promise { + const response = await getRequest('account/profile/favorites'); + if (response?.status === 200) { + return response.json(); + } else { + return []; + } +} + +export async function addFavorite(icao: string): Promise { + return await postRequest(`account/profile/favorites/${icao}`); +} + +export async function removeFavorite(icao: string): Promise { + return await deleteRequest(`account/profile/favorites/${icao}`); +} diff --git a/ui/src/lib/airport.ts b/ui/src/lib/airport.ts index 9e857e3..c66dd07 100644 --- a/ui/src/lib/airport.ts +++ b/ui/src/lib/airport.ts @@ -35,8 +35,8 @@ export async function getAirports({ icaos: icaos ?? undefined, name: name ?? undefined, metars: metars ?? undefined, - limit, - page + limit: limit, + page: page }); return response?.json() || { data: [] }; } diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts index 4eef128..a8d8d94 100644 --- a/ui/src/lib/index.ts +++ b/ui/src/lib/index.ts @@ -1,6 +1,6 @@ import { API_URL } from '@lib/constants.ts'; -export async function getRequest(endpoint: string, params: Record = {}): Promise { +export async function getRequest(endpoint: string, params: any = {}): Promise { Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); const urlParams = new URLSearchParams(params); const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`; @@ -11,7 +11,7 @@ export async function getRequest(endpoint: string, params: Record = } interface PostOptions { - headers?: Record; + headers?: Record; type?: 'json' | 'form'; } @@ -61,9 +61,8 @@ export async function putRequest(endpoint: string, body?: any, options?: PostOpt export async function deleteRequest(endpoint: string): Promise { const url = `${API_URL}/${endpoint}`; - const response = await fetch(url, { + return await fetch(url, { method: 'DELETE', credentials: 'include' }); - return response; }