diff --git a/.env b/.env index dc39679..176cd15 100644 --- a/.env +++ b/.env @@ -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 diff --git a/Makefile b/Makefile index 30bd92e..89f64dc 100644 --- a/Makefile +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 53c0f70..f308d4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/service/.env b/service/.env deleted file mode 100644 index ab52185..0000000 --- a/service/.env +++ /dev/null @@ -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 diff --git a/service/Cargo.lock b/service/Cargo.lock index 5e21747..48f7dcb 100644 --- a/service/Cargo.lock +++ b/service/Cargo.lock @@ -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", ] diff --git a/service/Cargo.toml b/service/Cargo.toml index 240f22a..5bfd56d 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -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" diff --git a/service/Makefile b/service/Makefile deleted file mode 100644 index c86079d..0000000 --- a/service/Makefile +++ /dev/null @@ -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} \ No newline at end of file diff --git a/service/docker-compose.yml b/service/docker-compose.yml deleted file mode 100644 index a0d4c6f..0000000 --- a/service/docker-compose.yml +++ /dev/null @@ -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: diff --git a/service/src/airports/model.rs b/service/src/airports/model.rs index b9f54e2..1ab0174 100644 --- a/service/src/airports/model.rs +++ b/service/src/airports/model.rs @@ -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, ServiceError> { + pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> ApiResult> { 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 = 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 { + pub fn get_count(filters: &QueryFilters) -> ApiResult { 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 = 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 { + fn build_filter_query(filters: &QueryFilters) -> ApiResult { let mut query = "".to_string(); let mut parts: Vec = 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 { + pub fn get(icao: &str) -> ApiResult { 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 { + pub fn insert(airport: Self) -> ApiResult { let mut conn: r2d2::PooledConnection> = db::connection()?; let airport = Self::from(airport); @@ -395,7 +395,7 @@ impl QueryAirport { Ok(airport) } - pub fn insert_all(airports: Vec) -> Result, ServiceError> { + pub fn insert_all(airports: Vec) -> ApiResult> { let mut conn: r2d2::PooledConnection> = db::connection()?; let mut inserted_airports: Vec = vec![]; @@ -410,7 +410,7 @@ impl QueryAirport { Ok(inserted_airports) } - pub fn update(airport: Self) -> Result { + pub fn update(airport: Self) -> ApiResult { 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) -> Result { + pub fn delete(icao: Option) -> ApiResult { let mut conn = db::connection()?; let res = match icao { Some(icao) => { diff --git a/service/src/airports/routes.rs b/service/src/airports/routes.rs index 14c186f..ec2a9e6 100644 --- a/service/src/airports/routes.rs +++ b/service/src/airports/routes.rs @@ -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) -> HttpResponse { } #[post("")] -async fn create_airport(airport: web::Json, auth: JwtAuth) -> HttpResponse { +async fn create_airport(airport: web::Json, 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, auth: JwtAuth) -> HttpRespo async fn update_airport( _icao: web::Path, airport: web::Json, - 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, auth: JwtAuth) -> HttpResponse { +async fn delete_airport(icao: web::Path, auth: Auth) -> HttpResponse { let _ = match verify_role(&auth, "admin") { Ok(_) => {} Err(err) => return ResponseError::error_response(&err), diff --git a/service/src/auth/mod.rs b/service/src/auth/mod.rs index f87e195..2179a81 100644 --- a/service/src/auth/mod.rs +++ b/service/src/auth/mod.rs @@ -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, - pub token_uuid: uuid::Uuid, - pub email: String, - pub expires_in: Option, -} - -pub fn verify_token(token: &str, public_key: &str) -> Result { - let key = DecodingKey::from_rsa_pem(public_key.as_bytes())?; - let validation = Validation::new(Algorithm::RS256); - let decoded = decode::(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 { - let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE") - .expect("ACCESS_TOKEN_MAXAGE must be set") - .parse::() - .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 { - let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE") - .expect("REFRESH_TOKEN_MAXAGE must be set") - .parse::() - .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 { - 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 { +pub fn hash(str: &str) -> ApiResult { 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(), + }) + } } diff --git a/service/src/auth/model.rs b/service/src/auth/model.rs index ada83f9..1cd2788 100644 --- a/service/src/auth/model.rs +++ b/service/src/auth/model.rs @@ -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, + pub user: UserResponse, } -impl RegisterUser { - pub fn convert_to_insert(self) -> Result { - 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, - pub favorites: Vec, - pub verified: bool, -} - -impl QueryUser { - pub fn get_by_email(email: &str) -> Result { - 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, - pub favorites: Vec, - pub verified: bool, -} - -impl InsertUser { - pub fn insert(user: Self) -> Result { - 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 { - 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) -> Result { - 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, -} - -impl From 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>; + type Future = Pin>>>; + 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) } } diff --git a/service/src/auth/routes.rs b/service/src/auth/routes.rs index 25d22ba..d7d46c1 100644 --- a/service/src/auth/routes.rs +++ b/service/src/auth/routes.rs @@ -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) -> HttpResponse { +async fn register(user: web::Json) -> 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) -> HttpResponse { } #[post("/login")] -async fn login(request: web::Json) -> HttpResponse { +async fn login(request: web::Json, 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::() - .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::() - .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, -} - -#[get("/refresh")] -async fn refresh(req: HttpRequest) -> HttpResponse { - let params = match web::Query::::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::() - .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::() - .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), ); } diff --git a/service/src/auth/session.rs b/service/src/auth/session.rs new file mode 100644 index 0000000..fbf2465 --- /dev/null +++ b/service/src/auth/session.rs @@ -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, +} + +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> { + let mut conn = redis_async_connection().await?; + let result: RedisResult> = 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 { + // 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() + } +} diff --git a/service/src/db/mod.rs b/service/src/db/mod.rs index 7caff52..0d2f7e6 100644 --- a/service/src/db/mod.rs +++ b/service/src/db/mod.rs @@ -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 { +pub fn connection() -> ApiResult { 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 { +pub fn redis_connection() -> ApiResult { let conn = REDIS.get_connection()?; Ok(conn) } -pub async fn redis_async_connection() -> Result { +pub async fn redis_async_connection() -> ApiResult { let conn = REDIS.get_multiplexed_async_connection().await?; Ok(conn) } -async fn create_bucket() -> Result { +async fn create_bucket() -> ApiResult { 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 { Ok(response) } -pub async fn upload_file(path: &str, content: &[u8]) -> Result { +pub async fn upload_file(path: &str, content: &[u8]) -> ApiResult { let response = BUCKET.put_object(path, content).await?; Ok(response) } -pub async fn get_file(path: &str) -> Result, ServiceError> { +pub async fn get_file(path: &str) -> ApiResult> { let response = BUCKET.get_object(path).await?; let bytes = response.bytes(); Ok(bytes.to_vec()) } -pub async fn delete_file(path: &str) -> Result { +pub async fn delete_file(path: &str) -> ApiResult { let response = BUCKET.delete_object(path).await?; Ok(response) } diff --git a/service/src/error.rs b/service/src/error.rs new file mode 100644 index 0000000..f423362 --- /dev/null +++ b/service/src/error.rs @@ -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 = Result; + +#[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 for ApiError { + fn from(error: std::io::Error) -> Self { + Self::new(500, format!("Unknown IO error: {}", error)) + } +} + +impl From for ApiError { + fn from(error: std::env::VarError) -> Self { + Self::new( + 500, + format!("Unknown environment variable error: {}", error), + ) + } +} + +impl From 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 for ApiError { + fn from(error: reqwest::Error) -> Self { + Self::new(500, format!("Unknown reqwest error: {}", error)) + } +} + +impl From for ApiError { + fn from(error: serde_json::Error) -> Self { + Self::new(500, format!("Unknown serde_json error: {}", error)) + } +} + +impl From for ApiError { + fn from(error: argon2::password_hash::Error) -> Self { + Self::new(500, format!("Unknown argon2 error: {}", error)) + } +} + +impl From for ApiError { + fn from(error: jsonwebtoken::errors::Error) -> Self { + Self::new(500, format!("Unknown jsonwebtoken error: {}", error)) + } +} + +impl From for ApiError { + fn from(error: redis::RedisError) -> Self { + Self::new(500, format!("Unknown redis error: {}", error)) + } +} + +impl From 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::() { + 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 })) + } +} diff --git a/service/src/error_handler.rs b/service/src/error_handler.rs deleted file mode 100644 index 3886ad7..0000000 --- a/service/src/error_handler.rs +++ /dev/null @@ -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 for ServiceError { - fn from(error: std::io::Error) -> ServiceError { - ServiceError::new(500, format!("Unknown IO error: {}", error)) - } -} - -impl From for ServiceError { - fn from(error: std::env::VarError) -> ServiceError { - ServiceError::new( - 500, - format!("Unknown environment variable error: {}", error), - ) - } -} - -impl From 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 for ServiceError { - fn from(error: reqwest::Error) -> ServiceError { - ServiceError::new(500, format!("Unknown reqwest error: {}", error)) - } -} - -impl From for ServiceError { - fn from(error: serde_json::Error) -> ServiceError { - ServiceError::new(500, format!("Unknown serde_json error: {}", error)) - } -} - -impl From for ServiceError { - fn from(error: argon2::password_hash::Error) -> ServiceError { - ServiceError::new(500, format!("Unknown argon2 error: {}", error)) - } -} - -impl From for ServiceError { - fn from(error: jsonwebtoken::errors::Error) -> ServiceError { - ServiceError::new(500, format!("Unknown jsonwebtoken error: {}", error)) - } -} - -impl From for ServiceError { - fn from(error: redis::RedisError) -> ServiceError { - ServiceError::new(500, format!("Unknown redis error: {}", error)) - } -} - -impl From 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 })) - } -} diff --git a/service/src/main.rs b/service/src/main.rs index 7c3cf77..a86e7ce 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -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); } }; diff --git a/service/src/metars/model.rs b/service/src/metars/model.rs index a689342..23d75bd 100644 --- a/service/src/metars/model.rs +++ b/service/src/metars/model.rs @@ -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, ServiceError> { + fn parse(metar_strings: Vec<&str>) -> ApiResult> { let mut metars: Vec = 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) -> Result, ServiceError> { + async fn get_remote_metars(icaos: Vec) -> ApiResult> { 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, ServiceError> { + pub async fn get_all(icao_string: String) -> ApiResult> { if icao_string.is_empty() { return Ok(vec![]); } @@ -834,14 +835,14 @@ struct InsertMetar { } impl InsertMetar { - fn insert(metars: &Vec) -> Result { + fn insert(metars: &Vec) -> ApiResult { 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, ServiceError> { + fn get_all(icaos: &Vec<&str>) -> ApiResult> { // 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); } diff --git a/service/src/metars/routes.rs b/service/src/metars/routes.rs index 2ef37db..5710fb6 100644 --- a/service/src/metars/routes.rs +++ b/service/src/metars/routes.rs @@ -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() diff --git a/service/src/users/mod.rs b/service/src/users/mod.rs index fa26829..6fbb137 100644 --- a/service/src/users/mod.rs +++ b/service/src/users/mod.rs @@ -1,3 +1,5 @@ +mod model; mod routes; +pub use model::*; pub use routes::init_routes; diff --git a/service/src/users/model.rs b/service/src/users/model.rs new file mode 100644 index 0000000..0544184 --- /dev/null +++ b/service/src/users/model.rs @@ -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 { + 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, +} + +impl From 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, + pub favorites: Vec, + pub verified: bool, +} + +impl User { + pub fn get_by_email(email: &str) -> ApiResult { + 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 { + let mut conn = connection()?; + let user = diesel::insert_into(users::table) + .values(user) + .get_result(&mut conn)?; + Ok(user) + } +} diff --git a/service/src/users/routes.rs b/service/src/users/routes.rs index 1ca6a02..87e6e21 100644 --- a/service/src/users/routes.rs +++ b/service/src/users/routes.rs @@ -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, 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, 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, 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, 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), + // ); } diff --git a/ui/.env b/ui/.env deleted file mode 100644 index 0b60bdd..0000000 --- a/ui/.env +++ /dev/null @@ -1,5 +0,0 @@ -SERVICE_HOST=service -SERVICE_PORT=5000 - -UI_PORT=3000 -NODE_ENV=development diff --git a/ui/docker-compose.yml b/ui/docker-compose.yml deleted file mode 100644 index cb17b48..0000000 --- a/ui/docker-compose.yml +++ /dev/null @@ -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: