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

Add auth

+119 -14
API.md
··· 3 3 ## Base URL 4 4 `http://localhost:8080` 5 5 6 - ## Endpoints 6 + ## Authentication 7 + The API uses JWT tokens for authentication. Include the token in the Authorization header: 8 + ``` 9 + Authorization: Bearer <your_token> 10 + ``` 11 + 12 + ### Register 13 + Create a new user account. 14 + 15 + ```bash 16 + POST /api/auth/register 17 + ``` 18 + 19 + Request Body: 20 + ```json 21 + { 22 + "email": string, // Required: Valid email address 23 + "password": string // Required: Password 24 + } 25 + ``` 26 + 27 + Example: 28 + ```bash 29 + curl -X POST http://localhost:8080/api/auth/register \ 30 + -H "Content-Type: application/json" \ 31 + -d '{ 32 + "email": "user@example.com", 33 + "password": "your_password" 34 + }' 35 + ``` 36 + 37 + Response (200 OK): 38 + ```json 39 + { 40 + "token": "eyJ0eXAiOiJKV1QiLCJhbGc...", 41 + "user": { 42 + "id": 1, 43 + "email": "user@example.com" 44 + } 45 + } 46 + ``` 47 + 48 + ### Login 49 + Authenticate and receive a JWT token. 50 + 51 + ```bash 52 + POST /api/auth/login 53 + ``` 54 + 55 + Request Body: 56 + ```json 57 + { 58 + "email": string, // Required: Registered email address 59 + "password": string // Required: Password 60 + } 61 + ``` 62 + 63 + Example: 64 + ```bash 65 + curl -X POST http://localhost:8080/api/auth/login \ 66 + -H "Content-Type: application/json" \ 67 + -d '{ 68 + "email": "user@example.com", 69 + "password": "your_password" 70 + }' 71 + ``` 72 + 73 + Response (200 OK): 74 + ```json 75 + { 76 + "token": "eyJ0eXAiOiJKV1QiLCJhbGc...", 77 + "user": { 78 + "id": 1, 79 + "email": "user@example.com" 80 + } 81 + } 82 + ``` 83 + 84 + ## Protected Endpoints 7 85 8 86 ### Health Check 9 87 Check if the service and database are running. ··· 28 106 ``` 29 107 30 108 ### Create Short URL 31 - Create a new shortened URL with optional custom code. 109 + Create a new shortened URL with optional custom code. Requires authentication. 32 110 33 111 ```bash 34 112 POST /api/shorten ··· 49 127 ```bash 50 128 curl -X POST http://localhost:8080/api/shorten \ 51 129 -H "Content-Type: application/json" \ 130 + -H "Authorization: Bearer YOUR_TOKEN" \ 52 131 -d '{ 53 132 "url": "https://example.com", 54 133 "source": "curl-test" ··· 59 138 ```json 60 139 { 61 140 "id": 1, 141 + "user_id": 1, 62 142 "original_url": "https://example.com", 63 143 "short_code": "Xa7Bc9", 64 144 "created_at": "2024-03-01T12:34:56Z", ··· 70 150 ```bash 71 151 curl -X POST http://localhost:8080/api/shorten \ 72 152 -H "Content-Type: application/json" \ 153 + -H "Authorization: Bearer YOUR_TOKEN" \ 73 154 -d '{ 74 155 "url": "https://example.com", 75 156 "custom_code": "example", ··· 81 162 ```json 82 163 { 83 164 "id": 2, 165 + "user_id": 1, 84 166 "original_url": "https://example.com", 85 167 "short_code": "example", 86 168 "created_at": "2024-03-01T12:34:56Z", ··· 111 193 } 112 194 ``` 113 195 196 + Unauthorized (401 Unauthorized): 197 + ```json 198 + { 199 + "error": "Unauthorized" 200 + } 201 + ``` 202 + 114 203 ### Get All Links 115 - Retrieve all shortened URLs. 204 + Retrieve all shortened URLs for the authenticated user. 116 205 117 206 ```bash 118 207 GET /api/links ··· 120 209 121 210 Example: 122 211 ```bash 123 - curl http://localhost:8080/api/links 212 + curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/links 124 213 ``` 125 214 126 215 Response (200 OK): ··· 128 217 [ 129 218 { 130 219 "id": 1, 220 + "user_id": 1, 131 221 "original_url": "https://example.com", 132 222 "short_code": "Xa7Bc9", 133 223 "created_at": "2024-03-01T12:34:56Z", ··· 135 225 }, 136 226 { 137 227 "id": 2, 228 + "user_id": 1, 138 229 "original_url": "https://example.org", 139 230 "short_code": "example", 140 231 "created_at": "2024-03-01T12:35:00Z", ··· 144 235 ``` 145 236 146 237 ### Redirect to Original URL 147 - Use the shortened URL to redirect to the original URL. 238 + Use the shortened URL to redirect to the original URL. Source tracking via query parameter is supported. 148 239 149 240 ```bash 150 - GET /{short_code} 241 + GET /{short_code}?source={source} 151 242 ``` 152 243 153 244 Example: 154 245 ```bash 155 - curl -i http://localhost:8080/example 246 + curl -i http://localhost:8080/example?source=email 156 247 ``` 157 248 158 249 Response (307 Temporary Redirect): ··· 169 260 ``` 170 261 171 262 ## Custom Code Rules 172 - 173 263 1. Length: 1-32 characters 174 264 2. Allowed characters: letters, numbers, underscores, and hyphens 175 265 3. Case-sensitive 176 266 4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"] 177 267 178 268 ## Rate Limiting 179 - 180 269 Currently, no rate limiting is implemented. 181 270 182 271 ## Notes 183 - 184 272 1. All timestamps are in UTC 185 273 2. Click counts are incremented on successful redirects 186 - 3. Source tracking is optional but recommended for analytics 274 + 3. Source tracking is supported both at link creation and during redirection via query parameter 187 275 4. Custom codes are case-sensitive 188 276 5. URLs must include protocol (http:// or https://) 277 + 6. All create/read operations require authentication 278 + 7. Users can only see and manage their own links 189 279 190 280 ## Error Codes 191 - 192 281 - 200: Success 193 282 - 201: Created 194 283 - 307: Temporary Redirect 195 284 - 400: Bad Request (invalid input) 285 + - 401: Unauthorized (missing or invalid token) 196 286 - 404: Not Found 197 287 - 503: Service Unavailable 198 288 199 289 ## Database Schema 290 + ```sql 291 + -- Users table for authentication 292 + CREATE TABLE users ( 293 + id SERIAL PRIMARY KEY, 294 + email VARCHAR(255) NOT NULL UNIQUE, 295 + password_hash TEXT NOT NULL 296 + ); 200 297 201 - ```sql 298 + -- Links table with user association 202 299 CREATE TABLE links ( 203 300 id SERIAL PRIMARY KEY, 204 301 original_url TEXT NOT NULL, 205 302 short_code VARCHAR(8) NOT NULL UNIQUE, 206 303 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 207 - clicks BIGINT NOT NULL DEFAULT 0 304 + clicks BIGINT NOT NULL DEFAULT 0, 305 + user_id INTEGER REFERENCES users(id) 208 306 ); 209 307 308 + -- Click tracking with source information 210 309 CREATE TABLE clicks ( 211 310 id SERIAL PRIMARY KEY, 212 311 link_id INTEGER REFERENCES links(id), 213 312 source TEXT, 313 + query_source TEXT, 214 314 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 215 315 ); 316 + 317 + -- Indexes 318 + CREATE INDEX idx_short_code ON links(short_code); 319 + CREATE INDEX idx_user_id ON links(user_id); 320 + CREATE INDEX idx_link_id ON clicks(link_id); 216 321 ```
+129 -5
Cargo.lock
··· 9 9 "actix-cors", 10 10 "actix-web", 11 11 "anyhow", 12 + "argon2", 12 13 "base62", 13 14 "chrono", 14 15 "clap", 15 16 "dotenv", 17 + "jsonwebtoken", 16 18 "lazy_static", 17 19 "regex", 18 20 "serde", 19 21 "serde_json", 20 22 "sqlx", 21 - "thiserror", 23 + "thiserror 1.0.69", 22 24 "tokio", 23 25 "tracing", 24 26 "tracing-subscriber", ··· 353 355 checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 354 356 355 357 [[package]] 358 + name = "argon2" 359 + version = "0.5.3" 360 + source = "registry+https://github.com/rust-lang/crates.io-index" 361 + checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" 362 + dependencies = [ 363 + "base64ct", 364 + "blake2", 365 + "cpufeatures", 366 + "password-hash", 367 + ] 368 + 369 + [[package]] 356 370 name = "atoi" 357 371 version = "2.0.0" 358 372 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 419 433 ] 420 434 421 435 [[package]] 436 + name = "blake2" 437 + version = "0.10.6" 438 + source = "registry+https://github.com/rust-lang/crates.io-index" 439 + checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" 440 + dependencies = [ 441 + "digest", 442 + ] 443 + 444 + [[package]] 422 445 name = "block-buffer" 423 446 version = "0.10.4" 424 447 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 915 938 checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 916 939 dependencies = [ 917 940 "cfg-if", 941 + "js-sys", 918 942 "libc", 919 943 "wasi", 944 + "wasm-bindgen", 920 945 ] 921 946 922 947 [[package]] ··· 1250 1275 ] 1251 1276 1252 1277 [[package]] 1278 + name = "jsonwebtoken" 1279 + version = "9.3.0" 1280 + source = "registry+https://github.com/rust-lang/crates.io-index" 1281 + checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" 1282 + dependencies = [ 1283 + "base64 0.21.7", 1284 + "js-sys", 1285 + "pem", 1286 + "ring", 1287 + "serde", 1288 + "serde_json", 1289 + "simple_asn1", 1290 + ] 1291 + 1292 + [[package]] 1253 1293 name = "language-tags" 1254 1294 version = "0.3.2" 1255 1295 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1419 1459 ] 1420 1460 1421 1461 [[package]] 1462 + name = "num-bigint" 1463 + version = "0.4.6" 1464 + source = "registry+https://github.com/rust-lang/crates.io-index" 1465 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 1466 + dependencies = [ 1467 + "num-integer", 1468 + "num-traits", 1469 + ] 1470 + 1471 + [[package]] 1422 1472 name = "num-bigint-dig" 1423 1473 version = "0.8.4" 1424 1474 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1560 1610 ] 1561 1611 1562 1612 [[package]] 1613 + name = "password-hash" 1614 + version = "0.5.0" 1615 + source = "registry+https://github.com/rust-lang/crates.io-index" 1616 + checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" 1617 + dependencies = [ 1618 + "base64ct", 1619 + "rand_core", 1620 + "subtle", 1621 + ] 1622 + 1623 + [[package]] 1563 1624 name = "paste" 1564 1625 version = "1.0.15" 1565 1626 source = "registry+https://github.com/rust-lang/crates.io-index" 1566 1627 checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1567 1628 1568 1629 [[package]] 1630 + name = "pem" 1631 + version = "3.0.4" 1632 + source = "registry+https://github.com/rust-lang/crates.io-index" 1633 + checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" 1634 + dependencies = [ 1635 + "base64 0.22.1", 1636 + "serde", 1637 + ] 1638 + 1639 + [[package]] 1569 1640 name = "pem-rfc7468" 1570 1641 version = "0.7.0" 1571 1642 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1727 1798 checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1728 1799 1729 1800 [[package]] 1801 + name = "ring" 1802 + version = "0.17.8" 1803 + source = "registry+https://github.com/rust-lang/crates.io-index" 1804 + checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" 1805 + dependencies = [ 1806 + "cc", 1807 + "cfg-if", 1808 + "getrandom", 1809 + "libc", 1810 + "spin", 1811 + "untrusted", 1812 + "windows-sys 0.52.0", 1813 + ] 1814 + 1815 + [[package]] 1730 1816 name = "rsa" 1731 1817 version = "0.9.7" 1732 1818 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1931 2017 ] 1932 2018 1933 2019 [[package]] 2020 + name = "simple_asn1" 2021 + version = "0.6.3" 2022 + source = "registry+https://github.com/rust-lang/crates.io-index" 2023 + checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" 2024 + dependencies = [ 2025 + "num-bigint", 2026 + "num-traits", 2027 + "thiserror 2.0.11", 2028 + "time", 2029 + ] 2030 + 2031 + [[package]] 1934 2032 name = "slab" 1935 2033 version = "0.4.9" 1936 2034 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2031 2129 "sha2", 2032 2130 "smallvec", 2033 2131 "sqlformat", 2034 - "thiserror", 2132 + "thiserror 1.0.69", 2035 2133 "tokio", 2036 2134 "tokio-stream", 2037 2135 "tracing", ··· 2116 2214 "smallvec", 2117 2215 "sqlx-core", 2118 2216 "stringprep", 2119 - "thiserror", 2217 + "thiserror 1.0.69", 2120 2218 "tracing", 2121 2219 "uuid", 2122 2220 "whoami", ··· 2156 2254 "smallvec", 2157 2255 "sqlx-core", 2158 2256 "stringprep", 2159 - "thiserror", 2257 + "thiserror 1.0.69", 2160 2258 "tracing", 2161 2259 "uuid", 2162 2260 "whoami", ··· 2269 2367 source = "registry+https://github.com/rust-lang/crates.io-index" 2270 2368 checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2271 2369 dependencies = [ 2272 - "thiserror-impl", 2370 + "thiserror-impl 1.0.69", 2371 + ] 2372 + 2373 + [[package]] 2374 + name = "thiserror" 2375 + version = "2.0.11" 2376 + source = "registry+https://github.com/rust-lang/crates.io-index" 2377 + checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" 2378 + dependencies = [ 2379 + "thiserror-impl 2.0.11", 2273 2380 ] 2274 2381 2275 2382 [[package]] ··· 2284 2391 ] 2285 2392 2286 2393 [[package]] 2394 + name = "thiserror-impl" 2395 + version = "2.0.11" 2396 + source = "registry+https://github.com/rust-lang/crates.io-index" 2397 + checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" 2398 + dependencies = [ 2399 + "proc-macro2", 2400 + "quote", 2401 + "syn 2.0.96", 2402 + ] 2403 + 2404 + [[package]] 2287 2405 name = "thread_local" 2288 2406 version = "1.1.8" 2289 2407 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2504 2622 version = "0.1.1" 2505 2623 source = "registry+https://github.com/rust-lang/crates.io-index" 2506 2624 checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" 2625 + 2626 + [[package]] 2627 + name = "untrusted" 2628 + version = "0.9.0" 2629 + source = "registry+https://github.com/rust-lang/crates.io-index" 2630 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 2507 2631 2508 2632 [[package]] 2509 2633 name = "url"
+2
Cargo.toml
··· 4 4 edition = "2021" 5 5 6 6 [dependencies] 7 + jsonwebtoken = "9" 7 8 actix-web = "4.4" 8 9 actix-cors = "0.6" 9 10 tokio = { version = "1.36", features = ["full"] } ··· 21 22 chrono = { version = "0.4", features = ["serde"] } 22 23 regex = "1.10" 23 24 lazy_static = "1.4" 25 + argon2 = "0.5.3"
-18
migrations/20240301000000_initial.sql
··· 1 - CREATE TABLE links ( 2 - id SERIAL PRIMARY KEY, 3 - original_url TEXT NOT NULL, 4 - short_code VARCHAR(8) NOT NULL UNIQUE, 5 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 6 - clicks BIGINT NOT NULL DEFAULT 0 7 - ); 8 - 9 - CREATE INDEX idx_short_code ON links(short_code); 10 - 11 - CREATE TABLE clicks ( 12 - id SERIAL PRIMARY KEY, 13 - link_id INTEGER REFERENCES links(id), 14 - source TEXT, 15 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 16 - ); 17 - 18 - CREATE INDEX idx_link_id ON clicks(link_id);
-15
migrations/20240302000000_auth_and_tracking.sql:
··· 1 - -- Add users table 2 - CREATE TABLE users ( 3 - id SERIAL PRIMARY KEY, 4 - email TEXT UNIQUE NOT NULL, 5 - password_hash TEXT NOT NULL, 6 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 7 - ); 8 - 9 - -- Add user_id to links 10 - ALTER TABLE links 11 - ADD COLUMN user_id INTEGER REFERENCES users(id); 12 - 13 - -- Add query_source to clicks 14 - ALTER TABLE clicks 15 - ADD COLUMN query_source TEXT;
+41
src/auth.rs
··· 1 + use actix_web::{dev::Payload, FromRequest, HttpRequest}; 2 + use jsonwebtoken::{decode, DecodingKey, Validation}; 3 + use std::future::{ready, Ready}; 4 + use crate::{error::AppError, models::Claims}; 5 + 6 + pub struct AuthenticatedUser { 7 + pub user_id: i32, 8 + } 9 + 10 + impl FromRequest for AuthenticatedUser { 11 + type Error = AppError; 12 + type Future = Ready<Result<Self, Self::Error>>; 13 + 14 + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 15 + let auth_header = req.headers() 16 + .get("Authorization") 17 + .and_then(|h| h.to_str().ok()); 18 + 19 + if let Some(auth_header) = auth_header { 20 + if auth_header.starts_with("Bearer ") { 21 + let token = &auth_header[7..]; 22 + let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 23 + 24 + match decode::<Claims>( 25 + token, 26 + &DecodingKey::from_secret(secret.as_bytes()), 27 + &Validation::default() 28 + ) { 29 + Ok(token_data) => { 30 + return ready(Ok(AuthenticatedUser { 31 + user_id: token_data.claims.sub, 32 + })); 33 + } 34 + Err(_) => return ready(Err(AppError::Unauthorized)), 35 + } 36 + } 37 + } 38 + 39 + ready(Err(AppError::Unauthorized)) 40 + } 41 + }
+10 -2
src/error.rs
··· 11 11 12 12 #[error("Invalid input: {0}")] 13 13 InvalidInput(String), 14 + 15 + #[error("Authentication error: {0}")] 16 + Auth(String), 17 + 18 + #[error("Unauthorized")] 19 + Unauthorized, 14 20 } 15 21 16 22 impl ResponseError for AppError { 17 23 fn error_response(&self) -> HttpResponse { 18 24 match self { 19 25 AppError::NotFound => HttpResponse::NotFound().json("Not found"), 20 - AppError::Database(_) => HttpResponse::InternalServerError().json("Internal server error"), 26 + AppError::Database(err) => HttpResponse::InternalServerError().json(format!("Database error: {}", err)), // Show actual error 21 27 AppError::InvalidInput(msg) => HttpResponse::BadRequest().json(msg), 28 + AppError::Auth(msg) => HttpResponse::BadRequest().json(msg), 29 + AppError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized"), 22 30 } 23 31 } 24 - } 32 + }
+112 -9
src/handlers.rs
··· 1 1 use actix_web::{web, HttpResponse, Responder, HttpRequest}; 2 - use crate::{AppState, error::AppError, models::{CreateLink, Link}}; 2 + use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation, errors::Error as JwtError};use crate::{error::AppError, models::{AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse}, AppState}; 3 3 use regex::Regex; 4 + use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier}; 4 5 use lazy_static::lazy_static; 6 + use argon2::{Argon2, PasswordHash, PasswordHasher}; 7 + use crate::auth::{AuthenticatedUser}; 5 8 6 9 lazy_static! { 7 10 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); ··· 9 12 10 13 pub async fn create_short_url( 11 14 state: web::Data<AppState>, 15 + user: AuthenticatedUser, 12 16 payload: web::Json<CreateLink>, 13 - req: HttpRequest, 14 17 ) -> Result<impl Responder, AppError> { 18 + tracing::debug!("Creating short URL with user_id: {}", user.user_id); 19 + 15 20 validate_url(&payload.url)?; 16 21 17 22 let short_code = if let Some(ref custom_code) = payload.custom_code { 18 23 validate_custom_code(custom_code)?; 19 24 25 + tracing::debug!("Checking if custom code {} exists", custom_code); 20 26 // Check if code is already taken 21 27 if let Some(_) = sqlx::query_as::<_, Link>( 22 28 "SELECT * FROM links WHERE short_code = $1" ··· 36 42 37 43 // Start transaction 38 44 let mut tx = state.db.begin().await?; 39 - 45 + 46 + tracing::debug!("Inserting new link with short_code: {}", short_code); 40 47 let link = sqlx::query_as::<_, Link>( 41 - "INSERT INTO links (original_url, short_code) VALUES ($1, $2) RETURNING *" 48 + "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *" 42 49 ) 43 50 .bind(&payload.url) 44 51 .bind(&short_code) 52 + .bind(user.user_id) 45 53 .fetch_one(&mut *tx) 46 54 .await?; 47 - 55 + 48 56 if let Some(ref source) = payload.source { 57 + tracing::debug!("Adding click source: {}", source); 49 58 sqlx::query( 50 59 "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" 51 60 ) ··· 54 63 .execute(&mut *tx) 55 64 .await?; 56 65 } 57 - 66 + 58 67 tx.commit().await?; 59 68 Ok(HttpResponse::Created().json(link)) 60 69 } ··· 94 103 ) -> Result<impl Responder, AppError> { 95 104 let short_code = path.into_inner(); 96 105 106 + // Extract query source if present 107 + let query_source = req.uri() 108 + .query() 109 + .and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok()) 110 + .and_then(|params| params.get("source").cloned()); 111 + 97 112 let mut tx = state.db.begin().await?; 98 113 99 114 let link = sqlx::query_as::<_, Link>( ··· 105 120 106 121 match link { 107 122 Some(link) => { 108 - // Record click with user agent as source 123 + // Record click with both user agent and query source 109 124 let user_agent = req.headers() 110 125 .get("user-agent") 111 126 .and_then(|h| h.to_str().ok()) ··· 113 128 .to_string(); 114 129 115 130 sqlx::query( 116 - "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" 131 + "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)" 117 132 ) 118 133 .bind(link.id) 119 134 .bind(user_agent) 135 + .bind(query_source) 120 136 .execute(&mut *tx) 121 137 .await?; 122 138 ··· 132 148 133 149 pub async fn get_all_links( 134 150 state: web::Data<AppState>, 151 + user: AuthenticatedUser, 135 152 ) -> Result<impl Responder, AppError> { 136 153 let links = sqlx::query_as::<_, Link>( 137 - "SELECT * FROM links ORDER BY created_at DESC" 154 + "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC" 138 155 ) 156 + .bind(user.user_id) 139 157 .fetch_all(&state.db) 140 158 .await?; 141 159 ··· 158 176 let uuid = Uuid::new_v4(); 159 177 encode(uuid.as_u128() as u64).chars().take(8).collect() 160 178 } 179 + 180 + pub async fn register( 181 + state: web::Data<AppState>, 182 + payload: web::Json<RegisterRequest>, 183 + ) -> 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?; 190 + 191 + if exists.is_some() { 192 + return Err(AppError::Auth("Email already registered".to_string())); 193 + } 194 + 195 + let salt = SaltString::generate(&mut OsRng); 196 + let argon2 = Argon2::default(); 197 + let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt) 198 + .map_err(|e| AppError::Auth(e.to_string()))? 199 + .to_string(); 200 + 201 + let user = sqlx::query_as!( 202 + User, 203 + "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *", 204 + payload.email, 205 + password_hash 206 + ) 207 + .fetch_one(&state.db) 208 + .await?; 209 + 210 + let claims = Claims::new(user.id); 211 + let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 212 + let token = encode( 213 + &Header::default(), 214 + &claims, 215 + &EncodingKey::from_secret(secret.as_bytes()) 216 + ).map_err(|e| AppError::Auth(e.to_string()))?; 217 + 218 + Ok(HttpResponse::Ok().json(AuthResponse { 219 + token, 220 + user: UserResponse { 221 + id: user.id, 222 + email: user.email, 223 + }, 224 + })) 225 + } 226 + 227 + pub async fn login( 228 + state: web::Data<AppState>, 229 + payload: web::Json<LoginRequest>, 230 + ) -> 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()))?; 239 + 240 + let argon2 = Argon2::default(); 241 + let parsed_hash = PasswordHash::new(&user.password_hash) 242 + .map_err(|e| AppError::Auth(e.to_string()))?; 243 + 244 + if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() { 245 + return Err(AppError::Auth("Invalid credentials".to_string())); 246 + } 247 + 248 + let claims = Claims::new(user.id); 249 + let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 250 + let token = encode( 251 + &Header::default(), 252 + &claims, 253 + &EncodingKey::from_secret(secret.as_bytes()) 254 + ).map_err(|e| AppError::Auth(e.to_string()))?; 255 + 256 + Ok(HttpResponse::Ok().json(AuthResponse { 257 + token, 258 + user: UserResponse { 259 + id: user.id, 260 + email: user.email, 261 + }, 262 + })) 263 + }
+5 -2
src/main.rs
··· 7 7 mod error; 8 8 mod handlers; 9 9 mod models; 10 + mod auth; 10 11 11 12 #[derive(Clone)] 12 13 pub struct AppState { ··· 35 36 .await?; 36 37 37 38 // Run database migrations 38 - sqlx::migrate!("./migrations").run(&pool).await?; 39 + //sqlx::migrate!("./migrations").run(&pool).await?; 39 40 40 41 let state = AppState { db: pool }; 41 42 ··· 55 56 .service( 56 57 web::scope("/api") 57 58 .route("/shorten", web::post().to(handlers::create_short_url)) 58 - .route("/links", web::get().to(handlers::get_all_links)), 59 + .route("/links", web::get().to(handlers::get_all_links)) 60 + .route("/auth/register", web::post().to(handlers::register)) 61 + .route("/auth/login", web::post().to(handlers::login)), 59 62 60 63 ) 61 64 .service(
+30
src/migrations/2025125_initial.sql
··· 1 + -- Create users table 2 + CREATE TABLE users ( 3 + id SERIAL PRIMARY KEY, 4 + email VARCHAR(255) NOT NULL UNIQUE, 5 + password_hash TEXT NOT NULL 6 + ); 7 + 8 + -- Create links table with user_id from the start 9 + CREATE TABLE links ( 10 + id SERIAL PRIMARY KEY, 11 + original_url TEXT NOT NULL, 12 + short_code VARCHAR(8) NOT NULL UNIQUE, 13 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 14 + clicks BIGINT NOT NULL DEFAULT 0, 15 + user_id INTEGER REFERENCES users(id) 16 + ); 17 + 18 + -- Create clicks table for tracking 19 + CREATE TABLE clicks ( 20 + id SERIAL PRIMARY KEY, 21 + link_id INTEGER REFERENCES links(id), 22 + source TEXT, 23 + query_source TEXT, 24 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 25 + ); 26 + 27 + -- Create indexes 28 + CREATE INDEX idx_short_code ON links(short_code); 29 + CREATE INDEX idx_user_id ON links(user_id); 30 + CREATE INDEX idx_link_id ON clicks(link_id);
+23 -1
src/models.rs
··· 1 + use std::time::{SystemTime, UNIX_EPOCH}; 2 + 1 3 use serde::{Deserialize, Serialize}; 2 4 use sqlx::FromRow; 3 5 6 + #[derive(Debug, Serialize, Deserialize)] 7 + pub struct Claims { 8 + pub sub: i32, // user id 9 + pub exp: usize, 10 + } 11 + 12 + impl Claims { 13 + pub fn new(user_id: i32) -> Self { 14 + let exp = SystemTime::now() 15 + .duration_since(UNIX_EPOCH) 16 + .unwrap() 17 + .as_secs() as usize + 24 * 60 * 60; // 24 hours from now 18 + 19 + Self { 20 + sub: user_id, 21 + exp, 22 + } 23 + } 24 + } 25 + 4 26 #[derive(Deserialize)] 5 27 pub struct CreateLink { 6 28 pub url: String, ··· 11 33 #[derive(Serialize, FromRow)] 12 34 pub struct Link { 13 35 pub id: i32, 14 - pub user_id: i32, 36 + pub user_id: Option<i32>, 15 37 pub original_url: String, 16 38 pub short_code: String, 17 39 pub created_at: chrono::DateTime<chrono::Utc>,