Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 3 additions & 4 deletions latent-backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432
TWILIO_AUTH_TOKEN="<<TWILIO_AUTH_TOKEN>>"
TWILIO_ACCOUNT_SID="<<TWILIO_ACCOUNT_SID>>"
TWILIO_PHONE_NUMBER="<<TWILIO_PHONE_NUMBER>>"
ADMIN_JWT_PASSWORD="<<MY_ADMIN_SUPER_PASS>>"
GUPSHUP_URL="http://enterprise.smsgupshup.com/GatewayAPI/rest"
GUPSHUP_UID=""
GUPSHUP_PASS=""
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
18 changes: 7 additions & 11 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};
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 Expand Up @@ -99,7 +95,7 @@ impl AdminApi {

// Verify OTP
if cfg!(not(debug_assertions)) {
if !totp::verify_token(&number, "AUTH", &otp) {
if !state.sms_service.verify_otp(&number, "AUTH", &otp).await {
return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody {
message: "Invalid OTP".to_string(),
})));
Expand Down
44 changes: 14 additions & 30 deletions latent-backend/api/src/routes/user/user.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use poem::web::{Data, Json};
use poem_openapi::{OpenApi, Object, payload};
use crate::{error::AppError, AppState, utils::{totp, twilio}};
use crate::{error::AppError, AppState};


#[derive(Debug, Serialize, Deserialize, Object)]
Expand Down 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 All @@ -100,7 +88,7 @@ impl UserApi {

// Verify OTP
if cfg!(not(debug_assertions)) {
if !totp::verify_token(&number, "AUTH", &otp) {
if !state.sms_service.verify_otp(&number, "AUTH", &otp).await {
return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody {
message: "Invalid OTP".to_string(),
})));
Expand All @@ -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 All @@ -151,7 +135,7 @@ impl UserApi {

// Verify OTP
if cfg!(not(debug_assertions)) {
if !totp::verify_token(&number, "AUTH", &otp) {
if !state.sms_service.verify_otp(&number, "AUTH", &otp).await {
return Err(AppError::InvalidCredentials(payload::Json(crate::error::ErrorBody {
message: "Invalid OTP".to_string(),
})));
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;
119 changes: 119 additions & 0 deletions latent-backend/api/src/services/sms_service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use chrono::{Duration, TimeZone, Utc};
use poem::web::Data;
use poem_openapi::payload;
use serde::Serialize;
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

#[derive(Serialize)]
struct SmsParams<'a> {
userid: &'a str,
password: &'a str,
send_to: &'a str,
msg: &'a str,
method: &'a str,
msg_type: &'a str,
format: &'a str,
auth_scheme: &'a str,
v: &'a str,
}

pub struct SmsService {
client: reqwest::Client,
gupshup_url: String,
gupshup_uid: String,
gupshup_pass: 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(),
}
}
}

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

let params = SmsParams {
userid: &self.gupshup_uid,
password: &self.gupshup_pass,
send_to: &number,
msg: &format!("Your OTP for the Latent app is {otp}"),
method: "sendMessage",
msg_type: "text",
format: "json",
auth_scheme: "plain",
v: "1.1",
};

let _ = self.client.post(&self.gupshup_url).form(&params).header("Content-Type", "application/x-www-form-urlencoded").send().await?;
let _ = state.db.update_otpc_by_number(&number).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
}
}
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;
Loading