From 36746236916ef982c8c3a6ddd0ce5738ec3a524c Mon Sep 17 00:00:00 2001 From: Ben Sherriff Date: Thu, 15 May 2025 09:16:22 -0400 Subject: [PATCH] Working on emails, updated swagger, added geometry column to airports --- .env | 4 +- api/Cargo.lock | 47 +++-- api/Cargo.toml | 8 +- api/migrations/20250513_initial.sql | 3 +- api/src/account/email_token.rs | 161 ++++++++++++++ api/src/account/mod.rs | 1 + api/src/account/routes.rs | 196 ++++++++++++++---- api/src/airports/model/airport.rs | 51 +++-- api/src/main.rs | 13 +- api/src/smtp/mod.rs | 41 +--- docker-compose.yml | 2 + templates/confirm_email.html | 53 +++++ .../password_reset.html | 4 +- 13 files changed, 449 insertions(+), 135 deletions(-) create mode 100644 api/src/account/email_token.rs create mode 100644 templates/confirm_email.html rename {api/templates => templates}/password_reset.html (95%) diff --git a/.env b/.env index d376f05..f33b818 100644 --- a/.env +++ b/.env @@ -45,10 +45,10 @@ VITE_DEFAULT_LIMIT=200 __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST} ENVIRONMENT=development -API_CONTACT_NAME=changeme -API_CONTACT_EMAIL=contact@example.com ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=changeme +TEMPLATE_DIR=../templates + AVIATION_WEATHER_URL=https://aviationweather.gov/api/data diff --git a/api/Cargo.lock b/api/Cargo.lock index 9455931..3853ceb 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -64,7 +64,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.0", + "rand 0.9.1", "sha1", "smallvec", "tokio", @@ -377,7 +377,7 @@ dependencies = [ "handlebars", "lettre", "log", - "rand 0.9.0", + "rand 0.9.1", "rand_chacha 0.9.0", "redis", "regex", @@ -623,9 +623,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -2437,13 +2437,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", - "zerocopy 0.8.24", ] [[package]] @@ -3046,9 +3045,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3059,10 +3058,11 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" dependencies = [ + "base64", "bytes", "chrono", "crc", @@ -3094,9 +3094,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" dependencies = [ "proc-macro2", "quote", @@ -3107,9 +3107,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" dependencies = [ "dotenvy", "either", @@ -3133,9 +3133,9 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" dependencies = [ "atoi", "base64", @@ -3177,9 +3177,9 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" dependencies = [ "atoi", "base64", @@ -3216,9 +3216,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" dependencies = [ "atoi", "chrono", @@ -3234,6 +3234,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror 2.0.12", "tracing", "url", "uuid", @@ -3460,9 +3461,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.2" +version = "1.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" dependencies = [ "backtrace", "bytes", diff --git a/api/Cargo.toml b/api/Cargo.toml index 6d5fa9b..d37b1ab 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -12,14 +12,14 @@ readme = "../README.md" actix-web = "4.10.2" actix-cors = "0.7.1" actix-multipart = "0.7.2" -chrono = { version = "0.4.40", features = ["serde"] } +chrono = { version = "0.4.41", features = ["serde"] } dotenv = "0.15.0" -sqlx = { version = "0.8.3", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } +sqlx = { version = "0.8.5", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } env_logger = "0.11.8" reqwest = "0.12.15" serde = {version = "1.0.219", features = ["derive"]} serde_json = "1.0.140" -tokio = { version = "1.44.2", features = ["macros", "rt", "time"] } +tokio = { version = "1.45.0", features = ["macros", "rt", "time"] } uuid = { version = "1.16.0", features = ["serde", "v4"] } log = "0.4.27" argon2 = "0.5.3" @@ -27,7 +27,7 @@ redis = { version = "0.31.0", features = ["tokio-comp", "connection-manager", "r regex = "1.11.1" futures-util = "0.3.31" rust-s3 = "0.35.1" -rand = "0.9.0" +rand = "0.9.1" rand_chacha = "0.9.0" futures = "0.3.31" utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] } diff --git a/api/migrations/20250513_initial.sql b/api/migrations/20250513_initial.sql index 57eb86b..928e09e 100644 --- a/api/migrations/20250513_initial.sql +++ b/api/migrations/20250513_initial.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS airports ( elevation_ft REAL NOT NULL, longitude REAL NOT NULL, latitude REAL NOT NULL, + geometry GEOMETRY(POINT, 4326) NOT NULL, has_tower BOOLEAN DEFAULT false, has_beacon BOOLEAN DEFAULT false, public BOOLEAN DEFAULT false, @@ -25,7 +26,7 @@ CREATE INDEX ON airports (category); CREATE INDEX ON airports (iso_country); CREATE INDEX ON airports (iso_region); CREATE INDEX ON airports (municipality); -CREATE INDEX ON airports (longitude, latitude); +CREATE INDEX ON airports USING GIST(geometry); CREATE INDEX ON airports (metar_observation_time); CREATE TABLE IF NOT EXISTS runways ( diff --git a/api/src/account/email_token.rs b/api/src/account/email_token.rs new file mode 100644 index 0000000..7a95de9 --- /dev/null +++ b/api/src/account/email_token.rs @@ -0,0 +1,161 @@ +use crate::account::hash; +use crate::db::redis_async_connection; +use crate::error::{ApiResult, Error}; +use crate::smtp; +use chrono::{Datelike, Utc}; +use redis::{AsyncCommands, RedisResult}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::{env, fs}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct EmailToken { + pub email: String, + pub token: String, + pub ip_address: String, +} + +impl EmailToken { + pub fn new(email: String, token: String, ip_address: &str) -> Self { + Self { + email, + token, + ip_address: hash(&ip_address).unwrap(), + } + } + + pub async fn store(&self, ttl_secs: i64) -> ApiResult<()> { + let mut conn = redis_async_connection().await?; + let key = self.token.clone(); + let value = serde_json::to_string(self)?; + let now = Utc::now(); + let expires_at = now + chrono::Duration::seconds(ttl_secs); + let ttl = expires_at.timestamp() - now.timestamp(); + let result: RedisResult<()> = conn.set_ex(key, &value, ttl as u64).await; + match result { + Ok(_) => Ok(()), + Err(err) => Err(err.into()), + } + } + + pub async fn get(token: &str) -> ApiResult { + let mut conn = redis_async_connection().await?; + let result: RedisResult> = conn.get(token).await; + match result { + Ok(Some(value)) => Ok(serde_json::from_str(&value)?), + Ok(None) => Err(Error::new(404, format!("Missing email token {}", token))), + Err(err) => Err(err.into()), + } + } + + pub async fn delete(token: &str) -> ApiResult<()> { + let mut conn = redis_async_connection().await?; + let result: RedisResult<()> = conn.del(token).await; + match result { + Ok(_) => Ok(()), + Err(err) => Err(err.into()), + } + } +} + +#[derive(Serialize)] +pub struct SimpleEmailCtx { + pub logo_url: String, + pub link: String, + pub domain: String, + pub year: i32, +} + +pub fn send_password_reset_email( + email: &str, + email_token: &EmailToken, + ip_address: &str, +) -> ApiResult<()> { + let base_url = env::var("EXTERNAL_URL")?; + let link = format!("{base_url}/profile/reset?token={}", email_token.token); + let subject = "Reset your password"; + + let plain = format!( + "Hello,\n\n\ + We received a password reset request. Click the link below:\n\n\ + {link}\n\n\ + This link expires in 24 hours. If you didn't request this, please ignore.\n\n\ + Cheers,\n\ + The Aviation Data Team", + link = link + ); + + let ctx = SimpleEmailCtx { + logo_url: format!("{}/logo.svg", base_url), + link: link.clone(), + domain: base_url, + year: Utc::now().year(), + }; + + let template_dir = env::var("TEMPLATE_DIR")?; + let tpl_path = Path::new(&template_dir).join("password_reset.html"); + let template_html = fs::read_to_string(&tpl_path)?; + let html = smtp::registry() + .render_template(&template_html, &ctx) + .unwrap(); + + match smtp::send_email(&email, subject, plain, html) { + Ok(_) => Ok(()), + Err(err) => { + log::error!( + "Invalid password reset attempt [Email: {}] [IP Address: {}]: {}", + email, + ip_address, + err + ); + Err(err.into()) + } + } +} + +pub fn send_confirm_email( + email: &str, + email_token: &EmailToken, + ip_address: &str, +) -> ApiResult<()> { + let base_url = env::var("EXTERNAL_URL")?; + let link = format!("{base_url}/profile/confirm?token={}", email_token.token); + let subject = "Confirm your email address"; + + let plain = format!( + "Hello,\n\n\ + Thanks for registering! Click the link below to confirm your email address:\n\n\ + {link}\n\n\ + If you didn’t sign up for an Aviation Data account, please ignore this.\n\n\ + Cheers,\n\ + The Aviation Data Team", + link = link + ); + + let ctx = SimpleEmailCtx { + logo_url: format!("{}/logo.svg", base_url), + link: link.clone(), + domain: base_url, + year: Utc::now().year(), + }; + + let template_dir = env::var("TEMPLATE_DIR")?; + let tpl_path = Path::new(&template_dir).join("confirm_email.html"); + let template_html = fs::read_to_string(&tpl_path)?; + let html = smtp::registry() + .render_template(&template_html, &ctx) + .unwrap(); + + match smtp::send_email(&email, subject, plain, html) { + Ok(_) => Ok(()), + Err(err) => { + log::error!( + "Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}", + email, + ip_address, + err + ); + Err(err.into()) + } + } +} diff --git a/api/src/account/mod.rs b/api/src/account/mod.rs index f4cebf8..eb4d8cb 100644 --- a/api/src/account/mod.rs +++ b/api/src/account/mod.rs @@ -7,6 +7,7 @@ use rand::prelude::*; use rand_chacha::ChaCha20Rng; mod auth; +mod email_token; mod routes; mod session; diff --git a/api/src/account/routes.rs b/api/src/account/routes.rs index e5fb890..98d5730 100644 --- a/api/src/account/routes.rs +++ b/api/src/account/routes.rs @@ -1,13 +1,16 @@ use crate::{ account::{SESSION_COOKIE_NAME, Session, verify_hash}, error::Error, - smtp, users::{LoginRequest, RegisterRequest, User, UserResponse}, }; use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web}; +use serde::Deserialize; +use std::fs; +use utoipa::ToSchema; use utoipa_actix_web::scope; use utoipa_actix_web::service_config::ServiceConfig; +use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email}; use crate::account::{Auth, csprng}; use crate::users::UpdateUser; @@ -17,8 +20,8 @@ use crate::users::UpdateUser; content = RegisterRequest, content_type = "application/json" ), responses( - (status = 200, description = "", body = UserResponse), - (status = 409, description = ""), + (status = 200, description = "Successful Response", body = UserResponse), + (status = 409, description = "Conflict"), ) )] #[post("/register")] @@ -39,6 +42,17 @@ async fn register(user: web::Json, req: HttpRequest) -> HttpRes email, ip_address ); + + // Send confirmation email + let token = csprng(128); + let email_token = EmailToken::new(email.clone(), token, &ip_address); + if let Err(err) = email_token.store(86400).await { + return ResponseError::error_response(&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) } Err(err) => { @@ -64,7 +78,7 @@ async fn register(user: web::Json, req: HttpRequest) -> HttpRes content = LoginRequest, content_type = "application/json" ), responses( - (status = 200, description = "", body = UserResponse), + (status = 200, description = "Successful Response", body = UserResponse), ), )] #[post("/login")] @@ -118,9 +132,8 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon #[utoipa::path( tag = "Account", responses( - (status = 200, description = ""), - (status = 401, description = ""), - (status = 500, description = ""), + (status = 200, description = "Successful Response"), + (status = 401, description = "Unauthorized"), ), security( ("session_auth" = []) @@ -168,8 +181,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { #[utoipa::path( tag = "Account", responses( - (status = 200, description = "", body = UserResponse), - (status = 401, description = ""), + (status = 200, description = "Successful Response", body = UserResponse), + (status = 401, description = "Unauthorized"), ), security( ("session_auth" = []) @@ -229,17 +242,86 @@ async fn get_profile(req: HttpRequest) -> HttpResponse { } } +#[derive(Debug, Deserialize, ToSchema)] +struct TokenRequest { + token: String, +} + +#[utoipa::path( + 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, 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( - (status = 200, description = ""), - (status = 401, description = ""), + (status = 200, description = "Successful Response"), + (status = 401, description = "Unauthorized"), ), security( ("session_auth" = []) ) )] -#[get("/session")] +#[post("/session")] async fn session_refresh(req: HttpRequest) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); // Verify a session cookie exists @@ -282,14 +364,19 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse { } } +#[derive(Debug, Deserialize, ToSchema)] +struct PasswordRequest { + password: String, +} + #[utoipa::path( tag = "Account", request_body( - content = String, content_type = "application/json" + content = PasswordRequest, content_type = "application/json" ), responses( - (status = 200, description = "", body = UserResponse), - (status = 401, description = ""), + (status = 200, description = "Successful Response", body = UserResponse), + (status = 401, description = "Unauthorized"), ), security( ("session_auth" = []) @@ -297,7 +384,7 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse { )] #[put("/password")] async fn change_password( - password: web::Json, + request: web::Json, req: HttpRequest, auth: Auth, ) -> HttpResponse { @@ -311,7 +398,7 @@ async fn change_password( let update_user = UpdateUser { email: None, email_verified: None, - password: Some(password.into_inner()), + password: Some(request.password.clone()), role: None, first_name: None, last_name: None, @@ -340,35 +427,72 @@ async fn change_password( } } +#[derive(Debug, Deserialize, ToSchema)] +struct EmailRequest { + email: String, +} + #[utoipa::path( tag = "Account", - responses( - (status = 200, description = ""), - (status = 401, description = ""), + request_body( + content = EmailRequest, content_type = "application/json" ), - security( - ("session_auth" = []) + responses( + (status = 200, description = "Successful Response"), ) )] #[post("/password/reset")] -async fn reset_password(req: HttpRequest, auth: Auth) -> HttpResponse { +async fn reset_password(request: web::Json, req: HttpRequest) -> HttpResponse { + let email = &request.email; let ip_address = req.peer_addr().unwrap().ip().to_string(); - let id = auth.user.id; - let email = auth.user.email; let token = csprng(128); - match smtp::send_password_reset(&email, &token) { - Ok(_) => HttpResponse::Ok().finish(), + // Silently return if the user does not exist + if let None = User::select_by_email(&email).await { + return HttpResponse::Ok().finish(); + }; + + let email_token = EmailToken::new(email.clone(), token, &ip_address); + if let Err(err) = email_token.store(86400).await { + return ResponseError::error_response(&err); + } + + if let Err(err) = send_password_reset_email(email, &email_token, &ip_address) { + return ResponseError::error_response(&Error::new(500, err.to_string())); + }; + HttpResponse::Ok().finish() +} + +#[utoipa::path( + tag = "Account", + request_body( + content = TokenRequest, content_type = "application/json" + ), + responses( + (status = 200, description = "Successful Response"), + (status = 404, description = "Not Found"), + ) +)] +#[post("/password/validate")] +async fn validate_reset_password( + request: web::Json, + 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(err) => { - log::error!( - "Invalid password reset attempt [ID: {}] [IP Address: {}]: {}", - &id, - ip_address, - err - ); - ResponseError::error_response(&err) + return HttpResponse::NotFound().json(err); } }; + HttpResponse::Ok().finish() } @@ -379,8 +503,10 @@ pub fn init_routes(config: &mut ServiceConfig) { .service(login) .service(logout) .service(get_profile) + .service(confirm_profile) .service(session_refresh) .service(change_password) - .service(reset_password), + .service(reset_password) + .service(validate_reset_password), ); } diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index 850483c..48fad5e 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -15,6 +15,9 @@ use std::str::FromStr; use utoipa::{IntoParams, ToSchema}; const TABLE_NAME: &str = "airports"; +const DEFAULT_COLUMNS: &str = "icao, iata, local, name, category, iso_country, \ + iso_region, municipality, elevation_ft, longitude, latitude, has_tower, has_beacon,\ + public, metar_observation_time"; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct Airport { @@ -209,10 +212,13 @@ impl Airport { let pool = db::pool(); let airport_fut = async { - sqlx::query_as(&format!("SELECT * FROM {} WHERE icao = $1", TABLE_NAME)) - .bind(icao) - .fetch_optional(pool) - .await + sqlx::query_as(&format!( + "SELECT {} FROM {} WHERE icao = $1", + DEFAULT_COLUMNS, TABLE_NAME + )) + .bind(icao) + .fetch_optional(pool) + .await }; let metar_fut = async { @@ -283,8 +289,8 @@ impl Airport { pub async fn select_all(client: &Client, query: &AirportQuery) -> ApiResult> { let pool = db::pool(); - let mut builder = QueryBuilder::::new("SELECT * FROM "); - builder.push(TABLE_NAME); + let mut builder = + QueryBuilder::::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME)); let mut has_where = false; Self::push_condition_array(&mut builder, &mut has_where, "icao", &query.icaos); @@ -445,15 +451,17 @@ impl Airport { r#" INSERT INTO {} ( icao, iata, local, name, category, iso_country, iso_region, municipality, - elevation_ft, longitude, latitude, has_tower, has_beacon, public + elevation_ft, longitude, latitude, geometry, has_tower, has_beacon, public ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, $10, $11, $12, $13, $14 + $1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, + ST_SetSRID(ST_MakePoint($10, $11), 4326), + $12, $13, $14 ) - RETURNING * + RETURNING {} "#, - TABLE_NAME, + TABLE_NAME, DEFAULT_COLUMNS )) .bind(self.icao.to_string()) .bind(&self.iata) @@ -497,7 +505,7 @@ impl Airport { let mut query_builder: QueryBuilder = QueryBuilder::new( "INSERT INTO airports (icao, iata, local, name, category, \ iso_country, iso_region, municipality, elevation_ft, \ - longitude, latitude, has_tower, has_beacon, public) ", + longitude, latitude, geometry, has_tower, has_beacon, public) ", ); query_builder.push_values(chunk, |mut b, row| { b.push_bind(&row.icao) @@ -511,6 +519,11 @@ impl Airport { .push_bind(row.elevation_ft) .push_bind(row.longitude) .push_bind(row.latitude) + .push_unseparated(", ST_SetSRID(ST_MakePoint(") + .push_bind_unseparated(row.longitude) + .push_unseparated(", ") + .push_bind_unseparated(row.latitude) + .push_unseparated("), 4326)") .push_bind(row.has_tower) .push_bind(row.has_beacon) .push_bind(row.public); @@ -641,15 +654,15 @@ impl Airport { let bounds = Bounds::parse(bounds_string)?; builder .push("(") - .push("latitude BETWEEN ") - .push_bind(bounds.south_west_lat) - .push(" AND ") - .push_bind(bounds.north_east_lat) - .push(" AND ") - .push("longitude BETWEEN ") + .push("geometry && ST_MakeEnvelope(") .push_bind(bounds.south_west_lon) - .push(" AND ") + .push(", ") + .push_bind(bounds.south_west_lat) + .push(", ") .push_bind(bounds.north_east_lon) + .push(", ") + .push_bind(bounds.north_east_lat) + .push(", 4326)") .push(")"); } Ok(()) diff --git a/api/src/main.rs b/api/src/main.rs index 6428d3e..171786d 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -9,7 +9,7 @@ use std::time::Duration; use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; use utoipa::openapi::{Contact, SecurityRequirement}; use utoipa_actix_web::{AppExt, scope}; -use utoipa_swagger_ui::SwaggerUi; +use utoipa_swagger_ui::{Config, SwaggerUi}; use uuid::Uuid; mod account; @@ -111,21 +111,12 @@ async fn main() -> Result<(), Box> { ) .split_for_parts(); - let contact_name = env::var("API_CONTACT_NAME").unwrap(); - let contact_url = env::var("EXTERNAL_URL").unwrap(); - let contact_email = env::var("API_CONTACT_EMAIL").unwrap(); let version = env::var("CARGO_PKG_VERSION").unwrap(); api.info.title = "Aviation Data".to_string(); api.info.description = None; api.info.terms_of_service = None; - api.info.contact = Some( - Contact::builder() - .name(Some(contact_name)) - .url(Some(format!("{}/support", contact_url))) - .email(Some(contact_email)) - .build(), - ); + api.info.contact = None; api.info.license = None; api.info.version = version; diff --git a/api/src/smtp/mod.rs b/api/src/smtp/mod.rs index fb61f80..d7fb31e 100644 --- a/api/src/smtp/mod.rs +++ b/api/src/smtp/mod.rs @@ -6,8 +6,9 @@ use lettre::message::{Mailbox, MultiPart, SinglePart}; use lettre::transport::smtp::authentication::Credentials; use lettre::{Address, Message, SmtpTransport, Transport}; use serde::Serialize; -use std::env; +use std::path::Path; use std::sync::OnceLock; +use std::{env, fs}; static MAILER: OnceLock = OnceLock::new(); static FROM_ADDRESS: OnceLock = OnceLock::new(); @@ -34,46 +35,10 @@ fn from_address() -> &'static Mailbox { }) } -fn registry() -> &'static Handlebars<'static> { +pub fn registry() -> &'static Handlebars<'static> { REGISTRY.get_or_init(|| Handlebars::new()) } -#[derive(Serialize)] -struct PasswordResetCtx { - logo_url: String, - link: String, - domain: String, - year: i32, -} - -pub fn send_password_reset(to: &str, token: &str) -> ApiResult<()> { - let base_url = env::var("EXTERNAL_URL")?.trim_end_matches('/').to_string(); - let link = format!("{base_url}/profile/reset?token={token}"); - let subject = "Reset your password"; - - let plain = format!( - "Hello,\n\n\ - We received a password reset request. Click the link below:\n\n\ - {link}\n\n\ - This link expires in 24 hours. If you didn't request this, please ignore.\n\n\ - Cheers,\n\ - \tAviation Data", - link = link - ); - - let ctx = PasswordResetCtx { - logo_url: format!("{}/logo.svg", base_url), - link: link.clone(), - domain: base_url, - year: Utc::now().year(), - }; - - let template_html = include_str!("../.././templates/password_reset.html"); - let html = registry().render_template(template_html, &ctx).unwrap(); - - send_email(to, subject, plain, html) -} - pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> { let to_address = to.parse::
()?; let to_mailbox = Mailbox::new(None, to_address); diff --git a/docker-compose.yml b/docker-compose.yml index 6afdea8..a5e5746 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,8 +102,10 @@ services: REDIS_PORT: 6379 MINIO_HOST: aviation-minio MINIO_PORT: 9000 + TEMPLATE_DIR: /templates volumes: - ./ssl:/ssl + - ./templates:/templates ports: - "${API_PORT:-5000}:5000" depends_on: diff --git a/templates/confirm_email.html b/templates/confirm_email.html new file mode 100644 index 0000000..44f7df2 --- /dev/null +++ b/templates/confirm_email.html @@ -0,0 +1,53 @@ + + + + + + Confirm your email + + + +
+
+ + +
+ Aviation Data Logo +

Aviation Data

+

Your source for aviation data

+
+ + +
+

Confirm Your Email

+

Thanks for signing up! Please confirm your email address by clicking the button below:

+ +

If you didn’t create an account with us, you can safely ignore this email.

+

Cheers,
The Aviation Data Team

+
+
+ + + +
+ + diff --git a/api/templates/password_reset.html b/templates/password_reset.html similarity index 95% rename from api/templates/password_reset.html rename to templates/password_reset.html index fedf627..d283198 100644 --- a/api/templates/password_reset.html +++ b/templates/password_reset.html @@ -36,7 +36,7 @@

Reset Your Password

We received a request to reset your password. Click the button below to choose a new one:

If you didn’t request this reset, you can safely ignore this email.

Cheers,
The Aviation Data Team

@@ -45,7 +45,7 @@