Refactor to break out scheduler
This commit is contained in:
4586
crates/api/Cargo.lock
generated
Normal file
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
29
crates/api/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
authors = ["Ben Sherriff <ben@bensherriff.com>"]
|
||||
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
|
||||
readme = "../../README.md"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
lib = { path = "../lib" }
|
||||
actix-web = "4.11.0"
|
||||
actix-cors = "0.7.1"
|
||||
actix-multipart = "0.7.2"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
dotenv = "0.15.0"
|
||||
env_logger = "0.11.8"
|
||||
serde = {version = "1.0.219", features = ["derive"]}
|
||||
serde_json = "1.0.142"
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt", "time"] }
|
||||
log = "0.4.28"
|
||||
argon2 = "0.5.3"
|
||||
futures-util = "0.3.31"
|
||||
utoipa = { version = "5.4.0", features = ["chrono", "uuid", "actix_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
|
||||
utoipa-actix-web = "0.1.2"
|
||||
lettre = { version = "0.11.18", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
|
||||
handlebars = "6.3.2"
|
||||
24
crates/api/Dockerfile
Normal file
24
crates/api/Dockerfile
Normal 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"]
|
||||
119
crates/api/src/accounts/auth.rs
Normal file
119
crates/api/src/accounts/auth.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
151
crates/api/src/accounts/email_token.rs
Normal file
151
crates/api/src/accounts/email_token.rs
Normal 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 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();
|
||||
|
||||
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(())
|
||||
}
|
||||
7
crates/api/src/accounts/mod.rs
Normal file
7
crates/api/src/accounts/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod auth;
|
||||
mod email_token;
|
||||
mod session;
|
||||
|
||||
pub use auth::*;
|
||||
pub use email_token::*;
|
||||
pub use session::*;
|
||||
181
crates/api/src/accounts/session.rs
Normal file
181
crates/api/src/accounts/session.rs
Normal 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
133
crates/api/src/error.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::fmt;
|
||||
use lib::error::{CoreError, CoreErrorKind};
|
||||
|
||||
pub type ApiResult<T> = Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Error {
|
||||
pub status: u16,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn new(status: u16, details: String) -> Self {
|
||||
Self {
|
||||
status,
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_http_response(&self) -> HttpResponse {
|
||||
let status = StatusCode::from_u16(self.status).unwrap_or_else(|err| {
|
||||
warn!("{}", err);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
});
|
||||
HttpResponse::build(status).body(self.details.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str(self.details.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn description(&self) -> &str {
|
||||
&self.details
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for Error {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let status =
|
||||
StatusCode::from_u16(self.status).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let status_code = status.as_u16();
|
||||
let details = match status_code {
|
||||
401 => String::from("Unauthorized"),
|
||||
code if code < 500 => self.details.clone(),
|
||||
_ => {
|
||||
log::error!("Internal server error: {}", self.details);
|
||||
String::from("Internal Server Error")
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponse::build(status).json(json!({ "status": status_code, "details": details }))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::new(500, format!("Unknown IO error: {:?}", error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::env::VarError> for Error {
|
||||
fn from(error: std::env::VarError) -> Self {
|
||||
Self::new(
|
||||
500,
|
||||
format!("Unknown environment variable error: {:?}", error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::new(500, format!("Unknown serde_json error: {:?}", error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<argon2::password_hash::Error> for Error {
|
||||
fn from(error: argon2::password_hash::Error) -> Self {
|
||||
Self::new(500, format!("Unknown argon2 error: {:?}", error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lettre::address::AddressError> for Error {
|
||||
fn from(error: lettre::address::AddressError) -> Self {
|
||||
Error::new(500, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lettre::error::Error> for Error {
|
||||
fn from(error: lettre::error::Error) -> Self {
|
||||
Error::new(500, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lettre::transport::smtp::Error> for Error {
|
||||
fn from(error: lettre::transport::smtp::Error) -> Self {
|
||||
Error::new(500, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Error {
|
||||
fn from(error: String) -> Self {
|
||||
Self::new(500, error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreError> for Error {
|
||||
fn from(error: CoreError) -> Self {
|
||||
match error.kind {
|
||||
CoreErrorKind::NotFound => Self::new(404, error.to_string()),
|
||||
CoreErrorKind::InvalidInput => Self::new(400, error.to_string()),
|
||||
CoreErrorKind::Conflict => Self::new(409, error.to_string()),
|
||||
CoreErrorKind::Unauthorized => Self::new(401, error.to_string()),
|
||||
CoreErrorKind::Forbidden => Self::new(403, error.to_string()),
|
||||
CoreErrorKind::PreconditionFailed => Self::new(412, error.to_string()),
|
||||
CoreErrorKind::Timeout => Self::new(408, error.to_string()),
|
||||
CoreErrorKind::Cancelled => Self::new(499, error.to_string()),
|
||||
CoreErrorKind::Unavailable => Self::new(503, error.to_string()),
|
||||
CoreErrorKind::Internal => Self::new(500, error.to_string()),
|
||||
CoreErrorKind::External => Self::new(502, error.to_string()),
|
||||
_ => Self::new(500, error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
152
crates/api/src/main.rs
Normal file
152
crates/api/src/main.rs
Normal 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(())
|
||||
}
|
||||
622
crates/api/src/routes/accounts.rs
Normal file
622
crates/api/src/routes/accounts.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
258
crates/api/src/routes/airports.rs
Normal file
258
crates/api/src/routes/airports.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
94
crates/api/src/routes/metars.rs
Normal file
94
crates/api/src/routes/metars.rs
Normal 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 = ¶meters.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 = ¶meters.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),
|
||||
);
|
||||
}
|
||||
12
crates/api/src/routes/mod.rs
Normal file
12
crates/api/src/routes/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use utoipa_actix_web::service_config::ServiceConfig;
|
||||
|
||||
mod accounts;
|
||||
mod airports;
|
||||
mod metars;
|
||||
|
||||
pub fn init_routes(config: &mut ServiceConfig) {
|
||||
config
|
||||
.configure(accounts::init_routes)
|
||||
.configure(airports::init_routes)
|
||||
.configure(metars::init_routes);
|
||||
}
|
||||
76
crates/api/src/smtp/mod.rs
Normal file
76
crates/api/src/smtp/mod.rs
Normal 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(())
|
||||
}
|
||||
34
crates/api/src/system/mod.rs
Normal file
34
crates/api/src/system/mod.rs
Normal 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
9
crates/api/src/utils.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Paged<T> {
|
||||
pub data: Vec<T>,
|
||||
pub page: u32,
|
||||
pub limit: u32,
|
||||
pub total: i64,
|
||||
}
|
||||
Reference in New Issue
Block a user