Working on emails, updated swagger, added geometry column to airports

This commit is contained in:
2025-05-15 09:16:22 -04:00
parent e46e8ab9b4
commit 3674623691
13 changed files with 449 additions and 135 deletions

4
.env
View File

@@ -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

47
api/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"] }

View File

@@ -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 (

View File

@@ -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<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = 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 didnt 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())
}
}
}

View File

@@ -7,6 +7,7 @@ use rand::prelude::*;
use rand_chacha::ChaCha20Rng;
mod auth;
mod email_token;
mod routes;
mod session;

View File

@@ -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<RegisterRequest>, 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<RegisterRequest>, 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<LoginRequest>, 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<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(
(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<String>,
request: web::Json<PasswordRequest>,
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<EmailRequest>, 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<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(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),
);
}

View File

@@ -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,7 +212,10 @@ impl Airport {
let pool = db::pool();
let airport_fut = async {
sqlx::query_as(&format!("SELECT * FROM {} WHERE icao = $1", TABLE_NAME))
sqlx::query_as(&format!(
"SELECT {} FROM {} WHERE icao = $1",
DEFAULT_COLUMNS, TABLE_NAME
))
.bind(icao)
.fetch_optional(pool)
.await
@@ -283,8 +289,8 @@ impl Airport {
pub async fn select_all(client: &Client, query: &AirportQuery) -> ApiResult<Vec<Self>> {
let pool = db::pool();
let mut builder = QueryBuilder::<Postgres>::new("SELECT * FROM ");
builder.push(TABLE_NAME);
let mut builder =
QueryBuilder::<Postgres>::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<Postgres> = QueryBuilder::new(
"INSERT INTO airports (icao, iata, local, name, category, \
iso_country, iso_region, municipality, elevation_ft, \
longitude, latitude, has_tower, has_beacon, public) ",
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(())

View File

@@ -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<dyn std::error::Error>> {
)
.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;

View File

@@ -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<SmtpTransport> = OnceLock::new();
static FROM_ADDRESS: OnceLock<Mailbox> = 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::<Address>()?;
let to_mailbox = Mailbox::new(None, to_address);

View File

@@ -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:

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Confirm your email</title>
<style>
body { margin:0; padding:0; background:#f2f2f2; font-family:Helvetica,Arial,sans-serif; }
.wrapper { width:100%; table-layout:fixed; background:#f2f2f2; padding:40px 0; }
.main { background:#ffffff; width:600px; margin:0 auto; border-radius:6px; overflow:hidden; }
.header { background:#fff; text-align:center; padding:30px; }
.header img { width:60px; height:auto; display:block; margin:0 auto 10px; }
.header h1 { margin:0; font-size:24px; color:#333333; }
.header p { margin:5px 0 0; font-size:14px; color:#777777; }
.content { padding:30px; color:#333333; font-size:16px; line-height:1.5; }
.content h2 { margin-top:0; font-size:20px; }
.btn-wrap { text-align:center; margin:30px 0; }
.btn { background:#007bff; color:#ffffff !important; text-decoration:none; padding:12px 24px; border-radius:4px; display:inline-block; font-size:16px; }
.footer { text-align:center; padding:20px; font-size:12px; color:#999999; }
.footer a { color:#999999; text-decoration:none; }
</style>
</head>
<body>
<div class="wrapper">
<div class="main">
<!-- header -->
<div class="header">
<img src="{{logo_url}}" alt="Aviation Data Logo" />
<h1>Aviation Data</h1>
<p>Your source for aviation data</p>
</div>
<!-- body -->
<div class="content">
<h2>Confirm Your Email</h2>
<p>Thanks for signing up! Please confirm your email address by clicking the button below:</p>
<div class="btn-wrap">
<a href="{{{link}}}" class="btn">Confirm my email</a>
</div>
<p>If you didnt create an account with us, you can safely ignore this email.</p>
<p>Cheers,<br/>The Aviation Data Team</p>
</div>
</div>
<!-- footer -->
<div class="footer">
Serving the Aviation Community<br/>
<a href="{{domain}}">{{domain}}</a> &nbsp;|&nbsp; © {{year}} Aviation Data
</div>
</div>
</body>
</html>

View File

@@ -36,7 +36,7 @@
<h2>Reset Your Password</h2>
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
<div class="btn-wrap">
<a href="{{link}}" class="btn">Reset my password</a>
<a href="{{{link}}}" class="btn">Reset my password</a>
</div>
<p>If you didnt request this reset, you can safely ignore this email.</p>
<p>Cheers,<br/>The Aviation Data Team</p>
@@ -45,7 +45,7 @@
<!-- footer -->
<div class="footer">
Serving the Enthusiast Community<br/>
Serving the Aviation Community<br/>
<a href="{{domain}}">{{domain}}</a> &nbsp;|&nbsp; © {{year}} Aviation Data
</div>
</div>