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