Working on email templating, updating with swagger

This commit is contained in:
2025-05-14 20:33:13 -04:00
parent 1e3c75624a
commit e46e8ab9b4
41 changed files with 1124 additions and 189 deletions

View File

@@ -1,10 +1,10 @@
use std::future::Future;
use std::pin::Pin;
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http};
use serde::{Serialize, Deserialize};
use super::{SESSION_COOKIE_NAME, Session};
use crate::{error::Error, users::User};
use super::{Session, SESSION_COOKIE_NAME};
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Auth {
@@ -34,13 +34,13 @@ impl FromRequest for Auth {
return Err(Error::new(401, "API Key does not exist".to_string()).into());
}
};
match User::select(&api_key.id).await {
match User::select(&api_key.user_id).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.id)).into()),
None => Err(Error::new(404, format!("User {} not found", api_key.user_id)).into()),
}
};
return Box::pin(fut);
@@ -79,13 +79,13 @@ impl FromRequest for Auth {
// Verify the session
let fut = async move {
match Session::verify(&session_id, &ip_address).await {
Ok(session) => match User::select(&session.id).await {
Ok(session) => match User::select(&session.user_id).await {
Some(user) => Ok(Auth {
session_id: Some(session_id),
api_key: None,
user,
}),
None => Err(Error::new(404, format!("User {} not found", session.id)).into()),
None => Err(Error::new(404, format!("User {} not found", session.user_id)).into()),
},
Err(err) => Err(err.into()),
}

View File

@@ -1,6 +1,6 @@
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use rand::distr::Alphanumeric;
use rand::prelude::*;
@@ -11,10 +11,10 @@ mod routes;
mod session;
pub use auth::*;
pub use session::*;
pub use routes::init_routes;
pub use session::*;
use crate::error::{Error, ApiResult};
use crate::error::{ApiResult, Error};
pub fn csprng(take: usize) -> String {
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)

View File

@@ -1,13 +1,26 @@
use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get};
use crate::{
account::{verify_hash, Session, SESSION_COOKIE_NAME},
account::{SESSION_COOKIE_NAME, Session, verify_hash},
error::Error,
smtp,
users::{LoginRequest, RegisterRequest, User, UserResponse},
};
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use crate::account::Auth;
use crate::account::{Auth, csprng};
use crate::users::UpdateUser;
#[utoipa::path(
tag = "Account",
request_body(
content = RegisterRequest, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
(status = 409, description = ""),
)
)]
#[post("/register")]
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner();
@@ -38,13 +51,22 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
);
HttpResponse::Conflict().finish()
} else {
log::error!("attemptFailed to register user [Email: {}]: {}", email, err);
log::error!("Failed to register user [Email: {}]: {}", email, err);
ResponseError::error_response(&err)
}
}
}
}
#[utoipa::path(
tag = "Account",
request_body(
content = LoginRequest, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
),
)]
#[post("/login")]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = &request.email;
@@ -93,6 +115,17 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
}
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
(status = 500, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/logout")]
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email;
@@ -132,6 +165,16 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
.finish()
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = "", body = UserResponse),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[get("/profile")]
async fn get_profile(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
@@ -154,7 +197,7 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
.finish();
}
};
let id = &session.id;
let id = &session.user_id;
let query_user = match User::select(&id).await {
Some(query_user) => query_user,
None => {
@@ -186,6 +229,16 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
}
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[get("/session")]
async fn session_refresh(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
@@ -208,7 +261,7 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
.finish();
}
};
let id = &session.id;
let id = &session.user_id;
let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
@@ -229,6 +282,19 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
}
}
#[utoipa::path(
tag = "Account",
request_body(
content = String, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[put("/password")]
async fn change_password(
password: web::Json<String>,
@@ -269,25 +335,52 @@ async fn change_password(
ip_address,
err
);
ResponseError::error_response(&Error::new(500, err.to_string()))
ResponseError::error_response(&err)
}
}
}
#[post("/password-reset")]
async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse {
let _ip_address = req.peer_addr().unwrap().ip().to_string();
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/password/reset")]
async fn reset_password(req: HttpRequest, auth: Auth) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let id = auth.user.id;
let email = auth.user.email;
let token = csprng(128);
match smtp::send_password_reset(&email, &token) {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => {
log::error!(
"Invalid password reset attempt [ID: {}] [IP Address: {}]: {}",
&id,
ip_address,
err
);
ResponseError::error_response(&err)
}
};
HttpResponse::Ok().finish()
}
pub fn init_routes(config: &mut web::ServiceConfig) {
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
web::scope("account")
scope::scope("/account")
.service(register)
.service(login)
.service(logout)
.service(change_password)
.service(get_profile)
.service(session_refresh),
.service(session_refresh)
.service(change_password)
.service(reset_password),
);
}

View File

@@ -1,14 +1,14 @@
use actix_web::cookie::{time::Duration, Cookie};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use tokio::task;
use uuid::Uuid;
use super::{csprng, hash, verify_hash};
use crate::{
db::redis_async_connection,
error::{Error, ApiResult},
error::{ApiResult, Error},
};
use super::{csprng, hash, verify_hash};
use actix_web::cookie::{Cookie, time::Duration};
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use tokio::task;
use uuid::Uuid;
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
@@ -17,22 +17,22 @@ pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub id: Uuid,
pub user_id: Uuid,
pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl Session {
pub fn default(id: &Uuid, ip_address: &str) -> Self {
Self::new(64, id, ip_address, Some(DEFAULT_SESSION_TTL))
pub fn default(user_id: &Uuid, ip_address: &str) -> Self {
Self::new(64, user_id, ip_address, Some(DEFAULT_SESSION_TTL))
}
pub fn new(take: usize, id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self {
pub fn new(take: usize, user_id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now();
Self {
session_id: csprng(take),
id: id.clone(),
user_id: user_id.clone(),
ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
@@ -79,7 +79,7 @@ impl Session {
);
};
});
session = Session::default(&session.id, ip_address);
session = Session::default(&session.user_id, ip_address);
session.store().await?;
Ok(session)
}
@@ -120,8 +120,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session cookie [ID: {}]: {}",
self.id,
"Session cookie [User ID: {}]: {}",
self.user_id,
self.session_id
);
cookie.set_secure(false);
@@ -148,8 +148,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session expiration cookie [ID: {}]: {}",
self.id,
"Session expiration cookie [User ID: {}]: {}",
self.user_id,
self.session_id
);
cookie.set_secure(false);

View File

@@ -1,5 +1,5 @@
mod model;
mod routes;
pub mod model;
pub mod routes;
pub use model::*;
pub use routes::init_routes;

View File

@@ -1,20 +1,22 @@
use std::collections::HashMap;
use std::str::FromStr;
use crate::airports::{
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
UpdateRunway,
};
use crate::db;
use crate::error::{ApiResult, Error};
use crate::metars::Metar;
use chrono::{DateTime, Utc};
use futures_util::try_join;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use crate::airports::{
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication, UpdateRunway,
};
use crate::db;
use crate::error::{ApiResult, Error};
use crate::metars::Metar;
use std::collections::HashMap;
use std::str::FromStr;
use utoipa::{IntoParams, ToSchema};
const TABLE_NAME: &str = "airports";
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct Airport {
pub icao: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -40,7 +42,8 @@ pub struct Airport {
pub latest_metar: Option<Metar>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct AirportQuery {
pub page: Option<u32>,
pub limit: Option<u32>,
@@ -75,7 +78,7 @@ impl Default for AirportQuery {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct Bounds {
pub north_east_lat: f32,
pub north_east_lon: f32,
@@ -125,7 +128,7 @@ struct AirportRow {
pub metar_observation_time: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateAirport {
pub icao: Option<String>,
pub iata: Option<String>,

View File

@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum AirportCategory {
#[serde(rename = "small_airport")]
Small,

View File

@@ -1,13 +1,14 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
use crate::db;
use crate::error::ApiResult;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
const TABLE_NAME: &str = "communications";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Communication {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -27,7 +28,7 @@ pub struct CommunicationRow {
pub phone: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateCommunication {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>,

View File

@@ -1,13 +1,14 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
use crate::db;
use crate::error::ApiResult;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
const TABLE_NAME: &str = "runways";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Runway {
#[serde(rename = "id")]
pub runway_id: String,
@@ -26,7 +27,7 @@ pub struct RunwayRow {
pub surface: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateRunway {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>,

View File

@@ -1,16 +1,39 @@
use futures_util::stream::StreamExt as _;
use crate::{
airports::Airport,
db::Paged,
account::{Auth, verify_role},
AppState,
};
use actix_multipart::Multipart;
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
use crate::airports::{AirportQuery, UpdateAirport};
use crate::users::ADMIN_ROLE;
use crate::{
AppState,
account::{Auth, verify_role},
airports::Airport,
db::Paged,
};
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;
#[derive(ToSchema)]
#[allow(unused)]
struct UploadedFile {
#[schema(value_type = String, format = Binary)]
file: Vec<u8>,
}
#[utoipa::path(
tag = "Airports",
request_body(
content = UploadedFile, content_type = "multipart/form-data"
),
responses(
(status = 200, description = "Successful import"),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/import")]
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, ADMIN_ROLE) {
@@ -53,6 +76,15 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
HttpResponse::Ok().finish()
}
#[utoipa::path(
tag = "Airports",
params(
AirportQuery
),
responses(
(status = 200, description = "", body = [Airport]),
),
)]
#[get("")]
async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
@@ -87,6 +119,13 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 200, description = "", body = Airport),
(status = 404, description = ""),
),
)]
#[get("/{icao}")]
async fn get_airport(
data: web::Data<AppState>,
@@ -108,6 +147,17 @@ async fn get_airport(
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
(status = 409, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("")]
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -123,6 +173,16 @@ async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[put("/{icao}")]
async fn update_airport(
icao: web::Path<String>,
@@ -142,6 +202,16 @@ async fn update_airport(
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("")]
async fn delete_airports(auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -157,6 +227,16 @@ async fn delete_airports(auth: Auth) -> HttpResponse {
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("/{icao}")]
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -172,9 +252,9 @@ async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
}
}
pub fn init_routes(config: &mut web::ServiceConfig) {
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
web::scope("airports")
scope::scope("/airports")
.service(import_airports)
.service(get_airports)
.service(get_airport)

View File

@@ -1,11 +1,11 @@
use crate::error::ApiResult;
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult};
use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData};
use redis::{Client as RedisClient, RedisResult, aio::MultiplexedConnection as RedisConnection};
use s3::{Bucket, BucketConfiguration, Region, creds::Credentials, request::ResponseData};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
use std::sync::OnceLock;
use std::time::Duration;
use sqlx::{Pool, Postgres};
use sqlx::postgres::PgPoolOptions;
static POOL: OnceLock<Pool<Postgres>> = OnceLock::new();
static REDIS: OnceLock<RedisClient> = OnceLock::new();
@@ -169,9 +169,3 @@ pub struct Paged<T> {
pub limit: u32,
pub total: i64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Coordinate {
pub lon: f64,
pub lat: f64,
}

View File

@@ -204,3 +204,27 @@ impl From<sqlx::migrate::MigrateError> for Error {
Error::new(500, error.to_string())
}
}
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)
}
}

View File

@@ -1,12 +1,16 @@
use std::env;
use std::time::Duration;
use crate::account::hash;
use crate::users::{ADMIN_ROLE, User};
use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename;
use reqwest::Certificate;
use std::env;
use std::time::Duration;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa::openapi::{Contact, SecurityRequirement};
use utoipa_actix_web::{AppExt, scope};
use utoipa_swagger_ui::SwaggerUi;
use uuid::Uuid;
use crate::account::hash;
use crate::users::{User, ADMIN_ROLE};
mod account;
mod airports;
@@ -14,6 +18,7 @@ mod db;
mod error;
mod metars;
mod scheduler;
mod smtp;
mod system;
mod users;
@@ -91,18 +96,49 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.allow_any_header()
.supports_credentials()
.max_age(3600);
App::new()
let (app, mut api) = App::new()
.wrap(cors)
.wrap(Logger::default())
.app_data(web::Data::new(state.clone()))
.into_utoipa_app()
.service(
web::scope("api")
scope::scope("/api")
.configure(airports::init_routes)
.configure(metars::init_routes)
.configure(account::init_routes)
.configure(users::init_routes)
.configure(system::init_routes),
)
.split_for_parts();
let contact_name = env::var("API_CONTACT_NAME").unwrap();
let contact_url = env::var("EXTERNAL_URL").unwrap();
let contact_email = env::var("API_CONTACT_EMAIL").unwrap();
let version = env::var("CARGO_PKG_VERSION").unwrap();
api.info.title = "Aviation Data".to_string();
api.info.description = None;
api.info.terms_of_service = None;
api.info.contact = Some(
Contact::builder()
.name(Some(contact_name))
.url(Some(format!("{}/support", contact_url)))
.email(Some(contact_email))
.build(),
);
api.info.license = None;
api.info.version = version;
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::new("session_auth", [""])]);
api.security = Some(vec![SecurityRequirement::default()]);
app.service(SwaggerUi::new("/swagger/{_:.*}").url("/api-docs/openapi.json", api))
})
.bind(format!("{}:{}", host, port))
{

View File

@@ -1,9 +1,9 @@
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use crate::db::redis_async_connection;
use crate::error::ApiResult;
use crate::metars::Metar;
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct MetarCheck {

View File

@@ -2,6 +2,6 @@ mod metar_check;
mod model;
mod routes;
pub use model::*;
pub use metar_check::*;
pub use model::*;
pub use routes::init_routes;

View File

@@ -1,20 +1,21 @@
use crate::airports::{Airport, UpdateAirport};
use crate::db::redis_async_connection;
use crate::error::Error;
use crate::{error::ApiResult, db};
use crate::metars::MetarCheck;
use crate::{db, error::ApiResult};
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use redis::{AsyncCommands, RedisResult};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fmt::Display;
use std::str::FromStr;
use redis::{AsyncCommands, RedisResult};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::airports::{Airport, UpdateAirport};
use crate::db::redis_async_connection;
use crate::metars::MetarCheck;
use utoipa::ToSchema;
const TABLE_NAME: &str = "metars";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Metar {
pub icao: String,
pub raw_text: String,
@@ -60,7 +61,7 @@ pub struct Metar {
pub density_altitude: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum ReportModifier {
#[serde(rename = "AUTO")]
Auto,
@@ -88,7 +89,7 @@ impl Display for ReportModifier {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RunwayVisualRange {
pub runway: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -110,7 +111,7 @@ impl Default for RunwayVisualRange {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum AutomatedStationType {
#[serde(rename = "AO1")]
WithoutPrecipitationDiscriminator,
@@ -141,7 +142,7 @@ impl Display for AutomatedStationType {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Remarks {
#[serde(skip_serializing_if = "Option::is_none")]
pub peak_wind: Option<PeakWind>,
@@ -165,7 +166,7 @@ pub struct Remarks {
pub sky_condition_at_secondary_location_not_available: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PeakWind {
pub degrees: i32,
pub speed: i32,
@@ -190,7 +191,7 @@ impl Default for Remarks {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SkyCondition {
pub sky_cover: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -209,7 +210,7 @@ impl Default for SkyCondition {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum FlightCategory {
VFR,
MVFR,
@@ -1134,8 +1135,8 @@ impl Metar {
#[cfg(test)]
mod tests {
use chrono::NaiveDateTime;
use super::*;
use chrono::NaiveDateTime;
#[test]
fn test_parse_time() {

View File

@@ -1,18 +1,34 @@
use crate::AppState;
use crate::metars::Metar;
use actix_web::{get, web, HttpResponse, HttpRequest};
use actix_web::{HttpRequest, HttpResponse, get, web};
use log::error;
use serde::{Deserialize, Serialize};
use crate::AppState;
use utoipa::{IntoParams, ToSchema};
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(Debug, Serialize, Deserialize)]
struct FindAllParameters {
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
struct MetarQuery {
icaos: Option<String>,
}
#[get("metars")]
#[utoipa::path(
tag = "METARs",
params(
MetarQuery,
),
responses(
(status = 200, description = "", body = [Metar]),
),
)]
#[get("/metars")]
async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let parameters = web::Query::<FindAllParameters>::from_query(req.query_string()).unwrap();
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"),
@@ -30,6 +46,6 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
HttpResponse::Ok().json(metars)
}
pub fn init_routes(config: &mut web::ServiceConfig) {
pub fn init_routes(config: &mut ServiceConfig) {
config.service(find_all);
}

103
api/src/smtp/mod.rs Normal file
View File

@@ -0,0 +1,103 @@
use crate::error::ApiResult;
use chrono::{Datelike, Utc};
use handlebars::Handlebars;
use lettre::message::header::ContentType;
use lettre::message::{Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Address, Message, SmtpTransport, Transport};
use serde::Serialize;
use std::env;
use std::sync::OnceLock;
static MAILER: OnceLock<SmtpTransport> = OnceLock::new();
static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new();
static REGISTRY: OnceLock<Handlebars> = OnceLock::new();
fn mailer() -> &'static SmtpTransport {
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 creds = Credentials::new(username, password);
SmtpTransport::relay(&server)
.expect("invalid SMTP_SERVER")
.credentials(creds)
.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)
})
}
fn registry() -> &'static Handlebars<'static> {
REGISTRY.get_or_init(|| Handlebars::new())
}
#[derive(Serialize)]
struct PasswordResetCtx {
logo_url: String,
link: String,
domain: String,
year: i32,
}
pub fn send_password_reset(to: &str, token: &str) -> ApiResult<()> {
let base_url = env::var("EXTERNAL_URL")?.trim_end_matches('/').to_string();
let link = format!("{base_url}/profile/reset?token={token}");
let subject = "Reset your password";
let plain = format!(
"Hello,\n\n\
We received a password reset request. Click the link below:\n\n\
{link}\n\n\
This link expires in 24 hours. If you didn't request this, please ignore.\n\n\
Cheers,\n\
\tAviation Data",
link = link
);
let ctx = PasswordResetCtx {
logo_url: format!("{}/logo.svg", base_url),
link: link.clone(),
domain: base_url,
year: Utc::now().year(),
};
let template_html = include_str!("../.././templates/password_reset.html");
let html = registry().render_template(template_html, &ctx).unwrap();
send_email(to, subject, plain, html)
}
pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> {
let to_address = to.parse::<Address>()?;
let to_mailbox = Mailbox::new(None, to_address);
// 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)?;
Ok(())
}

View File

@@ -1,13 +1,22 @@
use std::env;
use actix_web::{get, web, HttpResponse};
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)]
#[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 mut healthy = true;
@@ -24,6 +33,6 @@ async fn info() -> HttpResponse {
HttpResponse::Ok().json(info)
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(web::scope("/system").service(info));
pub fn init_routes(config: &mut ServiceConfig) {
config.service(scope::scope("/system").service(info));
}

View File

@@ -1,15 +1,17 @@
use crate::db;
use crate::{account::hash, error::ApiResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{Postgres, QueryBuilder};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{account::hash, error::ApiResult};
use crate::db;
pub const ADMIN_ROLE: &str = "ADMIN";
pub const USER_ROLE: &str = "USER";
const TABLE_NAME: &str = "users";
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
@@ -35,13 +37,21 @@ impl RegisterRequest {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
#[schema(
example = json!(
{
"email": "user@example.com",
"password": "changeme"
}
)
)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct UserResponse {
pub id: Uuid,
pub role: String,
@@ -65,7 +75,7 @@ impl From<User> for UserResponse {
}
}
#[derive(Debug, Deserialize, sqlx::FromRow)]
#[derive(Debug, Deserialize, sqlx::FromRow, ToSchema)]
pub struct UpdateUser {
pub email: Option<String>,
pub email_verified: Option<bool>,
@@ -167,13 +177,13 @@ impl User {
"#,
TABLE_NAME
))
.bind(id)
.fetch_optional(pool)
.await
.unwrap_or_else(|err| {
log::error!("Unable to find user by id '{}': {}", id, err);
None
});
.bind(id)
.fetch_optional(pool)
.await
.unwrap_or_else(|err| {
log::error!("Unable to find user by id '{}': {}", id, err);
None
});
user
}

View File

@@ -152,7 +152,9 @@
// }
// }
pub fn init_routes(_config: &mut actix_web::web::ServiceConfig) {
use utoipa_actix_web::service_config::ServiceConfig;
pub fn init_routes(_config: &mut ServiceConfig) {
// config.service(
// web::scope("users")
// .service(get_favorites)