diff --git a/.env b/.env index 9c5c888..69f6034 100644 --- a/.env +++ b/.env @@ -2,8 +2,7 @@ RUST_LOG=warn,api=info HTTPD_DOMAIN=localhost HTTPD_PROTOCOL=http -HTTPD_HTTP_PORT=8080 -HTTPD_HTTPS_PORT=8443 +HTTPD_PORT=8080 HTTPD_MINIO_HOST=host.docker.internal HTTPD_API_HOST=host.docker.internal HTTPD_UI_HOST=host.docker.internal @@ -24,18 +23,19 @@ MINIO_BUCKET=aviation MINIO_PROTOCOL=http MINIO_PORT=9000 MINIO_PORT_INTERNAL=9001 -MINIO_BROWSER_REDIRECT_URL=${HTTPD_PROTOCOL}://${HTTPD_DOMAIN}:${HTTPD_HTTP_PORT}/minio/ +MINIO_BROWSER_REDIRECT_URL=${HTTPD_PROTOCOL}://${HTTPD_DOMAIN}:${HTTPD_PORT}/minio/ UI_PROTOCOL=http UI_PORT=3000 API_PROTOCOL=http +API_HOST=0.0.0.0 API_PORT=5000 -VITE_API_URL=${HTTPD_PROTOCOL}://${HTTPD_DOMAIN}:${HTTPD_HTTP_PORT}/api +VITE_API_URL=${HTTPD_PROTOCOL}://${HTTPD_DOMAIN}:${HTTPD_PORT}/api ENVIRONMENT=development ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=CHANGEME -GOV_API_URL=https://aviationweather.gov/cgi-bin/data +AVIATION_WEATHER_URL=https://aviationweather.gov/api/data diff --git a/api/Dockerfile b/api/Dockerfile index 5dd2df9..1d5c83e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -11,26 +11,14 @@ COPY Cargo.toml ./ RUN apt-get update && apt-get install -y cmake RUN cargo build --release -# ====== -# Keys -# ====== -FROM debian:bookworm-slim AS keys -WORKDIR /keys - -RUN apt-get update && apt-get install -y openssl libpq-dev -RUN openssl genrsa -out access.pem 4096 -RUN openssl rsa -in access.pem -pubout -outform PEM -out access.pem.pub -RUN openssl genrsa -out refresh.pem 4096 -RUN openssl rsa -in refresh.pem -pubout -outform PEM -out refresh.pem.pub - # ========= # Runtime # ========= -FROM keys AS runtime +FROM debian:bookworm-slim AS runtime WORKDIR /api +RUN apt-get update && apt-get install -y openssl libpq-dev USER root COPY --from=builder /builder/target/release/api /usr/local/bin/api -COPY --from=keys /keys /keys CMD ["api"] diff --git a/api/migrations/10232024_initial.sql b/api/migrations/10232024_initial.sql index 95eac60..321dd12 100644 --- a/api/migrations/10232024_initial.sql +++ b/api/migrations/10232024_initial.sql @@ -17,6 +17,15 @@ CREATE TABLE IF NOT EXISTS airports ( public BOOLEAN DEFAULT false ); +CREATE INDEX ON airports (iata); +CREATE INDEX ON airports (local); +CREATE INDEX ON airports (name); +CREATE INDEX ON airports (category); +CREATE INDEX ON airports (iso_country); +CREATE INDEX ON airports (iso_region); +CREATE INDEX ON airports (municipality); +CREATE INDEX ON airports (longitude, latitude); + CREATE TABLE IF NOT EXISTS runways ( id UUID PRIMARY KEY NOT NULL, icao TEXT NOT NULL, @@ -26,6 +35,9 @@ CREATE TABLE IF NOT EXISTS runways ( surface TEXT NOT NULL ); +CREATE INDEX ON runways (icao); +CREATE INDEX ON runways (surface); + CREATE TABLE IF NOT EXISTS frequencies ( id UUID PRIMARY KEY NOT NULL, icao TEXT NOT NULL, @@ -33,6 +45,9 @@ CREATE TABLE IF NOT EXISTS frequencies ( frequency_mhz REAL NOT NULL ); +CREATE INDEX ON frequencies (icao); +CREATE INDEX ON frequencies (frequency_mhz); + CREATE TABLE IF NOT EXISTS metars ( icao TEXT NOT NULL, observation_time TIMESTAMPTZ NOT NULL, @@ -40,6 +55,8 @@ CREATE TABLE IF NOT EXISTS metars ( data JSONB NOT NULL ); +CREATE INDEX ON metars (observation_time DESC); + CREATE TABLE IF NOT EXISTS users ( email TEXT PRIMARY KEY NOT NULL, password_hash TEXT NOT NULL, diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index 15abd14..9d05668 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::str::FromStr; use futures_util::try_join; +use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::{Postgres, QueryBuilder}; use crate::airports::{ @@ -194,7 +195,7 @@ impl From for Airport { } impl Airport { - pub async fn select(icao: &str, metar: bool) -> Option { + pub async fn select(client: &Client, icao: &str, metar: bool) -> Option { let pool = db::pool(); let airport_fut = async { @@ -206,7 +207,7 @@ impl Airport { let metar_fut = async { if metar { - match Metar::find_all(&vec![icao.to_string()]).await { + match Metar::find_all(client, &vec![icao.to_string()], &false).await { Ok(m) => Some(m.into_iter().nth(0)), Err(err) => { log::error!("{}", err); @@ -269,7 +270,7 @@ impl Airport { }) } - pub async fn select_all(query: &AirportQuery) -> ApiResult> { + pub async fn select_all(client: &Client, query: &AirportQuery) -> ApiResult> { let pool = db::pool(); let mut builder = QueryBuilder::::new("SELECT * FROM "); @@ -337,7 +338,7 @@ impl Airport { let runway_future = Runway::select_all_map(icaos.clone()); let frequency_future = Frequency::select_all_map(icaos.clone()); let metar_future = if query.metars.unwrap_or(false) { - Some(Metar::find_all(&icaos)) + Some(Metar::find_all(client, &icaos, &false)) } else { None }; diff --git a/api/src/airports/routes.rs b/api/src/airports/routes.rs index 04271b0..dd010d7 100644 --- a/api/src/airports/routes.rs +++ b/api/src/airports/routes.rs @@ -4,6 +4,7 @@ use crate::{ airports::Airport, db::Paged, auth::{Auth, verify_role}, + AppState, }; use actix_multipart::Multipart; use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError}; @@ -53,7 +54,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { } #[get("")] -async fn get_airports(req: HttpRequest) -> HttpResponse { +async fn get_airports(data: web::Data, req: HttpRequest) -> HttpResponse { let mut query = match web::Query::::from_query(req.query_string()) { Ok(q) => q.into_inner(), Err(err) => { @@ -71,7 +72,8 @@ async fn get_airports(req: HttpRequest) -> HttpResponse { query.limit = Some(limit); query.page = Some(page); - match Airport::select_all(&query).await { + let client = &data.client; + match Airport::select_all(client, &query).await { Ok(airports) => HttpResponse::Ok().json(Paged { data: airports, page, @@ -86,7 +88,11 @@ async fn get_airports(req: HttpRequest) -> HttpResponse { } #[get("/{icao}")] -async fn get_airport(icao: web::Path, req: HttpRequest) -> HttpResponse { +async fn get_airport( + data: web::Data, + icao: web::Path, + req: HttpRequest, +) -> HttpResponse { let metar = match web::Query::::from_query(req.query_string()) { Ok(q) => q.metars.unwrap_or_else(|| false), Err(err) => { @@ -95,7 +101,8 @@ async fn get_airport(icao: web::Path, req: HttpRequest) -> HttpResponse } }; - match Airport::select(&icao.into_inner(), metar).await { + let client = &data.client; + match Airport::select(client, &icao.into_inner(), metar).await { Some(airport) => HttpResponse::Ok().json(airport), None => HttpResponse::NotFound().finish(), } diff --git a/api/src/error.rs b/api/src/error.rs index a86047d..a0db0e4 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -97,7 +97,18 @@ impl From for Error { impl From for Error { fn from(error: reqwest::Error) -> Self { - Self::new(500, format!("Unknown reqwest error: {}", error)) + match error.status() { + Some(status_code) => { + if status_code.is_client_error() { + Self::new(500, format!("Client reqwest error: {}", error)) + } else if status_code.is_server_error() { + Self::new(500, format!("Server reqwest error: {}", error)) + } else { + Self::new(500, format!("Unknown reqwest error: {:?}", error)) + } + } + _ => Self::new(500, format!("Unknown reqwest error: {:?}", error)), + } } } diff --git a/api/src/main.rs b/api/src/main.rs index b409608..097b551 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,5 +1,5 @@ use std::env; - +use std::time::Duration; use actix_cors::Cors; use actix_web::{App, HttpServer, middleware::Logger, web}; use dotenv::from_filename; @@ -14,15 +14,17 @@ mod metars; mod scheduler; mod users; +#[derive(Debug, Clone)] +struct AppState { + client: reqwest::Client, +} + #[actix_web::main] async fn main() -> Result<(), Box> { initialize_environment()?; db::initialize().await?; // scheduler::update_airports(); - let host = "0.0.0.0".to_string(); - let port = env::var("API_PORT").unwrap_or("5000".to_string()); - // Initialize admin user let admin_email = env::var("ADMIN_EMAIL"); let admin_password = env::var("ADMIN_PASSWORD"); @@ -55,6 +57,17 @@ async fn main() -> Result<(), Box> { } } + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .no_proxy() + .danger_accept_invalid_certs(true) + .build() + .expect("Failed to create reqwest client"); + + let state = AppState { client }; + let host = env::var("API_HOST").unwrap_or("localhost".to_string()); + let port = env::var("API_PORT").unwrap_or("5000".to_string()); + let server = match HttpServer::new(move || { let cors = Cors::default() .allow_any_origin() @@ -62,18 +75,22 @@ async fn main() -> Result<(), Box> { .allow_any_header() .supports_credentials() .max_age(3600); - App::new().wrap(cors).wrap(Logger::default()).service( - web::scope("api") - .configure(airports::init_routes) - .configure(metars::init_routes) - .configure(auth::init_routes) - .configure(users::init_routes), - ) + App::new() + .wrap(cors) + .wrap(Logger::default()) + .app_data(web::Data::new(state.clone())) + .service( + web::scope("api") + .configure(airports::init_routes) + .configure(metars::init_routes) + .configure(auth::init_routes) + .configure(users::init_routes), + ) }) .bind(format!("{}:{}", host, port)) { Ok(b) => { - log::info!("Binding server to {}:{}", host, port); + log::info!("Server bound to {}:{}", host, port); b } Err(err) => { diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index 74a078a..72e70bd 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -3,6 +3,7 @@ use crate::{error::ApiResult, db}; use chrono::{DateTime, Datelike, Utc}; use std::collections::HashSet; use redis::{AsyncCommands, RedisResult}; +use reqwest::Client; use serde::{Deserialize, Serialize}; use crate::db::redis_async_connection; @@ -845,8 +846,8 @@ impl Metar { missing_metar_icaos } - async fn get_remote_metars(icaos: &[&str]) -> ApiResult> { - let gov_api_url = std::env::var("GOV_API_URL").expect("GOV_API_URL must be set"); + async fn get_remote_metars(client: &Client, icaos: &[&str]) -> ApiResult> { + let base_url = std::env::var("AVIATION_WEATHER_URL").expect("GOV_API_URL must be set"); // Query the remote API for the missing METAR data 10 at a time let icao_chunks = icaos .chunks(10) @@ -854,14 +855,14 @@ impl Metar { .collect::>(); let mut metars: Vec = vec![]; for icao_chunk in icao_chunks { - let url = format!("{}/metar.php?ids={}", gov_api_url, icao_chunk); - let mut m = match reqwest::get(url).await { + let url = format!("{}/metar?ids={}&order=id", base_url, icao_chunk); + let mut m = match client.get(url).send().await { Ok(r) => { // Check if the status code is 200 if r.status() != 200 { return Err(Error::new( 500, - format!("Unable to get METAR request: {}", r.status()), + format!("Request returned status {}", r.status()), )); } match r.text().await { @@ -876,20 +877,10 @@ impl Metar { Err(err) => return Err(err), } } - Err(err) => { - return Err(Error::new( - 500, - format!("Unable to parse METAR request: {}", err), - )) - } + Err(err) => return Err(Error::new(500, format!("METAR parse failed: {}", err))), } } - Err(err) => { - return Err(Error::new( - 500, - format!("Unable to get METAR request: {}", err), - )) - } + Err(err) => return Err(err.into()), }; metars.append(&mut m); } @@ -911,7 +902,11 @@ impl Metar { }) } - pub async fn find_all(icao_list: &Vec) -> ApiResult> { + pub async fn find_all( + client: &Client, + icao_list: &Vec, + force: &bool, + ) -> ApiResult> { if icao_list.is_empty() { return Ok(Vec::new()); } @@ -937,17 +932,21 @@ impl Metar { if !missing_icao_list.is_empty() { let mut updated_missing_icao_list: Vec<&str> = Vec::new(); for icao in &missing_icao_list { - let result: RedisResult> = conn.get(icao).await; - match result { - Ok(Some(value)) => { - if value { + if *force { + updated_missing_icao_list.push(icao); + } else { + let result: RedisResult> = conn.get(icao).await; + match result { + Ok(Some(value)) => { + if value { + updated_missing_icao_list.push(icao); + } + } + Ok(None) => { updated_missing_icao_list.push(icao); } + Err(err) => return Err(err.into()), } - Ok(None) => { - updated_missing_icao_list.push(icao); - } - Err(err) => return Err(err.into()), } } if !updated_missing_icao_list.is_empty() { @@ -955,7 +954,7 @@ impl Metar { "Retrieving missing METAR data for {:?}", updated_missing_icao_list ); - let mut missing_icao_list = Self::get_remote_metars(&updated_missing_icao_list) + let mut missing_icao_list = Self::get_remote_metars(client, &updated_missing_icao_list) .await .unwrap_or_else(|err| { log::warn!("Unable to get remote METAR data; {}", err); diff --git a/api/src/metars/routes.rs b/api/src/metars/routes.rs index 4c147e6..fe8dbea 100644 --- a/api/src/metars/routes.rs +++ b/api/src/metars/routes.rs @@ -2,14 +2,16 @@ use crate::metars::Metar; use actix_web::{get, web, HttpResponse, HttpRequest}; use log::error; use serde::{Deserialize, Serialize}; +use crate::AppState; #[derive(Debug, Serialize, Deserialize)] struct FindAllParameters { icaos: Option, + force: Option, } #[get("metars")] -async fn find_all(req: HttpRequest) -> HttpResponse { +async fn find_all(data: web::Data, req: HttpRequest) -> HttpResponse { let parameters = web::Query::::from_query(req.query_string()).unwrap(); let icao_option = ¶meters.icaos; let icao_string = match icao_option { @@ -17,8 +19,10 @@ async fn find_all(req: HttpRequest) -> HttpResponse { None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"), }; let icaos: Vec = icao_string.split(',').map(|s| s.to_string()).collect(); + let force = ¶meters.force.unwrap_or(false); - let metars = match Metar::find_all(&icaos).await { + let client = &data.client; + let metars = match Metar::find_all(client, &icaos, force).await { Ok(a) => a, Err(err) => { error!("{}", err); diff --git a/bruno/Metars/Find Metars.bru b/bruno/Metars/Find Metars.bru index 768425b..319fe8f 100644 --- a/bruno/Metars/Find Metars.bru +++ b/bruno/Metars/Find Metars.bru @@ -5,11 +5,12 @@ meta { } get { - url: {{API_URL}}/metars?icaos=KJYO,KOKV,KMRB,KHEF,KIAD + url: {{API_URL}}/metars?icaos=KJYO,KOKV,KMRB,KHEF,KIAD&force=true body: none auth: none } params:query { icaos: KJYO,KOKV,KMRB,KHEF,KIAD + force: true } diff --git a/bruno/environments/Localhost.bru b/bruno/environments/Localhost.bru index 2459c37..4796118 100644 --- a/bruno/environments/Localhost.bru +++ b/bruno/environments/Localhost.bru @@ -1,3 +1,6 @@ vars { BASE_URL: http://localhost:8080 + ~BASE_URL: http://localhost:5000 + ~BASE_URL: http://127.0.0.1:5000 + ~BASE_URL: http://[::1]:5000 } diff --git a/docker-compose.yml b/docker-compose.yml index b79c8db..05fd805 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,9 @@ x-env_file: &env - path: .env.local required: false +x-restart: &default_restart + restart: unless-stopped + name: aviation services: httpd: @@ -14,12 +17,11 @@ services: dockerfile: Dockerfile env_file: *env ports: - - "${HTTPD_HTTP_PORT:-8080}:80" - - "${HTTPD_HTTPS_PORT:-8443}:443" + - "${HTTPD_PORT:-8080}:80" networks: - frontend - backend - restart: unless-stopped + <<: *default_restart postgres: image: postgis/postgis:17-3.5 @@ -38,7 +40,7 @@ services: - backend profiles: - backend - restart: unless-stopped + <<: *default_restart redis: image: redis:8.0-M03 # Replace with valkey? @@ -56,7 +58,7 @@ services: - backend profiles: - backend - restart: unless-stopped + <<: *default_restart minio: image: minio/minio:RELEASE.2025-02-28T09-55-16Z @@ -76,7 +78,7 @@ services: profiles: - backend command: server --console-address ":9001" /data - restart: unless-stopped + <<: *default_restart api: image: aviation-api:latest @@ -86,6 +88,7 @@ services: dockerfile: Dockerfile env_file: *env environment: + API_HOST: 0.0.0.0 POSTGRES_HOST: aviation-postgres POSTGRES_PORT: 5432 REDIS_HOST: aviation-redis @@ -103,7 +106,7 @@ services: - backend profiles: - api - restart: unless-stopped + <<: *default_restart ui: image: aviation-ui:latest @@ -125,7 +128,7 @@ services: profiles: - frontend command: ["npm", "run", "dev"] - restart: unless-stopped + <<: *default_restart volumes: postgres: diff --git a/httpd/httpd.conf b/httpd/httpd.conf index 6e82d94..7816f0f 100644 --- a/httpd/httpd.conf +++ b/httpd/httpd.conf @@ -158,14 +158,14 @@ LoadModule proxy_http_module modules/mod_proxy_http.so #LoadModule session_dbd_module modules/mod_session_dbd.so #LoadModule slotmem_shm_module modules/mod_slotmem_shm.so #LoadModule slotmem_plain_module modules/mod_slotmem_plain.so -LoadModule ssl_module modules/mod_ssl.so +#LoadModule ssl_module modules/mod_ssl.so #LoadModule optional_hook_export_module modules/mod_optional_hook_export.so #LoadModule optional_hook_import_module modules/mod_optional_hook_import.so #LoadModule optional_fn_import_module modules/mod_optional_fn_import.so #LoadModule optional_fn_export_module modules/mod_optional_fn_export.so #LoadModule dialup_module modules/mod_dialup.so -LoadModule http2_module modules/mod_http2.so -LoadModule proxy_http2_module modules/mod_proxy_http2.so +#LoadModule http2_module modules/mod_http2.so +#LoadModule proxy_http2_module modules/mod_proxy_http2.so #LoadModule md_module modules/mod_md.so #LoadModule lbmethod_byrequests_module modules/mod_lbmethod_byrequests.so #LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so @@ -229,7 +229,7 @@ Group www-data # e-mailed. This address appears on some server-generated pages, such # as error documents. e.g. admin@your-domain.com # -ServerAdmin you@example.com +ServerAdmin ben@bensherrif.com # # ServerName gives the name and port that the server uses to identify itself. diff --git a/ui/src/App.css b/ui/src/App.css index 8ba6fba..57dd1da 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -50,7 +50,9 @@ body, cursor: pointer; user-select: none; - transition: background-color 0.2s, color 0.2s; + transition: + background-color 0.2s, + color 0.2s; } .map-button.active { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8ee8c06..80da655 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -42,14 +42,14 @@ function App() { useEffect(() => { if (showRadar) { - getWeatherMapUrl().then(url => { + getWeatherMapUrl().then((url) => { setRainViewerUrl(url); }); } }, [showRadar]); function toggleRadar() { - setShowRadar(prev => { + setShowRadar((prev) => { const newValue = !prev; Cookies.set('showRadar', newValue.toString(), { expires: 7 }); return newValue; @@ -96,7 +96,7 @@ function App() { - {rainViewerUrl && showRadar && } + {rainViewerUrl && showRadar && } diff --git a/ui/src/components/AirportMarker.tsx b/ui/src/components/AirportMarker.tsx index d8d7edf..f1ecc2d 100644 --- a/ui/src/components/AirportMarker.tsx +++ b/ui/src/components/AirportMarker.tsx @@ -27,7 +27,7 @@ export default function AirportMarker({ mouseout: () => markerRef.current?.closePopup() }} > - + {airport.icao} - {airport.name} diff --git a/ui/src/lib/rainViewer.ts b/ui/src/lib/rainViewer.ts index b590b12..2a72fe3 100644 --- a/ui/src/lib/rainViewer.ts +++ b/ui/src/lib/rainViewer.ts @@ -4,7 +4,7 @@ const weatherMapsUrl = 'https://api.rainviewer.com/public/weather-maps.json'; async function getWeatherMaps(): Promise { const response = await fetch(`${weatherMapsUrl}`, { - method: 'GET', + method: 'GET' }); if (response?.status === 200) { return response.json(); @@ -18,16 +18,16 @@ export async function getWeatherMapUrl(): Promise { if (weatherMaps != undefined) { let url = weatherMaps.host; // url = 'https://cdn.rainviewer.com'; - let latest = ""; + let latest = ''; if (weatherMaps.radar.past.length > 0) { latest = weatherMaps.radar.past[weatherMaps.radar.past.length - 1].path; } else { return null; } - url += latest + "/256/{z}/{x}/{y}/2/1_1.png"; + url += latest + '/256/{z}/{x}/{y}/2/1_1.png'; // url += latest + "/256/{z}/{x}/{y}/255/1_1_1_0.webp"; return url; } else { return null; } -} \ No newline at end of file +} diff --git a/ui/src/lib/rainViewer.types.ts b/ui/src/lib/rainViewer.types.ts index 141dce7..f4a2595 100644 --- a/ui/src/lib/rainViewer.types.ts +++ b/ui/src/lib/rainViewer.types.ts @@ -18,4 +18,4 @@ export interface SatelliteObject { export interface FrameObject { time: number; path: string; -} \ No newline at end of file +}