diff --git a/latent-backend/.gitignore b/latent-backend/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/latent-backend/.gitignore @@ -0,0 +1 @@ +/target diff --git a/latent-backend/Cargo.lock b/latent-backend/Cargo.lock index b401302..8b8ae08 100644 --- a/latent-backend/Cargo.lock +++ b/latent-backend/Cargo.lock @@ -88,26 +88,19 @@ version = "0.1.0" dependencies = [ "db", "dotenv", + "env_logger", + "log", "poem", "poem-openapi", "reqwest", "serde", + "serde_json", + "sha2", "sqlx", "thiserror 2.0.11", "tokio", ] -[[package]] -name = "async-trait" -version = "0.1.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atoi" version = "2.0.0" @@ -395,8 +388,11 @@ name = "db" version = "0.1.0" dependencies = [ "dotenv", - "postgres", + "log", + "serde", "sqlx", + "tokio", + "uuid", ] [[package]] @@ -493,12 +489,35 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "erased-serde" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d" +dependencies = [ + "serde", + "typeid", +] + [[package]] name = "errno" version = "0.3.10" @@ -531,12 +550,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fastrand" version = "2.3.0" @@ -799,6 +812,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "hex" version = "0.4.3" @@ -900,6 +919,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.32" @@ -1165,6 +1190,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "is-terminal" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "itoa" version = "1.0.14" @@ -1239,6 +1275,9 @@ name = "log" version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6ea2a48c204030ee31a7d7fc72c93294c92fe87ecb1789881c9543516e1a0d" +dependencies = [ + "value-bag", +] [[package]] name = "md-5" @@ -1491,24 +1530,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1656,49 +1677,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "postgres" -version = "0.19.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95c918733159f4d55d2ceb262950f00b0aebd6af4aa97b5a47bb0655120475ed" -dependencies = [ - "bytes", - "fallible-iterator", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acda0ebdebc28befa84bee35e651e4c5f09073d668c7aed4cf7e23c3cda84b23" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator", - "hmac", - "md-5", - "memchr", - "rand", - "sha2", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66ea23a2d0e5734297357705193335e0a957696f34bed2f2faefacb2fec336f" -dependencies = [ - "bytes", - "fallible-iterator", - "postgres-protocol", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -1980,6 +1958,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_fmt" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d4ddca14104cd60529e8c7f7ba71a2c8acd8f7f5cfcdc2faf97eeb7c3010a4" +dependencies = [ + "serde", +] + [[package]] name = "serde_json" version = "1.0.135" @@ -2064,12 +2051,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - [[package]] name = "slab" version = "0.4.9" @@ -2150,6 +2131,7 @@ dependencies = [ "indexmap", "log", "memchr", + "native-tls", "once_cell", "percent-encoding", "serde", @@ -2157,10 +2139,12 @@ dependencies = [ "sha2", "smallvec", "thiserror 2.0.11", + "time", "tokio", "tokio-stream", "tracing", "url", + "uuid", ] [[package]] @@ -2240,7 +2224,9 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.11", + "time", "tracing", + "uuid", "whoami", ] @@ -2277,7 +2263,9 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.11", + "time", "tracing", + "uuid", "whoami", ] @@ -2300,8 +2288,10 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "time", "tracing", "url", + "uuid", ] [[package]] @@ -2333,6 +2323,84 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sval" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c2f18f53c889ec3dfe1c08b20fd51406d09b14bf18b366416718763ccff05a" + +[[package]] +name = "sval_buffer" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b8cb1bb48d0bed828b908e6b99e7ab8c7244994dc27948a2e31d42e8c4d77c1" +dependencies = [ + "sval", + "sval_ref", +] + +[[package]] +name = "sval_dynamic" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba574872d4ad653071a9db76c49656082db83a37cd5f559874273d36b4a02b9d" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944450b2dbbf8aae98537776b399b23d72b19243ee42522cfd110305f3c9ba5a" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411bbd543c413796ccfbaa44f6676e20032b6c69e4996cb6c3e6ef30c79b96d1" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30582d2a90869b380f8260559138c1b68ac3e0765520959f22a1a1fdca31769" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "762d3fbf3c0869064b7c93808c67ad2ed0292dde9b060ac282817941d4707dff" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "752d307438c6a6a3d095a2fecf6950cfb946d301a5bd6b57f047db4f6f8d97b9" +dependencies = [ + "serde", + "sval", + "sval_nested", +] + [[package]] name = "syn" version = "2.0.96" @@ -2405,6 +2473,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2540,32 +2617,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b5d3742945bc7d7f210693b0c58ae542c6fd47b17adbbda0885f3dcb34a6bdb" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand", - "socket2", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-stream" version = "0.1.17" @@ -2651,6 +2702,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e13db2e0ccd5e14a544e8a246ba2312cd25223f616442d7f2cb0e3db614236e" + [[package]] name = "typenum" version = "1.17.0" @@ -2738,6 +2795,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" +dependencies = [ + "getrandom", + "serde", +] + +[[package]] +name = "value-bag" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb773bd36fd59c7ca6e336c94454d9c66386416734817927ac93d81cb3c5b0b" +dependencies = [ + "erased-serde", + "serde", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a916a702cac43a88694c97657d449775667bcd14b70419441d05b7fea4a83a" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2856,7 +2959,6 @@ checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" dependencies = [ "redox_syscall", "wasite", - "web-sys", ] [[package]] @@ -2865,6 +2967,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "windows-core" version = "0.52.0" diff --git a/latent-backend/Cargo.toml b/latent-backend/Cargo.toml index 9cbd349..6c6b650 100644 --- a/latent-backend/Cargo.toml +++ b/latent-backend/Cargo.toml @@ -4,3 +4,5 @@ members = [ "api", "db" ] + +resolver = "2" diff --git a/latent-backend/api/Cargo.toml b/latent-backend/api/Cargo.toml index 731680b..3aa9f20 100644 --- a/latent-backend/api/Cargo.toml +++ b/latent-backend/api/Cargo.toml @@ -9,7 +9,11 @@ dotenv = "0.15.0" poem = "3.1.3" poem-openapi = { version = "5.1.2", features = ["swagger-ui"] } serde = "1.0.217" +serde_json = "1.0" thiserror = "2.0.11" tokio = { version = "1.43.0", features = ["full"] } -sqlx = { version = "0.8", features = [ "runtime-tokio", "postgres" ] } -reqwest = {version="0.11.13"} \ No newline at end of file +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "tls-native-tls"] } +reqwest = { version = "0.11.13", features = ["json"] } +env_logger = "0.10" +log = "0.4" +sha2 = "0.10" \ No newline at end of file diff --git a/latent-backend/api/src/error.rs b/latent-backend/api/src/error.rs index 6d95ae5..f2b9cc9 100644 --- a/latent-backend/api/src/error.rs +++ b/latent-backend/api/src/error.rs @@ -1,5 +1,4 @@ -use poem_openapi::{payload::Json, ApiResponse}; -use poem_openapi::Object; +use poem_openapi::{payload::Json, ApiResponse, Object}; #[derive(Debug, Object)] pub struct ErrorBody { @@ -8,21 +7,40 @@ pub struct ErrorBody { #[derive(ApiResponse, Debug)] pub enum AppError { - /// Something went wrong internally (500) + /// Database error (500) + #[oai(status = 500)] + Database(Json), + + /// Not found (404) + #[oai(status = 404)] + NotFound(Json), + + /// Invalid credentials (401) + #[oai(status = 401)] + InvalidCredentials(Json), + + /// Unauthorized (401) + #[oai(status = 401)] + Unauthorized(Json), + + /// Internal server error (500) #[oai(status = 500)] InternalServerError(Json), - - // If you have other error scenarios, you can add more variants: - // #[oai(status = 400)] - // BadRequest(Json), - // etc. + + /// Bad request (400) + #[oai(status = 400)] + BadRequest(Json), } impl From for AppError { fn from(err: sqlx::Error) -> Self { - println!("{}", err); - AppError::InternalServerError(Json(ErrorBody { - message: "Error while updating DB".to_string(), - })) + match err { + sqlx::Error::RowNotFound => AppError::NotFound(Json(ErrorBody { + message: "Resource not found".to_string(), + })), + _ => AppError::Database(Json(ErrorBody { + message: "Database error occurred".to_string(), + })), + } } } diff --git a/latent-backend/api/src/main.rs b/latent-backend/api/src/main.rs index 6a77ccc..a9418f8 100644 --- a/latent-backend/api/src/main.rs +++ b/latent-backend/api/src/main.rs @@ -1,32 +1,57 @@ -pub mod routes; -pub mod error; +use poem::{ + listener::TcpListener, + middleware::Cors, + EndpointExt, Route, Server, +}; +use poem_openapi::OpenApiService; +use std::sync::Arc; + +mod error; +mod routes; +mod utils; -use poem::{listener::TcpListener, web::Query, EndpointExt, Route}; -use poem_openapi::{OpenApiService, OpenApi}; use db::Db; use dotenv::dotenv; -struct Api; - #[derive(Clone)] -struct AppState { - db: Db +pub struct AppState { + db: Arc, } +pub struct Api; + #[tokio::main] -async fn main() { +async fn main() -> Result<(), std::io::Error> { + // Load environment variables dotenv().ok(); - let db = Db::new().await; - let api_service = OpenApiService::new(Api, "End user api", "1.0").server("http://localhost:3000"); + + // Initialize logger + env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); - let state = AppState { - db - }; + // Create and initialize database + let db = Db::new().await; + db.init().await.expect("Failed to initialize database"); + let db = Arc::new(db); + // Create API service + let api_service = OpenApiService::new(Api, "Latent Booking", "1.0") + .server("http://localhost:3000/api/v1"); + + // Create Swagger UI let ui = api_service.swagger_ui(); - let app = Route::new().nest("/api/v1", api_service).nest("/docs", ui).data(state); + + // Create route with CORS + let app = Route::new() + .nest("/api/v1", api_service) + .nest("/docs", ui) + .with(Cors::new()) + .data(AppState { db }); + + println!("Server running at http://localhost:3000"); + println!("API docs at http://localhost:3000/docs"); - let _ = poem::Server::new(TcpListener::bind("127.0.0.1:3000")) + // Start server + Server::new(TcpListener::bind("0.0.0.0:3000")) .run(app) - .await; + .await } diff --git a/latent-backend/api/src/routes/user.rs b/latent-backend/api/src/routes/user.rs index a611ccb..b14f4d4 100644 --- a/latent-backend/api/src/routes/user.rs +++ b/latent-backend/api/src/routes/user.rs @@ -1,48 +1,144 @@ use serde::{Deserialize, Serialize}; -use poem::{web::{Data, Json}, Error}; -use poem_openapi::OpenApi; -use crate::{error::AppError, Api, AppState}; -use poem_openapi::Object; +use poem::web::{Data, Json}; +use poem_openapi::{OpenApi, Object, payload}; +use crate::{error::AppError, Api, AppState, utils::{totp, twilio}}; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Object)] struct CreateUser { number: String, } -#[derive(Deserialize, Serialize, Object)] +#[derive(Debug, Deserialize, Serialize, Object)] struct CreateUserResponse { - message: String + message: String, + id: String, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Object)] struct CreateUserVerify { number: String, - totp: String + totp: String, + name: String, } -#[derive(Deserialize, Serialize, Object)] +#[derive(Debug, Deserialize, Serialize, Object)] struct VerifyUserResponse { token: String, - message: String +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SignInRequest { + number: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SignInResponse { + message: String, +} + +#[derive(Debug, Serialize, Deserialize, Object)] +struct SignInVerify { + number: String, + totp: String, } #[OpenApi] impl Api { - #[oai(path = "/", method = "post")] - async fn create_user(&self, body: Json, state: Data<&AppState>) -> poem::Result, AppError> { + /// Create a new user + #[oai(path = "/signup", method = "post")] + async fn create_user(&self, body: Json, state: Data<&AppState>) -> poem::Result, AppError> { let number = body.0.number; - state.db.create_user(number).await?; + let user = state.db.create_user(number.clone()).await?; + + // Generate and send OTP + let otp = totp::get_token(&number, "AUTH"); + if cfg!(not(debug_assertions)) { + twilio::send_message(&format!("Your OTP for signing up to Latent is {}", otp), &number) + .await + .map_err(|_| AppError::InternalServerError(payload::Json(crate::error::ErrorBody { + message: "Failed to send OTP".to_string(), + })))?; + } else { + println!("Development mode: OTP is {}", otp); + } - Ok(poem_openapi::payload::Json(CreateUserResponse { - message: "User created".to_string() + Ok(payload::Json(CreateUserResponse { + message: "User created successfully".to_string(), + id: user.id.to_string(), })) } - #[oai(path = "/verify", method = "post")] - async fn create_user_verify(&self, body: Json<) -> poem::Result>{ + /// Verify user creation with OTP + #[oai(path = "/signup/verify", method = "post")] + async fn create_user_verify( + &self, + body: Json, + state: Data<&AppState> + ) -> poem::Result, AppError> { + let CreateUserVerify { number, totp: otp, name } = body.0; + + // Verify OTP + if cfg!(not(debug_assertions)) { + if !totp::verify_token(&number, "AUTH", &otp) { + return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid OTP".to_string(), + }))); + } + } + + let token = state.db.verify_user(number, name).await?; + + Ok(payload::Json(VerifyUserResponse { token })) + } + + /// Sign in existing user + #[oai(path = "/signin", method = "post")] + async fn sign_in( + &self, + body: Json, + state: Data<&AppState> + ) -> poem::Result, AppError> { let number = body.0.number; - state.db.verify_user(number) + + let _user = state.db.get_user_by_number(&number).await?; + + // Generate and send OTP + let otp = totp::get_token(&number, "AUTH"); + if cfg!(not(debug_assertions)) { + twilio::send_message(&format!("Your OTP for signing in to Latent is {}", otp), &number) + .await + .map_err(|_| AppError::InternalServerError(payload::Json(crate::error::ErrorBody { + message: "Failed to send OTP".to_string(), + })))?; + } else { + println!("Development mode: OTP is {}", otp); + } + + Ok(payload::Json(SignInResponse { + message: "OTP sent successfully".to_string(), + })) } - async fn login_using_otp(&self, body: Json) + /// Verify sign in with OTP + #[oai(path = "/signin/verify", method = "post")] + async fn sign_in_verify( + &self, + body: Json, + state: Data<&AppState> + ) -> poem::Result, AppError> { + let SignInVerify { number, totp: otp } = body.0; + + // Verify OTP + if cfg!(not(debug_assertions)) { + if !totp::verify_token(&number, "AUTH", &otp) { + return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody { + message: "Invalid OTP".to_string(), + }))); + } + } + + let token = state.db.verify_signin(number).await?; + + Ok(payload::Json(VerifyUserResponse { token })) + } } \ No newline at end of file diff --git a/latent-backend/api/src/utils/mod.rs b/latent-backend/api/src/utils/mod.rs new file mode 100644 index 0000000..ab598b5 --- /dev/null +++ b/latent-backend/api/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod totp; +pub mod twilio; \ No newline at end of file diff --git a/latent-backend/api/src/utils/totp.rs b/latent-backend/api/src/utils/totp.rs new file mode 100644 index 0000000..f192ad9 --- /dev/null +++ b/latent-backend/api/src/utils/totp.rs @@ -0,0 +1,33 @@ +use sha2::{Sha256, Digest}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const TIME_STEP: u64 = 30; // 30 seconds + +pub fn get_token(key: &str, salt: &str) -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let counter = timestamp / TIME_STEP; + + let input = format!("{}{}{}", key, salt, counter); + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + + let offset = (result[result.len() - 1] & 0xf) as usize; + let code = ((result[offset] & 0x7f) as u32) << 24 | + (result[offset + 1] as u32) << 16 | + (result[offset + 2] as u32) << 8 | + (result[offset + 3] as u32); + + format!("{:0>6}", code % 1_000_000) // Always returns 6 digits +} + +pub fn verify_token(key: &str, salt: &str, token: &str) -> bool { + if token.len() != 6 { + return false; + } + let current = get_token(key, salt); + token == current +} \ No newline at end of file diff --git a/latent-backend/api/src/utils/twilio.rs b/latent-backend/api/src/utils/twilio.rs new file mode 100644 index 0000000..ea0af12 --- /dev/null +++ b/latent-backend/api/src/utils/twilio.rs @@ -0,0 +1,32 @@ +use reqwest::Client; +use serde_json::json; +use std::env; + +pub async fn send_message(message: &str, to: &str) -> Result<(), Box> { + let account_sid = env::var("TWILIO_ACCOUNT_SID")?; + let auth_token = env::var("TWILIO_AUTH_TOKEN")?; + let from = env::var("TWILIO_PHONE_NUMBER")?; + + let url = format!( + "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json", + account_sid + ); + + let client = Client::new(); + let response = client + .post(&url) + .basic_auth(&account_sid, Some(&auth_token)) + .form(&json!({ + "To": to, + "From": from, + "Body": message, + })) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to send SMS: {}", response.status()).into()); + } + + Ok(()) +} \ No newline at end of file diff --git a/latent-backend/api/test.sh b/latent-backend/api/test.sh new file mode 100755 index 0000000..5ac1010 --- /dev/null +++ b/latent-backend/api/test.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +echo "1. Creating new user..." +curl -X POST http://localhost:3000/api/v1/signup \ + -H "Content-Type: application/json" \ + -d '{"number": "9729302411"}' | jq + +echo -e "\nPress Enter to continue with signup verification..." +read + +echo "2. Verifying signup..." +curl -X POST http://localhost:3000/api/v1/signup/verify \ + -H "Content-Type: application/json" \ + -d '{ + "number": "9729302411", + "totp": "123456", + "name": "John Doe" + }' | jq + +echo -e "\nPress Enter to continue with signin..." +read + +echo "3. Signing in..." +curl -X POST http://localhost:3000/api/v1/signin \ + -H "Content-Type: application/json" \ + -d '{"number": "9729302411"}' | jq + +echo -e "\nPress Enter to continue with signin verification..." +read + +echo "4. Verifying signin..." +curl -X POST http://localhost:3000/api/v1/signin/verify \ + -H "Content-Type: application/json" \ + -d '{ + "number": "9729302411", + "totp": "123456" + }' | jq \ No newline at end of file diff --git a/latent-backend/db/Cargo.toml b/latent-backend/db/Cargo.toml index 7f6a9b1..088da88 100644 --- a/latent-backend/db/Cargo.toml +++ b/latent-backend/db/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "time", "tls-native-tls"] } +tokio = { version = "1.43.0", features = ["full"] } +serde = "1.0.217" +uuid = { version = "1.6", features = ["v4", "serde"] } dotenv = "0.15.0" -postgres = "0.19.9" -sqlx = { version = "0.8", features = [ "runtime-tokio", "postgres" ] } +log = "0.4" diff --git a/latent-backend/db/migrations/20240124_init.sql b/latent-backend/db/migrations/20240124_init.sql new file mode 100644 index 0000000..e553215 --- /dev/null +++ b/latent-backend/db/migrations/20240124_init.sql @@ -0,0 +1,89 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + number VARCHAR(15) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + verified BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Create admin_type enum +CREATE TYPE admin_type AS ENUM ('SuperAdmin', 'Creator'); + +-- Create admins table +CREATE TABLE IF NOT EXISTS admins ( + id UUID PRIMARY KEY, + number VARCHAR(15) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + verified BOOLEAN NOT NULL DEFAULT FALSE, + type admin_type NOT NULL +); + +-- Create locations table +CREATE TABLE IF NOT EXISTS locations ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + image_url TEXT NOT NULL +); + +-- Create events table +CREATE TABLE IF NOT EXISTS events ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + banner TEXT NOT NULL, + admin_id UUID NOT NULL REFERENCES admins(id), + location_id UUID NOT NULL REFERENCES locations(id), + start_time TIMESTAMP WITH TIME ZONE NOT NULL, + processed INTEGER NOT NULL DEFAULT 0, + published BOOLEAN NOT NULL DEFAULT FALSE, + ended BOOLEAN NOT NULL DEFAULT FALSE, + timeout_in_s INTEGER NOT NULL DEFAULT 600, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create seat_types table +CREATE TABLE IF NOT EXISTS seat_types ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + event_id UUID NOT NULL REFERENCES events(id), + price INTEGER NOT NULL, + capacity INTEGER NOT NULL, + filled INTEGER NOT NULL DEFAULT 0, + locked INTEGER NOT NULL DEFAULT 0 +); + +-- Create booking_status enum +CREATE TYPE booking_status AS ENUM ('Pending', 'PendingPayment', 'Confirmed', 'Timeout', 'Filled'); + +-- Create bookings table +CREATE TABLE IF NOT EXISTS bookings ( + id UUID PRIMARY KEY, + event_id UUID NOT NULL REFERENCES events(id), + user_id UUID NOT NULL REFERENCES users(id), + sequence_number INTEGER NOT NULL, + payment_id UUID, + status booking_status NOT NULL, + expiry TIMESTAMP WITH TIME ZONE NOT NULL, + current_sequence_number INTEGER NOT NULL +); + +-- Create seats table +CREATE TABLE IF NOT EXISTS seats ( + id UUID PRIMARY KEY, + seat_type_id UUID NOT NULL REFERENCES seat_types(id), + booking_id UUID NOT NULL REFERENCES bookings(id), + qr TEXT NOT NULL +); + +-- Create payment_state enum +CREATE TYPE payment_state AS ENUM ('Success', 'Fail', 'Pending'); + +-- Create payments table +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY, + event_id UUID NOT NULL REFERENCES events(id), + user_id UUID NOT NULL REFERENCES users(id), + status payment_state NOT NULL +); \ No newline at end of file diff --git a/latent-backend/db/migrations/20250119002058_create_users_table.sql b/latent-backend/db/migrations/20250119002058_create_users_table.sql deleted file mode 100644 index c1f564c..0000000 --- a/latent-backend/db/migrations/20250119002058_create_users_table.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Add migration script here -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - number TEXT NOT NULL -); \ No newline at end of file diff --git a/latent-backend/db/migrations/20250119004621_create_users_table.sql b/latent-backend/db/migrations/20250119004621_create_users_table.sql deleted file mode 100644 index 840912c..0000000 --- a/latent-backend/db/migrations/20250119004621_create_users_table.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Add migration script here -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - number TEXT NOT NULL UNIQUE -); \ No newline at end of file diff --git a/latent-backend/db/src/config.rs b/latent-backend/db/src/config.rs index c01a569..e0169b9 100644 --- a/latent-backend/db/src/config.rs +++ b/latent-backend/db/src/config.rs @@ -1,4 +1,6 @@ +use sqlx::postgres::{PgPool, PgPoolOptions}; use std::env; +use log::info; pub struct Config { pub db_url: String @@ -6,10 +8,22 @@ pub struct Config { impl Default for Config { fn default() -> Self { - let db_url = env::var("DB_URL").unwrap(); - println!("{}", db_url); + let db_url = env::var("DATABASE_URL") + .expect("DATABASE_URL must be set"); Self { db_url } } +} + +pub async fn create_pool() -> PgPool { + let config = Config::default(); + info!("Creating database pool with connection timeout of 5 seconds"); + + PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(std::time::Duration::from_secs(5)) + .connect(&config.db_url) + .await + .expect("Failed to create pool") } \ No newline at end of file diff --git a/latent-backend/db/src/lib.rs b/latent-backend/db/src/lib.rs index 4ec73c7..870aee5 100644 --- a/latent-backend/db/src/lib.rs +++ b/latent-backend/db/src/lib.rs @@ -1,22 +1,45 @@ -use config::Config; -use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; -pub mod user; -pub mod config; +use sqlx::postgres::PgPool; +use log::{info, error}; + +mod config; +mod user; + +pub use user::User; -#[derive(Clone)] pub struct Db { - client: Pool, + client: PgPool, } impl Db { pub async fn new() -> Self { - let config = Config::default(); - let client = PgPoolOptions::new().max_connections(5).connect(&config.db_url).await.unwrap(); + info!("Creating database pool..."); + let pool = config::create_pool().await; + Self { client: pool } + } + + pub async fn init(&self) -> Result<(), sqlx::Error> { + info!("Running database migrations..."); + + // First verify connection + match sqlx::query("SELECT 1").execute(&self.client).await { + Ok(_) => info!("Database connection successful"), + Err(e) => { + error!("Failed to connect to database: {}", e); + return Err(e); + } + } - let db = Db { - client - }; - return db; + // Run migrations + match sqlx::migrate!("./migrations").run(&self.client).await { + Ok(_) => { + info!("Database migrations completed successfully"); + Ok(()) + }, + Err(e) => { + error!("Migration failed: {}", e); + Err(e.into()) + } + } } } diff --git a/latent-backend/db/src/user.rs b/latent-backend/db/src/user.rs index 7cb0f5a..778c702 100644 --- a/latent-backend/db/src/user.rs +++ b/latent-backend/db/src/user.rs @@ -1,24 +1,76 @@ use crate::Db; use sqlx::Error; -use sqlx::{postgres::PgRow, FromRow}; +use sqlx::FromRow; +use uuid::Uuid; +use serde::{Deserialize, Serialize}; +use log::info; -#[derive(FromRow)] -struct User { - id: String, - number: String +#[derive(FromRow, Serialize, Deserialize)] +pub struct User { + pub id: Uuid, + pub number: String, + pub name: String, + pub verified: bool, } impl Db { - pub async fn create_user(&self, phone_number: String) -> Result { - sqlx::query("INSERT INTO users (number) VALUES ($1)") - .bind(phone_number) - .fetch_one(&self.client).await + pub async fn create_user(&self, phone_number: String) -> Result { + info!("Creating new user with number: {}", phone_number); + + let user = sqlx::query_as::<_, User>( + r#" + INSERT INTO users (id, number, name, verified) + VALUES ($1, $2, '', false) + ON CONFLICT (number) DO UPDATE + SET number = EXCLUDED.number + RETURNING * + "# + ) + .bind(Uuid::new_v4()) + .bind(phone_number) + .fetch_one(&self.client) + .await?; + + info!("User created/updated successfully with id: {}", user.id); + Ok(user) } - pub async fn verify_user(&self, phone_number: String) -> Result { - sqlx::query("UPDATE TABLE users SET verified=true WHERE number=$1") + pub async fn verify_user(&self, phone_number: String, name: String) -> Result { + info!("Verifying user with number: {}", phone_number); + + let user = sqlx::query_as::<_, User>( + "UPDATE users SET verified=true, name=$1 WHERE number=$2 RETURNING *" + ) + .bind(name) .bind(phone_number) - .fetch_one(&self.client).await + .fetch_one(&self.client) + .await?; + + info!("User verified successfully with id: {}", user.id); + Ok(user.id.to_string()) } + pub async fn get_user_by_number(&self, phone_number: &str) -> Result { + info!("Fetching user with number: {}", phone_number); + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE number = $1") + .bind(phone_number) + .fetch_one(&self.client) + .await?; + + info!("User found with id: {}", user.id); + Ok(user) + } + + pub async fn verify_signin(&self, phone_number: String) -> Result { + info!("Verifying signin for user with number: {}", phone_number); + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE number = $1") + .bind(phone_number) + .fetch_one(&self.client) + .await?; + + info!("Signin verified for user with id: {}", user.id); + Ok(user.id.to_string()) + } }