refactor auth, maybe bug with hashing

This commit is contained in:
2024-09-03 17:06:23 -04:00
parent cc74be72be
commit f0fd79bed8
25 changed files with 776 additions and 1340 deletions

13
.env
View File

@@ -1,12 +1,13 @@
RUST_LOG=warn,service=info
DATABASE_CONTAINER=aviation-db
DATABASE_USER=aviation
DATABASE_PASSWORD=
DATABASE_NAME=aviation
DATABASE_HOST=db
DATABASE_HOST=localhost
DATABASE_PORT=5432
REDIS_HOST=redis
REDIS_HOST=localhost
REDIS_PORT=6379
MINIO_ROOT_USER=aviation
@@ -15,11 +16,9 @@ MINIO_HOST=localhost
MINIO_PORT=9000
MINIO_PORT_INTERNAL=9001
SERVICE_HOST=service
SERVICE_HOST=localhost
SERVICE_PORT=5000
KEYS_DIR_PATH=
ACCESS_TOKEN_MAXAGE=5
REFRESH_TOKEN_MAXAGE=30
UI_PORT=3000
NODE_ENV=development
GOV_API_URL=https://aviationweather.gov/cgi-bin/data

View File

@@ -14,29 +14,39 @@ help: ## This info
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
build: ## Build Docker containers
docker compose build
format: ## Format code
@cd service && cargo fmt
@cd ui && npm run format
tag: ## Tag Docker images
docker tag aviation-ui:latest aviation-ui:${GIT_HASH}
docker tag aviation-service:latest aviation-service:${GIT_HASH}
backend-up: ## Start Docker containers
@docker compose --profile backend up -d
up: ## Start Docker containers
docker compose up -d
up-backend: backend-up
down: ## Stop Docker containers
docker compose down
backend-down: ## Stop Docker containers
@docker compose --profile backend down
clean: ## Cleanup Docker containers
docker compose down && \
docker image rm aviation-ui || \
docker image rm aviation-service || \
docker network rm aviation-frontend || \
docker network rm aviation-backend
down-backend: backend-down
generate: ## Generate RSA keys
mkdir keys
openssl genrsa -out keys/access_private_key.pem 4096
openssl rsa -in keys/access_private_key.pem -pubout -outform PEM -out keys/access_public_key.pem
openssl genrsa -out keys/refresh_private_key.pem 4096
openssl rsa -in keys/refresh_private_key.pem -pubout -outform PEM -out keys/refresh_public_key.pem
run: ## Run the api
@cd service && cargo run
frontend-up: ## Start Docker containers
@docker compose --profile frontend up -d
up-frontend: frontend-up
frontend-down: ## Stop Docker containers
@docker compose --profile frontend down
down-frontend: frontend-down
docker-clean: ## Stop the docker containers and remove volumes
@echo "Stopping docker container and removing volumes..."
@docker compose --profile frontend --profile api --profile backend down -v
@echo "Docker container stopped and volumes removed"
docker-refresh: docker-clean up-backend ## Refresh the database
psql: ## Connect to the PSQL DB
@docker exec -it ${DATABASE_CONTAINER} psql -U ${DATABASE_USER} -P pager=off

View File

@@ -1,12 +1,15 @@
version: '3'
x-env_file: &env
- path: .env
required: true
- path: .env.local
required: false
name: aviation
services:
db:
image: postgis/postgis:latest
container_name: aviation-db
env_file:
- .env
env_file: *env
environment:
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
@@ -18,7 +21,10 @@ services:
- "${DATABASE_PORT:-5432}:5432"
networks:
- backend
profiles:
- backend
restart: unless-stopped
redis:
image: redis:latest
container_name: aviation-redis
@@ -28,7 +34,10 @@ services:
- ${REDIS_PORT:-6379}:6379
networks:
- backend
profiles:
- backend
restart: unless-stopped
minio:
image: minio/minio
container_name: aviation-minio
@@ -42,17 +51,14 @@ services:
- ${MINIO_PORT_INTERNAL:-9001}:9001
networks:
- backend
profiles:
- backend
command: server --console-address ":9001" /data
restart: unless-stopped
service:
container_name: aviation-service
env_file:
- .env
environment:
KEYS_DIR_PATH: /keys
volumes:
- ${KEYS_DIR_PATH}:/keys
api:
container_name: aviation-api
env_file: *env
ports:
- "${SERVICE_PORT:-5000}:5000"
build:
@@ -64,22 +70,29 @@ services:
networks:
- frontend
- backend
profiles:
- api
restart: unless-stopped
ui:
container_name: aviation-ui
env_file:
- .env
env_file: *env
environment:
- NODE_ENV=${NODE_ENV:-production}
- NODE_ENV=${NODE_ENV:-development}
ports:
- ${UI_PORT:-3000}:3000
build:
context: ui
depends_on:
- service
context: ./ui/
target: dev
volumes:
- ./ui/src:/app/src
- ./ui/public:/app/public
- ./ui/styles:/app/styles
networks:
- frontend
profiles:
- frontend
command: ["npm", "run", "dev"]
restart: unless-stopped
volumes:

View File

@@ -1,27 +0,0 @@
RUST_LOG=warn,service=debug
DATABASE_CONTAINER=aviation-db
DATABASE_USER=aviation
DATABASE_PASSWORD=
DATABASE_NAME=aviation
DATABASE_HOST=localhost
DATABASE_PORT=5432
REDIS_HOST=localhost
REDIS_PORT=6379
MINIO_ROOT_USER=aviation
MINIO_ROOT_PASSWORD=
MINIO_HOST=localhost
MINIO_PORT=9000
MINIO_PORT_INTERNAL=9001
SERVICE_HOST=localhost
SERVICE_PORT=5000
KEYS_DIR_PATH=
ACCESS_TOKEN_MAXAGE=240
REFRESH_TOKEN_MAXAGE=1440
GOV_API_URL=https://aviationweather.gov/cgi-bin/data

23
service/Cargo.lock generated
View File

@@ -547,9 +547,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]]
name = "bytes"
version = "1.5.0"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
[[package]]
name = "bytestring"
@@ -2206,18 +2206,18 @@ checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
[[package]]
name = "serde"
version = "1.0.204"
version = "1.0.209"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.204"
version = "1.0.209"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
dependencies = [
"proc-macro2",
"quote",
@@ -2286,6 +2286,8 @@ dependencies = [
"log",
"postgis_diesel",
"r2d2",
"rand",
"rand_chacha",
"redis",
"regex",
"reqwest",
@@ -2698,11 +2700,10 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]]
name = "tracing"
version = "0.1.37"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [
"cfg-if",
"log",
"pin-project-lite",
"tracing-core",
@@ -2710,9 +2711,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.31"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
dependencies = [
"once_cell",
]

View File

@@ -36,3 +36,5 @@ ahash = "0.8.11" # https://github.com/tkaitchuck/aHash/issues/200
regex = "1.10.5"
futures-util = "0.3.30"
rust-s3 = "0.34.0"
rand = "0.8.5"
rand_chacha = "0.3.1"

View File

@@ -1,64 +0,0 @@
#!make
SHELL := /bin/bash
GIT_HASH ?= $(shell git log --format="%h" -n 1)
include .env
-include .env.local
export
.PHONY: help build start stop lint
help: ## This info
@echo
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
format: ## Format code
@echo "Formatting code..."
@cargo fmt
@echo "Format complete"
run: ## Run the service
@cargo run
clean: ## Cleanup
@echo "Cleaning up..."
@cargo clean
@rm -rf ../keys
@echo "Cleanup complete"
up: ## Start the Docker containers
@docker compose --profile backend up -d
down: ## Stop the Docker containers
@docker compose --profile backend down
connect: ## Connect to the PSQL DB
@docker exec -it ${DATABASE_CONTAINER} psql -U postgres
docker-build: ## Build the Docker image
@docker compose build
docker-tag: ## Tag the Docker image
@docker tag aviation-service:latest aviation-service:${GIT_HASH}
docker-run: ## Start the service
@docker compose --profile service up -d
docker-clean: ## Cleanup Docker containers
@docker compose --profile backend --profile service down -v
clean-db: ## Remove database
@docker exec -i ${DATABASE_CONTAINER} sh -c 'PGPASSWORD=${DATABASE_PASSWORD} psql -U ${DATABASE_USER} -d postgres -c "DROP DATABASE IF EXISTS \"${DATABASE_NAME}\";"'
@docker exec -i ${DATABASE_CONTAINER} sh -c 'PGPASSWORD=${DATABASE_PASSWORD} psql -U ${DATABASE_USER} -d postgres -c "CREATE DATABASE \"${DATABASE_NAME}\";"' || true
generate-keys: ## Generate RSA keys
@mkdir ../keys/
@openssl genrsa -out ../keys/access_private_key.pem 4096
@openssl rsa -in ../keys/access_private_key.pem -pubout -outform PEM -out ../keys/access_public_key.pem
@openssl genrsa -out ../keys/refresh_private_key.pem 4096
@openssl rsa -in ../keys/refresh_private_key.pem -pubout -outform PEM -out ../keys/refresh_public_key.pem
psql: ## Connect to the PSQL DB
@docker exec -it ${DATABASE_CONTAINER} psql -U ${DATABASE_USER}

View File

@@ -1,93 +0,0 @@
x-env_file: &env
- path: .env
required: true
- path: .env.local
required: false
name: aviation
services:
db:
image: postgis/postgis:latest
container_name: aviation-db
env_file: *env
environment:
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE_NAME}
volumes:
- db:/var/lib/postgresql/data
- db_logs:/var/log
ports:
- "${DATABASE_PORT:-5432}:5432"
networks:
- backend
profiles:
- backend
restart: unless-stopped
redis:
image: redis:latest
container_name: aviation-redis
volumes:
- redis:/data
ports:
- ${REDIS_PORT:-6379}:6379
networks:
- backend
profiles:
- backend
restart: unless-stopped
minio:
image: minio/minio
container_name: aviation-minio
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio:/data
ports:
- ${MINIO_PORT:-9000}:9000
- ${MINIO_PORT_INTERNAL:-9001}:9001
networks:
- backend
profiles:
- backend
command: server --console-address ":9001" /data
restart: unless-stopped
service:
container_name: aviation-service
env_file: *env
environment:
DATABASE_HOST: db
DATABASE_PORT: 5432
REDIS_HOST: redis
REDIS_PORT: 6379
SERVICE_HOST: service
SERVICE_PORT: 5000
KEYS_DIR_PATH: /keys
volumes:
- ${KEYS_DIR_PATH}:/keys
ports:
- "${SERVICE_PORT:-5000}:5000"
build:
context: .
depends_on:
- db
- redis
- minio
networks:
- frontend
- backend
profiles:
- service
restart: unless-stopped
volumes:
db:
db_logs:
redis:
minio:
networks:
frontend:
backend:

View File

@@ -2,7 +2,7 @@ use std::fmt::Display;
use std::str::FromStr;
use crate::db;
use crate::error_handler::ServiceError;
use crate::error::{ApiError, ApiResult};
use crate::db::schema::airports;
use diesel::prelude::*;
use diesel::sql_query;
@@ -218,7 +218,7 @@ impl FromStr for QueryOrderField {
}
impl QueryAirport {
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> ApiResult<Vec<Self>> {
let mut conn = db::connection()?;
let mut query: String = "SELECT * FROM airports".to_string();
query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
@@ -258,7 +258,7 @@ impl QueryAirport {
let airports: Vec<QueryAirport> = match sql_query(query).load(&mut conn) {
Ok(a) => a,
Err(err) => {
return Err(ServiceError {
return Err(ApiError {
status: 500,
message: format!("{}", err),
})
@@ -267,7 +267,7 @@ impl QueryAirport {
Ok(airports)
}
pub fn get_count(filters: &QueryFilters) -> Result<i64, ServiceError> {
pub fn get_count(filters: &QueryFilters) -> ApiResult<i64> {
let mut conn = db::connection()?;
let mut query = "SELECT COUNT(*) FROM airports".to_string();
query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
@@ -287,7 +287,7 @@ impl QueryAirport {
let count: Vec<Count> = match sql_query(query).load(&mut conn) {
Ok(a) => a,
Err(err) => {
return Err(ServiceError {
return Err(ApiError {
status: 500,
message: format!("{}", err),
})
@@ -297,14 +297,14 @@ impl QueryAirport {
}
// TODO: Unsafe query, need to sanitize inputs
fn build_filter_query(filters: &QueryFilters) -> Result<String, ServiceError> {
fn build_filter_query(filters: &QueryFilters) -> ApiResult<String> {
let mut query = "".to_string();
let mut parts: Vec<String> = vec![];
if let Some(bounds) = &filters.bounds {
// convert bounds to a WKT polygon
if bounds.rings.len() > 1 {
return Err(ServiceError {
return Err(ApiError {
status: 400,
message: "Only one polygon is allowed".to_string(),
});
@@ -376,7 +376,7 @@ impl QueryAirport {
return Ok(query);
}
pub fn get(icao: &str) -> Result<Self, ServiceError> {
pub fn get(icao: &str) -> ApiResult<Self> {
let mut conn = db::connection()?;
let airport = airports::table
.filter(airports::icao.eq(icao))
@@ -384,7 +384,7 @@ impl QueryAirport {
Ok(airport)
}
pub fn insert(airport: Self) -> Result<Self, ServiceError> {
pub fn insert(airport: Self) -> ApiResult<Self> {
let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> =
db::connection()?;
let airport = Self::from(airport);
@@ -395,7 +395,7 @@ impl QueryAirport {
Ok(airport)
}
pub fn insert_all(airports: Vec<Self>) -> Result<Vec<Self>, ServiceError> {
pub fn insert_all(airports: Vec<Self>) -> ApiResult<Vec<Self>> {
let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> =
db::connection()?;
let mut inserted_airports: Vec<Self> = vec![];
@@ -410,7 +410,7 @@ impl QueryAirport {
Ok(inserted_airports)
}
pub fn update(airport: Self) -> Result<Self, ServiceError> {
pub fn update(airport: Self) -> ApiResult<Self> {
let mut conn = db::connection()?;
let airport = diesel::update(airports::table)
.filter(airports::icao.eq(airport.icao.clone()))
@@ -419,7 +419,7 @@ impl QueryAirport {
Ok(airport)
}
pub fn delete(icao: Option<String>) -> Result<usize, ServiceError> {
pub fn delete(icao: Option<String>) -> ApiResult<usize> {
let mut conn = db::connection()?;
let res = match icao {
Some(icao) => {

View File

@@ -4,7 +4,7 @@ use futures_util::stream::StreamExt as _;
use crate::{
airports::{QueryAirport, QueryFilters, QueryOrderField, QueryOrderBy, Airport, AirportCategory},
db::{Response, Metadata},
auth::{JwtAuth, verify_role},
auth::{Auth, verify_role},
};
use actix_multipart::Multipart;
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
@@ -26,7 +26,7 @@ struct AirportsQuery {
}
#[post("/import")]
async fn import_airports(mut payload: Multipart, auth: JwtAuth) -> HttpResponse {
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err);
};
@@ -193,11 +193,7 @@ async fn get_airports(req: HttpRequest) -> HttpResponse {
}
HttpResponse::Ok().json(Response {
data: airports,
meta: Some(Metadata {
page,
limit,
total,
}),
meta: Some(Metadata { page, limit, total }),
})
}
Err(err) => {
@@ -222,7 +218,7 @@ async fn get_airport(icao: web::Path<String>) -> HttpResponse {
}
#[post("")]
async fn create_airport(airport: web::Json<Airport>, auth: JwtAuth) -> HttpResponse {
async fn create_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
@@ -244,7 +240,7 @@ async fn create_airport(airport: web::Json<Airport>, auth: JwtAuth) -> HttpRespo
async fn update_airport(
_icao: web::Path<String>,
airport: web::Json<Airport>,
auth: JwtAuth,
auth: Auth,
) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {}
@@ -264,7 +260,7 @@ async fn update_airport(
}
#[delete("")]
async fn delete_airports(auth: JwtAuth) -> HttpResponse {
async fn delete_airports(auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
@@ -279,7 +275,7 @@ async fn delete_airports(auth: JwtAuth) -> HttpResponse {
}
#[delete("/{icao}")]
async fn delete_airport(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse {
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),

View File

@@ -1,111 +1,58 @@
use std::env;
use argon2::{
password_hash::{
rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError,
},
Argon2, PasswordHash,
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, encode, decode, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use rand::prelude::*;
use rand_chacha::ChaCha20Rng;
mod model;
mod routes;
mod session;
pub use model::*;
pub use session::*;
pub use routes::init_routes;
use crate::error_handler::ServiceError;
#[derive(Debug, Serialize, Deserialize)]
struct TokenClaims {
sub: String, // Subject
token_uuid: String, // Token UUID
iss: String, // Issuer
exp: i64, // Expiration time
iat: i64, // Issued At
nbf: i64, // Not Before
use crate::error::{ApiError, ApiResult};
pub const SESSION_COOKIE_NAME: &str = "session";
pub fn csprng_128bit(take: usize) -> String {
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
let rng = ChaCha20Rng::from_entropy();
rng
.sample_iter(rand::distributions::Alphanumeric)
.take(take)
.map(char::from)
.collect()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenDetails {
pub token: Option<String>,
pub token_uuid: uuid::Uuid,
pub email: String,
pub expires_in: Option<i64>,
}
pub fn verify_token(token: &str, public_key: &str) -> Result<TokenDetails, ServiceError> {
let key = DecodingKey::from_rsa_pem(public_key.as_bytes())?;
let validation = Validation::new(Algorithm::RS256);
let decoded = decode::<TokenClaims>(token, &key, &validation)?;
let email = decoded.claims.sub;
let token_uuid = uuid::Uuid::parse_str(decoded.claims.token_uuid.as_str()).unwrap();
Ok(TokenDetails {
token: None,
token_uuid,
email,
expires_in: None,
})
}
pub fn generate_access_token(email: &str) -> Result<TokenDetails, ServiceError> {
let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE")
.expect("ACCESS_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("ACCESS_TOKEN_MAXAGE must be an integer");
let keys_dir = env::var("KEYS_DIR_PATH")?;
let access_private_key = std::fs::read_to_string(format!("{}/access_private_key.pem", keys_dir))?;
generate_token(&email, access_token_max_age, &access_private_key)
}
pub fn generate_refresh_token(email: &str) -> Result<TokenDetails, ServiceError> {
let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE")
.expect("REFRESH_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("REFRESH_TOKEN_MAXAGE must be an integer");
let keys_dir = env::var("KEYS_DIR_PATH")?;
let refresh_private_key =
std::fs::read_to_string(format!("{}/refresh_private_key.pem", keys_dir))?;
generate_token(&email, refresh_token_max_age, &refresh_private_key)
}
pub fn generate_token(
email: &str,
ttl: i64,
private_key: &str,
) -> Result<TokenDetails, ServiceError> {
let now = chrono::Utc::now();
let mut token_details = TokenDetails {
token: None,
token_uuid: uuid::Uuid::new_v4(),
email: email.to_string(),
expires_in: Some((now + chrono::Duration::minutes(ttl)).timestamp()),
};
let claims = TokenClaims {
sub: token_details.email.clone(),
iss: "aviation-weather".to_string(),
token_uuid: token_details.token_uuid.to_string(),
exp: token_details.expires_in.unwrap(),
iat: now.timestamp(),
nbf: now.timestamp(),
};
let header = Header::new(Algorithm::RS256);
let key = EncodingKey::from_rsa_pem(private_key.as_bytes())?;
let token = encode(&header, &claims, &key)?;
token_details.token = Some(token);
Ok(token_details)
}
pub fn hash_password(password: &[u8]) -> Result<String, HashError> {
pub fn hash(str: &str) -> ApiResult<String> {
let salt = SaltString::generate(&mut OsRng);
Ok(
Argon2::default()
.hash_password(password, &salt)?
.to_string(),
)
let bytes = str.as_bytes();
let hash = Argon2::default().hash_password(bytes, &salt)?.to_string();
Ok(hash)
}
pub fn verify_password(hash: &str, password: &[u8]) -> Result<(), HashError> {
let parsed_hash = PasswordHash::new(hash)?;
Ok(Argon2::default().verify_password(password, &parsed_hash)?)
pub fn verify_hash(str: &str, hash: &str) -> bool {
let bytes = str.as_bytes();
let parsed_hash = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
match Argon2::default().verify_password(bytes, &parsed_hash) {
Ok(_) => true,
Err(_) => false,
}
}
pub fn verify_role(auth: &Auth, role: &str) -> ApiResult<()> {
if auth.user.role == role {
Ok(())
} else {
Err(ApiError {
status: 403,
message: "User does not have permission to perform this action.".to_string(),
})
}
}

View File

@@ -1,156 +1,29 @@
use std::{
future::{ready, Ready},
env,
};
use std::future::Future;
use std::pin::Pin;
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http};
use diesel::prelude::*;
use log::error;
use redis::Commands;
use serde::{Serialize, Deserialize};
use crate::error_handler::ServiceError;
use crate::{
error::ApiError,
users::{User, UserResponse},
};
use crate::db::{schema::users, connection};
use super::{hash_password, verify_token};
use super::{Session, SESSION_COOKIE_NAME};
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterUser {
pub email: String,
pub password: String,
pub first_name: String,
pub last_name: String,
pub struct Auth {
pub session_id: Option<String>,
pub user: UserResponse,
}
impl RegisterUser {
pub fn convert_to_insert(self) -> Result<InsertUser, ServiceError> {
let hash = hash_password(self.password.as_bytes())?;
Ok(InsertUser {
email: self.email.to_lowercase(),
hash,
role: "user".to_string(),
first_name: self.first_name,
last_name: self.last_name,
updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(),
profile_picture: None,
favorites: vec![],
verified: false,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = users)]
pub struct QueryUser {
pub email: String,
pub hash: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
impl QueryUser {
pub fn get_by_email(email: &str) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
// Check if the user exists by email, case insensitive
let user = users::table
.filter(users::email.eq(email.to_lowercase()))
.first(&mut conn)?;
Ok(user)
}
}
#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize)]
#[diesel(table_name = users)]
pub struct InsertUser {
pub email: String,
pub hash: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
impl InsertUser {
pub fn insert(user: Self) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::insert_into(users::table)
.values(user)
.get_result(&mut conn)?;
Ok(user)
}
pub fn update_profile_picture(
email: &str,
profile_picture: Option<&str>,
) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
.set(users::profile_picture.eq(profile_picture))
.get_result(&mut conn)?;
Ok(user)
}
pub fn update_favorites(email: &str, favorites: Vec<String>) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
.set(users::favorites.eq(favorites))
.get_result(&mut conn)?;
Ok(user)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResponseUser {
pub email: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub profile_picture: Option<String>,
}
impl From<QueryUser> for ResponseUser {
fn from(user: QueryUser) -> Self {
ResponseUser {
email: user.email,
role: user.role,
first_name: user.first_name,
last_name: user.last_name,
profile_picture: user.profile_picture,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JwtAuth {
pub token: uuid::Uuid,
pub user: ResponseUser,
}
impl FromRequest for JwtAuth {
impl FromRequest for Auth {
type Error = ActixError;
type Future = Ready<Result<Self, Self::Error>>;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let access_token = match req
.cookie("access_token")
// Get session ID from request
let session_id = match req
.cookie(SESSION_COOKIE_NAME)
.map(|c| c.value().to_string())
.or_else(|| {
req
@@ -158,75 +31,37 @@ impl FromRequest for JwtAuth {
.get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
}) {
Some(token) => token,
Some(id) => id,
None => {
return ready(Err(ActixError::from(ServiceError {
status: 401,
message: "Unauthorized".to_string(),
})))
let fut = async {
Err(
ApiError {
status: 401,
message: "No session ID found in the request".to_string(),
}
.into(),
)
};
return Box::pin(fut);
}
};
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
let public_key = std::fs::read_to_string(format!("{}/access_public_key.pem", keys_dir))
.expect("Failed to read access public key");
// Get IP address from request
let ip_address = req.peer_addr().unwrap().ip().to_string();
let access_token_details = match verify_token(&access_token, &public_key) {
Ok(token_details) => token_details,
Err(err) => {
error!("Failed to verify access token: {}", err);
return ready(Err(ActixError::from(ServiceError {
status: 401,
message: format!("Failed to verify access token: {}", err),
})));
// Verify the session
let fut = async move {
match Session::verify(&session_id, &ip_address).await {
Ok(session) => match User::get_by_email(&session.email) {
Ok(user) => Ok(Auth {
session_id: Some(session_id),
user: user.into(),
}),
Err(err) => Err(err.into()),
},
Err(err) => Err(err.into()),
}
};
let access_token_uuid =
uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
let mut conn = match crate::db::redis_connection() {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ready(Err(ActixError::from(ServiceError {
status: 500,
message: format!("Failed to get redis connection: {}", err),
})));
}
};
let user_email = match conn.get::<_, String>(access_token_uuid.clone().to_string()) {
Ok(result) => result,
Err(_) => {
return ready(Err(ActixError::from(ServiceError {
status: 401,
message: format!("Access token was not found"),
})))
}
};
match QueryUser::get_by_email(&user_email) {
Ok(user) => ready(Ok(JwtAuth {
token: access_token_uuid,
user: user.into(),
})),
Err(_) => {
return ready(Err(ActixError::from(ServiceError {
status: 401,
message: format!("User was not found"),
})))
}
}
}
}
pub fn verify_role(auth: &JwtAuth, role: &str) -> Result<(), ServiceError> {
if auth.user.role == role {
Ok(())
} else {
Err(ServiceError {
status: 403,
message: "Forbidden".to_string(),
})
Box::pin(fut)
}
}

View File

@@ -1,32 +1,28 @@
use std::env;
use actix_web::{
get, post, web, HttpResponse, ResponseError,
post, web, HttpResponse, ResponseError,
cookie::{Cookie, time::Duration},
HttpRequest,
};
use log::error;
use redis::AsyncCommands;
use serde::{Serialize, Deserialize};
use crate::{error_handler::ServiceError, db::Response};
use crate::{
auth::{
LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token,
generate_access_token, generate_refresh_token,
},
db,
auth::{verify_hash, Session, SESSION_COOKIE_NAME},
error::ApiError,
users::{LoginRequest, RegisterRequest, User, UserResponse},
};
use crate::auth::Auth;
#[post("/register")]
async fn register(user: web::Json<RegisterUser>) -> HttpResponse {
async fn register(user: web::Json<RegisterRequest>) -> HttpResponse {
let register_user = user.0;
let insert_user: InsertUser = match register_user.convert_to_insert() {
let insert_user: User = match register_user.to_user() {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
match InsertUser::insert(insert_user) {
Ok(_) => HttpResponse::Created().finish(),
match User::insert(insert_user) {
Ok(user) => {
let response: UserResponse = user.into();
HttpResponse::Created().json(response)
},
Err(err) => {
// Obfuscate the service error message to prevent leaking database details
if err.status == 409 {
@@ -39,430 +35,64 @@ async fn register(user: web::Json<RegisterUser>) -> HttpResponse {
}
#[post("/login")]
async fn login(request: web::Json<LoginRequest>) -> HttpResponse {
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = request.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match QueryUser::get_by_email(&email) {
let query_user = match User::get_by_email(&email) {
Ok(query_user) => query_user,
Err(err) => return ResponseError::error_response(&err),
};
let hash = &query_user.hash;
let password = request.password.as_bytes();
match verify_password(hash, password) {
Ok(_) => {
let access_token_details = match generate_access_token(&email) {
Ok(token_details) => token_details,
Err(err) => {
error!("Failed to generate access token: {}", err);
return ResponseError::error_response(&err);
}
};
let refresh_token_details = match generate_refresh_token(&email) {
Ok(token_details) => token_details,
Err(err) => {
error!("Failed to generate refresh token: {}", err);
return ResponseError::error_response(&err);
}
};
let mut conn = match db::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ResponseError::error_response(&err);
}
};
let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE")
.expect("ACCESS_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("ACCESS_TOKEN_MAXAGE must be an integer");
let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE")
.expect("REFRESH_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("REFRESH_TOKEN_MAXAGE must be an integer");
let access_result: redis::RedisResult<()> = conn
.set_ex(
access_token_details.token_uuid.to_string(),
&email,
(access_token_max_age * 60) as u64,
)
.await;
if let Err(err) = access_result {
error!("Failed to set access token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set access token in redis: {}", err),
});
};
let refresh_result: redis::RedisResult<()> = conn
.set_ex(
refresh_token_details.token_uuid.to_string(),
&email,
(refresh_token_max_age * 60) as u64,
)
.await;
if let Err(err) = refresh_result {
error!("Failed to set refresh token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set refresh token in redis: {}", err),
});
};
let access_cookie =
Cookie::build("access_token", access_token_details.token.clone().unwrap())
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
let refresh_cookie = Cookie::build(
"refresh_token",
refresh_token_details.token.clone().unwrap(),
)
.path("/")
.max_age(Duration::new(refresh_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
let logged_in_cookie = Cookie::build("logged_in", "true")
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(false)
.finish();
let access_token_uuid =
uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
HttpResponse::Ok()
.cookie(access_cookie)
.cookie(refresh_cookie)
.cookie(logged_in_cookie)
.json(JwtAuth {
token: access_token_uuid,
user: query_user.into(),
})
}
Err(err) => ResponseError::error_response(&ServiceError {
status: 401,
message: err.to_string(),
}),
}
}
#[get("/session")]
async fn session(req: HttpRequest) -> HttpResponse {
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
// If there is a access_token cookie, check if it is valid
let has_session = match req.cookie("access_token") {
Some(cookie) => {
let access_token = cookie.value().to_string();
let public_key = std::fs::read_to_string(format!("{}access_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
match verify_token(&access_token, &public_key) {
Ok(_) => true,
Err(_) => false,
}
}
None => false,
};
if !has_session {
// If there is a refresh_token cookie, check if it is valid
match req.cookie("refresh_token") {
Some(cookie) => {
let refresh_token = cookie.value().to_string();
let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
match verify_token(&refresh_token, &public_key) {
Ok(_) => return HttpResponse::Ok().json(true),
Err(_) => return HttpResponse::Ok().json(false),
};
}
None => return HttpResponse::Ok().json(false),
};
} else {
return HttpResponse::Ok().json(true);
}
}
#[derive(Serialize, Deserialize)]
struct RefreshParams {
refresh_token_rotation: Option<bool>,
}
#[get("/refresh")]
async fn refresh(req: HttpRequest) -> HttpResponse {
let params = match web::Query::<RefreshParams>::from_query(req.query_string()) {
Ok(params) => params,
Err(err) => {
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string(),
})
log::error!("{}", err);
return ResponseError::error_response(&err);
}
};
let refresh_token = match req.cookie("refresh_token") {
Some(cookie) => cookie.value().to_string(),
None => {
return ResponseError::error_response(&ServiceError {
status: 401,
message: "Refresh token not found".to_string(),
})
if verify_hash(&query_user.hash, &request.password) {
// Create a session
let session = Session::new(&email, &ip_address);
let session_cookie = session.cookie();
// Save the session to the database
if let Err(err) = session.store().await {
log::error!("Failed to store session");
return ResponseError::error_response(&ApiError::new(500, err.to_string()));
}
};
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
let refresh_token_details = match verify_token(&refresh_token, &public_key) {
Ok(token_details) => token_details,
Err(err) => return ResponseError::error_response(&err),
};
let email = refresh_token_details.email.clone();
match QueryUser::get_by_email(&email) {
Ok(query_user) => {
let access_token_details = match generate_access_token(&email) {
Ok(token_details) => token_details,
Err(err) => {
error!("Failed to generate access token: {}", err);
return ResponseError::error_response(&err);
}
};
let mut conn = match db::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ResponseError::error_response(&err);
}
};
// Delete old auth token if it exists
match req.cookie("access_token") {
Some(cookie) => {
let access_token = cookie.value().to_string();
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
let public_key = std::fs::read_to_string(format!("{}/access_public_key.pem", keys_dir))
.expect("Unable to read access public key");
match verify_token(&access_token, &public_key) {
Ok(token_details) => {
let _: redis::RedisResult<()> = conn.del(token_details.token_uuid.to_string()).await;
}
Err(_) => {}
};
}
None => {}
};
let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE")
.expect("ACCESS_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("ACCESS_TOKEN_MAXAGE must be an integer");
let access_result: redis::RedisResult<()> = conn
.set_ex(
access_token_details.token_uuid.to_string(),
&email,
(access_token_max_age * 60) as u64,
)
.await;
if let Err(err) = access_result {
error!("Failed to set access token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set access token in redis: {}", err),
});
};
let access_cookie =
Cookie::build("access_token", access_token_details.token.clone().unwrap())
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
let logged_in_cookie = Cookie::build("logged_in", "true")
.path("/")
.max_age(Duration::new(access_token_max_age * 60, 0))
.http_only(false)
.finish();
let access_token_uuid =
uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
// Refresh the refresh token if requested
let refresh_token_rotation = match params.refresh_token_rotation {
Some(refresh_token_rotation) => refresh_token_rotation,
None => false,
};
if refresh_token_rotation {
// Delete the old refresh token
let _: redis::RedisResult<()> =
conn.del(refresh_token_details.token_uuid.to_string()).await;
let refresh_token_details = match generate_refresh_token(&refresh_token_details.email) {
Ok(token_details) => token_details,
Err(err) => {
error!("Failed to generate refresh token: {}", err);
return ResponseError::error_response(&err);
}
};
let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE")
.expect("REFRESH_TOKEN_MAXAGE must be set")
.parse::<i64>()
.expect("REFRESH_TOKEN_MAXAGE must be an integer");
let refresh_result: redis::RedisResult<()> = conn
.set_ex(
refresh_token_details.token_uuid.to_string(),
&refresh_token_details.email,
(refresh_token_max_age * 60) as u64,
)
.await;
if let Err(err) = refresh_result {
error!("Failed to set refresh token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set refresh token in redis: {}", err),
});
};
let refresh_cookie = Cookie::build(
"refresh_token",
refresh_token_details.token.clone().unwrap(),
)
.path("/")
.max_age(Duration::new(refresh_token_max_age * 60, 0))
.http_only(true)
.secure(true)
.finish();
HttpResponse::Ok()
.cookie(refresh_cookie)
.cookie(access_cookie)
.cookie(logged_in_cookie)
.json(JwtAuth {
token: access_token_uuid,
user: query_user.into(),
})
} else {
HttpResponse::Ok()
.cookie(access_cookie)
.cookie(logged_in_cookie)
.json(JwtAuth {
token: access_token_uuid,
user: query_user.into(),
})
}
}
Err(err) => return ResponseError::error_response(&err),
return HttpResponse::Ok().cookie(session_cookie).finish();
} else {
log::error!("Invalid login attempt for {}", email);
return HttpResponse::Unauthorized().finish();
}
}
#[post("/logout")]
async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
let refresh_token = match req.cookie("refresh_token") {
Some(cookie) => cookie.value().to_string(),
async fn logout(req: HttpRequest, _auth: Auth) -> HttpResponse {
// 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(&session_id).await {
log::error!("Failed to delete session");
return ResponseError::error_response(&ApiError::new(500, err.to_string()));
}
}
None => {
return ResponseError::error_response(&ServiceError {
status: 401,
message: "Refresh token not found".to_string(),
})
return ResponseError::error_response(&ApiError::new(400, "Invalid session".to_string()));
}
};
let keys_dir = env::var("KEYS_DIR_PATH").expect("KEYS_DIR_PATH must be set");
let public_key = std::fs::read_to_string(format!("{}/refresh_public_key.pem", keys_dir))
.expect("Unable to read refresh public key");
let refresh_token_details = match verify_token(&refresh_token, &public_key) {
Ok(token_details) => token_details,
Err(err) => return ResponseError::error_response(&err),
};
}
let mut conn = match db::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
return ResponseError::error_response(&err);
}
};
let access_result: redis::RedisResult<()> = conn
.del(&[
refresh_token_details.token_uuid.to_string(),
auth.token.to_string(),
])
.await;
if let Err(err) = access_result {
error!("Failed to set access token in redis: {}", err);
return ResponseError::error_response(&ServiceError {
status: 500,
message: format!("Failed to set access token in redis: {}", err),
});
};
let access_cookie = Cookie::build("access_token", "")
let session_cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::new(-1, 0))
.http_only(true)
.finish();
let refresh_cookie = Cookie::build("refresh_token", "")
.path("/")
.max_age(Duration::new(-1, 0))
.http_only(true)
.finish();
let logged_in_cookie = Cookie::build("logged_in", "")
.path("/")
.max_age(Duration::new(-1, 0))
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(true)
.finish();
HttpResponse::Ok()
.cookie(access_cookie)
.cookie(refresh_cookie)
.cookie(logged_in_cookie)
.finish()
}
#[get("/me")]
async fn me(auth: JwtAuth) -> HttpResponse {
HttpResponse::Ok().json(auth)
}
#[get("/roles")]
async fn roles() -> HttpResponse {
HttpResponse::Ok().json(Response {
data: vec!["admin", "user"],
meta: None,
})
HttpResponse::Ok().cookie(session_cookie).finish()
}
pub fn init_routes(config: &mut web::ServiceConfig) {
let r = RegisterUser {
email: "admin".to_string(),
password: "admin".to_string(),
first_name: "Admin".to_string(),
last_name: "Admin".to_string(),
};
let mut u = r.convert_to_insert().unwrap();
u.role = "admin".to_string();
u.verified = true;
let _ = InsertUser::insert(u);
config.service(
web::scope("auth")
.service(register)
.service(login)
.service(refresh)
.service(logout)
.service(me)
.service(roles),
.service(logout),
);
}

View File

@@ -0,0 +1,91 @@
use actix_web::cookie::{time::Duration, Cookie};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use crate::{
db::redis_async_connection,
error::{ApiError, ApiResult},
};
use super::{csprng_128bit, hash, verify_hash};
pub const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub email: String,
pub ip_address: String,
pub expires_at: DateTime<Utc>,
}
impl Session {
pub fn new(email: &str, ip_address: &str) -> Self {
let now = chrono::Utc::now();
Self {
session_id: csprng_128bit(32),
email: email.to_string(),
ip_address: hash(&ip_address).unwrap(),
expires_at: now + chrono::Duration::seconds(DEFAULT_SESSION_TTL),
}
}
pub async fn store(&self) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let key = self.session_id.clone();
let value = serde_json::to_string(self)?;
let result: RedisResult<()> = conn.set_ex(key, &value, DEFAULT_SESSION_TTL as u64).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(session_id: &str) -> ApiResult<Option<Self>> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(session_id).await;
match result {
Ok(Some(value)) => Ok(Some(serde_json::from_str(&value)?)),
Ok(None) => Ok(None),
Err(err) => Err(err.into()),
}
}
pub async fn delete(session_id: &str) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<()> = conn.del(session_id).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult<Self> {
// Check if the session exists
let session = match Self::get(session_id).await? {
Some(session) => session,
None => return Err(ApiError::new(401, "Session does not exist".to_string())),
};
// Check if the IP Address matches the Session's IP Address
if verify_hash(ip_address, &session.ip_address) {
return Ok(session);
} else {
return Err(ApiError::new(
401,
"IP Address does not match".to_string(),
));
}
}
pub fn cookie(&self) -> Cookie {
Cookie::build(SESSION_COOKIE_NAME, self.session_id.clone())
.path("/")
.max_age(Duration::seconds(DEFAULT_SESSION_TTL))
.secure(true)
.http_only(true)
.finish()
}
}

View File

@@ -1,4 +1,4 @@
use crate::error_handler::ServiceError;
use crate::error::{ApiError, ApiResult};
use diesel::{r2d2::ConnectionManager, PgConnection};
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection};
use s3::{
@@ -76,7 +76,7 @@ pub async fn init() {
Ok(_) => info!("Bucket initialized"),
Err(err) => match err.status {
409 => warn!("Bucket already exists"),
_ => error!("Failed to initialize bucket; {}", err.message),
_ => error!("Failed to initialize bucket; {}", err),
},
};
let mut pool: DbConnection = connection().expect("Failed to get db connection");
@@ -86,23 +86,23 @@ pub async fn init() {
};
}
pub fn connection() -> Result<DbConnection, ServiceError> {
pub fn connection() -> ApiResult<DbConnection> {
POOL
.get()
.map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e)))
.map_err(|e| ApiError::new(500, format!("Failed getting db connection: {}", e)))
}
pub fn redis_connection() -> Result<redis::Connection, ServiceError> {
pub fn redis_connection() -> ApiResult<redis::Connection> {
let conn = REDIS.get_connection()?;
Ok(conn)
}
pub async fn redis_async_connection() -> Result<RedisConnection, ServiceError> {
pub async fn redis_async_connection() -> ApiResult<RedisConnection> {
let conn = REDIS.get_multiplexed_async_connection().await?;
Ok(conn)
}
async fn create_bucket() -> Result<CreateBucketResponse, ServiceError> {
async fn create_bucket() -> ApiResult<CreateBucketResponse> {
let url = env::var("MINIO_URL").unwrap_or("localhost".to_string());
let port = env::var("MINIO_PORT").unwrap_or("9000".to_string());
let user = env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
@@ -132,18 +132,18 @@ async fn create_bucket() -> Result<CreateBucketResponse, ServiceError> {
Ok(response)
}
pub async fn upload_file(path: &str, content: &[u8]) -> Result<ResponseData, ServiceError> {
pub async fn upload_file(path: &str, content: &[u8]) -> ApiResult<ResponseData> {
let response = BUCKET.put_object(path, content).await?;
Ok(response)
}
pub async fn get_file(path: &str) -> Result<Vec<u8>, ServiceError> {
pub async fn get_file(path: &str) -> ApiResult<Vec<u8>> {
let response = BUCKET.get_object(path).await?;
let bytes = response.bytes();
Ok(bytes.to_vec())
}
pub async fn delete_file(path: &str) -> Result<ResponseData, ServiceError> {
pub async fn delete_file(path: &str) -> ApiResult<ResponseData> {
let response = BUCKET.delete_object(path).await?;
Ok(response)
}

154
service/src/error.rs Normal file
View File

@@ -0,0 +1,154 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use diesel::result::Error as DieselError;
use log::warn;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiError {
pub status: u16,
pub message: String,
}
impl ApiError {
pub fn new(status: u16, message: String) -> Self {
Self { status, message }
}
pub fn to_http_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(s) => s,
Err(err) => {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
}
};
HttpResponse::build(status).body(self.message.to_string())
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.message.as_str())
}
}
impl From<std::io::Error> for ApiError {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("Unknown IO error: {}", error))
}
}
impl From<std::env::VarError> for ApiError {
fn from(error: std::env::VarError) -> Self {
Self::new(
500,
format!("Unknown environment variable error: {}", error),
)
}
}
impl From<DieselError> for ApiError {
fn from(error: DieselError) -> Self {
match error {
DieselError::DatabaseError(kind, err) => match kind {
diesel::result::DatabaseErrorKind::UniqueViolation => {
Self::new(409, err.message().to_string())
}
_ => Self::new(500, err.message().to_string()),
},
DieselError::NotFound => Self::new(404, "The record was not found".to_string()),
DieselError::SerializationError(err) => Self::new(422, err.to_string()),
err => Self::new(500, format!("Unknown Diesel error: {}", err)),
}
}
}
impl From<reqwest::Error> for ApiError {
fn from(error: reqwest::Error) -> Self {
Self::new(500, format!("Unknown reqwest error: {}", error))
}
}
impl From<serde_json::Error> for ApiError {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("Unknown serde_json error: {}", error))
}
}
impl From<argon2::password_hash::Error> for ApiError {
fn from(error: argon2::password_hash::Error) -> Self {
Self::new(500, format!("Unknown argon2 error: {}", error))
}
}
impl From<jsonwebtoken::errors::Error> for ApiError {
fn from(error: jsonwebtoken::errors::Error) -> Self {
Self::new(500, format!("Unknown jsonwebtoken error: {}", error))
}
}
impl From<redis::RedisError> for ApiError {
fn from(error: redis::RedisError) -> Self {
Self::new(500, format!("Unknown redis error: {}", error))
}
}
impl From<s3::error::S3Error> for ApiError {
fn from(error: s3::error::S3Error) -> Self {
match error {
s3::error::S3Error::Credentials(err) => {
Self::new(500, format!("Unknown s3 credentials error: {}", err))
}
s3::error::S3Error::FromUtf8(err) => {
Self::new(500, format!("Unknown s3 from utf8 error: {}", err))
}
s3::error::S3Error::FmtError(err) => {
Self::new(500, format!("Unknown s3 fmt error: {}", err))
}
s3::error::S3Error::HeaderToStr(err) => {
Self::new(500, format!("Unknown s3 header to str error: {}", err))
}
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
500,
format!("Unknown s3 hmac invalid length error: {}", err),
),
s3::error::S3Error::Http(error) => {
Self::new(error.status_code().as_u16(), error.to_string())
}
_ => {
let re = Regex::new(r"HTTP (\d{3})").unwrap();
// Apply the regex to the input string
if let Some(captures) = re.captures(&error.to_string()) {
if let Some(http_code_str) = captures.get(1) {
if let Ok(http_code) = http_code_str.as_str().parse::<u16>() {
return Self::new(http_code, error.to_string());
}
}
}
Self::new(500, format!("Unknown s3 error: {}", error))
}
}
}
}
impl ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(status) => status,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = match status.as_u16() < 500 {
true => self.message.clone(),
false => "Internal server error".to_string(),
};
HttpResponse::build(status).json(json!({ "status": status.as_u16(), "message": message }))
}
}

View File

@@ -1,138 +0,0 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use diesel::result::Error as DieselError;
use log::warn;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
#[derive(Debug, Deserialize, Serialize)]
pub struct ServiceError {
pub status: u16,
pub message: String,
}
impl ServiceError {
pub fn new(status: u16, message: String) -> ServiceError {
ServiceError { status, message }
}
pub fn to_http_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(s) => s,
Err(err) => {
warn!("{}", err);
StatusCode::INTERNAL_SERVER_ERROR
}
};
HttpResponse::build(status).body(self.message.to_string())
}
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.message.as_str())
}
}
impl From<std::io::Error> for ServiceError {
fn from(error: std::io::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown IO error: {}", error))
}
}
impl From<std::env::VarError> for ServiceError {
fn from(error: std::env::VarError) -> ServiceError {
ServiceError::new(
500,
format!("Unknown environment variable error: {}", error),
)
}
}
impl From<DieselError> for ServiceError {
fn from(error: DieselError) -> ServiceError {
match error {
DieselError::DatabaseError(kind, err) => match kind {
diesel::result::DatabaseErrorKind::UniqueViolation => {
ServiceError::new(409, err.message().to_string())
}
_ => ServiceError::new(500, err.message().to_string()),
},
DieselError::NotFound => ServiceError::new(404, "The record was not found".to_string()),
DieselError::SerializationError(err) => ServiceError::new(422, err.to_string()),
err => ServiceError::new(500, format!("Unknown Diesel error: {}", err)),
}
}
}
impl From<reqwest::Error> for ServiceError {
fn from(error: reqwest::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown reqwest error: {}", error))
}
}
impl From<serde_json::Error> for ServiceError {
fn from(error: serde_json::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown serde_json error: {}", error))
}
}
impl From<argon2::password_hash::Error> for ServiceError {
fn from(error: argon2::password_hash::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown argon2 error: {}", error))
}
}
impl From<jsonwebtoken::errors::Error> for ServiceError {
fn from(error: jsonwebtoken::errors::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown jsonwebtoken error: {}", error))
}
}
impl From<redis::RedisError> for ServiceError {
fn from(error: redis::RedisError) -> ServiceError {
ServiceError::new(500, format!("Unknown redis error: {}", error))
}
}
impl From<s3::error::S3Error> for ServiceError {
fn from(error: s3::error::S3Error) -> ServiceError {
match error {
s3::error::S3Error::Credentials(err) => {
ServiceError::new(500, format!("Unknown s3 credentials error: {}", err))
}
s3::error::S3Error::FromUtf8(err) => {
ServiceError::new(500, format!("Unknown s3 from utf8 error: {}", err))
}
s3::error::S3Error::FmtError(err) => {
ServiceError::new(500, format!("Unknown s3 fmt error: {}", err))
}
s3::error::S3Error::HeaderToStr(err) => {
ServiceError::new(500, format!("Unknown s3 header to str error: {}", err))
}
s3::error::S3Error::HmacInvalidLength(err) => ServiceError::new(
500,
format!("Unknown s3 hmac invalid length error: {}", err),
),
s3::error::S3Error::Http(error) => ServiceError::new(error.status_code().as_u16(), error.to_string()),
_ => ServiceError::new(500, format!("Unknown s3 error: {}", error)),
}
}
}
impl ResponseError for ServiceError {
fn error_response(&self) -> HttpResponse {
let status = match StatusCode::from_u16(self.status) {
Ok(status) => status,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = match status.as_u16() < 500 {
true => self.message.clone(),
false => "Internal server error".to_string(),
};
HttpResponse::build(status).json(json!({ "status": status.as_u16(), "message": message }))
}
}

View File

@@ -7,12 +7,11 @@ use std::env;
use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger};
use dotenv::dotenv;
use log::{info, error};
mod airports;
mod auth;
mod db;
mod error_handler;
mod error;
mod metars;
mod scheduler;
mod users;
@@ -45,11 +44,11 @@ async fn main() -> std::io::Result<()> {
.bind(format!("{}:{}", host, port))
{
Ok(b) => {
info!("Binding server to {}:{}", host, port);
log::info!("Binding server to {}:{}", host, port);
b
}
Err(err) => {
error!("Could not bind server: {}", err);
log::error!("Could not bind server: {}", err);
return Err(err);
}
};

View File

@@ -1,5 +1,6 @@
use crate::airports::QueryAirport;
use crate::{error_handler::ServiceError, db};
use crate::error::ApiError;
use crate::{error::ApiResult, db};
use crate::db::schema::metars::{self};
use chrono::Datelike;
use diesel::{prelude::*, sql_query};
@@ -160,7 +161,7 @@ impl Default for Metar {
}
impl Metar {
fn parse(metar_strings: Vec<&str>) -> Result<Vec<Self>, ServiceError> {
fn parse(metar_strings: Vec<&str>) -> ApiResult<Vec<Self>> {
let mut metars: Vec<Self> = vec![];
for metar_string in metar_strings {
trace!("Parsing METAR data: {}", metar_string);
@@ -674,7 +675,7 @@ impl Metar {
return missing_metar_icaos;
}
async fn get_remote_metars(icaos: Vec<String>) -> Result<Vec<Metar>, ServiceError> {
async fn get_remote_metars(icaos: Vec<String>) -> ApiResult<Vec<Metar>> {
let gov_api_url = std::env::var("GOV_API_URL").expect("GOV_API_URL must be set");
// Query the remote API for the missing METAR data 10 at a time
let icao_chunks = icaos
@@ -688,7 +689,7 @@ impl Metar {
Ok(r) => {
// Check if the status code is 200
if r.status() != 200 {
return Err(ServiceError::new(
return Err(ApiError::new(
500,
format!("Unable to get METAR request: {}", r.status()),
));
@@ -706,7 +707,7 @@ impl Metar {
}
}
Err(err) => {
return Err(ServiceError::new(
return Err(ApiError::new(
500,
format!("Unable to parse METAR request: {}", err),
))
@@ -714,7 +715,7 @@ impl Metar {
}
}
Err(err) => {
return Err(ServiceError::new(
return Err(ApiError::new(
500,
format!("Unable to get METAR request: {}", err),
))
@@ -749,7 +750,7 @@ impl Metar {
return insert_metars;
}
pub async fn get_all(icao_string: String) -> Result<Vec<Self>, ServiceError> {
pub async fn get_all(icao_string: String) -> ApiResult<Vec<Self>> {
if icao_string.is_empty() {
return Ok(vec![]);
}
@@ -834,14 +835,14 @@ struct InsertMetar {
}
impl InsertMetar {
fn insert(metars: &Vec<Self>) -> Result<usize, ServiceError> {
fn insert(metars: &Vec<Self>) -> ApiResult<usize> {
let mut conn = db::connection()?;
match diesel::insert_into(metars::table)
.values(metars)
.execute(&mut conn)
{
Ok(rows) => Ok(rows),
Err(err) => Err(ServiceError {
Err(err) => Err(ApiError {
status: 500,
message: format!("{}", err),
}),
@@ -860,7 +861,7 @@ struct QueryMetar {
}
impl QueryMetar {
fn get_all(icaos: &Vec<&str>) -> Result<Vec<QueryMetar>, ServiceError> {
fn get_all(icaos: &Vec<&str>) -> ApiResult<Vec<QueryMetar>> {
// Sanitize search to only allow [a-zA-Z0-9]
let icaos = icaos
.iter()
@@ -880,7 +881,7 @@ impl QueryMetar {
format!("SELECT DISTINCT ON (icao) * FROM metars WHERE icao IN ({}) ORDER BY icao, observation_time DESC", station_query.join(","))
).load(&mut conn) {
Ok(m) => m,
Err(err) => return Err(ServiceError { status: 500, message: format!("{}", err) })
Err(err) => return Err(ApiError { status: 500, message: format!("{}", err) })
};
return Ok(db_metars);
}

View File

@@ -1,4 +1,4 @@
use crate::{error_handler::ServiceError, db::Metadata};
use crate::{error::ApiError, db::Metadata};
use crate::metars::Metar;
use actix_web::{get, web, HttpResponse, HttpRequest};
use log::error;
@@ -25,7 +25,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
};
let metars =
match web::block(|| Ok::<_, ServiceError>(async { Metar::get_all(icao_string).await }))
match web::block(|| Ok::<_, ApiError>(async { Metar::get_all(icao_string).await }))
.await
.unwrap()
.unwrap()

View File

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

108
service/src/users/model.rs Normal file
View File

@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
use diesel::prelude::*;
use crate::{
auth::hash,
db::{connection, schema::users},
error::ApiResult,
};
/**
* RegisterRequest
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub first_name: String,
pub last_name: String,
}
impl RegisterRequest {
pub fn to_user(self) -> ApiResult<User> {
let hash = hash(&self.password)?;
Ok(User {
email: self.email.to_lowercase(),
hash,
role: "user".to_string(),
first_name: self.first_name,
last_name: self.last_name,
updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(),
profile_picture: None,
favorites: vec![],
verified: false,
})
}
}
/**
* LoginRequest
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
/**
* UserResponse
*/
#[derive(Debug, Serialize, Deserialize)]
pub struct UserResponse {
pub email: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub profile_picture: Option<String>,
}
impl From<User> for UserResponse {
fn from(user: User) -> Self {
UserResponse {
email: user.email,
role: user.role,
first_name: user.first_name,
last_name: user.last_name,
profile_picture: user.profile_picture,
}
}
}
/**
* User
*/
#[derive(Debug, Insertable, AsChangeset, Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = users)]
pub struct User {
pub email: String,
pub hash: String,
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
impl User {
pub fn get_by_email(email: &str) -> ApiResult<User> {
let mut conn = connection()?;
// Check if the user exists by email, case insensitive
let user = users::table
.filter(users::email.eq(email.to_lowercase()))
.first(&mut conn)?;
Ok(user)
}
pub fn insert(user: Self) -> ApiResult<User> {
let mut conn = connection()?;
let user = diesel::insert_into(users::table)
.values(user)
.get_result(&mut conn)?;
Ok(user)
}
}

View File

@@ -1,164 +1,165 @@
use actix_multipart::Multipart;
use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
use futures_util::StreamExt;
// use actix_multipart::Multipart;
// use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
// use futures_util::StreamExt;
use crate::{
auth::{JwtAuth, QueryUser, InsertUser},
error_handler::ServiceError,
db::{upload_file, get_file, delete_file},
};
// use crate::{
// auth::Auth,
// db::{delete_file, get_file, upload_file},
// error::ServiceError,
// users::User,
// };
#[get("/favorites")]
async fn get_favorites(auth: JwtAuth) -> HttpResponse {
match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => return HttpResponse::Ok().json(user.favorites),
Err(err) => return ResponseError::error_response(&err),
}
}
#[post("/favorites/{icao}")]
async fn add_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse {
match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => {
if user.favorites.contains(&icao) {
// Check if the airport ICAO is already in the user's favorites
return HttpResponse::Conflict().finish();
} else {
// Add the airport ICAO to the user's favorites
let mut favorites = user.favorites;
favorites.push(icao.into_inner());
match InsertUser::update_favorites(&user.email, favorites) {
Ok(_) => return HttpResponse::Ok().finish(),
Err(err) => return ResponseError::error_response(&err),
}
}
}
Err(err) => return ResponseError::error_response(&err),
}
}
#[delete("/favorites/{icao}")]
async fn delete_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse {
let icao: String = icao.into_inner();
match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => {
if user.favorites.contains(&icao) {
// Check if the airport ICAO is already in the user's favorites
let mut favorites = user.favorites;
favorites.retain(|x| x != &icao);
match InsertUser::update_favorites(&user.email, favorites) {
Ok(_) => return HttpResponse::Ok().finish(),
Err(err) => return ResponseError::error_response(&err),
}
} else {
// Remove the airport ICAO from the user's favorites
return HttpResponse::Conflict().finish();
}
}
Err(err) => return ResponseError::error_response(&err),
}
}
#[post("/picture")]
async fn set_picture(mut payload: Multipart, auth: JwtAuth) -> HttpResponse {
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),
};
let content_type = field.content_disposition();
let filename = match content_type.unwrap().get_filename() {
Some(name) => match name.split(".").last() {
Some(ext) => match ext {
"apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg"
| "webp" => name,
_ => {
return ResponseError::error_response(&ServiceError::new(
400,
"File extension is not supported".to_string(),
))
}
},
None => {
return ResponseError::error_response(&ServiceError::new(
400,
"Unknown file extension".to_string(),
))
}
},
None => {
return ResponseError::error_response(&ServiceError::new(
400,
"File name is not provided".to_string(),
))
}
};
let path = format!("users/{}/{}", auth.user.email, filename);
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(data) => data,
Err(err) => return ResponseError::error_response(&err),
};
bytes.extend_from_slice(&data);
}
match upload_file(&path, &bytes).await {
Ok(_) => {
match InsertUser::update_profile_picture(&auth.user.email, Some(&path)) {
Ok(_) => {}
Err(err) => return ResponseError::error_response(&err),
};
}
Err(err) => return ResponseError::error_response(&err),
};
}
HttpResponse::Ok().finish()
}
#[get("/picture")]
async fn get_picture(auth: JwtAuth) -> HttpResponse {
let user = match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
if let Some(path) = user.profile_picture {
match get_file(&path).await {
Ok(bytes) => HttpResponse::Ok().body(bytes),
Err(err) => ResponseError::error_response(&err),
}
} else {
HttpResponse::NotFound().finish()
}
}
#[delete("/picture")]
async fn delete_picture(auth: JwtAuth) -> HttpResponse {
let user = match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
if let Some(path) = user.profile_picture {
match delete_file(&path).await {
Ok(_) => match InsertUser::update_profile_picture(&auth.user.email, None) {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => ResponseError::error_response(&err),
},
Err(err) => ResponseError::error_response(&err),
}
} else {
HttpResponse::NotFound().finish()
}
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(
web::scope("users")
.service(get_favorites)
.service(add_favorite)
.service(delete_favorite)
.service(set_picture)
.service(get_picture)
.service(delete_picture),
);
// #[get("/favorites")]
// async fn get_favorites(auth: Auth) -> HttpResponse {
// match User::get_by_email(&auth.user.email) {
// Ok(user) => return HttpResponse::Ok().json(user.favorites),
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// #[post("/favorites/{icao}")]
// async fn add_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
// match User::get_by_email(&auth.user.email) {
// Ok(user) => {
// if user.favorites.contains(&icao) {
// // Check if the airport ICAO is already in the user's favorites
// return HttpResponse::Conflict().finish();
// } else {
// // Add the airport ICAO to the user's favorites
// let mut favorites = user.favorites;
// favorites.push(icao.into_inner());
// match User::update_favorites(&user.email, favorites) {
// Ok(_) => return HttpResponse::Ok().finish(),
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// }
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// #[delete("/favorites/{icao}")]
// async fn delete_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
// let icao: String = icao.into_inner();
// match User::get_by_email(&auth.user.email) {
// Ok(user) => {
// if user.favorites.contains(&icao) {
// // Check if the airport ICAO is already in the user's favorites
// let mut favorites = user.favorites;
// favorites.retain(|x| x != &icao);
// match User::update_favorites(&user.email, favorites) {
// Ok(_) => return HttpResponse::Ok().finish(),
// Err(err) => return ResponseError::error_response(&err),
// }
// } else {
// // Remove the airport ICAO from the user's favorites
// return HttpResponse::Conflict().finish();
// }
// }
// Err(err) => return ResponseError::error_response(&err),
// }
// }
// #[post("/picture")]
// async fn set_picture(mut payload: Multipart, auth: Auth) -> HttpResponse {
// 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),
// };
// let content_type = field.content_disposition();
// let filename = match content_type.unwrap().get_filename() {
// Some(name) => match name.split(".").last() {
// Some(ext) => match ext {
// "apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg"
// | "webp" => name,
// _ => {
// return ResponseError::error_response(&ServiceError::new(
// 400,
// "File extension is not supported".to_string(),
// ))
// }
// },
// None => {
// return ResponseError::error_response(&ServiceError::new(
// 400,
// "Unknown file extension".to_string(),
// ))
// }
// },
// None => {
// return ResponseError::error_response(&ServiceError::new(
// 400,
// "File name is not provided".to_string(),
// ))
// }
// };
// let path = format!("users/{}/{}", auth.user.email, filename);
// while let Some(chunk) = field.next().await {
// let data = match chunk {
// Ok(data) => data,
// Err(err) => return ResponseError::error_response(&err),
// };
// bytes.extend_from_slice(&data);
// }
// match upload_file(&path, &bytes).await {
// Ok(_) => {
// match User::update_profile_picture(&auth.user.email, Some(&path)) {
// Ok(_) => {}
// Err(err) => return ResponseError::error_response(&err),
// };
// }
// Err(err) => return ResponseError::error_response(&err),
// };
// }
// HttpResponse::Ok().finish()
// }
// #[get("/picture")]
// async fn get_picture(auth: Auth) -> HttpResponse {
// let user = match User::get_by_email(&auth.user.email) {
// Ok(user) => user,
// Err(err) => return ResponseError::error_response(&err),
// };
// if let Some(path) = user.profile_picture {
// match get_file(&path).await {
// Ok(bytes) => HttpResponse::Ok().body(bytes),
// Err(err) => ResponseError::error_response(&err),
// }
// } else {
// HttpResponse::NotFound().finish()
// }
// }
// #[delete("/picture")]
// async fn delete_picture(auth: Auth) -> HttpResponse {
// let user = match User::get_by_email(&auth.user.email) {
// Ok(user) => user,
// Err(err) => return ResponseError::error_response(&err),
// };
// if let Some(path) = user.profile_picture {
// match delete_file(&path).await {
// Ok(_) => match User::update_profile_picture(&auth.user.email, None) {
// Ok(_) => HttpResponse::Ok().finish(),
// Err(err) => ResponseError::error_response(&err),
// },
// Err(err) => ResponseError::error_response(&err),
// }
// } else {
// HttpResponse::NotFound().finish()
// }
// }
pub fn init_routes(config: &mut actix_web::web::ServiceConfig) {
// config.service(
// web::scope("users")
// .service(get_favorites)
// .service(add_favorite)
// .service(delete_favorite)
// .service(set_picture)
// .service(get_picture)
// .service(delete_picture),
// );
}

View File

@@ -1,5 +0,0 @@
SERVICE_HOST=service
SERVICE_PORT=5000
UI_PORT=3000
NODE_ENV=development

View File

@@ -1,26 +0,0 @@
version: '3'
name: aviation
services:
ui:
container_name: aviation-ui
env_file:
- .env
environment:
- NODE_ENV=${NODE_ENV:-development}
ports:
- ${UI_PORT:-3000}:3000
build:
context: ./
target: dev
command: "npm run dev"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./styles:/app/styles
networks:
- frontend
restart: unless-stopped
networks:
frontend: