Merge pull request #2 from bensherriff/refactor

Refactor
This commit is contained in:
Ben Sherriff
2025-04-12 08:58:22 -04:00
committed by GitHub
142 changed files with 11893 additions and 10973 deletions

47
.env
View File

@@ -1,24 +1,47 @@
RUST_LOG=warn,api=info
DATABASE_CONTAINER=aviation-db
DATABASE_USER=aviation
DATABASE_PASSWORD=
DATABASE_NAME=aviation
DATABASE_HOST=localhost
DATABASE_PORT=5432
NGINX_HOST=localhost
NGINX_PROTOCOL=https
NGINX_HTTP_PORT=8080
NGINX_HTTPS_PORT=8443
NGINX_MINIO_HOST=host.docker.internal
NGINX_API_HOST=host.docker.internal
NGINX_UI_HOST=host.docker.internal
POSTGRES_HOST=localhost
POSTGRES_USER=aviation
POSTGRES_PASSWORD=CHANGEME
POSTGRES_NAME=aviation
POSTGRES_PORT=5432
REDIS_HOST=localhost
REDIS_PORT=6379
MINIO_ROOT_USER=aviation
MINIO_ROOT_PASSWORD=
MINIO_HOST=localhost
MINIO_ROOT_USER=aviation
MINIO_ROOT_PASSWORD=CHANGEME
MINIO_BUCKET=aviation
MINIO_PROTOCOL=http
MINIO_PORT=9000
MINIO_PORT_INTERNAL=9001
MINIO_BROWSER_REDIRECT_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTPS_PORT}/minio/
API_HOST=localhost
API_PORT=5000
UI_PROTOCOL=http
UI_PORT=3000
NODE_ENV=development
GOV_API_URL=https://aviationweather.gov/cgi-bin/data
API_PROTOCOL=http
API_HOST=0.0.0.0
API_PORT=5000
SSL_CA_NAME=ca
SSL_CA_PATH=../ssl/${SSL_CA_NAME}.pem
SSL_CERT_PATH=../ssl/localhost.crt
SSL_CERT_KEY_PATH=../ssl/localhost.key
VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTPS_PORT}/api
ENVIRONMENT=development
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=CHANGEME
AVIATION_WEATHER_URL=https://aviationweather.gov/api/data

38
.gitignore vendored
View File

@@ -1,40 +1,18 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.vscode/
.idea/
# dependencies
node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
.next/
/out/
node_modules
target/
dist/
Cargo.lock
ssl/
# production
/build
# misc
.DS_Store
*.pem
*.pem.pub
keys/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
target/
dist/

View File

@@ -1,4 +0,0 @@
{
"rust-analyzer.showUnlinkedFileNotification": false,
"editor.tabSize": 2
}

View File

@@ -1,7 +1,8 @@
#!make
SHELL := /bin/bash
GIT_HASH ?= $(shell git log --format="%h" -n 1)
export API_VERSION = $(shell awk -F ' = ' '$$1 ~ /package.version/ { gsub(/[\"]/, "", $$2); printf("%s",$$2) }' api/Cargo.toml)
export UI_VERSION := $(shell awk -F'"' '/"version"/ { print $$4 }' ui/package.json)
include .env
-include .env.local
@@ -14,10 +15,47 @@ help: ## This info
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
format: ## Format code
format: format-api format-ui ## Format code
psql: ## Connect to the PSQL DB
@docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -P pager=off
#################
# API Commands #
#################
format-api: ## Format code
@cd api && cargo fmt
build-api: ## Build the project
@cd api && cargo build
run-api: ## Run the API project
@cd api && cargo run -p api
#################
# UI Commands #
#################
lint-ui: ## Run the linter
@cd ui && npm run lint
format-ui: ## Run the formatter
@cd ui && npm run format
build-ui: ## Build the UI app
@cd ui && npm install && npm run build
clean-ui: ## Remove UI build files
@cd ui && rm -rf node_modules dist
run-ui: ## Run the UI app
@cd ui && npm install && npm run dev
###################
# Docker Commands #
###################
backend-up: ## Start Docker containers
@docker compose --profile backend up -d
@@ -41,12 +79,37 @@ frontend-down: ## Stop Docker containers
down-frontend: frontend-down
docker-prune: ## Prune the docker system
@docker system prune -a
docker-clean: ## Stop the docker containers and remove volumes
@echo "Stopping docker container and removing volumes..."
@docker compose --profile frontend --profile api --profile backend down -v
@echo "Docker container stopped and volumes removed"
docker-down: ## Stop the docker container
@docker compose --profile frontend --profile api --profile backend down
docker-up: ## Start the docker container
@docker compose --profile backend --profile api --profile frontend up -d
docker-refresh: docker-clean up-backend ## Refresh the database
psql: ## Connect to the PSQL DB
@docker exec -it ${DATABASE_CONTAINER} psql -U ${DATABASE_USER} -P pager=off
refresh: docker-refresh
build: version=$(if $(v),$(v),latest)
build: folder=$(if $(f),$(f),nginx)
build: image=aviation-${folder}:${version}
build: ## Build a specific docker image (`make build f=httpd`)
docker buildx build \
-f ${folder}/Dockerfile \
-t ${image} \
--load \
--build-arg BUILD_DATE=$$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg BUILD_VERSION=${version} \
--build-arg VCS_REF=$$(git rev-parse head) \
${folder}
docker-build: build
cert: domain=$(if $(d),$(d),${NGINX_HOST})
cert: ## Generate a cert for the given domain
./scripts/generate_cert.sh ${domain}

View File

@@ -1,22 +1,34 @@
# Aviation Weather
# Aviation
## Makefile
`make help` to list all commands
## Setup
1. Copy `.env.TEMPLATE` to `.env`
2. Generate JWT RS256 (RSA Signature with SHA-256) Private/Public keys with `make generate`
3. Build the api and ui images with `make build`
4. Run the application with `make up`
1. Override any environment variables in `.env.local`
2. Build the api and ui images with `make build`
3. Run the application with `make up`
## Decoding METARS
## Data Sources
### Airport Data
Potential Data sources
- https://adip.faa.gov/agis/public/#/airportSearch/advanced
- https://www.icao.int/Aviation-API-Data-Service/Pages/default.aspx
- https://ourairports.com/data/
- [mborsetti/airportsdata](https://github.com/mborsetti/airportsdata)
- https://www.iata.org/en/publications/directories/code-search/
- [openstreet](https://www.openstreetmap.org/#map=13/38.95223/-77.47417)
### Metar Data
Metar data is collected from aviationweather.gov.
#### Decoding METARS
The following resources were used to help decode METARS.
- [Metar Decode Key PDF](https://www.weather.gov/media/wrh/mesowest/metar_decode_key.pdf)
- [Metar Decode (NPS EDU)](https://met.nps.edu/~bcreasey/mr3222/files/helpful/DecodeMETAR-TAF.html)
- [Weather Phenomena](http://www.moratech.com/aviation/metar-class/metar-pg9-ww.html)
- Airport dataset is based on [mborsetti/airportsdata](https://github.com/mborsetti/airportsdata)
## OpenMapTiles
### OpenMapTiles
[Generate Vector Tiles](https://openmaptiles.org/docs/generate/generate-openmaptiles/)

2553
api/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,37 @@
[package]
name = "api"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
authors = ["Ben Sherriff <hello@bensherriff.com>"]
repository = "https://github.com/bensherriff/aviation-weather"
readme = "README.md"
readme = "../README.md"
license = "GPL-3.0-or-later"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4.9.0"
actix-cors = "0.7.0"
actix-web = "4.10.2"
actix-cors = "0.7.1"
actix-web-httpauth = "0.8.2"
actix-multipart = "0.7.2"
chrono = { version = "0.4.38", features = ["serde"] }
chrono = { version = "0.4.40", features = ["serde"] }
dotenv = "0.15.0"
diesel = { version = "2.2.4", features = ["postgres", "r2d2", "uuid", "chrono", "serde_json"] }
postgis_diesel = { version = "2.4.1", features = ["serde"] }
diesel_migrations = { version = "2.2.0", features = ["postgres"] }
env_logger = "0.11.5"
lazy_static = "1.5.0"
r2d2 = "0.8.10"
reqwest = "0.12.7"
serde = {version = "1.0.209", features = ["derive"]}
serde_json = "1.0.127"
tokio = { version = "1.40.0", features = ["macros", "rt", "time"] }
uuid = { version = "1.10.0", features = ["serde", "v4"] }
log = "0.4.22"
sqlx = { version = "0.8.3", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
env_logger = "0.11.8"
reqwest = "0.12.15"
serde = {version = "1.0.219", features = ["derive"]}
serde_json = "1.0.140"
tokio = { version = "1.44.2", features = ["macros", "rt", "time"] }
uuid = { version = "1.16.0", features = ["serde", "v4"] }
log = "0.4.27"
argon2 = "0.5.3"
redis = { version = "0.26.1", features = ["tokio-comp", "connection-manager", "r2d2"] }
regex = "1.10.6"
futures-util = "0.3.30"
redis = { version = "0.29.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
regex = "1.11.1"
futures-util = "0.3.31"
rust-s3 = "0.35.1"
rand = "0.8.5"
rand_chacha = "0.3.1"
rand = "0.9.0"
rand_chacha = "0.9.0"
geo-types = "0.7.15"
byteorder = "1.5.0"
futures = "0.3.31"
moka = { version = "0.12.10", features = ["future"] }

View File

@@ -1,7 +1,7 @@
# =========
# Builder
# =========
FROM rust:bookworm as builder
FROM rust:bookworm AS builder
WORKDIR /builder
COPY migrations ./migrations
@@ -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 ca-certificates
USER root
COPY --from=builder /builder/target/release/api /usr/local/bin/api
COPY --from=keys /keys /keys
CMD ["api"]

View File

@@ -1,7 +0,0 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::sql_types::SqlType", "std::fmt::Debug"]
import_types = ["diesel::sql_types::*", "postgis_diesel::sql_types::*"]

View File

@@ -1 +0,0 @@
DROP TABLE airports;

View File

@@ -1,13 +0,0 @@
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS airports (
icao TEXT PRIMARY KEY NOT NULL,
category TEXT NOT NULL,
name TEXT NOT NULL,
elevation_ft REAL NOT NULL,
iso_country TEXT NOT NULL,
iso_region TEXT NOT NULL,
municipality TEXT NOT NULL,
has_metar BOOLEAN NOT NULL DEFAULT FALSE,
point GEOMETRY(POINT,4326) NOT NULL,
data JSONB NOT NULL
);

View File

@@ -1 +0,0 @@
DROP TABLE metars;

View File

@@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS metars (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
icao TEXT NOT NULL,
observation_time TIMESTAMP NOT NULL,
raw_text TEXT NOT NULL,
data JSONB NOT NULL
);

View File

@@ -1 +0,0 @@
DROP TABLE airport_metar_cache;

View File

@@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS airport_metar_cache (
icao TEXT PRIMARY KEY NOT NULL,
has_metar BOOLEAN NOT NULL DEFAULT FALSE,
last_checked TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1 +0,0 @@
DROP TABLE users;

View File

@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
email TEXT PRIMARY KEY NOT NULL,
hash TEXT NOT NULL,
role TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
profile_picture TEXT,
favorites TEXT[] NOT NULL DEFAULT '{}',
verified BOOLEAN NOT NULL DEFAULT FALSE
);

View File

@@ -0,0 +1,68 @@
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS airports (
icao TEXT PRIMARY KEY NOT NULL,
iata TEXT,
local TEXT,
name TEXT NOT NULL,
category TEXT NOT NULL,
iso_country TEXT NOT NULL,
iso_region TEXT NOT NULL,
municipality TEXT NOT NULL,
elevation_ft REAL NOT NULL,
longitude REAL NOT NULL,
latitude REAL NOT NULL,
has_tower BOOLEAN DEFAULT false,
has_beacon BOOLEAN DEFAULT false,
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,
runway_id TEXT NOT NULL,
length_ft REAL NOT NULL,
width_ft REAL NOT NULL,
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,
frequency_id TEXT NOT NULL,
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,
raw_text TEXT NOT NULL,
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,
role TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -1,432 +0,0 @@
use std::fmt::Display;
use std::str::FromStr;
use crate::db;
use crate::error::{ApiError, ApiResult};
use crate::db::schema::airports;
use diesel::prelude::*;
use diesel::sql_query;
use log::error;
use postgis_diesel::types::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Runway {
pub id: String,
pub length_ft: f32,
pub width_ft: f32,
pub surface: String,
}
#[derive(Serialize, Deserialize)]
pub struct Frequency {
pub id: String,
pub frequency_mhz: f32,
}
#[derive(Serialize, Deserialize)]
pub struct Airport {
pub icao: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub iata: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<String>,
pub name: String,
pub category: AirportCategory,
pub iso_country: String,
pub iso_region: String,
pub municipality: String,
pub elevation_ft: f32,
pub latitude: f64,
pub longitude: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_tower: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_beacon: Option<bool>,
pub runways: Vec<Runway>,
pub frequencies: Vec<Frequency>,
pub public: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AirportMetarCache {
pub icao: String,
pub has_metar: bool,
pub last_checked: chrono::NaiveDateTime,
}
impl Into<QueryAirport> for Airport {
fn into(self) -> QueryAirport {
return QueryAirport {
icao: self.icao.clone(),
category: self.category.clone().to_string(),
name: self.name.clone(),
elevation_ft: self.elevation_ft,
iso_country: self.iso_country.clone(),
iso_region: self.iso_region.clone(),
municipality: self.municipality.clone(),
has_metar: false,
point: Point::new(self.longitude, self.latitude, Some(4326)),
data: match serde_json::to_value(&self) {
Ok(d) => d,
Err(err) => {
error!("{}", err);
serde_json::Value::Null
}
},
};
}
}
impl From<QueryAirport> for Airport {
fn from(airport: QueryAirport) -> Self {
serde_json::from_value(airport.data).unwrap()
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum AirportCategory {
#[serde(rename = "small_airport")]
Small,
#[serde(rename = "medium_airport")]
Medium,
#[serde(rename = "large_airport")]
Large,
#[serde(rename = "heliport")]
Heliport,
#[serde(rename = "closed")]
Closed,
#[serde(rename = "seaplane_base")]
Seaplane,
#[serde(rename = "balloonport")]
Balloonport,
#[serde(rename = "unknown")]
Unknown,
}
impl FromStr for AirportCategory {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"small_airport" => Ok(AirportCategory::Small),
"medium_airport" => Ok(AirportCategory::Medium),
"large_airport" => Ok(AirportCategory::Large),
"heliport" => Ok(AirportCategory::Heliport),
"closed" => Ok(AirportCategory::Closed),
"seaplane_base" => Ok(AirportCategory::Seaplane),
"balloonport" => Ok(AirportCategory::Balloonport),
_ => Ok(AirportCategory::Unknown),
}
}
}
impl Display for AirportCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AirportCategory::Small => write!(f, "small_airport"),
AirportCategory::Medium => write!(f, "medium_airport"),
AirportCategory::Large => write!(f, "large_airport"),
AirportCategory::Heliport => write!(f, "heliport"),
AirportCategory::Closed => write!(f, "closed"),
AirportCategory::Seaplane => write!(f, "seaplane_base"),
AirportCategory::Balloonport => write!(f, "balloonport"),
AirportCategory::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Serialize, Deserialize, AsChangeset, Insertable, Queryable, QueryableByName)]
#[diesel(table_name = airports)]
pub struct QueryAirport {
pub icao: String,
pub category: String,
pub name: String,
pub elevation_ft: f32,
pub iso_country: String,
pub iso_region: String,
pub municipality: String,
pub has_metar: bool,
pub point: Point,
pub data: serde_json::Value,
}
#[derive(Debug)]
pub struct QueryFilters {
pub icaos: Option<Vec<String>>,
pub name: Option<String>,
pub bounds: Option<Polygon<Point>>,
pub categories: Option<Vec<AirportCategory>>,
pub order_field: Option<QueryOrderField>,
pub order_by: Option<QueryOrderBy>,
pub has_metar: Option<bool>,
}
impl Default for QueryFilters {
fn default() -> Self {
QueryFilters {
icaos: None,
name: None,
bounds: None,
categories: None,
order_field: None,
order_by: None,
has_metar: None,
}
}
}
#[derive(Debug)]
pub enum QueryOrderBy {
Asc,
Desc,
}
impl FromStr for QueryOrderBy {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"asc" => Ok(QueryOrderBy::Asc),
"desc" => Ok(QueryOrderBy::Desc),
_ => Err(()),
}
}
}
#[derive(Debug)]
pub enum QueryOrderField {
Icao,
Name,
Category,
Country,
Region,
Municipality,
}
impl FromStr for QueryOrderField {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"icao" => Ok(QueryOrderField::Icao),
"name" => Ok(QueryOrderField::Name),
"category" => Ok(QueryOrderField::Category),
"iso_country" => Ok(QueryOrderField::Country),
"iso_region" => Ok(QueryOrderField::Region),
"municipality" => Ok(QueryOrderField::Municipality),
_ => Err(()),
}
}
}
impl QueryAirport {
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> ApiResult<Vec<Self>> {
let mut conn = db::connection()?;
let mut query: String = "SELECT * FROM airports".to_string();
query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
query = format!("{} ORDER BY has_metar DESC", query);
if let Some(order_by) = &filters.order_by {
match order_by {
QueryOrderBy::Asc => {
if let Some(order_field) = &filters.order_field {
query = match order_field {
QueryOrderField::Icao => format!("{}, icao ASC", query),
QueryOrderField::Name => format!("{}, name ASC", query),
QueryOrderField::Category => format!("{}, category ASC", query),
QueryOrderField::Country => format!("{}, iso_country ASC", query),
QueryOrderField::Region => format!("{}, iso_region ASC", query),
QueryOrderField::Municipality => format!("{}, municipality ASC", query),
};
};
}
QueryOrderBy::Desc => {
if let Some(order_field) = &filters.order_field {
query = match order_field {
QueryOrderField::Icao => format!("{}, icao DESC", query),
QueryOrderField::Name => format!("{}, name DESC", query),
QueryOrderField::Category => format!("{}, category DESC", query),
QueryOrderField::Country => format!("{}, iso_country DESC", query),
QueryOrderField::Region => format!("{}, iso_region DESC", query),
QueryOrderField::Municipality => format!("{}, municipality DESC", query),
};
};
}
}
}
// Limit query to page and limit
query = format!("{} LIMIT {} OFFSET {}", query, limit, (page - 1) * limit);
let airports: Vec<QueryAirport> = match sql_query(query).load(&mut conn) {
Ok(a) => a,
Err(err) => {
return Err(ApiError {
status: 500,
message: format!("{}", err),
})
}
};
Ok(airports)
}
pub fn get_count(filters: &QueryFilters) -> ApiResult<i64> {
let mut conn = db::connection()?;
let mut query = "SELECT COUNT(*) FROM airports".to_string();
query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
// TODO: Fix this to use get_result() instead of building this table to do the load()
diesel::table! {
airports (count) {
count -> BigInt,
}
}
#[derive(Debug, Queryable, QueryableByName)]
#[diesel(table_name = airports)]
struct Count {
count: i64,
}
let count: Vec<Count> = match sql_query(query).load(&mut conn) {
Ok(a) => a,
Err(err) => {
return Err(ApiError {
status: 500,
message: format!("{}", err),
})
}
};
return Ok(count[0].count);
}
// TODO: Unsafe query, need to sanitize inputs
fn build_filter_query(filters: &QueryFilters) -> ApiResult<String> {
let mut query = "".to_string();
let mut parts: Vec<String> = vec![];
if let Some(bounds) = &filters.bounds {
// convert bounds to a WKT polygon
if bounds.rings.len() > 1 {
return Err(ApiError {
status: 400,
message: "Only one polygon is allowed".to_string(),
});
} else {
let mut points: Vec<String> = vec![];
bounds.rings.iter().for_each(|ring| {
ring.iter().for_each(|point| {
points.push(format!("{} {}", point.get_x(), point.get_y()));
});
});
let bounds = format!("POLYGON(({}))", points.join(","));
parts.push(format!(
"ST_Contains(ST_GeomFromText('{}', 4326), point)",
bounds
));
}
}
if let Some(categories) = &filters.categories {
parts.push(format!(
"({})",
categories
.iter()
.map(|category| format!("category = '{}'", category.to_string()))
.collect::<Vec<String>>()
.join(" OR ")
));
}
fn sanitize_icao(icao: &str) -> String {
// Sanitize search to only allow [a-zA-Z0-9-\\s]
icao
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == ' ')
.collect::<String>()
}
if &filters.icaos.is_some() == &true && &filters.name.is_some() == &true {
let icaos = filters.icaos.as_ref().unwrap();
let name = sanitize_icao(filters.name.as_ref().unwrap());
let icao_part = format!(
"({})",
icaos
.iter()
.map(|icao| format!("icao ILIKE '{}'", sanitize_icao(icao)))
.collect::<Vec<String>>()
.join(" OR ")
);
let name_part = format!("name ILIKE '%{}%'", name);
parts.push(format!("({} OR {})", icao_part, name_part));
} else if let Some(icaos) = &filters.icaos {
parts.push(format!(
"({})",
icaos
.iter()
.map(|icao| format!("icao ILIKE '{}'", sanitize_icao(icao)))
.collect::<Vec<String>>()
.join(" OR ")
));
} else if let Some(name) = &filters.name {
let search = sanitize_icao(name);
parts.push(format!("name ILIKE '%{}%'", search));
}
if let Some(has_metar) = &filters.has_metar {
parts.push(format!("has_metar = {}", has_metar));
}
if parts.len() > 0 {
query = format!("{} WHERE {}", query, parts.join(" AND "));
}
return Ok(query);
}
pub fn get(icao: &str) -> ApiResult<Self> {
let mut conn = db::connection()?;
let airport = airports::table
.filter(airports::icao.eq(icao))
.first(&mut conn)?;
Ok(airport)
}
pub fn insert(airport: Self) -> ApiResult<Self> {
let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> =
db::connection()?;
let airport = Self::from(airport);
let airport = diesel::insert_into(airports::table)
.values(airport)
.on_conflict_do_nothing()
.get_result(&mut conn)?;
Ok(airport)
}
pub fn insert_all(airports: Vec<Self>) -> ApiResult<Vec<Self>> {
let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> =
db::connection()?;
let mut inserted_airports: Vec<Self> = vec![];
for airport in airports {
let airport = Self::from(airport);
let airport = diesel::insert_into(airports::table)
.values(airport)
.on_conflict_do_nothing()
.get_result(&mut conn)?;
inserted_airports.push(airport);
}
Ok(inserted_airports)
}
pub fn update(airport: Self) -> ApiResult<Self> {
let mut conn = db::connection()?;
let airport = diesel::update(airports::table)
.filter(airports::icao.eq(airport.icao.clone()))
.set(airport)
.get_result(&mut conn)?;
Ok(airport)
}
pub fn delete(icao: Option<String>) -> ApiResult<usize> {
let mut conn = db::connection()?;
let res = match icao {
Some(icao) => {
diesel::delete(airports::table.filter(airports::icao.eq(icao))).execute(&mut conn)?
}
None => diesel::delete(airports::table).execute(&mut conn)?,
};
Ok(res)
}
}

View File

@@ -0,0 +1,633 @@
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::{
AirportCategory, Frequency, FrequencyRow, Runway, RunwayRow, UpdateFrequency, UpdateRunway,
};
use crate::db;
use crate::error::{ApiResult, Error};
use crate::metars::Metar;
const TABLE_NAME: &str = "airports";
#[derive(Debug, Serialize, Deserialize)]
pub struct Airport {
pub icao: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub iata: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<String>,
pub name: String,
pub category: AirportCategory,
pub iso_country: String,
pub iso_region: String,
pub municipality: String,
pub elevation_ft: f32,
pub longitude: f32,
pub latitude: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_tower: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_beacon: Option<bool>,
pub runways: Vec<Runway>,
pub frequencies: Vec<Frequency>,
pub public: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_metar: Option<Metar>,
}
#[derive(Debug, Deserialize)]
pub struct AirportQuery {
pub page: Option<u32>,
pub limit: Option<u32>,
pub icaos: Option<String>,
pub iatas: Option<String>,
pub locals: Option<String>,
pub name: Option<String>,
pub categories: Option<String>,
pub iso_countries: Option<String>,
pub iso_regions: Option<String>,
pub municipalities: Option<String>,
pub bounds: Option<String>,
pub metars: Option<bool>,
}
impl Default for AirportQuery {
fn default() -> Self {
Self {
page: Some(1),
limit: Some(1000),
icaos: None,
iatas: None,
locals: None,
name: None,
categories: None,
iso_countries: None,
iso_regions: None,
municipalities: None,
bounds: None,
metars: None,
}
}
}
#[derive(Debug, Deserialize)]
pub struct Bounds {
pub north_east_lat: f32,
pub north_east_lon: f32,
pub south_west_lat: f32,
pub south_west_lon: f32,
}
impl Bounds {
fn parse(input: &str) -> ApiResult<Bounds> {
let parts: Vec<&str> = input.split(',').collect();
if parts.len() != 4 {
return Err(Error::new(
400,
format!("Expected 4 fields in bounds but received {}", parts.len()),
));
}
let north_east_lat = parts[0].trim().parse::<f32>()?;
let north_east_lon = parts[1].trim().parse::<f32>()?;
let south_west_lat = parts[2].trim().parse::<f32>()?;
let south_west_lon = parts[3].trim().parse::<f32>()?;
Ok(Bounds {
north_east_lat,
north_east_lon,
south_west_lat,
south_west_lon,
})
}
}
#[derive(Debug, Deserialize, sqlx::FromRow)]
struct AirportRow {
pub icao: String,
pub iata: Option<String>,
pub local: Option<String>,
pub name: String,
pub category: String,
pub iso_country: String,
pub iso_region: String,
pub municipality: String,
pub elevation_ft: f32,
longitude: f32,
latitude: f32,
pub has_tower: Option<bool>,
pub has_beacon: Option<bool>,
pub public: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAirport {
pub icao: Option<String>,
pub iata: Option<String>,
pub local: Option<String>,
pub name: Option<String>,
pub category: Option<AirportCategory>,
pub iso_country: Option<String>,
pub iso_region: Option<String>,
pub municipality: Option<String>,
pub elevation_ft: Option<f32>,
pub longitude: Option<f32>,
pub latitude: Option<f32>,
pub has_tower: Option<bool>,
pub has_beacon: Option<bool>,
pub runways: Option<Vec<UpdateRunway>>,
pub frequencies: Option<Vec<UpdateFrequency>>,
pub public: Option<bool>,
}
impl Into<AirportRow> for Airport {
fn into(self) -> AirportRow {
AirportRow {
icao: self.icao.clone(),
iata: self.iata.clone(),
local: self.local.clone(),
name: self.name.clone(),
category: self.category.clone().to_string(),
iso_country: self.iso_country.clone(),
iso_region: self.iso_region.clone(),
municipality: self.municipality.clone(),
elevation_ft: self.elevation_ft,
longitude: self.longitude,
latitude: self.latitude,
has_tower: self.has_tower,
has_beacon: self.has_beacon,
public: self.public,
}
}
}
impl From<AirportRow> for Airport {
fn from(airport: AirportRow) -> Self {
Self {
icao: airport.icao.clone(),
iata: airport.iata.clone(),
local: airport.local.clone(),
name: airport.name.clone(),
category: match AirportCategory::from_str(&airport.category) {
Ok(c) => c,
Err(_) => {
log::error!("Invalid Airport category: {}", airport.category);
AirportCategory::Unknown
}
},
iso_country: airport.iso_country.clone(),
iso_region: airport.iso_region.clone(),
municipality: airport.municipality.clone(),
elevation_ft: airport.elevation_ft,
longitude: airport.longitude,
latitude: airport.latitude,
has_tower: airport.has_tower,
has_beacon: airport.has_beacon,
runways: vec![],
frequencies: vec![],
public: airport.public,
latest_metar: None,
}
}
}
impl Airport {
pub async fn select(client: &Client, icao: &str, metar: bool) -> Option<Self> {
let pool = db::pool();
let airport_fut = async {
sqlx::query_as(&format!("SELECT * FROM {} WHERE icao = $1", TABLE_NAME))
.bind(icao)
.fetch_optional(pool)
.await
};
let metar_fut = async {
if metar {
match Metar::find_all(client, &vec![icao.to_string()], &false).await {
Ok(m) => Some(m.into_iter().nth(0)),
Err(err) => {
log::error!("{}", err);
None
}
}
} else {
None
}
};
let runways_fut = Runway::select_all(icao);
let frequencies_fut = Frequency::select_all(icao);
let (airport_result, runways_result, frequencies_result, metar_result) =
tokio::join!(airport_fut, runways_fut, frequencies_fut, metar_fut);
let airport_row: Option<AirportRow> = match airport_result {
Ok(opt) => opt,
Err(err) => {
log::error!("Unable to find airport '{}': {}", icao, err);
return None;
}
};
let runways: Vec<Runway> = match runways_result {
Ok(r) => r,
Err(err) => {
log::error!("Error retrieving runways for airport '{}': {}", icao, err);
vec![]
}
};
let frequencies: Vec<Frequency> = match frequencies_result {
Ok(f) => f,
Err(err) => {
log::error!(
"Error retrieving frequencies for airport '{}': {}",
icao,
err
);
vec![]
}
};
let metar: Option<Metar> = match metar_result {
Some(m_option) => match m_option {
Some(m) => Some(m),
None => None,
},
None => None,
};
airport_row.map(|row| {
let mut airport: Airport = row.into();
airport.runways = runways;
airport.frequencies = frequencies;
airport.latest_metar = metar;
airport
})
}
pub async fn select_all(client: &Client, query: &AirportQuery) -> ApiResult<Vec<Self>> {
let pool = db::pool();
let mut builder = QueryBuilder::<Postgres>::new("SELECT * FROM ");
builder.push(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);
Self::push_condition_array(
&mut builder,
&mut has_where,
"iso_country",
&query.iso_countries,
);
Self::push_condition_array(
&mut builder,
&mut has_where,
"iso_region",
&query.iso_regions,
);
Self::push_condition_array(
&mut builder,
&mut has_where,
"municipality",
&query.municipalities,
);
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.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)?;
// Order by AircraftCategory
builder.push(" ORDER BY CASE category ");
builder.push(" WHEN 'large_airport' THEN 1 ");
builder.push(" WHEN 'medium_airport' THEN 2 ");
builder.push(" WHEN 'small_airport' THEN 3 ");
builder.push(" WHEN 'seaplane_base' THEN 4 ");
builder.push(" WHEN 'heliport' THEN 5 ");
builder.push(" WHEN 'balloon_port' THEN 6 ");
builder.push(" WHEN 'unknown' THEN 7 ");
builder.push(" ELSE 8 END");
// Apply pagination.
if let Some(limit) = query.limit {
builder.push(" LIMIT ").push_bind(limit as i64);
let offset = if let Some(page) = query.page {
(page.saturating_sub(1) * limit) as i64
} else {
0
};
builder.push(" OFFSET ").push_bind(offset);
}
let airport_query = builder.build_query_as::<AirportRow>();
let airport_rows: Vec<AirportRow> = airport_query.fetch_all(pool).await?;
let mut airports: Vec<Airport> = airport_rows.into_iter().map(From::from).collect();
if airports.is_empty() {
return Ok(airports);
}
// Bulk update airport sub-fields
let icaos: Vec<String> = airports.iter().map(|a| a.icao.clone()).collect();
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(client, &icaos, &false))
} else {
None
};
let (runway_map, frequency_map, mut metars_opt) = match metar_future {
Some(future_metars) => {
let (runway_map, frequency_map, metars) =
try_join!(runway_future, frequency_future, future_metars)?;
(
runway_map,
frequency_map,
Some(
metars
.into_iter()
.map(|m| (m.station_id.clone(), m))
.collect::<HashMap<_, _>>(),
),
)
}
None => {
let (runway_map, frequency_map) = try_join!(runway_future, frequency_future)?;
(runway_map, frequency_map, None)
}
};
for airport in airports.iter_mut() {
airport.runways = runway_map.get(&airport.icao).cloned().unwrap_or_default();
airport.frequencies = frequency_map
.get(&airport.icao)
.cloned()
.unwrap_or_default();
if let Some(ref mut metar_map) = metars_opt {
airport.latest_metar = metar_map.remove(&airport.icao);
}
}
Ok(airports)
}
pub async fn count(query: &AirportQuery) -> i64 {
let pool = db::pool();
let mut builder = QueryBuilder::<Postgres>::new("SELECT COUNT(*) FROM ");
builder.push(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);
Self::push_condition_array(
&mut builder,
&mut has_where,
"iso_country",
&query.iso_countries,
);
Self::push_condition_array(
&mut builder,
&mut has_where,
"iso_region",
&query.iso_regions,
);
Self::push_condition_array(
&mut builder,
&mut has_where,
"municipality",
&query.municipalities,
);
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.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);
if let Err(err) = Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds) {
log::error!("Error parsing bounds string: {}", err);
return 0;
}
let sql_query = builder.build_query_scalar();
sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0)
}
pub async fn insert(&self) -> ApiResult<Self> {
let pool = db::pool();
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<FrequencyRow> = Vec::new();
for runway in &self.runways {
all_runway_rows.push(Runway::into(runway, &self.icao));
}
for frequency in &self.frequencies {
all_frequency_rows.push(Frequency::into(frequency, &self.icao));
}
Runway::insert_all(&all_runway_rows).await?;
Frequency::insert_all(&all_frequency_rows).await?;
let airport: AirportRow = sqlx::query_as(&format!(
r#"
INSERT INTO {} (
icao, iata, local, name, category, iso_country, iso_region, municipality,
elevation_ft, longitude, latitude, has_tower, has_beacon, public
)
VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13, $14
)
RETURNING *
"#,
TABLE_NAME,
))
.bind(self.icao.to_string())
.bind(&self.iata)
.bind(&self.local)
.bind(self.name.to_string())
.bind(self.category.to_string())
.bind(self.iso_country.to_string())
.bind(self.iso_region.to_string())
.bind(self.municipality.to_string())
.bind(self.elevation_ft)
.bind(self.longitude)
.bind(self.latitude)
.bind(self.has_tower)
.bind(self.has_beacon)
.bind(self.public)
.fetch_one(pool)
.await?;
Ok(airport.into())
}
pub async fn insert_all(airports: Vec<Self>) -> ApiResult<()> {
let pool = db::pool();
let chunk_size = 1000;
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
let mut all_frequency_rows: Vec<FrequencyRow> = Vec::new();
let airport_rows: Vec<AirportRow> = airports
.into_iter()
.map(|airport| {
for runway in &airport.runways {
all_runway_rows.push(Runway::into(runway, &airport.icao));
}
for frequency in &airport.frequencies {
all_frequency_rows.push(Frequency::into(frequency, &airport.icao));
}
airport.into()
})
.collect();
Runway::insert_all(&all_runway_rows).await?;
Frequency::insert_all(&all_frequency_rows).await?;
for chunk in airport_rows.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
"INSERT INTO airports (icao, iata, local, name, category, \
iso_country, iso_region, municipality, elevation_ft, \
longitude, latitude, has_tower, has_beacon, public) ",
);
query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.icao)
.push_bind(&row.iata)
.push_bind(&row.local)
.push_bind(&row.name)
.push_bind(&row.category)
.push_bind(&row.iso_country)
.push_bind(&row.iso_region)
.push_bind(&row.municipality)
.push_bind(row.elevation_ft)
.push_bind(row.longitude)
.push_bind(row.latitude)
.push_bind(row.has_tower)
.push_bind(row.has_beacon)
.push_bind(row.public);
});
let query = query_builder.build();
query.execute(pool).await?;
}
Ok(())
}
// TODO
pub async fn update(_icao: &str, _airport: &UpdateAirport) -> ApiResult<()> {
Ok(())
}
pub async fn delete(icao: &str) -> ApiResult<()> {
let pool = db::pool();
sqlx::query(&format!(
r#"
DELETE FROM {} WHERE icao = $1
"#,
TABLE_NAME
))
.bind(icao.to_string())
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_all() -> ApiResult<()> {
let pool = db::pool();
sqlx::query(&format!(
r#"
DELETE FROM {} WHERE true
"#,
TABLE_NAME
))
.execute(pool)
.await?;
Ok(())
}
fn push_condition_array<'a>(
builder: &mut QueryBuilder<'a, Postgres>,
has_where: &mut bool,
column: &str,
field: &'a Option<String>,
) {
if let Some(ref value_str) = field {
// Split on commas, trim whitespace, and drop empties.
let values: Vec<&str> = value_str
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !values.is_empty() {
if !*has_where {
builder.push(" WHERE ");
*has_where = true;
} else {
builder.push(" AND ");
}
builder.push(column);
builder.push(" = ANY(");
builder.push_bind(values);
builder.push(")");
}
}
}
fn push_condition_like<'a>(
builder: &mut QueryBuilder<'a, Postgres>,
has_where: &mut bool,
column: &str,
field: &'a Option<String>,
) {
// Query column like
if let Some(ref value) = field {
if !*has_where {
builder.push(" WHERE ");
*has_where = true;
} else {
builder.push(" AND ");
}
// Using ILIKE with wildcards for partial matching
builder
.push(column)
.push(" ILIKE ")
.push_bind(format!("%{}%", value));
}
}
fn push_condition_bounds<'a>(
builder: &mut QueryBuilder<'a, Postgres>,
has_where: &mut bool,
field: &'a Option<String>,
) -> ApiResult<()> {
// Query bounds
if let Some(ref bounds_string) = field {
if !*has_where {
builder.push(" WHERE ");
*has_where = true;
} else {
builder.push(" AND ");
}
let bounds = Bounds::parse(bounds_string)?;
builder
.push("(")
.push("latitude BETWEEN ")
.push_bind(bounds.south_west_lat)
.push(" AND ")
.push_bind(bounds.north_east_lat)
.push(" AND ")
.push("longitude BETWEEN ")
.push_bind(bounds.south_west_lon)
.push(" AND ")
.push_bind(bounds.north_east_lon)
.push(")");
}
Ok(())
}
}

View File

@@ -0,0 +1,54 @@
use std::fmt::Display;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AirportCategory {
#[serde(rename = "small_airport")]
Small,
#[serde(rename = "medium_airport")]
Medium,
#[serde(rename = "large_airport")]
Large,
#[serde(rename = "heliport")]
Heliport,
#[serde(rename = "closed")]
Closed,
#[serde(rename = "seaplane_base")]
Seaplane,
#[serde(rename = "balloon_port")]
BalloonPort,
#[serde(rename = "unknown")]
Unknown,
}
impl FromStr for AirportCategory {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"small_airport" => Ok(AirportCategory::Small),
"medium_airport" => Ok(AirportCategory::Medium),
"large_airport" => Ok(AirportCategory::Large),
"heliport" => Ok(AirportCategory::Heliport),
"closed" => Ok(AirportCategory::Closed),
"seaplane_base" => Ok(AirportCategory::Seaplane),
"balloon_port" => Ok(AirportCategory::BalloonPort),
_ => Ok(AirportCategory::Unknown),
}
}
}
impl Display for AirportCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AirportCategory::Small => write!(f, "small_airport"),
AirportCategory::Medium => write!(f, "medium_airport"),
AirportCategory::Large => write!(f, "large_airport"),
AirportCategory::Heliport => write!(f, "heliport"),
AirportCategory::Closed => write!(f, "closed"),
AirportCategory::Seaplane => write!(f, "seaplane_base"),
AirportCategory::BalloonPort => write!(f, "balloon_port"),
AirportCategory::Unknown => write!(f, "unknown"),
}
}
}

View File

@@ -0,0 +1,115 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
use crate::db;
use crate::error::ApiResult;
const TABLE_NAME: &str = "frequencies";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frequency {
#[serde(rename = "id")]
pub frequency_id: String,
pub frequency_mhz: f32,
}
#[derive(Debug, Deserialize, sqlx::FromRow)]
pub struct FrequencyRow {
pub id: Uuid,
pub icao: String,
pub frequency_id: String,
pub frequency_mhz: f32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateFrequency {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>,
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
pub frequency_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_mhz: Option<f32>,
}
impl From<FrequencyRow> for Frequency {
fn from(frequency: FrequencyRow) -> Self {
Self {
frequency_id: frequency.frequency_id.clone(),
frequency_mhz: frequency.frequency_mhz,
}
}
}
impl Frequency {
pub fn into(frequency: &Frequency, icao: &str) -> FrequencyRow {
FrequencyRow {
id: Uuid::new_v4(),
icao: icao.to_string(),
frequency_id: frequency.frequency_id.clone(),
frequency_mhz: frequency.frequency_mhz.clone(),
}
}
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool();
let frequency_rows: Vec<FrequencyRow> = sqlx::query_as(&format!(
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
TABLE_NAME
))
.bind(&icaos)
.fetch_all(pool)
.await?;
let mut frequency_map: HashMap<String, Vec<Self>> = HashMap::new();
for frequency_row in frequency_rows {
let icao = frequency_row.icao.clone();
let frequency = frequency_row.into();
frequency_map
.entry(icao.to_string())
.or_default()
.push(frequency);
}
Ok(frequency_map)
}
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
let pool = db::pool();
let frequency_row: Vec<FrequencyRow> = sqlx::query_as(&format!(
r#"
SELECT * FROM {} WHERE icao = $1
"#,
TABLE_NAME
))
.bind(icao)
.fetch_all(pool)
.await?;
Ok(frequency_row.into_iter().map(From::from).collect())
}
pub async fn insert_all(frequencies: &Vec<FrequencyRow>) -> ApiResult<()> {
let pool = db::pool();
let chunk_size = 1000;
for chunk in frequencies.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
"INSERT INTO {} (id, icao, frequency_id, 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_mhz);
});
let query = query_builder.build();
query.execute(pool).await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,9 @@
mod airport;
mod airport_category;
mod frequency;
mod runway;
pub use airport::*;
pub use airport_category::*;
pub use frequency::*;
pub use runway::*;

View File

@@ -0,0 +1,126 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
use crate::db;
use crate::error::ApiResult;
const TABLE_NAME: &str = "runways";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Runway {
#[serde(rename = "id")]
pub runway_id: String,
pub length_ft: f32,
pub width_ft: f32,
pub surface: String,
}
#[derive(Debug, Deserialize, sqlx::FromRow)]
pub struct RunwayRow {
pub id: Uuid,
pub icao: String,
pub runway_id: String,
pub length_ft: f32,
pub width_ft: f32,
pub surface: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateRunway {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>,
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
pub frequency_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub length_ft: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width_ft: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
}
impl From<RunwayRow> for Runway {
fn from(runway: RunwayRow) -> Self {
Self {
runway_id: runway.runway_id.clone(),
length_ft: runway.length_ft.clone(),
width_ft: runway.width_ft.clone(),
surface: runway.surface.clone(),
}
}
}
impl Runway {
pub fn into(runway: &Runway, icao: &str) -> RunwayRow {
RunwayRow {
id: Uuid::new_v4(),
icao: icao.to_string(),
runway_id: runway.runway_id.clone(),
length_ft: runway.length_ft.clone(),
width_ft: runway.width_ft.clone(),
surface: runway.surface.clone(),
}
}
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool();
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
TABLE_NAME
))
.bind(&icaos)
.fetch_all(pool)
.await?;
let mut runway_map: HashMap<String, Vec<Self>> = HashMap::new();
for runway_row in runway_rows {
let icao = runway_row.icao.clone();
let runway = runway_row.into();
runway_map.entry(icao.to_string()).or_default().push(runway);
}
Ok(runway_map)
}
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
let pool = db::pool();
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
r#"
SELECT * FROM {} WHERE icao = $1
"#,
TABLE_NAME
))
.bind(icao)
.fetch_all(pool)
.await?;
Ok(runway_rows.into_iter().map(From::from).collect())
}
pub async fn insert_all(runways: &Vec<RunwayRow>) -> ApiResult<()> {
let pool = db::pool();
let chunk_size = 1000;
for chunk in runways.chunks(chunk_size) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
"INSERT INTO {} (id, icao, runway_id, length_ft, width_ft, surface) ",
TABLE_NAME
));
query_builder.push_values(chunk, |mut b, row| {
b.push_bind(&row.id)
.push_bind(&row.icao)
.push_bind(&row.runway_id)
.push_bind(&row.length_ft)
.push_bind(&row.width_ft)
.push_bind(&row.surface);
});
let query = query_builder.build();
query.execute(pool).await?;
}
Ok(())
}
}

View File

@@ -1,33 +1,19 @@
use std::str::FromStr;
use futures_util::stream::StreamExt as _;
use crate::{
airports::{QueryAirport, QueryFilters, QueryOrderField, QueryOrderBy, Airport, AirportCategory},
db::{Response, Metadata},
airports::Airport,
db::Paged,
auth::{Auth, verify_role},
AppState,
};
use actix_multipart::Multipart;
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
use log::{error, warn};
use postgis_diesel::types::{Polygon, Point};
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct AirportsQuery {
icaos: Option<String>,
name: Option<String>,
bounds: Option<String>,
categories: Option<String>,
order_field: Option<String>,
order_by: Option<String>,
has_metar: Option<String>,
limit: Option<i32>,
page: Option<i32>,
}
use crate::airports::{AirportQuery, UpdateAirport};
use crate::users::ADMIN_ROLE;
#[post("/import")]
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, "admin") {
if let Err(err) = verify_role(&auth, ADMIN_ROLE) {
return ResponseError::error_response(&err);
};
@@ -43,7 +29,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
let data = match chunk {
Ok(data) => data,
Err(err) => {
error!("Failed to get chunk: {}", err);
log::error!("Failed to get chunk: {}", err);
return ResponseError::error_response(&err);
}
};
@@ -54,14 +40,12 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
let airports: Vec<Airport> = match serde_json::from_slice(&bytes) {
Ok(a) => a,
Err(err) => {
error!("Failed to parse JSON: {}", err);
log::error!("Failed to parse JSON: {}", err);
return ResponseError::error_response(&err);
}
};
// Convert Vec<Airport> to Vec<QueryAirport> and insert into database
let query_airports: Vec<QueryAirport> = airports.into_iter().map(|a| a.into()).collect();
match QueryAirport::insert_all(query_airports) {
match Airport::insert_all(airports).await {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
@@ -70,221 +54,120 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
}
#[get("")]
async fn get_airports(req: HttpRequest) -> HttpResponse {
let params = web::Query::<AirportsQuery>::from_query(req.query_string()).unwrap();
let mut filters = QueryFilters::default();
filters.icaos = match &params.icaos {
Some(i) => Some(i.split(",").map(|s| s.to_string()).collect()),
None => None,
};
filters.name = params.name.clone();
filters.categories = match &params.categories {
Some(c) => Some(
c.split(",")
.map(|s| AirportCategory::from_str(s).unwrap())
.collect(),
),
None => None,
};
filters.bounds = match &params.bounds {
Some(b) => {
let bounds: Vec<&str> = b.split(",").collect();
if bounds.len() != 4 {
warn!("Expected 4 bounds, received {}: {}", bounds.len(), b);
return HttpResponse::UnprocessableEntity().body(format!(
"Received {}; expected NE_LAT,NE_LON,SW_LAT,SW_LON",
b
));
}
let ne_lat = match bounds[0].parse::<f64>() {
Ok(b) => b,
async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.into_inner(),
Err(err) => {
warn!("{}", err);
return HttpResponse::UnprocessableEntity().body(format!("{}", err));
log::error!("{}", err);
AirportQuery::default()
}
};
let ne_lon = match bounds[1].parse::<f64>() {
Ok(b) => b,
Err(err) => {
warn!("{}", err);
return HttpResponse::UnprocessableEntity().body(format!("{}", err));
}
};
let sw_lat = match bounds[2].parse::<f64>() {
Ok(b) => b,
Err(err) => {
warn!("{}", err);
return HttpResponse::UnprocessableEntity().body(format!("{}", err));
}
};
let sw_lon = match bounds[3].parse::<f64>() {
Ok(b) => b,
Err(err) => {
warn!("{}", err);
return HttpResponse::UnprocessableEntity().body(format!("{}", err));
}
};
let mut polygon: Polygon<Point> = Polygon::new(Some(4326));
polygon.add_point(Point {
x: sw_lon,
y: sw_lat,
srid: Some(4326),
});
polygon.add_point(Point {
x: ne_lon,
y: sw_lat,
srid: Some(4326),
});
polygon.add_point(Point {
x: ne_lon,
y: ne_lat,
srid: Some(4326),
});
polygon.add_point(Point {
x: sw_lon,
y: ne_lat,
srid: Some(4326),
});
polygon.add_point(Point {
x: sw_lon,
y: sw_lat,
srid: Some(4326),
});
Some(polygon)
}
None => None,
};
filters.order_by = match &params.order_by {
Some(o) => Some(QueryOrderBy::from_str(&o).unwrap()),
None => None,
};
filters.order_field = match &params.order_field {
Some(o) => Some(QueryOrderField::from_str(&o).unwrap()),
None => None,
};
filters.has_metar = match &params.has_metar {
Some(h) => Some(h.parse::<bool>().unwrap()),
None => None,
};
let limit = match params.limit {
Some(l) => l,
None => 100,
};
let page = match params.page {
Some(p) => p,
None => 1,
};
let total = match QueryAirport::get_count(&filters) {
Ok(t) => t,
Err(_) => 0,
};
match web::block(move || QueryAirport::get_all(&filters, limit, page))
.await
.unwrap()
{
Ok(a) => {
// Convert Vec<QueryAirport> to Vec<Airport>
let mut airports: Vec<Airport> = vec![];
for airport in a {
airports.push(airport.into());
let total = Airport::count(&query).await;
let page = query.page.unwrap_or(1);
let mut limit = query.limit.unwrap_or(total as u32);
if limit > 1000 {
limit = 1000
}
HttpResponse::Ok().json(Response {
query.limit = Some(limit);
query.page = Some(page);
let client = &data.client;
match Airport::select_all(client, &query).await {
Ok(airports) => HttpResponse::Ok().json(Paged {
data: airports,
meta: Some(Metadata { page, limit, total }),
})
}
page,
limit,
total,
}),
Err(err) => {
error!("{}", err);
err.to_http_response()
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[get("/{icao}")]
async fn get_airport(icao: web::Path<String>) -> HttpResponse {
match QueryAirport::get(&icao.into_inner()) {
Ok(a) => {
let airport: Airport = a.into();
HttpResponse::Ok().json(airport)
}
async fn get_airport(
data: web::Data<AppState>,
icao: web::Path<String>,
req: HttpRequest,
) -> HttpResponse {
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.metars.unwrap_or_else(|| false),
Err(err) => {
error!("{}", err);
err.to_http_response()
log::error!("{}", err);
false
}
};
let client = &data.client;
match Airport::select(client, &icao.into_inner(), metar).await {
Some(airport) => HttpResponse::Ok().json(airport),
None => HttpResponse::NotFound().finish(),
}
}
#[post("")]
async fn create_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
let query_airport: QueryAirport = airport.into_inner().into();
match QueryAirport::insert(query_airport) {
Ok(a) => {
let airport: Airport = a.into();
HttpResponse::Ok().json(airport)
}
match airport.insert().await {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
error!("{}", err);
err.to_http_response()
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[put("/{icao}")]
async fn update_airport(
_icao: web::Path<String>,
airport: web::Json<Airport>,
icao: web::Path<String>,
airport: web::Json<UpdateAirport>,
auth: Auth,
) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
let query_airport: QueryAirport = airport.into_inner().into();
match QueryAirport::update(query_airport) {
Ok(a) => {
let airport: Airport = a.into();
HttpResponse::Ok().json(airport)
}
match Airport::update(&icao.into_inner(), &airport.into_inner()).await {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
error!("{}", err);
err.to_http_response()
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[delete("")]
async fn delete_airports(auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
match QueryAirport::delete(None) {
match Airport::delete_all().await {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
error!("{}", err);
err.to_http_response()
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[delete("/{icao}")]
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
let _ = match verify_role(&auth, ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
match QueryAirport::delete(Some(icao.into_inner())) {
match Airport::delete(&icao.into_inner()).await {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
error!("{}", err);
err.to_http_response()
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
@@ -295,7 +178,7 @@ pub fn init_routes(config: &mut web::ServiceConfig) {
.service(import_airports)
.service(get_airports)
.service(get_airport)
.service(create_airport)
.service(insert_airport)
.service(update_airport)
.service(delete_airports)
.service(delete_airport),

View File

@@ -2,6 +2,7 @@ use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use rand::distr::Alphanumeric;
use rand::prelude::*;
use rand_chacha::ChaCha20Rng;
@@ -13,46 +14,63 @@ pub use model::*;
pub use session::*;
pub use routes::init_routes;
use crate::error::{ApiError, ApiResult};
use crate::error::{Error, ApiResult};
pub const SESSION_COOKIE_NAME: &str = "session";
pub fn csprng_128bit(take: usize) -> String {
pub fn csprng(take: usize) -> String {
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
let rng = ChaCha20Rng::from_entropy();
let rng = ChaCha20Rng::from_os_rng();
rng
.sample_iter(rand::distributions::Alphanumeric)
.sample_iter(Alphanumeric)
.take(take)
.map(char::from)
.collect()
}
pub fn hash(str: &str) -> ApiResult<String> {
pub fn hash(string: &str) -> ApiResult<String> {
let salt = SaltString::generate(&mut OsRng);
let bytes = str.as_bytes();
let hash = Argon2::default().hash_password(bytes, &salt)?.to_string();
let hash = Argon2::default()
.hash_password(string.as_bytes(), &salt)?
.to_string();
Ok(hash)
}
pub fn verify_hash(str: &str, hash: &str) -> bool {
let bytes = str.as_bytes();
let parsed_hash = match PasswordHash::new(hash) {
pub fn verify_hash(string: &str, hashed_string: &str) -> bool {
let bytes = string.as_bytes();
let parsed_hash = match PasswordHash::new(hashed_string) {
Ok(h) => h,
Err(_) => return false,
};
match Argon2::default().verify_password(bytes, &parsed_hash) {
Ok(_) => true,
Err(_) => false,
Err(err) => {
log::error!(
"Failed to construct PasswordHash from '{}': {}",
hashed_string,
err
);
return false;
}
};
Argon2::default()
.verify_password(bytes, &parsed_hash)
.is_ok()
}
pub fn verify_role(auth: &Auth, role: &str) -> ApiResult<()> {
if auth.user.role == role {
Ok(())
} else {
Err(ApiError {
Err(Error {
status: 403,
message: "User does not have permission to perform this action.".to_string(),
details: "User does not have permission to perform this action.".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash() {
let password = hash("password").unwrap();
assert!(!verify_hash(&password, "bad_password"));
assert!(verify_hash("password", &password));
}
}

View File

@@ -3,17 +3,14 @@ use std::pin::Pin;
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http};
use serde::{Serialize, Deserialize};
use crate::{
error::ApiError,
users::{User, UserResponse},
};
use crate::{error::Error, users::User};
use super::{Session, SESSION_COOKIE_NAME};
#[derive(Debug, Serialize, Deserialize)]
pub struct Auth {
pub session_id: Option<String>,
pub user: UserResponse,
pub api_key: Option<String>,
pub user: User,
}
impl FromRequest for Auth {
@@ -21,7 +18,37 @@ impl FromRequest for Auth {
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
// Get session ID from request
// Check for API key
match req
.headers()
.get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
{
Some(key_id) => {
let fut = async move {
// Check if the Session API key exists
let api_key = match Session::get(&key_id).await {
Ok(session) => session,
Err(err) => {
log::error!("Invalid session auth attempt: {}", err);
return Err(Error::new(401, "API Key does not exist".to_string()).into());
}
};
match User::select(&api_key.email).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()),
}
};
return Box::pin(fut);
}
None => {}
};
// Check for session
let session_id = match req
.cookie(SESSION_COOKIE_NAME)
.map(|c| c.value().to_string())
@@ -35,9 +62,9 @@ impl FromRequest for Auth {
None => {
let fut = async {
Err(
ApiError {
Error {
status: 401,
message: "No session ID found in the request".to_string(),
details: "No session ID found in the request".to_string(),
}
.into(),
)
@@ -52,12 +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::get_by_email(&session.email) {
Ok(user) => Ok(Auth {
Ok(session) => match User::select(&session.email).await {
Some(user) => Ok(Auth {
session_id: Some(session_id),
user: user.into(),
api_key: None,
user,
}),
Err(err) => Err(err.into()),
None => Err(Error::new(404, format!("User {} not found", session.email)).into()),
},
Err(err) => Err(err.into()),
}

View File

@@ -1,34 +1,45 @@
use actix_web::{
post, web, HttpResponse, ResponseError,
cookie::{Cookie, time::Duration},
HttpRequest,
};
use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get};
use crate::{
auth::{verify_hash, Session, SESSION_COOKIE_NAME},
error::ApiError,
error::Error,
users::{LoginRequest, RegisterRequest, User, UserResponse},
};
use crate::auth::Auth;
use crate::users::UpdateUser;
#[post("/register")]
async fn register(user: web::Json<RegisterRequest>) -> HttpResponse {
let register_user = user.0;
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner();
let email = register_user.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string();
let insert_user: User = match register_user.to_user() {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
match User::insert(insert_user) {
match insert_user.insert().await {
Ok(user) => {
let response: UserResponse = user.into();
HttpResponse::Created().json(response)
},
let user_response: UserResponse = user.into();
log::info!(
"Successful user registration [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Created().json(user_response)
}
Err(err) => {
// Obfuscate the service error message to prevent leaking database details
if err.status == 409 {
return HttpResponse::Conflict().finish();
log::warn!(
"Duplicate user registration attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Conflict().finish()
} else {
return ResponseError::error_response(&err);
log::error!("attemptFailed to register user [Email: {}]: {}", email, err);
ResponseError::error_response(&err)
}
}
}
@@ -36,63 +47,186 @@ async fn register(user: web::Json<RegisterRequest>) -> HttpResponse {
#[post("/login")]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = request.email.clone();
let email = &request.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match User::get_by_email(&email) {
Ok(query_user) => query_user,
Err(err) => {
log::error!("{}", err);
return ResponseError::error_response(&err);
}
let query_user = match User::select(&email).await {
Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(),
};
if verify_hash(&query_user.hash, &request.password) {
if verify_hash(&request.password, &query_user.password_hash) {
// Create a session
let session = Session::new(&email, &ip_address);
let session = Session::default(&email, &ip_address);
let session_cookie = session.cookie();
// Save the session to the database
if let Err(err) = session.store().await {
log::error!("Failed to store session");
return ResponseError::error_response(&ApiError::new(500, err.to_string()));
log::error!(
"Login attempt failure [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
return HttpResponse::Ok().cookie(session_cookie).finish();
log::info!(
"Successful login attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
let user_response: UserResponse = query_user.into();
HttpResponse::Ok()
.cookie(session_cookie)
.json(user_response)
} else {
log::error!("Invalid login attempt for {}", email);
return HttpResponse::Unauthorized().finish();
log::error!(
"Invalid login attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Unauthorized().finish()
}
}
#[post("/logout")]
async fn logout(req: HttpRequest, _auth: Auth) -> HttpResponse {
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Delete the session from the store
match req.cookie(SESSION_COOKIE_NAME) {
Some(cookie) => {
let session_id = cookie.value().to_string();
if let Err(err) = Session::delete(&session_id).await {
log::error!("Failed to delete session");
return ResponseError::error_response(&ApiError::new(500, err.to_string()));
log::error!(
"Logout attempt failure [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
}
None => {
return ResponseError::error_response(&ApiError::new(400, "Invalid session".to_string()));
log::error!(
"Invalid logout attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
}
}
let session_cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(true)
.finish();
log::info!(
"Successful logout attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Ok().cookie(Session::empty_cookie()).finish()
}
HttpResponse::Ok().cookie(session_cookie).finish()
#[get("/session")]
async fn validate_session(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::replace(&session_id, &ip_address).await {
Ok(session) => session,
Err(err) => {
log::error!(
"Invalid session validate attempt [Session: {}] [IP Address: {}]",
session_id,
ip_address
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
};
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 session_cookie = session.cookie();
log::info!(
"Successful session validate attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Ok()
.cookie(session_cookie)
.json(user_response)
}
None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.finish(),
}
}
#[put("/password")]
async fn change_password(
password: web::Json<String>,
req: HttpRequest,
auth: Auth,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let email = auth.user.email;
if let None = User::select(&email).await {
return HttpResponse::Unauthorized().finish();
};
let update_user = UpdateUser {
email: None,
password: Some(password.into_inner()),
role: None,
first_name: None,
last_name: None,
};
match update_user.update(&email).await {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful password change attempt [Email: {}] [IP Address: {}]",
&email,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}",
&email,
ip_address,
err
);
ResponseError::error_response(&Error::new(500, err.to_string()))
}
}
}
#[post("/password-reset")]
async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse {
let _ip_address = req.peer_addr().unwrap().ip().to_string();
HttpResponse::Ok().finish()
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(
web::scope("auth")
web::scope("account")
.service(register)
.service(login)
.service(logout),
.service(logout)
.service(change_password)
.service(validate_session),
);
}

View File

@@ -2,15 +2,14 @@ use actix_web::cookie::{time::Duration, Cookie};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use tokio::task;
use crate::{
db::redis_async_connection,
error::{ApiError, ApiResult},
error::{Error, ApiResult},
};
use super::{csprng, hash, verify_hash};
use super::{csprng_128bit, hash, verify_hash};
pub const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
#[derive(Debug, Serialize, Deserialize)]
@@ -18,17 +17,25 @@ pub struct Session {
pub session_id: String,
pub email: String,
pub ip_address: String,
pub expires_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl Session {
pub fn new(email: &str, ip_address: &str) -> Self {
let now = chrono::Utc::now();
pub fn default(email: &str, ip_address: &str) -> Self {
Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL))
}
pub fn new(take: usize, email: &str, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now();
Self {
session_id: csprng_128bit(32),
session_id: csprng(take),
email: email.to_string(),
ip_address: hash(&ip_address).unwrap(),
expires_at: now + chrono::Duration::seconds(DEFAULT_SESSION_TTL),
expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
None => None,
},
}
}
@@ -36,23 +43,45 @@ impl Session {
let mut conn = redis_async_connection().await?;
let key = self.session_id.clone();
let value = serde_json::to_string(self)?;
let result: RedisResult<()> = conn.set_ex(key, &value, DEFAULT_SESSION_TTL as u64).await;
let result: RedisResult<()> = match self.expires_at {
Some(expires_at) => {
let ttl = expires_at.timestamp() - Utc::now().timestamp();
conn.set_ex(key, &value, ttl as u64).await
}
None => conn.set(key, value).await,
};
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(session_id: &str) -> ApiResult<Option<Self>> {
pub async fn get(session_id: &str) -> ApiResult<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(session_id).await;
match result {
Ok(Some(value)) => Ok(Some(serde_json::from_str(&value)?)),
Ok(None) => Ok(None),
Ok(Some(value)) => Ok(serde_json::from_str(&value)?),
Ok(None) => Err(Error::new(401, format!("Missing session {}", session_id))),
Err(err) => Err(err.into()),
}
}
pub async fn replace(session_id: &str, ip_address: &str) -> ApiResult<Self> {
let mut session = Self::verify(session_id, ip_address).await?;
let session_id_owned = session_id.to_owned();
task::spawn(async move {
if let Err(err) = Self::delete(&session_id_owned).await {
log::error!(
"Error deleting old session in replace session call: {}",
err
);
};
});
session = Session::default(&session.email, ip_address);
session.store().await?;
Ok(session)
}
pub async fn delete(session_id: &str) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<()> = conn.del(session_id).await;
@@ -63,29 +92,59 @@ impl Session {
}
pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult<Self> {
// Check if the session exists
let session = match Self::get(session_id).await? {
Some(session) => session,
None => return Err(ApiError::new(401, "Session does not exist".to_string())),
};
let session = Self::get(session_id).await?;
// Check if the IP Address matches the Session's IP Address
if verify_hash(ip_address, &session.ip_address) {
return Ok(session);
Ok(session)
} else {
return Err(ApiError::new(
401,
"IP Address does not match".to_string(),
));
Err(Error::new(401, "IP Address does not match".to_string()))
}
}
pub fn cookie(&self) -> Cookie {
Cookie::build(SESSION_COOKIE_NAME, self.session_id.clone())
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_COOKIE_NAME, self.session_id.clone())
.path("/")
.max_age(Duration::seconds(DEFAULT_SESSION_TTL))
.max_age(Duration::seconds(ttl))
.secure(true)
.http_only(true)
.finish()
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Development cookie [Email: {}]: {}",
self.email,
self.session_id
);
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
pub fn empty_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
}

View File

@@ -1,57 +1,75 @@
use crate::error::{ApiError, ApiResult};
use diesel::{r2d2::ConnectionManager, PgConnection};
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection};
use s3::{
Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData,
bucket_ops::CreateBucketResponse,
};
use crate::error::ApiResult;
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult};
use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData};
use serde::{Deserialize, Serialize};
use crate::diesel_migrations::MigrationHarness;
use lazy_static::lazy_static;
use log::{error, info, warn};
use r2d2;
use std::env;
use std::sync::OnceLock;
use std::time::Duration;
use sqlx::{Pool, Postgres};
use sqlx::postgres::PgPoolOptions;
pub mod schema;
static POOL: OnceLock<Pool<Postgres>> = OnceLock::new();
static REDIS: OnceLock<RedisClient> = OnceLock::new();
static BUCKET: OnceLock<Bucket> = OnceLock::new();
type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;
pub type DbConnection = r2d2::PooledConnection<ConnectionManager<PgConnection>>;
pub async fn initialize() -> ApiResult<()> {
// Setup Postgres pool connection
let pool = {
let user = std::env::var("POSTGRES_USER").unwrap_or("aviation".to_string());
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());
pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = embed_migrations!();
lazy_static! {
static ref POOL: Pool = {
let username = env::var("DATABASE_USER").expect("Database username is not set");
let password = env::var("DATABASE_PASSWORD").expect("Database password is not set");
let host = env::var("DATABASE_HOST").unwrap_or("localhost".to_string());
let name = env::var("DATABASE_NAME").expect("Database name is not set");
let port = env::var("DATABASE_PORT").unwrap_or("5432".to_string());
let url = format!(
let db_url = format!(
"postgres://{}:{}@{}:{}/{}",
username, password, host, port, name
&user, &password, &host, &port, &name
);
let manager = ConnectionManager::<PgConnection>::new(url);
Pool::builder()
.test_on_check_out(true)
.build(manager)
.expect("Failed to create db pool")
log::info!(
"Connecting to database at postgres://{}:*****@{}:{}/{}...",
&user,
&host,
&port,
&name
);
PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(30))
.connect(&db_url)
.await?
};
static ref REDIS: RedisClient = {
let host = env::var("REDIS_HOST").unwrap_or("localhost".to_string());
let port = env::var("REDIS_PORT").unwrap_or("6379".to_string());
match POOL.set(pool) {
Ok(_) => log::info!("Database connection established"),
Err(_) => log::warn!("Database pool already initialized"),
}
// Setup Redis connection
let redis = {
let host = std::env::var("REDIS_HOST").unwrap_or("localhost".to_string());
let port = std::env::var("REDIS_PORT").unwrap_or("6379".to_string());
let url = format!("redis://{}:{}", host, port);
log::info!("Connecting to redis at {}", &url);
RedisClient::open(url).expect("Failed to create redis client")
};
static ref BUCKET: Bucket = {
let url = env::var("MINIO_HOST").unwrap_or("localhost".to_string());
let port = env::var("MINIO_PORT").unwrap_or("9000".to_string());
let user = env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
let password = env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set");
let base_url = format!("http://{}:{}", url, port);
match REDIS.set(redis) {
Ok(_) => log::info!("Redis connection established"),
Err(_) => log::warn!("Redis client already initialized"),
}
// Setup Bucket connection
let bucket = {
let protocol = std::env::var("MINIO_PROTOCOL").unwrap_or("http".to_string());
let host = std::env::var("MINIO_HOST").unwrap_or("localhost".to_string());
let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string());
let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set");
let bucket_name = std::env::var("MINIO_BUCKET").unwrap_or("aviation".to_string());
let url = format!("{}://{}:{}", protocol, host, port);
let region = Region::Custom {
region: "".to_string(),
endpoint: base_url,
endpoint: url.to_string(),
};
let credentials = Credentials {
@@ -62,102 +80,93 @@ lazy_static! {
expiration: None,
};
*Bucket::new("aviation", region.clone(), credentials.clone())
.expect("Failed to create S3 Bucket")
.with_path_style()
};
}
pub async fn init() {
lazy_static::initialize(&POOL);
lazy_static::initialize(&REDIS);
lazy_static::initialize(&BUCKET);
match create_bucket().await {
Ok(_) => info!("Bucket initialized"),
Err(err) => match err.status {
409 => warn!("Bucket already exists"),
_ => error!("Failed to initialize bucket; {}", err),
},
};
let mut pool: DbConnection = connection().expect("Failed to get db connection");
match pool.run_pending_migrations(MIGRATIONS) {
Ok(_) => info!("Database initialized"),
Err(err) => error!("Failed to initialize database; {}", err),
};
}
pub fn connection() -> ApiResult<DbConnection> {
POOL
.get()
.map_err(|e| ApiError::new(500, format!("Failed getting db connection: {}", e)))
}
pub fn redis_connection() -> ApiResult<redis::Connection> {
let conn = REDIS.get_connection()?;
Ok(conn)
}
pub async fn redis_async_connection() -> ApiResult<RedisConnection> {
let conn = REDIS.get_multiplexed_async_connection().await?;
Ok(conn)
}
async fn create_bucket() -> ApiResult<CreateBucketResponse> {
let url = env::var("MINIO_URL").unwrap_or("localhost".to_string());
let port = env::var("MINIO_PORT").unwrap_or("9000".to_string());
let user = env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
let password = env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set");
let base_url = format!("http://{}:{}", url, port);
let region = Region::Custom {
region: "".to_string(),
endpoint: base_url,
};
let credentials = Credentials {
access_key: Some(user),
secret_key: Some(password),
security_token: None,
session_token: None,
expiration: None,
};
let bucket_name = "aviation";
let response = Bucket::create_with_path_style(
bucket_name,
let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style();
log::info!("Checking for object in bucket at {}", &region.endpoint());
match bucket.head_object("/").await {
Ok(_) => bucket,
Err(_) => {
log::debug!("Creating '{}' bucket", &bucket_name);
let response = match Bucket::create_with_path_style(
&bucket_name,
region,
credentials,
BucketConfiguration::default(),
)
.await?;
Ok(response)
.await
{
Ok(response) => response,
Err(err) => {
log::error!("Failed to create bucket '{}': {}", &bucket_name, err);
return Err(err.into());
}
};
response.bucket
}
}
};
match BUCKET.set(*bucket) {
Ok(_) => log::info!("Bucket connection initialized"),
Err(_) => log::warn!("Bucket connection already initialized"),
}
// Run migrations
match run_migrations().await {
Ok(_) => log::debug!("Successfully ran database migrations"),
Err(e) => log::error!("Failed to run migrations: {}", e),
}
log::info!("Database initialized");
Ok(())
}
pub fn pool() -> &'static Pool<Postgres> {
POOL.get().unwrap()
}
fn redis() -> &'static RedisClient {
REDIS.get().unwrap()
}
pub fn redis_connection() -> RedisResult<redis::Connection> {
let conn = redis().get_connection()?;
Ok(conn)
}
pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
let conn = redis().get_multiplexed_async_connection().await?;
Ok(conn)
}
async fn run_migrations() -> ApiResult<()> {
log::debug!("Running database migrations");
let pool = pool();
sqlx::migrate!().run(pool).await?;
Ok(())
}
pub async fn upload_file(path: &str, content: &[u8]) -> ApiResult<ResponseData> {
let response = BUCKET.put_object(path, content).await?;
let response = BUCKET.get().unwrap().put_object(path, content).await?;
Ok(response)
}
pub async fn get_file(path: &str) -> ApiResult<Vec<u8>> {
let response = BUCKET.get_object(path).await?;
let response = BUCKET.get().unwrap().get_object(path).await?;
let bytes = response.bytes();
Ok(bytes.to_vec())
}
pub async fn delete_file(path: &str) -> ApiResult<ResponseData> {
let response = BUCKET.delete_object(path).await?;
let response = BUCKET.get().unwrap().delete_object(path).await?;
Ok(response)
}
#[derive(Serialize, Deserialize)]
pub struct Response<T> {
pub struct Paged<T> {
pub data: T,
pub meta: Option<Metadata>,
}
#[derive(Serialize, Deserialize)]
pub struct Metadata {
pub page: i32,
pub limit: i32,
pub page: u32,
pub limit: u32,
pub total: i64,
}

View File

@@ -1,41 +0,0 @@
diesel::table! {
use diesel::sql_types::*;
use postgis_diesel::sql_types::*;
airports (icao) {
icao -> Text,
category -> Text,
name -> Text,
elevation_ft -> Float,
iso_country -> Text,
iso_region -> Text,
municipality -> Text,
has_metar -> Bool,
point -> Geometry,
data -> Jsonb
}
}
diesel::table! {
metars (id) {
id -> Integer,
icao -> Text,
observation_time -> Timestamp,
raw_text -> Text,
data -> Jsonb,
}
}
diesel::table! {
users (email) {
email -> Text,
hash -> Text,
role -> Text,
first_name -> Text,
last_name -> Text,
updated_at -> Timestamp,
created_at -> Timestamp,
profile_picture -> Nullable<Text>,
favorites -> Array<Text>,
verified -> Bool,
}
}

View File

@@ -1,50 +1,92 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use diesel::result::Error as DieselError;
use log::warn;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
pub type ApiResult<T> = Result<T, ApiError>;
pub type ApiResult<T> = Result<T, Error>;
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiError {
pub struct Error {
pub status: u16,
pub message: String,
pub details: String,
}
impl ApiError {
impl Error {
pub fn new(status: u16, message: String) -> Self {
Self { status, message }
Self {
status,
details: message,
}
}
pub fn to_http_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(s) => s,
Err(err) => {
let status = StatusCode::from_u16(self.status).unwrap_or_else(|err| {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
});
HttpResponse::build(status).body(self.details.to_string())
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.details.as_str())
}
}
impl std::error::Error for Error {
fn description(&self) -> &str {
&self.details
}
}
impl ResponseError for Error {
fn error_response(&self) -> HttpResponse {
let status =
StatusCode::from_u16(self.status).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
let status_code = status.as_u16();
let details = match status_code {
401 => String::from("Unauthorized"),
code if code < 500 => self.details.clone(),
_ => {
log::error!("Internal server error: {}", self.details);
String::from("Internal Server Error")
}
};
HttpResponse::build(status).body(self.message.to_string())
HttpResponse::build(status).json(json!({ "status": status_code, "details": details }))
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.message.as_str())
}
}
impl From<std::io::Error> for ApiError {
impl From<std::io::Error> for Error {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("Unknown IO error: {}", error))
}
}
impl From<std::env::VarError> for ApiError {
impl From<chrono::ParseError> for Error {
fn from(error: chrono::ParseError) -> Self {
Self::new(500, format!("Parse error: {}", error))
}
}
impl From<core::num::ParseIntError> for Error {
fn from(error: core::num::ParseIntError) -> Self {
Self::new(500, format!("Parse error: {}", error))
}
}
impl From<core::num::ParseFloatError> for Error {
fn from(error: core::num::ParseFloatError) -> Self {
Self::new(500, format!("Parse error: {}", error))
}
}
impl From<std::env::VarError> for Error {
fn from(error: std::env::VarError) -> Self {
Self::new(
500,
@@ -53,47 +95,42 @@ impl From<std::env::VarError> for ApiError {
}
}
impl From<DieselError> for ApiError {
fn from(error: DieselError) -> Self {
match error {
DieselError::DatabaseError(kind, err) => match kind {
diesel::result::DatabaseErrorKind::UniqueViolation => {
Self::new(409, err.message().to_string())
}
_ => Self::new(500, err.message().to_string()),
},
DieselError::NotFound => Self::new(404, "The record was not found".to_string()),
DieselError::SerializationError(err) => Self::new(422, err.to_string()),
err => Self::new(500, format!("Unknown Diesel error: {}", err)),
}
}
}
impl From<reqwest::Error> for ApiError {
impl From<reqwest::Error> 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)),
}
}
}
impl From<serde_json::Error> for ApiError {
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("Unknown serde_json error: {}", error))
}
}
impl From<argon2::password_hash::Error> for ApiError {
impl From<argon2::password_hash::Error> for Error {
fn from(error: argon2::password_hash::Error) -> Self {
Self::new(500, format!("Unknown argon2 error: {}", error))
}
}
impl From<redis::RedisError> for ApiError {
impl From<redis::RedisError> for Error {
fn from(error: redis::RedisError) -> Self {
Self::new(500, format!("Unknown redis error: {}", error))
}
}
impl From<s3::error::S3Error> for ApiError {
impl From<s3::error::S3Error> for Error {
fn from(error: s3::error::S3Error) -> Self {
match error {
s3::error::S3Error::Credentials(err) => {
@@ -102,9 +139,7 @@ impl From<s3::error::S3Error> for ApiError {
s3::error::S3Error::FromUtf8(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))
}
@@ -112,9 +147,7 @@ impl From<s3::error::S3Error> for ApiError {
500,
format!("Unknown s3 hmac invalid length error: {}", err),
),
s3::error::S3Error::Http(error) => {
Self::new(error.status_code().as_u16(), error.to_string())
}
s3::error::S3Error::Http(error) => Self::new(error.status_code().as_u16(), error.to_string()),
_ => {
let re = Regex::new(r"HTTP (\d{3})").unwrap();
// Apply the regex to the input string
@@ -131,18 +164,43 @@ impl From<s3::error::S3Error> for ApiError {
}
}
impl ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(status) => status,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = match status.as_u16() < 500 {
true => self.message.clone(),
false => "Internal server error".to_string(),
};
HttpResponse::build(status).json(json!({ "status": status.as_u16(), "message": message }))
impl From<sqlx::Error> for Error {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => Error::new(404, "Not found".to_string()),
sqlx::Error::ColumnIndexOutOfBounds { .. } => Error::new(422, error.to_string()),
sqlx::Error::ColumnNotFound { .. } => Error::new(422, error.to_string()),
sqlx::Error::ColumnDecode { .. } => Error::new(422, error.to_string()),
sqlx::Error::Decode(_) => Error::new(422, error.to_string()),
sqlx::Error::PoolTimedOut => Error::new(503, error.to_string()),
sqlx::Error::PoolClosed => Error::new(503, error.to_string()),
sqlx::Error::Tls(_) => Error::new(500, error.to_string()),
sqlx::Error::Io(_) => Error::new(500, error.to_string()),
sqlx::Error::Protocol(_) => Error::new(500, error.to_string()),
sqlx::Error::Configuration(_) => Error::new(500, error.to_string()),
sqlx::Error::AnyDriverError(_) => Error::new(500, error.to_string()),
sqlx::Error::Database(err) => {
if let Some(code) = err.code() {
match code.trim() {
// Unique violation
"23505" => return Error::new(409, err.to_string()),
_ => (),
}
}
Error::new(500, err.to_string())
}
sqlx::Error::Migrate(_) => Error::new(500, error.to_string()),
sqlx::Error::TypeNotFound { type_name } => {
Error::new(500, format!("Type not found: {}", type_name))
}
sqlx::Error::WorkerCrashed => Error::new(500, error.to_string()),
_ => Error::new(500, error.to_string()),
}
}
}
impl From<sqlx::migrate::MigrateError> for Error {
fn from(error: sqlx::migrate::MigrateError) -> Self {
Error::new(500, error.to_string())
}
}

View File

@@ -1,12 +1,11 @@
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
use std::env;
use std::time::Duration;
use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger};
use dotenv::dotenv;
use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename;
use reqwest::Certificate;
use crate::auth::hash;
use crate::users::{User, ADMIN_ROLE};
mod airports;
mod auth;
@@ -16,13 +15,61 @@ mod metars;
mod scheduler;
mod users;
#[derive(Debug, Clone)]
struct AppState {
client: reqwest::Client,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
db::init().await;
async fn main() -> Result<(), Box<dyn std::error::Error>> {
initialize_environment()?;
db::initialize().await?;
// scheduler::update_airports();
// Initialize admin user
let admin_email = env::var("ADMIN_EMAIL");
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() {
log::debug!("Creating default administrator");
let password = admin_password.unwrap();
let password_hash = hash(&password)?;
if email == "admin@example.com" || password == "CHANGEME" {
log::warn!(
"Default admin credentials are in use, update the ADMIN_EMAIL and ADMIN_PASSWORD."
);
}
let admin_user = User {
email,
password_hash,
role: ADMIN_ROLE.to_string(),
first_name: "Admin".to_string(),
last_name: "".to_string(),
updated_at: Default::default(),
created_at: Default::default(),
};
match admin_user.insert().await {
Ok(_) => log::debug!("Default administrator was successfully created"),
Err(err) => {
log::warn!("{}", err);
}
};
}
}
let certificate_path = env::var("SSL_CA_PATH")?;
let certificate_data = std::fs::read(certificate_path)?;
let certificate = Certificate::from_pem(&certificate_data)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.add_root_certificate(certificate)
.tls_built_in_root_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());
@@ -36,22 +83,52 @@ async fn main() -> std::io::Result<()> {
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)
.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) => {
log::error!("Could not bind server: {}", err);
return Err(err);
return Err(err.into());
}
};
server.run().await
if let Err(err) = server.run().await {
return Err(err.into());
}
Ok(())
}
fn initialize_environment() -> std::io::Result<()> {
// Iterate over files in the current directory
for entry in std::fs::read_dir(".")? {
let entry = entry?;
let path = entry.path();
// Check if the file name starts with ".env" and is a file
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.starts_with(".env") && path.is_file() {
// Try to load the file
if let Err(err) = from_filename(&file_name) {
eprintln!("Failed to load {}: {}", file_name, err);
} else {
println!("Loaded: {}", file_name);
}
}
}
}
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
Ok(())
}

View File

@@ -1,62 +1,54 @@
use crate::airports::QueryAirport;
use crate::error::ApiError;
use crate::error::Error;
use crate::{error::ApiResult, db};
use crate::db::schema::metars::{self};
use chrono::Datelike;
use diesel::{prelude::*, sql_query};
use log::{warn, trace};
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;
const TABLE_NAME: &str = "metars";
#[derive(Serialize, Deserialize, Debug)]
pub struct QualityControlFlags {
pub struct Metar {
pub station_id: String, // icao
pub raw_text: String,
pub observation_time: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto: Option<bool>,
pub temp_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_station_without_precipication: Option<bool>,
pub dewpoint_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_station_with_precipication: Option<bool>,
pub wind_dir_degrees: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maintenance_indicator_on: Option<bool>,
pub wind_speed_kt: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub corrected: Option<bool>,
pub wind_gust_kt: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_significant_change: Option<bool>,
pub variable_wind_dir_degrees: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temporary_change: Option<bool>,
}
impl Default for QualityControlFlags {
fn default() -> Self {
QualityControlFlags {
auto: None,
auto_station_without_precipication: None,
auto_station_with_precipication: None,
maintenance_indicator_on: None,
corrected: None,
no_significant_change: None,
temporary_change: None,
}
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SkyCondition {
pub sky_cover: String,
pub visibility_statute_mi: Option<String>,
pub runway_visual_range: Vec<RunwayVisualRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cloud_base_ft_agl: Option<i32>,
pub altim_in_hg: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub significant_convective_clouds: Option<String>,
}
impl Default for SkyCondition {
fn default() -> Self {
SkyCondition {
sky_cover: "".to_string(),
cloud_base_ft_agl: None,
significant_convective_clouds: None,
}
}
pub sea_level_pressure_mb: Option<f64>,
pub remarks: Remarks,
pub weather_phenomena: Vec<String>,
pub sky_condition: Vec<SkyCondition>,
pub flight_category: FlightCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub three_hr_pressure_tendency_mb: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_t_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_t_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub precip_in: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub humidity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub density_altitude: Option<f64>,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -81,6 +73,89 @@ impl Default for RunwayVisualRange {
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Remarks {
#[serde(skip_serializing_if = "Option::is_none")]
pub peak_wind: Option<PeakWind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_station_without_precipication: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_station_with_precipication: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maintenance_indicator_on: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub corrected: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_significant_change: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temporary_change: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rvr_missing: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub precipication_identifier_information_not_available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub precipication_information_not_available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub freezing_rain_information_not_available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thunderstorm_information_not_available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility_at_secondary_location_not_available: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sky_condition_at_secondary_location_not_available: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PeakWind {
pub degrees: i32,
pub speed: i32,
pub hour: Option<i32>,
pub minutes: i32,
}
impl Default for Remarks {
fn default() -> Self {
Remarks {
peak_wind: None,
auto: None,
auto_station_without_precipication: None,
auto_station_with_precipication: None,
maintenance_indicator_on: None,
corrected: None,
no_significant_change: None,
temporary_change: None,
rvr_missing: None,
precipication_identifier_information_not_available: None,
precipication_information_not_available: None,
freezing_rain_information_not_available: None,
thunderstorm_information_not_available: None,
visibility_at_secondary_location_not_available: None,
sky_condition_at_secondary_location_not_available: None,
}
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SkyCondition {
pub sky_cover: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cloud_base_ft_agl: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub significant_convective_clouds: Option<String>,
}
impl Default for SkyCondition {
fn default() -> Self {
SkyCondition {
sky_cover: "".to_string(),
cloud_base_ft_agl: None,
significant_convective_clouds: None,
}
}
}
#[derive(Serialize, Deserialize, Debug)]
pub enum FlightCategory {
VFR,
@@ -90,54 +165,14 @@ pub enum FlightCategory {
UNKN,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Metar {
pub raw_text: String,
pub station_id: String,
pub observation_time: chrono::NaiveDateTime,
#[serde(skip_serializing_if = "Option::is_none")]
pub temp_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dewpoint_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wind_dir_degrees: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wind_speed_kt: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wind_gust_kt: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variable_wind_dir_degrees: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility_statute_mi: Option<String>,
pub runway_visual_range: Vec<RunwayVisualRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altim_in_hg: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sea_level_pressure_mb: Option<f64>,
pub quality_control_flags: QualityControlFlags,
pub weather_phenomena: Vec<String>,
pub sky_condition: Vec<SkyCondition>,
pub flight_category: FlightCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub three_hr_pressure_tendency_mb: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_t_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_t_c: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub precip_in: Option<f64>,
}
impl Default for Metar {
fn default() -> Self {
Metar {
Self {
raw_text: "".to_string(),
station_id: "".to_string(),
observation_time: chrono::NaiveDateTime::parse_from_str(
"1970-01-01T00:00:00",
"%Y-%m-%dT%H:%M:%S",
)
.unwrap(),
observation_time: chrono::DateTime::parse_from_rfc3339("1970-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
temp_c: None,
dewpoint_c: None,
wind_dir_degrees: None,
@@ -148,7 +183,7 @@ impl Default for Metar {
runway_visual_range: vec![],
altim_in_hg: None,
sea_level_pressure_mb: None,
quality_control_flags: QualityControlFlags::default(),
remarks: Remarks::default(),
weather_phenomena: vec![],
sky_condition: vec![],
flight_category: FlightCategory::UNKN,
@@ -156,24 +191,87 @@ impl Default for Metar {
max_t_c: None,
min_t_c: None,
precip_in: None,
humidity: None,
density_altitude: None,
}
}
}
#[derive(Serialize, Deserialize, sqlx::FromRow, Debug)]
struct MetarRow {
icao: String,
observation_time: DateTime<Utc>,
raw_text: String,
data: serde_json::Value,
}
impl MetarRow {
async fn insert(&self) -> ApiResult<()> {
let pool = db::pool();
sqlx::query(&format!(
r#"
INSERT INTO {} (
icao,
observation_time,
raw_text,
data
)
VALUES ($1, $2, $3, $4)
"#,
TABLE_NAME,
))
.bind(self.icao.clone())
.bind(self.observation_time.clone())
.bind(self.raw_text.clone())
.bind(self.data.clone())
.execute(pool)
.await?;
Ok(())
}
}
impl Metar {
fn parse(metar_strings: Vec<&str>) -> ApiResult<Vec<Self>> {
let mut metars: Vec<Self> = vec![];
fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> {
let mut metars: Vec<Metar> = vec![];
for metar_string in metar_strings {
trace!("Parsing METAR data: {}", metar_string);
match Metar::parse(metar_string) {
Ok(metar) => metars.push(metar),
Err(e) => {
log::warn!("Failed to parse metar string: {}", e);
continue;
}
};
}
Ok(metars)
}
fn parse(metar_string: &str) -> ApiResult<Self> {
if metar_string.is_empty() {
return Err(Error::new(
404,
"Unable to parse empty METAR data".to_string(),
));
}
log::trace!("Parsing METAR data: {}", metar_string);
let mut metar: Metar = Metar::default();
metar.raw_text = metar_string.to_owned();
let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect();
if metar_parts.len() < 4 {
warn!(
return Err(Error::new(
500,
format!(
"Unable to parse METAR data in an unexpected format: {}",
metar_string
);
continue;
),
));
}
// Remove METAR at start of text
if metar_parts[0].to_string() == "METAR".to_string() {
metar_parts.remove(0);
}
// Station Identifier
@@ -184,19 +282,30 @@ impl Metar {
let observation_time = metar_parts[0];
metar_parts.remove(0);
if observation_time.len() != 7 {
warn!(
return Err(Error::new(
500,
format!(
"Unable to parse observation time in {}: {}",
observation_time, metar_string
);
continue;
),
));
}
let observation_time_day = &observation_time[0..2];
let observation_time_hour = &observation_time[2..4];
let observation_time_minute = &observation_time[4..6];
let current_time = chrono::Utc::now().naive_utc();
let observation_time_day = match observation_time[0..2].parse::<u32>() {
Ok(day) => day,
Err(err) => return Err(err.into()),
};
let observation_time_hour = match observation_time[2..4].parse::<u32>() {
Ok(hour) => hour,
Err(err) => return Err(err.into()),
};
let observation_time_minute = match observation_time[4..6].parse::<u32>() {
Ok(minute) => minute,
Err(err) => return Err(err.into()),
};
let current_time = Utc::now().naive_utc();
// Check if the observation time is from the previous month
let observation_time_month =
if current_time.day() > observation_time_day.parse::<u32>().unwrap() {
let observation_time_month = if current_time.day() > observation_time_day {
current_time.month() - 1
} else {
current_time.month()
@@ -207,23 +316,18 @@ impl Metar {
} else {
current_time.year()
};
// Handle Daylight Savings Time
let observation_time_hour =
if observation_time_month == 3 && observation_time_day.parse::<u32>().unwrap() < 14 {
observation_time_hour.parse::<u32>().unwrap() - 1
} else {
observation_time_hour.parse::<u32>().unwrap()
};
let observation_time = format!(
"{}-{}-{}T{}:{}:00Z",
"{:04}-{:02}-{:02}T{:02}:{:02}:00Z",
observation_time_year,
observation_time_month,
observation_time_day,
observation_time_hour,
observation_time_minute
);
metar.observation_time =
chrono::NaiveDateTime::parse_from_str(&observation_time, "%Y-%m-%dT%H:%M:%SZ").unwrap();
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
Ok(datetime) => datetime.with_timezone(&Utc),
Err(err) => return Err(err.into()),
};
loop {
if metar_parts.is_empty() {
@@ -231,15 +335,15 @@ impl Metar {
}
// Report Modifiers
if !metar_parts.is_empty() && metar_parts[0] == "AUTO" {
metar.quality_control_flags.auto = Some(true);
metar.remarks.auto = Some(true);
metar_parts.remove(0);
}
if !metar_parts.is_empty() && metar_parts[0] == "COR" {
metar.quality_control_flags.corrected = Some(true);
metar.remarks.corrected = Some(true);
metar_parts.remove(0);
}
if !metar_parts.is_empty() && metar_parts[0] == "NOSIG" {
metar.quality_control_flags.no_significant_change = Some(true);
metar.remarks.no_significant_change = Some(true);
metar_parts.remove(0);
}
@@ -410,9 +514,10 @@ impl Metar {
} else {
let rvr_variable_parts: Vec<&str> = rvr_parts[1].split("V").collect();
if rvr_variable_parts.len() != 2 {
warn!(
log::warn!(
"Unable to parse runway visual range in {}: {}",
rvr_string, metar_string
rvr_string,
metar_string
);
} else {
rvr.variable_visibility_low_ft = Some(rvr_variable_parts[0].to_string());
@@ -468,9 +573,10 @@ impl Metar {
sky_condition.cloud_base_ft_agl = match cloud_base_ft_agl.parse::<i32>() {
Ok(c) => Some(c * 100),
Err(err) => {
warn!(
log::warn!(
"Unable to parse cloud base in {}: {}",
sky_condition_string, err
sky_condition_string,
err
);
None
}
@@ -509,7 +615,7 @@ impl Metar {
metar.temp_c = match temp_c.parse::<f64>() {
Ok(t) => Some(t),
Err(err) => {
warn!("Unable to parse temperature in {}: {}", temp_c, err);
log::warn!("Unable to parse temperature in {}: {}", temp_c, err);
None
}
};
@@ -520,7 +626,7 @@ impl Metar {
metar.dewpoint_c = match dewpoint_c.parse::<f64>() {
Ok(d) => Some(d),
Err(err) => {
warn!("Unable to parse dewpoint in {}: {}", dewpoint_c, err);
log::warn!("Unable to parse dewpoint in {}: {}", dewpoint_c, err);
None
}
};
@@ -545,7 +651,7 @@ impl Metar {
// Temporary Change
if !metar_parts.is_empty() && metar_parts[0] == "TEMPO" {
metar.quality_control_flags.temporary_change = Some(true);
metar.remarks.temporary_change = Some(true);
metar_parts.remove(0);
}
@@ -561,13 +667,66 @@ impl Metar {
let remark = metar_parts[0];
metar_parts.remove(0);
if remark == "AO1" {
metar
.quality_control_flags
.auto_station_without_precipication = Some(true);
metar.remarks.auto_station_without_precipication = Some(true);
} else if remark == "AO2" {
metar.quality_control_flags.auto_station_with_precipication = Some(true);
metar.remarks.auto_station_with_precipication = Some(true);
} else if remark == "$" {
metar.quality_control_flags.maintenance_indicator_on = Some(true);
metar.remarks.maintenance_indicator_on = Some(true);
} else if remark == "PK" && metar_parts.len() >= 2 && metar_parts[0] == "WND" {
metar_parts.remove(0);
let string = metar_parts[0];
metar_parts.remove(0);
let re = regex::Regex::new(
r"(?<degrees>\d{3})(?<speed>\d{2,3})/(?:(?<hour>\d{2}))?(?<minutes>\d{2})",
)
.unwrap();
if let Some(caps) = re.captures(string) {
// Get degrees, speed, minutes
let degrees: i32 = caps["degrees"].parse()?;
let speed: i32 = caps["speed"].parse()?;
let minutes: i32 = caps["minutes"].parse()?;
// Get optional hours
let hour = if let Some(hour_match) = caps.name("hour") {
Some(hour_match.as_str().parse()?)
} else {
None
};
metar.remarks.peak_wind = Some(PeakWind {
degrees,
speed,
hour,
minutes,
});
} else {
return Err(Error::new(
500,
"Input string format is invalid".to_string(),
));
}
} else if remark == "PNO" {
metar.remarks.precipication_information_not_available = Some(true);
} else if remark == "RVRNO" {
metar.remarks.rvr_missing = Some(true);
} else if remark == "PWINO" {
metar
.remarks
.precipication_identifier_information_not_available = Some(true);
} else if remark == "FZRANO" {
metar.remarks.freezing_rain_information_not_available = Some(true);
} else if remark == "TSNO" {
metar.remarks.thunderstorm_information_not_available = Some(true);
} else if remark == "VISNO" {
let location = metar_parts[0];
metar_parts.remove(0);
metar.remarks.visibility_at_secondary_location_not_available =
Some(location.to_string());
} else if remark == "CHINO" {
let location = metar_parts[0];
metar_parts.remove(0);
metar
.remarks
.sky_condition_at_secondary_location_not_available = Some(location.to_string());
} else if slp_re.is_match(remark) {
let slp = slp_re.captures(remark).unwrap();
let sea_level_pressure = slp[1].parse::<f64>().unwrap();
@@ -586,7 +745,7 @@ impl Metar {
metar.temp_c = Some(t / 10.0 * -1.0);
}
}
let dewpoint_negation = &remark[6..7];
let dewpoint_negation = &remark[5..6];
let dewpoint = &remark[6..9];
if let Ok(d) = dewpoint.parse::<f64>() {
if dewpoint_negation == "0" {
@@ -601,9 +760,10 @@ impl Metar {
// Skip unexpected fields
if !metar_parts.is_empty() {
warn!(
log::warn!(
"Skipping unexpected field: '{}' ({})",
metar_parts[0], metar_string
metar_parts[0],
metar_string
);
metar_parts.remove(0);
}
@@ -650,33 +810,44 @@ impl Metar {
}
}
metars.push(metar);
}
return Ok(metars);
// Calculate estimated humidity
if metar.temp_c.is_some() && metar.dewpoint_c.is_some() {
let estimated_humidity = 100.0 - ((metar.temp_c.unwrap() - metar.dewpoint_c.unwrap()) * 5.0);
metar.humidity = Some(estimated_humidity);
}
fn get_missing_metar_icaos(db_metars: &Vec<Self>, station_icaos: &Vec<&str>) -> Vec<String> {
// Calculate estimated density
// let estimated_density = ;
// metar.density_altitude = Some(metar.density_altitude);
Ok(metar)
}
async fn get_missing_metar_icaos(
db_metars: &Vec<Self>,
station_icaos: &Vec<String>,
) -> Vec<String> {
let mut missing_metar_icaos: Vec<String> = vec![];
let current_time = chrono::Local::now().naive_local().and_utc().timestamp();
let db_metars_set: HashSet<&str> = db_metars
.iter()
.map(|icao| icao.station_id.as_str())
.collect();
let station_icaos_set: HashSet<&str> = station_icaos.to_owned().into_iter().collect();
let station_icaos_set: HashSet<&str> = station_icaos.iter().map(|s| s.as_str()).collect();
for difference in db_metars_set.symmetric_difference(&station_icaos_set) {
missing_metar_icaos.push(difference.to_string());
}
for metar in db_metars {
if current_time > (metar.observation_time.and_utc().timestamp() + 3600) {
trace!("{} METAR data is outdated", metar.station_id);
if current_time > (metar.observation_time.timestamp() + 3600) {
log::trace!("{} METAR data is outdated", metar.station_id);
missing_metar_icaos.push(metar.station_id.to_string());
}
}
return missing_metar_icaos;
missing_metar_icaos
}
async fn get_remote_metars(icaos: Vec<String>) -> ApiResult<Vec<Metar>> {
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<Vec<Metar>> {
let base_url = std::env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
// Query the remote API for the missing METAR data 10 at a time
let icao_chunks = icaos
.chunks(10)
@@ -684,14 +855,14 @@ impl Metar {
.collect::<Vec<String>>();
let mut metars: Vec<Metar> = 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(ApiError::new(
return Err(Error::new(
500,
format!("Unable to get METAR request: {}", r.status()),
format!("Request returned status {}", r.status()),
));
}
match r.text().await {
@@ -701,188 +872,147 @@ impl Metar {
.split("\n")
.filter(|m| !m.trim().is_empty())
.collect();
match Metar::parse(metar_chunk) {
match Self::parse_multiple(&metar_chunk) {
Ok(m) => m,
Err(err) => return Err(err),
}
}
Err(err) => {
return Err(ApiError::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(ApiError::new(
500,
format!("Unable to get METAR request: {}", err),
))
}
Err(err) => return Err(err.into()),
};
metars.append(&mut m);
}
return Ok(metars);
}
fn from_query(query_metars: Vec<QueryMetar>) -> Vec<Self> {
let mut metars: Vec<Metar> = vec![];
for metar in query_metars {
let mut metar: Metar = serde_json::from_value(metar.data).unwrap();
metar.raw_text = metar.raw_text.to_string();
metar.station_id = metar.station_id.to_string();
metars.push(metar);
}
return metars;
}
fn to_insert(metars: &Vec<Self>) -> Vec<InsertMetar> {
let mut insert_metars: Vec<InsertMetar> = vec![];
for metar in metars {
insert_metars.push(InsertMetar {
icao: metar.station_id.to_string(),
observation_time: metar.observation_time,
raw_text: metar.raw_text.to_string(),
data: serde_json::to_value(metar).unwrap(),
});
}
return insert_metars;
}
pub async fn get_all(icao_string: String) -> ApiResult<Vec<Self>> {
if icao_string.is_empty() {
return Ok(vec![]);
}
let icaos: Vec<&str> = icao_string.split(",").collect();
let mut db_metars = match QueryMetar::get_all(&icaos) {
Ok(m) => Self::from_query(m),
Err(err) => return Err(err),
};
let missing_icaos = Self::get_missing_metar_icaos(&db_metars, &icaos);
if missing_icaos.is_empty() {
return Ok(db_metars);
}
trace!("Retrieving missing METAR data for {:?}", missing_icaos);
let missing_icaos_string: Vec<String> = missing_icaos
.iter()
.map(|icao| format!("{}", icao.to_string()))
.collect();
let mut airports: Vec<QueryAirport> = vec![];
missing_icaos_string
.clone()
.iter()
.for_each(|icao| match QueryAirport::get(icao) {
Ok(a) => airports.push(a),
Err(_) => {}
});
let missing_result = Self::get_remote_metars(missing_icaos_string).await;
let mut missing_metars = match missing_result {
Ok(m) => m,
Err(err) => {
warn!("Unable to get remote METAR data; {}", err);
vec![]
}
};
if missing_metars.len() > 0 {
let insert_metars = Self::to_insert(&missing_metars);
match InsertMetar::insert(&insert_metars) {
Ok(rows) => trace!("Inserted {} metar rows", rows),
Err(err) => warn!("Unable to insert metar data; {}", err),
};
// Update airports with the appropriate has_metar flag
airports.iter().for_each(|airport| {
if missing_metars
.iter()
.any(|metar| metar.station_id == airport.icao)
{
let updated = QueryAirport {
icao: airport.icao.to_string(),
category: airport.category.to_string(),
name: airport.name.to_string(),
elevation_ft: airport.elevation_ft,
iso_country: airport.iso_country.to_string(),
iso_region: airport.iso_region.to_string(),
municipality: airport.municipality.to_string(),
has_metar: true,
point: airport.point,
data: airport.data.to_owned(),
};
match QueryAirport::update(updated) {
Ok(_) => {}
Err(err) => warn!("Unable to update airport with has_metar flag; {}", err),
}
}
});
}
let mut metars: Vec<Metar> = vec![];
metars.append(&mut missing_metars);
metars.append(&mut db_metars);
Ok(metars)
}
fn from_db(metar_db: MetarRow) -> ApiResult<Metar> {
let metar: Metar = serde_json::from_value(metar_db.data)?;
Ok(metar)
}
#[derive(Serialize, Deserialize, AsChangeset, Insertable)]
#[diesel(table_name = metars)]
struct InsertMetar {
icao: String,
observation_time: chrono::NaiveDateTime,
raw_text: String,
data: serde_json::Value,
}
impl InsertMetar {
fn insert(metars: &Vec<Self>) -> ApiResult<usize> {
let mut conn = db::connection()?;
match diesel::insert_into(metars::table)
.values(metars)
.execute(&mut conn)
{
Ok(rows) => Ok(rows),
Err(err) => Err(ApiError {
status: 500,
message: format!("{}", err),
}),
}
}
}
#[derive(Serialize, Deserialize, Queryable, QueryableByName)]
#[diesel(table_name = metars)]
struct QueryMetar {
id: i32,
icao: String,
observation_time: chrono::NaiveDateTime,
raw_text: String,
data: serde_json::Value,
}
impl QueryMetar {
fn get_all(icaos: &Vec<&str>) -> ApiResult<Vec<QueryMetar>> {
// Sanitize search to only allow [a-zA-Z0-9]
let icaos = icaos
.iter()
.map(|icao| {
icao
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>()
fn to_db(&self) -> ApiResult<MetarRow> {
let data = serde_json::to_value(self)?;
Ok(MetarRow {
icao: self.station_id.clone(),
observation_time: self.observation_time,
raw_text: self.raw_text.clone(),
data,
})
.collect::<Vec<String>>();
let station_query: Vec<String> = icaos
.iter()
.map(|icao| format!("'{}'", icao.to_string()))
}
pub async fn find_all(
client: &Client,
icao_list: &Vec<String>,
force: &bool,
) -> ApiResult<Vec<Self>> {
if icao_list.is_empty() {
return Ok(Vec::new());
}
let pool = db::pool();
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
r#"
SELECT DISTINCT ON (icao) * FROM {} WHERE icao = ANY($1) ORDER BY icao, observation_time DESC
"#,
TABLE_NAME
))
.bind(icao_list)
.fetch_all(pool)
.await?;
let mut metars: Vec<Metar> = metar_rows
.into_iter()
.filter_map(|metar_db| Metar::from_db(metar_db).ok())
.collect();
let mut conn = db::connection()?;
let db_metars: Vec<Self> = match sql_query(
format!("SELECT DISTINCT ON (icao) * FROM metars WHERE icao IN ({}) ORDER BY icao, observation_time DESC", station_query.join(","))
).load(&mut conn) {
Ok(m) => m,
Err(err) => return Err(ApiError { status: 500, message: format!("{}", err) })
};
return Ok(db_metars);
let mut conn = redis_async_connection().await?;
// Check for missing metars
let missing_icao_list = Self::get_missing_metar_icaos(&metars, icao_list).await;
if !missing_icao_list.is_empty() {
let mut updated_missing_icao_list: Vec<&str> = Vec::new();
for icao in &missing_icao_list {
if *force {
updated_missing_icao_list.push(icao);
} else {
let result: RedisResult<Option<bool>> = 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()),
}
}
}
if !updated_missing_icao_list.is_empty() {
log::trace!(
"Retrieving missing METAR data for {:?}",
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);
vec![]
});
if missing_icao_list.len() > 0 {
// Insert missing METARs
for missing_metar in &missing_icao_list {
let _: RedisResult<()> = conn.set(&missing_metar.station_id, true).await;
missing_metar.insert().await?;
}
metars.append(&mut missing_icao_list)
}
// Invalidate the still missing icaos
let still_missing_icao_list =
Self::get_missing_metar_icaos(&missing_icao_list, icao_list).await;
if !still_missing_icao_list.is_empty() {
for icao in still_missing_icao_list {
let _: RedisResult<()> = conn.set_ex(&icao, false, 3600).await;
}
}
}
}
Ok(metars)
}
pub async fn insert(&self) -> ApiResult<()> {
let metar: MetarRow = self.to_db()?;
metar.insert().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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();
let metar = Metar::parse(&metar_string).unwrap();
// dbg!(&metar);
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);
metar_string =
"KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117"
.to_string();
let metar = Metar::parse(&metar_string).unwrap();
// dbg!(&metar);
// metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string();
}
}

View File

@@ -1,36 +1,28 @@
use crate::{error::ApiError, db::Metadata};
use crate::metars::Metar;
use actix_web::{get, web, HttpResponse, HttpRequest};
use log::error;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct MetarsResponse {
pub data: Vec<Metar>,
pub meta: Metadata,
}
use crate::AppState;
#[derive(Debug, Serialize, Deserialize)]
struct GetAllParameters {
struct FindAllParameters {
icaos: Option<String>,
force: Option<bool>,
}
#[get("metars")]
async fn get_all(req: HttpRequest) -> HttpResponse {
let params = web::Query::<GetAllParameters>::from_query(req.query_string()).unwrap();
let icao_option = params.icaos.clone();
async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let parameters = web::Query::<FindAllParameters>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos;
let icao_string = match icao_option {
Some(i) => i,
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
};
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_string()).collect();
let force = &parameters.force.unwrap_or(false);
let metars =
match web::block(|| Ok::<_, ApiError>(async { Metar::get_all(icao_string).await }))
.await
.unwrap()
.unwrap()
.await
{
let client = &data.client;
let metars = match Metar::find_all(client, &icaos, force).await {
Ok(a) => a,
Err(err) => {
error!("{}", err);
@@ -41,5 +33,5 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(get_all);
config.service(find_all);
}

View File

@@ -1,74 +1,74 @@
use tokio::time::{sleep, Duration};
// use tokio::time::{sleep, Duration};
use crate::airports::{QueryAirport, QueryFilters};
use crate::metars::Metar;
// use crate::airports::{AirportDb, AirportFilter};
// use crate::metars::Metar;
pub fn update_airports() {
tokio::spawn(async {
let mut airports: Vec<QueryAirport> = vec![];
let limit = 100;
loop {
log::debug!("METAR update start");
let total = match QueryAirport::get_count(&QueryFilters::default()) {
Ok(t) => t,
Err(err) => {
log::warn!("{}", err);
break;
}
};
if total != airports.len() as i64 {
log::debug!("{} cached airports, expected {}", airports.len(), total);
airports = vec![];
let pages = ((total as f32) / (if limit <= 0 { 1 } else { limit } as f32)).ceil() as i32;
for page in 1..(pages + 1) {
match QueryAirport::get_all(&QueryFilters::default(), limit, page) {
Ok(mut a) => airports.append(&mut a),
Err(err) => {
log::warn!("{}", err);
break;
}
}
}
}
log::debug!("Updating {} airport METARS", airports.len());
let airport_icaos: Vec<String> = airports.iter().map(|a| a.icao.to_string()).collect();
let mut peekable = airport_icaos.into_iter().peekable();
let mut observation_time = chrono::Utc::now().timestamp();
if peekable.peek().is_none() {
log::debug!("No airports to update, sleeping for 1 hour");
sleep(Duration::from_secs(3600)).await;
continue;
}
while peekable.peek().is_some() {
let chunk: Vec<String> = peekable.by_ref().take(limit as usize).collect();
let icao_string = chunk.join(",");
log::trace!("Updating METARS for: {}", icao_string);
match Metar::get_all(icao_string).await {
Ok(metars) => {
// Find the oldest observation time
for metar in metars {
if metar.observation_time.and_utc().timestamp() < observation_time {
observation_time = metar.observation_time.and_utc().timestamp();
}
}
}
Err(err) => {
log::warn!("{}", err);
}
}
// Sleep for 100ms between chunks to avoid rate limiting
sleep(Duration::from_millis(100)).await;
}
log::debug!("METAR update complete");
// Sleep until the earliest observation time is 1 hour old
// Bounded by 1 and 3600 seconds
let now = chrono::Utc::now().timestamp();
let sleep_time = std::cmp::min(std::cmp::max(1, now - (observation_time + 3600)), 3600);
log::debug!("Next update in {} seconds", sleep_time);
sleep(Duration::from_secs(sleep_time as u64)).await;
}
});
// tokio::spawn(async {
// let mut airports: Vec<AirportDb> = vec![];
// let limit = 100;
// loop {
// log::debug!("METAR update start");
// let total = match AirportDb::count(&AirportFilter::default()).await {
// Ok(t) => t,
// Err(err) => {
// log::warn!("{}", err);
// break;
// }
// };
// if total != airports.len() as i64 {
// log::debug!("{} cached airports, expected {}", airports.len(), total);
// airports = vec![];
// let pages = ((total as f32) / (if limit <= 0 { 1 } else { limit } as f32)).ceil() as i32;
// for page in 1..(pages + 1) {
// match AirportDb::find_all(&AirportFilter::default(), limit, page).await {
// Ok(mut a) => airports.append(&mut a),
// Err(err) => {
// log::warn!("{}", err);
// break;
// }
// }
// }
// }
// log::debug!("Updating {} airport METARS", airports.len());
//
// let airport_icaos: Vec<String> = airports.iter().map(|a| a.icao.to_string()).collect();
// let mut peekable = airport_icaos.into_iter().peekable();
// let mut observation_time = chrono::Utc::now().timestamp();
//
// if peekable.peek().is_none() {
// log::debug!("No airports to update, sleeping for 1 hour");
// sleep(Duration::from_secs(3600)).await;
// continue;
// }
//
// while peekable.peek().is_some() {
// let chunk: Vec<String> = peekable.by_ref().take(limit as usize).collect();
// let icao_string = chunk.join(",");
// log::warn!("Updating METARS for: {}", &icao_string); // TODO: back to trace after
// match Metar::find_all(&[&icao_string]).await {
// Ok(metars) => {
// // Find the oldest observation time
// for metar in metars {
// if metar.observation_time.timestamp() < observation_time {
// observation_time = metar.observation_time.timestamp();
// }
// }
// }
// Err(err) => {
// log::warn!("{}", err);
// }
// }
// // Sleep for 100ms between chunks to avoid rate limiting
// sleep(Duration::from_millis(100)).await;
// }
// log::debug!("METAR update complete");
// // Sleep until the earliest observation time is 1 hour old
// // Bounded by 1 and 3600 seconds
// let now = chrono::Utc::now().timestamp();
// let sleep_time = std::cmp::min(std::cmp::max(1, now - (observation_time + 3600)), 3600);
// log::debug!("Next update in {} seconds", sleep_time);
// sleep(Duration::from_secs(sleep_time as u64)).await;
// }
// });
}

View File

@@ -1,15 +1,13 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use diesel::prelude::*;
use sqlx::{Postgres, QueryBuilder};
use crate::{auth::hash, error::ApiResult};
use crate::db;
use crate::{
auth::hash,
db::{connection, schema::users},
error::ApiResult,
};
pub const ADMIN_ROLE: &str = "ADMIN";
pub const USER_ROLE: &str = "USER";
const TABLE_NAME: &str = "users";
/**
* RegisterRequest
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterRequest {
pub email: String,
@@ -20,41 +18,31 @@ pub struct RegisterRequest {
impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> {
let hash = hash(&self.password)?;
let password_hash = hash(&self.password)?;
Ok(User {
email: self.email.to_lowercase(),
hash,
role: "user".to_string(),
password_hash,
role: USER_ROLE.to_string(),
first_name: self.first_name,
last_name: self.last_name,
updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(),
profile_picture: None,
favorites: vec![],
verified: false,
updated_at: Utc::now(),
created_at: Utc::now(),
})
}
}
/**
* LoginRequest
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
/**
* UserResponse
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct UserResponse {
pub email: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub profile_picture: Option<String>,
}
impl From<User> for UserResponse {
@@ -64,45 +52,152 @@ impl From<User> for UserResponse {
role: user.role,
first_name: user.first_name,
last_name: user.last_name,
profile_picture: user.profile_picture,
}
}
}
/**
* User
*/
#[derive(Debug, Insertable, AsChangeset, Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = users)]
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct UpdateUser {
pub email: Option<String>,
pub password: Option<String>,
pub role: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
}
impl UpdateUser {
pub async fn update(&self, email: &str) -> ApiResult<User> {
let pool = db::pool();
let mut query_builder: QueryBuilder<Postgres> =
QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME));
let mut first_clause = true;
let mut push_comma = |query_builder: &mut QueryBuilder<Postgres>| {
if !first_clause {
query_builder.push(", ");
} else {
first_clause = false;
}
};
if let Some(ref email) = self.email {
push_comma(&mut query_builder);
query_builder.push("email = ");
query_builder.push_bind(email);
}
if let Some(ref password) = self.password {
push_comma(&mut query_builder);
let password_hash = hash(password)?;
query_builder.push("password_hash = ");
query_builder.push_bind(password_hash);
}
if let Some(ref role) = self.role {
push_comma(&mut query_builder);
query_builder.push("role = ");
query_builder.push_bind(role);
}
if let Some(ref first_name) = self.first_name {
push_comma(&mut query_builder);
query_builder.push("first_name = ");
query_builder.push_bind(first_name);
}
if let Some(ref last_name) = self.last_name {
push_comma(&mut query_builder);
query_builder.push("last_name = ");
query_builder.push_bind(last_name);
}
push_comma(&mut query_builder);
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(" RETURNING *");
dbg!(&query_builder.sql());
let query = query_builder.build_query_as::<User>();
let user = query.fetch_one(pool).await?;
Ok(user)
}
}
#[derive(Serialize, Deserialize, sqlx::FromRow, Debug)]
pub struct User {
pub email: String,
pub hash: String,
pub password_hash: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn get_by_email(email: &str) -> ApiResult<User> {
let mut conn = connection()?;
// Check if the user exists by email, case insensitive
pub async fn select(email: &str) -> Option<Self> {
let pool = db::pool();
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
r#"
SELECT * FROM {} WHERE email = LOWER($1)
"#,
TABLE_NAME
))
.bind(email)
.fetch_optional(pool)
.await
.unwrap_or_else(|err| {
log::error!("Unable to find user '{}': {}", email, err);
None
});
let user = users::table
.filter(users::email.eq(email.to_lowercase()))
.first(&mut conn)?;
Ok(user)
user
}
pub fn insert(user: Self) -> ApiResult<User> {
let mut conn = connection()?;
let user = diesel::insert_into(users::table)
.values(user)
.get_result(&mut conn)?;
pub async fn count() -> i64 {
let pool = db::pool();
sqlx::query_scalar(&format!(
r#"
SELECT COUNT(*) FROM {}
"#,
TABLE_NAME
))
.fetch_one(pool)
.await
.unwrap_or_else(|_| 0)
}
pub async fn insert(&self) -> ApiResult<User> {
let pool = db::pool();
let user: User = sqlx::query_as::<_, Self>(&format!(
r#"
INSERT INTO {} (
email,
password_hash,
role,
first_name,
last_name,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
TABLE_NAME,
))
.bind(&self.email)
.bind(&self.password_hash)
.bind(&self.role)
.bind(&self.first_name)
.bind(&self.last_name)
.bind(self.created_at)
.bind(self.updated_at)
.fetch_one(pool)
.await?;
Ok(user)
}
}

View File

@@ -152,7 +152,7 @@
// }
// }
pub fn init_routes(config: &mut actix_web::web::ServiceConfig) {
pub fn init_routes(_config: &mut actix_web::web::ServiceConfig) {
// config.service(
// web::scope("users")
// .service(get_favorites)

View File

@@ -0,0 +1,11 @@
meta {
name: Delete Airport
type: http
seq: 5
}
delete {
url: {{API_URL}}/airports/TEST
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Delete All Airports
type: http
seq: 6
}
delete {
url: {{API_URL}}/airports
body: none
auth: none
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get Airport
type: http
seq: 3
}
get {
url: {{API_URL}}/airports/KHEF?metars=true
body: none
auth: none
}
params:query {
metars: true
}

View File

@@ -0,0 +1,19 @@
meta {
name: Get All Airports
type: http
seq: 4
}
get {
url: {{API_URL}}/airports?page=1&limit=1000&metars=true
body: none
auth: none
}
params:query {
page: 1
limit: 1000
metars: true
~icaos: 00AA
~icaos: KHEF,KJYO,KMRB,KOKV
}

View File

@@ -0,0 +1,15 @@
meta {
name: Import Airports
type: http
seq: 2
}
post {
url: {{API_URL}}/airports/import
body: multipartForm
auth: none
}
body:multipart-form {
: @file(/Users/bsherriff/git/private/aviation-weather/data/airports_2023-12-21.json)
}

View File

@@ -0,0 +1,28 @@
meta {
name: Insert Airport
type: http
seq: 1
}
post {
url: {{API_URL}}/airports
body: json
auth: none
}
body:json {
{
"icao": "TEST",
"name": "Test Airport",
"category": "unknown",
"iso_country": "",
"iso_region": "",
"municipality": "",
"elevation_ft": 0,
"latitude": 0,
"longitude": 0,
"runways": [],
"frequencies": [],
"public": true
}
}

View File

@@ -0,0 +1,16 @@
meta {
name: Find Metars
type: http
seq: 1
}
get {
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
}

View File

@@ -0,0 +1,20 @@
meta {
name: Change Password
type: http
seq: 4
}
put {
url: {{API_URL}}/account/password
body: json
auth: none
}
body:json {
"New Password"
}
script:post-response {
const apiKey = res.body
bru.setVar("bearer",apiKey)
}

18
bruno/Users/Login.bru Normal file
View File

@@ -0,0 +1,18 @@
meta {
name: Login
type: http
seq: 2
}
post {
url: {{API_URL}}/account/login
body: json
auth: none
}
body:json {
{
"email": "admin@example.com",
"password": "CHANGEME"
}
}

18
bruno/Users/Logout.bru Normal file
View File

@@ -0,0 +1,18 @@
meta {
name: Logout
type: http
seq: 3
}
post {
url: {{API_URL}}/account/logout
body: none
auth: none
}
body:json {
{
"email": "john.doe@gmail.com",
"password": "fake_password123"
}
}

20
bruno/Users/Register.bru Normal file
View File

@@ -0,0 +1,20 @@
meta {
name: Register
type: http
seq: 1
}
post {
url: {{API_URL}}/account/register
body: json
auth: none
}
body:json {
{
"email": "john.doe@gmail.com",
"password": "fake_password123",
"first_name": "John",
"last_name": "Doe"
}
}

View File

@@ -0,0 +1,11 @@
meta {
name: Validate Session
type: http
seq: 5
}
get {
url: {{API_URL}}/account/session
body: none
auth: none
}

23
bruno/bruno.json Normal file
View File

@@ -0,0 +1,23 @@
{
"version": "1",
"name": "Aviation",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"size": 0.0026407241821289062,
"filesCount": 14,
"clientCertificates": {
"enabled": true,
"certs": [
{
"domain": "localhost",
"type": "cert",
"certFilePath": "../ssl/localhost.crt",
"keyFilePath": "../ssl/localhost.key",
"passphrase": ""
}
]
}
}

3
bruno/collection.bru Normal file
View File

@@ -0,0 +1,3 @@
vars:pre-request {
API_URL: {{BASE_URL}}/api
}

View File

@@ -0,0 +1,3 @@
vars {
BASE_URL: https://localhost:8443
}

View File

@@ -144368,7 +144368,7 @@
"iata": "",
"local": "64CL",
"name": "Goodyear Blimp Base Airport",
"category": "balloonport",
"category": "balloon_port",
"iso_country": "US",
"iso_region": "US-CA",
"municipality": "Gardena",

File diff suppressed because one or more lines are too long

View File

@@ -4,67 +4,112 @@ x-env_file: &env
- path: .env.local
required: false
x-restart: &default_restart
restart: unless-stopped
name: aviation
services:
db:
image: postgis/postgis:latest
container_name: aviation-db
nginx:
# image: nginx
image: aviation-nginx:latest
container_name: aviation-nginx
build:
context: ./nginx
dockerfile: Dockerfile
env_file: *env
environment:
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE_NAME}
volumes:
- db:/var/lib/postgresql/data
- db_logs:/var/log
SSL_CERT_PATH: /etc/nginx/ssl/localhost.crt
SSL_CERT_KEY_PATH: /etc/nginx/ssl/localhost.key
NGINX_HOST: ${NGINX_HOST:-localhost}
ports:
- "${DATABASE_PORT:-5432}:5432"
- "${NGINX_HTTP_PORT:-8080}:80"
- "${NGINX_HTTPS_PORT:-8443}:443"
volumes:
- ./ssl:/etc/nginx/ssl/
networks:
- frontend
- backend
<<: *default_restart
postgres:
image: postgis/postgis:17-3.5
container_name: aviation-postgres
env_file: *env
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_NAME}
volumes:
- postgres:/var/lib/postgresql/data
- postgres_logs:/var/log
ports:
- "${POSTGRES_PORT:-5432}:5432"
networks:
- backend
profiles:
- backend
restart: unless-stopped
<<: *default_restart
redis:
image: redis:latest
image: redis:8.0-M03 # Replace with valkey?
container_name: aviation-redis
volumes:
- redis:/data
ports:
- ${REDIS_PORT:-6379}:6379
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 10s
timeout: 5s
retries: 3
networks:
- backend
profiles:
- backend
restart: unless-stopped
<<: *default_restart
minio:
image: minio/minio
image: minio/minio:RELEASE.2025-02-28T09-55-16Z
container_name: aviation-minio
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL}
MINIO_BROWSER_LOGIN_ANIMATION: false
volumes:
- minio:/data
ports:
- ${MINIO_PORT:-9000}:9000
- ${MINIO_PORT_INTERNAL:-9001}:9001
- "${MINIO_PORT:-9000}:9000"
- "${MINIO_PORT_INTERNAL:-9001}:9001"
networks:
- backend
profiles:
- backend
command: server --console-address ":9001" /data
restart: unless-stopped
<<: *default_restart
api:
image: aviation-api:latest
container_name: aviation-api
build:
context: ./api
dockerfile: Dockerfile
env_file: *env
environment:
API_HOST: 0.0.0.0
SSL_CA_PATH: /ssl/ca.pem
POSTGRES_HOST: aviation-postgres
POSTGRES_PORT: 5432
REDIS_HOST: aviation-redis
REDIS_PORT: 6379
MINIO_HOST: aviation-minio
MINIO_PORT: 9000
volumes:
- ./ssl:/ssl
ports:
- "${API_PORT:-5000}:5000"
build:
context: api
depends_on:
- db
- postgres
- redis
- minio
networks:
@@ -72,18 +117,19 @@ services:
- backend
profiles:
- api
restart: unless-stopped
<<: *default_restart
ui:
image: aviation-ui:latest
container_name: aviation-ui
build:
context: ./ui
dockerfile: Dockerfile
env_file: *env
environment:
- NODE_ENV=${NODE_ENV:-development}
ports:
- ${UI_PORT:-3000}:3000
build:
context: ./ui/
target: dev
- "${UI_PORT:-3000}:3000"
volumes:
- ./ui/src:/app/src
- ./ui/public:/app/public
@@ -93,11 +139,11 @@ services:
profiles:
- frontend
command: ["npm", "run", "dev"]
restart: unless-stopped
<<: *default_restart
volumes:
db:
db_logs:
postgres:
postgres_logs:
redis:
minio:

BIN
docs/logo.afdesign Normal file

Binary file not shown.

BIN
docs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

3
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf
COPY templates/ /etc/nginx/templates/

34
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,34 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
# Set client limit to 100 MB
client_max_body_size 100M;
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -0,0 +1,56 @@
# HTTP server configuration
server {
listen 80;
listen [::]:80;
server_name ${NGINX_HOST};
# Redirect all incoming requests to HTTPS
return 301 https://$host:${NGINX_HTTPS_PORT}$request_uri;
}
# HTTPS server configuration
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name ${NGINX_HOST};
# SSL settings
ssl_certificate ${SSL_CERT_PATH};
ssl_certificate_key ${SSL_CERT_KEY_PATH};
# Optional: SSL session settings and ciphers (adjust as required)
#ssl_session_cache shared:SSL:10m;
#ssl_session_timeout 10m;
#ssl_ciphers HIGH:!aNULL:!MD5;
#ssl_prefer_server_ciphers on;
location /api/ {
proxy_pass ${API_PROTOCOL}://${NGINX_API_HOST}:${API_PORT}/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /minio/ {
proxy_pass ${MINIO_PROTOCOL}://${NGINX_MINIO_HOST}:${MINIO_PORT_INTERNAL}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Reverse proxy for the UI and default catch-all
location / {
proxy_pass ${UI_PROTOCOL}://${NGINX_UI_HOST}:${UI_PORT}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

71
scripts/generate_cert.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
# Usage: ./generate_cert.sh example.com
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <domain>"
exit 1
fi
DOMAIN=$1
DAYS=365
SSL_DIR="./ssl"
# Create directory if it doesn't exist
mkdir -p "$SSL_DIR"
# Define CA file names
CA_KEY="${SSL_DIR}/${SSL_CA_NAME}.key"
CA_CERT="${SSL_DIR}/${SSL_CA_NAME}.pem"
# Check if CA files exist; if not, generate them.
if [ ! -f "$CA_KEY" ] || [ ! -f "$CA_CERT" ]; then
echo "Generating CA key and self-signed CA certificate..."
openssl genrsa -out "$CA_KEY" 4096
if [ $? -ne 0 ]; then
echo "Failed to generate CA key"
exit 1
fi
openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 1024 -out "$CA_CERT" -subj "/CN=My Custom CA"
if [ $? -ne 0 ]; then
echo "Failed to generate CA certificate"
exit 1
fi
echo "CA generated successfully:"
else
echo "Existing CA:"
fi
echo " CA Private Key: $CA_KEY"
echo " CA Certificate: $CA_CERT"
# Define domain file names
DOMAIN_KEY="${SSL_DIR}/${DOMAIN}.key"
DOMAIN_CSR="${SSL_DIR}/${DOMAIN}.csr"
DOMAIN_CERT="${SSL_DIR}/${DOMAIN}.crt"
echo "Generating private key for domain ${DOMAIN}..."
openssl genrsa -out "$DOMAIN_KEY" 2048
if [ $? -ne 0 ]; then
echo "Failed to generate domain key"
exit 1
fi
echo "Generating CSR for domain ${DOMAIN}..."
openssl req -new -key "$DOMAIN_KEY" -out "$DOMAIN_CSR" -subj "/CN=${DOMAIN}"
if [ $? -ne 0 ]; then
echo "Failed to generate CSR for ${DOMAIN}"
exit 1
fi
echo "Signing certificate for ${DOMAIN} using our CA..."
openssl x509 -req -in "$DOMAIN_CSR" -CA "$CA_CERT" -CAkey "$CA_KEY" -CAcreateserial -out "$DOMAIN_CERT" -days $DAYS -sha256
if [ $? -ne 0 ]; then
echo "Failed to sign certificate for ${DOMAIN}"
exit 1
fi
echo "Successfully generated the following files:"
echo " CA Private Key: $CA_KEY"
echo " CA Certificate: $CA_CERT"
echo " Domain Private Key: $DOMAIN_KEY"
echo " Domain Certificate: $DOMAIN_CERT"
echo " Domain Certificate Signing Request: $DOMAIN_CSR"

3
ui/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
package-lock.json
node_modules
dist/

View File

@@ -1,17 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint/eslint-plugin"
],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"rules": {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}

View File

@@ -1 +0,0 @@
18.17.1

View File

@@ -1,39 +1,33 @@
# Base
FROM node:21-alpine AS base
FROM node:18-alpine AS base
# Dependencies
FROM base as deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Dev
FROM base AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Builder
# ============
# Builder Stage
# ============
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
RUN apk add --no-cache libc6-compat
WORKDIR /builder
COPY . .
RUN npm run build
RUN \
if [ -f package.json ]; then \
npm i && \
npm run build; \
else \
echo "package.json not found." && \
exit 2; \
fi
# Runner
# ============
# Runtime Stage
# ============
FROM base AS runner
ARG PORT=3000
ENV PORT=${PORT}
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV NEXT_TELEMETRY_DISABLED 1
COPY --chown=node --from=builder /builder /app
CMD ["node", "server.js"]
USER node
EXPOSE ${PORT}
CMD ["npm", "run", "dev"]

View File

@@ -1,34 +0,0 @@
#!make
SHELL := /bin/bash
GIT_HASH ?= $(shell git log --format="%h" -n 1)
include .env
-include .env.local
export
.PHONY: help build start stop lint
help: ## This info
@echo
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
build: ## Install the dependencies and build
docker compose build
tag: ## Tag the Docker image
docker tag aviation-ui:latest aviation-ui:${GIT_HASH}
up: ## Start the dev instance
docker compose up -d
down: ## Stop the dev instance
docker compose down
lint: ## Run the linter
npm run lint
clean: ## Remove node modules
docker compose down && \
docker image rm aviation-ui

28
ui/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

15
ui/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="stylesheet" href="styles/global.css">
<link rel="stylesheet" href="styles/leaflet.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aviation Data</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

5
ui/next-env.d.ts vendored
View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -1,18 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
eslint: {
ignoreDuringBuilds: true
},
publicRuntimeConfig: {
// remove private variables from processEnv
processEnv: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.includes('NEXT_PUBLIC_')))
},
output: 'standalone',
experimental: {
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
},
};
module.exports = nextConfig;

6275
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,50 @@
{
"name": "aviation-weather",
"version": "0.1.0",
"name": "aviation-ui",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"dev": "vite --host --port 3000",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write src"
},
"dependencies": {
"@mantine/core": "^7.3.2",
"@mantine/form": "^7.3.2",
"@mantine/hooks": "^7.3.2",
"@mantine/modals": "^7.3.2",
"@mantine/notifications": "^7.3.2",
"@mantine/core": "^7.17.2",
"@mantine/form": "^7.17.2",
"@mantine/hooks": "^7.17.2",
"@mantine/modals": "^7.17.2",
"@mantine/notifications": "^7.17.2",
"@tabler/icons-react": "^3.31.0",
"d3": "^7.9.0",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"next": "^14.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.11.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.10.3",
"recoil": "^0.7.7"
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/d3": "^7.4.3",
"@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.8",
"@types/node": "20.10.5",
"@types/react": "18.2.45",
"@types/react-dom": "18.2.18",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"@types/leaflet": "^1.9.16",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.16",
"eslint": "8.56.0",
"eslint-config-next": "14.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.0",
"postcss": "^8.4.32",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"postcss-import": "^15.1.0",
"postcss-preset-mantine": "^1.12.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.1.1",
"typescript": "5.3.3"
"prettier": "3.4.1",
"typescript": "~5.7.2",
"typescript-eslint": "^8.25.0",
"vite": "^6.2.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg874" width="185.96mm" height="185.96mm" version="1.1" viewBox="0 0 185.96 185.96" xmlns="http://www.w3.org/2000/svg">
<circle id="path1419" cx="92.99" cy="92.983" r="92.982" fill="#28a745"/>
<g id="text1423" fill="#fff" stroke-width="13.795" style="font-feature-settings:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-variant-numeric:normal" aria-label="V">
<path id="path821" d="m120.94 45.222h17.819l-36.787 105.05h-18.106l-36.644-105.05h17.675l21.914 65.312q1.7244 4.6703 3.6644 12.071 1.94 7.3288 2.5148 10.921 0.93406-5.4606 2.874-12.646 1.94-7.1851 3.1614-10.634z" fill="#fff" stroke-width="13.795"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 695 B

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(255,0,0);"/>
</g>
<g transform="matrix(130.653,0,0,130.653,45.627,110.762)">
<g transform="matrix(1,0,0,1,0.277832,0)">
</g>
<text x="0px" y="0px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:1px;fill:white;">I</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 871 B

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(128,0,128);"/>
</g>
<g transform="matrix(128.435,0,0,128.435,25.8707,109.968)">
<g transform="matrix(1,0,0,1,0.556152,0)">
</g>
<text x="0px" y="0px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:1px;fill:white;">L</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 874 B

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(0,0,255);"/>
</g>
<g transform="matrix(238.636,0,0,238.636,-4483.2,-6772.13)">
<g transform="matrix(0.50957,0,0,0.50957,19.2676,28.829)">
</g>
<text x="18.843px" y="28.829px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:0.51px;fill:white;">M</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 902 B

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.99926,0,0,1.97614,-28.3993,-25.2946)">
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(105,105,105);"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 624 B

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(62,62,62);"/>
</g>
<g transform="matrix(238.636,0,0,238.636,-4476.43,-6772.87)">
<g transform="matrix(0.50957,0,0,0.50957,19.2111,28.829)">
</g>
<text x="18.843px" y="28.829px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:0.51px;fill:white;">U</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 904 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 B

8
ui/public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

View File

@@ -1,4 +0,0 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

69
ui/src/App.css Normal file
View File

@@ -0,0 +1,69 @@
/* Ensure that the html and body take up the full height */
html,
body,
#root,
.App {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Set up Flexbox layout */
.App {
display: flex;
flex-direction: column;
height: 100%;
}
.app-header {
background-color: #333;
color: #fff;
}
.map-wrapper {
flex: 1;
}
.leaflet-container {
height: 100%;
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 {
background-color: #228be6;
color: #fff;
}
.map-button.active:hover {
background-color: #187ed7;
}
.map-button:hover {
background: #e6e6e6;
}

117
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { LayersControl, MapContainer, TileLayer, useMapEvents, ZoomControl } from 'react-leaflet';
import '@mantine/core/styles.css';
import 'leaflet/dist/leaflet.css';
import './App.css';
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import L from 'leaflet';
import { Header } from '@components/Header';
import AirportLayer from '@components/AirportLayer.tsx';
import { useEffect, useState } from 'react';
import { Airport } from '@lib/airport.types.ts';
import AirportDrawer from '@components/AirportDrawer.tsx';
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
// import { IconRadar } from '@tabler/icons-react';
import Cookies from 'js-cookie';
import { UnstyledButton } from '@mantine/core';
// Fix Leaflet's default icon path issues with Webpack
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow
});
const openStreetMapUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const lightLayerUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
const darkLayerUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
// const dark1Url = 'https://maps.rainviewer.com/data/v3/5/10/11.pbf';
// const dark2Url = 'https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/2/0/3.pbf';
const defaultZoom = 6;
const defaultCenter: L.LatLngExpression = [38.944444, -77.455833];
function App() {
const [airport, setAirport] = useState<Airport | null>(null);
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
const initialRadarValue = Cookies.get('showRadar') === 'true';
const [showRadar, setShowRadar] = useState<boolean>(initialRadarValue);
const [baseLayer, setBaseLayer] = useState<string>(Cookies.get('selectedBaseLayer') || 'Open Street Map');
useEffect(() => {
if (showRadar) {
getWeatherMapUrl().then((url) => {
setRainViewerUrl(url);
});
}
}, [showRadar]);
function toggleRadar() {
setShowRadar((prev) => {
const newValue = !prev;
Cookies.set('showRadar', newValue.toString(), { expires: 7 });
return newValue;
});
}
function BaseLayerChangeHandler() {
useMapEvents({
baselayerchange: (e) => {
setBaseLayer(e.name);
Cookies.set('selectedBaseLayer', e.name, { expires: 7 });
}
});
return null;
}
return (
<div className='App'>
<Header />
<div className='map-wrapper'>
<AirportDrawer airport={airport} setAirport={setAirport} />
<MapContainer
className='leaflet-container'
attributionControl={false}
center={defaultCenter}
zoom={defaultZoom}
minZoom={3}
maxZoom={19}
maxBounds={[
[-85.06, -181],
[85.06, 181]
]}
scrollWheelZoom={true}
zoomControl={false}
>
<LayersControl>
<LayersControl.BaseLayer checked={baseLayer === 'Open Street Map'} name={'Open Street Map'}>
<TileLayer url={openStreetMapUrl} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={baseLayer === 'Carto Light'} name={'Carto Light'}>
<TileLayer url={lightLayerUrl} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={baseLayer === 'Carto Dark'} name={'Carto Dark'}>
<TileLayer url={darkLayerUrl} />
</LayersControl.BaseLayer>
</LayersControl>
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
<ZoomControl position={'bottomright'} />
<AirportLayer setAirport={setAirport} />
<BaseLayerChangeHandler />
</MapContainer>
<UnstyledButton
onClick={toggleRadar}
style={{ bottom: '80px' }}
className={`map-button ${showRadar ? 'active' : ''}`}
>
Radar
</UnstyledButton>
</div>
</div>
);
}
export default App;

View File

@@ -1,79 +0,0 @@
import { Airport, AirportOrderField, Bounds, GetAirportsResponse } from './airport.types';
import { getRequest, deleteRequest, postRequest, putRequest } from '.';
interface GetAirportProps {
icao: string;
}
export async function getAirport({ icao }: GetAirportProps): Promise<Airport> {
const response = await getRequest(`airports/${icao}`);
return response?.json() || {};
}
interface GetAirportsProps {
bounds?: Bounds;
categories?: string[];
icaos?: string[];
name?: string;
order_field?: AirportOrderField;
order_by?: 'asc' | 'desc';
has_metar?: boolean;
page?: number;
limit?: number;
}
export async function getAirports({
bounds,
categories,
icaos,
name,
order_field,
order_by,
has_metar,
limit = 10,
page = 1
}: GetAirportsProps): Promise<GetAirportsResponse> {
const response = await getRequest('airports', {
bounds: bounds
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}`
: undefined,
categories: categories ?? undefined,
icaos: icaos ?? undefined,
name: name ?? undefined,
order_field: order_field ?? undefined,
order_by: order_by ?? undefined,
has_metar: has_metar ?? undefined,
limit,
page
});
return response?.json() || { data: [] };
}
export async function removeAirport({ icao }: { icao?: string }): Promise<any> {
let response
if (icao) {
response = await deleteRequest(`airports/${icao}`);
} else {
response = await deleteRequest('airports');
}
return response.status == 204;
}
export async function createAirport({ airport }: { airport: Airport }): Promise<any> {
const response = await postRequest(`airports`, airport);
return response?.json() || { data: undefined };
}
export async function updateAirport({ airport }: { airport: Airport }): Promise<any> {
const response = await putRequest(`airports/${airport.icao}`, airport);
return response?.json() || { data: undefined };
}
export async function importAirports(payload: File): Promise<boolean> {
const data = new FormData();
data.append('data', payload);
const response = await postRequest('airports/import', data, {
type: 'form'
});
return response ? response.status === 200 : false;
}

View File

@@ -1,11 +0,0 @@
import { Metar } from './metar.types';
import { getRequest } from '.';
export async function getMetars(icaos: string[]): Promise<Metar[]> {
if (icaos.length == 0) {
return [];
}
const stationICAOs: string = icaos.map((icao) => icao).join(',');
const response = await getRequest(`metars`, { icaos: stationICAOs });
return response?.json() || [];
}

View File

@@ -1,51 +0,0 @@
import { deleteRequest, getRequest, postRequest } from '.';
export async function getPicture(): Promise<Blob | undefined> {
const response = await getRequest('users/picture');
if (response?.status === 200) {
return await response.blob();
} else {
return undefined;
}
}
export async function setPicture(payload: File): Promise<boolean> {
const data = new FormData();
data.append('data', payload);
// TODO: Figure out why the form data object is empty
const response = await postRequest('users/picture', data, {
type: 'form'
});
if (response?.status === 200) {
return true;
} else {
return false;
}
}
export async function getFavorites(): Promise<string[]> {
const response = await getRequest('users/favorites');
if (response?.status === 200) {
return response.json();
} else {
return [];
}
}
export async function addFavorite(icao: string): Promise<boolean> {
const response = await postRequest(`users/favorites/${icao}`);
if (response?.status === 200) {
return true;
} else {
return false;
}
}
export async function removeFavorite(icao: string): Promise<boolean> {
const response = await deleteRequest(`users/favorites/${icao}`);
if (response?.status === 200) {
return true;
} else {
return false;
}
}

View File

@@ -1,56 +0,0 @@
'use client';
import { createAirport, removeAirport, updateAirport } from "@/api/airport";
import { Airport } from "@/api/airport.types";
import AirportForm from "@/components/Admin/AirportForm";
import AirportTablePanel from "@/components/Admin/AirportTablePanel";
import { isAdminState } from "@/state/auth";
import { Container, Grid, Modal, SimpleGrid } from "@mantine/core";
import { useState } from "react";
import { useRecoilValue } from "recoil";
export default function Page() {
const [showModal, setShowModal] = useState(false);
const [airport, setAirport] = useState<Airport | undefined>(undefined);
const isAdmin = useRecoilValue(isAdminState);
return (
<>
{isAdmin && (
<Container fluid>
<SimpleGrid cols={{ base: 1, xs: 1 }} spacing={'md'}>
<Grid p={'lg'}>
<Grid.Col span={12}>
<AirportTablePanel setShowModal={setShowModal} setAirport={setAirport} />
</Grid.Col>
</Grid>
</SimpleGrid>
<Modal size={'xl'} opened={showModal} onClose={() => {
setAirport(undefined);
setShowModal(false);
}}>
<AirportForm
title={airport ? 'Update Airport' : 'Create Airport'}
submitText={airport ? 'Update' : 'Create'}
airport={airport}
onDelete={airport ? async () => {
const response = await removeAirport({ icao: airport.icao });
setShowModal(false);
} : undefined}
onSubmit={async (value) => {
if (airport) {
const response = await updateAirport({ airport: value });
} else {
const response = await createAirport({ airport: value });
}
setShowModal(false);
}}
/>
</Modal>
</Container>
)}
</>
);
}

View File

@@ -1,63 +0,0 @@
'use client';
import { getAirport } from '@/api/airport';
import { Airport } from '@/api/airport.types';
import { getMetars } from '@/api/metar';
import { Metar } from '@/api/metar.types';
import { Grid, Title, Text } from '@mantine/core';
import { useEffect, useState } from 'react';
export default function Page({ params }: { params: { icao: string } }) {
const [airport, setAirport] = useState<Airport | undefined>(undefined);
const [metar, setMetar] = useState<Metar | undefined>(undefined);
useEffect(() => {
async function loadAirport() {
const airportData = await getAirport({ icao: params.icao });
setAirport(airportData);
const metarData = await getMetars([airportData.icao]);
if (metarData.length > 0) {
setMetar(metarData[0]);
}
}
loadAirport();
}, []);
if (airport) {
return (
<Grid gutter={80} style={{ margin: '0 0.5em'}}>
<Grid.Col span={12}>
<Title className='title' order={1}>{airport.icao} - {airport.name}</Title>
<Text c="dimmed">
{airport.municipality} | {airport.iso_region} | {airport.iso_country}
</Text>
{metar && (
<Text c="dimmed">
{metar.raw_text}
</Text>
)}
<h3>Frequencies</h3>
{airport.frequencies.map((frequency) => (
<div key={frequency.frequency_mhz}>
<ul>
<li>{frequency.id}: {frequency.frequency_mhz} MHz</li>
</ul>
</div>
))}
<h3>Runway Information</h3>
{airport.runways.map((runway) => (
<div key={runway.id}>
<b>Runway {runway.id}</b>
<ul>
<li>Dimensions: {runway.length_ft} x {runway.width_ft} ft.</li>
<li>Surface: {runway.surface}</li>
</ul>
</div>
))}
</Grid.Col>
</Grid>
);
} else {
return <></>;
}
}

View File

@@ -1,38 +0,0 @@
import React from 'react';
import RecoilRootWrapper from '@app/recoil-root-wrapper';
import { MantineProvider, Skeleton } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import 'styles/globals.css';
import 'styles/leaflet.css';
import '@mantine/core/styles.css';
import { Notifications } from '@mantine/notifications';
import Loader from '@/components/Loader';
export const metadata = {
title: 'Aviation Weather',
description: ''
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<head>
<title>Aviation Weather</title>
</head>
<body>
<MantineProvider>
<Notifications />
<ModalsProvider>
<RecoilRootWrapper>
<React.Suspense fallback={<Skeleton/>}>
<Loader>
{children}
</Loader>
</React.Suspense>
</RecoilRootWrapper>
</ModalsProvider>
</MantineProvider>
</body>
</html>
);
}

View File

@@ -1,7 +0,0 @@
import React from 'react';
import Metar from '@/components/Metars';
export default function Page() {
return <Metar />;
// return <></>;
}

View File

@@ -1,179 +0,0 @@
'use client';
import { getAirports } from "@/api/airport";
import { Airport } from "@/api/airport.types";
import { useEffect, useState } from "react";
import { useRecoilState, useRecoilValue } from "recoil";
import { Autocomplete, Badge, Box, Button, Card, Grid, Group, SimpleGrid, Text, Title } from "@mantine/core";
import classes from './profile.module.css';
import { addFavorite, getFavorites, removeFavorite } from "@/api/users";
import { getMetars } from "@/api/metar";
import { Metar } from "@/api/metar.types";
import { MdLocationSearching } from 'react-icons/md';
import { useRouter } from "next/navigation";
import { coordinatesState } from "@/state/map";
import { userState } from "@/state/auth";
export default function Page() {
const user = useRecoilValue(userState);
return (
<Grid gutter={80}>
<Grid.Col span={12}>
<Box m="lg">
<Title className={classes.title} order={2}>
{user?.first_name} {user?.last_name}
</Title>
<hr />
<Text c="dimmed">
</Text>
</Box>
</Grid.Col>
<Grid.Col span={12}>
<TopSection />
</Grid.Col>
</Grid>
);
}
function TopSection() {
const [airports, setAirports] = useState<Airport[]>([]);
const [metars, setMetars] = useState<Metar[]>([]);
const [search, setSearch] = useState<string>('');
const [searchAirports, setSearchAirports] = useState<Airport[]>([]);
const router = useRouter();
const [_, setCoordinates] = useRecoilState(coordinatesState);
useEffect(() => {
updateFavorites();
}, []);
function metarColor(metar?: Metar): string {
switch (metar?.flight_category) {
case 'VFR':
return 'green';
case 'MVFR':
return 'blue';
case 'IFR':
return 'red';
case 'LIFR':
return 'purple';
default:
return 'gray';
}
}
function AirportCard(airport: Airport) {
let metar = metars.find((m) => m.station_id === airport.icao);
let color = metarColor(metar);
let text = metar?.flight_category || 'UNKN';
return (
<Card key={airport.icao} shadow="sm" padding="lg" radius="md" withBorder>
<Group justify="space-between" mt="md" mb="xs">
<Text fw={500} style={{ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', width: '20em' }}>{airport.name}</Text>
<Badge color={color} variant="light">{text}</Badge>
</Group>
<Group style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => {
setCoordinates({
lat: airport.latitude,
lon: airport.longitude,
});
router.push('/');
}}>
<MdLocationSearching size={20} />
<Text size="sm" c="dimmed">
{airport.latitude.toFixed(3)}, {airport.longitude.toFixed(3)}
</Text>
</Group>
<Group style={{
display: 'flex',
justifyContent: 'end',
alignItems: 'center',
}}>
<Button
variant="outline"
color="blue"
size="sm"
radius="lg"
style={{ marginTop: '10px' }}
onClick={() => {
router.push(`/airport/${airport.icao}`);
}}
>
View
</Button>
<Button
variant="outline"
color="red"
size="sm"
radius="lg"
style={{ marginTop: '10px' }}
onClick={async () => {
await removeFavorite(airport.icao);
await updateFavorites();
}}
>
Remove
</Button>
</Group>
</Card>
);
}
async function updateFavorites() {
const favorites = await getFavorites();
const m = (await getMetars(favorites)).data;
setMetars(m);
const a = (await getAirports({ icaos: favorites })).data;
setAirports(a);
}
return (
<div className={classes.wrapper}>
<Grid gutter={80}>
<Grid.Col span={{ base: 12, md: 5 }}>
<Title className={classes.title} order={2}>
Logbook
</Title>
<hr />
<Text c="dimmed">
Your logbook is a list of your flights. You can add flights to your logbook by clicking the "Add to logbook" button on the flight page.
</Text>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
<Title className={classes.title} order={2}>
Saved Airports
</Title>
<hr />
<Autocomplete
label='Add an airport to your favorites'
placeholder='ICAO or Airport Name'
value={search}
data={searchAirports.map((a) => ({ value: a.icao, label: `${a.icao} - ${a.name}` }))}
limit={5}
style={{ paddingBottom: '10px' }}
onChange={async (value) => {
setSearch(value);
if (value) {
const a = await getAirports({ icaos: [value], name: value });
setSearchAirports(a.data);
}
}}
onOptionSubmit={async (value) => {
if (!airports.find((a) => a.icao === value)) {
await addFavorite(value);
await updateFavorites();
}
setSearch('');
}}
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={30}>
{airports.map((airport) => AirportCard(airport))}
</SimpleGrid>
</Grid.Col>
</Grid>
</div>
);
}

View File

@@ -1,14 +0,0 @@
.wrapper {
padding: calc(var(--mantine-spacing-xl) * 2) var(--mantine-spacing-xl);
}
.title {
font-family:
Greycliff CF,
var(--mantine-font-family);
font-size: rem(36px);
font-weight: 900;
line-height: 1.1;
margin-bottom: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}

View File

@@ -1,8 +0,0 @@
'use client';
import { RecoilRoot } from 'recoil';
import React, { ReactNode } from 'react';
export default function RecoilRootWrapper({ children }: { children: ReactNode }) {
return <RecoilRoot>{children}</RecoilRoot>;
}

Some files were not shown because too many files have changed in this diff Show More