A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

Add delete

+1
.preludeignore
··· 1 + .sqlx
+23
.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id FROM links WHERE id = $1 AND user_id = $2", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Int4", 15 + "Int4" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b" 23 + }
+14
.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM links WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85" 14 + }
+14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM clicks WHERE link_id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5" 14 + }
+118 -74
src/handlers.rs
··· 1 - use actix_web::{web, HttpResponse, Responder, HttpRequest}; 2 - use jsonwebtoken::{encode, Header, EncodingKey};use crate::{error::AppError, models::{AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse}, AppState}; 3 - use regex::Regex; 4 - use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier}; 5 - use lazy_static::lazy_static; 6 - use argon2::{Argon2, PasswordHash, PasswordHasher}; 7 1 use crate::auth::AuthenticatedUser; 2 + use crate::{ 3 + error::AppError, 4 + models::{ 5 + AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse, 6 + }, 7 + AppState, 8 + }; 9 + use actix_web::{web, HttpRequest, HttpResponse, Responder}; 10 + use argon2::{ 11 + password_hash::{rand_core::OsRng, SaltString}, 12 + PasswordVerifier, 13 + }; 14 + use argon2::{Argon2, PasswordHash, PasswordHasher}; 15 + use jsonwebtoken::{encode, EncodingKey, Header}; 16 + use lazy_static::lazy_static; 17 + use regex::Regex; 8 18 9 19 lazy_static! { 10 20 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); ··· 16 26 payload: web::Json<CreateLink>, 17 27 ) -> Result<impl Responder, AppError> { 18 28 tracing::debug!("Creating short URL with user_id: {}", user.user_id); 19 - 29 + 20 30 validate_url(&payload.url)?; 21 - 31 + 22 32 let short_code = if let Some(ref custom_code) = payload.custom_code { 23 33 validate_custom_code(custom_code)?; 24 - 34 + 25 35 tracing::debug!("Checking if custom code {} exists", custom_code); 26 36 // Check if code is already taken 27 - if let Some(_) = sqlx::query_as::<_, Link>( 28 - "SELECT * FROM links WHERE short_code = $1" 29 - ) 30 - .bind(custom_code) 31 - .fetch_optional(&state.db) 32 - .await? { 37 + if let Some(_) = sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1") 38 + .bind(custom_code) 39 + .fetch_optional(&state.db) 40 + .await? 41 + { 33 42 return Err(AppError::InvalidInput( 34 - "Custom code already taken".to_string() 43 + "Custom code already taken".to_string(), 35 44 )); 36 45 } 37 - 46 + 38 47 custom_code.clone() 39 48 } else { 40 49 generate_short_code() 41 50 }; 42 - 51 + 43 52 // Start transaction 44 53 let mut tx = state.db.begin().await?; 45 - 54 + 46 55 tracing::debug!("Inserting new link with short_code: {}", short_code); 47 56 let link = sqlx::query_as::<_, Link>( 48 - "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *" 57 + "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *", 49 58 ) 50 59 .bind(&payload.url) 51 60 .bind(&short_code) 52 61 .bind(user.user_id) 53 62 .fetch_one(&mut *tx) 54 63 .await?; 55 - 64 + 56 65 if let Some(ref source) = payload.source { 57 66 tracing::debug!("Adding click source: {}", source); 58 - sqlx::query( 59 - "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" 60 - ) 61 - .bind(link.id) 62 - .bind(source) 63 - .execute(&mut *tx) 64 - .await?; 67 + sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)") 68 + .bind(link.id) 69 + .bind(source) 70 + .execute(&mut *tx) 71 + .await?; 65 72 } 66 - 73 + 67 74 tx.commit().await?; 68 75 Ok(HttpResponse::Created().json(link)) 69 76 } ··· 74 81 "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string() 75 82 )); 76 83 } 77 - 84 + 78 85 // Add reserved words check 79 86 let reserved_words = ["api", "health", "admin", "static", "assets"]; 80 87 if reserved_words.contains(&code.to_lowercase().as_str()) { 81 88 return Err(AppError::InvalidInput( 82 - "This code is reserved and cannot be used".to_string() 89 + "This code is reserved and cannot be used".to_string(), 83 90 )); 84 91 } 85 - 92 + 86 93 Ok(()) 87 94 } 88 95 ··· 91 98 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 92 99 } 93 100 if !url.starts_with("http://") && !url.starts_with("https://") { 94 - return Err(AppError::InvalidInput("URL must start with http:// or https://".to_string())); 101 + return Err(AppError::InvalidInput( 102 + "URL must start with http:// or https://".to_string(), 103 + )); 95 104 } 96 105 Ok(()) 97 106 } ··· 102 111 req: HttpRequest, 103 112 ) -> Result<impl Responder, AppError> { 104 113 let short_code = path.into_inner(); 105 - 114 + 106 115 // Extract query source if present 107 - let query_source = req.uri() 116 + let query_source = req 117 + .uri() 108 118 .query() 109 119 .and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok()) 110 120 .and_then(|params| params.get("source").cloned()); ··· 112 122 let mut tx = state.db.begin().await?; 113 123 114 124 let link = sqlx::query_as::<_, Link>( 115 - "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *" 125 + "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *", 116 126 ) 117 127 .bind(&short_code) 118 128 .fetch_optional(&mut *tx) ··· 121 131 match link { 122 132 Some(link) => { 123 133 // Record click with both user agent and query source 124 - let user_agent = req.headers() 134 + let user_agent = req 135 + .headers() 125 136 .get("user-agent") 126 137 .and_then(|h| h.to_str().ok()) 127 138 .unwrap_or("unknown") 128 139 .to_string(); 129 140 130 - sqlx::query( 131 - "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)" 132 - ) 133 - .bind(link.id) 134 - .bind(user_agent) 135 - .bind(query_source) 136 - .execute(&mut *tx) 137 - .await?; 141 + sqlx::query("INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)") 142 + .bind(link.id) 143 + .bind(user_agent) 144 + .bind(query_source) 145 + .execute(&mut *tx) 146 + .await?; 138 147 139 148 tx.commit().await?; 140 149 141 150 Ok(HttpResponse::TemporaryRedirect() 142 151 .append_header(("Location", link.original_url)) 143 152 .finish()) 144 - }, 153 + } 145 154 None => Err(AppError::NotFound), 146 155 } 147 156 } ··· 151 160 user: AuthenticatedUser, 152 161 ) -> Result<impl Responder, AppError> { 153 162 let links = sqlx::query_as::<_, Link>( 154 - "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC" 163 + "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC", 155 164 ) 156 165 .bind(user.user_id) 157 166 .fetch_all(&state.db) ··· 160 169 Ok(HttpResponse::Ok().json(links)) 161 170 } 162 171 163 - pub async fn health_check( 164 - state: web::Data<AppState>, 165 - ) -> impl Responder { 172 + pub async fn health_check(state: web::Data<AppState>) -> impl Responder { 166 173 match sqlx::query("SELECT 1").execute(&state.db).await { 167 174 Ok(_) => HttpResponse::Ok().json("Healthy"), 168 175 Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"), ··· 172 179 fn generate_short_code() -> String { 173 180 use base62::encode; 174 181 use uuid::Uuid; 175 - 182 + 176 183 let uuid = Uuid::new_v4(); 177 184 encode(uuid.as_u128() as u64).chars().take(8).collect() 178 185 } ··· 181 188 state: web::Data<AppState>, 182 189 payload: web::Json<RegisterRequest>, 183 190 ) -> Result<impl Responder, AppError> { 184 - let exists = sqlx::query!( 185 - "SELECT id FROM users WHERE email = $1", 186 - payload.email 187 - ) 188 - .fetch_optional(&state.db) 189 - .await?; 191 + let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email) 192 + .fetch_optional(&state.db) 193 + .await?; 190 194 191 195 if exists.is_some() { 192 196 return Err(AppError::Auth("Email already registered".to_string())); ··· 194 198 195 199 let salt = SaltString::generate(&mut OsRng); 196 200 let argon2 = Argon2::default(); 197 - let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt) 201 + let password_hash = argon2 202 + .hash_password(payload.password.as_bytes(), &salt) 198 203 .map_err(|e| AppError::Auth(e.to_string()))? 199 204 .to_string(); 200 205 ··· 212 217 let token = encode( 213 218 &Header::default(), 214 219 &claims, 215 - &EncodingKey::from_secret(secret.as_bytes()) 216 - ).map_err(|e| AppError::Auth(e.to_string()))?; 220 + &EncodingKey::from_secret(secret.as_bytes()), 221 + ) 222 + .map_err(|e| AppError::Auth(e.to_string()))?; 217 223 218 224 Ok(HttpResponse::Ok().json(AuthResponse { 219 225 token, ··· 228 234 state: web::Data<AppState>, 229 235 payload: web::Json<LoginRequest>, 230 236 ) -> Result<impl Responder, AppError> { 231 - let user = sqlx::query_as!( 232 - User, 233 - "SELECT * FROM users WHERE email = $1", 234 - payload.email 235 - ) 236 - .fetch_optional(&state.db) 237 - .await? 238 - .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?; 237 + let user = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", payload.email) 238 + .fetch_optional(&state.db) 239 + .await? 240 + .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?; 239 241 240 242 let argon2 = Argon2::default(); 241 - let parsed_hash = PasswordHash::new(&user.password_hash) 242 - .map_err(|e| AppError::Auth(e.to_string()))?; 243 + let parsed_hash = 244 + PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?; 243 245 244 - if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() { 246 + if argon2 247 + .verify_password(payload.password.as_bytes(), &parsed_hash) 248 + .is_err() 249 + { 245 250 return Err(AppError::Auth("Invalid credentials".to_string())); 246 251 } 247 252 ··· 250 255 let token = encode( 251 256 &Header::default(), 252 257 &claims, 253 - &EncodingKey::from_secret(secret.as_bytes()) 254 - ).map_err(|e| AppError::Auth(e.to_string()))?; 258 + &EncodingKey::from_secret(secret.as_bytes()), 259 + ) 260 + .map_err(|e| AppError::Auth(e.to_string()))?; 255 261 256 262 Ok(HttpResponse::Ok().json(AuthResponse { 257 263 token, ··· 260 266 email: user.email, 261 267 }, 262 268 })) 263 - } 269 + } 270 + 271 + pub async fn delete_link( 272 + state: web::Data<AppState>, 273 + user: AuthenticatedUser, 274 + path: web::Path<i32>, 275 + ) -> Result<impl Responder, AppError> { 276 + let link_id = path.into_inner(); 277 + 278 + // Start transaction 279 + let mut tx = state.db.begin().await?; 280 + 281 + // Verify the link belongs to the user 282 + let link = sqlx::query!( 283 + "SELECT id FROM links WHERE id = $1 AND user_id = $2", 284 + link_id, 285 + user.user_id 286 + ) 287 + .fetch_optional(&mut *tx) 288 + .await?; 289 + 290 + if link.is_none() { 291 + return Err(AppError::NotFound); 292 + } 293 + 294 + // Delete associated clicks first due to foreign key constraint 295 + sqlx::query!("DELETE FROM clicks WHERE link_id = $1", link_id) 296 + .execute(&mut *tx) 297 + .await?; 298 + 299 + // Delete the link 300 + sqlx::query!("DELETE FROM links WHERE id = $1", link_id) 301 + .execute(&mut *tx) 302 + .await?; 303 + 304 + tx.commit().await?; 305 + 306 + Ok(HttpResponse::NoContent().finish()) 307 + }
+5 -7
src/main.rs
··· 1 - use actix_web::{web, App, HttpServer}; 2 1 use actix_cors::Cors; 2 + use actix_web::{web, App, HttpServer}; 3 3 use anyhow::Result; 4 + use simple_link::{handlers, AppState}; 4 5 use sqlx::postgres::PgPoolOptions; 5 - use simple_link::{AppState, handlers}; 6 6 use tracing::info; 7 7 8 8 #[actix_web::main] ··· 37 37 .allow_any_method() 38 38 .allow_any_header() 39 39 .max_age(3600); 40 - 40 + 41 41 App::new() 42 42 .wrap(cors) 43 43 .app_data(web::Data::new(state.clone())) ··· 45 45 web::scope("/api") 46 46 .route("/shorten", web::post().to(handlers::create_short_url)) 47 47 .route("/links", web::get().to(handlers::get_all_links)) 48 + .route("/links/{id}", web::delete().to(handlers::delete_link)) 48 49 .route("/auth/register", web::post().to(handlers::register)) 49 50 .route("/auth/login", web::post().to(handlers::login)) 50 51 .route("/health", web::get().to(handlers::health_check)), 51 52 ) 52 - .service( 53 - web::resource("/{short_code}") 54 - .route(web::get().to(handlers::redirect_to_url)) 55 - ) 53 + .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url))) 56 54 }) 57 55 .workers(2) 58 56 .backlog(10_000)