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, DatabasePool, Link, LoginRequest, 6 RegisterRequest, 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; 19use sqlx::{Postgres, Sqlite}; 20 21lazy_static! { 22 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); 23} 24 25pub async fn create_short_url( 26 state: web::Data<AppState>, 27 user: AuthenticatedUser, 28 payload: web::Json<CreateLink>, 29) -> Result<impl Responder, AppError> { 30 tracing::debug!("Creating short URL with user_id: {}", user.user_id); 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 // Check if code exists using match on pool type 37 let exists = match &state.db { 38 DatabasePool::Postgres(pool) => { 39 sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1") 40 .bind(custom_code) 41 .fetch_optional(pool) 42 .await? 43 } 44 DatabasePool::Sqlite(pool) => { 45 sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1") 46 .bind(custom_code) 47 .fetch_optional(pool) 48 .await? 49 } 50 }; 51 52 if exists.is_some() { 53 return Err(AppError::InvalidInput( 54 "Custom code already taken".to_string(), 55 )); 56 } 57 custom_code.clone() 58 } else { 59 generate_short_code() 60 }; 61 62 // Start transaction based on pool type 63 let result = match &state.db { 64 DatabasePool::Postgres(pool) => { 65 let mut tx = pool.begin().await?; 66 67 let link = sqlx::query_as::<_, Link>( 68 "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *" 69 ) 70 .bind(&payload.url) 71 .bind(&short_code) 72 .bind(user.user_id) 73 .fetch_one(&mut *tx) 74 .await?; 75 76 if let Some(ref source) = payload.source { 77 sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)") 78 .bind(link.id) 79 .bind(source) 80 .execute(&mut *tx) 81 .await?; 82 } 83 84 tx.commit().await?; 85 link 86 } 87 DatabasePool::Sqlite(pool) => { 88 let mut tx = pool.begin().await?; 89 90 let link = sqlx::query_as::<_, Link>( 91 "INSERT INTO links (original_url, short_code, user_id) VALUES (?1, ?2, ?3) RETURNING *" 92 ) 93 .bind(&payload.url) 94 .bind(&short_code) 95 .bind(user.user_id) 96 .fetch_one(&mut *tx) 97 .await?; 98 99 if let Some(ref source) = payload.source { 100 sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)") 101 .bind(link.id) 102 .bind(source) 103 .execute(&mut *tx) 104 .await?; 105 } 106 107 tx.commit().await?; 108 link 109 } 110 }; 111 112 Ok(HttpResponse::Created().json(result)) 113} 114 115fn validate_custom_code(code: &str) -> Result<(), AppError> { 116 if !VALID_CODE_REGEX.is_match(code) { 117 return Err(AppError::InvalidInput( 118 "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string() 119 )); 120 } 121 122 // Add reserved words check 123 let reserved_words = ["api", "health", "admin", "static", "assets"]; 124 if reserved_words.contains(&code.to_lowercase().as_str()) { 125 return Err(AppError::InvalidInput( 126 "This code is reserved and cannot be used".to_string(), 127 )); 128 } 129 130 Ok(()) 131} 132 133fn validate_url(url: &String) -> Result<(), AppError> { 134 if url.is_empty() { 135 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 136 } 137 if !url.starts_with("http://") && !url.starts_with("https://") { 138 return Err(AppError::InvalidInput( 139 "URL must start with http:// or https://".to_string(), 140 )); 141 } 142 Ok(()) 143} 144 145pub async fn redirect_to_url( 146 state: web::Data<AppState>, 147 path: web::Path<String>, 148 req: HttpRequest, 149) -> Result<impl Responder, AppError> { 150 let short_code = path.into_inner(); 151 152 // Extract query source if present 153 let query_source = req 154 .uri() 155 .query() 156 .and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok()) 157 .and_then(|params| params.get("source").cloned()); 158 159 let link = match &state.db { 160 DatabasePool::Postgres(pool) => { 161 let mut tx = pool.begin().await?; 162 let link = sqlx::query_as::<_, Link>( 163 "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *", 164 ) 165 .bind(&short_code) 166 .fetch_optional(&mut *tx) 167 .await?; 168 tx.commit().await?; 169 link 170 } 171 DatabasePool::Sqlite(pool) => { 172 let mut tx = pool.begin().await?; 173 let link = sqlx::query_as::<_, Link>( 174 "UPDATE links SET clicks = clicks + 1 WHERE short_code = ?1 RETURNING *", 175 ) 176 .bind(&short_code) 177 .fetch_optional(&mut *tx) 178 .await?; 179 tx.commit().await?; 180 link 181 } 182 }; 183 184 match link { 185 Some(link) => { 186 // Handle click recording based on database type 187 match &state.db { 188 DatabasePool::Postgres(pool) => { 189 let mut tx = pool.begin().await?; 190 let user_agent = req 191 .headers() 192 .get("user-agent") 193 .and_then(|h| h.to_str().ok()) 194 .unwrap_or("unknown") 195 .to_string(); 196 197 sqlx::query( 198 "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)", 199 ) 200 .bind(link.id) 201 .bind(user_agent) 202 .bind(query_source) 203 .execute(&mut *tx) 204 .await?; 205 206 tx.commit().await?; 207 } 208 DatabasePool::Sqlite(pool) => { 209 let mut tx = pool.begin().await?; 210 let user_agent = req 211 .headers() 212 .get("user-agent") 213 .and_then(|h| h.to_str().ok()) 214 .unwrap_or("unknown") 215 .to_string(); 216 217 sqlx::query( 218 "INSERT INTO clicks (link_id, source, query_source) VALUES (?1, ?2, ?3)", 219 ) 220 .bind(link.id) 221 .bind(user_agent) 222 .bind(query_source) 223 .execute(&mut *tx) 224 .await?; 225 226 tx.commit().await?; 227 } 228 }; 229 230 Ok(HttpResponse::TemporaryRedirect() 231 .append_header(("Location", link.original_url)) 232 .finish()) 233 } 234 None => Err(AppError::NotFound), 235 } 236} 237 238pub async fn get_all_links( 239 state: web::Data<AppState>, 240 user: AuthenticatedUser, 241) -> Result<impl Responder, AppError> { 242 let links = match &state.db { 243 DatabasePool::Postgres(pool) => { 244 sqlx::query_as::<_, Link>( 245 "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC", 246 ) 247 .bind(user.user_id) 248 .fetch_all(pool) 249 .await? 250 } 251 DatabasePool::Sqlite(pool) => { 252 sqlx::query_as::<_, Link>( 253 "SELECT * FROM links WHERE user_id = ?1 ORDER BY created_at DESC", 254 ) 255 .bind(user.user_id) 256 .fetch_all(pool) 257 .await? 258 } 259 }; 260 261 Ok(HttpResponse::Ok().json(links)) 262} 263 264pub async fn health_check(state: web::Data<AppState>) -> impl Responder { 265 let is_healthy = match &state.db { 266 DatabasePool::Postgres(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(), 267 DatabasePool::Sqlite(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(), 268 }; 269 270 if is_healthy { 271 HttpResponse::Ok().json("Healthy") 272 } else { 273 HttpResponse::ServiceUnavailable().json("Database unavailable") 274 } 275} 276 277fn generate_short_code() -> String { 278 use base62::encode; 279 use uuid::Uuid; 280 281 let uuid = Uuid::new_v4(); 282 encode(uuid.as_u128() as u64).chars().take(32).collect() 283} 284 285pub async fn register( 286 state: web::Data<AppState>, 287 payload: web::Json<RegisterRequest>, 288) -> Result<impl Responder, AppError> { 289 // Check if any users exist 290 let user_count = match &state.db { 291 DatabasePool::Postgres(pool) => { 292 let mut tx = pool.begin().await?; 293 let count = sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users") 294 .fetch_one(&mut *tx) 295 .await? 296 .0; 297 tx.commit().await?; 298 count 299 } 300 DatabasePool::Sqlite(pool) => { 301 let mut tx = pool.begin().await?; 302 let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users") 303 .fetch_one(&mut *tx) 304 .await? 305 .0; 306 tx.commit().await?; 307 count 308 } 309 }; 310 311 // If users exist, registration is closed - no exceptions 312 if user_count > 0 { 313 return Err(AppError::Auth("Registration is closed".to_string())); 314 } 315 316 // Verify admin token for first user 317 match (&state.admin_token, &payload.admin_token) { 318 (Some(stored_token), Some(provided_token)) if stored_token == provided_token => { 319 // Token matches, proceed with registration 320 } 321 _ => return Err(AppError::Auth("Invalid admin setup token".to_string())), 322 } 323 324 // Check if email already exists 325 let exists = match &state.db { 326 DatabasePool::Postgres(pool) => { 327 let mut tx = pool.begin().await?; 328 let exists = 329 sqlx::query_as::<Postgres, (i32,)>("SELECT id FROM users WHERE email = $1") 330 .bind(&payload.email) 331 .fetch_optional(&mut *tx) 332 .await?; 333 tx.commit().await?; 334 exists 335 } 336 DatabasePool::Sqlite(pool) => { 337 let mut tx = pool.begin().await?; 338 let exists = sqlx::query_as::<Sqlite, (i32,)>("SELECT id FROM users WHERE email = ?") 339 .bind(&payload.email) 340 .fetch_optional(&mut *tx) 341 .await?; 342 tx.commit().await?; 343 exists 344 } 345 }; 346 347 if exists.is_some() { 348 return Err(AppError::Auth("Email already registered".to_string())); 349 } 350 351 let salt = SaltString::generate(&mut OsRng); 352 let argon2 = Argon2::default(); 353 let password_hash = argon2 354 .hash_password(payload.password.as_bytes(), &salt) 355 .map_err(|e| AppError::Auth(e.to_string()))? 356 .to_string(); 357 358 // Insert new user 359 let user = match &state.db { 360 DatabasePool::Postgres(pool) => { 361 let mut tx = pool.begin().await?; 362 let user = sqlx::query_as::<Postgres, User>( 363 "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *", 364 ) 365 .bind(&payload.email) 366 .bind(&password_hash) 367 .fetch_one(&mut *tx) 368 .await?; 369 tx.commit().await?; 370 user 371 } 372 DatabasePool::Sqlite(pool) => { 373 let mut tx = pool.begin().await?; 374 let user = sqlx::query_as::<Sqlite, User>( 375 "INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING *", 376 ) 377 .bind(&payload.email) 378 .bind(&password_hash) 379 .fetch_one(&mut *tx) 380 .await?; 381 tx.commit().await?; 382 user 383 } 384 }; 385 386 let claims = Claims::new(user.id); 387 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 388 let token = encode( 389 &Header::default(), 390 &claims, 391 &EncodingKey::from_secret(secret.as_bytes()), 392 ) 393 .map_err(|e| AppError::Auth(e.to_string()))?; 394 395 Ok(HttpResponse::Ok().json(AuthResponse { 396 token, 397 user: UserResponse { 398 id: user.id, 399 email: user.email, 400 }, 401 })) 402} 403 404pub async fn login( 405 state: web::Data<AppState>, 406 payload: web::Json<LoginRequest>, 407) -> Result<impl Responder, AppError> { 408 let user = match &state.db { 409 DatabasePool::Postgres(pool) => { 410 let mut tx = pool.begin().await?; 411 let user = sqlx::query_as::<Postgres, User>("SELECT * FROM users WHERE email = $1") 412 .bind(&payload.email) 413 .fetch_optional(&mut *tx) 414 .await?; 415 tx.commit().await?; 416 user 417 } 418 DatabasePool::Sqlite(pool) => { 419 let mut tx = pool.begin().await?; 420 let user = sqlx::query_as::<Sqlite, User>("SELECT * FROM users WHERE email = ?") 421 .bind(&payload.email) 422 .fetch_optional(&mut *tx) 423 .await?; 424 tx.commit().await?; 425 user 426 } 427 } 428 .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?; 429 430 let argon2 = Argon2::default(); 431 let parsed_hash = 432 PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?; 433 434 if argon2 435 .verify_password(payload.password.as_bytes(), &parsed_hash) 436 .is_err() 437 { 438 return Err(AppError::Auth("Invalid credentials".to_string())); 439 } 440 441 let claims = Claims::new(user.id); 442 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 443 let token = encode( 444 &Header::default(), 445 &claims, 446 &EncodingKey::from_secret(secret.as_bytes()), 447 ) 448 .map_err(|e| AppError::Auth(e.to_string()))?; 449 450 Ok(HttpResponse::Ok().json(AuthResponse { 451 token, 452 user: UserResponse { 453 id: user.id, 454 email: user.email, 455 }, 456 })) 457} 458 459pub async fn delete_link( 460 state: web::Data<AppState>, 461 user: AuthenticatedUser, 462 path: web::Path<i32>, 463) -> Result<impl Responder, AppError> { 464 let link_id = path.into_inner(); 465 466 match &state.db { 467 DatabasePool::Postgres(pool) => { 468 let mut tx = pool.begin().await?; 469 470 // Verify the link belongs to the user 471 let link = sqlx::query_as::<Postgres, (i32,)>( 472 "SELECT id FROM links WHERE id = $1 AND user_id = $2", 473 ) 474 .bind(link_id) 475 .bind(user.user_id) 476 .fetch_optional(&mut *tx) 477 .await?; 478 479 if link.is_none() { 480 return Err(AppError::NotFound); 481 } 482 483 // Delete associated clicks first due to foreign key constraint 484 sqlx::query("DELETE FROM clicks WHERE link_id = $1") 485 .bind(link_id) 486 .execute(&mut *tx) 487 .await?; 488 489 // Delete the link 490 sqlx::query("DELETE FROM links WHERE id = $1") 491 .bind(link_id) 492 .execute(&mut *tx) 493 .await?; 494 495 tx.commit().await?; 496 } 497 DatabasePool::Sqlite(pool) => { 498 let mut tx = pool.begin().await?; 499 500 // Verify the link belongs to the user 501 let link = sqlx::query_as::<Sqlite, (i32,)>( 502 "SELECT id FROM links WHERE id = ? AND user_id = ?", 503 ) 504 .bind(link_id) 505 .bind(user.user_id) 506 .fetch_optional(&mut *tx) 507 .await?; 508 509 if link.is_none() { 510 return Err(AppError::NotFound); 511 } 512 513 // Delete associated clicks first due to foreign key constraint 514 sqlx::query("DELETE FROM clicks WHERE link_id = ?") 515 .bind(link_id) 516 .execute(&mut *tx) 517 .await?; 518 519 // Delete the link 520 sqlx::query("DELETE FROM links WHERE id = ?") 521 .bind(link_id) 522 .execute(&mut *tx) 523 .await?; 524 525 tx.commit().await?; 526 } 527 } 528 529 Ok(HttpResponse::NoContent().finish()) 530} 531 532pub async fn get_link_clicks( 533 state: web::Data<AppState>, 534 user: AuthenticatedUser, 535 path: web::Path<i32>, 536) -> Result<impl Responder, AppError> { 537 let link_id = path.into_inner(); 538 539 // Verify the link belongs to the user 540 let link = match &state.db { 541 DatabasePool::Postgres(pool) => { 542 let mut tx = pool.begin().await?; 543 let link = sqlx::query_as::<Postgres, (i32,)>( 544 "SELECT id FROM links WHERE id = $1 AND user_id = $2", 545 ) 546 .bind(link_id) 547 .bind(user.user_id) 548 .fetch_optional(&mut *tx) 549 .await?; 550 tx.commit().await?; 551 link 552 } 553 DatabasePool::Sqlite(pool) => { 554 let mut tx = pool.begin().await?; 555 let link = sqlx::query_as::<Sqlite, (i32,)>( 556 "SELECT id FROM links WHERE id = ? AND user_id = ?", 557 ) 558 .bind(link_id) 559 .bind(user.user_id) 560 .fetch_optional(&mut *tx) 561 .await?; 562 tx.commit().await?; 563 link 564 } 565 }; 566 567 if link.is_none() { 568 return Err(AppError::NotFound); 569 } 570 571 let clicks = match &state.db { 572 DatabasePool::Postgres(pool) => { 573 sqlx::query_as::<Postgres, ClickStats>( 574 r#" 575 SELECT 576 DATE(created_at)::date as "date!", 577 COUNT(*)::bigint as "clicks!" 578 FROM clicks 579 WHERE link_id = $1 580 GROUP BY DATE(created_at) 581 ORDER BY DATE(created_at) ASC 582 LIMIT 30 583 "#, 584 ) 585 .bind(link_id) 586 .fetch_all(pool) 587 .await? 588 } 589 DatabasePool::Sqlite(pool) => { 590 sqlx::query_as::<Sqlite, ClickStats>( 591 r#" 592 SELECT 593 DATE(created_at) as "date!", 594 COUNT(*) as "clicks!" 595 FROM clicks 596 WHERE link_id = ? 597 GROUP BY DATE(created_at) 598 ORDER BY DATE(created_at) ASC 599 LIMIT 30 600 "#, 601 ) 602 .bind(link_id) 603 .fetch_all(pool) 604 .await? 605 } 606 }; 607 608 Ok(HttpResponse::Ok().json(clicks)) 609} 610 611pub async fn get_link_sources( 612 state: web::Data<AppState>, 613 user: AuthenticatedUser, 614 path: web::Path<i32>, 615) -> Result<impl Responder, AppError> { 616 let link_id = path.into_inner(); 617 618 // Verify the link belongs to the user 619 let link = match &state.db { 620 DatabasePool::Postgres(pool) => { 621 let mut tx = pool.begin().await?; 622 let link = sqlx::query_as::<Postgres, (i32,)>( 623 "SELECT id FROM links WHERE id = $1 AND user_id = $2", 624 ) 625 .bind(link_id) 626 .bind(user.user_id) 627 .fetch_optional(&mut *tx) 628 .await?; 629 tx.commit().await?; 630 link 631 } 632 DatabasePool::Sqlite(pool) => { 633 let mut tx = pool.begin().await?; 634 let link = sqlx::query_as::<Sqlite, (i32,)>( 635 "SELECT id FROM links WHERE id = ? AND user_id = ?", 636 ) 637 .bind(link_id) 638 .bind(user.user_id) 639 .fetch_optional(&mut *tx) 640 .await?; 641 tx.commit().await?; 642 link 643 } 644 }; 645 646 if link.is_none() { 647 return Err(AppError::NotFound); 648 } 649 650 let sources = match &state.db { 651 DatabasePool::Postgres(pool) => { 652 sqlx::query_as::<Postgres, SourceStats>( 653 r#" 654 SELECT 655 query_source as "source!", 656 COUNT(*)::bigint as "count!" 657 FROM clicks 658 WHERE link_id = $1 659 AND query_source IS NOT NULL 660 AND query_source != '' 661 GROUP BY query_source 662 ORDER BY COUNT(*) DESC 663 LIMIT 10 664 "#, 665 ) 666 .bind(link_id) 667 .fetch_all(pool) 668 .await? 669 } 670 DatabasePool::Sqlite(pool) => { 671 sqlx::query_as::<Sqlite, SourceStats>( 672 r#" 673 SELECT 674 query_source as "source!", 675 COUNT(*) as "count!" 676 FROM clicks 677 WHERE link_id = ? 678 AND query_source IS NOT NULL 679 AND query_source != '' 680 GROUP BY query_source 681 ORDER BY COUNT(*) DESC 682 LIMIT 10 683 "#, 684 ) 685 .bind(link_id) 686 .fetch_all(pool) 687 .await? 688 } 689 }; 690 691 Ok(HttpResponse::Ok().json(sources)) 692}