Metar overhaul, added footer, updated schemas

This commit is contained in:
2025-05-19 16:09:02 -04:00
parent 2ecb82ae63
commit ed98140d22
54 changed files with 1084 additions and 4924 deletions

14
.env
View File

@@ -38,7 +38,13 @@ SSL_CERT_KEY_PATH=../ssl/localhost.key
SMTP_USERNAME=smtp-user SMTP_USERNAME=smtp-user
SMTP_PASSWORD=smtp-password SMTP_PASSWORD=smtp-password
SMTP_FROM=noreply@example.com SMTP_FROM=noreply@example.com
SMTP_SERVER=smtp.example.com SMTP_SERVER=localhost
SMTP_PORT=1025
#SMTP_USERNAME=smtp-user
#SMTP_PASSWORD=smtp-password
#SMTP_FROM=noreply@example.com
#SMTP_SERVER=smtp.example.com
#SMTP_PORT=587
VITE_API_URL=${EXTERNAL_URL}/api VITE_API_URL=${EXTERNAL_URL}/api
VITE_DEFAULT_LIMIT=200 VITE_DEFAULT_LIMIT=200
@@ -46,9 +52,13 @@ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST}
ENVIRONMENT=development ENVIRONMENT=development
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme ADMIN_PASSWORD=changeme
TEMPLATE_DIR=../templates TEMPLATE_DIR=../templates
AVIATION_WEATHER_URL=https://aviationweather.gov/api/data MAILPIT_WEB_PORT=8025
MAILPIT_SMTP_PORT=1025
AVIATION_WEATHER_URL=https://aviationweather.gov

View File

@@ -76,24 +76,24 @@ down-backend: backend-down
run: ## Run the api run: ## Run the api
@cd api && cargo run @cd api && cargo run
frontend-up: ## Start Docker containers dev-up: ## Start Docker containers
@docker compose --profile frontend up -d @docker compose --profile dev up -d
up-frontend: frontend-up up-dev: dev-up
frontend-down: ## Stop Docker containers dev-down: ## Stop Docker containers
@docker compose --profile frontend down @docker compose --profile dev down
down-frontend: frontend-down down-dev: dev-down
docker-prune: ## Prune the docker system docker-prune: ## Prune the docker system
@docker system prune -a @docker system prune -a
docker-clean: ## Stop the docker containers and remove volumes docker-clean: ## Stop the docker containers and remove volumes
@docker compose --profile frontend --profile api --profile backend down -v @docker compose --profile dev --profile api --profile backend down -v
docker-down: ## Stop the docker container docker-down: ## Stop the docker container
@docker compose --profile frontend --profile api --profile backend down @docker compose --profile dev --profile api --profile backend down
docker-up: ## Start the docker container docker-up: ## Start the docker container
@docker compose --profile backend --profile api up -d @docker compose --profile backend --profile api up -d
@@ -122,6 +122,10 @@ 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 (`make push f=httpd`)
docker buildx create \
--use \
--name aviation-builder \
--platform ${platform} || true; \
docker buildx build \ docker buildx build \
-f ${folder}/Dockerfile \ -f ${folder}/Dockerfile \
--platform ${platform} \ --platform ${platform} \

4361
api/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,9 +30,15 @@ rust-s3 = "0.35.1"
rand = "0.9.1" rand = "0.9.1"
rand_chacha = "0.9.0" rand_chacha = "0.9.0"
futures = "0.3.31" futures = "0.3.31"
utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] } #utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] }
utoipa-swagger-ui = { version = "9.0.1", features = ["actix-web"] } #utoipa-swagger-ui = { version = "9.0.1", features = ["actix-web"] }
utoipa-actix-web = "0.1.2" #utoipa-actix-web = "0.1.2"
# Temporary fix until crate is updated to fix zip yank
utoipa = { git = "https://github.com/juhaku/utoipa.git", rev = "cecda0531bf7d90800af66b186055932ee730526", features = ["chrono", "uuid", "actix_extras"] }
utoipa-swagger-ui = { git = "https://github.com/juhaku/utoipa.git", rev = "cecda0531bf7d90800af66b186055932ee730526", features = ["actix-web"] }
utoipa-actix-web = { git = "https://github.com/juhaku/utoipa.git", rev = "cecda0531bf7d90800af66b186055932ee730526" }
webpki-roots = "1.0.0" webpki-roots = "1.0.0"
lettre = { version = "0.11.16", features = ["builder", "smtp-transport", "tokio1-native-tls"] } lettre = { version = "0.11.16", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
handlebars = "6.3.2" handlebars = "6.3.2"
governor = "0.10.0"
flate2 = "1.1.1"

View File

@@ -65,8 +65,8 @@ CREATE TABLE IF NOT EXISTS metars (
CREATE INDEX ON metars (observation_time DESC); CREATE INDEX ON metars (observation_time DESC);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID UNIQUE NOT NULL, username TEXT PRIMARY KEY NOT NULL,
email TEXT NOT NULL, email TEXT,
email_verified BOOLEAN NOT NULL DEFAULT false, email_verified BOOLEAN NOT NULL DEFAULT false,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
@@ -74,6 +74,5 @@ CREATE TABLE IF NOT EXISTS users (
last_name TEXT NOT NULL, last_name TEXT NOT NULL,
avatar TEXT, avatar TEXT,
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()
PRIMARY KEY(email)
); );

View File

@@ -34,13 +34,13 @@ impl FromRequest for Auth {
return Err(Error::new(401, "API Key does not exist".to_string()).into()); return Err(Error::new(401, "API Key does not exist".to_string()).into());
} }
}; };
match User::select(&api_key.user_id).await { match User::select(&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),
user, user,
}), }),
None => Err(Error::new(404, format!("User {} not found", api_key.user_id)).into()), None => Err(Error::new(404, format!("User {} not found", api_key.username)).into()),
} }
}; };
return Box::pin(fut); return Box::pin(fut);
@@ -79,13 +79,13 @@ impl FromRequest for Auth {
// Verify the session // Verify the session
let fut = async move { let fut = async move {
match Session::verify(&session_id, &ip_address).await { match Session::verify(&session_id, &ip_address).await {
Ok(session) => match User::select(&session.user_id).await { Ok(session) => match User::select(&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,
user, user,
}), }),
None => Err(Error::new(404, format!("User {} not found", session.user_id)).into()), None => Err(Error::new(404, format!("User {} not found", session.username)).into()),
}, },
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
} }

View File

@@ -1,4 +1,4 @@
use crate::account::hash; use crate::account::{csprng, hash};
use crate::db::redis_async_connection; use crate::db::redis_async_connection;
use crate::error::{ApiResult, Error}; use crate::error::{ApiResult, Error};
use crate::smtp; use crate::smtp;
@@ -66,7 +66,7 @@ pub struct SimpleEmailCtx {
pub year: i32, pub year: i32,
} }
pub fn send_password_reset_email( pub async fn send_password_reset_email(
email: &str, email: &str,
email_token: &EmailToken, email_token: &EmailToken,
ip_address: &str, ip_address: &str,
@@ -99,7 +99,7 @@ pub fn send_password_reset_email(
.render_template(&template_html, &ctx) .render_template(&template_html, &ctx)
.unwrap(); .unwrap();
match smtp::send_email(&email, subject, plain, html) { match smtp::send_email(&email, subject, plain, html).await {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(err) => { Err(err) => {
log::error!( log::error!(
@@ -113,11 +113,11 @@ pub fn send_password_reset_email(
} }
} }
pub fn send_confirm_email( pub async fn send_confirm_email(email: &str, ip_address: &str) -> ApiResult<()> {
email: &str, let token = csprng(128);
email_token: &EmailToken, let email_token = EmailToken::new(email.to_string(), token, &ip_address);
ip_address: &str, email_token.store(86400).await?;
) -> ApiResult<()> {
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);
let subject = "Confirm your email address"; let subject = "Confirm your email address";
@@ -146,16 +146,16 @@ pub fn send_confirm_email(
.render_template(&template_html, &ctx) .render_template(&template_html, &ctx)
.unwrap(); .unwrap();
match smtp::send_email(&email, subject, plain, html) { if let Err(err) = smtp::send_email(&email, subject, plain, html).await {
Ok(_) => Ok(()), log::error!(
Err(err) => { "Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
log::error!( email,
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}", ip_address,
email, err
ip_address, );
err let _ = EmailToken::delete(&email_token.token);
); return Err(err);
Err(err.into())
}
} }
Ok(())
} }

View File

@@ -10,6 +10,7 @@ mod auth;
mod email_token; mod email_token;
mod routes; mod routes;
mod session; mod session;
mod model;
pub use auth::*; pub use auth::*;
pub use routes::init_routes; pub use routes::init_routes;

24
api/src/account/model.rs Normal file
View File

@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasswordRequirements {
pub max_length: Option<usize>,
pub min_length: Option<usize>,
pub lowercase_count: Option<usize>,
pub uppercase_count: Option<usize>,
pub numeric_count: Option<usize>,
pub special_count: Option<usize>,
}
impl Default for PasswordRequirements {
fn default() -> Self {
Self {
max_length: Some(128),
min_length: Some(6),
lowercase_count: None,
uppercase_count: None,
numeric_count: None,
special_count: None,
}
}
}

View File

@@ -5,7 +5,6 @@ use crate::{
}; };
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web}; use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
use serde::Deserialize; use serde::Deserialize;
use std::fs;
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;
@@ -15,7 +14,7 @@ use crate::account::{Auth, csprng};
use crate::users::UpdateUser; use crate::users::UpdateUser;
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
request_body( request_body(
content = RegisterRequest, content_type = "application/json" content = RegisterRequest, content_type = "application/json"
), ),
@@ -27,6 +26,7 @@ use crate::users::UpdateUser;
#[post("/register")] #[post("/register")]
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse { async fn register(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 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() {
@@ -38,20 +38,19 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
Ok(user) => { Ok(user) => {
let user_response: UserResponse = user.into(); let user_response: UserResponse = user.into();
log::info!( log::info!(
"Successful user registration [Email: {}] [IP Address: {}]", "Successful user registration [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
// Send confirmation email // Send confirmation email
let token = csprng(128); if let Some(email) = email {
let email_token = EmailToken::new(email.clone(), token, &ip_address); tokio::spawn(async move {
if let Err(err) = email_token.store(86400).await { if let Err(err) = send_confirm_email(&email, &ip_address).await {
return ResponseError::error_response(&err); log::error!("Failed to send confirmation email: {}", err);
};
});
} }
if let Err(err) = send_confirm_email(&email, &email_token, &ip_address) {
return ResponseError::error_response(&Error::new(500, err.to_string()));
};
HttpResponse::Created().json(user_response) HttpResponse::Created().json(user_response)
} }
@@ -59,21 +58,133 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
// Obfuscate the service error message to prevent leaking database details // Obfuscate the service error message to prevent leaking database details
if err.status == 409 { if err.status == 409 {
log::warn!( log::warn!(
"Duplicate user registration attempt [Email: {}] [IP Address: {}]", "Duplicate user registration attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
HttpResponse::Conflict().finish() HttpResponse::Conflict().finish()
} else { } else {
log::error!("Failed to register user [Email: {}]: {}", email, err); log::error!("Failed to register user [User: {}]: {}", username, err);
ResponseError::error_response(&err) ResponseError::error_response(&err)
} }
} }
} }
} }
#[derive(Debug, Deserialize, ToSchema)]
struct ConfirmEmail {
token: String,
}
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
request_body(
content = ConfirmEmail, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 404, description = "Not Found"),
(status = 409, description = "Conflict"),
),
)]
#[post("/register/confirm")]
async fn confirm_email_registration(
request: web::Json<ConfirmEmail>,
req: HttpRequest,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let token = &request.token;
let email_token = match EmailToken::get(token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(_) => {
return HttpResponse::NotFound().finish();
}
};
match User::select_by_email(&email_token.email).await {
Some(user) => {
let update_user = UpdateUser {
email: None,
email_verified: Some(true),
password: None,
role: None,
first_name: None,
last_name: None,
avatar: None,
};
match update_user.update(&user.username).await {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful email confirmation attempt [Email: {}] [IP Address: {}]",
&email_token.email,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
&email_token.email,
ip_address,
err
);
ResponseError::error_response(&err)
}
}
}
None => HttpResponse::NotFound().finish(),
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 404, description = "Not Found"),
),
security(
("session_auth" = [])
)
)]
#[post("/register/email")]
async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
match email {
Some(email) => {
let user = match User::select_by_email(&email).await {
Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(),
};
// Cannot reverify if user is already verified
if user.email_verified {
return HttpResponse::Conflict().finish();
}
// Send reverify confirmation email
tokio::spawn(async move {
if let Err(err) = send_confirm_email(&email, &ip_address).await {
log::error!("Failed to send reverify confirmation email: {}", err);
};
});
HttpResponse::Ok().finish()
}
None => HttpResponse::NotFound().finish(),
}
}
#[utoipa::path(
tag = "account",
request_body( request_body(
content = LoginRequest, content_type = "application/json" content = LoginRequest, content_type = "application/json"
), ),
@@ -83,32 +194,32 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
)] )]
#[post("/login")] #[post("/login")]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse { async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = &request.email; 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_by_email(&email).await { let query_user = match User::select(&username).await {
Some(query_user) => query_user, Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(), None => return HttpResponse::Unauthorized().finish(),
}; };
if verify_hash(&request.password, &query_user.password_hash) { if verify_hash(&request.password, &query_user.password_hash) {
// Create a session // Create a session
let session = Session::default(&query_user.id, &ip_address); let session = Session::default(&query_user.username, &ip_address);
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().await {
log::error!( log::error!(
"Login attempt failure [Email: {}] [IP Address: {}]: {}", "Login attempt failure [User: {}] [IP Address: {}]: {}",
email, username,
ip_address, ip_address,
err err
); );
return ResponseError::error_response(&Error::new(500, err.to_string())); return ResponseError::error_response(&Error::new(500, err.to_string()));
} }
log::info!( log::info!(
"Successful login attempt [Email: {}] [IP Address: {}]", "Successful login attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
let user_response: UserResponse = query_user.into(); let user_response: UserResponse = query_user.into();
@@ -118,8 +229,8 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
.json(user_response) .json(user_response)
} else { } else {
log::error!( log::error!(
"Invalid login attempt [Email: {}] [IP Address: {}]", "Invalid login attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
HttpResponse::Unauthorized() HttpResponse::Unauthorized()
@@ -130,7 +241,7 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
} }
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
responses( responses(
(status = 200, description = "Successful Response"), (status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -141,7 +252,7 @@ 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(req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email; 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) {
@@ -149,8 +260,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
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(&session_id).await {
log::error!( log::error!(
"Logout attempt failure [Email: {}] [IP Address: {}]: {}", "Logout attempt failure [User: {}] [IP Address: {}]: {}",
email, username,
ip_address, ip_address,
err err
); );
@@ -159,8 +270,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
} }
None => { None => {
log::error!( log::error!(
"Invalid logout attempt [Email: {}] [IP Address: {}]", "Invalid logout attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string())); return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
@@ -168,8 +279,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
} }
log::info!( log::info!(
"Successful logout attempt [Email: {}] [IP Address: {}]", "Successful logout attempt [User: {}] [IP Address: {}]",
email, username,
ip_address ip_address
); );
HttpResponse::Ok() HttpResponse::Ok()
@@ -179,7 +290,7 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
} }
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
responses( responses(
(status = 200, description = "Successful Response", body = UserResponse), (status = 200, description = "Successful Response", body = UserResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -210,8 +321,8 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
.finish(); .finish();
} }
}; };
let id = &session.user_id; let username = &session.username;
let query_user = match User::select(&id).await { let query_user = match User::select(&username).await {
Some(query_user) => query_user, Some(query_user) => query_user,
None => { None => {
return HttpResponse::Unauthorized() return HttpResponse::Unauthorized()
@@ -226,8 +337,8 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
let session_exp_cookie = session.expiration_cookie(); let session_exp_cookie = session.expiration_cookie();
log::info!( log::info!(
"Successful profile attempt [ID: {}] [IP Address: {}]", "Successful profile attempt [User: {}] [IP Address: {}]",
id, username,
ip_address ip_address
); );
HttpResponse::Ok() HttpResponse::Ok()
@@ -242,77 +353,8 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
} }
} }
#[derive(Debug, Deserialize, ToSchema)]
struct TokenRequest {
token: String,
}
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
request_body(
content = TokenRequest, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 404, description = "Not Found"),
),
)]
#[post("/profile/confirm")]
async fn confirm_profile(request: web::Json<TokenRequest>, req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let token = &request.token;
let email_token = match EmailToken::get(token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(_) => {
return HttpResponse::NotFound().finish();
}
};
match User::select_by_email(&email_token.email).await {
Some(user) => {
let update_user = UpdateUser {
email: None,
email_verified: Some(true),
password: None,
role: None,
first_name: None,
last_name: None,
avatar: None,
};
match update_user.update(&user.id).await {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful email confirmation attempt [Email: {}] [IP Address: {}]",
&email_token.email,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
&email_token.email,
ip_address,
err
);
ResponseError::error_response(&err)
}
}
}
None => HttpResponse::NotFound().finish(),
}
}
#[utoipa::path(
tag = "Account",
responses( responses(
(status = 200, description = "Successful Response"), (status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -343,13 +385,13 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
.finish(); .finish();
} }
}; };
let id = &session.user_id; let username = &session.username;
let session_cookie = session.cookie(); let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie(); let session_exp_cookie = session.expiration_cookie();
log::info!( log::info!(
"Successful session validate attempt [ID: {}] [IP Address: {}]", "Successful session validate attempt [User: {}] [IP Address: {}]",
id, username,
ip_address ip_address
); );
HttpResponse::Ok() HttpResponse::Ok()
@@ -365,14 +407,14 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
struct PasswordRequest { struct ChangePassword {
password: String, password: String,
} }
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
request_body( request_body(
content = PasswordRequest, content_type = "application/json" content = ChangePassword, content_type = "application/json"
), ),
responses( responses(
(status = 200, description = "Successful Response", body = UserResponse), (status = 200, description = "Successful Response", body = UserResponse),
@@ -384,14 +426,14 @@ struct PasswordRequest {
)] )]
#[put("/password")] #[put("/password")]
async fn change_password( async fn change_password(
request: web::Json<PasswordRequest>, request: web::Json<ChangePassword>,
req: HttpRequest, req: HttpRequest,
auth: Auth, auth: Auth,
) -> HttpResponse { ) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string(); let ip_address = req.peer_addr().unwrap().ip().to_string();
let id = auth.user.id; let username = auth.user.username;
if let None = User::select(&id).await { if let None = User::select(&username).await {
return HttpResponse::Unauthorized().finish(); return HttpResponse::Unauthorized().finish();
}; };
@@ -405,20 +447,20 @@ async fn change_password(
avatar: None, avatar: None,
}; };
match update_user.update(&id).await { match update_user.update(&username).await {
Ok(user) => { Ok(user) => {
let response: UserResponse = user.into(); let response: UserResponse = user.into();
log::info!( log::info!(
"Successful password change attempt [ID: {}] [IP Address: {}]", "Successful password change attempt [User: {}] [IP Address: {}]",
&id, &username,
ip_address ip_address
); );
HttpResponse::Ok().json(response) HttpResponse::Ok().json(response)
} }
Err(err) => { Err(err) => {
log::error!( log::error!(
"Invalid password change attempt [ID: {}] [IP Address: {}]: {}", "Invalid password change attempt [User: {}] [IP Address: {}]: {}",
&id, &username,
ip_address, ip_address,
err err
); );
@@ -428,26 +470,26 @@ async fn change_password(
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
struct EmailRequest { struct PasswordReset {
email: String, email: String,
} }
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
request_body( request_body(
content = EmailRequest, content_type = "application/json" content = PasswordReset, content_type = "application/json"
), ),
responses( responses(
(status = 200, description = "Successful Response"), (status = 200, description = "Successful Response"),
) )
)] )]
#[post("/password/reset")] #[post("/password/reset")]
async fn reset_password(request: web::Json<EmailRequest>, req: HttpRequest) -> HttpResponse { async fn reset_password(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 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(&email).await {
return HttpResponse::Ok().finish(); return HttpResponse::Ok().finish();
}; };
@@ -457,27 +499,34 @@ async fn reset_password(request: web::Json<EmailRequest>, req: HttpRequest) -> H
return ResponseError::error_response(&err); return ResponseError::error_response(&err);
} }
if let Err(err) = send_password_reset_email(email, &email_token, &ip_address) { if let Err(err) = send_password_reset_email(email, &email_token, &ip_address).await {
return ResponseError::error_response(&Error::new(500, err.to_string())); return ResponseError::error_response(&Error::new(500, err.to_string()));
}; };
HttpResponse::Ok().finish() HttpResponse::Ok().finish()
} }
#[derive(Debug, Deserialize, ToSchema)]
struct ConfirmPasswordReset {
token: String,
password: String,
}
#[utoipa::path( #[utoipa::path(
tag = "Account", tag = "account",
request_body( request_body(
content = TokenRequest, content_type = "application/json" content = ConfirmPasswordReset, content_type = "application/json"
), ),
responses( responses(
(status = 200, description = "Successful Response"), (status = 200, description = "Successful Response"),
(status = 404, description = "Not Found"), (status = 404, description = "Not Found"),
) )
)] )]
#[post("/password/validate")] #[post("/password/reset/confirm")]
async fn validate_reset_password( async fn confirm_password_reset(
request: web::Json<TokenRequest>, request: web::Json<ConfirmPasswordReset>,
req: HttpRequest, req: HttpRequest,
) -> HttpResponse { ) -> HttpResponse {
// 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;
@@ -500,13 +549,14 @@ pub fn init_routes(config: &mut ServiceConfig) {
config.service( config.service(
scope::scope("/account") scope::scope("/account")
.service(register) .service(register)
.service(confirm_email_registration)
.service(resend_email_verification)
.service(login) .service(login)
.service(logout) .service(logout)
.service(get_profile) .service(get_profile)
.service(confirm_profile)
.service(session_refresh) .service(session_refresh)
.service(change_password) .service(change_password)
.service(reset_password) .service(reset_password)
.service(validate_reset_password), .service(confirm_password_reset),
); );
} }

View File

@@ -8,7 +8,6 @@ use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult}; use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::task; use tokio::task;
use uuid::Uuid;
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";
@@ -17,22 +16,22 @@ pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Session { pub struct Session {
pub session_id: String, pub session_id: String,
pub user_id: Uuid, pub username: String,
pub ip_address: String, pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>, pub expires_at: Option<DateTime<Utc>>,
} }
impl Session { impl Session {
pub fn default(user_id: &Uuid, ip_address: &str) -> Self { pub fn default(username: &str, ip_address: &str) -> Self {
Self::new(64, user_id, ip_address, Some(DEFAULT_SESSION_TTL)) Self::new(64, username, ip_address, Some(DEFAULT_SESSION_TTL))
} }
pub fn new(take: usize, user_id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self { pub fn new(take: usize, username: &str, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now(); let now = Utc::now();
Self { Self {
session_id: csprng(take), session_id: csprng(take),
user_id: user_id.clone(), username: username.to_string(),
ip_address: hash(&ip_address).unwrap(), ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl { expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)), Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
@@ -79,7 +78,7 @@ impl Session {
); );
}; };
}); });
session = Session::default(&session.user_id, ip_address); session = Session::default(&session.username, ip_address);
session.store().await?; session.store().await?;
Ok(session) Ok(session)
} }
@@ -120,8 +119,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") { if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" { if environment == "development" || environment == "dev" {
log::trace!( log::trace!(
"Session cookie [User ID: {}]: {}", "Session cookie [User: {}]: {}",
self.user_id, self.username,
self.session_id self.session_id
); );
cookie.set_secure(false); cookie.set_secure(false);
@@ -148,8 +147,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") { if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" { if environment == "development" || environment == "dev" {
log::trace!( log::trace!(
"Session expiration cookie [User ID: {}]: {}", "Session expiration cookie [User: {}]: {}",
self.user_id, self.username,
self.session_id self.session_id
); );
cookie.set_secure(false); cookie.set_secure(false);

View File

@@ -7,7 +7,6 @@ use crate::error::{ApiResult, Error};
use crate::metars::Metar; use crate::metars::Metar;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use futures_util::try_join; use futures_util::try_join;
use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder}; use sqlx::{Postgres, QueryBuilder};
use std::collections::HashMap; use std::collections::HashMap;
@@ -81,6 +80,54 @@ impl Default for AirportQuery {
} }
} }
impl AirportQuery {
pub fn builder() -> AirportQueryBuilder {
AirportQueryBuilder::new()
}
}
pub struct AirportQueryBuilder {
inner: AirportQuery,
}
impl AirportQueryBuilder {
/// start the builder
pub fn new() -> Self {
AirportQueryBuilder {
inner: AirportQuery::default(),
}
}
pub fn page(mut self, page: u32) -> Self {
self.inner.page = Some(page);
self
}
pub fn limit(mut self, limit: u32) -> Self {
self.inner.limit = Some(limit);
self
}
pub fn icaos<T: Into<String>>(mut self, v: T) -> Self {
self.inner.icaos = Some(v.into());
self
}
pub fn iatas<T: Into<String>>(mut self, v: T) -> Self {
self.inner.iatas = Some(v.into());
self
}
pub fn metars(mut self, v: bool) -> Self {
self.inner.metars = Some(v);
self
}
pub fn build(self) -> AirportQuery {
self.inner
}
}
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
pub struct Bounds { pub struct Bounds {
pub north_east_lat: f32, pub north_east_lat: f32,
@@ -208,7 +255,7 @@ impl From<AirportRow> for Airport {
} }
impl Airport { impl Airport {
pub async fn select(client: &Client, icao: &str, metar: bool) -> Option<Self> { pub async fn select(icao: &str, metar: bool) -> Option<Self> {
let pool = db::pool(); let pool = db::pool();
let airport_fut = async { let airport_fut = async {
@@ -223,7 +270,7 @@ impl Airport {
let metar_fut = async { let metar_fut = async {
if metar { if metar {
match Metar::find_all_distinct(client, &vec![icao.to_string()]).await { match Metar::get_all_distinct(&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);
@@ -286,7 +333,7 @@ impl Airport {
}) })
} }
pub async fn select_all(client: &Client, query: &AirportQuery) -> ApiResult<Vec<Self>> { pub async fn select_all(query: &AirportQuery) -> ApiResult<Vec<Self>> {
let pool = db::pool(); let pool = db::pool();
let mut builder = let mut builder =
@@ -349,12 +396,12 @@ impl Airport {
} }
// Bulk update airport sub-fields // Bulk update airport sub-fields
let icaos: Vec<String> = airports.iter().map(|a| a.icao.clone()).collect(); let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
let runway_future = Runway::select_all_map(icaos.clone()); let runway_future = Runway::select_all_map(&icaos);
let frequency_future = Communication::select_all_map(icaos.clone()); let frequency_future = Communication::select_all_map(&icaos);
let metar_future = if query.metars.unwrap_or(false) { let metar_future = if query.metars.unwrap_or(false) {
Some(Metar::find_all_distinct(client, &icaos)) Some(Metar::get_all_distinct(&icaos))
} else { } else {
None None
}; };

View File

@@ -65,7 +65,7 @@ impl Communication {
} }
} }
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> { pub async fn select_all_map(icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool(); let pool = db::pool();
let frequency_rows: Vec<CommunicationRow> = sqlx::query_as(&format!( let frequency_rows: Vec<CommunicationRow> = sqlx::query_as(&format!(

View File

@@ -64,7 +64,7 @@ impl Runway {
} }
} }
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> { pub async fn select_all_map(icaos: &Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
let pool = db::pool(); let pool = db::pool();
let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!( let runway_rows: Vec<RunwayRow> = sqlx::query_as(&format!(

View File

@@ -3,7 +3,6 @@ use futures_util::stream::StreamExt as _;
use crate::airports::{AirportQuery, UpdateAirport}; use crate::airports::{AirportQuery, UpdateAirport};
use crate::users::ADMIN_ROLE; use crate::users::ADMIN_ROLE;
use crate::{ use crate::{
AppState,
account::{Auth, verify_role}, account::{Auth, verify_role},
airports::Airport, airports::Airport,
db::Paged, db::Paged,
@@ -16,15 +15,15 @@ use utoipa_actix_web::service_config::ServiceConfig;
#[derive(ToSchema)] #[derive(ToSchema)]
#[allow(unused)] #[allow(unused)]
struct UploadedFile { struct FileUpload {
#[schema(value_type = String, format = Binary)] #[schema(value_type = String, format = Binary)]
file: Vec<u8>, file: Vec<u8>,
} }
#[utoipa::path( #[utoipa::path(
tag = "Airports", tag = "airport",
request_body( request_body(
content = UploadedFile, content_type = "multipart/form-data" content = FileUpload, content_type = "multipart/form-data"
), ),
responses( responses(
(status = 200, description = "Successful import"), (status = 200, description = "Successful import"),
@@ -77,7 +76,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
} }
#[utoipa::path( #[utoipa::path(
tag = "Airports", tag = "airport",
params( params(
AirportQuery AirportQuery
), ),
@@ -86,7 +85,7 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
), ),
)] )]
#[get("")] #[get("")]
async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse { async fn get_airports(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) => {
@@ -104,8 +103,7 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
query.limit = Some(limit); query.limit = Some(limit);
query.page = Some(page); query.page = Some(page);
let client = &data.client; match Airport::select_all(&query).await {
match Airport::select_all(client, &query).await {
Ok(airports) => HttpResponse::Ok().json(Paged { Ok(airports) => HttpResponse::Ok().json(Paged {
data: airports, data: airports,
page, page,
@@ -120,18 +118,14 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
} }
#[utoipa::path( #[utoipa::path(
tag = "Airports", tag = "airport",
responses( responses(
(status = 200, description = "", body = Airport), (status = 200, description = "", body = Airport),
(status = 404, description = ""), (status = 404, description = ""),
), ),
)] )]
#[get("/{icao}")] #[get("/{icao}")]
async fn get_airport( async fn get_airport(icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
data: 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) => {
@@ -140,15 +134,14 @@ async fn get_airport(
} }
}; };
let client = &data.client; match Airport::select(&icao.into_inner(), metar).await {
match Airport::select(client, &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(),
} }
} }
#[utoipa::path( #[utoipa::path(
tag = "Airports", tag = "airport",
responses( responses(
(status = 200, description = "", body = Airport), (status = 200, description = "", body = Airport),
(status = 401, description = ""), (status = 401, description = ""),
@@ -174,7 +167,7 @@ async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse
} }
#[utoipa::path( #[utoipa::path(
tag = "Airports", tag = "airport",
responses( responses(
(status = 200, description = "", body = Airport), (status = 200, description = "", body = Airport),
(status = 401, description = ""), (status = 401, description = ""),
@@ -203,7 +196,7 @@ async fn update_airport(
} }
#[utoipa::path( #[utoipa::path(
tag = "Airports", tag = "airport",
responses( responses(
(status = 201, description = ""), (status = 201, description = ""),
(status = 401, description = ""), (status = 401, description = ""),
@@ -228,7 +221,7 @@ async fn delete_airports(auth: Auth) -> HttpResponse {
} }
#[utoipa::path( #[utoipa::path(
tag = "Airports", tag = "airport",
responses( responses(
(status = 201, description = ""), (status = 201, description = ""),
(status = 401, description = ""), (status = 401, description = ""),

91
api/src/http_client.rs Normal file
View File

@@ -0,0 +1,91 @@
use crate::error::{ApiResult, Error};
use governor::clock::DefaultClock;
use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter};
use reqwest::header::{IF_NONE_MATCH, RETRY_AFTER};
use reqwest::{Certificate, Client, Response, StatusCode};
use std::env;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
#[derive(Debug, Clone)]
pub struct HttpClient {
client: Client,
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
pub default_retry_after: u64,
}
impl HttpClient {
pub fn new(default_retry_after: u64) -> ApiResult<Self> {
let mut client_builder = Client::builder()
.timeout(Duration::from_secs(10))
.tls_built_in_root_certs(true);
if let Ok(val) = env::var("NGINX_SSL_ENABLED") {
if val == "true" {
let certificate_path = env::var("SSL_CA_PATH")?;
let certificate_data = std::fs::read(certificate_path)?;
let certificate = Certificate::from_pem(&certificate_data)?;
client_builder = client_builder.add_root_certificate(certificate);
}
}
let client = client_builder.build()?;
let quota = Quota::per_second(NonZeroU32::new(15).unwrap());
let limiter = RateLimiter::direct(quota);
let limiter = Arc::new(limiter);
Ok(Self {
client,
limiter,
default_retry_after,
})
}
pub fn default() -> ApiResult<Self> {
Self::new(60)
}
pub async fn get(&self, url: &str, etag: Option<String>) -> ApiResult<Response> {
self.limiter.until_ready().await;
let mut request = self.client.get(url);
if let Some(ref etag) = etag {
request = request.header(IF_NONE_MATCH, etag);
}
let mut response = request.send().await?;
// Handle too many requests
if response.status() == StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get(RETRY_AFTER)
.and_then(|hdr| hdr.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(self.default_retry_after);
log::warn!(
"Received 429 Too Many Requests, retrying after {}s",
retry_after
);
sleep(Duration::from_secs(retry_after)).await;
// Retry once more
response = self.client.get(url).send().await?;
} else if response.status() == StatusCode::NOT_MODIFIED {
log::warn!("Received 304 Not modified")
}
if response.status() != 200 {
return Err(Error::new(
response.status().as_u16(),
format!("Request returned status {}", response.status())));
}
Ok(response)
}
}

View File

@@ -1,21 +1,21 @@
use crate::account::hash; use crate::account::hash;
use crate::http_client::HttpClient;
use crate::users::{ADMIN_ROLE, User}; 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 reqwest::Certificate;
use std::env; use std::env;
use std::time::Duration; use std::sync::Arc;
use utoipa::openapi::SecurityRequirement;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa::openapi::{Contact, SecurityRequirement};
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 uuid::Uuid;
mod account; mod account;
mod airports; mod airports;
mod db; mod db;
mod error; mod error;
mod http_client;
mod metars; mod metars;
mod scheduler; mod scheduler;
mod smtp; mod smtp;
@@ -24,19 +24,25 @@ mod users;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct AppState { struct AppState {
client: reqwest::Client, 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?; db::initialize().await?;
// scheduler::update_airports();
let client = Arc::new(HttpClient::default()?);
let scheduler_client = client.clone();
scheduler::update_metars(scheduler_client, 600);
// Initialize admin user // Initialize admin user
let admin_username = env::var("ADMIN_USERNAME");
let admin_email = env::var("ADMIN_EMAIL"); let admin_email = env::var("ADMIN_EMAIL");
let admin_password = env::var("ADMIN_PASSWORD"); let admin_password = env::var("ADMIN_PASSWORD");
if 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 email = admin_email.unwrap(); let email = admin_email.unwrap();
if User::select_by_email(&email).await.is_none() { if User::select_by_email(&email).await.is_none() {
log::debug!("Creating default administrator"); log::debug!("Creating default administrator");
@@ -44,12 +50,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let password_hash = hash(&password)?; let password_hash = hash(&password)?;
if email == "admin@example.com" || password == "changeme" { if email == "admin@example.com" || password == "changeme" {
log::warn!( log::warn!(
"Default admin credentials are in use, update the ADMIN_EMAIL and ADMIN_PASSWORD." "Default admin credentials are in use, update the ADMIN_USERNAME, ADMIN_EMAIL, and ADMIN_PASSWORD."
); );
} }
let admin_user = User { let admin_user = User {
id: Uuid::new_v4(), username,
email, email: Some(email),
email_verified: true, email_verified: true,
password_hash, password_hash,
role: ADMIN_ROLE.to_string(), role: ADMIN_ROLE.to_string(),
@@ -68,23 +74,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
let mut client_builder = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.tls_built_in_root_certs(true);
if let Ok(val) = env::var("NGINX_SSL_ENABLED") {
if val == "true" {
let certificate_path = env::var("SSL_CA_PATH")?;
let certificate_data = std::fs::read(certificate_path)?;
let certificate = Certificate::from_pem(&certificate_data)?;
client_builder = client_builder.add_root_certificate(certificate);
}
}
let client = client_builder
.build()
.expect("Failed to create reqwest client");
let state = AppState { client }; 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());
@@ -111,14 +100,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
) )
.split_for_parts(); .split_for_parts();
let version = env::var("CARGO_PKG_VERSION").unwrap(); let version = env!("CARGO_PKG_VERSION");
api.info.title = "Aviation Data".to_string(); api.info.title = "Aviation Data".to_string();
api.info.description = None; api.info.description = Some("This documentation describe the Aviation Data API".to_string());
api.info.terms_of_service = None; api.info.terms_of_service = None;
api.info.contact = None; api.info.contact = None;
api.info.license = None; api.info.license = None;
api.info.version = version; api.info.version = version.to_string();
let session_scheme = SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session"))); let session_scheme = SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session")));
let mut components = api.components.take().unwrap_or_default(); let mut components = api.components.take().unwrap_or_default();
@@ -126,10 +115,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.security_schemes .security_schemes
.insert("session_auth".to_string(), session_scheme); .insert("session_auth".to_string(), session_scheme);
api.components = Some(components); api.components = Some(components);
// api.security = Some(vec![SecurityRequirement::new("session_auth", [""])]);
api.security = Some(vec![SecurityRequirement::default()]); api.security = Some(vec![SecurityRequirement::default()]);
app.service(SwaggerUi::new("/swagger/{_:.*}").url("/api-docs/openapi.json", api)) app.service(
SwaggerUi::new("/swagger/{_:.*}")
.url("/api-docs/openapi.json", api)
.config(Config::default().use_base_layout()),
)
}) })
.bind(format!("{}:{}", host, port)) .bind(format!("{}:{}", host, port))
{ {

View File

@@ -35,7 +35,7 @@ impl MetarCheck {
let mut conn = match redis_async_connection().await { let mut conn = match redis_async_connection().await {
Ok(conn) => conn, Ok(conn) => conn,
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("Unable to get connection for ICAO {}: {}", icao, err);
return None; return None;
} }
}; };
@@ -44,24 +44,22 @@ impl MetarCheck {
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),
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("Unable to get MetarCheck for ICAO {}: {}", icao, err);
None None
} }
}, },
Ok(None) => None, Ok(None) => None,
Err(err) => { Err(err) => {
log::error!("{}", err); log::error!("Error getting MetarCheck for ICAO {}: {}", icao, err);
None None
} }
} }
} }
pub async fn insert(&self, seconds: u64) -> ApiResult<()> { pub async fn insert(&self) -> ApiResult<()> {
let mut conn = redis_async_connection().await?; let mut conn = redis_async_connection().await?;
let value = serde_json::to_string(&self)?; let value = serde_json::to_string(&self)?;
conn conn.set::<_, _, ()>(self.icao.as_str(), value).await?;
.set_ex::<_, _, ()>(self.icao.as_str(), value, seconds)
.await?;
Ok(()) Ok(())
} }

View File

@@ -1,19 +1,33 @@
use crate::airports::{Airport, UpdateAirport}; use crate::airports::{Airport, UpdateAirport};
use crate::db::redis_async_connection;
use crate::error::Error; use crate::error::Error;
use crate::http_client::HttpClient;
use crate::metars::MetarCheck; use crate::metars::MetarCheck;
use crate::{db, error::ApiResult}; use crate::{db, error::ApiResult};
use chrono::{DateTime, Datelike, NaiveDate, Utc}; use chrono::{DateTime, Datelike, NaiveDate, Utc};
use redis::{AsyncCommands, RedisResult};
use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, 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::str::FromStr; use std::str::FromStr;
use std::sync::OnceLock;
use flate2::read::GzDecoder;
use reqwest::header::ETAG;
use utoipa::ToSchema; use utoipa::ToSchema;
static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
const TABLE_NAME: &str = "metars"; const TABLE_NAME: &str = "metars";
const DEFAULT_REFRESH_DURATION: i64 = 3000;
fn time_offset() -> i64 {
*TIME_OFFSET.get_or_init(|| {
env::var("API_METAR_TIME_OFFSET")
.unwrap_or("1800".to_string())
.parse::<i64>()
.unwrap_or(1800)
})
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Metar { pub struct Metar {
@@ -292,9 +306,9 @@ impl MetarRow {
impl Metar { impl Metar {
fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> { fn parse_multiple(metar_strings: &Vec<&str>) -> ApiResult<Vec<Self>> {
let mut metars: Vec<Metar> = vec![]; let mut metars: Vec<Self> = vec![];
for metar_string in metar_strings { for metar_string in metar_strings {
match Metar::parse(metar_string) { match Self::parse(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);
@@ -315,7 +329,7 @@ impl Metar {
} }
log::trace!("Parsing METAR data: {}", metar_string); log::trace!("Parsing METAR data: {}", metar_string);
let mut metar: Metar = Metar::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 {
@@ -906,9 +920,14 @@ impl Metar {
observation_day observation_day
), ),
) )
})? })?;
.and_hms_opt(observation_hour, observation_minute, 0) let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) {
.unwrap(); 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 obs_datetime = if candidate_date > current_time {
// Subtract one month. (Handle year rollover carefully.) // Subtract one month. (Handle year rollover carefully.)
@@ -928,35 +947,74 @@ impl Metar {
), ),
) )
})?; })?;
adjusted_date.and_hms(observation_hour, observation_minute, 0) adjusted_date
.and_hms_opt(observation_hour, observation_minute, 0)
.unwrap()
} else { } else {
candidate_date candidate_date
}; };
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string()) Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
} }
async fn get_remote_metars(client: &Client, icaos: &Vec<String>) -> ApiResult<Vec<Metar>> { pub async fn get_cached_remote_metars(client: &HttpClient, etag: Option<String>) -> ApiResult<(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);
match client.get(&url, etag.clone()).await {
Ok(r) => {
let new_etag = r
.headers()
.get(ETAG)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let bytes = r.bytes().await?;
let mut gz = GzDecoder::new(Cursor::new(bytes));
let mut text = String::new();
gz.read_to_string(&mut text)?;
let mut output: Vec<Metar> = Vec::new();
for line in text.lines() {
// Split off first column
let raw_text = line.splitn(2, ',').next().unwrap();
match Metar::parse(raw_text) {
Ok(m) => output.push(m),
Err(err) => {
log::warn!("{}", err);
}
};
}
match new_etag {
Some(etag) => Ok((output, etag)),
None => match etag {
Some(etag) => Ok((output, etag)),
None => Ok((output, String::new()))
}
}
}
Err(err) => Err(err.into()),
}
}
pub async fn get_remote_metars(client: &HttpClient, icaos: &Vec<String>) -> ApiResult<Vec<Self>> {
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
.chunks(10) .chunks(10)
.map(|chunk| chunk.join(",")) .map(|chunk| chunk.join(","))
.collect::<Vec<String>>(); .collect::<Vec<String>>();
let mut metars: Vec<Metar> = vec![]; let mut metars: Vec<Self> = vec![];
for icao_chunk in icao_chunks { for icao_chunk in icao_chunks {
let url = format!( let url = format!(
"{}/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).send().await { let mut m = match client.get(&url, None).await {
Ok(r) => { Ok(r) => {
// Check if the status code is 200
if r.status() != 200 {
return Err(Error::new(
500,
format!("Request returned status {}", r.status()),
));
}
match r.text().await { match r.text().await {
Ok(r) => { Ok(r) => {
let metar_chunk = r let metar_chunk = r
@@ -979,22 +1037,22 @@ impl Metar {
Ok(metars) Ok(metars)
} }
fn from_db(metar_db: MetarRow) -> ApiResult<Metar> { fn from_row(row: MetarRow) -> ApiResult<Self> {
let metar: Metar = serde_json::from_value(metar_db.data)?; let metar: Self = serde_json::from_value(row.data)?;
Ok(metar) Ok(metar)
} }
fn to_db(&self) -> ApiResult<MetarRow> { fn to_row(&self) -> ApiResult<MetarRow> {
let data = serde_json::to_value(self)?; let data = serde_json::to_value(self)?;
Ok(MetarRow { Ok(MetarRow {
icao: self.icao.clone(), icao: self.icao.to_uppercase(),
observation_time: self.observation_time, observation_time: self.observation_time,
raw_text: self.raw_text.clone(), raw_text: self.raw_text.clone(),
data, data,
}) })
} }
pub async fn find_all_distinct(client: &Client, icao_list: &Vec<String>) -> ApiResult<Vec<Self>> { pub async fn get_all_distinct(icao_list: &Vec<String>) -> ApiResult<Vec<Self>> {
if icao_list.is_empty() { if icao_list.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -1011,61 +1069,67 @@ impl Metar {
.bind(icao_list) .bind(icao_list)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
let mut metars = vec![];
for metar_row in metar_rows {
metars.push(Self::from_row(metar_row)?)
}
Ok(metars)
}
pub async fn get_or_update_metars(
client: &HttpClient,
icaos: &Vec<String>,
) -> ApiResult<Vec<Self>> {
let metars = Self::get_all_distinct(&icaos).await?;
let current_time = Utc::now().timestamp(); let current_time = Utc::now().timestamp();
let time_offset = env::var("API_METAR_TIME_OFFSET")
.unwrap_or("1800".to_string())
.parse::<i64>()
.unwrap_or(1800);
let short_time_offset: i64 = 300;
// Setup metars and missing metar structures let mut updated_metars: Vec<Self> = vec![];
let mut metars: Vec<Metar> = vec![];
let mut missing_metar_icaos: Vec<String> = vec![]; let mut missing_metar_icaos: Vec<String> = vec![];
let mut found_metar_icaos: HashSet<String> = HashSet::new(); let mut found_metar_icaos: HashSet<String> = HashSet::new();
let mut requested_icaos: HashSet<String> = HashSet::from_iter(icao_list.clone()); let mut requested_icaos: HashSet<String> = HashSet::from_iter(icaos.clone());
// Iterate over returned database metars for metar in metars {
for metar_row in metar_rows { let icao = metar.icao.clone();
let icao = metar_row.icao.clone(); // Remove found icao from requested ICAOs
// Remove icao from requested icaos
requested_icaos.remove(&icao); requested_icaos.remove(&icao);
// Handle outdated metars // Handle outdated METARs
if current_time > (metar_row.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
let refresh_seconds = match MetarCheck::get(&icao).await { let refresh_seconds = match MetarCheck::get(&icao).await {
Some(c) => current_time - c.updated_at.timestamp(), Some(c) => current_time - c.updated_at.timestamp(),
None => short_time_offset, None => DEFAULT_REFRESH_DURATION,
}; };
// If the metar was cached more than short_time_offset minutes ago, refresh it
if refresh_seconds >= short_time_offset { // If the metar is outdated, add it to the refresh list
if refresh_seconds >= DEFAULT_REFRESH_DURATION {
log::trace!("{} METAR data is outdated, marked for refresh", &icao); log::trace!("{} METAR data is outdated, marked for refresh", &icao);
missing_metar_icaos.push(icao.clone()); missing_metar_icaos.push(icao.clone());
} }
// Otherwise return outdated data and wait // Otherwise return the outdated data (to be checked on the next cycle)
else { else {
log::trace!( log::trace!(
"{} METAR data is outdated; refreshing in {} seconds", "{} METAR data is outdated; refreshing in {} seconds",
&icao, &icao,
short_time_offset - refresh_seconds DEFAULT_REFRESH_DURATION - refresh_seconds
); );
metars.push(Metar::from_db(metar_row)?) updated_metars.push(metar);
} }
} }
// Otherwise add the metar to the vector // 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(icao, true).await;
metar_check.insert(time_offset as u64).await?; metar_check.insert().await?;
metars.push(Metar::from_db(metar_row)?); 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(icao).await {
Some(c) => { Some(c) => {
if current_time > (c.updated_at.timestamp() + short_time_offset) { if current_time > (c.updated_at.timestamp() + DEFAULT_REFRESH_DURATION) {
missing_metar_icaos.push(icao.to_string()); missing_metar_icaos.push(icao.to_string());
} }
} }
@@ -1075,6 +1139,7 @@ impl Metar {
} }
} }
// Retrieve missing METARs
if !missing_metar_icaos.is_empty() { if !missing_metar_icaos.is_empty() {
log::trace!( log::trace!(
"Retrieving missing METAR data for {:?}", "Retrieving missing METAR data for {:?}",
@@ -1087,38 +1152,47 @@ impl Metar {
vec![] vec![]
}); });
// Insert missing METARs
if remote_metars.len() > 0 { if remote_metars.len() > 0 {
// Insert missing METARs
for remote_metar in remote_metars.clone() { for remote_metar in remote_metars.clone() {
remote_metar.insert().await?; remote_metar.insert().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(remote_metar.icao.clone(), true).await;
metar_check.last_metar = Some(remote_metar); metar_check.last_metar = Some(remote_metar);
metar_check.insert(time_offset as u64).await?; metar_check.insert().await?;
} }
metars.append(&mut remote_metars); updated_metars.append(&mut remote_metars);
} }
// Update still missing metars // Update still missing METARs
// let mut still_missing_metar_icaos: Vec<String> = vec![];
for difference in found_metar_icaos.symmetric_difference(&requested_icaos) { for difference in found_metar_icaos.symmetric_difference(&requested_icaos) {
// still_missing_metar_icaos.push(difference.to_string());
let metar_check = MetarCheck::new(difference.to_string(), false).await; let metar_check = MetarCheck::new(difference.to_string(), false).await;
metar_check.insert(short_time_offset as u64).await?; metar_check.insert().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);
if last_metar.observation_time < four_hours_ago { if last_metar.observation_time < four_hours_ago {
metars.push(last_metar); updated_metars.push(last_metar);
} }
} }
} }
// if !still_missing_metar_icaos.is_empty() {
// log::trace!("Still missing METAR data from {:?}", still_missing_metar_icaos);
// }
} }
Ok(metars) Ok(updated_metars)
}
pub async fn update_metars(client: &HttpClient, etag: Option<String>) -> ApiResult<String> {
let (remote_metars, etag) = Self::get_cached_remote_metars(client, etag)
.await
.unwrap_or_else(|err| {
log::warn!("Unable to get cached remote METAR data; {}", err);
(vec![], String::new())
});
for remote_metar in remote_metars.clone() {
remote_metar.insert().await?;
}
Ok(etag)
} }
pub async fn insert(&self) -> ApiResult<()> { pub async fn insert(&self) -> ApiResult<()> {
@@ -1127,7 +1201,7 @@ impl Metar {
self.icao, self.icao,
self.observation_time self.observation_time
); );
let metar: MetarRow = self.to_db()?; let metar: MetarRow = self.to_row()?;
metar.insert().await?; metar.insert().await?;
Ok(()) Ok(())
} }

View File

@@ -1,10 +1,12 @@
use crate::AppState; use crate::AppState;
use crate::metars::Metar; use crate::metars::Metar;
use actix_web::{HttpRequest, HttpResponse, get, web}; use actix_web::{HttpRequest, HttpResponse, get, put, web};
use log::error; use log::error;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig; use utoipa_actix_web::service_config::ServiceConfig;
use crate::account::Auth;
#[derive(Debug, Deserialize, ToSchema, IntoParams)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
@@ -13,16 +15,16 @@ struct MetarQuery {
} }
#[utoipa::path( #[utoipa::path(
tag = "METARs", tag = "metar",
params( params(
MetarQuery, MetarQuery,
), ),
responses( responses(
(status = 200, description = "", body = [Metar]), (status = 200, description = "Successful Response", body = [Metar]),
), ),
)] )]
#[get("/metars")] #[get("")]
async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse { async fn find_all(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 = &parameters.icaos; let icao_option = &parameters.icaos;
if let None = icao_option { if let None = icao_option {
@@ -33,10 +35,9 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
Some(i) => i, Some(i) => i,
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"), None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
}; };
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_string()).collect(); let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
let client = &data.client; let metars = match Metar::get_all_distinct(&icaos).await {
let metars = match Metar::find_all_distinct(client, &icaos).await {
Ok(a) => a, Ok(a) => a,
Err(err) => { Err(err) => {
error!("{}", err); error!("{}", err);
@@ -46,6 +47,75 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
HttpResponse::Ok().json(metars) HttpResponse::Ok().json(metars)
} }
pub fn init_routes(config: &mut ServiceConfig) { #[utoipa::path(
config.service(find_all); tag = "metar",
params(
MetarQuery,
),
responses(
(status = 200, description = "Successful Response", body = [Metar]),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[put("")]
async fn refresh_metars(data: 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 icao_option = &parameters.icaos;
if let None = icao_option {
let empty_metars: Vec<Metar> = vec![];
return HttpResponse::Ok().json(empty_metars);
}
let icao_string = match icao_option {
Some(i) => i,
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
};
let icaos: Vec<String> = icao_string.split(',').map(|s| s.to_uppercase()).collect();
let metars = match Metar::get_or_update_metars(&client, &icaos).await {
Ok(a) => a,
Err(err) => {
error!("{}", err);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metars)
}
#[utoipa::path(
tag = "metar",
responses(
(status = 200, description = "Successful Response", body = Metar),
(status = 404, description = "Not Found"),
),
)]
#[get("/{icao}")]
async fn find(icao: web::Path<String>) -> HttpResponse {
let icao = vec![icao.to_uppercase()];
let metar = match Metar::get_all_distinct(&icao).await {
Ok(metars) => {
if metars.len() == 1 {
metars[0].clone()
} else {
return HttpResponse::NotFound().finish()
}
},
Err(err) => {
error!("{}", err);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metar)
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/metars")
.service(find_all)
.service(refresh_metars)
.service(find)
);
} }

View File

@@ -1,74 +1,37 @@
// use tokio::time::{sleep, Duration}; 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;
// use crate::airports::{AirportDb, AirportFilter}; pub fn update_metars(client: Arc<HttpClient>, seconds: u64) {
// use crate::metars::Metar; tokio::spawn(async move {
// Create interval ticker
let mut interval = interval(Duration::from_secs(seconds));
let mut etag = None;
pub fn update_airports() { loop {
// tokio::spawn(async { interval.tick().await;
// let mut airports: Vec<AirportDb> = vec![];
// let limit = 100; // Record start times
// loop { let start_monotonic = Instant::now();
// log::debug!("METAR update start"); let start_utc: DateTime<Utc> = Utc::now();
// let total = match AirportDb::count(&AirportFilter::default()).await { log::debug!("METAR update started at {}", start_utc);
// Ok(t) => t,
// Err(err) => { // Run the update
// log::warn!("{}", err); match Metar::update_metars(&client, etag.clone()).await {
// break; Ok(new_etag) => etag = Some(new_etag),
// } Err(err) => log::error!("METAR update failed: {}", err)
// }; }
// if total != airports.len() as i64 {
// log::debug!("{} cached airports, expected {}", airports.len(), total); let elapsed = start_monotonic.elapsed();
// airports = vec![]; let next_utc = Utc::now() + chrono::Duration::from_std(Duration::from_secs(seconds)).unwrap();
// let pages = ((total as f32) / (if limit <= 0 { 1 } else { limit } as f32)).ceil() as i32; log::info!(
// for page in 1..(pages + 1) { "METAR update finished in {:.2?}; next run at {}",
// match AirportDb::find_all(&AirportFilter::default(), limit, page).await { elapsed,
// Ok(mut a) => airports.append(&mut a), next_utc
// Err(err) => { );
// log::warn!("{}", err); }
// break; });
// }
// }
// }
// }
// log::debug!("Updating {} airport METARS", airports.len());
//
// let airport_icaos: Vec<String> = airports.iter().map(|a| a.icao.to_string()).collect();
// let mut peekable = airport_icaos.into_iter().peekable();
// let mut observation_time = chrono::Utc::now().timestamp();
//
// if peekable.peek().is_none() {
// log::debug!("No airports to update, sleeping for 1 hour");
// sleep(Duration::from_secs(3600)).await;
// continue;
// }
//
// while peekable.peek().is_some() {
// let chunk: Vec<String> = peekable.by_ref().take(limit as usize).collect();
// let icao_string = chunk.join(",");
// log::warn!("Updating METARS for: {}", &icao_string); // TODO: back to trace after
// match Metar::find_all(&[&icao_string]).await {
// Ok(metars) => {
// // Find the oldest observation time
// for metar in metars {
// if metar.observation_time.timestamp() < observation_time {
// observation_time = metar.observation_time.timestamp();
// }
// }
// }
// Err(err) => {
// log::warn!("{}", err);
// }
// }
// // Sleep for 100ms between chunks to avoid rate limiting
// sleep(Duration::from_millis(100)).await;
// }
// log::debug!("METAR update complete");
// // Sleep until the earliest observation time is 1 hour old
// // Bounded by 1 and 3600 seconds
// let now = chrono::Utc::now().timestamp();
// let sleep_time = std::cmp::min(std::cmp::max(1, now - (observation_time + 3600)), 3600);
// log::debug!("Next update in {} seconds", sleep_time);
// sleep(Duration::from_secs(sleep_time as u64)).await;
// }
// });
} }

View File

@@ -1,28 +1,36 @@
use crate::error::ApiResult; use crate::error::ApiResult;
use chrono::{Datelike, Utc};
use handlebars::Handlebars; use handlebars::Handlebars;
use lettre::message::header::ContentType; use lettre::message::header::ContentType;
use lettre::message::{Mailbox, MultiPart, SinglePart}; use lettre::message::{Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::AsyncSmtpTransportBuilder;
use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::authentication::Credentials;
use lettre::{Address, Message, SmtpTransport, Transport}; use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use serde::Serialize; use std::env;
use std::path::Path;
use std::sync::OnceLock; use std::sync::OnceLock;
use std::{env, fs}; use std::time::Duration;
static MAILER: OnceLock<SmtpTransport> = OnceLock::new(); static MAILER: OnceLock<AsyncSmtpTransport<Tokio1Executor>> = OnceLock::new();
static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new(); static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new();
static REGISTRY: OnceLock<Handlebars> = OnceLock::new(); static REGISTRY: OnceLock<Handlebars> = OnceLock::new();
fn mailer() -> &'static SmtpTransport { fn mailer() -> &'static AsyncSmtpTransport<Tokio1Executor> {
MAILER.get_or_init(|| { MAILER.get_or_init(|| {
let server = env::var("SMTP_SERVER").expect("SMTP_SERVER missing"); let server = env::var("SMTP_SERVER").expect("SMTP_SERVER missing");
let username = env::var("SMTP_USERNAME").expect("SMTP_USERNAME missing"); let username = env::var("SMTP_USERNAME").expect("SMTP_USERNAME missing");
let password = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD missing"); let password = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD missing");
let port = env::var("SMTP_PORT").expect("SMTP_PORT missing");
let creds = Credentials::new(username, password); let creds = Credentials::new(username, password);
SmtpTransport::relay(&server) let builder: AsyncSmtpTransportBuilder;
.expect("invalid SMTP_SERVER") if server == "localhost" || server == "127.0.0.1" {
builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&server);
log::warn!("Using a local SMTP server: {}", server);
} else {
builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&server).expect("invalid SMTP_SERVER");
}
builder
.credentials(creds) .credentials(creds)
.port(port.parse().expect("SMTP_PORT invalid"))
.timeout(Some(Duration::from_secs(10)))
.build() .build()
}) })
} }
@@ -39,7 +47,7 @@ pub fn registry() -> &'static Handlebars<'static> {
REGISTRY.get_or_init(|| Handlebars::new()) REGISTRY.get_or_init(|| Handlebars::new())
} }
pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> { pub async fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> {
let to_address = to.parse::<Address>()?; let to_address = to.parse::<Address>()?;
let to_mailbox = Mailbox::new(None, to_address); let to_mailbox = Mailbox::new(None, to_address);
@@ -63,6 +71,6 @@ pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiR
)?; )?;
// Send the email // Send the email
mailer().send(&email)?; mailer().send(email).await?;
Ok(()) Ok(())
} }

View File

@@ -12,23 +12,16 @@ pub struct SystemInfo {
} }
#[utoipa::path( #[utoipa::path(
tag = "System", tag = "system",
responses( responses(
(status = 200, description = "Successful system info"), (status = 200, description = "Successful system info"),
) )
)] )]
#[get("/info")] #[get("/info")]
async fn info() -> HttpResponse { async fn info() -> HttpResponse {
let mut healthy = true; let healthy = true;
let version = match env::var("CARGO_PKG_VERSION") { let version = env!("CARGO_PKG_VERSION");
Ok(v) => v, let info = SystemInfo { version: version.to_string(), healthy };
Err(_) => {
healthy = false;
String::from("unknown")
}
};
let info = SystemInfo { version, healthy };
HttpResponse::Ok().json(info) HttpResponse::Ok().json(info)
} }

View File

@@ -2,20 +2,34 @@ use crate::db;
use crate::{account::hash, error::ApiResult}; 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
use serde_json::json; use serde_json::json;
use sqlx::{Postgres, QueryBuilder}; use sqlx::{Postgres, QueryBuilder};
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid;
pub const ADMIN_ROLE: &str = "ADMIN"; pub const ADMIN_ROLE: &str = "ADMIN";
pub const USER_ROLE: &str = "USER"; pub const USER_ROLE: &str = "USER";
const TABLE_NAME: &str = "users"; const TABLE_NAME: &str = "users";
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
#[schema(
example = json!(
{
"email": "user",
"email": "user@example.com",
"password": "changeme",
"firstName": "firstname",
"lastName": "lastname"
}
)
)]
pub struct RegisterRequest { pub struct RegisterRequest {
pub email: String, pub username: String,
pub email: Option<String>,
pub password: String, pub password: String,
#[serde(rename = "firstName")]
pub first_name: String, pub first_name: String,
#[serde(rename = "lastName")]
pub last_name: String, pub last_name: String,
} }
@@ -23,8 +37,11 @@ impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> { pub fn to_user(self) -> ApiResult<User> {
let password_hash = hash(&self.password)?; let password_hash = hash(&self.password)?;
Ok(User { Ok(User {
id: Uuid::new_v4(), username: self.username,
email: self.email.to_lowercase(), email: match self.email {
Some(email) => Some(email.to_lowercase()),
None => None,
},
email_verified: false, email_verified: false,
password_hash, password_hash,
role: USER_ROLE.to_string(), role: USER_ROLE.to_string(),
@@ -41,31 +58,34 @@ impl RegisterRequest {
#[schema( #[schema(
example = json!( example = json!(
{ {
"email": "user@example.com", "username": "admin",
"password": "changeme" "password": "changeme"
} }
) )
)] )]
pub struct LoginRequest { pub struct LoginRequest {
pub email: String, pub username: String,
pub password: String, pub password: String,
} }
#[derive(Debug, Serialize, ToSchema)] #[derive(Debug, Serialize, ToSchema)]
pub struct UserResponse { pub struct UserResponse {
pub id: Uuid, pub username: String,
pub role: String, pub role: String,
#[serde(rename = "firstName")]
pub first_name: String, pub first_name: String,
#[serde(rename = "lastName")]
pub last_name: String, pub last_name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>, pub avatar: Option<String>,
#[serde(rename = "emailVerified")]
pub email_verified: bool, pub email_verified: bool,
} }
impl From<User> for UserResponse { impl From<User> for UserResponse {
fn from(user: User) -> Self { fn from(user: User) -> Self {
UserResponse { UserResponse {
id: user.id, username: user.username,
email_verified: user.email_verified, email_verified: user.email_verified,
role: user.role, role: user.role,
first_name: user.first_name, first_name: user.first_name,
@@ -87,7 +107,7 @@ pub struct UpdateUser {
} }
impl UpdateUser { impl UpdateUser {
pub async fn update(&self, id: &Uuid) -> ApiResult<User> { pub async fn update(&self, username: &str) -> ApiResult<User> {
let pool = db::pool(); let pool = db::pool();
let mut query_builder: QueryBuilder<Postgres> = let mut query_builder: QueryBuilder<Postgres> =
@@ -143,8 +163,8 @@ impl UpdateUser {
query_builder.push("updated_at = "); query_builder.push("updated_at = ");
query_builder.push_bind(Utc::now()); query_builder.push_bind(Utc::now());
query_builder.push(" WHERE id = "); query_builder.push(" WHERE username = ");
query_builder.push_bind(id); query_builder.push_bind(username);
query_builder.push(" RETURNING *"); query_builder.push(" RETURNING *");
let query = query_builder.build_query_as::<User>(); let query = query_builder.build_query_as::<User>();
@@ -156,8 +176,8 @@ impl UpdateUser {
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User { pub struct User {
pub id: Uuid, pub username: String,
pub email: String, pub email: Option<String>,
pub email_verified: bool, pub email_verified: bool,
pub password_hash: String, pub password_hash: String,
pub role: String, pub role: String,
@@ -169,19 +189,19 @@ pub struct User {
} }
impl User { impl User {
pub async fn select(id: &Uuid) -> Option<Self> { pub async fn select(username: &str) -> Option<Self> {
let pool = db::pool(); 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 id = $1 SELECT * FROM {} WHERE username = $1
"#, "#,
TABLE_NAME TABLE_NAME
)) ))
.bind(id) .bind(username)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
log::error!("Unable to find user by id '{}': {}", id, err); log::error!("Unable to find user '{}': {}", username, err);
None None
}); });
@@ -192,11 +212,11 @@ impl User {
let pool = db::pool(); 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 = LOWER($1) SELECT * FROM {} WHERE email = $1
"#, "#,
TABLE_NAME TABLE_NAME
)) ))
.bind(email) .bind(email.to_lowercase())
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
@@ -207,6 +227,7 @@ impl User {
user user
} }
#[allow(dead_code)]
pub async fn count() -> i64 { pub async fn count() -> i64 {
let pool = db::pool(); let pool = db::pool();
@@ -226,7 +247,7 @@ impl User {
let user: User = sqlx::query_as::<_, Self>(&format!( let user: User = sqlx::query_as::<_, Self>(&format!(
r#" r#"
INSERT INTO {} ( INSERT INTO {} (
id, username,
email, email,
email_verified, email_verified,
password_hash, password_hash,
@@ -242,7 +263,7 @@ impl User {
"#, "#,
TABLE_NAME, TABLE_NAME,
)) ))
.bind(&self.id) .bind(&self.username)
.bind(&self.email) .bind(&self.email)
.bind(&self.email_verified) .bind(&self.email_verified)
.bind(&self.password_hash) .bind(&self.password_hash)

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Change Password name: Change Password
type: http type: http
seq: 4 seq: 6
} }
put { put {
@@ -11,7 +11,9 @@ put {
} }
body:json { body:json {
"New Password" {
"password": "New Password"
}
} }
script:post-response { script:post-response {

View File

@@ -0,0 +1,18 @@
meta {
name: Confirm Password Reset
type: http
seq: 8
}
post {
url: {{API_URL}}/account/password/verify
body: json
auth: none
}
body:json {
{
"token": "token",
"password": "New Password"
}
}

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Get Profile name: Get Profile
type: http type: http
seq: 7 seq: 10
} }
get { get {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Login name: Login
type: http type: http
seq: 2 seq: 4
} }
post { post {
@@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"email": "admin@example.com", "username": "user",
"password": "changeme" "password": "changeme"
} }
} }

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Logout name: Logout
type: http type: http
seq: 3 seq: 5
} }
post { post {
@@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"email": "john.doe@gmail.com", "email": "user@gmail.com",
"password": "fake_password123" "password": "changeme"
} }
} }

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Refresh Session name: Refresh Session
type: http type: http
seq: 6 seq: 9
} }
get { get {

View File

@@ -12,9 +12,10 @@ post {
body:json { body:json {
{ {
"email": "john.doe@gmail.com", "username": "user",
"password": "fake_password123", "email": "user@example.com",
"first_name": "John", "password": "changeme",
"last_name": "Doe" "firstName": "John",
"lastName": "Doe"
} }
} }

View File

@@ -0,0 +1,11 @@
meta {
name: Resend Email Confirmation
type: http
seq: 2
}
post {
url: {{API_URL}}/account/register/resend
body: none
auth: none
}

View File

@@ -1,11 +1,17 @@
meta { meta {
name: Reset Password name: Reset Password
type: http type: http
seq: 5 seq: 7
} }
post { post {
url: {{API_URL}}/account/password/reset url: {{API_URL}}/account/password/reset
body: none body: json
auth: none auth: none
} }
body:json {
{
"email": "user@example.com"
}
}

View File

@@ -0,0 +1,17 @@
meta {
name: Verify Email Confirmation
type: http
seq: 3
}
post {
url: {{API_URL}}/account/register/verify
body: json
auth: none
}
body:json {
{
"token": "token"
}
}

3
bruno/Account/folder.bru Normal file
View File

@@ -0,0 +1,3 @@
meta {
name: Account
}

View File

@@ -25,8 +25,7 @@ services:
volumes: volumes:
- ./ssl:/etc/nginx/ssl/ - ./ssl:/etc/nginx/ssl/
networks: networks:
- frontend - default
- backend
<<: *default_restart <<: *default_restart
postgres: postgres:
@@ -43,7 +42,7 @@ services:
ports: ports:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
networks: networks:
- backend - default
profiles: profiles:
- backend - backend
<<: *default_restart <<: *default_restart
@@ -61,7 +60,7 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
networks: networks:
- backend - default
profiles: profiles:
- backend - backend
<<: *default_restart <<: *default_restart
@@ -80,7 +79,7 @@ services:
- "${MINIO_PORT:-9000}:9000" - "${MINIO_PORT:-9000}:9000"
- "${MINIO_INTERNAL_PORT:-9001}:9001" - "${MINIO_INTERNAL_PORT:-9001}:9001"
networks: networks:
- backend - default
profiles: profiles:
- backend - backend
command: server --console-address ":9001" /data command: server --console-address ":9001" /data
@@ -113,32 +112,50 @@ services:
- redis - redis
- minio - minio
networks: networks:
- frontend - default
- backend
profiles: profiles:
- api - api
<<: *default_restart <<: *default_restart
ui-dev: # Development Containers
image: gitea.bensherriff.com/bsherriff/aviation-ui:latest # ui-dev:
container_name: aviation-ui-dev # image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
build: # container_name: aviation-ui-dev
context: . # build:
dockerfile: Dockerfile # context: .
env_file: *env # dockerfile: Dockerfile
# env_file: *env
# environment:
# - VITE_NODE_ENV=${VITE_NODE_ENV:-development}
# ports:
# - "${UI_PORT:-3000}:3000"
# volumes:
# - ./ui/src:/app/src
# - ./ui/public:/app/public
# - ./ui/styles:/app/styles
# networks:
# - default
# profiles:
# - dev
# command: ["npm", "run", "dev"]
# <<: *default_restart
mailpit:
image: axllent/mailpit
container_name: mailpit
environment: environment:
- VITE_NODE_ENV=${VITE_NODE_ENV:-development} MP_MAX_MESSAGES: 5000
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
ports: ports:
- "${UI_PORT:-3000}:3000" - "${MAILPIT_WEB_PORT:-8025}:8025"
- "${MAILPIT_SMTP_PORT:-1025}:1025"
volumes: volumes:
- ./ui/src:/app/src - mailpit:/data
- ./ui/public:/app/public
- ./ui/styles:/app/styles
networks: networks:
- frontend - default
profiles: profiles:
- frontend - dev
command: ["npm", "run", "dev"]
<<: *default_restart <<: *default_restart
volumes: volumes:
@@ -146,7 +163,7 @@ volumes:
postgres_logs: postgres_logs:
redis: redis:
minio: minio:
mailpit:
networks: networks:
frontend: default:
backend:

View File

@@ -16,6 +16,7 @@ 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
@@ -139,6 +140,7 @@ function App() {
/> />
</MapContainer> </MapContainer>
</div> </div>
<Footer />
</div> </div>
); );
} }

View File

@@ -14,13 +14,13 @@ import {
import { Airport, AirportCategory } from '@lib/airport.types.ts'; 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 { getMetars } from '@lib/metar.ts';
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery } from '@mantine/hooks';
import { IconViewfinder } from '@tabler/icons-react'; import { 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';
export function AirportDrawer({ export function AirportDrawer({
airport, airport,

View File

@@ -5,8 +5,7 @@ import debounce from 'lodash.debounce';
import { getAirports } from '@lib/airport.ts'; import { getAirports } from '@lib/airport.ts';
import AirportMarker from '@components/AirportMarker.tsx'; import AirportMarker from '@components/AirportMarker.tsx';
import { LayerInfo } from '@/App.tsx'; import { LayerInfo } from '@/App.tsx';
import { LatLng } from 'leaflet';
const EXPANSION_FACTOR = 0.5;
export default function AirportLayer({ export default function AirportLayer({
setAirport, setAirport,
@@ -18,21 +17,13 @@ export default function AirportLayer({
selectedLayer: LayerInfo; selectedLayer: LayerInfo;
}) { }) {
const [airports, setAirports] = useState<Airport[]>([]); const [airports, setAirports] = useState<Airport[]>([]);
const lastBoundsRef = useRef<{ ne: any; sw: any } | null>(null); const lastBoundsRef = useRef<{ ne: LatLng; sw: LatLng } | null>(null);
const debouncedLoad = useRef( const debouncedLoad = useRef(
debounce(async (map: any) => { debounce(async (map: any) => {
const b = map.getBounds(); const bounds = map.getBounds();
const north = b.getNorth(), const ne = bounds.getNorthEast()
south = b.getSouth(); const sw = bounds.getSouthWest()
const east = b.getEast(),
west = b.getWest();
const latDelta = (north - south) * EXPANSION_FACTOR;
const lonDelta = (east - west) * EXPANSION_FACTOR;
// expanded bbox
const ne = { lat: north + latDelta, lon: east + lonDelta };
const sw = { lat: south - latDelta, lon: west - lonDelta };
lastBoundsRef.current = { ne, sw }; lastBoundsRef.current = { ne, sw };
try { try {
@@ -58,7 +49,7 @@ export default function AirportLayer({
return () => { return () => {
debouncedLoad.cancel(); debouncedLoad.cancel();
}; };
}, [map]); }, [map, debouncedLoad]);
return ( return (
<> <>

View File

@@ -0,0 +1,26 @@
.footer {
background: #32495f;
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.inner {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
@media (max-width: $mantine-breakpoint-xs) {
flex-direction: column;
}
}
.link {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
@media (max-width: $mantine-breakpoint-xs) {
margin-top: var(--mantine-spacing-md);
}
}
.link:hover {
color: light-dark(var(--mantine-color-white));
}

View File

@@ -0,0 +1,53 @@
import classes from './Footer.module.css';
import { Divider, Group, Text } from '@mantine/core';
import { useEffect, useState } from 'react';
import { systemInfo } from '@lib/system.ts';
import { useMediaQuery } from '@mantine/hooks';
const links = [
{ link: `/swagger/`, newTab: true, label: 'API Docs' },
{ link: '/cookies', label: 'Cookies' },
{ link: '/privacy', label: 'Privacy' },
{ link: '/terms', label: 'Terms' },
{ link: '/contact', label: 'Contact' }
];
export function Footer() {
const [version, setVersion] = useState('0.0.0');
const isMobile = useMediaQuery('(max-width: 768px)');
const items = links.map((link) => (
<a className={classes.link} key={link.label} href={link.link} target={link.newTab ? `_blank` : ''}>
<Text size='sm'>{link.label}</Text>
</a>
));
useEffect(() => {
systemInfo().then((info) => {
if (info != undefined) {
setVersion(info.version);
}
});
}, []);
return (
<div className={classes.footer}>
<Group className={classes.inner}>
<Group>
<Text size='sm'>
API{' '}
<a className={classes.link} href={'https://gitea.bensherriff.com/bsherriff/aviation'} target={'_blank'}>
v{version}
</a>
</Text>
<Divider orientation={'vertical'} />
<Text size='sm'>© {new Date().getFullYear()} Aviation Data</Text>
</Group>
{!isMobile && (
<Group gap='xs' justify='flex-end' wrap='nowrap'>
{items}
</Group>
)}
</Group>
</div>
);
}

View File

@@ -17,46 +17,46 @@ import Cookies from 'js-cookie';
interface HeaderModalProps { interface HeaderModalProps {
type?: string; type?: string;
toggle: (input: string | undefined) => void; toggle: (input: string | undefined) => void;
login: ({ email, password }: { email: string; password: string }) => Promise<boolean>; login: ({ username, password }: { username: string; password: string }) => Promise<boolean>;
register: ({ register: ({
firstName, firstName,
lastName, lastName,
email, username,
password password
}: { }: {
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; username: string;
password: string; password: string;
}) => Promise<boolean>; }) => Promise<boolean>;
} }
export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) { export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) {
function passwordValidator(value: string) { function passwordValidator(value: string) {
if (value.trim().length < 8) { if (value.trim().length < 6) {
return 'Password must be at least 8 characters'; return 'Password must be at least 6 characters';
} }
if (value.trim().length >= 128) { if (value.trim().length >= 128) {
return 'Password must be at most 128 characters'; return 'Password must be at most 128 characters';
} }
if (!/(\d)/.test(value)) { // if (!/(\d)/.test(value)) {
return 'Password must contain at least one number'; // return 'Password must contain at least one number';
} // }
if (!/[a-z]/.test(value)) { // if (!/[a-z]/.test(value)) {
return 'Password must contain at least one lowercase letter'; // return 'Password must contain at least one lowercase letter';
} // }
if (!/[A-Z]/.test(value)) { // if (!/[A-Z]/.test(value)) {
return 'Password must contain at least one uppercase letter'; // return 'Password must contain at least one uppercase letter';
} // }
if (!/[!@#$%^&*]/.test(value)) { // if (!/[!@#$%^&*]/.test(value)) {
return 'Password must contain at least one special character'; // return 'Password must contain at least one special character';
} // }
return null; return null;
} }
function emailValidator(value: string) { function emailValidator(value: string) {
if (value.trim().length == 0) { if (value.trim().length == 0) {
return 'Email is required'; return null;
} }
if (!/^\S+@\S+$/.test(value)) { if (!/^\S+@\S+$/.test(value)) {
return 'Invalid email'; return 'Invalid email';
@@ -68,12 +68,14 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
initialValues: { initialValues: {
firstName: '', firstName: '',
lastName: '', lastName: '',
username: '',
email: '', email: '',
password: '' password: ''
}, },
validate: { validate: {
firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'), firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'),
lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'), lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'),
username: (value) => (value.trim().length > 0 ? null : 'Username is required'),
email: emailValidator, email: emailValidator,
password: passwordValidator password: passwordValidator
} }
@@ -81,7 +83,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
const loginForm = useForm({ const loginForm = useForm({
initialValues: { initialValues: {
email: Cookies.get('email') || '', username: Cookies.get('username') || '',
password: '', password: '',
remember: Cookies.get('remember') === 'true' remember: Cookies.get('remember') === 'true'
} }
@@ -150,14 +152,20 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
{...registerForm.getInputProps('lastName')} {...registerForm.getInputProps('lastName')}
/> />
<TextInput <TextInput
label='Email' label='Username'
placeholder='you@example.com' placeholder='Your username'
required required
{...registerForm.getInputProps('username')}
/>
<TextInput
label='Email'
description={'Optional for email verification and updates'}
placeholder='you@example.com'
{...registerForm.getInputProps('email')} {...registerForm.getInputProps('email')}
/> />
<PasswordInput <PasswordInput
label='Password' label='Password'
description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.' // description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
placeholder='Your password' placeholder='Your password'
required required
mt='md' mt='md'
@@ -184,9 +192,9 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
onSubmit={loginForm.onSubmit(async (values) => { onSubmit={loginForm.onSubmit(async (values) => {
Cookies.set('remember', 'true', { expires: 365 }); Cookies.set('remember', 'true', { expires: 365 });
if (values.remember) { if (values.remember) {
Cookies.set('email', values.email, { expires: 365 }); Cookies.set('username', values.username, { expires: 365 });
} else { } else {
Cookies.remove('email'); Cookies.remove('username');
} }
const success = await login(values); const success = await login(values);
if (success) { if (success) {
@@ -194,7 +202,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
} }
})} })}
> >
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} /> <TextInput label='Username' placeholder='Your username' required {...loginForm.getInputProps('username')} />
<PasswordInput <PasswordInput
label='Password' label='Password'
placeholder='Your password' placeholder='Your password'

View File

@@ -21,7 +21,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} /> <Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
<div tabIndex={-1} style={{ flex: 1, userSelect: 'none' }}> <div tabIndex={-1} style={{ flex: 1, userSelect: 'none' }}>
<Text size='sm' fw={500}> <Text size='sm' fw={500}>
{user.first_name} {user.last_name} {user.firstName} {user.lastName}
</Text> </Text>
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}> <Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
{user.role} {user.role}
@@ -62,7 +62,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
)} )}
</FileButton> </FileButton>
<Text ta='center' fz='lg' fw={500} mt='sm'> <Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name} {user.firstName} {user.lastName}
</Text> </Text>
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}> <Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
{user.role} {user.role}

View File

@@ -36,12 +36,12 @@ export function Header() {
// </a> // </a>
// )); // ));
async function loginUser({ email, password }: { email: string; password: string }): Promise<boolean> { async function loginUser({ username, password }: { username: string; password: string }): Promise<boolean> {
const loginResponse = await login(email, password); const loginResponse = await login(username, password);
if (loginResponse) { if (loginResponse) {
setUser(loginResponse); setUser(loginResponse);
notifications.show({ notifications.show({
title: `Welcome back ${loginResponse.first_name}!`, title: `Welcome back ${loginResponse.firstName}!`,
message: `You have been logged in.`, message: `You have been logged in.`,
color: 'green', color: 'green',
autoClose: 2000, autoClose: 2000,
@@ -69,12 +69,14 @@ export function Header() {
async function registerUser({ async function registerUser({
firstName, firstName,
lastName, lastName,
username,
email, email,
password password
}: { }: {
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; username: string;
email?: string;
password: string; password: string;
}): Promise<boolean> { }): Promise<boolean> {
const id = notifications.show({ const id = notifications.show({
@@ -85,19 +87,20 @@ export function Header() {
withCloseButton: false withCloseButton: false
}); });
const registerResponse = await register({ const registerResponse = await register({
first_name: firstName, firstName: firstName,
last_name: lastName, lastName: lastName,
username: username,
email: email, email: email,
password: password password: password
}); });
if (registerResponse) { if (registerResponse) {
const loginResponse = await login(email, password); const loginResponse = await login(username, password);
if (loginResponse) { if (loginResponse) {
setUser(loginResponse); setUser(loginResponse);
notifications.update({ notifications.update({
id, id,
title: `Account created`, title: `Account created`,
message: `Welcome ${loginResponse.first_name}!`, message: `Welcome ${loginResponse.firstName}!`,
color: 'green', color: 'green',
autoClose: 2000, autoClose: 2000,
loading: false loading: false

View File

@@ -12,7 +12,7 @@ export function Profile() {
return ( return (
<> <>
<Header /> <Header />
Todo: profile {user?.first_name} Todo: profile {user?.firstName}
</> </>
); );
} }

View File

@@ -1,8 +1,8 @@
import { getRequest, postRequest } from '.'; import { getRequest, postRequest } from '.';
import { RegisterUser, User } from './account.types'; import { RegisterUser, User } from './account.types';
export async function login(email: string, password: string): Promise<User | undefined> { export async function login(username: string, password: string): Promise<User | undefined> {
const response = await postRequest('account/login', { email, password }); const response = await postRequest('account/login', { username, password });
if (response?.status === 200) { if (response?.status === 200) {
return response.json(); return response.json();
} else { } else {

View File

@@ -1,14 +1,16 @@
export interface RegisterUser { export interface RegisterUser {
email: string; username: string;
email?: string;
password: string; password: string;
first_name: string; firstName: string;
last_name: string; lastName: string;
} }
export interface User { export interface User {
email_verified: boolean; username: string;
emailVerified: boolean;
role: string; role: string;
first_name: string; firstName: string;
last_name: string; lastName: string;
profile_picture?: string; profilePicture?: string;
} }

View File

@@ -29,7 +29,7 @@ export async function getAirports({
}: GetAirportsParameters): Promise<GetAirportsResponse> { }: GetAirportsParameters): Promise<GetAirportsResponse> {
const response = await getRequest('airports', { const response = await getRequest('airports', {
bounds: bounds bounds: bounds
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}` ? `${bounds?.northEast.lat},${bounds?.northEast.lng},${bounds?.southWest.lat},${bounds?.southWest.lng}`
: undefined, : undefined,
categories: categories ?? undefined, categories: categories ?? undefined,
icaos: icaos ?? undefined, icaos: icaos ?? undefined,

View File

@@ -1,4 +1,5 @@
import { Metar } from './metar.types'; import { Metar } from './metar.types';
import { LatLng } from 'leaflet';
export enum AirportCategory { export enum AirportCategory {
SMALL = 'small_airport', SMALL = 'small_airport',
@@ -12,13 +13,8 @@ export enum AirportCategory {
} }
export interface Bounds { export interface Bounds {
northEast: Coordinate; northEast: LatLng;
southWest: Coordinate; southWest: LatLng;
}
export interface Coordinate {
lat: number;
lon: number;
} }
export interface Airport { export interface Airport {

2
ui/src/lib/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
// @ts-expect-error The window.__CONFIG__ only exists in production
export const API_URL = window.__CONFIG__?.API_URL || import.meta.env.VITE_API_URL || 'http://localhost:8080/api';

View File

@@ -1,10 +1,9 @@
// @ts-expect-error The window.__CONFIG__ only exists in production import { API_URL } from '@lib/constants.ts';
const baseUrl = window.__CONFIG__?.API_URL || import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> { export async function getRequest(endpoint: string, params: Record<string, 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 ? `${baseUrl}/${endpoint}?${urlParams}` : `${baseUrl}/${endpoint}`; const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
return await fetch(url, { return await fetch(url, {
method: 'GET', method: 'GET',
credentials: 'include' credentials: 'include'
@@ -17,7 +16,7 @@ interface PostOptions {
} }
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> { export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
const url = `${baseUrl}/${endpoint}`; const url = `${API_URL}/${endpoint}`;
let response; let response;
if (body && (!options?.type || options.type === 'json')) { if (body && (!options?.type || options.type === 'json')) {
response = await fetch(url, { response = await fetch(url, {
@@ -39,7 +38,7 @@ export async function postRequest(endpoint: string, body?: any, options?: PostOp
} }
export async function putRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> { export async function putRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
const url = `${baseUrl}/${endpoint}`; const url = `${API_URL}/${endpoint}`;
let response; let response;
if (body && (!options?.type || options.type === 'json')) { if (body && (!options?.type || options.type === 'json')) {
response = await fetch(url, { response = await fetch(url, {
@@ -61,7 +60,7 @@ 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 = `${baseUrl}/${endpoint}`; const url = `${API_URL}/${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'DELETE', method: 'DELETE',
credentials: 'include' credentials: 'include'

View File

@@ -1,10 +1,16 @@
import { Metar } from '@lib/metar.types.ts'; import { Metar } from '@lib/metar.types.ts';
import { getRequest } from '@lib/index.ts'; import { getRequest, putRequest } from '@lib/index.ts';
export async function getMetars({ icaos, force }: { icaos: string[]; force?: boolean }): Promise<Metar[]> { export async function getMetars({ icaos }: { icaos: string[] }): Promise<Metar[]> {
const response = await getRequest('metars', { const response = await getRequest('metars', {
icaos: icaos, icaos: icaos
force: force });
return response?.json() || {};
}
export async function refreshMetars({ icaos }: { icaos: string[] }): Promise<Metar[]> {
const response = await putRequest('metars', {
icaos: icaos
}); });
return response?.json() || {}; return response?.json() || {};
} }

11
ui/src/lib/system.ts Normal file
View File

@@ -0,0 +1,11 @@
import { getRequest } from '@lib/index.ts';
import { SystemInfo } from '@lib/system.types.ts';
export async function systemInfo(): Promise<SystemInfo | undefined> {
const response = await getRequest('system/info');
if (response?.status === 200) {
return response.json();
} else {
return undefined;
}
}

View File

@@ -0,0 +1,4 @@
export interface SystemInfo {
version: string;
healthy: boolean;
}