Refactor to break out scheduler

This commit is contained in:
2025-10-23 20:23:03 -04:00
parent 84312d0b50
commit a9dc5ffdc1
66 changed files with 5796 additions and 705 deletions

4586
crates/api/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
crates/api/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "api"
version = "0.1.3"
edition = "2024"
authors = ["Ben Sherriff <ben@bensherriff.com>"]
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
readme = "../../README.md"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
lib = { path = "../lib" }
actix-web = "4.11.0"
actix-cors = "0.7.1"
actix-multipart = "0.7.2"
chrono = { version = "0.4.42", features = ["serde"] }
dotenv = "0.15.0"
env_logger = "0.11.8"
serde = {version = "1.0.219", features = ["derive"]}
serde_json = "1.0.142"
tokio = { version = "1.47.1", features = ["macros", "rt", "time"] }
log = "0.4.28"
argon2 = "0.5.3"
futures-util = "0.3.31"
utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
utoipa-actix-web = "0.1.2"
lettre = { version = "0.11.18", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
handlebars = "6.3.2"

24
crates/api/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# =========
# Builder
# =========
FROM rust:bookworm AS builder
WORKDIR /builder
COPY crates/lib /lib
COPY crates/api/src ./src
COPY crates/api/Cargo.toml ./
RUN apt-get update && apt-get install -y cmake
RUN cargo build --release
# =========
# Runtime
# =========
FROM debian:bookworm-slim AS runtime
WORKDIR /api
RUN apt-get update && apt-get install -y openssl libpq-dev ca-certificates
USER root
COPY --from=builder /builder/target/release/api /usr/local/bin/api
CMD ["api"]

View File

@@ -0,0 +1,119 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use super::{SESSION_COOKIE_NAME, Session};
use crate::error::{ApiResult, Error};
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http, web};
use serde::{Deserialize, Serialize};
use lib::accounts::User;
use lib::state::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct Auth {
pub session_id: Option<String>,
pub api_key: Option<String>,
pub user: User,
}
impl FromRequest for Auth {
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let state = match req.app_data::<web::Data<AppState>>() {
Some(state) => state,
None => return Box::pin(
async { Err(Error::new(500, "Internal server error".to_string()).into()) },
)
};
// Check for an API key
match req
.headers()
.get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
{
Some(key_id) => {
let state = Arc::clone(&state);
let fut = async move {
// Check if the Session API key exists
let api_key = match Session::get(&state, &key_id).await {
Ok(session) => session,
Err(err) => {
log::error!("Invalid session auth attempt: {}", err);
return Err(Error::new(401, "API Key does not exist".to_string()).into());
}
};
match User::select(&state.pool, &api_key.username).await {
Some(user) => Ok(Auth {
session_id: None,
api_key: Some(key_id),
user,
}),
None => Err(Error::new(404, format!("User {} not found", api_key.username)).into()),
}
};
return Box::pin(fut);
}
None => {}
};
// Check for session
let session_id = match req
.cookie(SESSION_COOKIE_NAME)
.map(|c| c.value().to_string())
.or_else(|| {
req
.headers()
.get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
}) {
Some(id) => id,
None => {
let fut = async {
Err(
Error {
status: 401,
details: "No session ID found in the request".to_string(),
}
.into(),
)
};
return Box::pin(fut);
}
};
// Get IP address from request
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify the session
let state = Arc::clone(&state); // state: Arc<State>
let fut = async move {
match Session::verify(&state, &session_id, &ip_address).await {
Ok(session) => match User::select(&state.pool, &session.username).await {
Some(user) => Ok(Auth {
session_id: Some(session_id),
api_key: None,
user,
}),
None => Err(Error::new(404, format!("User {} not found", session.username)).into()),
},
Err(err) => Err(err.into()),
}
};
Box::pin(fut)
}
}
impl Auth {
pub fn verify_role(&self, role: &str) -> ApiResult<()> {
if self.user.role == role {
Ok(())
} else {
Err(Error {
status: 403,
details: "User does not have permission to perform this action.".to_string(),
})
}
}
}

View File

@@ -0,0 +1,151 @@
use lib::accounts::{csprng, hash};
use crate::error::{ApiResult, Error};
use crate::smtp;
use chrono::{Datelike, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::{env, fs};
use lib::state::AppState;
#[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, state: &AppState, ttl_secs: i64) -> ApiResult<()> {
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 _ = state.set_ex(&key, &value, ttl as u64).await?;
Ok(())
}
pub async fn get(state: &AppState, token: &str) -> ApiResult<Self> {
let result: Option<String> = state.get(token).await?;
match result {
Some(value) => Ok(serde_json::from_str(&value)?),
None => Err(Error::new(404, format!("Missing email token {}", token))),
}
}
pub async fn delete(state: &AppState, token: &str) -> ApiResult<()> {
let _ = state.del(token).await;
Ok(())
}
}
#[derive(Serialize)]
pub struct SimpleEmailCtx {
pub logo_url: String,
pub link: String,
pub domain: String,
pub year: i32,
}
pub async 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).await {
Ok(_) => Ok(()),
Err(err) => {
log::error!(
"Invalid password reset attempt [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
Err(err.into())
}
}
}
pub async fn send_confirm_email(state: &AppState, email: &str, ip_address: &str) -> ApiResult<()> {
let token = csprng(128);
let email_token = EmailToken::new(email.to_string(), token, &ip_address);
email_token.store(state, 86400).await?;
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();
if let Err(err) = smtp::send_email(&email, subject, plain, html).await {
log::error!(
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
let _ = EmailToken::delete(state, &email_token.token);
return Err(err);
}
Ok(())
}

View File

@@ -0,0 +1,7 @@
mod auth;
mod email_token;
mod session;
pub use auth::*;
pub use email_token::*;
pub use session::*;

View File

@@ -0,0 +1,181 @@
use lib::accounts::{csprng, hash, verify_hash};
use crate::error::{ApiResult, Error};
use actix_web::cookie::{Cookie, time::Duration};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use lib::error::CoreResult;
use lib::state::AppState;
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub username: String,
pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl Session {
pub fn default(username: &str, ip_address: &str) -> Self {
Self::new(64, username, ip_address, Some(DEFAULT_SESSION_TTL))
}
pub fn new(take: usize, username: &str, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now();
Self {
session_id: csprng(take),
username: username.to_string(),
ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
None => None,
},
}
}
pub async fn store(&self, state: &AppState) -> ApiResult<()> {
let key = self.session_id.clone();
let value = serde_json::to_string(self)?;
let result: CoreResult<()> = match self.expires_at {
Some(expires_at) => {
let ttl = expires_at.timestamp() - Utc::now().timestamp();
state.set_ex(&key, &value, ttl as u64).await
}
None => state.set(&key, &value).await,
};
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(state: &AppState, session_id: &str) -> ApiResult<Self> {
let result: Option<String> = state.get(session_id).await?;
match result {
Some(value) => Ok(serde_json::from_str(&value)?),
None => Err(Error::new(401, format!("Missing session {}", session_id))),
}
}
pub async fn replace(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
let mut session = Self::verify(state, session_id, ip_address).await?;
let session_id_owned = session_id.to_owned();
Self::delete(state, &session_id_owned).await?;
session = Session::default(&session.username, ip_address);
session.store(state).await?;
Ok(session)
}
pub async fn delete(state: &AppState, session_id: &str) -> ApiResult<()> {
let result: CoreResult<()> = state.del(session_id).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn verify(state: &AppState, session_id: &str, ip_address: &str) -> ApiResult<Self> {
let session = Self::get(state, session_id).await?;
// Check if the IP Address matches the Session's IP Address
if verify_hash(ip_address, &session.ip_address) {
Ok(session)
} else {
Err(Error::new(401, "IP Address does not match".to_string()))
}
}
pub fn cookie(&self) -> Cookie<'_> {
let expires_at = match self.expires_at {
Some(expires_at) => expires_at.timestamp(),
None => DEFAULT_SESSION_TTL,
};
let ttl = expires_at - Utc::now().timestamp();
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, self.session_id.clone())
.path("/")
.max_age(Duration::seconds(ttl))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session cookie [User: {}]: {}",
self.username,
self.session_id
);
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
pub fn expiration_cookie(&self) -> Cookie<'_> {
let expires_at = match self.expires_at {
Some(expires_at) => expires_at.timestamp(),
None => DEFAULT_SESSION_TTL,
};
let ttl = expires_at - Utc::now().timestamp();
let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, expires_at.to_string())
.path("/")
.max_age(Duration::seconds(ttl))
.secure(true)
.http_only(false)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session expiration cookie [User: {}]: {}",
self.username,
self.session_id
);
cookie.set_secure(false);
}
}
cookie
}
pub fn empty_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
pub fn empty_expiration_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(false)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
cookie.set_secure(false);
}
}
cookie
}
}

133
crates/api/src/error.rs Normal file
View File

@@ -0,0 +1,133 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use log::warn;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
use lib::error::{CoreError, CoreErrorKind};
pub type ApiResult<T> = Result<T, Error>;
#[derive(Debug, Deserialize, Serialize)]
pub struct Error {
pub status: u16,
pub details: String,
}
impl Error {
pub fn new(status: u16, details: String) -> Self {
Self {
status,
details,
}
}
pub fn to_http_response(&self) -> HttpResponse {
let status = StatusCode::from_u16(self.status).unwrap_or_else(|err| {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
});
HttpResponse::build(status).body(self.details.to_string())
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.details.as_str())
}
}
impl std::error::Error for Error {
fn description(&self) -> &str {
&self.details
}
}
impl ResponseError for Error {
fn error_response(&self) -> HttpResponse {
let status =
StatusCode::from_u16(self.status).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
let status_code = status.as_u16();
let details = match status_code {
401 => String::from("Unauthorized"),
code if code < 500 => self.details.clone(),
_ => {
log::error!("Internal server error: {}", self.details);
String::from("Internal Server Error")
}
};
HttpResponse::build(status).json(json!({ "status": status_code, "details": details }))
}
}
impl From<std::io::Error> for Error {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("Unknown IO error: {:?}", error))
}
}
impl From<std::env::VarError> for Error {
fn from(error: std::env::VarError) -> Self {
Self::new(
500,
format!("Unknown environment variable error: {:?}", error),
)
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("Unknown serde_json error: {:?}", error))
}
}
impl From<argon2::password_hash::Error> for Error {
fn from(error: argon2::password_hash::Error) -> Self {
Self::new(500, format!("Unknown argon2 error: {:?}", error))
}
}
impl From<lettre::address::AddressError> for Error {
fn from(error: lettre::address::AddressError) -> Self {
Error::new(500, error.to_string())
}
}
impl From<lettre::error::Error> for Error {
fn from(error: lettre::error::Error) -> Self {
Error::new(500, error.to_string())
}
}
impl From<lettre::transport::smtp::Error> for Error {
fn from(error: lettre::transport::smtp::Error) -> Self {
Error::new(500, error.to_string())
}
}
impl From<String> for Error {
fn from(error: String) -> Self {
Self::new(500, error)
}
}
impl From<CoreError> for Error {
fn from(error: CoreError) -> Self {
match error.kind {
CoreErrorKind::NotFound => Self::new(404, error.to_string()),
CoreErrorKind::InvalidInput => Self::new(400, error.to_string()),
CoreErrorKind::Conflict => Self::new(409, error.to_string()),
CoreErrorKind::Unauthorized => Self::new(401, error.to_string()),
CoreErrorKind::Forbidden => Self::new(403, error.to_string()),
CoreErrorKind::PreconditionFailed => Self::new(412, error.to_string()),
CoreErrorKind::Timeout => Self::new(408, error.to_string()),
CoreErrorKind::Cancelled => Self::new(499, error.to_string()),
CoreErrorKind::Unavailable => Self::new(503, error.to_string()),
CoreErrorKind::Internal => Self::new(500, error.to_string()),
CoreErrorKind::External => Self::new(502, error.to_string()),
_ => Self::new(500, error.to_string()),
}
}
}

152
crates/api/src/main.rs Normal file
View File

@@ -0,0 +1,152 @@
use lib::accounts::{User, ADMIN_ROLE, hash};
use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename;
use std::env;
use utoipa::openapi::SecurityRequirement;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa_actix_web::{AppExt, scope};
use utoipa_swagger_ui::{Config, SwaggerUi};
use lib::state::AppState;
mod accounts;
mod error;
mod routes;
mod smtp;
mod system;
mod utils;
#[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
initialize_environment()?;
let state = AppState::new().await?;
// Initialize admin user
let admin_username = env::var("ADMIN_USERNAME");
let admin_email = env::var("ADMIN_EMAIL");
let admin_password = env::var("ADMIN_PASSWORD");
if admin_username.is_ok() && admin_email.is_ok() && admin_password.is_ok() {
let username = admin_username.unwrap();
let email = admin_email.unwrap();
if User::select_by_email(&state.pool, &email).await.is_none() {
log::debug!("Creating default administrator");
let password = admin_password.unwrap();
let password_hash = hash(&password)?;
if email == "admin@example.com" || password == "changeme" {
log::warn!(
"Default admin credentials are in use, update the ADMIN_USERNAME, ADMIN_EMAIL, and ADMIN_PASSWORD."
);
}
let admin_user = User {
username,
email: Some(email),
email_verified: true,
password_hash,
role: ADMIN_ROLE.to_string(),
first_name: "Admin".to_string(),
last_name: "".to_string(),
avatar: None,
updated_at: Default::default(),
created_at: Default::default(),
};
match admin_user.insert(&state.pool).await {
Ok(_) => log::debug!("Default administrator was successfully created"),
Err(err) => {
log::warn!("{}", err);
}
};
}
}
let host = "0.0.0.0";
let port = env::var("API_PORT").unwrap_or("5000".to_string());
let server = match HttpServer::new(move || {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600);
let (app, mut api) = App::new()
.wrap(cors)
.wrap(Logger::default())
.app_data(web::Data::new(state.clone()))
.into_utoipa_app()
.service(
scope::scope("/api")
.configure(routes::init_routes)
.configure(system::init_routes),
)
.split_for_parts();
let version = env!("CARGO_PKG_VERSION");
api.info.title = "Aviation Data".to_string();
api.info.description = Some("This documentation describe the Aviation Data API".to_string());
api.info.terms_of_service = None;
api.info.contact = None;
api.info.license = None;
api.info.version = version.to_string();
let session_scheme = SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session")));
let mut components = api.components.take().unwrap_or_default();
components
.security_schemes
.insert("session_auth".to_string(), session_scheme);
api.components = Some(components);
api.security = Some(vec![SecurityRequirement::default()]);
app.service(
SwaggerUi::new("/swagger/{_:.*}")
.url("/api-docs/openapi.json", api)
.config(Config::default().use_base_layout()),
)
})
.bind(format!("{}:{}", host, port))
{
Ok(b) => {
log::info!("Server bound to {}:{}", host, port);
b
}
Err(err) => {
log::error!("Could not bind server: {}", err);
return Err(err.into());
}
};
if let Err(err) = server.run().await {
return Err(err.into());
}
Ok(())
}
fn initialize_environment() -> std::io::Result<()> {
fn init_dir(directory: &str) -> std::io::Result<()> {
// Iterate over files in the current directory
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let path = entry.path();
// Check if the file name starts with ".env" and is a file
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.starts_with(".env") && path.is_file() {
// Try to load the file
if let Err(err) = from_filename(&file_name) {
eprintln!("Failed to load {}: {}", file_name, err);
} else {
println!("Loaded: {}", file_name);
}
}
}
}
Ok(())
}
init_dir("..")?;
init_dir(".")?;
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,api=info"));
Ok(())
}

View File

@@ -0,0 +1,622 @@
use lib::accounts::{csprng, LoginRequest, RegisterRequest, UpdateUser, User, UserResponse, UserFavorite, verify_hash};
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use serde::Deserialize;
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use lib::error::CoreErrorKind;
use lib::state::AppState;
use crate::accounts::{send_confirm_email, send_password_reset_email, Auth, EmailToken, Session, SESSION_COOKIE_NAME};
use crate::error::Error;
#[utoipa::path(
tag = "account",
request_body(
content = RegisterRequest, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 409, description = "Conflict"),
)
)]
#[post("/register")]
async fn register(state: web::Data<AppState>, user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner();
let username = register_user.username.clone();
let email = register_user.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string();
let insert_user: User = match register_user.to_user().map_err(Error::from) {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
match insert_user.insert(&state.pool).await.map_err(Error::from) {
Ok(user) => {
let user_response: UserResponse = user.into();
log::info!(
"Successful user registration [User: {}] [IP Address: {}]",
username,
ip_address
);
// Send confirmation email
if let Some(email) = email {
if !email.is_empty() {
tokio::task::spawn_local(async move {
if let Err(err) = send_confirm_email(&state, &email, &ip_address).await {
log::error!("Failed to send confirmation email: {}", err);
};
});
}
}
HttpResponse::Created().json(user_response)
}
Err(err) => {
// Obfuscate the service error message to prevent leaking database details
if err.status == 409 {
log::warn!(
"Duplicate user registration attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Conflict().finish()
} else {
log::error!("Failed to register user [User: {}]: {}", username, err);
ResponseError::error_response(&err)
}
}
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct ConfirmEmail {
token: String,
}
#[utoipa::path(
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(
state: web::Data<AppState>,
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(&state, token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&state, &password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(_) => {
return HttpResponse::NotFound().finish();
}
};
match User::select_by_email(&state.pool, &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(&state.pool, &user.username).await.map_err(Error::from) {
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(state: web::Data<AppState>, 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(&state.pool, &email).await {
Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(),
};
// Cannot reverify if the user is already verified
if user.email_verified {
return HttpResponse::Conflict().finish();
}
// Send reverify confirmation email
if let Err(err) = send_confirm_email(&state, &email, &ip_address).await {
log::error!("Failed to send reverify confirmation email: {}", err);
return HttpResponse::InternalServerError().finish();
};
HttpResponse::Ok().finish()
}
None => HttpResponse::NotFound().finish(),
}
}
#[utoipa::path(
tag = "account",
request_body(
content = LoginRequest, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
),
)]
#[post("/login")]
async fn login(state: web::Data<AppState>, request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let username = &request.username;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match User::select(&state.pool, &username).await {
Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(),
};
if verify_hash(&request.password, &query_user.password_hash) {
// Create a session
let session = Session::default(&query_user.username, &ip_address);
let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
// Save the session to the database
if let Err(err) = session.store(&state).await {
log::error!(
"Login attempt failure [User: {}] [IP Address: {}]: {}",
username,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
log::info!(
"Successful login attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
let user_response: UserResponse = query_user.into();
HttpResponse::Ok()
.cookie(session_cookie)
.cookie(session_exp_cookie)
.json(user_response)
} else {
log::error!(
"Invalid login attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish()
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[post("/logout")]
async fn logout(state: web::Data<AppState>, req: HttpRequest, auth: Auth) -> HttpResponse {
let username = auth.user.username;
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Delete the session from the store
match req.cookie(SESSION_COOKIE_NAME) {
Some(cookie) => {
let session_id = cookie.value().to_string();
if let Err(err) = Session::delete(&state, &session_id).await {
log::error!(
"Logout attempt failure [User: {}] [IP Address: {}]: {}",
username,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
}
None => {
log::error!(
"Invalid logout attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
}
}
log::info!(
"Successful logout attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Ok()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish()
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[get("/profile")]
async fn get_profile(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) {
// Validate the session
Some(cookie) => {
let session_id = cookie.value().to_string();
let session = match Session::get(&state, &session_id).await {
Ok(session) => session,
Err(_) => {
log::error!(
"Invalid profile attempt [Session: {}] [IP Address: {}]",
session_id,
ip_address
);
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish();
}
};
let username = &session.username;
let query_user = match User::select(&state.pool, &username).await {
Some(query_user) => query_user,
None => {
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish();
}
};
let user_response: UserResponse = query_user.into();
let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
log::info!(
"Successful profile attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Ok()
.cookie(session_cookie)
.cookie(session_exp_cookie)
.json(user_response)
}
None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish(),
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[post("/session")]
async fn session_refresh(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) {
// Validate the session
Some(cookie) => {
let session_id = cookie.value().to_string();
let session = match Session::replace(&state, &session_id, &ip_address).await {
Ok(session) => session,
Err(_) => {
log::error!(
"Invalid session validate attempt [Session: {}] [IP Address: {}]",
session_id,
ip_address
);
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish();
}
};
let username = &session.username;
let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
log::info!(
"Successful session validate attempt [User: {}] [IP Address: {}]",
username,
ip_address
);
HttpResponse::Ok()
.cookie(session_cookie)
.cookie(session_exp_cookie)
.finish()
}
None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.cookie(Session::empty_expiration_cookie())
.finish(),
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct ChangePassword {
password: String,
}
#[utoipa::path(
tag = "account",
request_body(
content = ChangePassword, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[put("/password")]
async fn change_password(
state: web::Data<AppState>,
request: web::Json<ChangePassword>,
req: HttpRequest,
auth: Auth,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let username = auth.user.username;
if let None = User::select(&state.pool, &username).await {
return HttpResponse::Unauthorized().finish();
};
let update_user = UpdateUser {
email: None,
email_verified: None,
password: Some(request.password.clone()),
role: None,
first_name: None,
last_name: None,
avatar: None,
};
match update_user.update(&state.pool, &username).await.map_err(Error::from) {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful password change attempt [User: {}] [IP Address: {}]",
&username,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid password change attempt [User: {}] [IP Address: {}]: {}",
&username,
ip_address,
err
);
ResponseError::error_response(&err)
}
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct PasswordReset {
email: String,
}
#[utoipa::path(
tag = "account",
request_body(
content = PasswordReset, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response"),
)
)]
#[post("/password/reset")]
async fn reset_password(state: web::Data<AppState>, request: web::Json<PasswordReset>, req: HttpRequest) -> HttpResponse {
let email = &request.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let token = csprng(128);
// Silently return if the user's email does not exist
if let None = User::select_by_email(&state.pool, &email).await {
return HttpResponse::Ok().finish();
};
let email_token = EmailToken::new(email.clone(), token, &ip_address);
if let Err(err) = email_token.store(&state, 86400).await {
return ResponseError::error_response(&err);
}
if let Err(err) = send_password_reset_email(email, &email_token, &ip_address).await {
return ResponseError::error_response(&Error::new(500, err.to_string()));
};
HttpResponse::Ok().finish()
}
#[derive(Debug, Deserialize, ToSchema)]
struct ConfirmPasswordReset {
token: String,
password: String,
}
#[utoipa::path(
tag = "account",
request_body(
content = ConfirmPasswordReset, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response"),
(status = 404, description = "Not Found"),
)
)]
#[post("/password/reset/confirm")]
async fn confirm_password_reset(
state: web::Data<AppState>,
request: web::Json<ConfirmPasswordReset>,
req: HttpRequest,
) -> HttpResponse {
// TODO
let _ip_address = req.peer_addr().unwrap().ip().to_string();
let token = &request.token;
let _email_token = match EmailToken::get(&state, token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&state, &password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(err) => {
return HttpResponse::NotFound().json(err);
}
};
HttpResponse::Ok().finish()
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[get("/profile/favorites")]
async fn get_favorites(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
let username = auth.user.username;
match UserFavorite::select_all(&state.pool, &username).await.map_err(Error::from) {
Ok(favorites) => HttpResponse::Ok().json(favorites),
Err(err) => ResponseError::error_response(&err),
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[post("/profile/favorites/{icao}")]
async fn add_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let username = auth.user.username;
match UserFavorite::insert(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err),
}
}
#[utoipa::path(
tag = "account",
responses(
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[delete("/profile/favorites/{icao}")]
async fn remove_favorite(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let username = auth.user.username;
match UserFavorite::delete(&state.pool, &username, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err),
}
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/account")
.service(register)
.service(confirm_email_registration)
.service(resend_email_verification)
.service(login)
.service(logout)
.service(get_profile)
.service(session_refresh)
.service(change_password)
.service(reset_password)
.service(confirm_password_reset)
.service(get_favorites)
.service(add_favorite)
.service(remove_favorite),
);
}

View File

@@ -0,0 +1,258 @@
use futures_util::stream::StreamExt as _;
use lib::{accounts::ADMIN_ROLE, airports::{Airport, AirportQuery, UpdateAirport}};
use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use lib::state::AppState;
use crate::accounts::Auth;
use crate::error::Error;
use crate::utils::Paged;
#[derive(ToSchema)]
#[allow(unused)]
struct FileUpload {
#[schema(value_type = String, format = Binary)]
file: Vec<u8>,
}
#[utoipa::path(
tag = "airport",
request_body(
content = FileUpload, content_type = "multipart/form-data"
),
responses(
(status = 200, description = "Successful import"),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/import")]
async fn import_airports(state: web::Data<AppState>, mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = &auth.verify_role(ADMIN_ROLE).map_err(Error::from) {
return ResponseError::error_response(err);
};
while let Some(item) = payload.next().await {
let mut bytes = web::BytesMut::new();
let mut field = match item {
Ok(field) => field,
Err(err) => return ResponseError::error_response(&err),
};
// Build bytes from chunks
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(data) => data,
Err(err) => {
log::error!("Failed to get chunk: {}", err);
return ResponseError::error_response(&err);
}
};
bytes.extend_from_slice(&data);
}
// Convert bytes to Vec<Airport>
let airports: Vec<Airport> = match serde_json::from_slice(&bytes) {
Ok(a) => a,
Err(err) => {
log::error!("Failed to parse JSON: {}", err);
return ResponseError::error_response(&err);
}
};
match Airport::insert_all(&state.pool, airports).await.map_err(Error::from) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
}
HttpResponse::Ok().finish()
}
#[utoipa::path(
tag = "airport",
params(
AirportQuery
),
responses(
(status = 200, description = "", body = [Airport]),
),
)]
#[get("")]
async fn get_airports(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.into_inner(),
Err(err) => {
log::error!("{}", err);
AirportQuery::default()
}
};
let total = Airport::count(&state.pool, &query).await;
let page = query.page.unwrap_or(1);
let mut limit = query.limit.unwrap_or(total as u32);
if limit > 1000 {
limit = 1000
}
query.limit = Some(limit);
query.page = Some(page);
match Airport::select_all(&state.pool, &query).await.map_err(Error::from) {
Ok(airports) => HttpResponse::Ok().json(Paged {
data: airports,
page,
limit,
total,
}),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 404, description = ""),
),
)]
#[get("/{icao}")]
async fn get_airport(state: web::Data<AppState>, icao: web::Path<String>, req: HttpRequest) -> HttpResponse {
let metar = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(q) => q.metars.unwrap_or_else(|| false),
Err(err) => {
log::error!("{}", err);
false
}
};
match Airport::select(&state.pool, &icao.into_inner(), metar).await {
Some(airport) => HttpResponse::Ok().json(airport),
None => HttpResponse::NotFound().finish(),
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
(status = 409, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("")]
async fn insert_airport(state: web::Data<AppState>, airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match airport.insert(&state.pool).await.map_err(Error::from) {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[put("/{icao}")]
async fn update_airport(
state: web::Data<AppState>,
icao: web::Path<String>,
airport: web::Json<UpdateAirport>,
auth: Auth,
) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match Airport::update(&state.pool, &icao.into_inner(), &airport.into_inner()).await.map_err(Error::from) {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("")]
async fn delete_airports(state: web::Data<AppState>, auth: Auth) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match Airport::delete_all(&state.pool).await.map_err(Error::from) {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
#[utoipa::path(
tag = "airport",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("/{icao}")]
async fn delete_airport(state: web::Data<AppState>, icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match &auth.verify_role(ADMIN_ROLE) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(err),
};
match Airport::delete(&state.pool, &icao.into_inner()).await.map_err(Error::from) {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {
log::error!("{}", err);
ResponseError::error_response(&err)
}
}
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/airports")
.service(import_airports)
.service(get_airports)
.service(get_airport)
.service(insert_airport)
.service(update_airport)
.service(delete_airports)
.service(delete_airport),
);
}

View File

@@ -0,0 +1,94 @@
use crate::AppState;
use actix_web::{HttpRequest, HttpResponse, get, put, web};
use log::error;
use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use lib::metars::Metar;
use crate::accounts::Auth;
use crate::error::Error;
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
struct MetarQuery {
icaos: Option<String>,
}
#[utoipa::path(
tag = "metar",
params(
MetarQuery,
),
responses(
(status = 200, description = "Successful Response", body = [Metar]),
),
)]
#[get("")]
async fn find_all(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
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_all_distinct(&state.pool, &icaos).await.map_err(Error::from) {
Ok(a) => a,
Err(err) => {
error!("{}", err);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metars)
}
#[utoipa::path(
tag = "metar",
params(
MetarQuery,
),
responses(
(status = 200, description = "Successful Response", body = [Metar]),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[put("")]
async fn refresh_metars(state: web::Data<AppState>, req: HttpRequest, _auth: Auth) -> HttpResponse {
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(&state, &icaos).await.map_err(Error::from) {
Ok(a) => a,
Err(err) => {
error!("{}", err);
return err.to_http_response();
}
};
HttpResponse::Ok().json(metars)
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
scope::scope("/metars")
.service(find_all)
.service(refresh_metars),
);
}

View File

@@ -0,0 +1,12 @@
use utoipa_actix_web::service_config::ServiceConfig;
mod accounts;
mod airports;
mod metars;
pub fn init_routes(config: &mut ServiceConfig) {
config
.configure(accounts::init_routes)
.configure(airports::init_routes)
.configure(metars::init_routes);
}

View File

@@ -0,0 +1,76 @@
use crate::error::ApiResult;
use handlebars::Handlebars;
use lettre::message::header::ContentType;
use lettre::message::{Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::AsyncSmtpTransportBuilder;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use std::env;
use std::sync::OnceLock;
use std::time::Duration;
static MAILER: OnceLock<AsyncSmtpTransport<Tokio1Executor>> = OnceLock::new();
static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new();
static REGISTRY: OnceLock<Handlebars> = OnceLock::new();
fn mailer() -> &'static AsyncSmtpTransport<Tokio1Executor> {
MAILER.get_or_init(|| {
let server = env::var("SMTP_SERVER").expect("SMTP_SERVER missing");
let username = env::var("SMTP_USERNAME").expect("SMTP_USERNAME 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 builder: AsyncSmtpTransportBuilder;
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)
.port(port.parse().expect("SMTP_PORT invalid"))
.timeout(Some(Duration::from_secs(10)))
.build()
})
}
fn from_address() -> &'static Mailbox {
FROM_ADDRESS.get_or_init(|| {
let raw = env::var("SMTP_FROM").expect("SMTP_FROM missing");
let addr = raw.parse().expect("SMTP_FROM invalid");
Mailbox::new(Some("Aviation Data".into()), addr)
})
}
pub fn registry() -> &'static Handlebars<'static> {
REGISTRY.get_or_init(|| Handlebars::new())
}
pub async 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);
// Build the email
let email = Message::builder()
.from(from_address().clone())
.to(to_mailbox)
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(header),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html),
),
)?;
// Send the email
mailer().send(email).await?;
Ok(())
}

View File

@@ -0,0 +1,34 @@
use actix_web::{HttpResponse, get};
use serde::{Deserialize, Serialize};
use std::env;
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SystemInfo {
version: String,
healthy: bool,
}
#[utoipa::path(
tag = "system",
responses(
(status = 200, description = "Successful system info"),
)
)]
#[get("/info")]
async fn info() -> HttpResponse {
let healthy = true;
let version = env!("CARGO_PKG_VERSION");
let info = SystemInfo {
version: version.to_string(),
healthy,
};
HttpResponse::Ok().json(info)
}
pub fn init_routes(config: &mut ServiceConfig) {
config.service(scope::scope("/system").service(info));
}

9
crates/api/src/utils.rs Normal file
View File

@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Paged<T> {
pub data: Vec<T>,
pub page: u32,
pub limit: u32,
pub total: i64,
}