diff --git a/.preludeignore b/.preludeignore new file mode 100644 index 0000000..626a9ba --- /dev/null +++ b/.preludeignore @@ -0,0 +1 @@ +.sqlx diff --git a/.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json b/.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json new file mode 100644 index 0000000..1b6ab7b --- /dev/null +++ b/.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM links WHERE id = $1 AND user_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b" +} diff --git a/.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json b/.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json new file mode 100644 index 0000000..ed9a12b --- /dev/null +++ b/.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM links WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85" +} diff --git a/.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json b/.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json new file mode 100644 index 0000000..09a6788 --- /dev/null +++ b/.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM clicks WHERE link_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5" +} diff --git a/src/handlers.rs b/src/handlers.rs index 74c1444..88c7307 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,10 +1,20 @@ -use actix_web::{web, HttpResponse, Responder, HttpRequest}; -use jsonwebtoken::{encode, Header, EncodingKey};use crate::{error::AppError, models::{AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse}, AppState}; -use regex::Regex; -use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier}; -use lazy_static::lazy_static; -use argon2::{Argon2, PasswordHash, PasswordHasher}; use crate::auth::AuthenticatedUser; +use crate::{ + error::AppError, + models::{ + AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse, + }, + AppState, +}; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + PasswordVerifier, +}; +use argon2::{Argon2, PasswordHash, PasswordHasher}; +use jsonwebtoken::{encode, EncodingKey, Header}; +use lazy_static::lazy_static; +use regex::Regex; lazy_static! { static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); @@ -16,54 +26,51 @@ pub async fn create_short_url( payload: web::Json, ) -> Result { tracing::debug!("Creating short URL with user_id: {}", user.user_id); - + validate_url(&payload.url)?; - + let short_code = if let Some(ref custom_code) = payload.custom_code { validate_custom_code(custom_code)?; - + tracing::debug!("Checking if custom code {} exists", custom_code); // Check if code is already taken - if let Some(_) = sqlx::query_as::<_, Link>( - "SELECT * FROM links WHERE short_code = $1" - ) - .bind(custom_code) - .fetch_optional(&state.db) - .await? { + if let Some(_) = sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1") + .bind(custom_code) + .fetch_optional(&state.db) + .await? + { return Err(AppError::InvalidInput( - "Custom code already taken".to_string() + "Custom code already taken".to_string(), )); } - + custom_code.clone() } else { generate_short_code() }; - + // Start transaction let mut tx = state.db.begin().await?; - + tracing::debug!("Inserting new link with short_code: {}", short_code); let link = sqlx::query_as::<_, Link>( - "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *" + "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *", ) .bind(&payload.url) .bind(&short_code) .bind(user.user_id) .fetch_one(&mut *tx) .await?; - + if let Some(ref source) = payload.source { tracing::debug!("Adding click source: {}", source); - sqlx::query( - "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" - ) - .bind(link.id) - .bind(source) - .execute(&mut *tx) - .await?; + sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)") + .bind(link.id) + .bind(source) + .execute(&mut *tx) + .await?; } - + tx.commit().await?; Ok(HttpResponse::Created().json(link)) } @@ -74,15 +81,15 @@ fn validate_custom_code(code: &str) -> Result<(), AppError> { "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string() )); } - + // Add reserved words check let reserved_words = ["api", "health", "admin", "static", "assets"]; if reserved_words.contains(&code.to_lowercase().as_str()) { return Err(AppError::InvalidInput( - "This code is reserved and cannot be used".to_string() + "This code is reserved and cannot be used".to_string(), )); } - + Ok(()) } @@ -91,7 +98,9 @@ fn validate_url(url: &String) -> Result<(), AppError> { return Err(AppError::InvalidInput("URL cannot be empty".to_string())); } if !url.starts_with("http://") && !url.starts_with("https://") { - return Err(AppError::InvalidInput("URL must start with http:// or https://".to_string())); + return Err(AppError::InvalidInput( + "URL must start with http:// or https://".to_string(), + )); } Ok(()) } @@ -102,9 +111,10 @@ pub async fn redirect_to_url( req: HttpRequest, ) -> Result { let short_code = path.into_inner(); - + // Extract query source if present - let query_source = req.uri() + let query_source = req + .uri() .query() .and_then(|q| web::Query::>::from_query(q).ok()) .and_then(|params| params.get("source").cloned()); @@ -112,7 +122,7 @@ pub async fn redirect_to_url( let mut tx = state.db.begin().await?; let link = sqlx::query_as::<_, Link>( - "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *" + "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *", ) .bind(&short_code) .fetch_optional(&mut *tx) @@ -121,27 +131,26 @@ pub async fn redirect_to_url( match link { Some(link) => { // Record click with both user agent and query source - let user_agent = req.headers() + let user_agent = req + .headers() .get("user-agent") .and_then(|h| h.to_str().ok()) .unwrap_or("unknown") .to_string(); - sqlx::query( - "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)" - ) - .bind(link.id) - .bind(user_agent) - .bind(query_source) - .execute(&mut *tx) - .await?; + sqlx::query("INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)") + .bind(link.id) + .bind(user_agent) + .bind(query_source) + .execute(&mut *tx) + .await?; tx.commit().await?; Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", link.original_url)) .finish()) - }, + } None => Err(AppError::NotFound), } } @@ -151,7 +160,7 @@ pub async fn get_all_links( user: AuthenticatedUser, ) -> Result { let links = sqlx::query_as::<_, Link>( - "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC" + "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC", ) .bind(user.user_id) .fetch_all(&state.db) @@ -160,9 +169,7 @@ pub async fn get_all_links( Ok(HttpResponse::Ok().json(links)) } -pub async fn health_check( - state: web::Data, -) -> impl Responder { +pub async fn health_check(state: web::Data) -> impl Responder { match sqlx::query("SELECT 1").execute(&state.db).await { Ok(_) => HttpResponse::Ok().json("Healthy"), Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"), @@ -172,7 +179,7 @@ pub async fn health_check( fn generate_short_code() -> String { use base62::encode; use uuid::Uuid; - + let uuid = Uuid::new_v4(); encode(uuid.as_u128() as u64).chars().take(8).collect() } @@ -181,12 +188,9 @@ pub async fn register( state: web::Data, payload: web::Json, ) -> Result { - let exists = sqlx::query!( - "SELECT id FROM users WHERE email = $1", - payload.email - ) - .fetch_optional(&state.db) - .await?; + let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email) + .fetch_optional(&state.db) + .await?; if exists.is_some() { return Err(AppError::Auth("Email already registered".to_string())); @@ -194,7 +198,8 @@ pub async fn register( let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); - let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt) + let password_hash = argon2 + .hash_password(payload.password.as_bytes(), &salt) .map_err(|e| AppError::Auth(e.to_string()))? .to_string(); @@ -212,8 +217,9 @@ pub async fn register( let token = encode( &Header::default(), &claims, - &EncodingKey::from_secret(secret.as_bytes()) - ).map_err(|e| AppError::Auth(e.to_string()))?; + &EncodingKey::from_secret(secret.as_bytes()), + ) + .map_err(|e| AppError::Auth(e.to_string()))?; Ok(HttpResponse::Ok().json(AuthResponse { token, @@ -228,20 +234,19 @@ pub async fn login( state: web::Data, payload: web::Json, ) -> Result { - let user = sqlx::query_as!( - User, - "SELECT * FROM users WHERE email = $1", - payload.email - ) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?; + let user = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", payload.email) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?; let argon2 = Argon2::default(); - let parsed_hash = PasswordHash::new(&user.password_hash) - .map_err(|e| AppError::Auth(e.to_string()))?; + let parsed_hash = + PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?; - if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() { + if argon2 + .verify_password(payload.password.as_bytes(), &parsed_hash) + .is_err() + { return Err(AppError::Auth("Invalid credentials".to_string())); } @@ -250,8 +255,9 @@ pub async fn login( let token = encode( &Header::default(), &claims, - &EncodingKey::from_secret(secret.as_bytes()) - ).map_err(|e| AppError::Auth(e.to_string()))?; + &EncodingKey::from_secret(secret.as_bytes()), + ) + .map_err(|e| AppError::Auth(e.to_string()))?; Ok(HttpResponse::Ok().json(AuthResponse { token, @@ -260,4 +266,42 @@ pub async fn login( email: user.email, }, })) -} \ No newline at end of file +} + +pub async fn delete_link( + state: web::Data, + user: AuthenticatedUser, + path: web::Path, +) -> Result { + let link_id = path.into_inner(); + + // Start transaction + let mut tx = state.db.begin().await?; + + // Verify the link belongs to the user + let link = sqlx::query!( + "SELECT id FROM links WHERE id = $1 AND user_id = $2", + link_id, + user.user_id + ) + .fetch_optional(&mut *tx) + .await?; + + if link.is_none() { + return Err(AppError::NotFound); + } + + // Delete associated clicks first due to foreign key constraint + sqlx::query!("DELETE FROM clicks WHERE link_id = $1", link_id) + .execute(&mut *tx) + .await?; + + // Delete the link + sqlx::query!("DELETE FROM links WHERE id = $1", link_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/src/main.rs b/src/main.rs index 8a9e3bd..01388b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ -use actix_web::{web, App, HttpServer}; use actix_cors::Cors; +use actix_web::{web, App, HttpServer}; use anyhow::Result; +use simple_link::{handlers, AppState}; use sqlx::postgres::PgPoolOptions; -use simple_link::{AppState, handlers}; use tracing::info; #[actix_web::main] @@ -37,7 +37,7 @@ async fn main() -> Result<()> { .allow_any_method() .allow_any_header() .max_age(3600); - + App::new() .wrap(cors) .app_data(web::Data::new(state.clone())) @@ -45,14 +45,12 @@ async fn main() -> Result<()> { web::scope("/api") .route("/shorten", web::post().to(handlers::create_short_url)) .route("/links", web::get().to(handlers::get_all_links)) + .route("/links/{id}", web::delete().to(handlers::delete_link)) .route("/auth/register", web::post().to(handlers::register)) .route("/auth/login", web::post().to(handlers::login)) .route("/health", web::get().to(handlers::health_check)), ) - .service( - web::resource("/{short_code}") - .route(web::get().to(handlers::redirect_to_url)) - ) + .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url))) }) .workers(2) .backlog(10_000)