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

Compare changes

Choose any two refs to compare.

+2 -2
.github/workflows/docker-image.yml
··· 29 29 30 30 - name: Install cosign 31 31 if: github.event_name != 'pull_request' 32 - uses: sigstore/cosign-installer@v3.7.0 32 + uses: sigstore/cosign-installer@v3.8.1 33 33 with: 34 - cosign-release: "v2.4.1" 34 + cosign-release: "v2.4.3" 35 35 36 36 - name: Setup Docker buildx 37 37 uses: docker/setup-buildx-action@v3
+9 -39
Cargo.lock
··· 1440 1440 1441 1441 [[package]] 1442 1442 name = "nu-ansi-term" 1443 - version = "0.46.0" 1443 + version = "0.50.1" 1444 1444 source = "registry+https://github.com/rust-lang/crates.io-index" 1445 - checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1445 + checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" 1446 1446 dependencies = [ 1447 - "overload", 1448 - "winapi", 1447 + "windows-sys 0.52.0", 1449 1448 ] 1450 1449 1451 1450 [[package]] ··· 1525 1524 version = "1.20.2" 1526 1525 source = "registry+https://github.com/rust-lang/crates.io-index" 1527 1526 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 1528 - 1529 - [[package]] 1530 - name = "overload" 1531 - version = "0.1.1" 1532 - source = "registry+https://github.com/rust-lang/crates.io-index" 1533 - checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 1534 1527 1535 1528 [[package]] 1536 1529 name = "parking" ··· 1751 1744 1752 1745 [[package]] 1753 1746 name = "ring" 1754 - version = "0.17.8" 1747 + version = "0.17.13" 1755 1748 source = "registry+https://github.com/rust-lang/crates.io-index" 1756 - checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" 1749 + checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" 1757 1750 dependencies = [ 1758 1751 "cc", 1759 1752 "cfg-if", 1760 1753 "getrandom", 1761 1754 "libc", 1762 - "spin", 1763 1755 "untrusted", 1764 1756 "windows-sys 0.52.0", 1765 1757 ] ··· 2432 2424 2433 2425 [[package]] 2434 2426 name = "tokio" 2435 - version = "1.43.0" 2427 + version = "1.43.1" 2436 2428 source = "registry+https://github.com/rust-lang/crates.io-index" 2437 - checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" 2429 + checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c" 2438 2430 dependencies = [ 2439 2431 "backtrace", 2440 2432 "bytes", ··· 2529 2521 2530 2522 [[package]] 2531 2523 name = "tracing-subscriber" 2532 - version = "0.3.19" 2524 + version = "0.3.20" 2533 2525 source = "registry+https://github.com/rust-lang/crates.io-index" 2534 - checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2526 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 2535 2527 dependencies = [ 2536 2528 "nu-ansi-term", 2537 2529 "sharded-slab", ··· 2739 2731 ] 2740 2732 2741 2733 [[package]] 2742 - name = "winapi" 2743 - version = "0.3.9" 2744 - source = "registry+https://github.com/rust-lang/crates.io-index" 2745 - checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2746 - dependencies = [ 2747 - "winapi-i686-pc-windows-gnu", 2748 - "winapi-x86_64-pc-windows-gnu", 2749 - ] 2750 - 2751 - [[package]] 2752 - name = "winapi-i686-pc-windows-gnu" 2753 - version = "0.4.0" 2754 - source = "registry+https://github.com/rust-lang/crates.io-index" 2755 - checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2756 - 2757 - [[package]] 2758 2734 name = "winapi-util" 2759 2735 version = "0.1.9" 2760 2736 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2762 2738 dependencies = [ 2763 2739 "windows-sys 0.59.0", 2764 2740 ] 2765 - 2766 - [[package]] 2767 - name = "winapi-x86_64-pc-windows-gnu" 2768 - version = "0.4.0" 2769 - source = "registry+https://github.com/rust-lang/crates.io-index" 2770 - checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2771 2741 2772 2742 [[package]] 2773 2743 name = "windows-core"
+1 -1
Cargo.toml
··· 13 13 actix-web = "4.4" 14 14 actix-files = "0.6" 15 15 actix-cors = "0.6" 16 - tokio = { version = "1.36", features = ["rt-multi-thread", "macros"] } 16 + tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] } 17 17 sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] } 18 18 serde = { version = "1.0", features = ["derive"] } 19 19 serde_json = "1.0"
+1 -1
Dockerfile
··· 4 4 WORKDIR /usr/src/frontend 5 5 6 6 # Copy frontend files 7 - COPY frontend/package*.json ./ 7 + COPY frontend/package.json ./ 8 8 RUN bun install 9 9 10 10 COPY frontend/ ./
+3 -3
README.md
··· 8 8 9 9 ## How to Run 10 10 11 - ### From Docker: 11 + ### From Docker 12 12 13 13 ```bash 14 14 docker run -p 8080:8080 \ ··· 16 16 -e SIMPLELINK_USER=admin@example.com \ 17 17 -e SIMPLELINK_PASS=your-secure-password \ 18 18 -v simplelink_data:/data \ 19 - ghcr.io/waveringana/simplelink:v2 19 + ghcr.io/waveringana/simplelink:v2.2 20 20 ``` 21 21 22 22 ### Environment Variables ··· 31 31 32 32 If `SIMPLELINK_USER` and `SIMPLELINK_PASS` are not passed, an admin-setup-token is pasted to the console and as a text file in the project root. 33 33 34 - ### From Docker Compose: 34 + ### From Docker Compose 35 35 36 36 Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration. 37 37
+1 -1
docker-compose.yml
··· 19 19 - shortener-network 20 20 21 21 app: 22 - image: ghcr.io/waveringana/simplelink:v2 22 + image: ghcr.io/waveringana/simplelink:v2.2 23 23 container_name: shortener-app 24 24 ports: 25 25 - "8080:8080"
+3
migrations/20250219000000_extend_short_code.sql
··· 1 + -- PostgreSQL migration 2 + ALTER TABLE links ALTER COLUMN short_code TYPE VARCHAR(32); 3 +
+8 -7
src/auth.rs
··· 1 + use crate::{error::AppError, models::Claims}; 1 2 use actix_web::{dev::Payload, FromRequest, HttpRequest}; 2 3 use jsonwebtoken::{decode, DecodingKey, Validation}; 3 4 use std::future::{ready, Ready}; 4 - use crate::{error::AppError, models::Claims}; 5 5 6 6 pub struct AuthenticatedUser { 7 7 pub user_id: i32, ··· 12 12 type Future = Ready<Result<Self, Self::Error>>; 13 13 14 14 fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 15 - let auth_header = req.headers() 15 + let auth_header = req 16 + .headers() 16 17 .get("Authorization") 17 18 .and_then(|h| h.to_str().ok()); 18 19 19 20 if let Some(auth_header) = auth_header { 20 21 if auth_header.starts_with("Bearer ") { 21 22 let token = &auth_header[7..]; 22 - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 23 - 23 + let secret = 24 + std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 24 25 match decode::<Claims>( 25 26 token, 26 27 &DecodingKey::from_secret(secret.as_bytes()), 27 - &Validation::default() 28 + &Validation::default(), 28 29 ) { 29 30 Ok(token_data) => { 30 31 return ready(Ok(AuthenticatedUser { ··· 35 36 } 36 37 } 37 38 } 38 - 39 39 ready(Err(AppError::Unauthorized)) 40 40 } 41 - } 41 + } 42 +
+1 -5
src/handlers.rs
··· 131 131 Ok(()) 132 132 } 133 133 134 - fn validate_url(url: &String) -> Result<(), AppError> { 134 + fn validate_url(url: &str) -> Result<(), AppError> { 135 135 if url.is_empty() { 136 136 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 137 137 } ··· 707 707 WHERE link_id = $1 708 708 GROUP BY DATE(created_at) 709 709 ORDER BY DATE(created_at) ASC 710 - LIMIT 30 711 710 "#, 712 711 ) 713 712 .bind(link_id) ··· 724 723 WHERE link_id = ? 725 724 GROUP BY DATE(created_at) 726 725 ORDER BY DATE(created_at) ASC 727 - LIMIT 30 728 726 "#, 729 727 ) 730 728 .bind(link_id) ··· 789 787 AND query_source != '' 790 788 GROUP BY DATE(created_at), query_source 791 789 ORDER BY DATE(created_at) ASC, COUNT(*) DESC 792 - LIMIT 300 793 790 "#, 794 791 ) 795 792 .bind(link_id) ··· 809 806 AND query_source != '' 810 807 GROUP BY DATE(created_at), query_source 811 808 ORDER BY DATE(created_at) ASC, COUNT(*) DESC 812 - LIMIT 300 813 809 "#, 814 810 ) 815 811 .bind(link_id)
+102
src/main.rs
··· 1 1 use actix_cors::Cors; 2 2 use actix_web::{web, App, HttpResponse, HttpServer}; 3 3 use anyhow::Result; 4 + use clap::Parser; 4 5 use rust_embed::RustEmbed; 5 6 use simplelink::check_and_generate_admin_token; 7 + use simplelink::models::DatabasePool; 6 8 use simplelink::{create_db_pool, run_migrations}; 7 9 use simplelink::{handlers, AppState}; 8 10 use sqlx::{Postgres, Sqlite}; ··· 24 26 } 25 27 None => HttpResponse::NotFound().body("404 Not Found"), 26 28 } 29 + } 30 + 31 + async fn create_initial_links(pool: &DatabasePool) -> Result<()> { 32 + if let Ok(links) = std::env::var("INITIAL_LINKS") { 33 + for link_entry in links.split(';') { 34 + let parts: Vec<&str> = link_entry.split(',').collect(); 35 + if parts.len() >= 2 { 36 + let url = parts[0]; 37 + let code = parts[1]; 38 + 39 + match pool { 40 + DatabasePool::Postgres(pool) => { 41 + sqlx::query( 42 + "INSERT INTO links (original_url, short_code, user_id) 43 + VALUES ($1, $2, $3) 44 + ON CONFLICT (short_code) 45 + DO UPDATE SET short_code = EXCLUDED.short_code 46 + WHERE links.original_url = EXCLUDED.original_url", 47 + ) 48 + .bind(url) 49 + .bind(code) 50 + .bind(1) 51 + .execute(pool) 52 + .await?; 53 + } 54 + DatabasePool::Sqlite(pool) => { 55 + // First check if the exact combination exists 56 + let exists = sqlx::query_scalar::<_, bool>( 57 + "SELECT EXISTS( 58 + SELECT 1 FROM links 59 + WHERE original_url = ?1 60 + AND short_code = ?2 61 + )", 62 + ) 63 + .bind(url) 64 + .bind(code) 65 + .fetch_one(pool) 66 + .await?; 67 + 68 + // Only insert if the exact combination doesn't exist 69 + if !exists { 70 + sqlx::query( 71 + "INSERT INTO links (original_url, short_code, user_id) 72 + VALUES (?1, ?2, ?3)", 73 + ) 74 + .bind(url) 75 + .bind(code) 76 + .bind(1) 77 + .execute(pool) 78 + .await?; 79 + info!("Created initial link: {} -> {} for user_id: 1", code, url); 80 + } else { 81 + info!("Skipped existing link: {} -> {} for user_id: 1", code, url); 82 + } 83 + } 84 + } 85 + } 86 + } 87 + } 88 + Ok(()) 89 + } 90 + 91 + async fn create_admin_user(pool: &DatabasePool, email: &str, password: &str) -> Result<()> { 92 + use argon2::{ 93 + password_hash::{rand_core::OsRng, SaltString}, 94 + Argon2, PasswordHasher, 95 + }; 96 + 97 + let salt = SaltString::generate(&mut OsRng); 98 + let argon2 = Argon2::default(); 99 + let password_hash = argon2 100 + .hash_password(password.as_bytes(), &salt) 101 + .map_err(|e| anyhow::anyhow!("Password hashing error: {}", e))? 102 + .to_string(); 103 + 104 + match pool { 105 + DatabasePool::Postgres(pool) => { 106 + sqlx::query( 107 + "INSERT INTO users (email, password_hash) 108 + VALUES ($1, $2) 109 + ON CONFLICT (email) DO NOTHING", 110 + ) 111 + .bind(email) 112 + .bind(&password_hash) 113 + .execute(pool) 114 + .await?; 115 + } 116 + DatabasePool::Sqlite(pool) => { 117 + sqlx::query( 118 + "INSERT OR IGNORE INTO users (email, password_hash) 119 + VALUES (?1, ?2)", 120 + ) 121 + .bind(email) 122 + .bind(&password_hash) 123 + .execute(pool) 124 + .await?; 125 + } 126 + } 127 + info!("Created admin user: {}", email); 128 + Ok(()) 27 129 } 28 130 29 131 #[actix_web::main]
+1 -1
src/models.rs
··· 87 87 .duration_since(UNIX_EPOCH) 88 88 .unwrap() 89 89 .as_secs() as usize 90 - + 24 * 60 * 60; // 24 hours from now 90 + + 14 * 24 * 60 * 60; // 2 weeks from now 91 91 92 92 Self { sub: user_id, exp } 93 93 }