A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.
1use crate::auth::AuthenticatedUser; 2use crate::{ 3 error::AppError, 4 models::{ 5 AuthResponse, Claims, ClickStats, CreateLink, Link, LoginRequest, RegisterRequest, 6 SourceStats, User, UserResponse, 7 }, 8 AppState, 9}; 10use actix_web::{web, HttpRequest, HttpResponse, Responder}; 11use argon2::{ 12 password_hash::{rand_core::OsRng, SaltString}, 13 PasswordVerifier, 14}; 15use argon2::{Argon2, PasswordHash, PasswordHasher}; 16use jsonwebtoken::{encode, EncodingKey, Header}; 17use lazy_static::lazy_static; 18use regex::Regex; 19 20lazy_static! { 21 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); 22} 23 24pub async fn create_short_url( 25 state: web::Data<AppState>, 26 user: AuthenticatedUser, 27 payload: web::Json<CreateLink>, 28) -> Result<impl Responder, AppError> { 29 tracing::debug!("Creating short URL with user_id: {}", user.user_id); 30 31 validate_url(&payload.url)?; 32 33 let short_code = if let Some(ref custom_code) = payload.custom_code { 34 validate_custom_code(custom_code)?; 35 36 tracing::debug!("Checking if custom code {} exists", custom_code); 37 // Check if code is already taken 38 if let Some(_) = sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1") 39 .bind(custom_code) 40 .fetch_optional(&state.db) 41 .await? 42 { 43 return Err(AppError::InvalidInput( 44 "Custom code already taken".to_string(), 45 )); 46 } 47 48 custom_code.clone() 49 } else { 50 generate_short_code() 51 }; 52 53 // Start transaction 54 let mut tx = state.db.begin().await?; 55 56 tracing::debug!("Inserting new link with short_code: {}", short_code); 57 let link = sqlx::query_as::<_, Link>( 58 "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *", 59 ) 60 .bind(&payload.url) 61 .bind(&short_code) 62 .bind(user.user_id) 63 .fetch_one(&mut *tx) 64 .await?; 65 66 if let Some(ref source) = payload.source { 67 tracing::debug!("Adding click source: {}", source); 68 sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)") 69 .bind(link.id) 70 .bind(source) 71 .execute(&mut *tx) 72 .await?; 73 } 74 75 tx.commit().await?; 76 Ok(HttpResponse::Created().json(link)) 77} 78 79fn validate_custom_code(code: &str) -> Result<(), AppError> { 80 if !VALID_CODE_REGEX.is_match(code) { 81 return Err(AppError::InvalidInput( 82 "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string() 83 )); 84 } 85 86 // Add reserved words check 87 let reserved_words = ["api", "health", "admin", "static", "assets"]; 88 if reserved_words.contains(&code.to_lowercase().as_str()) { 89 return Err(AppError::InvalidInput( 90 "This code is reserved and cannot be used".to_string(), 91 )); 92 } 93 94 Ok(()) 95} 96 97fn validate_url(url: &String) -> Result<(), AppError> { 98 if url.is_empty() { 99 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 100 } 101 if !url.starts_with("http://") && !url.starts_with("https://") { 102 return Err(AppError::InvalidInput( 103 "URL must start with http:// or https://".to_string(), 104 )); 105 } 106 Ok(()) 107} 108 109pub async fn redirect_to_url( 110 state: web::Data<AppState>, 111 path: web::Path<String>, 112 req: HttpRequest, 113) -> Result<impl Responder, AppError> { 114 let short_code = path.into_inner(); 115 116 // Extract query source if present 117 let query_source = req 118 .uri() 119 .query() 120 .and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok()) 121 .and_then(|params| params.get("source").cloned()); 122 123 let mut tx = state.db.begin().await?; 124 125 let link = sqlx::query_as::<_, Link>( 126 "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *", 127 ) 128 .bind(&short_code) 129 .fetch_optional(&mut *tx) 130 .await?; 131 132 match link { 133 Some(link) => { 134 // Record click with both user agent and query source 135 let user_agent = req 136 .headers() 137 .get("user-agent") 138 .and_then(|h| h.to_str().ok()) 139 .unwrap_or("unknown") 140 .to_string(); 141 142 sqlx::query("INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)") 143 .bind(link.id) 144 .bind(user_agent) 145 .bind(query_source) 146 .execute(&mut *tx) 147 .await?; 148 149 tx.commit().await?; 150 151 Ok(HttpResponse::TemporaryRedirect() 152 .append_header(("Location", link.original_url)) 153 .finish()) 154 } 155 None => Err(AppError::NotFound), 156 } 157} 158 159pub async fn get_all_links( 160 state: web::Data<AppState>, 161 user: AuthenticatedUser, 162) -> Result<impl Responder, AppError> { 163 let links = sqlx::query_as::<_, Link>( 164 "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC", 165 ) 166 .bind(user.user_id) 167 .fetch_all(&state.db) 168 .await?; 169 170 Ok(HttpResponse::Ok().json(links)) 171} 172 173pub async fn health_check(state: web::Data<AppState>) -> impl Responder { 174 match sqlx::query("SELECT 1").execute(&state.db).await { 175 Ok(_) => HttpResponse::Ok().json("Healthy"), 176 Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"), 177 } 178} 179 180fn generate_short_code() -> String { 181 use base62::encode; 182 use uuid::Uuid; 183 184 let uuid = Uuid::new_v4(); 185 encode(uuid.as_u128() as u64).chars().take(8).collect() 186} 187 188pub async fn register( 189 state: web::Data<AppState>, 190 payload: web::Json<RegisterRequest>, 191) -> Result<impl Responder, AppError> { 192 // Check if any users exist 193 let user_count = sqlx::query!("SELECT COUNT(*) as count FROM users") 194 .fetch_one(&state.db) 195 .await? 196 .count 197 .unwrap_or(0); 198 199 // If users exist, registration is closed - no exceptions 200 if user_count > 0 { 201 return Err(AppError::Auth("Registration is closed".to_string())); 202 } 203 204 // Verify admin token for first user 205 match (&state.admin_token, &payload.admin_token) { 206 (Some(stored_token), Some(provided_token)) if stored_token == provided_token => { 207 // Token matches, proceed with registration 208 } 209 _ => return Err(AppError::Auth("Invalid admin setup token".to_string())), 210 } 211 212 // Check if email already exists 213 let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email) 214 .fetch_optional(&state.db) 215 .await?; 216 217 if exists.is_some() { 218 return Err(AppError::Auth("Email already registered".to_string())); 219 } 220 221 let salt = SaltString::generate(&mut OsRng); 222 let argon2 = Argon2::default(); 223 let password_hash = argon2 224 .hash_password(payload.password.as_bytes(), &salt) 225 .map_err(|e| AppError::Auth(e.to_string()))? 226 .to_string(); 227 228 let user = sqlx::query_as!( 229 User, 230 "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *", 231 payload.email, 232 password_hash 233 ) 234 .fetch_one(&state.db) 235 .await?; 236 237 let claims = Claims::new(user.id); 238 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 239 let token = encode( 240 &Header::default(), 241 &claims, 242 &EncodingKey::from_secret(secret.as_bytes()), 243 ) 244 .map_err(|e| AppError::Auth(e.to_string()))?; 245 246 Ok(HttpResponse::Ok().json(AuthResponse { 247 token, 248 user: UserResponse { 249 id: user.id, 250 email: user.email, 251 }, 252 })) 253} 254 255pub async fn login( 256 state: web::Data<AppState>, 257 payload: web::Json<LoginRequest>, 258) -> Result<impl Responder, AppError> { 259 let user = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", payload.email) 260 .fetch_optional(&state.db) 261 .await? 262 .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?; 263 264 let argon2 = Argon2::default(); 265 let parsed_hash = 266 PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?; 267 268 if argon2 269 .verify_password(payload.password.as_bytes(), &parsed_hash) 270 .is_err() 271 { 272 return Err(AppError::Auth("Invalid credentials".to_string())); 273 } 274 275 let claims = Claims::new(user.id); 276 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 277 let token = encode( 278 &Header::default(), 279 &claims, 280 &EncodingKey::from_secret(secret.as_bytes()), 281 ) 282 .map_err(|e| AppError::Auth(e.to_string()))?; 283 284 Ok(HttpResponse::Ok().json(AuthResponse { 285 token, 286 user: UserResponse { 287 id: user.id, 288 email: user.email, 289 }, 290 })) 291} 292 293pub async fn delete_link( 294 state: web::Data<AppState>, 295 user: AuthenticatedUser, 296 path: web::Path<i32>, 297) -> Result<impl Responder, AppError> { 298 let link_id = path.into_inner(); 299 300 // Start transaction 301 let mut tx = state.db.begin().await?; 302 303 // Verify the link belongs to the user 304 let link = sqlx::query!( 305 "SELECT id FROM links WHERE id = $1 AND user_id = $2", 306 link_id, 307 user.user_id 308 ) 309 .fetch_optional(&mut *tx) 310 .await?; 311 312 if link.is_none() { 313 return Err(AppError::NotFound); 314 } 315 316 // Delete associated clicks first due to foreign key constraint 317 sqlx::query!("DELETE FROM clicks WHERE link_id = $1", link_id) 318 .execute(&mut *tx) 319 .await?; 320 321 // Delete the link 322 sqlx::query!("DELETE FROM links WHERE id = $1", link_id) 323 .execute(&mut *tx) 324 .await?; 325 326 tx.commit().await?; 327 328 Ok(HttpResponse::NoContent().finish()) 329} 330 331pub async fn get_link_clicks( 332 state: web::Data<AppState>, 333 user: AuthenticatedUser, 334 path: web::Path<i32>, 335) -> Result<impl Responder, AppError> { 336 let link_id = path.into_inner(); 337 338 // Verify the link belongs to the user 339 let link = sqlx::query!( 340 "SELECT id FROM links WHERE id = $1 AND user_id = $2", 341 link_id, 342 user.user_id 343 ) 344 .fetch_optional(&state.db) 345 .await?; 346 347 if link.is_none() { 348 return Err(AppError::NotFound); 349 } 350 351 let clicks = sqlx::query_as!( 352 ClickStats, 353 r#" 354 SELECT 355 DATE(created_at)::date as "date!", 356 COUNT(*)::bigint as "clicks!" 357 FROM clicks 358 WHERE link_id = $1 359 GROUP BY DATE(created_at) 360 ORDER BY DATE(created_at) ASC -- Changed from DESC to ASC 361 LIMIT 30 362 "#, 363 link_id 364 ) 365 .fetch_all(&state.db) 366 .await?; 367 368 Ok(HttpResponse::Ok().json(clicks)) 369} 370 371pub async fn get_link_sources( 372 state: web::Data<AppState>, 373 user: AuthenticatedUser, 374 path: web::Path<i32>, 375) -> Result<impl Responder, AppError> { 376 let link_id = path.into_inner(); 377 378 // Verify the link belongs to the user 379 let link = sqlx::query!( 380 "SELECT id FROM links WHERE id = $1 AND user_id = $2", 381 link_id, 382 user.user_id 383 ) 384 .fetch_optional(&state.db) 385 .await?; 386 387 if link.is_none() { 388 return Err(AppError::NotFound); 389 } 390 391 let sources = sqlx::query_as!( 392 SourceStats, 393 r#" 394 SELECT 395 query_source as "source!", 396 COUNT(*)::bigint as "count!" 397 FROM clicks 398 WHERE link_id = $1 399 AND query_source IS NOT NULL 400 AND query_source != '' 401 GROUP BY query_source 402 ORDER BY COUNT(*) DESC 403 LIMIT 10 404 "#, 405 link_id 406 ) 407 .fetch_all(&state.db) 408 .await?; 409 410 Ok(HttpResponse::Ok().json(sources)) 411}