Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
Notes
11 changes: 11 additions & 0 deletions latent-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
- Clone the repo
- Copy .env.example to .env
- Start postgres locally

```
docker run -d \
--name postgres-container \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=mysecretpassword \
-e POSTGRES_DB=latent \
-p 5432:5432 \
postgres:latest

```
- Migrate Postgres
```
psql "postgres://postgres:mysecretpassword@localhost:5432/"
Expand Down
34 changes: 31 additions & 3 deletions latent-backend/api/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use poem_openapi::{payload::Json, ApiResponse, Object};
use poem_openapi::{
payload::{self, Json},
ApiResponse, Object,
};

#[derive(Debug, Object)]
pub struct ErrorBody {
Expand Down Expand Up @@ -30,6 +33,14 @@ pub enum AppError {
/// Bad request (400)
#[oai(status = 400)]
BadRequest(Json<ErrorBody>),

// Too Many Requests
#[oai(status = 429)]
RateLimitted(Json<ErrorBody>),

// Any 3rd Party API Call Error
#[oai(status = 500)]
NetworkError(Json<ErrorBody>),
}

impl From<sqlx::Error> for AppError {
Expand All @@ -38,9 +49,26 @@ impl From<sqlx::Error> for AppError {
sqlx::Error::RowNotFound => AppError::NotFound(Json(ErrorBody {
message: "Resource not found".to_string(),
})),
_ => AppError::Database(Json(ErrorBody {
message: "Database error occurred".to_string(),

e => AppError::Database(Json(ErrorBody {
// message: "Database error occurred".to_string(),
message: format!("{:?}", e.to_string()),
})),
}
}
}

impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
if let Some(status) = err.status() {
if status != reqwest::StatusCode::OK {
return AppError::NetworkError(payload::Json(ErrorBody {
message: "Cannot send OTP now. Please try again later.".to_string(),
}));
}
}
AppError::InternalServerError(payload::Json(ErrorBody {
message: "Somthing went wrong".to_string(),
}))
}
}
20 changes: 11 additions & 9 deletions latent-backend/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@ use poem::{
EndpointExt, Route, Server,
};
use poem_openapi::OpenApiService;
use services::sms_service::SmsService;
use std::{env, sync::Arc};

mod error;
mod routes;
mod utils;
mod middleware;
mod services;

use db::Db;
use dotenv::dotenv;

#[derive(Clone)]
pub struct AppState {
sms_service: Arc<SmsService>,
db: Arc<Db>,
}

Expand All @@ -34,18 +37,17 @@ async fn main() -> Result<(), std::io::Error> {
db.init().await.expect("Failed to initialize database");
let db = Arc::new(db);

// Injecting the sms service
let sms_service = Arc::new(SmsService::default());

// API services
let user_api_service = OpenApiService::new(routes::user::user::UserApi, "Latent Booking API", "1.0")
.server(format!("{}/user", server_url));
let user_api_service = OpenApiService::new(routes::user::user::UserApi, "Latent Booking API", "1.0");

let admin_api_service = OpenApiService::new(routes::admin::admin::AdminApi, "Admin Latent Booking API", "1.0")
.server(format!("{}/admin", server_url));
let admin_api_service = OpenApiService::new(routes::admin::admin::AdminApi, "Admin Latent Booking API", "1.0");

let location_api_service = OpenApiService::new(routes::admin::location::LocationApi, "Location Latent Booking API", "1.0")
.server(format!("{}/location", server_url));
let location_api_service = OpenApiService::new(routes::admin::location::LocationApi, "Location Latent Booking API", "1.0");

let event_api_service = OpenApiService::new(routes::admin::event::EventApi, "Location Latent Booking API", "1.0")
.server(format!("{}/event", server_url));
let event_api_service = OpenApiService::new(routes::admin::event::EventApi, "Location Latent Booking API", "1.0");

// Swagger UI for each API group
let user_ui = user_api_service.swagger_ui();
Expand Down Expand Up @@ -74,7 +76,7 @@ async fn main() -> Result<(), std::io::Error> {
}

// middleware and shared state
let app = app.with(Cors::new()).data(AppState { db });
let app = app.with(Cors::new()).data(AppState { sms_service, db });

println!("Server running at http://localhost:{}", port);
println!("User API docs available at http://localhost:{}/docs/user", port);
Expand Down
16 changes: 6 additions & 10 deletions latent-backend/api/src/routes/admin/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::env;
use log::info;
use poem::web::{Data, Json};
use poem_openapi::{OpenApi, Object, payload};
use crate::{error::AppError, AppState, utils::{totp, twilio}};
use crate::{error::AppError, AppState, utils::totp};
use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, Header};

Expand Down Expand Up @@ -54,15 +54,11 @@ impl AdminApi {
let admin = state.db.create_admin(number.clone()).await?;

// Generate and send OTP
let otp = totp::get_token(&number, "SUPERADMIN");
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);
let otp = state.sms_service.generate_otp(&number, "AUTH").await;
if cfg!(not(debug_assertions)){
let _ = state.sms_service.send_otp(state, number, otp).await?;
}else {
println!("Development Mode OTP is {}", otp)
}

let admin_secret = env::var("ADMIN_JWT_PASSWORD").unwrap_or_else(|_| "admin".to_string());
Expand Down
38 changes: 11 additions & 27 deletions latent-backend/api/src/routes/user/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,25 +64,13 @@ impl UserApi {
let number = body.0.number;
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);
let otp = state.sms_service.generate_otp(&number, "AUTH").await;
if cfg!(not(debug_assertions)){
let _ = state.sms_service.send_otp(state, number, otp).await?;
}else {
println!("Development Mode OTP is {}", otp)
}


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(),
})))?;


Ok(payload::Json(CreateUserResponse {
message: "User created successfully".to_string(),
id: user.id.to_string(),
Expand Down Expand Up @@ -124,15 +112,11 @@ impl UserApi {
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);
let otp = state.sms_service.generate_otp(&number, "AUTH").await;
if cfg!(not(debug_assertions)){
let _ = state.sms_service.send_otp(state, number, otp).await?;
}else {
println!("Development Mode OTP is {}", otp)
}

Ok(payload::Json(SignInResponse {
Expand Down
1 change: 1 addition & 0 deletions latent-backend/api/src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod sms_service;
102 changes: 102 additions & 0 deletions latent-backend/api/src/services/sms_service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use chrono::{Duration, TimeZone, Utc};
use poem::web::Data;
use poem_openapi::payload;
use sha2::{Digest, Sha256};
use std::{
env,
time::{SystemTime, UNIX_EPOCH},
};

use crate::{error::AppError, AppState};
use sqlx::Error;

const TIME_STEP: u64 = 30; // 30 seconds
pub struct SmsService {
client: reqwest::Client,
gupshup_url: String,
gupshup_uid: String,
gupshup_pass: String,
template_id: String,
}

impl Default for SmsService {
fn default() -> Self {
Self {
client: reqwest::Client::new(),
gupshup_url: env::var("GUPSHUP_URL").unwrap(),
gupshup_uid: env::var("GUPSHUP_UID").unwrap(),
gupshup_pass: env::var("GUPSHUP_PASS").unwrap(),
template_id: env::var("TEMPLATE_ID").unwrap(),
}
}
}

impl SmsService {
pub async fn send_otp(
&self,
state: Data<&AppState>,
number: String,
_otp: String,
) -> Result<(), AppError> {
if !self.can_send_otp(&state, &number).await? {
return Err(AppError::RateLimitted(payload::Json(
crate::error::ErrorBody {
message: "Too Many Requests".to_string(),
},
)));
}

// update db count :TODO: Make sure to move this line below before push
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can remove this comment 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh' that was reminder to myself I forget to remove that. Ill do that in final PR.
actually code should be this:

let _ = self.client.post(&self.gupshup_url).body("").send().await?;
let _ = state.db.update_otpc_by_number(&number).await?;

first send the otp. if otp send success then update the count.

let _ = state.db.update_otpc_by_number(&number).await?;

let _ = self.client.post(&self.gupshup_url).body("").send().await?;

Ok(())
}

pub async fn can_send_otp(&self, state: &Data<&AppState>, number: &str) -> Result<bool, Error> {
let user = state.db.get_user_by_number(&number).await?;
let updated_at_utc = Utc.from_utc_datetime(&user.updated_at);
if user.otp_request_count > 4
&& Utc::now().signed_duration_since(updated_at_utc) < Duration::minutes(30)
{
return Ok(false);
}
Ok(true)
}

pub async fn generate_otp(&self, 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 async fn verify_otp(&self, key: &str, salt: &str, token: &str) -> bool {
if token.len() != 6 {
return false;
}
let current = self.generate_otp(key, salt).await;
token == current
}
}

#[derive(Debug)]
pub enum OtpError {
RequestErr(reqwest::Error),
ResponseErr(u16),
}
3 changes: 3 additions & 0 deletions latent-backend/db/migrations/002_20250201_opt_ratelimiter.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN otp_request_count INT DEFAULT 0,
ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
12 changes: 11 additions & 1 deletion latent-backend/db/src/user.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
use crate::Db;
use chrono::NaiveDateTime;
use log::info;
use serde::{Deserialize, Serialize};
use sqlx::Error;
use sqlx::FromRow;
use uuid::Uuid;

#[derive(FromRow, Serialize, Deserialize)]
#[derive(FromRow, Serialize, Deserialize, Debug)]
pub struct User {
pub id: Uuid,
pub number: String,
pub name: String,
pub verified: bool,
pub otp_request_count: i32,
pub updated_at: NaiveDateTime
}

#[derive(sqlx::Type, Serialize, Deserialize)]
Expand Down Expand Up @@ -89,4 +92,11 @@ impl Db {
info!("Signin verified for user with id: {}", user.id);
Ok(user.id.to_string())
}

pub async fn update_otpc_by_number(&self, phone_number: &str) -> Result<bool, Error> {
info!("Updating OTP count for number: {}", phone_number);

let _ = sqlx::query!("UPDATE users SET otp_request_count = otp_request_count + 1, updated_at = CURRENT_TIMESTAMP WHERE number = $1", phone_number).execute(&self.client).await?;
Ok(true)
}
}