Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa3ea85200 | |||
| a9dc5ffdc1 | |||
| 84312d0b50 | |||
| 8844ee75fe | |||
| 995e86f229 | |||
| 263c33fd5a | |||
| 7dedc7a8dc | |||
| 25608db372 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
postgres/
|
||||||
|
postgres_logs/
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
|||||||
4775
Cargo.lock
generated
Normal file
4775
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
Cargo.toml
Normal file
4
Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[workspace]
|
||||||
|
|
||||||
|
members = [ "crates/adsb", "crates/api", "crates/lib", "crates/scheduler" ]
|
||||||
|
resolver = "2"
|
||||||
6
Makefile
6
Makefile
@@ -106,7 +106,7 @@ build: version=$(if $(v),$(v),latest)
|
|||||||
build: folder=$(if $(f),$(f),nginx)
|
build: folder=$(if $(f),$(f),nginx)
|
||||||
build: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
|
build: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
|
||||||
build: image=${registry}/aviation-${folder}:${version}
|
build: image=${registry}/aviation-${folder}:${version}
|
||||||
build: ## Build a specific docker image (`make build f=httpd`)
|
build: ## Build a specific docker image from a folder
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
-f ${folder}/Dockerfile \
|
-f ${folder}/Dockerfile \
|
||||||
-t ${image} \
|
-t ${image} \
|
||||||
@@ -121,10 +121,10 @@ push: folder=$(if $(f),$(f),nginx)
|
|||||||
push: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
|
push: registry=$(if $(r),$(r),gitea.bensherriff.com/bsherriff)
|
||||||
push: platform=$(if $(p),$(p),linux/amd64,linux/arm64)
|
push: platform=$(if $(p),$(p),linux/amd64,linux/arm64)
|
||||||
push: image=${registry}/aviation-${folder}:${version}
|
push: image=${registry}/aviation-${folder}:${version}
|
||||||
push: ## Build and push a specific docker image (`make push f=httpd`)
|
push: ## Build and push a specific docker image from a folder
|
||||||
docker buildx create \
|
docker buildx create \
|
||||||
--use \
|
--use \
|
||||||
--name aviation-builder \
|
--name default-builder \
|
||||||
--platform ${platform} || true; \
|
--platform ${platform} || true; \
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
-f ${folder}/Dockerfile \
|
-f ${folder}/Dockerfile \
|
||||||
|
|||||||
148
Taskfile.yml
Normal file
148
Taskfile.yml
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# https://taskfile.dev
|
||||||
|
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
dotenv: ['.env.local', '.env']
|
||||||
|
|
||||||
|
vars:
|
||||||
|
version: '{{ coalesce .version .v "latest" }}'
|
||||||
|
folder: '{{ coalesce .folder .f "nginx" }}'
|
||||||
|
registry: '{{ coalesce .registry .r "gitea.bensherriff.com/bsherriff" }}'
|
||||||
|
platform: '{{ coalesce .platform .p "linux/amd64,linux/arm64" }}'
|
||||||
|
image: '{{.registry}}/aviation-{{.folder}}:{{.version}}'
|
||||||
|
build_date:
|
||||||
|
sh: date -u +%Y-%m-%dT%H:%M:%SZ
|
||||||
|
vcs_ref:
|
||||||
|
sh: git rev-parse HEAD
|
||||||
|
context: '{{ coalesce .context .ctx "." }}'
|
||||||
|
dockerfile: >
|
||||||
|
{{- if or (eq .folder "nginx") (eq .folder "ui") -}}
|
||||||
|
{{.folder}}/Dockerfile
|
||||||
|
{{- else if eq .folder "api" -}}
|
||||||
|
crates/api/Dockerfile
|
||||||
|
{{- else if eq .folder "scheduler" -}}
|
||||||
|
crates/scheduler/Dockerfile
|
||||||
|
{{- else -}}
|
||||||
|
{{ fail (printf "Invalid folder '%s'. Valid: nginx, ui, api, scheduler" .folder) }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
cmds:
|
||||||
|
- task: docker-up
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
test:
|
||||||
|
cmds:
|
||||||
|
- task: docker-backend
|
||||||
|
- task: dev-servers
|
||||||
|
|
||||||
|
dev-servers:
|
||||||
|
deps:
|
||||||
|
- task: run-api
|
||||||
|
- task: run-scheduler
|
||||||
|
- task: run-ui
|
||||||
|
|
||||||
|
# API Commands
|
||||||
|
build-api:
|
||||||
|
dir: crates/api
|
||||||
|
cmd: cargo build
|
||||||
|
format-api:
|
||||||
|
dir: crates/api
|
||||||
|
cmd: cargo fmt
|
||||||
|
run-api:
|
||||||
|
dir: crates/api
|
||||||
|
cmd: cargo run
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
# Scheduler Commands
|
||||||
|
build-scheduler:
|
||||||
|
dir: crates/scheduler
|
||||||
|
cmd: cargo build
|
||||||
|
format-scheduler:
|
||||||
|
dir: crates/scheduler
|
||||||
|
cmd: cargo fmt
|
||||||
|
run-scheduler:
|
||||||
|
dir: crates/scheduler
|
||||||
|
cmd: cargo run
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
# UI Commands
|
||||||
|
build-ui:
|
||||||
|
dir: ui
|
||||||
|
cmd: npm run build
|
||||||
|
format-ui:
|
||||||
|
dir: ui
|
||||||
|
cmd: npm run format
|
||||||
|
clean-ui:
|
||||||
|
dir: ui
|
||||||
|
cmd: rm -rf node_modules dist stats.html
|
||||||
|
run-ui:
|
||||||
|
dir: ui
|
||||||
|
cmd: npm run dev
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
# Docker Commands
|
||||||
|
docker-backend:
|
||||||
|
cmd: docker compose --profile backend up -d
|
||||||
|
docker-up:
|
||||||
|
cmd: docker compose --profile backend --profile api up -d
|
||||||
|
docker-down:
|
||||||
|
cmd: docker compose --profile backend --profile api down
|
||||||
|
docker-clean:
|
||||||
|
cmd: docker compose --profile backend --profile api down -v
|
||||||
|
docker-refresh:
|
||||||
|
cmds:
|
||||||
|
- task: docker-clean
|
||||||
|
- task: docker-up
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build a specific docker image from a folder
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
docker buildx build \
|
||||||
|
-f {{.dockerfile}} \
|
||||||
|
-t {{.image}} \
|
||||||
|
--load \
|
||||||
|
--build-arg BUILD_DATE={{.build_date}} \
|
||||||
|
--build-arg BUILD_VERSION={{.version}} \
|
||||||
|
--build-arg VCS_REF={{.vcs_ref}} \
|
||||||
|
{{.context}}
|
||||||
|
|
||||||
|
push:
|
||||||
|
desc: Build and push a specific docker image from a folder
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
docker buildx create \
|
||||||
|
--use \
|
||||||
|
--driver docker-container \
|
||||||
|
--bootstrap \
|
||||||
|
--name default-builder \
|
||||||
|
--platform {{.platform}} \
|
||||||
|
|| true
|
||||||
|
ignore_error: true
|
||||||
|
- |
|
||||||
|
docker buildx build \
|
||||||
|
-f {{.dockerfile}} \
|
||||||
|
--platform {{.platform}} \
|
||||||
|
-t {{.image}} \
|
||||||
|
--push \
|
||||||
|
--build-arg BUILD_DATE={{.build_date}} \
|
||||||
|
--build-arg BUILD_VERSION={{.version}} \
|
||||||
|
--build-arg VCS_REF={{.vcs_ref}} \
|
||||||
|
{{.context}}
|
||||||
|
.
|
||||||
|
|
||||||
|
psql:
|
||||||
|
cmd: docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off
|
||||||
|
|
||||||
|
cert:
|
||||||
|
cmds:
|
||||||
|
- ./scripts/generate_ca_cert.sh
|
||||||
|
- ./scripts/generate_server_cert.sh ${TLS_HOST} nginx
|
||||||
|
- ./scripts/generate_server_cert.sh ${API_HOST} api
|
||||||
|
silent: true
|
||||||
|
cert-clean:
|
||||||
|
cmds:
|
||||||
|
- rm -rf ./data/certificates
|
||||||
|
silent: true
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
indent_style = "Block"
|
|
||||||
reorder_imports = false
|
|
||||||
tab_spaces = 2
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
mod constants;
|
|
||||||
mod device;
|
|
||||||
mod error;
|
|
||||||
mod frame;
|
|
||||||
mod hex;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use crate::device::RtlSdrDevice;
|
|
||||||
use clap::Parser;
|
|
||||||
use crate::constants::DEVICE_RTL2832U;
|
|
||||||
use crate::frame::ADSBFrame;
|
|
||||||
use crate::hex::hex_to_bytes;
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(author, version, about = "An ADS-B Receiver")]
|
|
||||||
struct ReceiverArgs {
|
|
||||||
/// Hex-string to decode
|
|
||||||
#[arg(short = 'd', long)]
|
|
||||||
decode: Option<String>,
|
|
||||||
|
|
||||||
/// Connect to the USB device
|
|
||||||
#[arg(short = 'c', long, action)]
|
|
||||||
connect: bool,
|
|
||||||
|
|
||||||
/// Display ADS-B/Mode-S receiver info
|
|
||||||
#[arg(short = 'i', long, action)]
|
|
||||||
info: bool,
|
|
||||||
|
|
||||||
/// Enable debug logging
|
|
||||||
#[arg(short = 'D', long, action = clap::ArgAction::Count)]
|
|
||||||
debug: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args = ReceiverArgs::parse();
|
|
||||||
|
|
||||||
let default_filter = match args.debug {
|
|
||||||
0 => "warn,adsb=info", // no -D
|
|
||||||
1 => "warn,adsb=debug", // -D
|
|
||||||
_ => "trace,adsb=trace", // -DD or more
|
|
||||||
};
|
|
||||||
|
|
||||||
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", default_filter));
|
|
||||||
|
|
||||||
let device_info = DEVICE_RTL2832U;
|
|
||||||
|
|
||||||
// Handle connection
|
|
||||||
if args.connect {
|
|
||||||
log::info!("Connecting to {:?}", device_info);
|
|
||||||
let mut device = match RtlSdrDevice::open(device_info.vid, device_info.pid) {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(err) => {
|
|
||||||
log::error!("Unable to open RTL SDR device: {:?}", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
log::debug!("Connected to {:?}", device_info.to_string());
|
|
||||||
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
if let Err(err) = ctrlc::set_handler({
|
|
||||||
let running = running.clone();
|
|
||||||
move || running.store(false, Ordering::SeqCst)
|
|
||||||
}) {
|
|
||||||
log::error!("Error setting Ctrl-C handler: {}", err);
|
|
||||||
running.store(false, Ordering::SeqCst);
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = device.process(running) {
|
|
||||||
log::error!("Failed to read from device: {}", err);
|
|
||||||
if let Err(err) = device.close() {
|
|
||||||
log::error!("Failed to close device: {}", err);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Display dongle info
|
|
||||||
else if args.info {
|
|
||||||
RtlSdrDevice::info(device_info.vid, device_info.pid);
|
|
||||||
}
|
|
||||||
// Handle decode mode
|
|
||||||
else if let Some(mut hex_string) = args.decode {
|
|
||||||
if let Some(stripped) = hex_string.strip_prefix("0x") {
|
|
||||||
hex_string = stripped.to_string();
|
|
||||||
}
|
|
||||||
let buffer = match hex_to_bytes(&hex_string) {
|
|
||||||
Ok(buffer) => buffer,
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("Unable to convert hex to bytes: {:?}", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Ok(frame) = ADSBFrame::decode(&buffer) {
|
|
||||||
println!("{:?}", frame);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
eprintln!("No connection specified");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "api"
|
|
||||||
version = "0.1.2"
|
|
||||||
edition = "2024"
|
|
||||||
authors = ["Ben Sherriff <ben@bensherriff.com>"]
|
|
||||||
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
|
|
||||||
readme = "../README.md"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
actix-web = "4.10.2"
|
|
||||||
actix-cors = "0.7.1"
|
|
||||||
actix-multipart = "0.7.2"
|
|
||||||
chrono = { version = "0.4.41", features = ["serde"] }
|
|
||||||
dotenv = "0.15.0"
|
|
||||||
sqlx = { version = "0.8.5", 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.45.0", features = ["macros", "rt", "time"] }
|
|
||||||
uuid = { version = "1.16.0", features = ["serde", "v4"] }
|
|
||||||
log = "0.4.27"
|
|
||||||
argon2 = "0.5.3"
|
|
||||||
redis = { version = "0.31.0", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
|
|
||||||
regex = "1.11.1"
|
|
||||||
futures-util = "0.3.31"
|
|
||||||
rust-s3 = "0.35.1"
|
|
||||||
rand = "0.9.1"
|
|
||||||
rand_chacha = "0.9.0"
|
|
||||||
futures = "0.3.31"
|
|
||||||
utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] }
|
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
|
|
||||||
utoipa-actix-web = "0.1.2"
|
|
||||||
webpki-roots = "1.0.0"
|
|
||||||
lettre = { version = "0.11.16", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
|
|
||||||
handlebars = "6.3.2"
|
|
||||||
governor = "0.10.0"
|
|
||||||
flate2 = "1.1.1"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
indent_style = "Block"
|
|
||||||
reorder_imports = true
|
|
||||||
tab_spaces = 2
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod model;
|
|
||||||
pub mod routes;
|
|
||||||
|
|
||||||
pub use model::*;
|
|
||||||
pub use routes::init_routes;
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
use crate::error::ApiResult;
|
|
||||||
use redis::{Client as RedisClient, RedisResult, aio::MultiplexedConnection as RedisConnection};
|
|
||||||
use s3::{Bucket, BucketConfiguration, Region, creds::Credentials, request::ResponseData};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::postgres::PgPoolOptions;
|
|
||||||
use sqlx::{Pool, Postgres};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
static POOL: OnceLock<Pool<Postgres>> = OnceLock::new();
|
|
||||||
static REDIS: OnceLock<RedisClient> = OnceLock::new();
|
|
||||||
static BUCKET: OnceLock<Bucket> = OnceLock::new();
|
|
||||||
|
|
||||||
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_DB").unwrap_or("aviation_db".to_string());
|
|
||||||
|
|
||||||
let db_url = format!(
|
|
||||||
"postgres://{}:{}@{}:{}/{}",
|
|
||||||
&user, &password, &host, &port, &name
|
|
||||||
);
|
|
||||||
|
|
||||||
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?
|
|
||||||
};
|
|
||||||
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")
|
|
||||||
};
|
|
||||||
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: url.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let credentials = Credentials {
|
|
||||||
access_key: Some(user),
|
|
||||||
secret_key: Some(password),
|
|
||||||
security_token: None,
|
|
||||||
session_token: None,
|
|
||||||
expiration: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style();
|
|
||||||
log::info!("Checking for object in bucket at {}", ®ion.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) => 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.get().unwrap().put_object(path, content).await?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_file(path: &str) -> ApiResult<Vec<u8>> {
|
|
||||||
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.get().unwrap().delete_object(path).await?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct Paged<T> {
|
|
||||||
pub data: T,
|
|
||||||
pub page: u32,
|
|
||||||
pub limit: u32,
|
|
||||||
pub total: i64,
|
|
||||||
}
|
|
||||||
230
api/src/error.rs
230
api/src/error.rs
@@ -1,230 +0,0 @@
|
|||||||
use actix_web::http::StatusCode;
|
|
||||||
use actix_web::{HttpResponse, ResponseError};
|
|
||||||
use log::warn;
|
|
||||||
use regex::Regex;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::json;
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
pub type ApiResult<T> = Result<T, Error>;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
pub struct Error {
|
|
||||||
pub status: u16,
|
|
||||||
pub details: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error {
|
|
||||||
pub fn new(status: u16, message: String) -> Self {
|
|
||||||
Self {
|
|
||||||
status,
|
|
||||||
details: message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_http_response(&self) -> HttpResponse {
|
|
||||||
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).json(json!({ "status": status_code, "details": details }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<std::io::Error> for Error {
|
|
||||||
fn from(error: std::io::Error) -> Self {
|
|
||||||
Self::new(500, format!("Unknown IO error: {}", error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
format!("Unknown environment variable error: {}", error),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> for Error {
|
|
||||||
fn from(error: reqwest::Error) -> Self {
|
|
||||||
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 Error {
|
|
||||||
fn from(error: serde_json::Error) -> Self {
|
|
||||||
Self::new(500, format!("Unknown serde_json error: {}", error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Error {
|
|
||||||
fn from(error: redis::RedisError) -> Self {
|
|
||||||
Self::new(500, format!("Unknown redis error: {}", error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<s3::error::S3Error> for Error {
|
|
||||||
fn from(error: s3::error::S3Error) -> Self {
|
|
||||||
match error {
|
|
||||||
s3::error::S3Error::Credentials(err) => {
|
|
||||||
Self::new(500, format!("Unknown s3 credentials error: {}", err))
|
|
||||||
}
|
|
||||||
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::HeaderToStr(err) => {
|
|
||||||
Self::new(500, format!("Unknown s3 header to str error: {}", err))
|
|
||||||
}
|
|
||||||
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
|
|
||||||
500,
|
|
||||||
format!("Unknown s3 hmac invalid length error: {}", err),
|
|
||||||
),
|
|
||||||
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
|
|
||||||
if let Some(captures) = re.captures(&error.to_string()) {
|
|
||||||
if let Some(http_code_str) = captures.get(1) {
|
|
||||||
if let Ok(http_code) = http_code_str.as_str().parse::<u16>() {
|
|
||||||
return Self::new(http_code, error.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self::new(500, format!("Unknown s3 error: {}", error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<lettre::address::AddressError> for Error {
|
|
||||||
fn from(error: lettre::address::AddressError) -> Self {
|
|
||||||
Error::new(500, error.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<lettre::error::Error> for Error {
|
|
||||||
fn from(error: lettre::error::Error) -> Self {
|
|
||||||
Error::new(500, error.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<lettre::transport::smtp::Error> for Error {
|
|
||||||
fn from(error: lettre::transport::smtp::Error) -> Self {
|
|
||||||
Error::new(500, error.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for Error {
|
|
||||||
fn from(error: String) -> Self {
|
|
||||||
Self::new(500, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
use crate::http_client::HttpClient;
|
|
||||||
use crate::metars::Metar;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::time::interval;
|
|
||||||
|
|
||||||
pub fn update_metars(client: Arc<HttpClient>, seconds: u64) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
// Create interval ticker
|
|
||||||
let mut interval = interval(Duration::from_secs(seconds));
|
|
||||||
let mut etag = None;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
|
|
||||||
// Record start times
|
|
||||||
let start_monotonic = Instant::now();
|
|
||||||
let start_utc: DateTime<Utc> = Utc::now();
|
|
||||||
log::debug!("METAR update started at {}", start_utc);
|
|
||||||
|
|
||||||
// Run the update
|
|
||||||
match Metar::update_metars(&client, etag.clone()).await {
|
|
||||||
Ok(new_etag) => etag = Some(new_etag),
|
|
||||||
Err(err) => log::error!("METAR update failed: {}", err),
|
|
||||||
}
|
|
||||||
|
|
||||||
let elapsed = start_monotonic.elapsed();
|
|
||||||
let next_utc = Utc::now() + chrono::Duration::from_std(Duration::from_secs(seconds)).unwrap();
|
|
||||||
log::info!(
|
|
||||||
"METAR update finished in {:.2?}; next run at {}",
|
|
||||||
elapsed,
|
|
||||||
next_utc
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mod model;
|
|
||||||
mod routes;
|
|
||||||
|
|
||||||
pub use model::*;
|
|
||||||
pub use routes::init_routes;
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// use actix_multipart::Multipart;
|
|
||||||
// use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
|
|
||||||
// use futures_util::StreamExt;
|
|
||||||
|
|
||||||
// use crate::{
|
|
||||||
// auth::Auth,
|
|
||||||
// db::{delete_file, get_file, upload_file},
|
|
||||||
// error::ServiceError,
|
|
||||||
// users::User,
|
|
||||||
// };
|
|
||||||
|
|
||||||
// #[get("/favorites")]
|
|
||||||
// async fn get_favorites(auth: Auth) -> HttpResponse {
|
|
||||||
// match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => return HttpResponse::Ok().json(user.favorites),
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[post("/favorites/{icao}")]
|
|
||||||
// async fn add_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
|
||||||
// match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => {
|
|
||||||
// if user.favorites.contains(&icao) {
|
|
||||||
// // Check if the airport ICAO is already in the user's favorites
|
|
||||||
// return HttpResponse::Conflict().finish();
|
|
||||||
// } else {
|
|
||||||
// // Add the airport ICAO to the user's favorites
|
|
||||||
// let mut favorites = user.favorites;
|
|
||||||
// favorites.push(icao.into_inner());
|
|
||||||
// match User::update_favorites(&user.email, favorites) {
|
|
||||||
// Ok(_) => return HttpResponse::Ok().finish(),
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[delete("/favorites/{icao}")]
|
|
||||||
// async fn delete_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
|
||||||
// let icao: String = icao.into_inner();
|
|
||||||
// match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => {
|
|
||||||
// if user.favorites.contains(&icao) {
|
|
||||||
// // Check if the airport ICAO is already in the user's favorites
|
|
||||||
// let mut favorites = user.favorites;
|
|
||||||
// favorites.retain(|x| x != &icao);
|
|
||||||
// match User::update_favorites(&user.email, favorites) {
|
|
||||||
// Ok(_) => return HttpResponse::Ok().finish(),
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// // Remove the airport ICAO from the user's favorites
|
|
||||||
// return HttpResponse::Conflict().finish();
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[post("/picture")]
|
|
||||||
// async fn set_picture(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
|
||||||
// while let Some(item) = payload.next().await {
|
|
||||||
// let mut bytes = web::BytesMut::new();
|
|
||||||
// let mut field = match item {
|
|
||||||
// Ok(field) => field,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// let content_type = field.content_disposition();
|
|
||||||
// let filename = match content_type.unwrap().get_filename() {
|
|
||||||
// Some(name) => match name.split(".").last() {
|
|
||||||
// Some(ext) => match ext {
|
|
||||||
// "apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg"
|
|
||||||
// | "webp" => name,
|
|
||||||
// _ => {
|
|
||||||
// return ResponseError::error_response(&ServiceError::new(
|
|
||||||
// 400,
|
|
||||||
// "File extension is not supported".to_string(),
|
|
||||||
// ))
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// None => {
|
|
||||||
// return ResponseError::error_response(&ServiceError::new(
|
|
||||||
// 400,
|
|
||||||
// "Unknown file extension".to_string(),
|
|
||||||
// ))
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// None => {
|
|
||||||
// return ResponseError::error_response(&ServiceError::new(
|
|
||||||
// 400,
|
|
||||||
// "File name is not provided".to_string(),
|
|
||||||
// ))
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
// let path = format!("users/{}/{}", auth.user.email, filename);
|
|
||||||
|
|
||||||
// while let Some(chunk) = field.next().await {
|
|
||||||
// let data = match chunk {
|
|
||||||
// Ok(data) => data,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// bytes.extend_from_slice(&data);
|
|
||||||
// }
|
|
||||||
// match upload_file(&path, &bytes).await {
|
|
||||||
// Ok(_) => {
|
|
||||||
// match User::update_profile_picture(&auth.user.email, Some(&path)) {
|
|
||||||
// Ok(_) => {}
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// HttpResponse::Ok().finish()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[get("/picture")]
|
|
||||||
// async fn get_picture(auth: Auth) -> HttpResponse {
|
|
||||||
// let user = match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => user,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// if let Some(path) = user.profile_picture {
|
|
||||||
// match get_file(&path).await {
|
|
||||||
// Ok(bytes) => HttpResponse::Ok().body(bytes),
|
|
||||||
// Err(err) => ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// HttpResponse::NotFound().finish()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[delete("/picture")]
|
|
||||||
// async fn delete_picture(auth: Auth) -> HttpResponse {
|
|
||||||
// let user = match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => user,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// if let Some(path) = user.profile_picture {
|
|
||||||
// match delete_file(&path).await {
|
|
||||||
// Ok(_) => match User::update_profile_picture(&auth.user.email, None) {
|
|
||||||
// Ok(_) => HttpResponse::Ok().finish(),
|
|
||||||
// Err(err) => ResponseError::error_response(&err),
|
|
||||||
// },
|
|
||||||
// Err(err) => ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// HttpResponse::NotFound().finish()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
use utoipa_actix_web::service_config::ServiceConfig;
|
|
||||||
|
|
||||||
pub fn init_routes(_config: &mut ServiceConfig) {
|
|
||||||
// config.service(
|
|
||||||
// web::scope("users")
|
|
||||||
// .service(get_favorites)
|
|
||||||
// .service(add_favorite)
|
|
||||||
// .service(delete_favorite)
|
|
||||||
// .service(set_picture)
|
|
||||||
// .service(get_picture)
|
|
||||||
// .service(delete_picture),
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Change Password
|
name: Change Password
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 7
|
||||||
}
|
}
|
||||||
|
|
||||||
put {
|
put {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Confirm Password Reset
|
name: Confirm Password Reset
|
||||||
type: http
|
type: http
|
||||||
seq: 8
|
seq: 9
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Get Profile
|
name: Get Profile
|
||||||
type: http
|
type: http
|
||||||
seq: 10
|
seq: 11
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
|
|||||||
18
bruno/Account/Login Admin (Default).bru
Normal file
18
bruno/Account/Login Admin (Default).bru
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: Login Admin (Default)
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{API_URL}}/account/login
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "changeme"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Logout
|
name: Logout
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 6
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Refresh Session
|
name: Refresh Session
|
||||||
type: http
|
type: http
|
||||||
seq: 9
|
seq: 10
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Reset Password
|
name: Reset Password
|
||||||
type: http
|
type: http
|
||||||
seq: 7
|
seq: 8
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
|
|||||||
0
adsb/Cargo.lock → crates/adsb/Cargo.lock
generated
0
adsb/Cargo.lock → crates/adsb/Cargo.lock
generated
@@ -1,3 +1,3 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "stable"
|
channel = "nightly"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
231
crates/adsb/src/main.rs
Normal file
231
crates/adsb/src/main.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
#![feature(extern_types)]
|
||||||
|
|
||||||
|
mod constants;
|
||||||
|
mod device;
|
||||||
|
mod error;
|
||||||
|
mod frame;
|
||||||
|
mod hex;
|
||||||
|
|
||||||
|
use std::ffi::{c_char, c_int, c_void, CStr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use crate::device::RtlSdrDevice;
|
||||||
|
use clap::Parser;
|
||||||
|
use crate::constants::DEVICE_RTL2832U;
|
||||||
|
use crate::frame::ADSBFrame;
|
||||||
|
use crate::hex::hex_to_bytes;
|
||||||
|
|
||||||
|
#[link(name = "rtlsdr")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn rtlsdr_get_device_count() -> u32;
|
||||||
|
fn rtlsdr_get_device_name(index: u32) -> *const c_char;
|
||||||
|
fn rtlsdr_get_device_usb_strings(
|
||||||
|
index: u32,
|
||||||
|
manufact: *mut c_char,
|
||||||
|
product: *mut c_char,
|
||||||
|
serial: *mut c_char,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
type rtlsdr_dev_t;
|
||||||
|
|
||||||
|
fn rtlsdr_open(dev: *mut *mut rtlsdr_dev_t, index: u32) -> c_int;
|
||||||
|
fn rtlsdr_close(dev: *mut rtlsdr_dev_t) -> c_int;
|
||||||
|
|
||||||
|
fn rtlsdr_set_sample_rate(dev: *mut rtlsdr_dev_t, rate: u32) -> c_int;
|
||||||
|
fn rtlsdr_set_center_freq(dev: *mut rtlsdr_dev_t, freq: u32) -> c_int;
|
||||||
|
fn rtlsdr_set_agc_mode(dev: *mut rtlsdr_dev_t, on: c_int) -> c_int;
|
||||||
|
fn rtlsdr_set_tuner_gain_mode(dev: *mut rtlsdr_dev_t, manual: c_int) -> c_int;
|
||||||
|
fn rtlsdr_reset_buffer(dev: *mut rtlsdr_dev_t) -> c_int;
|
||||||
|
|
||||||
|
fn rtlsdr_read_sync(
|
||||||
|
dev: *mut rtlsdr_dev_t,
|
||||||
|
buf: *mut c_void,
|
||||||
|
len: c_int,
|
||||||
|
n_read: *mut c_int,
|
||||||
|
) -> c_int;
|
||||||
|
}
|
||||||
|
struct Device {
|
||||||
|
ptr: *mut rtlsdr_dev_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device {
|
||||||
|
fn open(index: u32) -> Result<Self, String> {
|
||||||
|
let mut dev: *mut rtlsdr_dev_t = std::ptr::null_mut();
|
||||||
|
let rc = unsafe { rtlsdr_open(&mut dev as *mut _, index) };
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(format!("Failed to open device: {}", rc));
|
||||||
|
}
|
||||||
|
Ok(Self { ptr: dev })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sample_rate(&mut self, rate: u32) -> Result<(), String> {
|
||||||
|
let rc = unsafe { rtlsdr_set_sample_rate(self.ptr, rate) };
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(format!("Failed to set sample rate: {}", rc));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_center_freq(&mut self, freq: u32) -> Result<(), String> {
|
||||||
|
let rc = unsafe { rtlsdr_set_center_freq(self.ptr, freq) };
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(format!("Failed to set center freq: {}", rc));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_agc(&mut self, on: bool) -> Result<(), String> {
|
||||||
|
let rc = unsafe { rtlsdr_set_agc_mode(self.ptr, if on { 1 } else { 0 }) };
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(format!("Failed to set AGC: {}", rc));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// manual = false -> auto-gain, manual = true -> set a gain
|
||||||
|
fn set_tuner_gain_mode(&mut self, manual: bool) -> Result<(), String> {
|
||||||
|
let rc = unsafe { rtlsdr_set_tuner_gain_mode(self.ptr, if manual { 1 } else { 0 }) };
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(format!("Failed to set tuner gain mode: {}", rc));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_buffer(&mut self) -> Result<(), String> {
|
||||||
|
let rc = unsafe { rtlsdr_reset_buffer(self.ptr) };
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(format!("Failed to reset buffer: {}", rc));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the number of bytes placed in buf (I/Q interleaved, u8
|
||||||
|
fn read_sync(&mut self, buf: &mut [u8]) -> Result<usize, String> {
|
||||||
|
let mut n_read: c_int = 0;
|
||||||
|
let rc = unsafe {
|
||||||
|
rtlsdr_read_sync(
|
||||||
|
self.ptr,
|
||||||
|
buf.as_mut_ptr() as *mut c_void,
|
||||||
|
buf.len() as c_int,
|
||||||
|
&mut n_read as *mut _,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(format!("Failed to read sync: {}", rc));
|
||||||
|
}
|
||||||
|
Ok(n_read as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Device {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.ptr.is_null() {
|
||||||
|
unsafe { rtlsdr_close(self.ptr) };
|
||||||
|
self.ptr = std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cstr_from_ptr(ptr: *const c_char) -> String {
|
||||||
|
if ptr.is_null() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
unsafe {
|
||||||
|
let count = rtlsdr_get_device_count();
|
||||||
|
log::debug!("Found {} devices", count);
|
||||||
|
for i in 0..count {
|
||||||
|
let name = cstr_from_ptr(rtlsdr_get_device_name(i));
|
||||||
|
log::debug!("Device {}: {}", i, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[derive(Parser, Debug)]
|
||||||
|
// #[command(author, version, about = "An ADS-B Receiver")]
|
||||||
|
// struct ReceiverArgs {
|
||||||
|
// /// Hex-string to decode
|
||||||
|
// #[arg(short = 'd', long)]
|
||||||
|
// decode: Option<String>,
|
||||||
|
//
|
||||||
|
// /// Connect to the USB device
|
||||||
|
// #[arg(short = 'c', long, action)]
|
||||||
|
// connect: bool,
|
||||||
|
//
|
||||||
|
// /// Display ADS-B/Mode-S receiver info
|
||||||
|
// #[arg(short = 'i', long, action)]
|
||||||
|
// info: bool,
|
||||||
|
//
|
||||||
|
// /// Enable debug logging
|
||||||
|
// #[arg(short = 'D', long, action = clap::ArgAction::Count)]
|
||||||
|
// debug: u8,
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// fn main() {
|
||||||
|
// let args = ReceiverArgs::parse();
|
||||||
|
//
|
||||||
|
// let default_filter = match args.debug {
|
||||||
|
// 0 => "warn,adsb=info", // no -D
|
||||||
|
// 1 => "warn,adsb=debug", // -D
|
||||||
|
// _ => "trace,adsb=trace", // -DD or more
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", default_filter));
|
||||||
|
//
|
||||||
|
// let device_info = DEVICE_RTL2832U;
|
||||||
|
//
|
||||||
|
// // Handle connection
|
||||||
|
// if args.connect {
|
||||||
|
// log::info!("Connecting to {:?}", device_info);
|
||||||
|
// let mut device = match RtlSdrDevice::open(device_info.vid, device_info.pid) {
|
||||||
|
// Ok(d) => d,
|
||||||
|
// Err(err) => {
|
||||||
|
// log::error!("Unable to open RTL SDR device: {:?}", err);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// log::debug!("Connected to {:?}", device_info.to_string());
|
||||||
|
//
|
||||||
|
// let running = Arc::new(AtomicBool::new(true));
|
||||||
|
// if let Err(err) = ctrlc::set_handler({
|
||||||
|
// let running = running.clone();
|
||||||
|
// move || running.store(false, Ordering::SeqCst)
|
||||||
|
// }) {
|
||||||
|
// log::error!("Error setting Ctrl-C handler: {}", err);
|
||||||
|
// running.store(false, Ordering::SeqCst);
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// if let Err(err) = device.process(running) {
|
||||||
|
// log::error!("Failed to read from device: {}", err);
|
||||||
|
// if let Err(err) = device.close() {
|
||||||
|
// log::error!("Failed to close device: {}", err);
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// // Display dongle info
|
||||||
|
// else if args.info {
|
||||||
|
// RtlSdrDevice::info(device_info.vid, device_info.pid);
|
||||||
|
// }
|
||||||
|
// // Handle decode mode
|
||||||
|
// else if let Some(mut hex_string) = args.decode {
|
||||||
|
// if let Some(stripped) = hex_string.strip_prefix("0x") {
|
||||||
|
// hex_string = stripped.to_string();
|
||||||
|
// }
|
||||||
|
// let buffer = match hex_to_bytes(&hex_string) {
|
||||||
|
// Ok(buffer) => buffer,
|
||||||
|
// Err(err) => {
|
||||||
|
// eprintln!("Unable to convert hex to bytes: {:?}", err);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// if let Ok(frame) = ADSBFrame::decode(&buffer) {
|
||||||
|
// println!("{:?}", frame);
|
||||||
|
// };
|
||||||
|
// } else {
|
||||||
|
// eprintln!("No connection specified");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
524
api/Cargo.lock → crates/api/Cargo.lock
generated
524
api/Cargo.lock → crates/api/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
29
crates/api/Cargo.toml
Normal file
29
crates/api/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "api"
|
||||||
|
version = "0.1.3"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["Ben Sherriff <ben@bensherriff.com>"]
|
||||||
|
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
|
||||||
|
readme = "../../README.md"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
lib = { path = "../lib" }
|
||||||
|
actix-web = "4.11.0"
|
||||||
|
actix-cors = "0.7.1"
|
||||||
|
actix-multipart = "0.7.2"
|
||||||
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
|
dotenv = "0.15.0"
|
||||||
|
env_logger = "0.11.8"
|
||||||
|
serde = {version = "1.0.219", features = ["derive"]}
|
||||||
|
serde_json = "1.0.142"
|
||||||
|
tokio = { version = "1.47.1", features = ["macros", "rt", "time"] }
|
||||||
|
log = "0.4.28"
|
||||||
|
argon2 = "0.5.3"
|
||||||
|
futures-util = "0.3.31"
|
||||||
|
utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] }
|
||||||
|
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
|
||||||
|
utoipa-actix-web = "0.1.2"
|
||||||
|
lettre = { version = "0.11.18", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
|
||||||
|
handlebars = "6.3.2"
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
FROM rust:bookworm AS builder
|
FROM rust:bookworm AS builder
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
|
|
||||||
COPY api/migrations ./migrations
|
COPY crates/lib /lib
|
||||||
COPY api/src ./src
|
COPY crates/api/src ./src
|
||||||
COPY api/Cargo.toml ./
|
COPY crates/api/Cargo.toml ./
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y cmake
|
RUN apt-get update && apt-get install -y cmake
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
use super::{SESSION_COOKIE_NAME, Session};
|
use super::{SESSION_COOKIE_NAME, Session};
|
||||||
use crate::{error::Error, users::User};
|
use crate::error::{ApiResult, Error};
|
||||||
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http};
|
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http, web};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use lib::accounts::User;
|
||||||
|
use lib::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Auth {
|
pub struct Auth {
|
||||||
@@ -18,23 +20,31 @@ impl FromRequest for Auth {
|
|||||||
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
|
||||||
|
|
||||||
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
||||||
// Check for API key
|
let state = match req.app_data::<web::Data<AppState>>() {
|
||||||
|
Some(state) => state,
|
||||||
|
None => return Box::pin(
|
||||||
|
async { Err(Error::new(500, "Internal server error".to_string()).into()) },
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for an API key
|
||||||
match req
|
match req
|
||||||
.headers()
|
.headers()
|
||||||
.get(http::header::AUTHORIZATION)
|
.get(http::header::AUTHORIZATION)
|
||||||
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
|
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
|
||||||
{
|
{
|
||||||
Some(key_id) => {
|
Some(key_id) => {
|
||||||
|
let state = Arc::clone(&state);
|
||||||
let fut = async move {
|
let fut = async move {
|
||||||
// Check if the Session API key exists
|
// Check if the Session API key exists
|
||||||
let api_key = match Session::get(&key_id).await {
|
let api_key = match Session::get(&state, &key_id).await {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("Invalid session auth attempt: {}", err);
|
log::error!("Invalid session auth attempt: {}", err);
|
||||||
return Err(Error::new(401, "API Key does not exist".to_string()).into());
|
return Err(Error::new(401, "API Key does not exist".to_string()).into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match User::select(&api_key.username).await {
|
match User::select(&state.pool, &api_key.username).await {
|
||||||
Some(user) => Ok(Auth {
|
Some(user) => Ok(Auth {
|
||||||
session_id: None,
|
session_id: None,
|
||||||
api_key: Some(key_id),
|
api_key: Some(key_id),
|
||||||
@@ -77,9 +87,10 @@ impl FromRequest for Auth {
|
|||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
|
|
||||||
// Verify the session
|
// Verify the session
|
||||||
|
let state = Arc::clone(&state); // state: Arc<State>
|
||||||
let fut = async move {
|
let fut = async move {
|
||||||
match Session::verify(&session_id, &ip_address).await {
|
match Session::verify(&state, &session_id, &ip_address).await {
|
||||||
Ok(session) => match User::select(&session.username).await {
|
Ok(session) => match User::select(&state.pool, &session.username).await {
|
||||||
Some(user) => Ok(Auth {
|
Some(user) => Ok(Auth {
|
||||||
session_id: Some(session_id),
|
session_id: Some(session_id),
|
||||||
api_key: None,
|
api_key: None,
|
||||||
@@ -93,3 +104,16 @@ impl FromRequest for Auth {
|
|||||||
Box::pin(fut)
|
Box::pin(fut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Auth {
|
||||||
|
pub fn verify_role(&self, role: &str) -> ApiResult<()> {
|
||||||
|
if self.user.role == role {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(Error {
|
||||||
|
status: 403,
|
||||||
|
details: "User does not have permission to perform this action.".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
use crate::account::{csprng, hash};
|
use lib::accounts::{csprng, hash};
|
||||||
use crate::db::redis_async_connection;
|
|
||||||
use crate::error::{ApiResult, Error};
|
use crate::error::{ApiResult, Error};
|
||||||
use crate::smtp;
|
use crate::smtp;
|
||||||
use chrono::{Datelike, Utc};
|
use chrono::{Datelike, Utc};
|
||||||
use redis::{AsyncCommands, RedisResult};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
|
use lib::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct EmailToken {
|
pub struct EmailToken {
|
||||||
@@ -24,37 +23,28 @@ impl EmailToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn store(&self, ttl_secs: i64) -> ApiResult<()> {
|
pub async fn store(&self, state: &AppState, ttl_secs: i64) -> ApiResult<()> {
|
||||||
let mut conn = redis_async_connection().await?;
|
|
||||||
let key = self.token.clone();
|
let key = self.token.clone();
|
||||||
let value = serde_json::to_string(self)?;
|
let value = serde_json::to_string(self)?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let expires_at = now + chrono::Duration::seconds(ttl_secs);
|
let expires_at = now + chrono::Duration::seconds(ttl_secs);
|
||||||
let ttl = expires_at.timestamp() - now.timestamp();
|
let ttl = expires_at.timestamp() - now.timestamp();
|
||||||
let result: RedisResult<()> = conn.set_ex(key, &value, ttl as u64).await;
|
let _ = state.set_ex(&key, &value, ttl as u64).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(state: &AppState, token: &str) -> ApiResult<Self> {
|
||||||
|
let result: Option<String> = state.get(token).await?;
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(()),
|
Some(value) => Ok(serde_json::from_str(&value)?),
|
||||||
Err(err) => Err(err.into()),
|
None => Err(Error::new(404, format!("Missing email token {}", token))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(token: &str) -> ApiResult<Self> {
|
pub async fn delete(state: &AppState, token: &str) -> ApiResult<()> {
|
||||||
let mut conn = redis_async_connection().await?;
|
let _ = state.del(token).await;
|
||||||
let result: RedisResult<Option<String>> = conn.get(token).await;
|
Ok(())
|
||||||
match result {
|
|
||||||
Ok(Some(value)) => Ok(serde_json::from_str(&value)?),
|
|
||||||
Ok(None) => Err(Error::new(404, format!("Missing email token {}", token))),
|
|
||||||
Err(err) => Err(err.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(token: &str) -> ApiResult<()> {
|
|
||||||
let mut conn = redis_async_connection().await?;
|
|
||||||
let result: RedisResult<()> = conn.del(token).await;
|
|
||||||
match result {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(err) => Err(err.into()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,10 +103,10 @@ pub async fn send_password_reset_email(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_confirm_email(email: &str, ip_address: &str) -> ApiResult<()> {
|
pub async fn send_confirm_email(state: &AppState, email: &str, ip_address: &str) -> ApiResult<()> {
|
||||||
let token = csprng(128);
|
let token = csprng(128);
|
||||||
let email_token = EmailToken::new(email.to_string(), token, &ip_address);
|
let email_token = EmailToken::new(email.to_string(), token, &ip_address);
|
||||||
email_token.store(86400).await?;
|
email_token.store(state, 86400).await?;
|
||||||
|
|
||||||
let base_url = env::var("EXTERNAL_URL")?;
|
let base_url = env::var("EXTERNAL_URL")?;
|
||||||
let link = format!("{base_url}/profile/confirm?token={}", email_token.token);
|
let link = format!("{base_url}/profile/confirm?token={}", email_token.token);
|
||||||
@@ -153,7 +143,7 @@ pub async fn send_confirm_email(email: &str, ip_address: &str) -> ApiResult<()>
|
|||||||
ip_address,
|
ip_address,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
let _ = EmailToken::delete(&email_token.token);
|
let _ = EmailToken::delete(state, &email_token.token);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
7
crates/api/src/accounts/mod.rs
Normal file
7
crates/api/src/accounts/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod auth;
|
||||||
|
mod email_token;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
pub use auth::*;
|
||||||
|
pub use email_token::*;
|
||||||
|
pub use session::*;
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
use super::{csprng, hash, verify_hash};
|
use lib::accounts::{csprng, hash, verify_hash};
|
||||||
use crate::{
|
use crate::error::{ApiResult, Error};
|
||||||
db::redis_async_connection,
|
|
||||||
error::{ApiResult, Error},
|
|
||||||
};
|
|
||||||
use actix_web::cookie::{Cookie, time::Duration};
|
use actix_web::cookie::{Cookie, time::Duration};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use redis::{AsyncCommands, RedisResult};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::task;
|
use lib::error::CoreResult;
|
||||||
|
use lib::state::AppState;
|
||||||
|
|
||||||
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
|
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
|
||||||
pub const SESSION_COOKIE_NAME: &str = "session";
|
pub const SESSION_COOKIE_NAME: &str = "session";
|
||||||
@@ -40,16 +37,15 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn store(&self) -> ApiResult<()> {
|
pub async fn store(&self, state: &AppState) -> ApiResult<()> {
|
||||||
let mut conn = redis_async_connection().await?;
|
|
||||||
let key = self.session_id.clone();
|
let key = self.session_id.clone();
|
||||||
let value = serde_json::to_string(self)?;
|
let value = serde_json::to_string(self)?;
|
||||||
let result: RedisResult<()> = match self.expires_at {
|
let result: CoreResult<()> = match self.expires_at {
|
||||||
Some(expires_at) => {
|
Some(expires_at) => {
|
||||||
let ttl = expires_at.timestamp() - Utc::now().timestamp();
|
let ttl = expires_at.timestamp() - Utc::now().timestamp();
|
||||||
conn.set_ex(key, &value, ttl as u64).await
|
state.set_ex(&key, &value, ttl as u64).await
|
||||||
}
|
}
|
||||||
None => conn.set(key, value).await,
|
None => state.set(&key, &value).await,
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
@@ -57,43 +53,33 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(session_id: &str) -> ApiResult<Self> {
|
pub async fn get(state: &AppState, session_id: &str) -> ApiResult<Self> {
|
||||||
let mut conn = redis_async_connection().await?;
|
let result: Option<String> = state.get(session_id).await?;
|
||||||
let result: RedisResult<Option<String>> = conn.get(session_id).await;
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Some(value)) => Ok(serde_json::from_str(&value)?),
|
Some(value) => Ok(serde_json::from_str(&value)?),
|
||||||
Ok(None) => Err(Error::new(401, format!("Missing session {}", session_id))),
|
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> {
|
pub async fn replace(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
|
||||||
let mut session = Self::verify(session_id, ip_address).await?;
|
let mut session = Self::verify(state, session_id, ip_address).await?;
|
||||||
let session_id_owned = session_id.to_owned();
|
let session_id_owned = session_id.to_owned();
|
||||||
task::spawn(async move {
|
Self::delete(state, &session_id_owned).await?;
|
||||||
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.username, ip_address);
|
session = Session::default(&session.username, ip_address);
|
||||||
session.store().await?;
|
session.store(state).await?;
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(session_id: &str) -> ApiResult<()> {
|
pub async fn delete(state: &AppState, session_id: &str) -> ApiResult<()> {
|
||||||
let mut conn = redis_async_connection().await?;
|
let result: CoreResult<()> = state.del(session_id).await;
|
||||||
let result: RedisResult<()> = conn.del(session_id).await;
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(err) => Err(err.into()),
|
Err(err) => Err(err.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult<Self> {
|
pub async fn verify(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
|
||||||
let session = Self::get(session_id).await?;
|
let session = Self::get(state, session_id).await?;
|
||||||
|
|
||||||
// Check if the IP Address matches the Session's IP Address
|
// Check if the IP Address matches the Session's IP Address
|
||||||
if verify_hash(ip_address, &session.ip_address) {
|
if verify_hash(ip_address, &session.ip_address) {
|
||||||
@@ -103,7 +89,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cookie(&self) -> Cookie {
|
pub fn cookie(&self) -> Cookie<'_> {
|
||||||
let expires_at = match self.expires_at {
|
let expires_at = match self.expires_at {
|
||||||
Some(expires_at) => expires_at.timestamp(),
|
Some(expires_at) => expires_at.timestamp(),
|
||||||
None => DEFAULT_SESSION_TTL,
|
None => DEFAULT_SESSION_TTL,
|
||||||
@@ -131,7 +117,7 @@ impl Session {
|
|||||||
cookie
|
cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expiration_cookie(&self) -> Cookie {
|
pub fn expiration_cookie(&self) -> Cookie<'_> {
|
||||||
let expires_at = match self.expires_at {
|
let expires_at = match self.expires_at {
|
||||||
Some(expires_at) => expires_at.timestamp(),
|
Some(expires_at) => expires_at.timestamp(),
|
||||||
None => DEFAULT_SESSION_TTL,
|
None => DEFAULT_SESSION_TTL,
|
||||||
133
crates/api/src/error.rs
Normal file
133
crates/api/src/error.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{HttpResponse, ResponseError};
|
||||||
|
use log::warn;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::fmt;
|
||||||
|
use lib::error::{CoreError, CoreErrorKind};
|
||||||
|
|
||||||
|
pub type ApiResult<T> = Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Error {
|
||||||
|
pub status: u16,
|
||||||
|
pub details: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn new(status: u16, details: String) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_http_response(&self) -> HttpResponse {
|
||||||
|
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).json(json!({ "status": status_code, "details": details }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Error {
|
||||||
|
fn from(error: std::env::VarError) -> Self {
|
||||||
|
Self::new(
|
||||||
|
500,
|
||||||
|
format!("Unknown environment variable error: {:?}", error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Error {
|
||||||
|
fn from(error: argon2::password_hash::Error) -> Self {
|
||||||
|
Self::new(500, format!("Unknown argon2 error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<lettre::address::AddressError> for Error {
|
||||||
|
fn from(error: lettre::address::AddressError) -> Self {
|
||||||
|
Error::new(500, error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<lettre::error::Error> for Error {
|
||||||
|
fn from(error: lettre::error::Error) -> Self {
|
||||||
|
Error::new(500, error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<lettre::transport::smtp::Error> for Error {
|
||||||
|
fn from(error: lettre::transport::smtp::Error) -> Self {
|
||||||
|
Error::new(500, error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Error {
|
||||||
|
fn from(error: String) -> Self {
|
||||||
|
Self::new(500, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CoreError> for Error {
|
||||||
|
fn from(error: CoreError) -> Self {
|
||||||
|
match error.kind {
|
||||||
|
CoreErrorKind::NotFound => Self::new(404, error.to_string()),
|
||||||
|
CoreErrorKind::InvalidInput => Self::new(400, error.to_string()),
|
||||||
|
CoreErrorKind::Conflict => Self::new(409, error.to_string()),
|
||||||
|
CoreErrorKind::Unauthorized => Self::new(401, error.to_string()),
|
||||||
|
CoreErrorKind::Forbidden => Self::new(403, error.to_string()),
|
||||||
|
CoreErrorKind::PreconditionFailed => Self::new(412, error.to_string()),
|
||||||
|
CoreErrorKind::Timeout => Self::new(408, error.to_string()),
|
||||||
|
CoreErrorKind::Cancelled => Self::new(499, error.to_string()),
|
||||||
|
CoreErrorKind::Unavailable => Self::new(503, error.to_string()),
|
||||||
|
CoreErrorKind::Internal => Self::new(500, error.to_string()),
|
||||||
|
CoreErrorKind::External => Self::new(502, error.to_string()),
|
||||||
|
_ => Self::new(500, error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +1,26 @@
|
|||||||
use crate::account::hash;
|
use lib::accounts::{User, ADMIN_ROLE, hash};
|
||||||
use crate::http_client::HttpClient;
|
|
||||||
use crate::users::{ADMIN_ROLE, User};
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_web::{App, HttpServer, middleware::Logger, web};
|
use actix_web::{App, HttpServer, middleware::Logger, web};
|
||||||
use dotenv::from_filename;
|
use dotenv::from_filename;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::sync::Arc;
|
|
||||||
use utoipa::openapi::SecurityRequirement;
|
use utoipa::openapi::SecurityRequirement;
|
||||||
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
|
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
|
||||||
use utoipa_actix_web::{AppExt, scope};
|
use utoipa_actix_web::{AppExt, scope};
|
||||||
use utoipa_swagger_ui::{Config, SwaggerUi};
|
use utoipa_swagger_ui::{Config, SwaggerUi};
|
||||||
|
use lib::state::AppState;
|
||||||
|
|
||||||
mod account;
|
mod accounts;
|
||||||
mod airports;
|
|
||||||
mod db;
|
|
||||||
mod error;
|
mod error;
|
||||||
mod http_client;
|
mod routes;
|
||||||
mod metars;
|
|
||||||
mod scheduler;
|
|
||||||
mod smtp;
|
mod smtp;
|
||||||
mod system;
|
mod system;
|
||||||
mod users;
|
mod utils;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct AppState {
|
|
||||||
client: Arc<HttpClient>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
initialize_environment()?;
|
initialize_environment()?;
|
||||||
db::initialize().await?;
|
|
||||||
|
|
||||||
let client = Arc::new(HttpClient::default()?);
|
let state = AppState::new().await?;
|
||||||
|
|
||||||
let scheduler_client = client.clone();
|
|
||||||
let interval = env::var("METAR_INTERVAL")
|
|
||||||
.unwrap_or("300".to_string())
|
|
||||||
.parse::<u64>()
|
|
||||||
.unwrap_or(300);
|
|
||||||
scheduler::update_metars(scheduler_client, interval);
|
|
||||||
|
|
||||||
// Initialize admin user
|
// Initialize admin user
|
||||||
let admin_username = env::var("ADMIN_USERNAME");
|
let admin_username = env::var("ADMIN_USERNAME");
|
||||||
@@ -48,7 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if admin_username.is_ok() && admin_email.is_ok() && admin_password.is_ok() {
|
if admin_username.is_ok() && admin_email.is_ok() && admin_password.is_ok() {
|
||||||
let username = admin_username.unwrap();
|
let username = admin_username.unwrap();
|
||||||
let email = admin_email.unwrap();
|
let email = admin_email.unwrap();
|
||||||
if User::select_by_email(&email).await.is_none() {
|
if User::select_by_email(&state.pool, &email).await.is_none() {
|
||||||
log::debug!("Creating default administrator");
|
log::debug!("Creating default administrator");
|
||||||
let password = admin_password.unwrap();
|
let password = admin_password.unwrap();
|
||||||
let password_hash = hash(&password)?;
|
let password_hash = hash(&password)?;
|
||||||
@@ -69,7 +50,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
updated_at: Default::default(),
|
updated_at: Default::default(),
|
||||||
created_at: Default::default(),
|
created_at: Default::default(),
|
||||||
};
|
};
|
||||||
match admin_user.insert().await {
|
match admin_user.insert(&state.pool).await {
|
||||||
Ok(_) => log::debug!("Default administrator was successfully created"),
|
Ok(_) => log::debug!("Default administrator was successfully created"),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("{}", err);
|
log::warn!("{}", err);
|
||||||
@@ -78,7 +59,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = AppState { client };
|
|
||||||
let host = "0.0.0.0";
|
let host = "0.0.0.0";
|
||||||
let port = env::var("API_PORT").unwrap_or("5000".to_string());
|
let port = env::var("API_PORT").unwrap_or("5000".to_string());
|
||||||
|
|
||||||
@@ -96,10 +76,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.into_utoipa_app()
|
.into_utoipa_app()
|
||||||
.service(
|
.service(
|
||||||
scope::scope("/api")
|
scope::scope("/api")
|
||||||
.configure(airports::init_routes)
|
.configure(routes::init_routes)
|
||||||
.configure(metars::init_routes)
|
|
||||||
.configure(account::init_routes)
|
|
||||||
.configure(users::init_routes)
|
|
||||||
.configure(system::init_routes),
|
.configure(system::init_routes),
|
||||||
)
|
)
|
||||||
.split_for_parts();
|
.split_for_parts();
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
use crate::{
|
use lib::accounts::{csprng, LoginRequest, RegisterRequest, UpdateUser, User, UserResponse, UserFavorite, verify_hash};
|
||||||
account::{SESSION_COOKIE_NAME, Session, verify_hash},
|
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
|
||||||
error::Error,
|
|
||||||
users::{LoginRequest, RegisterRequest, User, UserResponse},
|
|
||||||
};
|
|
||||||
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use utoipa_actix_web::scope;
|
use utoipa_actix_web::scope;
|
||||||
use utoipa_actix_web::service_config::ServiceConfig;
|
use utoipa_actix_web::service_config::ServiceConfig;
|
||||||
|
use lib::error::CoreErrorKind;
|
||||||
use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email};
|
use lib::state::AppState;
|
||||||
use crate::account::{Auth, csprng};
|
use crate::accounts::{send_confirm_email, send_password_reset_email, Auth, EmailToken, Session, SESSION_COOKIE_NAME};
|
||||||
use crate::users::UpdateUser;
|
use crate::error::Error;
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
tag = "account",
|
tag = "account",
|
||||||
@@ -24,17 +20,17 @@ use crate::users::UpdateUser;
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[post("/register")]
|
#[post("/register")]
|
||||||
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
|
async fn register(state: web::Data<AppState>, user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
|
||||||
let register_user = user.into_inner();
|
let register_user = user.into_inner();
|
||||||
let username = register_user.username.clone();
|
let username = register_user.username.clone();
|
||||||
let email = register_user.email.clone();
|
let email = register_user.email.clone();
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
let insert_user: User = match register_user.to_user() {
|
let insert_user: User = match register_user.to_user().map_err(Error::from) {
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
Err(err) => return ResponseError::error_response(&err),
|
Err(err) => return ResponseError::error_response(&err),
|
||||||
};
|
};
|
||||||
|
|
||||||
match insert_user.insert().await {
|
match insert_user.insert(&state.pool).await.map_err(Error::from) {
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
let user_response: UserResponse = user.into();
|
let user_response: UserResponse = user.into();
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -45,11 +41,13 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
|
|||||||
|
|
||||||
// Send confirmation email
|
// Send confirmation email
|
||||||
if let Some(email) = email {
|
if let Some(email) = email {
|
||||||
tokio::spawn(async move {
|
if !email.is_empty() {
|
||||||
if let Err(err) = send_confirm_email(&email, &ip_address).await {
|
tokio::task::spawn_local(async move {
|
||||||
log::error!("Failed to send confirmation email: {}", err);
|
if let Err(err) = send_confirm_email(&state, &email, &ip_address).await {
|
||||||
};
|
log::error!("Failed to send confirmation email: {}", err);
|
||||||
});
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::Created().json(user_response)
|
HttpResponse::Created().json(user_response)
|
||||||
@@ -89,15 +87,16 @@ struct ConfirmEmail {
|
|||||||
)]
|
)]
|
||||||
#[post("/register/confirm")]
|
#[post("/register/confirm")]
|
||||||
async fn confirm_email_registration(
|
async fn confirm_email_registration(
|
||||||
|
state: web::Data<AppState>,
|
||||||
request: web::Json<ConfirmEmail>,
|
request: web::Json<ConfirmEmail>,
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
let token = &request.token;
|
let token = &request.token;
|
||||||
|
|
||||||
let email_token = match EmailToken::get(token).await {
|
let email_token = match EmailToken::get(&state, token).await {
|
||||||
Ok(password_reset) => {
|
Ok(password_reset) => {
|
||||||
if let Err(err) = EmailToken::delete(&password_reset.token).await {
|
if let Err(err) = EmailToken::delete(&state, &password_reset.token).await {
|
||||||
return ResponseError::error_response(&err);
|
return ResponseError::error_response(&err);
|
||||||
};
|
};
|
||||||
password_reset
|
password_reset
|
||||||
@@ -107,7 +106,7 @@ async fn confirm_email_registration(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match User::select_by_email(&email_token.email).await {
|
match User::select_by_email(&state.pool, &email_token.email).await {
|
||||||
Some(user) => {
|
Some(user) => {
|
||||||
let update_user = UpdateUser {
|
let update_user = UpdateUser {
|
||||||
email: None,
|
email: None,
|
||||||
@@ -119,7 +118,7 @@ async fn confirm_email_registration(
|
|||||||
avatar: None,
|
avatar: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match update_user.update(&user.username).await {
|
match update_user.update(&state.pool, &user.username).await.map_err(Error::from) {
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
let response: UserResponse = user.into();
|
let response: UserResponse = user.into();
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -155,28 +154,27 @@ async fn confirm_email_registration(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[post("/register/email")]
|
#[post("/register/email")]
|
||||||
async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse {
|
async fn resend_email_verification(state: web::Data<AppState>, req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||||
let email = auth.user.email;
|
let email = auth.user.email;
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
|
|
||||||
match email {
|
match email {
|
||||||
Some(email) => {
|
Some(email) => {
|
||||||
let user = match User::select_by_email(&email).await {
|
let user = match User::select_by_email(&state.pool, &email).await {
|
||||||
Some(query_user) => query_user,
|
Some(query_user) => query_user,
|
||||||
None => return HttpResponse::Unauthorized().finish(),
|
None => return HttpResponse::Unauthorized().finish(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cannot reverify if user is already verified
|
// Cannot reverify if the user is already verified
|
||||||
if user.email_verified {
|
if user.email_verified {
|
||||||
return HttpResponse::Conflict().finish();
|
return HttpResponse::Conflict().finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send reverify confirmation email
|
// Send reverify confirmation email
|
||||||
tokio::spawn(async move {
|
if let Err(err) = send_confirm_email(&state, &email, &ip_address).await {
|
||||||
if let Err(err) = send_confirm_email(&email, &ip_address).await {
|
log::error!("Failed to send reverify confirmation email: {}", err);
|
||||||
log::error!("Failed to send reverify confirmation email: {}", err);
|
return HttpResponse::InternalServerError().finish();
|
||||||
};
|
};
|
||||||
});
|
|
||||||
HttpResponse::Ok().finish()
|
HttpResponse::Ok().finish()
|
||||||
}
|
}
|
||||||
None => HttpResponse::NotFound().finish(),
|
None => HttpResponse::NotFound().finish(),
|
||||||
@@ -193,11 +191,11 @@ async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse
|
|||||||
),
|
),
|
||||||
)]
|
)]
|
||||||
#[post("/login")]
|
#[post("/login")]
|
||||||
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
|
async fn login(state: web::Data<AppState>, request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
|
||||||
let username = &request.username;
|
let username = &request.username;
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
|
|
||||||
let query_user = match User::select(&username).await {
|
let query_user = match User::select(&state.pool, &username).await {
|
||||||
Some(query_user) => query_user,
|
Some(query_user) => query_user,
|
||||||
None => return HttpResponse::Unauthorized().finish(),
|
None => return HttpResponse::Unauthorized().finish(),
|
||||||
};
|
};
|
||||||
@@ -208,7 +206,7 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
|||||||
let session_cookie = session.cookie();
|
let session_cookie = session.cookie();
|
||||||
let session_exp_cookie = session.expiration_cookie();
|
let session_exp_cookie = session.expiration_cookie();
|
||||||
// Save the session to the database
|
// Save the session to the database
|
||||||
if let Err(err) = session.store().await {
|
if let Err(err) = session.store(&state).await {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Login attempt failure [User: {}] [IP Address: {}]: {}",
|
"Login attempt failure [User: {}] [IP Address: {}]: {}",
|
||||||
username,
|
username,
|
||||||
@@ -251,14 +249,14 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[post("/logout")]
|
#[post("/logout")]
|
||||||
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
async fn logout(state: web::Data<AppState>, req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||||
let username = auth.user.username;
|
let username = auth.user.username;
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
// Delete the session from the store
|
// Delete the session from the store
|
||||||
match req.cookie(SESSION_COOKIE_NAME) {
|
match req.cookie(SESSION_COOKIE_NAME) {
|
||||||
Some(cookie) => {
|
Some(cookie) => {
|
||||||
let session_id = cookie.value().to_string();
|
let session_id = cookie.value().to_string();
|
||||||
if let Err(err) = Session::delete(&session_id).await {
|
if let Err(err) = Session::delete(&state, &session_id).await {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Logout attempt failure [User: {}] [IP Address: {}]: {}",
|
"Logout attempt failure [User: {}] [IP Address: {}]: {}",
|
||||||
username,
|
username,
|
||||||
@@ -300,14 +298,14 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[get("/profile")]
|
#[get("/profile")]
|
||||||
async fn get_profile(req: HttpRequest) -> HttpResponse {
|
async fn get_profile(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
// Verify a session cookie exists
|
// Verify a session cookie exists
|
||||||
match req.cookie(SESSION_COOKIE_NAME) {
|
match req.cookie(SESSION_COOKIE_NAME) {
|
||||||
// Validate the session
|
// Validate the session
|
||||||
Some(cookie) => {
|
Some(cookie) => {
|
||||||
let session_id = cookie.value().to_string();
|
let session_id = cookie.value().to_string();
|
||||||
let session = match Session::get(&session_id).await {
|
let session = match Session::get(&state, &session_id).await {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
log::error!(
|
log::error!(
|
||||||
@@ -322,7 +320,7 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let username = &session.username;
|
let username = &session.username;
|
||||||
let query_user = match User::select(&username).await {
|
let query_user = match User::select(&state.pool, &username).await {
|
||||||
Some(query_user) => query_user,
|
Some(query_user) => query_user,
|
||||||
None => {
|
None => {
|
||||||
return HttpResponse::Unauthorized()
|
return HttpResponse::Unauthorized()
|
||||||
@@ -364,14 +362,14 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[post("/session")]
|
#[post("/session")]
|
||||||
async fn session_refresh(req: HttpRequest) -> HttpResponse {
|
async fn session_refresh(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
// Verify a session cookie exists
|
// Verify a session cookie exists
|
||||||
match req.cookie(SESSION_COOKIE_NAME) {
|
match req.cookie(SESSION_COOKIE_NAME) {
|
||||||
// Validate the session
|
// Validate the session
|
||||||
Some(cookie) => {
|
Some(cookie) => {
|
||||||
let session_id = cookie.value().to_string();
|
let session_id = cookie.value().to_string();
|
||||||
let session = match Session::replace(&session_id, &ip_address).await {
|
let session = match Session::replace(&state, &session_id, &ip_address).await {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
log::error!(
|
log::error!(
|
||||||
@@ -426,6 +424,7 @@ struct ChangePassword {
|
|||||||
)]
|
)]
|
||||||
#[put("/password")]
|
#[put("/password")]
|
||||||
async fn change_password(
|
async fn change_password(
|
||||||
|
state: web::Data<AppState>,
|
||||||
request: web::Json<ChangePassword>,
|
request: web::Json<ChangePassword>,
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
auth: Auth,
|
auth: Auth,
|
||||||
@@ -433,7 +432,7 @@ async fn change_password(
|
|||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
let username = auth.user.username;
|
let username = auth.user.username;
|
||||||
|
|
||||||
if let None = User::select(&username).await {
|
if let None = User::select(&state.pool, &username).await {
|
||||||
return HttpResponse::Unauthorized().finish();
|
return HttpResponse::Unauthorized().finish();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -447,7 +446,7 @@ async fn change_password(
|
|||||||
avatar: None,
|
avatar: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match update_user.update(&username).await {
|
match update_user.update(&state.pool, &username).await.map_err(Error::from) {
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
let response: UserResponse = user.into();
|
let response: UserResponse = user.into();
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -484,18 +483,18 @@ struct PasswordReset {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[post("/password/reset")]
|
#[post("/password/reset")]
|
||||||
async fn reset_password(request: web::Json<PasswordReset>, req: HttpRequest) -> HttpResponse {
|
async fn reset_password(state: web::Data<AppState>, request: web::Json<PasswordReset>, req: HttpRequest) -> HttpResponse {
|
||||||
let email = &request.email;
|
let email = &request.email;
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
let token = csprng(128);
|
let token = csprng(128);
|
||||||
|
|
||||||
// Silently return if the user's email does not exist
|
// Silently return if the user's email does not exist
|
||||||
if let None = User::select_by_email(&email).await {
|
if let None = User::select_by_email(&state.pool, &email).await {
|
||||||
return HttpResponse::Ok().finish();
|
return HttpResponse::Ok().finish();
|
||||||
};
|
};
|
||||||
|
|
||||||
let email_token = EmailToken::new(email.clone(), token, &ip_address);
|
let email_token = EmailToken::new(email.clone(), token, &ip_address);
|
||||||
if let Err(err) = email_token.store(86400).await {
|
if let Err(err) = email_token.store(&state, 86400).await {
|
||||||
return ResponseError::error_response(&err);
|
return ResponseError::error_response(&err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,16 +522,17 @@ struct ConfirmPasswordReset {
|
|||||||
)]
|
)]
|
||||||
#[post("/password/reset/confirm")]
|
#[post("/password/reset/confirm")]
|
||||||
async fn confirm_password_reset(
|
async fn confirm_password_reset(
|
||||||
|
state: web::Data<AppState>,
|
||||||
request: web::Json<ConfirmPasswordReset>,
|
request: web::Json<ConfirmPasswordReset>,
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
// TODO
|
// TODO
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let _ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
let token = &request.token;
|
let token = &request.token;
|
||||||
|
|
||||||
let email_token = match EmailToken::get(token).await {
|
let _email_token = match EmailToken::get(&state, token).await {
|
||||||
Ok(password_reset) => {
|
Ok(password_reset) => {
|
||||||
if let Err(err) = EmailToken::delete(&password_reset.token).await {
|
if let Err(err) = EmailToken::delete(&state, &password_reset.token).await {
|
||||||
return ResponseError::error_response(&err);
|
return ResponseError::error_response(&err);
|
||||||
};
|
};
|
||||||
password_reset
|
password_reset
|
||||||
@@ -545,6 +545,63 @@ async fn confirm_password_reset(
|
|||||||
HttpResponse::Ok().finish()
|
HttpResponse::Ok().finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
tag = "account",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Successful Response"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("session_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[get("/profile/favorites")]
|
||||||
|
async fn get_favorites(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
|
||||||
|
let username = auth.user.username;
|
||||||
|
match UserFavorite::select_all(&state.pool, &username).await.map_err(Error::from) {
|
||||||
|
Ok(favorites) => HttpResponse::Ok().json(favorites),
|
||||||
|
Err(err) => ResponseError::error_response(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
tag = "account",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Successful Response"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("session_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[post("/profile/favorites/{icao}")]
|
||||||
|
async fn add_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
||||||
|
let username = auth.user.username;
|
||||||
|
match UserFavorite::insert(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
|
||||||
|
Ok(_) => HttpResponse::Ok().finish(),
|
||||||
|
Err(err) => ResponseError::error_response(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
tag = "account",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Successful Response"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("session_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[delete("/profile/favorites/{icao}")]
|
||||||
|
async fn remove_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
||||||
|
let username = auth.user.username;
|
||||||
|
match UserFavorite::delete(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
|
||||||
|
Ok(_) => HttpResponse::Ok().finish(),
|
||||||
|
Err(err) => ResponseError::error_response(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init_routes(config: &mut ServiceConfig) {
|
pub fn init_routes(config: &mut ServiceConfig) {
|
||||||
config.service(
|
config.service(
|
||||||
scope::scope("/account")
|
scope::scope("/account")
|
||||||
@@ -557,6 +614,9 @@ pub fn init_routes(config: &mut ServiceConfig) {
|
|||||||
.service(session_refresh)
|
.service(session_refresh)
|
||||||
.service(change_password)
|
.service(change_password)
|
||||||
.service(reset_password)
|
.service(reset_password)
|
||||||
.service(confirm_password_reset),
|
.service(confirm_password_reset)
|
||||||
|
.service(get_favorites)
|
||||||
|
.service(add_favorite)
|
||||||
|
.service(remove_favorite),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
use futures_util::stream::StreamExt as _;
|
use futures_util::stream::StreamExt as _;
|
||||||
|
|
||||||
use crate::airports::{AirportQuery, UpdateAirport};
|
use lib::{accounts::ADMIN_ROLE, airports::{Airport, AirportQuery, UpdateAirport}};
|
||||||
use crate::users::ADMIN_ROLE;
|
|
||||||
use crate::{
|
|
||||||
account::{Auth, verify_role},
|
|
||||||
airports::Airport,
|
|
||||||
db::Paged,
|
|
||||||
};
|
|
||||||
use actix_multipart::Multipart;
|
use actix_multipart::Multipart;
|
||||||
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
|
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use utoipa_actix_web::scope;
|
use utoipa_actix_web::scope;
|
||||||
use utoipa_actix_web::service_config::ServiceConfig;
|
use utoipa_actix_web::service_config::ServiceConfig;
|
||||||
|
use lib::state::AppState;
|
||||||
|
use crate::accounts::Auth;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::utils::Paged;
|
||||||
|
|
||||||
#[derive(ToSchema)]
|
#[derive(ToSchema)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
@@ -34,9 +32,9 @@ struct FileUpload {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[post("/import")]
|
#[post("/import")]
|
||||||
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
async fn import_airports(state: web::Data<AppState>, mut payload: Multipart, auth: Auth) -> HttpResponse {
|
||||||
if let Err(err) = verify_role(&auth, ADMIN_ROLE) {
|
if let Err(err) = &auth.verify_role(ADMIN_ROLE).map_err(Error::from) {
|
||||||
return ResponseError::error_response(&err);
|
return ResponseError::error_response(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
while let Some(item) = payload.next().await {
|
while let Some(item) = payload.next().await {
|
||||||
@@ -67,7 +65,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match Airport::insert_all(airports).await {
|
match Airport::insert_all(&state.pool, airports).await.map_err(Error::from) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => return ResponseError::error_response(&err),
|
Err(err) => return ResponseError::error_response(&err),
|
||||||
};
|
};
|
||||||
@@ -85,7 +83,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
|||||||
),
|
),
|
||||||
)]
|
)]
|
||||||
#[get("")]
|
#[get("")]
|
||||||
async fn get_airports(req: HttpRequest) -> HttpResponse {
|
async fn get_airports(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||||
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
|
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
|
||||||
Ok(q) => q.into_inner(),
|
Ok(q) => q.into_inner(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -94,7 +92,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let total = Airport::count(&query).await;
|
let total = Airport::count(&state.pool, &query).await;
|
||||||
let page = query.page.unwrap_or(1);
|
let page = query.page.unwrap_or(1);
|
||||||
let mut limit = query.limit.unwrap_or(total as u32);
|
let mut limit = query.limit.unwrap_or(total as u32);
|
||||||
if limit > 1000 {
|
if limit > 1000 {
|
||||||
@@ -103,7 +101,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse {
|
|||||||
query.limit = Some(limit);
|
query.limit = Some(limit);
|
||||||
query.page = Some(page);
|
query.page = Some(page);
|
||||||
|
|
||||||
match Airport::select_all(&query).await {
|
match Airport::select_all(&state.pool, &query).await.map_err(Error::from) {
|
||||||
Ok(airports) => HttpResponse::Ok().json(Paged {
|
Ok(airports) => HttpResponse::Ok().json(Paged {
|
||||||
data: airports,
|
data: airports,
|
||||||
page,
|
page,
|
||||||
@@ -125,7 +123,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse {
|
|||||||
),
|
),
|
||||||
)]
|
)]
|
||||||
#[get("/{icao}")]
|
#[get("/{icao}")]
|
||||||
async fn get_airport(icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
|
async fn get_airport(state: web::Data<AppState>, icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
|
||||||
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
|
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
|
||||||
Ok(q) => q.metars.unwrap_or_else(|| false),
|
Ok(q) => q.metars.unwrap_or_else(|| false),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -134,7 +132,7 @@ async fn get_airport(icao: web::Path<String>, req: HttpRequest) -> HttpResponse
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match Airport::select(&icao.into_inner(), metar).await {
|
match Airport::select(&state.pool, &icao.into_inner(), metar).await {
|
||||||
Some(airport) => HttpResponse::Ok().json(airport),
|
Some(airport) => HttpResponse::Ok().json(airport),
|
||||||
None => HttpResponse::NotFound().finish(),
|
None => HttpResponse::NotFound().finish(),
|
||||||
}
|
}
|
||||||
@@ -152,12 +150,12 @@ async fn get_airport(icao: web::Path<String>, req: HttpRequest) -> HttpResponse
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[post("")]
|
#[post("")]
|
||||||
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
|
async fn insert_airport(state: web::Data<AppState>, airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, ADMIN_ROLE) {
|
let _ = match &auth.verify_role(ADMIN_ROLE) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => return ResponseError::error_response(&err),
|
Err(err) => return ResponseError::error_response(err),
|
||||||
};
|
};
|
||||||
match airport.insert().await {
|
match airport.insert(&state.pool).await.map_err(Error::from) {
|
||||||
Ok(a) => HttpResponse::Ok().json(a),
|
Ok(a) => HttpResponse::Ok().json(a),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("{}", err);
|
log::error!("{}", err);
|
||||||
@@ -178,15 +176,16 @@ async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse
|
|||||||
)]
|
)]
|
||||||
#[put("/{icao}")]
|
#[put("/{icao}")]
|
||||||
async fn update_airport(
|
async fn update_airport(
|
||||||
|
state: web::Data<AppState>,
|
||||||
icao: web::Path<String>,
|
icao: web::Path<String>,
|
||||||
airport: web::Json<UpdateAirport>,
|
airport: web::Json<UpdateAirport>,
|
||||||
auth: Auth,
|
auth: Auth,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, ADMIN_ROLE) {
|
let _ = match &auth.verify_role(ADMIN_ROLE) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => return ResponseError::error_response(&err),
|
Err(err) => return ResponseError::error_response(err),
|
||||||
};
|
};
|
||||||
match Airport::update(&icao.into_inner(), &airport.into_inner()).await {
|
match Airport::update(&state.pool, &icao.into_inner(), &airport.into_inner()).await.map_err(Error::from) {
|
||||||
Ok(a) => HttpResponse::Ok().json(a),
|
Ok(a) => HttpResponse::Ok().json(a),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("{}", err);
|
log::error!("{}", err);
|
||||||
@@ -206,12 +205,12 @@ async fn update_airport(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[delete("")]
|
#[delete("")]
|
||||||
async fn delete_airports(auth: Auth) -> HttpResponse {
|
async fn delete_airports(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, ADMIN_ROLE) {
|
let _ = match &auth.verify_role(ADMIN_ROLE) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => return ResponseError::error_response(&err),
|
Err(err) => return ResponseError::error_response(err),
|
||||||
};
|
};
|
||||||
match Airport::delete_all().await {
|
match Airport::delete_all(&state.pool).await.map_err(Error::from) {
|
||||||
Ok(_) => HttpResponse::NoContent().finish(),
|
Ok(_) => HttpResponse::NoContent().finish(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("{}", err);
|
log::error!("{}", err);
|
||||||
@@ -231,12 +230,12 @@ async fn delete_airports(auth: Auth) -> HttpResponse {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[delete("/{icao}")]
|
#[delete("/{icao}")]
|
||||||
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
async fn delete_airport(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, ADMIN_ROLE) {
|
let _ = match &auth.verify_role(ADMIN_ROLE) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => return ResponseError::error_response(&err),
|
Err(err) => return ResponseError::error_response(err),
|
||||||
};
|
};
|
||||||
match Airport::delete(&icao.into_inner()).await {
|
match Airport::delete(&state.pool, &icao.into_inner()).await.map_err(Error::from) {
|
||||||
Ok(_) => HttpResponse::NoContent().finish(),
|
Ok(_) => HttpResponse::NoContent().finish(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("{}", err);
|
log::error!("{}", err);
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::account::Auth;
|
|
||||||
use crate::metars::Metar;
|
|
||||||
use actix_web::{HttpRequest, HttpResponse, get, put, web};
|
use actix_web::{HttpRequest, HttpResponse, get, put, web};
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use utoipa_actix_web::scope;
|
use utoipa_actix_web::scope;
|
||||||
use utoipa_actix_web::service_config::ServiceConfig;
|
use utoipa_actix_web::service_config::ServiceConfig;
|
||||||
|
use lib::metars::Metar;
|
||||||
|
use crate::accounts::Auth;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||||
#[into_params(parameter_in = Query)]
|
#[into_params(parameter_in = Query)]
|
||||||
@@ -24,7 +25,7 @@ struct MetarQuery {
|
|||||||
),
|
),
|
||||||
)]
|
)]
|
||||||
#[get("")]
|
#[get("")]
|
||||||
async fn find_all(req: HttpRequest) -> HttpResponse {
|
async fn find_all(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||||
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
|
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
|
||||||
let icao_option = ¶meters.icaos;
|
let icao_option = ¶meters.icaos;
|
||||||
if let None = icao_option {
|
if let None = icao_option {
|
||||||
@@ -37,7 +38,7 @@ async fn find_all(req: HttpRequest) -> HttpResponse {
|
|||||||
};
|
};
|
||||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
||||||
|
|
||||||
let metars = match Metar::get_all_distinct(&icaos).await {
|
let metars = match Metar::get_all_distinct(&state.pool, &icaos).await.map_err(Error::from) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("{}", err);
|
error!("{}", err);
|
||||||
@@ -61,8 +62,7 @@ async fn find_all(req: HttpRequest) -> HttpResponse {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[put("")]
|
#[put("")]
|
||||||
async fn refresh_metars(data: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
|
async fn refresh_metars(state: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
|
||||||
let client = data.client.clone();
|
|
||||||
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
|
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
|
||||||
let icao_option = ¶meters.icaos;
|
let icao_option = ¶meters.icaos;
|
||||||
if let None = icao_option {
|
if let None = icao_option {
|
||||||
@@ -75,7 +75,7 @@ async fn refresh_metars(data: web::Data<AppState>, req: HttpRequest, _auth: Auth
|
|||||||
};
|
};
|
||||||
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
|
||||||
|
|
||||||
let metars = match Metar::get_or_update_metars(&client, &icaos).await {
|
let metars = match Metar::get_or_update_metars(&state, &icaos).await.map_err(Error::from) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("{}", err);
|
error!("{}", err);
|
||||||
12
crates/api/src/routes/mod.rs
Normal file
12
crates/api/src/routes/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use utoipa_actix_web::service_config::ServiceConfig;
|
||||||
|
|
||||||
|
mod accounts;
|
||||||
|
mod airports;
|
||||||
|
mod metars;
|
||||||
|
|
||||||
|
pub fn init_routes(config: &mut ServiceConfig) {
|
||||||
|
config
|
||||||
|
.configure(accounts::init_routes)
|
||||||
|
.configure(airports::init_routes)
|
||||||
|
.configure(metars::init_routes);
|
||||||
|
}
|
||||||
9
crates/api/src/utils.rs
Normal file
9
crates/api/src/utils.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Paged<T> {
|
||||||
|
pub data: Vec<T>,
|
||||||
|
pub page: u32,
|
||||||
|
pub limit: u32,
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
24
crates/lib/Cargo.toml
Normal file
24
crates/lib/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "lib"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
argon2 = "0.5.3"
|
||||||
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
|
log = "0.4.28"
|
||||||
|
rand = "0.9.2"
|
||||||
|
rand_chacha = "0.9.0"
|
||||||
|
serde = { version = "1.0.226", features = ["derive"] }
|
||||||
|
serde_json = "1.0.142"
|
||||||
|
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
||||||
|
utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] }
|
||||||
|
uuid = { version = "1.18.1", features = ["serde", "v4"] }
|
||||||
|
futures-util = "0.3.31"
|
||||||
|
flate2 = "1.1.2"
|
||||||
|
reqwest = "0.12.23"
|
||||||
|
regex = "1.11.2"
|
||||||
|
redis = { version = "0.32.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
|
||||||
|
governor = "0.10.1"
|
||||||
|
tokio = { version = "1.47.1", features = ["macros", "rt", "time"] }
|
||||||
|
rust-s3 = "0.37.0"
|
||||||
@@ -76,3 +76,9 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_airport_favorites (
|
||||||
|
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (username, icao)
|
||||||
|
);
|
||||||
9
crates/lib/src/accounts/mod.rs
Normal file
9
crates/lib/src/accounts/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
mod password_requirements;
|
||||||
|
mod user;
|
||||||
|
mod user_favorites;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
pub use password_requirements::*;
|
||||||
|
pub use user::*;
|
||||||
|
pub use user_favorites::*;
|
||||||
|
pub use utils::*;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
use crate::db;
|
|
||||||
use crate::{account::hash, error::ApiResult};
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
#[allow(unused_imports)] // Import is used in schema examples
|
#[allow(unused_imports)] // Import is used in schema examples
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::{Postgres, QueryBuilder};
|
use sqlx::{Pool, Postgres, QueryBuilder};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use crate::accounts::hash;
|
||||||
|
use crate::error::CoreResult;
|
||||||
|
|
||||||
pub const ADMIN_ROLE: &str = "ADMIN";
|
pub const ADMIN_ROLE: &str = "ADMIN";
|
||||||
pub const USER_ROLE: &str = "USER";
|
pub const USER_ROLE: &str = "USER";
|
||||||
@@ -34,7 +34,7 @@ pub struct RegisterRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RegisterRequest {
|
impl RegisterRequest {
|
||||||
pub fn to_user(self) -> ApiResult<User> {
|
pub fn to_user(self) -> CoreResult<User> {
|
||||||
let password_hash = hash(&self.password)?;
|
let password_hash = hash(&self.password)?;
|
||||||
Ok(User {
|
Ok(User {
|
||||||
username: self.username,
|
username: self.username,
|
||||||
@@ -107,9 +107,7 @@ pub struct UpdateUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UpdateUser {
|
impl UpdateUser {
|
||||||
pub async fn update(&self, username: &str) -> ApiResult<User> {
|
pub async fn update(&self, pool: &Pool<Postgres>, username: &str) -> CoreResult<User> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let mut query_builder: QueryBuilder<Postgres> =
|
let mut query_builder: QueryBuilder<Postgres> =
|
||||||
QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME));
|
QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME));
|
||||||
|
|
||||||
@@ -189,8 +187,7 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub async fn select(username: &str) -> Option<Self> {
|
pub async fn select(pool: &Pool<Postgres>, username: &str) -> Option<Self> {
|
||||||
let pool = db::pool();
|
|
||||||
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM {} WHERE username = $1
|
SELECT * FROM {} WHERE username = $1
|
||||||
@@ -208,8 +205,7 @@ impl User {
|
|||||||
user
|
user
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_by_email(email: &str) -> Option<Self> {
|
pub async fn select_by_email(pool: &Pool<Postgres>, email: &str) -> Option<Self> {
|
||||||
let pool = db::pool();
|
|
||||||
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM {} WHERE email = $1
|
SELECT * FROM {} WHERE email = $1
|
||||||
@@ -228,9 +224,7 @@ impl User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn count() -> i64 {
|
pub async fn count(pool: &Pool<Postgres>) -> i64 {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
sqlx::query_scalar(&format!(
|
sqlx::query_scalar(&format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*) FROM {}
|
SELECT COUNT(*) FROM {}
|
||||||
@@ -242,8 +236,7 @@ impl User {
|
|||||||
.unwrap_or_else(|_| 0)
|
.unwrap_or_else(|_| 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert(&self) -> ApiResult<User> {
|
pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<User> {
|
||||||
let pool = db::pool();
|
|
||||||
let user: User = sqlx::query_as::<_, Self>(&format!(
|
let user: User = sqlx::query_as::<_, Self>(&format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO {} (
|
INSERT INTO {} (
|
||||||
61
crates/lib/src/accounts/user_favorites.rs
Normal file
61
crates/lib/src/accounts/user_favorites.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use crate::error::CoreResult;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{Pool, Postgres};
|
||||||
|
|
||||||
|
const TABLE_NAME: &str = "user_airport_favorites";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct UserFavorite {
|
||||||
|
pub username: String,
|
||||||
|
pub icao: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserFavorite {
|
||||||
|
pub async fn select_all(pool: &Pool<Postgres>, username: &str) -> CoreResult<Vec<String>> {
|
||||||
|
let user_favorites: Vec<UserFavorite> = sqlx::query_as::<_, UserFavorite>(&format!(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM {} WHERE username = $1
|
||||||
|
"#,
|
||||||
|
TABLE_NAME
|
||||||
|
))
|
||||||
|
.bind(username)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let favorites = user_favorites.iter().map(|uf| uf.icao.clone()).collect();
|
||||||
|
|
||||||
|
Ok(favorites)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(pool: &Pool<Postgres>, username: &str, icao: &str) -> CoreResult<()> {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO {} (
|
||||||
|
username, icao
|
||||||
|
) VALUES ($1, $2)
|
||||||
|
"#,
|
||||||
|
TABLE_NAME
|
||||||
|
))
|
||||||
|
.bind(username)
|
||||||
|
.bind(icao)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(pool: &Pool<Postgres>, username: &str, icao: &str) -> CoreResult<()> {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
r#"
|
||||||
|
DELETE FROM {} WHERE username = $1 AND icao = $2
|
||||||
|
"#,
|
||||||
|
TABLE_NAME
|
||||||
|
))
|
||||||
|
.bind(username)
|
||||||
|
.bind(icao)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,11 @@
|
|||||||
use argon2::{
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
use argon2::password_hash::rand_core::OsRng;
|
||||||
password_hash::{SaltString, rand_core::OsRng},
|
use argon2::password_hash::SaltString;
|
||||||
};
|
|
||||||
use rand::distr::Alphanumeric;
|
use rand::distr::Alphanumeric;
|
||||||
use rand::prelude::*;
|
use rand::Rng;
|
||||||
use rand_chacha::ChaCha20Rng;
|
use rand_chacha::ChaCha20Rng;
|
||||||
|
use rand_chacha::rand_core::SeedableRng;
|
||||||
mod auth;
|
use crate::error::CoreResult;
|
||||||
mod email_token;
|
|
||||||
mod model;
|
|
||||||
mod routes;
|
|
||||||
mod session;
|
|
||||||
|
|
||||||
pub use auth::*;
|
|
||||||
pub use routes::init_routes;
|
|
||||||
pub use session::*;
|
|
||||||
|
|
||||||
use crate::error::{ApiResult, Error};
|
|
||||||
|
|
||||||
pub fn csprng(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)
|
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
|
||||||
@@ -28,7 +17,7 @@ pub fn csprng(take: usize) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hash(string: &str) -> ApiResult<String> {
|
pub fn hash(string: &str) -> CoreResult<String> {
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
let hash = Argon2::default()
|
let hash = Argon2::default()
|
||||||
.hash_password(string.as_bytes(), &salt)?
|
.hash_password(string.as_bytes(), &salt)?
|
||||||
@@ -54,17 +43,6 @@ pub fn verify_hash(string: &str, hashed_string: &str) -> bool {
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_role(auth: &Auth, role: &str) -> ApiResult<()> {
|
|
||||||
if auth.user.role == role {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(Error {
|
|
||||||
status: 403,
|
|
||||||
details: "User does not have permission to perform this action.".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -75,4 +53,4 @@ mod tests {
|
|||||||
assert!(!verify_hash(&password, "bad_password"));
|
assert!(!verify_hash(&password, "bad_password"));
|
||||||
assert!(verify_hash("password", &password));
|
assert!(verify_hash("password", &password));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,16 +2,15 @@ use crate::airports::{
|
|||||||
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
|
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
|
||||||
UpdateRunway,
|
UpdateRunway,
|
||||||
};
|
};
|
||||||
use crate::db;
|
|
||||||
use crate::error::{ApiResult, Error};
|
|
||||||
use crate::metars::Metar;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use futures_util::try_join;
|
use futures_util::try_join;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Postgres, QueryBuilder};
|
use sqlx::{Pool, Postgres, QueryBuilder};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
use crate::error::{CoreError, CoreErrorKind, CoreResult};
|
||||||
|
use crate::metars::Metar;
|
||||||
|
|
||||||
const TABLE_NAME: &str = "airports";
|
const TABLE_NAME: &str = "airports";
|
||||||
const DEFAULT_COLUMNS: &str = "icao, iata, local, name, category, iso_country, \
|
const DEFAULT_COLUMNS: &str = "icao, iata, local, name, category, iso_country, \
|
||||||
@@ -137,11 +136,11 @@ pub struct Bounds {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Bounds {
|
impl Bounds {
|
||||||
fn parse(input: &str) -> ApiResult<Bounds> {
|
fn parse(input: &str) -> CoreResult<Bounds> {
|
||||||
let parts: Vec<&str> = input.split(',').collect();
|
let parts: Vec<&str> = input.split(',').collect();
|
||||||
if parts.len() != 4 {
|
if parts.len() != 4 {
|
||||||
return Err(Error::new(
|
return Err(CoreError::new(
|
||||||
400,
|
CoreErrorKind::InvalidInput,
|
||||||
format!("Expected 4 fields in bounds but received {}", parts.len()),
|
format!("Expected 4 fields in bounds but received {}", parts.len()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -255,22 +254,21 @@ impl From<AirportRow> for Airport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Airport {
|
impl Airport {
|
||||||
pub async fn select(icao: &str, metar: bool) -> Option<Self> {
|
pub async fn select(pool: &Pool<Postgres>, icao: &str, metar: bool) -> Option<Self> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let airport_fut = async {
|
let airport_fut = async {
|
||||||
sqlx::query_as(&format!(
|
sqlx::query_as(&format!(
|
||||||
"SELECT {} FROM {} WHERE icao = $1",
|
"SELECT {} FROM {} WHERE icao = $1",
|
||||||
DEFAULT_COLUMNS, TABLE_NAME
|
DEFAULT_COLUMNS, TABLE_NAME
|
||||||
))
|
))
|
||||||
.bind(icao)
|
.bind(icao.to_uppercase())
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
|
|
||||||
let metar_fut = async {
|
let metar_fut = async {
|
||||||
if metar {
|
if metar {
|
||||||
match Metar::get_all_distinct(&vec![icao.to_uppercase()]).await {
|
match Metar::get_all_distinct(pool, &vec![icao.to_uppercase()]).await {
|
||||||
Ok(m) => Some(m.into_iter().nth(0)),
|
Ok(m) => Some(m.into_iter().nth(0)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("{}", err);
|
log::error!("{}", err);
|
||||||
@@ -282,8 +280,8 @@ impl Airport {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let runways_fut = Runway::select_all(icao);
|
let runways_fut = Runway::select_all(pool, icao);
|
||||||
let communications_fut = Communication::select_all(icao);
|
let communications_fut = Communication::select_all(pool, icao);
|
||||||
|
|
||||||
let (airport_result, runways_result, communications_result, metar_result) =
|
let (airport_result, runways_result, communications_result, metar_result) =
|
||||||
tokio::join!(airport_fut, runways_fut, communications_fut, metar_fut);
|
tokio::join!(airport_fut, runways_fut, communications_fut, metar_fut);
|
||||||
@@ -333,15 +331,21 @@ impl Airport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_all(query: &AirportQuery) -> ApiResult<Vec<Self>> {
|
pub async fn select_all(pool: &Pool<Postgres>, query: &AirportQuery) -> CoreResult<Vec<Self>> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let mut builder =
|
let mut builder =
|
||||||
QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
|
QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
|
||||||
|
|
||||||
let mut has_where = false;
|
let mut has_where = false;
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "icao", &query.icaos);
|
let icaos = match &query.icaos {
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "iata", &query.iatas);
|
Some(icaos) => Some(icaos.to_uppercase()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Self::push_condition_array(&mut builder, &mut has_where, "icao", &icaos);
|
||||||
|
let iatas = match &query.iatas {
|
||||||
|
Some(iatas) => Some(iatas.to_uppercase()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Self::push_condition_array(&mut builder, &mut has_where, "iata", &iatas);
|
||||||
Self::push_condition_array(
|
Self::push_condition_array(
|
||||||
&mut builder,
|
&mut builder,
|
||||||
&mut has_where,
|
&mut has_where,
|
||||||
@@ -360,7 +364,11 @@ impl Airport {
|
|||||||
"municipality",
|
"municipality",
|
||||||
&query.municipalities,
|
&query.municipalities,
|
||||||
);
|
);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals);
|
let locals = match &query.locals {
|
||||||
|
Some(locals) => Some(locals.to_uppercase()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Self::push_condition_array(&mut builder, &mut has_where, "local", &locals);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
||||||
Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name);
|
Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name);
|
||||||
Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?;
|
Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?;
|
||||||
@@ -395,13 +403,13 @@ impl Airport {
|
|||||||
return Ok(airports);
|
return Ok(airports);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk update airport sub-fields
|
// Bulk update airport subfields
|
||||||
let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
|
let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
|
||||||
|
|
||||||
let runway_future = Runway::select_all_map(&icaos);
|
let runway_future = Runway::select_all_map(pool, &icaos);
|
||||||
let frequency_future = Communication::select_all_map(&icaos);
|
let frequency_future = Communication::select_all_map(pool, &icaos);
|
||||||
let metar_future = if query.metars.unwrap_or(false) {
|
let metar_future = if query.metars.unwrap_or(false) {
|
||||||
Some(Metar::get_all_distinct(&icaos))
|
Some(Metar::get_all_distinct(pool, &icaos))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -441,9 +449,7 @@ impl Airport {
|
|||||||
Ok(airports)
|
Ok(airports)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn count(query: &AirportQuery) -> i64 {
|
pub async fn count(pool: &Pool<Postgres>, query: &AirportQuery) -> i64 {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let mut builder = QueryBuilder::<Postgres>::new("SELECT COUNT(*) FROM ");
|
let mut builder = QueryBuilder::<Postgres>::new("SELECT COUNT(*) FROM ");
|
||||||
builder.push(TABLE_NAME);
|
builder.push(TABLE_NAME);
|
||||||
|
|
||||||
@@ -480,9 +486,7 @@ impl Airport {
|
|||||||
sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0)
|
sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert(&self) -> ApiResult<Self> {
|
pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<Self> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
||||||
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
|
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
|
||||||
for runway in &self.runways {
|
for runway in &self.runways {
|
||||||
@@ -491,8 +495,8 @@ impl Airport {
|
|||||||
for frequency in &self.communications {
|
for frequency in &self.communications {
|
||||||
all_frequency_rows.push(Communication::into(frequency, &self.icao));
|
all_frequency_rows.push(Communication::into(frequency, &self.icao));
|
||||||
}
|
}
|
||||||
Runway::insert_all(&all_runway_rows).await?;
|
Runway::insert_all(pool, &all_runway_rows).await?;
|
||||||
Communication::insert_all(&all_frequency_rows).await?;
|
Communication::insert_all(pool, &all_frequency_rows).await?;
|
||||||
|
|
||||||
let airport: AirportRow = sqlx::query_as(&format!(
|
let airport: AirportRow = sqlx::query_as(&format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -530,8 +534,7 @@ impl Airport {
|
|||||||
Ok(airport.into())
|
Ok(airport.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_all(airports: Vec<Self>) -> ApiResult<()> {
|
pub async fn insert_all(pool: &Pool<Postgres>, airports: Vec<Self>) -> CoreResult<()> {
|
||||||
let pool = db::pool();
|
|
||||||
let chunk_size = 1000;
|
let chunk_size = 1000;
|
||||||
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
||||||
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
|
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
|
||||||
@@ -549,11 +552,12 @@ impl Airport {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for chunk in airport_rows.chunks(chunk_size) {
|
for chunk in airport_rows.chunks(chunk_size) {
|
||||||
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
|
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(format!(
|
||||||
"INSERT INTO airports (icao, iata, local, name, category, \
|
"INSERT INTO {} (icao, iata, local, name, category, \
|
||||||
iso_country, iso_region, municipality, elevation_ft, \
|
iso_country, iso_region, municipality, elevation_ft, \
|
||||||
longitude, latitude, geometry, has_tower, has_beacon, public) ",
|
longitude, latitude, geometry, has_tower, has_beacon, public) ",
|
||||||
);
|
TABLE_NAME
|
||||||
|
));
|
||||||
query_builder.push_values(chunk, |mut b, row| {
|
query_builder.push_values(chunk, |mut b, row| {
|
||||||
b.push_bind(&row.icao)
|
b.push_bind(&row.icao)
|
||||||
.push_bind(&row.iata)
|
.push_bind(&row.iata)
|
||||||
@@ -580,16 +584,14 @@ impl Airport {
|
|||||||
query.execute(pool).await?;
|
query.execute(pool).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Runway::insert_all(&all_runway_rows).await?;
|
Runway::insert_all(pool, &all_runway_rows).await?;
|
||||||
Communication::insert_all(&all_frequency_rows).await?;
|
Communication::insert_all(pool, &all_frequency_rows).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
// TODO
|
||||||
pub async fn update(icao: &str, airport: &UpdateAirport) -> ApiResult<()> {
|
pub async fn update(pool: &Pool<Postgres>, icao: &str, airport: &UpdateAirport) -> CoreResult<()> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let mut query_builder: QueryBuilder<Postgres> =
|
let mut query_builder: QueryBuilder<Postgres> =
|
||||||
QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME));
|
QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME));
|
||||||
if let Some(latest_metar_observation) = airport.latest_metar_observation {
|
if let Some(latest_metar_observation) = airport.latest_metar_observation {
|
||||||
@@ -604,9 +606,7 @@ impl Airport {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(icao: &str) -> ApiResult<()> {
|
pub async fn delete(pool: &Pool<Postgres>, icao: &str) -> CoreResult<()> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
sqlx::query(&format!(
|
sqlx::query(&format!(
|
||||||
r#"
|
r#"
|
||||||
DELETE FROM {} WHERE icao = $1
|
DELETE FROM {} WHERE icao = $1
|
||||||
@@ -620,9 +620,7 @@ impl Airport {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all() -> ApiResult<()> {
|
pub async fn delete_all(pool: &Pool<Postgres>) -> CoreResult<()> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
sqlx::query(&format!(
|
sqlx::query(&format!(
|
||||||
r#"
|
r#"
|
||||||
DELETE FROM {} WHERE true
|
DELETE FROM {} WHERE true
|
||||||
@@ -689,7 +687,7 @@ impl Airport {
|
|||||||
builder: &mut QueryBuilder<'a, Postgres>,
|
builder: &mut QueryBuilder<'a, Postgres>,
|
||||||
has_where: &mut bool,
|
has_where: &mut bool,
|
||||||
field: &'a Option<String>,
|
field: &'a Option<String>,
|
||||||
) -> ApiResult<()> {
|
) -> CoreResult<()> {
|
||||||
// Query bounds
|
// Query bounds
|
||||||
if let Some(bounds_string) = field {
|
if let Some(bounds_string) = field {
|
||||||
if !*has_where {
|
if !*has_where {
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
use crate::db;
|
|
||||||
use crate::error::ApiResult;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Postgres, QueryBuilder};
|
use sqlx::{Pool, Postgres, QueryBuilder};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use crate::error::CoreResult;
|
||||||
|
|
||||||
const TABLE_NAME: &str = "communications";
|
const TABLE_NAME: &str = "communications";
|
||||||
|
|
||||||
@@ -65,9 +64,7 @@ impl Communication {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_all_map(icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
pub async fn select_all_map(pool: &Pool<Postgres>, icaos: &Vec<String>) -> CoreResult<HashMap<String, Vec<Self>>> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let frequency_rows: Vec<CommunicationRow> = sqlx::query_as(&format!(
|
let frequency_rows: Vec<CommunicationRow> = sqlx::query_as(&format!(
|
||||||
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
|
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
|
||||||
TABLE_NAME
|
TABLE_NAME
|
||||||
@@ -89,9 +86,7 @@ impl Communication {
|
|||||||
Ok(frequency_map)
|
Ok(frequency_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
|
pub async fn select_all(pool: &Pool<Postgres>, icao: &str) -> CoreResult<Vec<Self>> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let frequency_row: Vec<CommunicationRow> = sqlx::query_as(&format!(
|
let frequency_row: Vec<CommunicationRow> = sqlx::query_as(&format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM {} WHERE icao = $1
|
SELECT * FROM {} WHERE icao = $1
|
||||||
@@ -104,8 +99,7 @@ impl Communication {
|
|||||||
Ok(frequency_row.into_iter().map(From::from).collect())
|
Ok(frequency_row.into_iter().map(From::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_all(communications: &Vec<CommunicationRow>) -> ApiResult<()> {
|
pub async fn insert_all(pool: &Pool<Postgres>, communications: &Vec<CommunicationRow>) -> CoreResult<()> {
|
||||||
let pool = db::pool();
|
|
||||||
let chunk_size = 1000;
|
let chunk_size = 1000;
|
||||||
|
|
||||||
for chunk in communications.chunks(chunk_size) {
|
for chunk in communications.chunks(chunk_size) {
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::db;
|
use crate::error::CoreResult;
|
||||||
use crate::error::ApiResult;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Postgres, QueryBuilder};
|
use sqlx::{Pool, Postgres, QueryBuilder};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -64,9 +63,7 @@ impl Runway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_all_map(icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
pub async fn select_all_map(pool: &Pool<Postgres>, icaos: &Vec<String>) -> CoreResult<HashMap<String, Vec<Self>>> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
|
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
|
||||||
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
|
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
|
||||||
TABLE_NAME
|
TABLE_NAME
|
||||||
@@ -85,9 +82,7 @@ impl Runway {
|
|||||||
Ok(runway_map)
|
Ok(runway_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
|
pub async fn select_all(pool: &Pool<Postgres>, icao: &str) -> CoreResult<Vec<Self>> {
|
||||||
let pool = db::pool();
|
|
||||||
|
|
||||||
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
|
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM {} WHERE icao = $1
|
SELECT * FROM {} WHERE icao = $1
|
||||||
@@ -100,8 +95,7 @@ impl Runway {
|
|||||||
Ok(runway_rows.into_iter().map(From::from).collect())
|
Ok(runway_rows.into_iter().map(From::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_all(runways: &Vec<RunwayRow>) -> ApiResult<()> {
|
pub async fn insert_all(pool: &Pool<Postgres>, runways: &Vec<RunwayRow>) -> CoreResult<()> {
|
||||||
let pool = db::pool();
|
|
||||||
let chunk_size = 1000;
|
let chunk_size = 1000;
|
||||||
|
|
||||||
for chunk in runways.chunks(chunk_size) {
|
for chunk in runways.chunks(chunk_size) {
|
||||||
220
crates/lib/src/error.rs
Normal file
220
crates/lib/src/error.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::sync::{MutexGuard, PoisonError};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::de::StdError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum CoreErrorKind {
|
||||||
|
NotFound,
|
||||||
|
InvalidInput,
|
||||||
|
Conflict,
|
||||||
|
Unauthorized,
|
||||||
|
Forbidden,
|
||||||
|
PreconditionFailed,
|
||||||
|
Timeout,
|
||||||
|
Cancelled,
|
||||||
|
Unavailable,
|
||||||
|
Internal,
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CoreError {
|
||||||
|
pub kind: CoreErrorKind,
|
||||||
|
pub message: String,
|
||||||
|
pub context: Vec<(&'static str, String)>,
|
||||||
|
source: Option<Box<dyn StdError>>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreError {
|
||||||
|
pub fn new(kind: CoreErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
context: vec![],
|
||||||
|
source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_source(kind: CoreErrorKind, message: impl Into<String>, source: impl StdError + Send + Sync + 'static) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
context: vec![],
|
||||||
|
source: Some(Box::new(source)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context(mut self, context: Vec<(&'static str, String)>) -> Self {
|
||||||
|
self.context = context;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for CoreError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:?} - {}", self.kind, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdError for CoreError {
|
||||||
|
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
||||||
|
self.source.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type CoreResult<T> = Result<T, CoreError>;
|
||||||
|
|
||||||
|
pub fn not_found(entity: &'static str, id: impl Into<String>) -> CoreError {
|
||||||
|
CoreError::new(CoreErrorKind::NotFound, format!("{entity} not found: {}", id.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl From<argon2::password_hash::Error> for CoreError {
|
||||||
|
fn from(error: argon2::password_hash::Error) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for CoreError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown IO error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<redis::RedisError> for CoreError {
|
||||||
|
fn from(error: redis::RedisError) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown redis error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for CoreError {
|
||||||
|
fn from(error: sqlx::Error) -> Self {
|
||||||
|
match error {
|
||||||
|
sqlx::Error::RowNotFound => CoreError::new(CoreErrorKind::NotFound, "Not found".to_string()),
|
||||||
|
sqlx::Error::ColumnIndexOutOfBounds { .. } => CoreError::new(CoreErrorKind::InvalidInput, error.to_string()),
|
||||||
|
sqlx::Error::ColumnNotFound { .. } => CoreError::new(CoreErrorKind::NotFound, error.to_string()),
|
||||||
|
sqlx::Error::ColumnDecode { .. } => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::Decode(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::PoolTimedOut => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::PoolClosed => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::Tls(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::Io(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::Protocol(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::Configuration(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::AnyDriverError(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::Database(err) => {
|
||||||
|
if let Some(code) = err.code() {
|
||||||
|
match code.trim() {
|
||||||
|
// Unique violation
|
||||||
|
"23505" => return CoreError::new(CoreErrorKind::External, err.to_string()),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CoreError::new(CoreErrorKind::External, err.to_string())
|
||||||
|
}
|
||||||
|
sqlx::Error::Migrate(_) => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
sqlx::Error::TypeNotFound { type_name } => {
|
||||||
|
CoreError::new(CoreErrorKind::External, format!("Type not found: {}", type_name))
|
||||||
|
}
|
||||||
|
sqlx::Error::WorkerCrashed => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
_ => CoreError::new(CoreErrorKind::External, error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for CoreError {
|
||||||
|
fn from(error: reqwest::Error) -> Self {
|
||||||
|
match error.status() {
|
||||||
|
Some(status_code) => {
|
||||||
|
if status_code.is_client_error() {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Client reqwest error: {:?}", error))
|
||||||
|
} else if status_code.is_server_error() {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Server reqwest error: {:?}", error))
|
||||||
|
} else {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown reqwest error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Self::new(CoreErrorKind::External, format!("Unknown reqwest error: {:?}", error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<s3::error::S3Error> for CoreError {
|
||||||
|
fn from(error: s3::error::S3Error) -> Self {
|
||||||
|
match error {
|
||||||
|
s3::error::S3Error::Credentials(err) => {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown s3 credentials error: {:?}", err))
|
||||||
|
}
|
||||||
|
s3::error::S3Error::FromUtf8(err) => {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown s3 from utf8 error: {:?}", err))
|
||||||
|
}
|
||||||
|
s3::error::S3Error::FmtError(err) => {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown s3 fmt error: {:?}", err))
|
||||||
|
}
|
||||||
|
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
|
||||||
|
CoreErrorKind::External,
|
||||||
|
format!("Unknown s3 hmac invalid length error: {:?}", err),
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
let re = Regex::new(r"HTTP (\d{3})").unwrap();
|
||||||
|
// Apply the regex to the input string
|
||||||
|
if let Some(captures) = re.captures(&error.to_string()) {
|
||||||
|
if let Some(http_code_str) = captures.get(1) {
|
||||||
|
if let Ok(http_code) = http_code_str.as_str().parse::<u16>() {
|
||||||
|
return Self::new(CoreErrorKind::External, error.to_string()).context(vec![("http_code", http_code.to_string())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown s3 error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::env::VarError> for CoreError {
|
||||||
|
fn from(error: std::env::VarError) -> Self {
|
||||||
|
Self::new(
|
||||||
|
CoreErrorKind::External,
|
||||||
|
format!("Unknown environment variable error: {:?}", error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for CoreError {
|
||||||
|
fn from(error: serde_json::Error) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Unknown serde_json error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> From<PoisonError<MutexGuard<'a, T>>> for CoreError {
|
||||||
|
fn from(_: PoisonError<MutexGuard<'a, T>>) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, "Failed to acquire lock".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core::num::ParseIntError> for CoreError {
|
||||||
|
fn from(error: core::num::ParseIntError) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Integer parse error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core::num::ParseFloatError> for CoreError {
|
||||||
|
fn from(error: core::num::ParseFloatError) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Float parse error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<regex::Error> for CoreError {
|
||||||
|
fn from(error: regex::Error) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chrono::ParseError> for CoreError {
|
||||||
|
fn from(error: chrono::ParseError) -> Self {
|
||||||
|
Self::new(CoreErrorKind::External, format!("Chrono parse error: {:?}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::error::{ApiResult, Error};
|
use crate::error::{CoreResult, CoreError, CoreErrorKind};
|
||||||
use governor::clock::DefaultClock;
|
use governor::clock::DefaultClock;
|
||||||
use governor::state::{InMemoryState, NotKeyed};
|
use governor::state::{InMemoryState, NotKeyed};
|
||||||
use governor::{Quota, RateLimiter};
|
use governor::{Quota, RateLimiter};
|
||||||
@@ -18,7 +18,7 @@ pub struct HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HttpClient {
|
impl HttpClient {
|
||||||
pub fn new(default_retry_after: u64) -> ApiResult<Self> {
|
pub fn new(default_retry_after: u64) -> CoreResult<Self> {
|
||||||
let mut client_builder = Client::builder()
|
let mut client_builder = Client::builder()
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.tls_built_in_root_certs(true);
|
.tls_built_in_root_certs(true);
|
||||||
@@ -45,11 +45,11 @@ impl HttpClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default() -> ApiResult<Self> {
|
pub fn default() -> CoreResult<Self> {
|
||||||
Self::new(60)
|
Self::new(60)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(&self, url: &str, etag: Option<String>) -> ApiResult<Response> {
|
pub async fn get(&self, url: &str, etag: Option<String>) -> CoreResult<Response> {
|
||||||
self.limiter.until_ready().await;
|
self.limiter.until_ready().await;
|
||||||
|
|
||||||
let mut request = self.client.get(url);
|
let mut request = self.client.get(url);
|
||||||
@@ -81,8 +81,8 @@ impl HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.status() != 200 {
|
if response.status() != 200 {
|
||||||
return Err(Error::new(
|
return Err(CoreError::new(
|
||||||
response.status().as_u16(),
|
CoreErrorKind::External,
|
||||||
format!("Request returned status {}", response.status()),
|
format!("Request returned status {}", response.status()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
6
crates/lib/src/lib.rs
Normal file
6
crates/lib/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod accounts;
|
||||||
|
pub mod airports;
|
||||||
|
pub mod metars;
|
||||||
|
pub mod http_client;
|
||||||
|
pub mod state;
|
||||||
|
pub mod error;
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
use crate::db::redis_async_connection;
|
|
||||||
use crate::error::ApiResult;
|
|
||||||
use crate::metars::Metar;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use redis::{AsyncCommands, RedisResult};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::error::CoreResult;
|
||||||
|
use crate::metars::model::Metar;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct MetarCheck {
|
pub struct MetarCheck {
|
||||||
@@ -14,8 +13,8 @@ pub struct MetarCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MetarCheck {
|
impl MetarCheck {
|
||||||
pub async fn new(icao: String, status: bool) -> Self {
|
pub async fn new(state: &AppState, icao: String, status: bool) -> Self {
|
||||||
match Self::get(&icao).await {
|
match Self::get(state, &icao).await {
|
||||||
Some(c) => Self {
|
Some(c) => Self {
|
||||||
icao,
|
icao,
|
||||||
status,
|
status,
|
||||||
@@ -31,15 +30,8 @@ impl MetarCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(icao: &str) -> Option<MetarCheck> {
|
pub async fn get(state: &AppState, icao: &str) -> Option<MetarCheck> {
|
||||||
let mut conn = match redis_async_connection().await {
|
let result: CoreResult<Option<String>> = state.get(icao).await;
|
||||||
Ok(conn) => conn,
|
|
||||||
Err(err) => {
|
|
||||||
log::error!("Unable to get connection for ICAO {}: {}", icao, err);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let result: RedisResult<Option<String>> = conn.get(icao).await;
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Some(value)) => match serde_json::from_str(&value) {
|
Ok(Some(value)) => match serde_json::from_str(&value) {
|
||||||
Ok(result) => Some(result),
|
Ok(result) => Some(result),
|
||||||
@@ -56,10 +48,9 @@ impl MetarCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert(&self) -> ApiResult<()> {
|
pub async fn insert(&self, state: &AppState) -> CoreResult<()> {
|
||||||
let mut conn = redis_async_connection().await?;
|
|
||||||
let value = serde_json::to_string(&self)?;
|
let value = serde_json::to_string(&self)?;
|
||||||
conn.set::<_, _, ()>(self.icao.as_str(), value).await?;
|
state.set(self.icao.as_str(), &value).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
mod metar_check;
|
mod metar_check;
|
||||||
mod model;
|
mod model;
|
||||||
mod routes;
|
mod utils;
|
||||||
|
|
||||||
pub use metar_check::*;
|
pub use metar_check::*;
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
pub use routes::init_routes;
|
pub use utils::*;
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
use crate::airports::{Airport, UpdateAirport};
|
use crate::airports::{Airport, UpdateAirport};
|
||||||
use crate::error::Error;
|
use crate::error::{CoreError, CoreErrorKind, CoreResult};
|
||||||
use crate::http_client::HttpClient;
|
use crate::metars::utils::parse_metar_time;
|
||||||
use crate::metars::MetarCheck;
|
use chrono::{DateTime, Utc};
|
||||||
use crate::{db, error::ApiResult};
|
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use reqwest::header::ETAG;
|
use reqwest::header::ETAG;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Pool, Postgres, QueryBuilder};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::io::{Cursor, Read};
|
use std::io::{Cursor, Read};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
use regex::Regex;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use crate::metars::metar_check::MetarCheck;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
|
static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
|
||||||
|
|
||||||
@@ -84,12 +86,12 @@ pub enum ReportModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for ReportModifier {
|
impl FromStr for ReportModifier {
|
||||||
type Err = Error;
|
type Err = CoreError;
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s {
|
match s {
|
||||||
"AUTO" => Ok(ReportModifier::Auto),
|
"AUTO" => Ok(ReportModifier::Auto),
|
||||||
"COR" => Ok(ReportModifier::Corrected),
|
"COR" => Ok(ReportModifier::Corrected),
|
||||||
_ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))),
|
_ => Err(CoreError::new(CoreErrorKind::InvalidInput, format!("Invalid report modifier '{}'", s))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,13 +136,13 @@ pub enum AutomatedStationType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for AutomatedStationType {
|
impl FromStr for AutomatedStationType {
|
||||||
type Err = Error;
|
type Err = CoreError;
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s {
|
match s {
|
||||||
"AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator),
|
"AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator),
|
||||||
"AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator),
|
"AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator),
|
||||||
_ => Err(Error::new(
|
_ => Err(CoreError::new(
|
||||||
400,
|
CoreErrorKind::InvalidInput,
|
||||||
format!("Invalid automated station type '{}'", s),
|
format!("Invalid automated station type '{}'", s),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -276,8 +278,7 @@ struct MetarRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MetarRow {
|
impl MetarRow {
|
||||||
async fn insert(&self) -> ApiResult<()> {
|
async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<()> {
|
||||||
let pool = db::pool();
|
|
||||||
sqlx::query(&format!(
|
sqlx::query(&format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO {} (
|
INSERT INTO {} (
|
||||||
@@ -302,13 +303,47 @@ impl MetarRow {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn insert_all(pool: &Pool<Postgres>, metars: Vec<Metar>) -> CoreResult<()> {
|
||||||
|
let chunk_size = 1000;
|
||||||
|
|
||||||
|
for chunk in metars.chunks(chunk_size) {
|
||||||
|
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(format!(
|
||||||
|
"INSERT INTO {} (icao, observation_time, raw_text, data) ",
|
||||||
|
TABLE_NAME
|
||||||
|
));
|
||||||
|
query_builder.push_values(chunk, |mut b, metar| {
|
||||||
|
let row: Self = match metar.to_row() {
|
||||||
|
Ok(row) => row,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to serialize METAR data: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
b.push_bind(row.icao)
|
||||||
|
.push_bind(row.observation_time)
|
||||||
|
.push_bind(row.raw_text)
|
||||||
|
.push_bind(row.data);
|
||||||
|
});
|
||||||
|
query_builder.push(
|
||||||
|
" ON CONFLICT (icao, observation_time) DO UPDATE SET \
|
||||||
|
raw_text = EXCLUDED.raw_text, \
|
||||||
|
data = EXCLUDED.data",
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = query_builder.build();
|
||||||
|
query.execute(pool).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metar {
|
impl Metar {
|
||||||
fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> {
|
fn parse_multiple(pool: &Pool<Postgres>, metar_strings: &Vec<&str>) -> CoreResult<Vec<Self>> {
|
||||||
let mut metars: Vec<Self> = vec![];
|
let mut metars: Vec<Self> = vec![];
|
||||||
for metar_string in metar_strings {
|
for metar_string in metar_strings {
|
||||||
match Self::parse(metar_string) {
|
match Self::parse(pool, metar_string) {
|
||||||
Ok(metar) => metars.push(metar),
|
Ok(metar) => metars.push(metar),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to parse metar string: {}", e);
|
log::warn!("Failed to parse metar string: {}", e);
|
||||||
@@ -320,21 +355,26 @@ impl Metar {
|
|||||||
Ok(metars)
|
Ok(metars)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse(metar_string: &str) -> ApiResult<Self> {
|
fn parse(pool: &Pool<Postgres>, metar_string: &str) -> CoreResult<Self> {
|
||||||
if metar_string.is_empty() {
|
if metar_string.is_empty() {
|
||||||
return Err(Error::new(
|
return Err(CoreError::new(
|
||||||
404,
|
CoreErrorKind::InvalidInput,
|
||||||
"Unable to parse empty METAR data".to_string(),
|
"Unable to parse empty METAR data".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let metar_string = metar_string
|
||||||
|
.trim()
|
||||||
|
.trim_matches(|c| c == '"' || c == '\'' || c == '“' || c == '”' || c == '‘' || c == '’')
|
||||||
|
.trim();
|
||||||
|
|
||||||
log::trace!("Parsing METAR data: {}", metar_string);
|
log::trace!("Parsing METAR data: {}", metar_string);
|
||||||
let mut metar: Self = Self::default();
|
let mut metar: Self = Self::default();
|
||||||
metar.raw_text = metar_string.to_owned();
|
metar.raw_text = metar_string.to_owned();
|
||||||
let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect();
|
let mut metar_parts: Vec<&str> = metar_string.split_whitespace().collect();
|
||||||
if metar_parts.len() < 4 {
|
if metar_parts.len() < 4 {
|
||||||
return Err(Error::new(
|
return Err(CoreError::new(
|
||||||
500,
|
CoreErrorKind::InvalidInput,
|
||||||
format!(
|
format!(
|
||||||
"Unable to parse METAR data in an unexpected format: {}",
|
"Unable to parse METAR data in an unexpected format: {}",
|
||||||
metar_string
|
metar_string
|
||||||
@@ -342,9 +382,16 @@ impl Metar {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove METAR at start of text
|
// Remove METAR at the start of the text
|
||||||
if metar_parts[0].to_string() == "METAR".to_string() {
|
let metar_re: Regex = Regex::new(r"(?i)^[\p{P}\s]*METAR[\p{P}\s]*$")?;
|
||||||
|
let speci_re: Regex = Regex::new(r"(?i)^[\p{P}\s]*SPECI[\p{P}\s]*$")?;
|
||||||
|
let token = metar_parts[0].trim();
|
||||||
|
|
||||||
|
if metar_re.is_match(token) {
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
|
} else if speci_re.is_match(token) {
|
||||||
|
// TODO: Handle SPECI data
|
||||||
|
return Err(CoreError::new(CoreErrorKind::InvalidInput, format!("Unable to parse SPECI data: {}", metar_string)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station Identifier
|
// Station Identifier
|
||||||
@@ -354,10 +401,22 @@ impl Metar {
|
|||||||
// Date/Time
|
// Date/Time
|
||||||
let observation_time = metar_parts[0];
|
let observation_time = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
let observation_time = Self::parse_time(observation_time)?;
|
match parse_metar_time(observation_time) {
|
||||||
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
|
Ok(observation_time) => {
|
||||||
Ok(datetime) => datetime.with_timezone(&Utc),
|
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
|
||||||
Err(err) => return Err(err.into()),
|
Ok(datetime) => datetime.with_timezone(&Utc),
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(CoreError::new(
|
||||||
|
CoreErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"Unexpected observation time field '{}': {}; {}",
|
||||||
|
observation_time, metar_string, err
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -375,9 +434,8 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wind Direction and Speed
|
// Wind Direction and Speed
|
||||||
let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}(?:KT|MPS)$").unwrap();
|
let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}(?:KT|MPS)$")?;
|
||||||
let wind_gust_re =
|
let wind_gust_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}(?:KT|MPS)$")?;
|
||||||
regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}(?:KT|MPS)$").unwrap();
|
|
||||||
// Handle input error where there is a space between the numbers and units
|
// Handle input error where there is a space between the numbers and units
|
||||||
let mut value: Option<String> = None;
|
let mut value: Option<String> = None;
|
||||||
if metar_parts.len() >= 2
|
if metar_parts.len() >= 2
|
||||||
@@ -411,9 +469,9 @@ impl Metar {
|
|||||||
let mut wind_speed_kt = wind[3..5].to_string();
|
let mut wind_speed_kt = wind[3..5].to_string();
|
||||||
// Convert m/s to kt
|
// Convert m/s to kt
|
||||||
if wind.len() == 8 {
|
if wind.len() == 8 {
|
||||||
wind_speed_kt = (wind_speed_kt.parse::<f64>().unwrap() * 1.94384).to_string();
|
wind_speed_kt = (wind_speed_kt.parse::<f64>()? * 1.94384).to_string();
|
||||||
}
|
}
|
||||||
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>().unwrap());
|
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>()?);
|
||||||
} else if wind_gust_re.is_match(&wind) {
|
} else if wind_gust_re.is_match(&wind) {
|
||||||
let wind_dir_degrees = &wind[0..3];
|
let wind_dir_degrees = &wind[0..3];
|
||||||
metar.wind_dir_degrees = Some(wind_dir_degrees.to_string());
|
metar.wind_dir_degrees = Some(wind_dir_degrees.to_string());
|
||||||
@@ -421,26 +479,26 @@ impl Metar {
|
|||||||
let mut wind_gust_kt = wind[6..8].to_string();
|
let mut wind_gust_kt = wind[6..8].to_string();
|
||||||
// Convert m/s to kt
|
// Convert m/s to kt
|
||||||
if wind.len() == 9 {
|
if wind.len() == 9 {
|
||||||
wind_speed_kt = (wind_speed_kt.parse::<f64>().unwrap() * 1.94384).to_string();
|
wind_speed_kt = (wind_speed_kt.parse::<f64>()? * 1.94384).to_string();
|
||||||
wind_gust_kt = (wind_gust_kt.parse::<f64>().unwrap() * 1.94384).to_string();
|
wind_gust_kt = (wind_gust_kt.parse::<f64>()? * 1.94384).to_string();
|
||||||
}
|
}
|
||||||
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>().unwrap());
|
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>()?);
|
||||||
metar.wind_gust_kt = Some(wind_gust_kt.parse::<f64>().unwrap());
|
metar.wind_gust_kt = Some(wind_gust_kt.parse::<f64>()?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variable Wind Direction
|
// Variable Wind Direction
|
||||||
let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$").unwrap();
|
let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$")?;
|
||||||
if !metar_parts.is_empty() && variable_wind_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && variable_wind_re.is_match(metar_parts[0]) {
|
||||||
metar.variable_wind_dir_degrees = Some(metar_parts[0].to_string());
|
metar.variable_wind_dir_degrees = Some(metar_parts[0].to_string());
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visibility
|
// Visibility
|
||||||
let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM$").unwrap();
|
let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM$")?;
|
||||||
let visibility_re_m = regex::Regex::new(r"^[0-9]{4}(:?N|NE|NW|S|SE|SW)?$").unwrap();
|
let visibility_re_m = regex::Regex::new(r"^[0-9]{4}(:?N|NE|NW|S|SE|SW)?$")?;
|
||||||
if !metar_parts.is_empty() && visibility_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && visibility_re.is_match(metar_parts[0]) {
|
||||||
let visibility_str = &metar_parts[0][0..metar_parts[0].len() - 2];
|
let visibility_str = &metar_parts[0][0..metar_parts[0].len() - 2];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -474,59 +532,72 @@ impl Metar {
|
|||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
|
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
let visibility_left = visibility_parts[0];
|
if visibility_parts.len() == 1 {
|
||||||
// Parse the right-hand of visibility, with or without an SM suffix
|
metar.visibility_statute_mi = Some(visibility_parts[0].to_string());
|
||||||
let visibility_right_string = match visibility_parts[1].strip_suffix("SM") {
|
} else if visibility_parts.len() == 2 {
|
||||||
Some(s) => s,
|
let visibility_left = visibility_parts[0];
|
||||||
None => {
|
// Parse the right-hand of visibility, with or without an SM suffix
|
||||||
if visibility_parts[1].chars().all(|c| c.is_numeric() || c == '.') {
|
let visibility_right_string = match visibility_parts[1].strip_suffix("SM") {
|
||||||
visibility_parts[1]
|
Some(s) => s,
|
||||||
} else {
|
None => {
|
||||||
log::warn!(
|
if visibility_parts[1]
|
||||||
"Skipping invalid visibility field '{}' ({})",
|
.chars()
|
||||||
metar_parts[0],
|
.all(|c| c.is_numeric() || c == '.')
|
||||||
metar_string
|
{
|
||||||
);
|
visibility_parts[1]
|
||||||
continue;
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Skipping unexpected visibility field '{:?}' ({})",
|
||||||
|
visibility_parts,
|
||||||
|
metar_string
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let visibility_right = visibility_right_string.parse::<f64>()?;
|
||||||
|
let visibility = if visibility_left.starts_with("M") {
|
||||||
|
format!(
|
||||||
|
"M{}",
|
||||||
|
visibility_whole
|
||||||
|
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
||||||
|
)
|
||||||
|
} else if visibility_left.starts_with("P") {
|
||||||
|
format!(
|
||||||
|
"P{}",
|
||||||
|
visibility_whole
|
||||||
|
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}",
|
||||||
|
visibility_whole + (visibility_left.parse::<f64>()? / visibility_right)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
metar.visibility_statute_mi = Some(visibility);
|
||||||
|
} else if !metar_parts.is_empty() && visibility_re_m.is_match(metar_parts[0]) {
|
||||||
|
// Convert meters to statute miles
|
||||||
|
let visibility = metar_parts[0];
|
||||||
|
metar_parts.remove(0);
|
||||||
|
if &visibility[0..4] == "9999" {
|
||||||
|
metar.visibility_statute_mi = Some("P10".to_string());
|
||||||
|
} else {
|
||||||
|
let visibility = visibility[0..4].parse::<f64>()? * 0.000621371;
|
||||||
|
metar.visibility_statute_mi = Some(format!("{:.2}", visibility));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let visibility_right = visibility_right_string.parse::<f64>()?;
|
|
||||||
let visibility = if visibility_left.starts_with("M") {
|
|
||||||
format!(
|
|
||||||
"M{}",
|
|
||||||
visibility_whole
|
|
||||||
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
|
||||||
)
|
|
||||||
} else if visibility_left.starts_with("P") {
|
|
||||||
format!(
|
|
||||||
"P{}",
|
|
||||||
visibility_whole
|
|
||||||
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!(
|
log::warn!(
|
||||||
"{}",
|
"Skipping unexpected visibility field '{}' ({})",
|
||||||
visibility_whole + (visibility_left.parse::<f64>()? / visibility_right)
|
metar_parts[0],
|
||||||
)
|
metar_string
|
||||||
};
|
);
|
||||||
metar.visibility_statute_mi = Some(visibility);
|
|
||||||
} else if !metar_parts.is_empty() && visibility_re_m.is_match(metar_parts[0]) {
|
|
||||||
// Convert meters to statute miles
|
|
||||||
let visibility = metar_parts[0];
|
|
||||||
metar_parts.remove(0);
|
|
||||||
if &visibility[0..4] == "9999" {
|
|
||||||
metar.visibility_statute_mi = Some("P10".to_string());
|
|
||||||
} else {
|
|
||||||
let visibility = visibility[0..4].parse::<f64>()? * 0.000621371;
|
|
||||||
metar.visibility_statute_mi = Some(format!("{:.2}", visibility));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runway Visual Range
|
// Runway Visual Range
|
||||||
let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$").unwrap();
|
let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$")?;
|
||||||
let variable_rvr_re =
|
let variable_rvr_re =
|
||||||
regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$").unwrap();
|
regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$")?;
|
||||||
while !metar_parts.is_empty()
|
while !metar_parts.is_empty()
|
||||||
&& (rvr_re.is_match(metar_parts[0]) || variable_rvr_re.is_match(metar_parts[0]))
|
&& (rvr_re.is_match(metar_parts[0]) || variable_rvr_re.is_match(metar_parts[0]))
|
||||||
{
|
{
|
||||||
@@ -567,63 +638,10 @@ impl Metar {
|
|||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sky Condition
|
metar.parse_sky_condition(&mut metar_parts);
|
||||||
if !metar_parts.is_empty() && metar_parts[0] == "CAVOK" {
|
|
||||||
metar.sky_condition.push(SkyCondition {
|
|
||||||
sky_cover: "CLR".to_string(),
|
|
||||||
cloud_base_ft_agl: None,
|
|
||||||
significant_convective_clouds: None,
|
|
||||||
});
|
|
||||||
metar_parts.remove(0);
|
|
||||||
}
|
|
||||||
let sky_condition_re =
|
|
||||||
regex::Regex::new(r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$")
|
|
||||||
.unwrap();
|
|
||||||
while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) {
|
|
||||||
let mut sky_condition_string = metar_parts[0];
|
|
||||||
metar_parts.remove(0);
|
|
||||||
|
|
||||||
if sky_condition_string.ends_with("///") {
|
|
||||||
sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sky_condition = SkyCondition::default();
|
|
||||||
let mut vv_offset = 0;
|
|
||||||
if &sky_condition_string[0..2] == "VV" {
|
|
||||||
sky_condition.sky_cover = "VV".to_string();
|
|
||||||
vv_offset = 1;
|
|
||||||
} else {
|
|
||||||
sky_condition.sky_cover = sky_condition_string[0..3].to_string();
|
|
||||||
}
|
|
||||||
if sky_condition_string.len() > 3 - vv_offset {
|
|
||||||
// Parse out the next three digits
|
|
||||||
let cloud_base_ft_agl = &sky_condition_string[3 - vv_offset..6 - vv_offset];
|
|
||||||
if cloud_base_ft_agl == "///" {
|
|
||||||
sky_condition.cloud_base_ft_agl = None;
|
|
||||||
} else {
|
|
||||||
sky_condition.cloud_base_ft_agl = match cloud_base_ft_agl.parse::<i32>() {
|
|
||||||
Ok(c) => Some(c * 100),
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!(
|
|
||||||
"Unable to parse cloud base in {}: {}",
|
|
||||||
sky_condition_string,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if sky_condition_string.len() > 6 - vv_offset {
|
|
||||||
// Parse out the next two digits
|
|
||||||
let scc = &sky_condition_string[6 - vv_offset..8 - vv_offset];
|
|
||||||
sky_condition.significant_convective_clouds = Some(scc.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metar.sky_condition.push(sky_condition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temperature and Dewpoint
|
// Temperature and Dewpoint
|
||||||
let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$").unwrap();
|
let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$")?;
|
||||||
if !metar_parts.is_empty() && temp_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && temp_re.is_match(metar_parts[0]) {
|
||||||
let temp_string = metar_parts[0];
|
let temp_string = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -665,7 +683,7 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Altimeter
|
// Altimeter
|
||||||
let altim_re = regex::Regex::new(r"^A[0-9]{4}$").unwrap();
|
let altim_re = regex::Regex::new(r"^A[0-9]{4}$")?;
|
||||||
if !metar_parts.is_empty() && altim_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && altim_re.is_match(metar_parts[0]) {
|
||||||
let altim = metar_parts[0];
|
let altim = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -673,7 +691,7 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pressure
|
// Pressure
|
||||||
let pressure_re = regex::Regex::new(r"^Q[0-9]{4}$").unwrap();
|
let pressure_re = regex::Regex::new(r"^Q[0-9]{4}$")?;
|
||||||
if !metar_parts.is_empty() && pressure_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && pressure_re.is_match(metar_parts[0]) {
|
||||||
let pressure = metar_parts[0];
|
let pressure = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -705,8 +723,8 @@ impl Metar {
|
|||||||
if metar_parts.is_empty() {
|
if metar_parts.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let slp_re = regex::Regex::new(r"^SLP([0-9]{3})$").unwrap();
|
let slp_re = Regex::new(r"^SLP([0-9]{3})$")?;
|
||||||
let hourly_temp_re = regex::Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$").unwrap();
|
let hourly_temp_re = Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$")?;
|
||||||
let remark = metar_parts[0];
|
let remark = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
if remark == "AO1" || remark == "AO2" {
|
if remark == "AO1" || remark == "AO2" {
|
||||||
@@ -740,8 +758,8 @@ impl Metar {
|
|||||||
minutes,
|
minutes,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::new(
|
return Err(CoreError::new(
|
||||||
500,
|
CoreErrorKind::InvalidInput,
|
||||||
"Input string format is invalid".to_string(),
|
"Input string format is invalid".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -801,7 +819,7 @@ impl Metar {
|
|||||||
|
|
||||||
// Skip unexpected fields
|
// Skip unexpected fields
|
||||||
if !metar_parts.is_empty() {
|
if !metar_parts.is_empty() {
|
||||||
log::warn!(
|
log::trace!(
|
||||||
"Skipping unexpected field: '{}' ({})",
|
"Skipping unexpected field: '{}' ({})",
|
||||||
metar_parts[0],
|
metar_parts[0],
|
||||||
metar_string
|
metar_string
|
||||||
@@ -872,8 +890,10 @@ impl Metar {
|
|||||||
// Update the airport's metar observation time
|
// Update the airport's metar observation time
|
||||||
let icao = metar.icao.clone();
|
let icao = metar.icao.clone();
|
||||||
let observation_time = metar.observation_time.clone();
|
let observation_time = metar.observation_time.clone();
|
||||||
|
let pool = pool.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match Airport::update(
|
match Airport::update(
|
||||||
|
&pool.clone(),
|
||||||
&icao,
|
&icao,
|
||||||
&UpdateAirport {
|
&UpdateAirport {
|
||||||
icao: None,
|
icao: None,
|
||||||
@@ -909,124 +929,112 @@ impl Metar {
|
|||||||
Ok(metar)
|
Ok(metar)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_time(observation_time: &str) -> ApiResult<String> {
|
fn parse_sky_condition(&mut self, metar_parts: &mut Vec<&str>) {
|
||||||
if observation_time.len() != 7 {
|
// Check if sky condition is CAVOK
|
||||||
return Err(Error::new(
|
if !metar_parts.is_empty() && metar_parts[0] == "CAVOK" {
|
||||||
500,
|
self.sky_condition.push(SkyCondition {
|
||||||
format!("Unable to parse observation time in {}", observation_time),
|
sky_cover: "CLR".to_string(),
|
||||||
));
|
cloud_base_ft_agl: None,
|
||||||
|
significant_convective_clouds: None,
|
||||||
|
});
|
||||||
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
let observation_day = match observation_time[0..2].parse::<u32>() {
|
|
||||||
Ok(day) => day,
|
let sky_condition_re = regex::Regex::new(
|
||||||
Err(err) => return Err(err.into()),
|
r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$",
|
||||||
};
|
)
|
||||||
let observation_hour = match observation_time[2..4].parse::<u32>() {
|
.unwrap();
|
||||||
Ok(hour) => hour,
|
|
||||||
Err(err) => return Err(err.into()),
|
while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) {
|
||||||
};
|
// Get the next METAR part
|
||||||
let observation_minute = match observation_time[4..6].parse::<u32>() {
|
let mut sky_condition_string = metar_parts[0];
|
||||||
Ok(minute) => minute,
|
metar_parts.remove(0);
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
};
|
// Remove trailing slashes
|
||||||
let current_time = Utc::now().naive_utc();
|
if sky_condition_string.ends_with("///") {
|
||||||
let current_year = current_time.year();
|
sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3];
|
||||||
let current_month = current_time.month();
|
|
||||||
let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
Error::new(
|
|
||||||
500,
|
|
||||||
format!(
|
|
||||||
"Invalid date with day {} for current month",
|
|
||||||
observation_day
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) {
|
|
||||||
Some(date) => date,
|
|
||||||
None => {
|
|
||||||
return Err(Error::new(
|
|
||||||
500,
|
|
||||||
format!(
|
|
||||||
"Invalid time for time '{}': hour {}, minute {}",
|
|
||||||
observation_time, observation_hour, observation_minute
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let obs_datetime = if candidate_date > current_time {
|
let mut sky_condition = SkyCondition::default();
|
||||||
// Subtract one month. (Handle year rollover carefully.)
|
// Handle sky cover and optionally vertical visibility
|
||||||
let (month, year) = if current_month == 1 {
|
let mut vv_offset = 0;
|
||||||
(12, current_year - 1)
|
if &sky_condition_string[0..2] == "VV" {
|
||||||
|
sky_condition.sky_cover = "VV".to_string();
|
||||||
|
vv_offset = 1;
|
||||||
} else {
|
} else {
|
||||||
(current_month - 1, current_year)
|
sky_condition.sky_cover = sky_condition_string[0..3].to_string();
|
||||||
};
|
}
|
||||||
|
if sky_condition_string.len() > 3 - vv_offset {
|
||||||
|
if sky_condition_string.len() < 6 - vv_offset {
|
||||||
|
// Parse out the significant convective clouds
|
||||||
|
let scc = &sky_condition_string[3 - vv_offset..];
|
||||||
|
sky_condition.significant_convective_clouds = Some(scc.to_string());
|
||||||
|
} else {
|
||||||
|
// Parse out the next three digits
|
||||||
|
let cloud_base_ft_agl = &sky_condition_string[3 - vv_offset..6 - vv_offset];
|
||||||
|
sky_condition.cloud_base_ft_agl = match cloud_base_ft_agl.parse::<i32>() {
|
||||||
|
Ok(c) => Some(c * 100),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Unable to parse cloud base in {}: {}",
|
||||||
|
sky_condition_string,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let adjusted_date =
|
// Parse out the significant convective clouds
|
||||||
NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| {
|
let scc = &sky_condition_string[6 - vv_offset..];
|
||||||
Error::new(
|
sky_condition.significant_convective_clouds = Some(scc.to_string());
|
||||||
500,
|
}
|
||||||
format!(
|
}
|
||||||
"Invalid date with day {} for month {}",
|
self.sky_condition.push(sky_condition);
|
||||||
observation_day, month
|
}
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
adjusted_date
|
|
||||||
.and_hms_opt(observation_hour, observation_minute, 0)
|
|
||||||
.unwrap()
|
|
||||||
} else {
|
|
||||||
candidate_date
|
|
||||||
};
|
|
||||||
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_cached_remote_metars(
|
pub async fn get_cached_remote_metars(
|
||||||
client: &HttpClient,
|
state: &AppState,
|
||||||
etag: Option<String>,
|
etag: Option<String>,
|
||||||
) -> ApiResult<(Vec<Self>, String)> {
|
) -> CoreResult<(Vec<Self>, String)> {
|
||||||
let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
|
let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
|
||||||
let url = format!("{}/data/cache/metars.cache.csv.gz", base_url);
|
let url = format!("{}/data/cache/metars.cache.csv.gz", base_url);
|
||||||
|
|
||||||
match client.get(&url, etag.clone()).await {
|
let response = state.client.get(&url, etag.clone()).await?;
|
||||||
Ok(r) => {
|
let new_etag = response
|
||||||
let new_etag = r
|
.headers()
|
||||||
.headers()
|
.get(ETAG)
|
||||||
.get(ETAG)
|
.and_then(|h| h.to_str().ok())
|
||||||
.and_then(|h| h.to_str().ok())
|
.map(|s| s.to_string());
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let bytes = r.bytes().await?;
|
let bytes = response.bytes().await?;
|
||||||
let mut gz = GzDecoder::new(Cursor::new(bytes));
|
let mut gz = GzDecoder::new(Cursor::new(bytes));
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
gz.read_to_string(&mut text)?;
|
gz.read_to_string(&mut text)?;
|
||||||
|
|
||||||
let mut output: Vec<Metar> = Vec::new();
|
let mut output: Vec<Metar> = Vec::new();
|
||||||
|
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
// Split off first column
|
// Split off the first column
|
||||||
let raw_text = line.splitn(2, ',').next().unwrap();
|
let raw_text = line.splitn(2, ',').next().unwrap();
|
||||||
match Metar::parse(raw_text) {
|
match Metar::parse(&state.pool, raw_text) {
|
||||||
Ok(m) => output.push(m),
|
Ok(m) => output.push(m),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("{}", err);
|
log::warn!("{}", err);
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
match new_etag {
|
match new_etag {
|
||||||
Some(etag) => Ok((output, etag)),
|
Some(etag) => Ok((output, etag)),
|
||||||
None => match etag {
|
None => match etag {
|
||||||
Some(etag) => Ok((output, etag)),
|
Some(etag) => Ok((output, etag.to_string())),
|
||||||
None => Ok((output, String::new())),
|
None => Ok((output, String::new())),
|
||||||
},
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.into()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_remote_metars(client: &HttpClient, icaos: &Vec<String>) -> ApiResult<Vec<Self>> {
|
pub async fn get_remote_metars(state: &AppState, icaos: &Vec<String>) -> CoreResult<Vec<Self>> {
|
||||||
let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
|
let base_url = 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
|
// Query the remote API for the missing METAR data 10 at a time
|
||||||
let icao_chunks = icaos
|
let icao_chunks = icaos
|
||||||
@@ -1039,7 +1047,7 @@ impl Metar {
|
|||||||
"{}/api/data/metar?ids={}&hours=0&order=id,-obs",
|
"{}/api/data/metar?ids={}&hours=0&order=id,-obs",
|
||||||
base_url, icao_chunk
|
base_url, icao_chunk
|
||||||
);
|
);
|
||||||
let mut m = match client.get(&url, None).await {
|
let mut m = match state.client.get(&url, None).await {
|
||||||
Ok(r) => match r.text().await {
|
Ok(r) => match r.text().await {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
let metar_chunk = r
|
let metar_chunk = r
|
||||||
@@ -1047,12 +1055,12 @@ impl Metar {
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(|m| !m.trim().is_empty())
|
.filter(|m| !m.trim().is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
match Self::parse_multiple(&metar_chunk) {
|
match Self::parse_multiple(&state.pool, &metar_chunk) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => return Err(Error::new(500, format!("METAR parse failed: {}", err))),
|
Err(err) => return Err(CoreError::new(CoreErrorKind::InvalidInput, format!("METAR parse failed: {}", err))),
|
||||||
},
|
},
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
};
|
};
|
||||||
@@ -1061,12 +1069,12 @@ impl Metar {
|
|||||||
Ok(metars)
|
Ok(metars)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_row(row: MetarRow) -> ApiResult<Self> {
|
fn from_row(row: MetarRow) -> CoreResult<Self> {
|
||||||
let metar: Self = serde_json::from_value(row.data)?;
|
let metar: Self = serde_json::from_value(row.data)?;
|
||||||
Ok(metar)
|
Ok(metar)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_row(&self) -> ApiResult<MetarRow> {
|
fn to_row(&self) -> CoreResult<MetarRow> {
|
||||||
let data = serde_json::to_value(self)?;
|
let data = serde_json::to_value(self)?;
|
||||||
Ok(MetarRow {
|
Ok(MetarRow {
|
||||||
icao: self.icao.to_uppercase(),
|
icao: self.icao.to_uppercase(),
|
||||||
@@ -1076,12 +1084,11 @@ impl Metar {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all_distinct(icao_list: &Vec<String>) -> ApiResult<Vec<Self>> {
|
pub async fn get_all_distinct(pool: &Pool<Postgres>, icao_list: &Vec<String>) -> CoreResult<Vec<Self>> {
|
||||||
if icao_list.is_empty() {
|
if icao_list.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = db::pool();
|
|
||||||
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
|
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT DISTINCT ON (icao) * FROM {}
|
SELECT DISTINCT ON (icao) * FROM {}
|
||||||
@@ -1101,10 +1108,10 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_or_update_metars(
|
pub async fn get_or_update_metars(
|
||||||
client: &HttpClient,
|
state: &AppState,
|
||||||
icaos: &Vec<String>,
|
icaos: &Vec<String>,
|
||||||
) -> ApiResult<Vec<Self>> {
|
) -> CoreResult<Vec<Self>> {
|
||||||
let metars = Self::get_all_distinct(&icaos).await?;
|
let metars = Self::get_all_distinct(&state.pool, &icaos).await?;
|
||||||
let current_time = Utc::now().timestamp();
|
let current_time = Utc::now().timestamp();
|
||||||
|
|
||||||
let mut updated_metars: Vec<Self> = vec![];
|
let mut updated_metars: Vec<Self> = vec![];
|
||||||
@@ -1120,7 +1127,7 @@ impl Metar {
|
|||||||
// Handle outdated METARs
|
// Handle outdated METARs
|
||||||
if current_time > (metar.observation_time.timestamp() + time_offset()) {
|
if current_time > (metar.observation_time.timestamp() + time_offset()) {
|
||||||
// If the METAR has previously been found, get the updated_at time, otherwise default
|
// If the METAR has previously been found, get the updated_at time, otherwise default
|
||||||
let refresh_seconds = match MetarCheck::get(&icao).await {
|
let refresh_seconds = match MetarCheck::get(state, &icao).await {
|
||||||
Some(c) => current_time - c.updated_at.timestamp(),
|
Some(c) => current_time - c.updated_at.timestamp(),
|
||||||
None => DEFAULT_REFRESH_DURATION,
|
None => DEFAULT_REFRESH_DURATION,
|
||||||
};
|
};
|
||||||
@@ -1143,15 +1150,15 @@ impl Metar {
|
|||||||
// Otherwise add the valid metar to the updated list
|
// Otherwise add the valid metar to the updated list
|
||||||
else {
|
else {
|
||||||
found_metar_icaos.insert(icao.clone());
|
found_metar_icaos.insert(icao.clone());
|
||||||
let metar_check = MetarCheck::new(icao, true).await;
|
let metar_check = MetarCheck::new(state, icao, true).await;
|
||||||
metar_check.insert().await?;
|
metar_check.insert(state).await?;
|
||||||
updated_metars.push(metar);
|
updated_metars.push(metar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all METARs that were not in the returned database METARs
|
// Add all METARs that were not in the returned database METARs
|
||||||
for icao in &requested_icaos {
|
for icao in &requested_icaos {
|
||||||
match MetarCheck::get(icao).await {
|
match MetarCheck::get(state, icao).await {
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
if current_time > (c.updated_at.timestamp() + DEFAULT_REFRESH_DURATION) {
|
if current_time > (c.updated_at.timestamp() + DEFAULT_REFRESH_DURATION) {
|
||||||
missing_metar_icaos.push(icao.to_string());
|
missing_metar_icaos.push(icao.to_string());
|
||||||
@@ -1169,7 +1176,7 @@ impl Metar {
|
|||||||
"Retrieving missing METAR data for {:?}",
|
"Retrieving missing METAR data for {:?}",
|
||||||
missing_metar_icaos
|
missing_metar_icaos
|
||||||
);
|
);
|
||||||
let mut remote_metars = Self::get_remote_metars(client, &missing_metar_icaos)
|
let mut remote_metars = Self::get_remote_metars(&state, &missing_metar_icaos)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|err| {
|
.unwrap_or_else(|err| {
|
||||||
log::warn!("Unable to get remote METAR data; {}", err);
|
log::warn!("Unable to get remote METAR data; {}", err);
|
||||||
@@ -1179,19 +1186,19 @@ impl Metar {
|
|||||||
// Insert missing METARs
|
// Insert missing METARs
|
||||||
if remote_metars.len() > 0 {
|
if remote_metars.len() > 0 {
|
||||||
for remote_metar in remote_metars.clone() {
|
for remote_metar in remote_metars.clone() {
|
||||||
remote_metar.insert().await?;
|
remote_metar.insert(&state.pool).await?;
|
||||||
found_metar_icaos.insert(remote_metar.icao.to_string());
|
found_metar_icaos.insert(remote_metar.icao.to_string());
|
||||||
let mut metar_check = MetarCheck::new(remote_metar.icao.clone(), true).await;
|
let mut metar_check = MetarCheck::new(state, remote_metar.icao.clone(), true).await;
|
||||||
metar_check.last_metar = Some(remote_metar);
|
metar_check.last_metar = Some(remote_metar);
|
||||||
metar_check.insert().await?;
|
metar_check.insert(state).await?;
|
||||||
}
|
}
|
||||||
updated_metars.append(&mut remote_metars);
|
updated_metars.append(&mut remote_metars);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update still missing METARs
|
// Update still missing METARs
|
||||||
for difference in found_metar_icaos.symmetric_difference(&requested_icaos) {
|
for difference in found_metar_icaos.symmetric_difference(&requested_icaos) {
|
||||||
let metar_check = MetarCheck::new(difference.to_string(), false).await;
|
let metar_check = MetarCheck::new(state, difference.to_string(), false).await;
|
||||||
metar_check.insert().await?;
|
metar_check.insert(state).await?;
|
||||||
// Only add cached metar data if it's less than 4 hours old
|
// Only add cached metar data if it's less than 4 hours old
|
||||||
if let Some(last_metar) = metar_check.last_metar {
|
if let Some(last_metar) = metar_check.last_metar {
|
||||||
let four_hours_ago = Utc::now() - chrono::Duration::hours(4);
|
let four_hours_ago = Utc::now() - chrono::Duration::hours(4);
|
||||||
@@ -1205,28 +1212,26 @@ impl Metar {
|
|||||||
Ok(updated_metars)
|
Ok(updated_metars)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_metars(client: &HttpClient, etag: Option<String>) -> ApiResult<String> {
|
pub async fn update_metars(state: &AppState, etag: Option<String>) -> CoreResult<String> {
|
||||||
let (remote_metars, etag) = Self::get_cached_remote_metars(client, etag)
|
let (remote_metars, etag) = Self::get_cached_remote_metars(state, etag)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|err| {
|
.unwrap_or_else(|err| {
|
||||||
log::warn!("Unable to get cached remote METAR data; {}", err);
|
log::warn!("Unable to get cached remote METAR data; {}", err);
|
||||||
(vec![], String::new())
|
(vec![], String::new())
|
||||||
});
|
});
|
||||||
for remote_metar in remote_metars.clone() {
|
MetarRow::insert_all(&state.pool, remote_metars).await?;
|
||||||
remote_metar.insert().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(etag)
|
Ok(etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert(&self) -> ApiResult<()> {
|
pub async fn insert(&self, pool: &Pool<Postgres>) -> CoreResult<()> {
|
||||||
log::trace!(
|
log::trace!(
|
||||||
"Inserting metar {} with observation time {}",
|
"Inserting metar {} with observation time {}",
|
||||||
self.icao,
|
self.icao,
|
||||||
self.observation_time
|
self.observation_time
|
||||||
);
|
);
|
||||||
let metar: MetarRow = self.to_row()?;
|
let metar: MetarRow = self.to_row()?;
|
||||||
metar.insert().await?;
|
metar.insert(pool).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1234,66 +1239,49 @@ impl Metar {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_time() {
|
|
||||||
for day in 1..=31 {
|
|
||||||
for hour in 0..24 {
|
|
||||||
for minute in 0..60 {
|
|
||||||
// METAR form "DDHHMMZ"
|
|
||||||
let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute);
|
|
||||||
let result = Metar::parse_time(&obs_time);
|
|
||||||
match result {
|
|
||||||
Ok(datetime_str) => {
|
|
||||||
// "YYYY-MM-DDTHH:MM:00Z"
|
|
||||||
assert_eq!(
|
|
||||||
datetime_str.len(),
|
|
||||||
20,
|
|
||||||
"Unexpected length for input {} yielded {}",
|
|
||||||
obs_time,
|
|
||||||
datetime_str
|
|
||||||
);
|
|
||||||
// Remove the trailing 'Z' and parse
|
|
||||||
let trimmed = &datetime_str[..19];
|
|
||||||
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"Parsing '{}' from input {} failed: {}",
|
|
||||||
trimmed, obs_time, e
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_err) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_metar() {
|
async fn test_metar_parse() {
|
||||||
let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990
|
let state = AppState::new().await.unwrap();
|
||||||
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 mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT \
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
-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(&state.pool, &metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string();
|
metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 \
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
SLP126 T02500217 $"
|
||||||
|
.to_string();
|
||||||
|
let metar = Metar::parse(&state.pool, &metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string =
|
metar_string =
|
||||||
"KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117"
|
"KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117"
|
||||||
.to_string();
|
.to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&state.pool, &metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string();
|
metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 \
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
10133 20078 53002 PNO $"
|
||||||
|
.to_string();
|
||||||
|
let metar = Metar::parse(&state.pool, &metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 SLP090 P0001 60004 T00001017 10000 21011 53026".to_string();
|
metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 \
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
SLP090 P0001 60004 T00001017 10000 21011 53026"
|
||||||
|
.to_string();
|
||||||
|
let metar = Metar::parse(&state.pool, &metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
|
metar_string = "KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 \
|
||||||
|
SCTCB FEW123TCU 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 \
|
||||||
|
RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $"
|
||||||
|
.to_string();
|
||||||
|
let metar = Metar::parse(&state.pool, &metar_string).unwrap();
|
||||||
|
dbg!(&metar.observation_time);
|
||||||
|
dbg!(&metar.sky_condition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
113
crates/lib/src/metars/utils.rs
Normal file
113
crates/lib/src/metars/utils.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::error::{CoreError, CoreErrorKind, CoreResult};
|
||||||
|
use chrono::{Datelike, NaiveDate, Utc};
|
||||||
|
|
||||||
|
pub fn parse_metar_time(observation_time: &str) -> CoreResult<String> {
|
||||||
|
if observation_time.len() != 7 {
|
||||||
|
return Err(CoreError::new(
|
||||||
|
CoreErrorKind::InvalidInput,
|
||||||
|
format!("Unable to parse observation time in {}", observation_time),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let observation_day = match observation_time[0..2].parse::<u32>() {
|
||||||
|
Ok(day) => day,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let observation_hour = match observation_time[2..4].parse::<u32>() {
|
||||||
|
Ok(hour) => hour,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let observation_minute = match observation_time[4..6].parse::<u32>() {
|
||||||
|
Ok(minute) => minute,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let current_time = Utc::now().naive_utc();
|
||||||
|
let current_year = current_time.year();
|
||||||
|
let current_month = current_time.month();
|
||||||
|
let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CoreError::new(
|
||||||
|
CoreErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"Invalid date with day {} for current month",
|
||||||
|
observation_day
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) {
|
||||||
|
Some(date) => date,
|
||||||
|
None => {
|
||||||
|
return Err(CoreError::new(
|
||||||
|
CoreErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"Invalid time for time '{}': hour {}, minute {}",
|
||||||
|
observation_time, observation_hour, observation_minute
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let obs_datetime = if candidate_date > current_time {
|
||||||
|
// Subtract one month. (Handle year rollover carefully.)
|
||||||
|
let (month, year) = if current_month == 1 {
|
||||||
|
(12, current_year - 1)
|
||||||
|
} else {
|
||||||
|
(current_month - 1, current_year)
|
||||||
|
};
|
||||||
|
|
||||||
|
let adjusted_date = NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| {
|
||||||
|
CoreError::new(
|
||||||
|
CoreErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"Invalid date with day {} for month {}",
|
||||||
|
observation_day, month
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
adjusted_date
|
||||||
|
.and_hms_opt(observation_hour, observation_minute, 0)
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
candidate_date
|
||||||
|
};
|
||||||
|
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_metar_time() {
|
||||||
|
for day in 1..=31 {
|
||||||
|
for hour in 0..24 {
|
||||||
|
for minute in 0..60 {
|
||||||
|
// METAR form "DDHHMMZ"
|
||||||
|
let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute);
|
||||||
|
let result = parse_metar_time(&obs_time);
|
||||||
|
match result {
|
||||||
|
Ok(datetime_str) => {
|
||||||
|
// "YYYY-MM-DDTHH:MM:00Z"
|
||||||
|
assert_eq!(
|
||||||
|
datetime_str.len(),
|
||||||
|
20,
|
||||||
|
"Unexpected length for input {} yielded {}",
|
||||||
|
obs_time,
|
||||||
|
datetime_str
|
||||||
|
);
|
||||||
|
// Remove the trailing 'Z' and parse
|
||||||
|
let trimmed = &datetime_str[..19];
|
||||||
|
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"Parsing '{}' from input {} failed: {}",
|
||||||
|
trimmed, obs_time, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
crates/lib/src/state.rs
Normal file
167
crates/lib/src/state.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use redis::aio::ConnectionManager;
|
||||||
|
use redis::AsyncTypedCommands;
|
||||||
|
use s3::{Bucket, BucketConfiguration, Region};
|
||||||
|
use s3::creds::Credentials;
|
||||||
|
use sqlx::{Pool, Postgres};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use crate::error::CoreResult;
|
||||||
|
use crate::http_client::HttpClient;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub client: HttpClient,
|
||||||
|
pub pool: Pool<Postgres>,
|
||||||
|
pub connection_manager: Arc<Mutex<ConnectionManager>>,
|
||||||
|
pub bucket: Box<Bucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub async fn new() -> CoreResult<Self> {
|
||||||
|
let client = HttpClient::default()?;
|
||||||
|
|
||||||
|
let pool: Pool<Postgres> = {
|
||||||
|
let user = env::var("POSTGRES_USER").unwrap_or("raac".to_string());
|
||||||
|
let password = env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set");
|
||||||
|
let host: String = env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set");
|
||||||
|
let port = env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
|
||||||
|
let name = env::var("POSTGRES_DB").unwrap_or("raac".to_string());
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"postgres://{}:{}@{}:{}/{}",
|
||||||
|
&user, &password, &host, &port, &name
|
||||||
|
);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Connecting to database at postgres://{}:*****@{}:{}/{}...",
|
||||||
|
&user,
|
||||||
|
&host,
|
||||||
|
&port,
|
||||||
|
&name
|
||||||
|
);
|
||||||
|
|
||||||
|
let connections = env::var("POSTGRES_CONNECTIONS")
|
||||||
|
.unwrap_or("5".to_string())
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap_or(5);
|
||||||
|
let timeout = env::var("POSTGRES_TIMEOUT")
|
||||||
|
.unwrap_or("30".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(30);
|
||||||
|
|
||||||
|
PgPoolOptions::new()
|
||||||
|
.max_connections(connections)
|
||||||
|
.acquire_timeout(Duration::from_secs(timeout))
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create postgres pool")
|
||||||
|
};
|
||||||
|
|
||||||
|
let run_migrations = env::var("POSTGRES_MIGRATE")
|
||||||
|
.unwrap_or("true".to_string())
|
||||||
|
.parse::<bool>()
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if run_migrations {
|
||||||
|
log::debug!("Running database migrations...");
|
||||||
|
match sqlx::migrate!().run(&pool).await {
|
||||||
|
Ok(_) => log::debug!("Database migrations completed"),
|
||||||
|
Err(err) => log::error!("Failed to run database migrations: {}", err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection_manager: ConnectionManager = {
|
||||||
|
let host = env::var("VALKEY_HOST").unwrap_or("localhost".to_string());
|
||||||
|
let port = env::var("VALKEY_PORT").unwrap_or("6379".to_string());
|
||||||
|
let url = format!("redis://{}:{}", host, port);
|
||||||
|
log::info!("Connecting to in-memory datastore at {}...", &url);
|
||||||
|
let client = redis::Client::open(url).expect("Failed to create in-memory datastore client");
|
||||||
|
ConnectionManager::new(client)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create in-memory datastore connection manager")
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: url.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let credentials = Credentials {
|
||||||
|
access_key: Some(user),
|
||||||
|
secret_key: Some(password),
|
||||||
|
security_token: None,
|
||||||
|
session_token: None,
|
||||||
|
expiration: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style();
|
||||||
|
log::info!("Checking for object in bucket at {}", ®ion.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) => response,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to create bucket '{}': {}", &bucket_name, err);
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
response.bucket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
pool,
|
||||||
|
connection_manager: Arc::new(Mutex::new(connection_manager)),
|
||||||
|
bucket,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(&self, key: &str, value: &str) -> CoreResult<()> {
|
||||||
|
let mut connection_manager = self.connection_manager.lock()?;
|
||||||
|
connection_manager.set(key, value).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_ex(&self, key: &str, value: &str, seconds: u64) -> CoreResult<()> {
|
||||||
|
let mut connection_manager = self.connection_manager.lock()?;
|
||||||
|
connection_manager.set_ex(key, value, seconds).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, key: &str) -> CoreResult<Option<String>> {
|
||||||
|
let mut connection_manager = self.connection_manager.lock()?;
|
||||||
|
match connection_manager.get(key).await {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn del(&self, key: &str) -> CoreResult<()> {
|
||||||
|
let mut connection_manager = self.connection_manager.lock()?;
|
||||||
|
connection_manager.del(key).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/scheduler/Cargo.toml
Normal file
11
crates/scheduler/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "scheduler"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
lib = { path = "../lib" }
|
||||||
|
chrono = "0.4.42"
|
||||||
|
tokio = { version = "1.47.1", features = ["rt", "rt-multi-thread"] }
|
||||||
|
log = "0.4.28"
|
||||||
|
env_logger = "0.11.8"
|
||||||
24
crates/scheduler/Dockerfile
Normal file
24
crates/scheduler/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# =========
|
||||||
|
# Builder
|
||||||
|
# =========
|
||||||
|
FROM rust:bookworm AS builder
|
||||||
|
WORKDIR /builder
|
||||||
|
|
||||||
|
COPY crates/lib /lib
|
||||||
|
COPY crates/scheduler/src ./src
|
||||||
|
COPY crates/scheduler/Cargo.toml ./
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y cmake
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# =========
|
||||||
|
# Runtime
|
||||||
|
# =========
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
WORKDIR /scheduler
|
||||||
|
RUN apt-get update && apt-get install -y openssl libpq-dev ca-certificates
|
||||||
|
USER root
|
||||||
|
|
||||||
|
COPY --from=builder /builder/target/release/scheduler /usr/local/bin/scheduler
|
||||||
|
|
||||||
|
CMD ["scheduler"]
|
||||||
56
crates/scheduler/src/main.rs
Normal file
56
crates/scheduler/src/main.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::env;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use env_logger::Builder;
|
||||||
|
use log::LevelFilter;
|
||||||
|
use tokio::time::interval;
|
||||||
|
use lib::metars::Metar;
|
||||||
|
use lib::state::AppState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn main() {
|
||||||
|
Builder::new()
|
||||||
|
.filter_level(LevelFilter::Info) // Set a default log level
|
||||||
|
.filter_module("scheduler", LevelFilter::Trace)
|
||||||
|
.filter_module("lib", LevelFilter::Trace)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let state = match AppState::new().await {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to create state: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let seconds = env::var("METAR_INTERVAL")
|
||||||
|
.unwrap_or("300".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(300);
|
||||||
|
|
||||||
|
// Create an interval ticker
|
||||||
|
let mut interval = interval(Duration::from_secs(seconds));
|
||||||
|
let mut etag = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
// Record start times
|
||||||
|
let start_monotonic = Instant::now();
|
||||||
|
let start_utc: DateTime<Utc> = Utc::now();
|
||||||
|
log::debug!("METAR update started at {}", start_utc);
|
||||||
|
|
||||||
|
// Run the update
|
||||||
|
match Metar::update_metars(&state, etag.clone()).await {
|
||||||
|
Ok(new_etag) => etag = Some(new_etag),
|
||||||
|
Err(err) => log::error!("METAR update failed: {}", err),
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = start_monotonic.elapsed();
|
||||||
|
let next_utc = Utc::now() + chrono::Duration::from_std(Duration::from_secs(seconds)).unwrap();
|
||||||
|
log::info!(
|
||||||
|
"METAR update finished in {:.2?}; next run at {}",
|
||||||
|
elapsed,
|
||||||
|
next_utc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
container_name: aviation-nginx
|
container_name: aviation-nginx
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: nginx/Dockerfile
|
||||||
env_file: *env
|
env_file: *env
|
||||||
environment:
|
environment:
|
||||||
SSL_CERT_PATH: /etc/nginx/ssl/localhost.crt
|
SSL_CERT_PATH: /etc/nginx/ssl/localhost.crt
|
||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./ssl:/etc/nginx/ssl/
|
- ./ssl:/etc/nginx/ssl/
|
||||||
networks:
|
networks:
|
||||||
- default
|
- web
|
||||||
<<: *default_restart
|
<<: *default_restart
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
@@ -41,32 +41,28 @@ services:
|
|||||||
- postgres_logs:/var/log
|
- postgres_logs:/var/log
|
||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
profiles:
|
profiles:
|
||||||
- backend
|
- backend
|
||||||
<<: *default_restart
|
<<: *default_restart
|
||||||
|
|
||||||
redis:
|
valkey:
|
||||||
image: gitea.bensherriff.com/homelab/redis:8.0-M03
|
image: valkey/valkey:8.1.3
|
||||||
container_name: aviation-redis
|
container_name: aviation-valkey
|
||||||
volumes:
|
volumes:
|
||||||
- redis:/data
|
- valkey:/data
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "${VALKEY_PORT:-6379}:6379"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
test: [ "CMD", "valkey-cli", "--raw", "incr", "ping" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
profiles:
|
profiles:
|
||||||
- backend
|
- backend
|
||||||
<<: *default_restart
|
<<: *default_restart
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: gitea.bensherriff.com/homelab/minio:RELEASE.2025-02-28T09-55-16Z
|
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
|
||||||
container_name: aviation-minio
|
container_name: aviation-minio
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
@@ -78,8 +74,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${MINIO_PORT:-9000}:9000"
|
- "${MINIO_PORT:-9000}:9000"
|
||||||
- "${MINIO_INTERNAL_PORT:-9001}:9001"
|
- "${MINIO_INTERNAL_PORT:-9001}:9001"
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
profiles:
|
profiles:
|
||||||
- backend
|
- backend
|
||||||
command: server --console-address ":9001" /data
|
command: server --console-address ":9001" /data
|
||||||
@@ -90,15 +84,15 @@ services:
|
|||||||
container_name: aviation-api
|
container_name: aviation-api
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: crates/api/Dockerfile
|
||||||
env_file: *env
|
env_file: *env
|
||||||
environment:
|
environment:
|
||||||
SSL_CA_PATH: /ssl/ca.pem
|
SSL_CA_PATH: /ssl/ca.pem
|
||||||
API_PORT: 5000
|
API_PORT: 5000
|
||||||
POSTGRES_HOST: aviation-postgres
|
POSTGRES_HOST: aviation-postgres
|
||||||
POSTGRES_PORT: 5432
|
POSTGRES_PORT: 5432
|
||||||
REDIS_HOST: aviation-redis
|
VALKEY_HOST: aviation-valkey
|
||||||
REDIS_PORT: 6379
|
VALKEY_PORT: 6379
|
||||||
MINIO_HOST: aviation-minio
|
MINIO_HOST: aviation-minio
|
||||||
MINIO_PORT: 9000
|
MINIO_PORT: 9000
|
||||||
TEMPLATE_DIR: /templates
|
TEMPLATE_DIR: /templates
|
||||||
@@ -109,36 +103,28 @@ services:
|
|||||||
- "${API_PORT:-5000}:5000"
|
- "${API_PORT:-5000}:5000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- redis
|
- valkey
|
||||||
- minio
|
- minio
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
profiles:
|
profiles:
|
||||||
- api
|
- api
|
||||||
<<: *default_restart
|
<<: *default_restart
|
||||||
|
|
||||||
# Development Containers
|
scheduler:
|
||||||
# ui-dev:
|
image: gitea.bensherriff.com/bsherriff/aviation-scheduler:latest
|
||||||
# image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
|
container_name: aviation-scheduler
|
||||||
# container_name: aviation-ui-dev
|
build:
|
||||||
# build:
|
context: .
|
||||||
# context: .
|
dockerfile: crates/scheduler/Dockerfile
|
||||||
# dockerfile: Dockerfile
|
env_file: *env
|
||||||
# env_file: *env
|
environment:
|
||||||
# environment:
|
POSTGRES_HOST: aviation-postgres
|
||||||
# - VITE_NODE_ENV=${VITE_NODE_ENV:-development}
|
POSTGRES_PORT: 5432
|
||||||
# ports:
|
depends_on:
|
||||||
# - "${UI_PORT:-3000}:3000"
|
- postgres
|
||||||
# volumes:
|
profiles:
|
||||||
# - ./ui/src:/app/src
|
- api
|
||||||
# - ./ui/public:/app/public
|
<<: *default_restart
|
||||||
# - ./ui/styles:/app/styles
|
|
||||||
# networks:
|
|
||||||
# - default
|
|
||||||
# profiles:
|
|
||||||
# - dev
|
|
||||||
# command: ["npm", "run", "dev"]
|
|
||||||
# <<: *default_restart
|
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit
|
image: axllent/mailpit
|
||||||
container_name: mailpit
|
container_name: mailpit
|
||||||
@@ -152,8 +138,6 @@ services:
|
|||||||
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||||
volumes:
|
volumes:
|
||||||
- mailpit:/data
|
- mailpit:/data
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
<<: *default_restart
|
<<: *default_restart
|
||||||
@@ -161,9 +145,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
postgres_logs:
|
postgres_logs:
|
||||||
redis:
|
valkey:
|
||||||
minio:
|
minio:
|
||||||
mailpit:
|
mailpit:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
web:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
@@ -4,31 +4,33 @@ worker_processes auto;
|
|||||||
error_log /var/log/nginx/error.log notice;
|
error_log /var/log/nginx/error.log notice;
|
||||||
pid /var/run/nginx.pid;
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 1024;
|
worker_connections 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
http {
|
http {
|
||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
sendfile on;
|
# allow HTTP/2 on the front‐end
|
||||||
#tcp_nopush on;
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
keepalive_timeout 65;
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
|
||||||
#gzip on;
|
keepalive_timeout 65;
|
||||||
|
|
||||||
# Set client limit to 100 MB
|
gzip on;
|
||||||
client_max_body_size 100M;
|
|
||||||
|
|
||||||
include /etc/nginx/conf.d/*.conf;
|
# Set client limit to 100 MB
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "stable"
|
channel = "nightly"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
6
rustfmt.toml
Normal file
6
rustfmt.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
indent_style = "Block"
|
||||||
|
reorder_imports = true
|
||||||
|
imports_layout = "HorizontalVertical"
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
group_imports = "One"
|
||||||
|
tab_spaces = 2
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
force=0
|
force=0
|
||||||
push=0
|
push=0
|
||||||
|
push_all=0
|
||||||
|
|
||||||
API_VERSION=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "$(pwd)"/api/Cargo.toml)
|
API_VERSION=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "$(pwd)"/api/Cargo.toml)
|
||||||
UI_VERSION=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(pwd)"/ui/package.json)
|
UI_VERSION=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(pwd)"/ui/package.json)
|
||||||
@@ -17,6 +18,13 @@ for arg in "$@"; do
|
|||||||
push=1
|
push=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
-a|--push-all)
|
||||||
|
push_all=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -69,3 +77,14 @@ if echo "$changed_files" | grep -q "^api/"; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Push all tags
|
||||||
|
if [ $push_all -eq 1 ]; then
|
||||||
|
if [ $force -eq 1 ]; then
|
||||||
|
echo "Force-pushing ALL tags to remote"
|
||||||
|
git push -f origin --tags
|
||||||
|
else
|
||||||
|
echo "Pushing ALL tags to remote"
|
||||||
|
git push origin --tags
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* Set up Flexbox layout */
|
html, body, #root {
|
||||||
.App {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-wrapper {
|
.map-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
|||||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { Header } from '@components/Header';
|
|
||||||
import AirportLayer from '@components/AirportLayer.tsx';
|
import AirportLayer from '@components/AirportLayer.tsx';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Airport } from '@lib/airport.types.ts';
|
import { Airport } from '@lib/airport.types.ts';
|
||||||
@@ -16,7 +15,6 @@ import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
|
|||||||
import { GroupControl } from '@components/GroupControl.tsx';
|
import { GroupControl } from '@components/GroupControl.tsx';
|
||||||
import { AirportDrawer } from '@components/AirportDrawer';
|
import { AirportDrawer } from '@components/AirportDrawer';
|
||||||
import { LocateControl } from '@components/LocateControl.tsx';
|
import { LocateControl } from '@components/LocateControl.tsx';
|
||||||
import { Footer } from '@components/Footer';
|
|
||||||
// Fix Leaflet's default icon path issues with Webpack
|
// Fix Leaflet's default icon path issues with Webpack
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -91,57 +89,54 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='App'>
|
<div className='map-wrapper' style={{ flex: 1, minHeight: 0 }}>
|
||||||
<Header />
|
<MapContainer
|
||||||
<div className='map-wrapper'>
|
style={{ height: '100%', width: '100%' }}
|
||||||
<MapContainer
|
className='leaflet-container'
|
||||||
className='leaflet-container'
|
attributionControl={false}
|
||||||
attributionControl={false}
|
center={defaultCenter}
|
||||||
center={defaultCenter}
|
zoom={defaultZoom}
|
||||||
zoom={defaultZoom}
|
minZoom={3}
|
||||||
minZoom={3}
|
maxZoom={19}
|
||||||
maxZoom={19}
|
maxBounds={[
|
||||||
maxBounds={[
|
[-85.06, -181],
|
||||||
[-85.06, -181],
|
[85.06, 181]
|
||||||
[85.06, 181]
|
]}
|
||||||
|
scrollWheelZoom={true}
|
||||||
|
zoomControl={false}
|
||||||
|
markerZoomAnimation={false}
|
||||||
|
>
|
||||||
|
<AirportDrawer airport={airport} setAirport={setAirport} />
|
||||||
|
<LayersControl>
|
||||||
|
{layerMap.map((layer, index) => (
|
||||||
|
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
|
||||||
|
<TileLayer url={layer.url} />
|
||||||
|
</LayersControl.BaseLayer>
|
||||||
|
))}
|
||||||
|
</LayersControl>
|
||||||
|
<ScaleControl />
|
||||||
|
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
|
||||||
|
<ZoomControl position={'bottomright'} />
|
||||||
|
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
||||||
|
<BaseLayerChangeHandler />
|
||||||
|
<LocateControl />
|
||||||
|
<GroupControl
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
title: 'Toggle radar',
|
||||||
|
active: showRadar,
|
||||||
|
onClick: toggleRadar,
|
||||||
|
icon: <IconRadar />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Toggle non‐METAR airports',
|
||||||
|
active: showNoMetar,
|
||||||
|
onClick: toggleShowNoMetar,
|
||||||
|
icon: <IconBuildingAirport />
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
scrollWheelZoom={true}
|
/>
|
||||||
zoomControl={false}
|
</MapContainer>
|
||||||
markerZoomAnimation={false}
|
|
||||||
>
|
|
||||||
<AirportDrawer airport={airport} setAirport={setAirport} />
|
|
||||||
<LayersControl>
|
|
||||||
{layerMap.map((layer, index) => (
|
|
||||||
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
|
|
||||||
<TileLayer url={layer.url} />
|
|
||||||
</LayersControl.BaseLayer>
|
|
||||||
))}
|
|
||||||
</LayersControl>
|
|
||||||
<ScaleControl />
|
|
||||||
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
|
|
||||||
<ZoomControl position={'bottomright'} />
|
|
||||||
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
|
||||||
<BaseLayerChangeHandler />
|
|
||||||
<LocateControl />
|
|
||||||
<GroupControl
|
|
||||||
buttons={[
|
|
||||||
{
|
|
||||||
title: 'Toggle radar',
|
|
||||||
active: showRadar,
|
|
||||||
onClick: toggleRadar,
|
|
||||||
icon: <IconRadar />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Toggle non‐METAR airports',
|
|
||||||
active: showNoMetar,
|
|
||||||
onClick: toggleShowNoMetar,
|
|
||||||
icon: <IconBuildingAirport />
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</MapContainer>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Header } from '@components/Header';
|
|
||||||
import { useUserContext } from '@components/context/UserContext.tsx';
|
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||||
import { AirportTable } from '@components/AirportTable';
|
import { AirportTable } from '@components/AirportTable';
|
||||||
import { AirportDrop } from '@components/AirportDrop';
|
import { AirportDrop } from '@components/AirportDrop';
|
||||||
@@ -13,7 +12,6 @@ export function Administration() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
|
||||||
<AirportTable />
|
<AirportTable />
|
||||||
<AirportDrop />
|
<AirportDrop />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
Group,
|
Group,
|
||||||
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsList,
|
TabsList,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,12 +17,13 @@ import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
|||||||
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
|
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
|
||||||
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
|
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
|
||||||
import { useMediaQuery } from '@mantine/hooks';
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
import { IconViewfinder } from '@tabler/icons-react';
|
import { IconStar, IconStarFilled, IconViewfinder } from '@tabler/icons-react';
|
||||||
import { RunwayTable } from '@components/AirportDrawer/RunwayTable.tsx';
|
import { RunwayTable } from '@components/AirportDrawer/RunwayTable.tsx';
|
||||||
import { CommunicationTable } from '@components/AirportDrawer/CommunicationTable.tsx';
|
import { CommunicationTable } from '@components/AirportDrawer/CommunicationTable.tsx';
|
||||||
import { useMap } from 'react-leaflet';
|
import { useMap } from 'react-leaflet';
|
||||||
import type { Map as LeafletMap } from 'leaflet';
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
import { getMetars } from '@lib/metar.ts';
|
import { getMetars } from '@lib/metar.ts';
|
||||||
|
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||||
|
|
||||||
export function AirportDrawer({
|
export function AirportDrawer({
|
||||||
airport,
|
airport,
|
||||||
@@ -29,6 +32,9 @@ export function AirportDrawer({
|
|||||||
airport: Airport | null;
|
airport: Airport | null;
|
||||||
setAirport: (airport: Airport | null) => void;
|
setAirport: (airport: Airport | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { user, favorites, toggleFavorite } = useUserContext();
|
||||||
|
const isAdmin = user?.role === 'ADMIN';
|
||||||
|
const isFavorite = airport ? favorites.includes(airport.icao) : false;
|
||||||
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -65,7 +71,7 @@ export function AirportDrawer({
|
|||||||
onClose={() => setAirport(null)}
|
onClose={() => setAirport(null)}
|
||||||
withinPortal
|
withinPortal
|
||||||
zIndex={1000}
|
zIndex={1000}
|
||||||
styles={{ root: { padding: 0, margin: 0, width: 0, height: 0, backgroundColor: 'red' } }}
|
styles={{ root: { padding: 0, margin: 0, width: 0, height: 0 } }}
|
||||||
padding='md'
|
padding='md'
|
||||||
size={isMobile ? '100%' : 'md'}
|
size={isMobile ? '100%' : 'md'}
|
||||||
position='left'
|
position='left'
|
||||||
@@ -73,6 +79,15 @@ export function AirportDrawer({
|
|||||||
>
|
>
|
||||||
<Drawer.Content>
|
<Drawer.Content>
|
||||||
<Drawer.Header>
|
<Drawer.Header>
|
||||||
|
{user && (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => toggleFavorite(airport.icao)}
|
||||||
|
aria-label={isFavorite ? 'Unfavorite airport' : 'Favorite airport'}
|
||||||
|
style={{ padding: 4 }}
|
||||||
|
>
|
||||||
|
{isFavorite ? <IconStarFilled size={24} color='#faca15' /> : <IconStar size={24} />}
|
||||||
|
</UnstyledButton>
|
||||||
|
)}
|
||||||
<Drawer.Title>
|
<Drawer.Title>
|
||||||
<Text size={'xl'}>{airport.name}</Text>
|
<Text size={'xl'}>{airport.name}</Text>
|
||||||
</Drawer.Title>
|
</Drawer.Title>
|
||||||
@@ -104,6 +119,7 @@ export function AirportDrawer({
|
|||||||
<TabsList grow>
|
<TabsList grow>
|
||||||
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
|
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
|
||||||
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
||||||
|
{user && <Tabs.Tab value={'manage'}>Manage</Tabs.Tab>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Tabs.Panel value={'info'}>
|
<Tabs.Panel value={'info'}>
|
||||||
<AirportInfo map={map} airport={airport} />
|
<AirportInfo map={map} airport={airport} />
|
||||||
@@ -111,6 +127,23 @@ export function AirportDrawer({
|
|||||||
<Tabs.Panel value={'weather'}>
|
<Tabs.Panel value={'weather'}>
|
||||||
<WeatherInfo metar={airport.latest_metar} />
|
<WeatherInfo metar={airport.latest_metar} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
{user && (
|
||||||
|
<Tabs.Panel value={'manage'}>
|
||||||
|
{isAdmin ? (
|
||||||
|
<Stack mt='md'>
|
||||||
|
<Button onClick={() => {}}>Update METAR</Button>
|
||||||
|
<Button onClick={() => {}}>Edit Airport</Button>
|
||||||
|
<Button color='red' onClick={() => {}}>
|
||||||
|
Delete Airport
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack mt='md'>
|
||||||
|
<Button onClick={() => {}}>Request Edit</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer.Body>
|
</Drawer.Body>
|
||||||
@@ -205,7 +238,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) {
|
|||||||
|
|
||||||
function WeatherInfo({ metar }: { metar?: Metar }) {
|
function WeatherInfo({ metar }: { metar?: Metar }) {
|
||||||
if (!metar) {
|
if (!metar) {
|
||||||
return <>No METAR observation available/</>
|
return <>No METAR observation available/</>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -231,7 +264,7 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{metar.wind_dir_degrees && metar.wind_speed_kt != null && (
|
{metar.wind_dir_degrees && metar.wind_speed_kt != null && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Wind:</strong> {metar.wind_dir_degrees}° at {metar.wind_speed_kt} kt
|
<strong>Wind:</strong> {metar.wind_dir_degrees}° at {metar.wind_speed_kt} kt
|
||||||
{metar.wind_gust_kt && `, gusts ${metar.wind_gust_kt} kt`}
|
{metar.wind_gust_kt && `, gusts ${metar.wind_gust_kt} kt`}
|
||||||
{metar.variable_wind_dir_degrees && ` (variable ${metar.variable_wind_dir_degrees})`}
|
{metar.variable_wind_dir_degrees && ` (variable ${metar.variable_wind_dir_degrees})`}
|
||||||
@@ -239,20 +272,20 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.visibility_statute_mi && (
|
{metar.visibility_statute_mi && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Visibility:</strong> {metar.visibility_statute_mi} statute miles
|
<strong>Visibility:</strong> {metar.visibility_statute_mi} statute miles
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(metar.temp_c != null || metar.dew_point_c != null) && (
|
{(metar.temp_c != null || metar.dew_point_c != null) && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Temp / Dew Point:</strong> {metar.temp_c}°C / {metar.dew_point_c}°C
|
<strong>Temp / Dew Point:</strong> {metar.temp_c}°C / {metar.dew_point_c}°C
|
||||||
{metar.estimated_humidity != null && ` (${metar.estimated_humidity}% RH)`}
|
{metar.estimated_humidity != null && ` (${metar.estimated_humidity}% RH)`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(metar.altimeter_in_hg != null || metar.sea_level_pressure_mb != null) && (
|
{(metar.altimeter_in_hg != null || metar.sea_level_pressure_mb != null) && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Pressure:</strong>
|
<strong>Pressure:</strong>
|
||||||
{metar.altimeter_in_hg != null && ` Alt ${metar.altimeter_in_hg} inHg`}
|
{metar.altimeter_in_hg != null && ` Alt ${metar.altimeter_in_hg} inHg`}
|
||||||
{metar.sea_level_pressure_mb != null && `, SLP ${metar.sea_level_pressure_mb} mb`}
|
{metar.sea_level_pressure_mb != null && `, SLP ${metar.sea_level_pressure_mb} mb`}
|
||||||
@@ -260,13 +293,13 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.weather_phenomena.length > 0 && (
|
{metar.weather_phenomena.length > 0 && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Weather:</strong> {metar.weather_phenomena.join(', ')}
|
<strong>Weather:</strong> {metar.weather_phenomena.join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.sky_condition.length > 0 && (
|
{metar.sky_condition.length > 0 && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Sky:</strong>{' '}
|
<strong>Sky:</strong>{' '}
|
||||||
{metar.sky_condition
|
{metar.sky_condition
|
||||||
.map((s) => `${s.sky_cover}${s.cloud_base_ft_agl ? ` at ${s.cloud_base_ft_agl} ft` : ''}`)
|
.map((s) => `${s.sky_cover}${s.cloud_base_ft_agl ? ` at ${s.cloud_base_ft_agl} ft` : ''}`)
|
||||||
@@ -274,19 +307,19 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(metar.max_temp_c != null && metar.min_temp_c != null) && (
|
{metar.max_temp_c != null && metar.min_temp_c != null && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Max / Min:</strong> {metar.max_temp_c}°C / {metar.min_temp_c}°C
|
<strong>Max / Min:</strong> {metar.max_temp_c}°C / {metar.min_temp_c}°C
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.density_altutude != null && (
|
{metar.density_altutude != null && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Density Altitude:</strong> {metar.density_altutude} ft
|
<strong>Density Altitude:</strong> {metar.density_altutude} ft
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function airportCategoryToText(category: AirportCategory): string {
|
function airportCategoryToText(category: AirportCategory): string {
|
||||||
|
|||||||
62
ui/src/components/AirportSearch.tsx
Normal file
62
ui/src/components/AirportSearch.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { getAirports } from '@lib/airport.ts';
|
||||||
|
import { Autocomplete } from '@mantine/core';
|
||||||
|
|
||||||
|
export interface AirportSearchProps {
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AirportSearch({ limit = 5 }: AirportSearchProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debounced] = useDebouncedValue(search, 300);
|
||||||
|
const [data, setData] = useState<{ key: string; value: string; label: string }[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debounced) {
|
||||||
|
setData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetch(): Promise<{ key: string; value: string; label: string }[]> {
|
||||||
|
try {
|
||||||
|
const icaoResponse = await getAirports({
|
||||||
|
icaos: [debounced],
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
const nameResponse = await getAirports({
|
||||||
|
name: debounced,
|
||||||
|
limit: limit - 1
|
||||||
|
});
|
||||||
|
let combined = [...icaoResponse.data, ...nameResponse.data];
|
||||||
|
combined = combined.slice(0, limit);
|
||||||
|
return combined.map((airport) => ({
|
||||||
|
key: airport.icao,
|
||||||
|
value: airport.icao,
|
||||||
|
label: `${airport.icao} - ${airport.name}`
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('airport search failed', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch().then((d) => {
|
||||||
|
setData(d);
|
||||||
|
console.log(d);
|
||||||
|
});
|
||||||
|
}, [debounced, limit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
placeholder='Enter airport name or ICAO'
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
data={data}
|
||||||
|
limit={limit}
|
||||||
|
onOptionSubmit={() => {}}
|
||||||
|
radius={'xl'}
|
||||||
|
onBlur={() => setSearch('')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
.footer {
|
.footer {
|
||||||
background: #32495f;
|
height: 48px;
|
||||||
|
background: #2b2d31;
|
||||||
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner {
|
.inner {
|
||||||
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function Footer() {
|
|||||||
</a>
|
</a>
|
||||||
</Text>
|
</Text>
|
||||||
<Divider orientation={'vertical'} />
|
<Divider orientation={'vertical'} />
|
||||||
<Text size='sm'>© {new Date().getFullYear()} Aviation Data</Text>
|
<Text size='sm'>© {new Date().getFullYear()} bensherriff.com</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Group gap='xs' justify='flex-end' wrap='nowrap'>
|
<Group gap='xs' justify='flex-end' wrap='nowrap'>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 0 16px 0 16px;
|
padding: 0 16px 0 16px;
|
||||||
/*background-color: var(--mantine-color-body);*/
|
/*background-color: var(--mantine-color-body);*/
|
||||||
background: #32495f;
|
background: #2b2d31;
|
||||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Autocomplete, Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
|
import { Avatar, Box, Burger, Button, Drawer, Group, Text } from '@mantine/core';
|
||||||
import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks';
|
import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks';
|
||||||
import classes from './Header.module.css';
|
import classes from './Header.module.css';
|
||||||
import { HeaderModal } from '@components/Header/HeaderModal.tsx';
|
import { HeaderModal } from '@components/Header/HeaderModal.tsx';
|
||||||
@@ -6,7 +6,8 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { login, logout, register } from '@lib/account.ts';
|
import { login, logout, register } from '@lib/account.ts';
|
||||||
import HeaderUser from '@components/Header/HeaderUser.tsx';
|
import HeaderUser from '@components/Header/HeaderUser.tsx';
|
||||||
import { useUserContext } from '@components/context/UserContext.tsx';
|
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||||
import { Link } from 'react-router';
|
import { Link, matchPath, useLocation, useNavigate } from 'react-router';
|
||||||
|
import { AirportSearch } from '@components/AirportSearch.tsx';
|
||||||
|
|
||||||
// const links = [
|
// const links = [
|
||||||
// { link: '/', label: 'Map' },
|
// { link: '/', label: 'Map' },
|
||||||
@@ -14,11 +15,15 @@ import { Link } from 'react-router';
|
|||||||
// { link: '/metars', label: 'Metars' }
|
// { link: '/metars', label: 'Metars' }
|
||||||
// ];
|
// ];
|
||||||
|
|
||||||
|
const protectedPages = ['/administration', '/profile'];
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { user, setUser } = useUserContext();
|
const { user, setUser } = useUserContext();
|
||||||
const [opened, { toggle }] = useDisclosure(false);
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
// const [active, setActive] = useState(links[0].link);
|
// const [active, setActive] = useState(links[0].link);
|
||||||
|
|
||||||
// const navItems = links.map((link) => (
|
// const navItems = links.map((link) => (
|
||||||
@@ -63,7 +68,12 @@ export function Header() {
|
|||||||
async function logoutUser(): Promise<void> {
|
async function logoutUser(): Promise<void> {
|
||||||
await logout();
|
await logout();
|
||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
window.location.reload();
|
|
||||||
|
// See if the current page is a protected page
|
||||||
|
const isProtected = protectedPages.some((pattern) => matchPath(pattern, pathname));
|
||||||
|
if (isProtected) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerUser({
|
async function registerUser({
|
||||||
@@ -136,7 +146,7 @@ export function Header() {
|
|||||||
<Group justify='space-between' h='100%'>
|
<Group justify='space-between' h='100%'>
|
||||||
<Group align='center' gap='xs'>
|
<Group align='center' gap='xs'>
|
||||||
<Link to='/'>
|
<Link to='/'>
|
||||||
<Avatar src='/logo.svg' alt='logo' onClick={toggle} />
|
<Avatar src='/logo.svg' alt='logo' />
|
||||||
</Link>
|
</Link>
|
||||||
<Text size={'xl'}>Aviation Data</Text>
|
<Text size={'xl'}>Aviation Data</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -145,7 +155,8 @@ export function Header() {
|
|||||||
{/*</Group>*/}
|
{/*</Group>*/}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Group align='center' gap='xs'>
|
<Group align='center' gap='xs'>
|
||||||
<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />
|
<AirportSearch />
|
||||||
|
{/*<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />*/}
|
||||||
{user ? (
|
{user ? (
|
||||||
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
|
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
|
||||||
) : (
|
) : (
|
||||||
@@ -162,6 +173,21 @@ export function Header() {
|
|||||||
</Group>
|
</Group>
|
||||||
</header>
|
</header>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Drawer.Root
|
||||||
|
opened={opened}
|
||||||
|
onClose={toggle}
|
||||||
|
zIndex={1001}
|
||||||
|
padding="md"
|
||||||
|
size="40%"
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<Drawer.Overlay />
|
||||||
|
<Drawer.Content>
|
||||||
|
<Drawer.Body>
|
||||||
|
test
|
||||||
|
</Drawer.Body>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Root>
|
||||||
<HeaderModal type={modalType} toggle={modalToggle} login={loginUser} register={registerUser} />
|
<HeaderModal type={modalType} toggle={modalToggle} login={loginUser} register={registerUser} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
29
ui/src/components/MainLayout.tsx
Normal file
29
ui/src/components/MainLayout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { AppShell } from '@mantine/core';
|
||||||
|
import { Outlet } from 'react-router';
|
||||||
|
import { Header } from '@components/Header';
|
||||||
|
import { Footer } from '@components/Footer';
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
return (
|
||||||
|
<AppShell padding={0} mih={'100dvh'}>
|
||||||
|
<AppShell.Header>
|
||||||
|
<Header />
|
||||||
|
</AppShell.Header>
|
||||||
|
<AppShell.Main p={0} style={{ display: 'flex', minHeight: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</AppShell.Main>
|
||||||
|
<AppShell.Footer>
|
||||||
|
<Footer />
|
||||||
|
</AppShell.Footer>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Header } from '@components/Header';
|
|
||||||
import { useUserContext } from '@components/context/UserContext.tsx';
|
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||||
import { NotFound } from '@components/NotFound';
|
import { NotFound } from '@components/NotFound';
|
||||||
|
|
||||||
@@ -11,7 +10,6 @@ export function Profile() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
|
||||||
Todo: profile {user?.firstName}
|
Todo: profile {user?.firstName}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ interface UserContextType {
|
|||||||
user?: User;
|
user?: User;
|
||||||
setUser: (user: User | undefined) => void;
|
setUser: (user: User | undefined) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
favorites: string[];
|
||||||
|
toggleFavorite: (icao: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserContext = createContext<UserContextType>({
|
export const UserContext = createContext<UserContextType>({
|
||||||
user: undefined,
|
user: undefined,
|
||||||
setUser: () => {},
|
setUser: () => {},
|
||||||
loading: true
|
loading: true,
|
||||||
|
favorites: [],
|
||||||
|
toggleFavorite: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useUserContext(): UserContextType {
|
export function useUserContext(): UserContextType {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
import { UserContext } from './UserContext.tsx';
|
import { UserContext } from './UserContext.tsx';
|
||||||
import { profile } from '@lib/account.ts';
|
import { addFavorite, getFavorites, profile, removeFavorite } from '@lib/account.ts';
|
||||||
import { User } from '@lib/account.types.ts';
|
import { User } from '@lib/account.types.ts';
|
||||||
import { Center, Loader } from '@mantine/core';
|
import { Center, Loader } from '@mantine/core';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
@@ -9,8 +9,23 @@ const sessionExpirationName = 'session_expiration';
|
|||||||
|
|
||||||
export function UserProvider({ children }: { children: ReactNode }) {
|
export function UserProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | undefined>(undefined);
|
const [user, setUser] = useState<User | undefined>(undefined);
|
||||||
|
const [favorites, setFavorites] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function toggleFavorite(icao: string) {
|
||||||
|
setFavorites((prev) => {
|
||||||
|
const isFav = prev.includes(icao);
|
||||||
|
const next = isFav ? prev.filter((i) => i !== icao) : [...prev, icao];
|
||||||
|
|
||||||
|
(isFav ? removeFavorite(icao) : addFavorite(icao)).catch((err) => {
|
||||||
|
console.error('Sync failed, rolling back', err);
|
||||||
|
setFavorites(prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sessionExpiration = Cookies.get(sessionExpirationName);
|
const sessionExpiration = Cookies.get(sessionExpirationName);
|
||||||
|
|
||||||
@@ -35,8 +50,16 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user != undefined) {
|
||||||
|
getFavorites().then((f) => {
|
||||||
|
setFavorites(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ user, setUser, loading }}>
|
<UserContext.Provider value={{ user, setUser, loading, favorites, toggleFavorite }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Center style={{ height: '100vh' }}>
|
<Center style={{ height: '100vh' }}>
|
||||||
<Loader size='xl' />
|
<Loader size='xl' />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getRequest, postRequest } from '.';
|
import { deleteRequest, getRequest, postRequest } from '.';
|
||||||
import { RegisterUser, User } from './account.types';
|
import { RegisterUser, User } from './account.types';
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<User | undefined> {
|
export async function login(username: string, password: string): Promise<User | undefined> {
|
||||||
@@ -31,3 +31,20 @@ export async function profile(): Promise<User | undefined> {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFavorites(): Promise<string[]> {
|
||||||
|
const response = await getRequest('account/profile/favorites');
|
||||||
|
if (response?.status === 200) {
|
||||||
|
return response.json();
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFavorite(icao: string): Promise<Response> {
|
||||||
|
return await postRequest(`account/profile/favorites/${icao}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFavorite(icao: string): Promise<Response> {
|
||||||
|
return await deleteRequest(`account/profile/favorites/${icao}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export async function getAirports({
|
|||||||
icaos: icaos ?? undefined,
|
icaos: icaos ?? undefined,
|
||||||
name: name ?? undefined,
|
name: name ?? undefined,
|
||||||
metars: metars ?? undefined,
|
metars: metars ?? undefined,
|
||||||
limit,
|
limit: limit,
|
||||||
page
|
page: page
|
||||||
});
|
});
|
||||||
return response?.json() || { data: [] };
|
return response?.json() || { data: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { API_URL } from '@lib/constants.ts';
|
import { API_URL } from '@lib/constants.ts';
|
||||||
|
|
||||||
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
export async function getRequest(endpoint: string, params: any = {}): Promise<Response> {
|
||||||
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
||||||
const urlParams = new URLSearchParams(params);
|
const urlParams = new URLSearchParams(params);
|
||||||
const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
|
const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
|
||||||
@@ -11,7 +11,7 @@ export async function getRequest(endpoint: string, params: Record<string, any> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PostOptions {
|
interface PostOptions {
|
||||||
headers?: Record<string, any>;
|
headers?: Record<string, string>;
|
||||||
type?: 'json' | 'form';
|
type?: 'json' | 'form';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +61,8 @@ export async function putRequest(endpoint: string, body?: any, options?: PostOpt
|
|||||||
|
|
||||||
export async function deleteRequest(endpoint: string): Promise<Response> {
|
export async function deleteRequest(endpoint: string): Promise<Response> {
|
||||||
const url = `${API_URL}/${endpoint}`;
|
const url = `${API_URL}/${endpoint}`;
|
||||||
const response = await fetch(url, {
|
return await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
@@ -9,6 +10,7 @@ import { BrowserRouter, Route, Routes } from 'react-router';
|
|||||||
import { Profile } from '@components/Profile.tsx';
|
import { Profile } from '@components/Profile.tsx';
|
||||||
import { Administration } from '@components/Administration.tsx';
|
import { Administration } from '@components/Administration.tsx';
|
||||||
import { NotFound } from '@components/NotFound';
|
import { NotFound } from '@components/NotFound';
|
||||||
|
import { MainLayout } from '@components/MainLayout.tsx';
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
fontFamily: 'Inter, sans-serif'
|
fontFamily: 'Inter, sans-serif'
|
||||||
@@ -21,9 +23,11 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Notifications zIndex={2000} />
|
<Notifications zIndex={2000} />
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path='/' element={<App />} />
|
<Route path="/" element={<MainLayout />}>
|
||||||
<Route path='/profile' element={<Profile />} />
|
<Route index element={<App />} />
|
||||||
<Route path='/administration' element={<Administration />} />
|
<Route path='profile' element={<Profile />} />
|
||||||
|
<Route path='administration' element={<Administration />} />
|
||||||
|
</Route>
|
||||||
<Route path='*' element={<NotFound />} />
|
<Route path='*' element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user