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 ## Base URL 4 `http://localhost:8080` 5 6 - ## Endpoints 7 8 ### Health Check 9 Check if the service and database are running. ··· 28 ``` 29 30 ### Create Short URL 31 - Create a new shortened URL with optional custom code. 32 33 ```bash 34 POST /api/shorten ··· 49 ```bash 50 curl -X POST http://localhost:8080/api/shorten \ 51 -H "Content-Type: application/json" \ 52 -d '{ 53 "url": "https://example.com", 54 "source": "curl-test" ··· 59 ```json 60 { 61 "id": 1, 62 "original_url": "https://example.com", 63 "short_code": "Xa7Bc9", 64 "created_at": "2024-03-01T12:34:56Z", ··· 70 ```bash 71 curl -X POST http://localhost:8080/api/shorten \ 72 -H "Content-Type: application/json" \ 73 -d '{ 74 "url": "https://example.com", 75 "custom_code": "example", ··· 81 ```json 82 { 83 "id": 2, 84 "original_url": "https://example.com", 85 "short_code": "example", 86 "created_at": "2024-03-01T12:34:56Z", ··· 111 } 112 ``` 113 114 ### Get All Links 115 - Retrieve all shortened URLs. 116 117 ```bash 118 GET /api/links ··· 120 121 Example: 122 ```bash 123 - curl http://localhost:8080/api/links 124 ``` 125 126 Response (200 OK): ··· 128 [ 129 { 130 "id": 1, 131 "original_url": "https://example.com", 132 "short_code": "Xa7Bc9", 133 "created_at": "2024-03-01T12:34:56Z", ··· 135 }, 136 { 137 "id": 2, 138 "original_url": "https://example.org", 139 "short_code": "example", 140 "created_at": "2024-03-01T12:35:00Z", ··· 144 ``` 145 146 ### Redirect to Original URL 147 - Use the shortened URL to redirect to the original URL. 148 149 ```bash 150 - GET /{short_code} 151 ``` 152 153 Example: 154 ```bash 155 - curl -i http://localhost:8080/example 156 ``` 157 158 Response (307 Temporary Redirect): ··· 169 ``` 170 171 ## Custom Code Rules 172 - 173 1. Length: 1-32 characters 174 2. Allowed characters: letters, numbers, underscores, and hyphens 175 3. Case-sensitive 176 4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"] 177 178 ## Rate Limiting 179 - 180 Currently, no rate limiting is implemented. 181 182 ## Notes 183 - 184 1. All timestamps are in UTC 185 2. Click counts are incremented on successful redirects 186 - 3. Source tracking is optional but recommended for analytics 187 4. Custom codes are case-sensitive 188 5. URLs must include protocol (http:// or https://) 189 190 ## Error Codes 191 - 192 - 200: Success 193 - 201: Created 194 - 307: Temporary Redirect 195 - 400: Bad Request (invalid input) 196 - 404: Not Found 197 - 503: Service Unavailable 198 199 ## Database Schema 200 201 - ```sql 202 CREATE TABLE links ( 203 id SERIAL PRIMARY KEY, 204 original_url TEXT NOT NULL, 205 short_code VARCHAR(8) NOT NULL UNIQUE, 206 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 207 - clicks BIGINT NOT NULL DEFAULT 0 208 ); 209 210 CREATE TABLE clicks ( 211 id SERIAL PRIMARY KEY, 212 link_id INTEGER REFERENCES links(id), 213 source TEXT, 214 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 215 ); 216 ```
··· 3 ## Base URL 4 `http://localhost:8080` 5 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 85 86 ### Health Check 87 Check if the service and database are running. ··· 106 ``` 107 108 ### Create Short URL 109 + Create a new shortened URL with optional custom code. Requires authentication. 110 111 ```bash 112 POST /api/shorten ··· 127 ```bash 128 curl -X POST http://localhost:8080/api/shorten \ 129 -H "Content-Type: application/json" \ 130 + -H "Authorization: Bearer YOUR_TOKEN" \ 131 -d '{ 132 "url": "https://example.com", 133 "source": "curl-test" ··· 138 ```json 139 { 140 "id": 1, 141 + "user_id": 1, 142 "original_url": "https://example.com", 143 "short_code": "Xa7Bc9", 144 "created_at": "2024-03-01T12:34:56Z", ··· 150 ```bash 151 curl -X POST http://localhost:8080/api/shorten \ 152 -H "Content-Type: application/json" \ 153 + -H "Authorization: Bearer YOUR_TOKEN" \ 154 -d '{ 155 "url": "https://example.com", 156 "custom_code": "example", ··· 162 ```json 163 { 164 "id": 2, 165 + "user_id": 1, 166 "original_url": "https://example.com", 167 "short_code": "example", 168 "created_at": "2024-03-01T12:34:56Z", ··· 193 } 194 ``` 195 196 + Unauthorized (401 Unauthorized): 197 + ```json 198 + { 199 + "error": "Unauthorized" 200 + } 201 + ``` 202 + 203 ### Get All Links 204 + Retrieve all shortened URLs for the authenticated user. 205 206 ```bash 207 GET /api/links ··· 209 210 Example: 211 ```bash 212 + curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/links 213 ``` 214 215 Response (200 OK): ··· 217 [ 218 { 219 "id": 1, 220 + "user_id": 1, 221 "original_url": "https://example.com", 222 "short_code": "Xa7Bc9", 223 "created_at": "2024-03-01T12:34:56Z", ··· 225 }, 226 { 227 "id": 2, 228 + "user_id": 1, 229 "original_url": "https://example.org", 230 "short_code": "example", 231 "created_at": "2024-03-01T12:35:00Z", ··· 235 ``` 236 237 ### Redirect to Original URL 238 + Use the shortened URL to redirect to the original URL. Source tracking via query parameter is supported. 239 240 ```bash 241 + GET /{short_code}?source={source} 242 ``` 243 244 Example: 245 ```bash 246 + curl -i http://localhost:8080/example?source=email 247 ``` 248 249 Response (307 Temporary Redirect): ··· 260 ``` 261 262 ## Custom Code Rules 263 1. Length: 1-32 characters 264 2. Allowed characters: letters, numbers, underscores, and hyphens 265 3. Case-sensitive 266 4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"] 267 268 ## Rate Limiting 269 Currently, no rate limiting is implemented. 270 271 ## Notes 272 1. All timestamps are in UTC 273 2. Click counts are incremented on successful redirects 274 + 3. Source tracking is supported both at link creation and during redirection via query parameter 275 4. Custom codes are case-sensitive 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 279 280 ## Error Codes 281 - 200: Success 282 - 201: Created 283 - 307: Temporary Redirect 284 - 400: Bad Request (invalid input) 285 + - 401: Unauthorized (missing or invalid token) 286 - 404: Not Found 287 - 503: Service Unavailable 288 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 + ); 297 298 + -- Links table with user association 299 CREATE TABLE links ( 300 id SERIAL PRIMARY KEY, 301 original_url TEXT NOT NULL, 302 short_code VARCHAR(8) NOT NULL UNIQUE, 303 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 304 + clicks BIGINT NOT NULL DEFAULT 0, 305 + user_id INTEGER REFERENCES users(id) 306 ); 307 308 + -- Click tracking with source information 309 CREATE TABLE clicks ( 310 id SERIAL PRIMARY KEY, 311 link_id INTEGER REFERENCES links(id), 312 source TEXT, 313 + query_source TEXT, 314 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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); 321 ```
+129 -5
Cargo.lock
··· 9 "actix-cors", 10 "actix-web", 11 "anyhow", 12 "base62", 13 "chrono", 14 "clap", 15 "dotenv", 16 "lazy_static", 17 "regex", 18 "serde", 19 "serde_json", 20 "sqlx", 21 - "thiserror", 22 "tokio", 23 "tracing", 24 "tracing-subscriber", ··· 353 checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 354 355 [[package]] 356 name = "atoi" 357 version = "2.0.0" 358 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 419 ] 420 421 [[package]] 422 name = "block-buffer" 423 version = "0.10.4" 424 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 915 checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 916 dependencies = [ 917 "cfg-if", 918 "libc", 919 "wasi", 920 ] 921 922 [[package]] ··· 1250 ] 1251 1252 [[package]] 1253 name = "language-tags" 1254 version = "0.3.2" 1255 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1419 ] 1420 1421 [[package]] 1422 name = "num-bigint-dig" 1423 version = "0.8.4" 1424 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1560 ] 1561 1562 [[package]] 1563 name = "paste" 1564 version = "1.0.15" 1565 source = "registry+https://github.com/rust-lang/crates.io-index" 1566 checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1567 1568 [[package]] 1569 name = "pem-rfc7468" 1570 version = "0.7.0" 1571 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1727 checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1728 1729 [[package]] 1730 name = "rsa" 1731 version = "0.9.7" 1732 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1931 ] 1932 1933 [[package]] 1934 name = "slab" 1935 version = "0.4.9" 1936 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2031 "sha2", 2032 "smallvec", 2033 "sqlformat", 2034 - "thiserror", 2035 "tokio", 2036 "tokio-stream", 2037 "tracing", ··· 2116 "smallvec", 2117 "sqlx-core", 2118 "stringprep", 2119 - "thiserror", 2120 "tracing", 2121 "uuid", 2122 "whoami", ··· 2156 "smallvec", 2157 "sqlx-core", 2158 "stringprep", 2159 - "thiserror", 2160 "tracing", 2161 "uuid", 2162 "whoami", ··· 2269 source = "registry+https://github.com/rust-lang/crates.io-index" 2270 checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2271 dependencies = [ 2272 - "thiserror-impl", 2273 ] 2274 2275 [[package]] ··· 2284 ] 2285 2286 [[package]] 2287 name = "thread_local" 2288 version = "1.1.8" 2289 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2504 version = "0.1.1" 2505 source = "registry+https://github.com/rust-lang/crates.io-index" 2506 checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" 2507 2508 [[package]] 2509 name = "url"
··· 9 "actix-cors", 10 "actix-web", 11 "anyhow", 12 + "argon2", 13 "base62", 14 "chrono", 15 "clap", 16 "dotenv", 17 + "jsonwebtoken", 18 "lazy_static", 19 "regex", 20 "serde", 21 "serde_json", 22 "sqlx", 23 + "thiserror 1.0.69", 24 "tokio", 25 "tracing", 26 "tracing-subscriber", ··· 355 checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 356 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]] 370 name = "atoi" 371 version = "2.0.0" 372 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 433 ] 434 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]] 445 name = "block-buffer" 446 version = "0.10.4" 447 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 938 checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 939 dependencies = [ 940 "cfg-if", 941 + "js-sys", 942 "libc", 943 "wasi", 944 + "wasm-bindgen", 945 ] 946 947 [[package]] ··· 1275 ] 1276 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]] 1293 name = "language-tags" 1294 version = "0.3.2" 1295 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1459 ] 1460 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]] 1472 name = "num-bigint-dig" 1473 version = "0.8.4" 1474 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1610 ] 1611 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]] 1624 name = "paste" 1625 version = "1.0.15" 1626 source = "registry+https://github.com/rust-lang/crates.io-index" 1627 checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1628 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]] 1640 name = "pem-rfc7468" 1641 version = "0.7.0" 1642 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1798 checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1799 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]] 1816 name = "rsa" 1817 version = "0.9.7" 1818 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2017 ] 2018 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]] 2032 name = "slab" 2033 version = "0.4.9" 2034 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2129 "sha2", 2130 "smallvec", 2131 "sqlformat", 2132 + "thiserror 1.0.69", 2133 "tokio", 2134 "tokio-stream", 2135 "tracing", ··· 2214 "smallvec", 2215 "sqlx-core", 2216 "stringprep", 2217 + "thiserror 1.0.69", 2218 "tracing", 2219 "uuid", 2220 "whoami", ··· 2254 "smallvec", 2255 "sqlx-core", 2256 "stringprep", 2257 + "thiserror 1.0.69", 2258 "tracing", 2259 "uuid", 2260 "whoami", ··· 2367 source = "registry+https://github.com/rust-lang/crates.io-index" 2368 checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2369 dependencies = [ 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", 2380 ] 2381 2382 [[package]] ··· 2391 ] 2392 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]] 2405 name = "thread_local" 2406 version = "1.1.8" 2407 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2622 version = "0.1.1" 2623 source = "registry+https://github.com/rust-lang/crates.io-index" 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" 2631 2632 [[package]] 2633 name = "url"
+2
Cargo.toml
··· 4 edition = "2021" 5 6 [dependencies] 7 actix-web = "4.4" 8 actix-cors = "0.6" 9 tokio = { version = "1.36", features = ["full"] } ··· 21 chrono = { version = "0.4", features = ["serde"] } 22 regex = "1.10" 23 lazy_static = "1.4"
··· 4 edition = "2021" 5 6 [dependencies] 7 + jsonwebtoken = "9" 8 actix-web = "4.4" 9 actix-cors = "0.6" 10 tokio = { version = "1.36", features = ["full"] } ··· 22 chrono = { version = "0.4", features = ["serde"] } 23 regex = "1.10" 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 12 #[error("Invalid input: {0}")] 13 InvalidInput(String), 14 } 15 16 impl ResponseError for AppError { 17 fn error_response(&self) -> HttpResponse { 18 match self { 19 AppError::NotFound => HttpResponse::NotFound().json("Not found"), 20 - AppError::Database(_) => HttpResponse::InternalServerError().json("Internal server error"), 21 AppError::InvalidInput(msg) => HttpResponse::BadRequest().json(msg), 22 } 23 } 24 - }
··· 11 12 #[error("Invalid input: {0}")] 13 InvalidInput(String), 14 + 15 + #[error("Authentication error: {0}")] 16 + Auth(String), 17 + 18 + #[error("Unauthorized")] 19 + Unauthorized, 20 } 21 22 impl ResponseError for AppError { 23 fn error_response(&self) -> HttpResponse { 24 match self { 25 AppError::NotFound => HttpResponse::NotFound().json("Not found"), 26 + AppError::Database(err) => HttpResponse::InternalServerError().json(format!("Database error: {}", err)), // Show actual error 27 AppError::InvalidInput(msg) => HttpResponse::BadRequest().json(msg), 28 + AppError::Auth(msg) => HttpResponse::BadRequest().json(msg), 29 + AppError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized"), 30 } 31 } 32 + }
+112 -9
src/handlers.rs
··· 1 use actix_web::{web, HttpResponse, Responder, HttpRequest}; 2 - use crate::{AppState, error::AppError, models::{CreateLink, Link}}; 3 use regex::Regex; 4 use lazy_static::lazy_static; 5 6 lazy_static! { 7 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); ··· 9 10 pub async fn create_short_url( 11 state: web::Data<AppState>, 12 payload: web::Json<CreateLink>, 13 - req: HttpRequest, 14 ) -> Result<impl Responder, AppError> { 15 validate_url(&payload.url)?; 16 17 let short_code = if let Some(ref custom_code) = payload.custom_code { 18 validate_custom_code(custom_code)?; 19 20 // Check if code is already taken 21 if let Some(_) = sqlx::query_as::<_, Link>( 22 "SELECT * FROM links WHERE short_code = $1" ··· 36 37 // Start transaction 38 let mut tx = state.db.begin().await?; 39 - 40 let link = sqlx::query_as::<_, Link>( 41 - "INSERT INTO links (original_url, short_code) VALUES ($1, $2) RETURNING *" 42 ) 43 .bind(&payload.url) 44 .bind(&short_code) 45 .fetch_one(&mut *tx) 46 .await?; 47 - 48 if let Some(ref source) = payload.source { 49 sqlx::query( 50 "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" 51 ) ··· 54 .execute(&mut *tx) 55 .await?; 56 } 57 - 58 tx.commit().await?; 59 Ok(HttpResponse::Created().json(link)) 60 } ··· 94 ) -> Result<impl Responder, AppError> { 95 let short_code = path.into_inner(); 96 97 let mut tx = state.db.begin().await?; 98 99 let link = sqlx::query_as::<_, Link>( ··· 105 106 match link { 107 Some(link) => { 108 - // Record click with user agent as source 109 let user_agent = req.headers() 110 .get("user-agent") 111 .and_then(|h| h.to_str().ok()) ··· 113 .to_string(); 114 115 sqlx::query( 116 - "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" 117 ) 118 .bind(link.id) 119 .bind(user_agent) 120 .execute(&mut *tx) 121 .await?; 122 ··· 132 133 pub async fn get_all_links( 134 state: web::Data<AppState>, 135 ) -> Result<impl Responder, AppError> { 136 let links = sqlx::query_as::<_, Link>( 137 - "SELECT * FROM links ORDER BY created_at DESC" 138 ) 139 .fetch_all(&state.db) 140 .await?; 141 ··· 158 let uuid = Uuid::new_v4(); 159 encode(uuid.as_u128() as u64).chars().take(8).collect() 160 }
··· 1 use actix_web::{web, HttpResponse, Responder, HttpRequest}; 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 use regex::Regex; 4 + use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier}; 5 use lazy_static::lazy_static; 6 + use argon2::{Argon2, PasswordHash, PasswordHasher}; 7 + use crate::auth::{AuthenticatedUser}; 8 9 lazy_static! { 10 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); ··· 12 13 pub async fn create_short_url( 14 state: web::Data<AppState>, 15 + user: AuthenticatedUser, 16 payload: web::Json<CreateLink>, 17 ) -> Result<impl Responder, AppError> { 18 + tracing::debug!("Creating short URL with user_id: {}", user.user_id); 19 + 20 validate_url(&payload.url)?; 21 22 let short_code = if let Some(ref custom_code) = payload.custom_code { 23 validate_custom_code(custom_code)?; 24 25 + tracing::debug!("Checking if custom code {} exists", custom_code); 26 // Check if code is already taken 27 if let Some(_) = sqlx::query_as::<_, Link>( 28 "SELECT * FROM links WHERE short_code = $1" ··· 42 43 // Start transaction 44 let mut tx = state.db.begin().await?; 45 + 46 + tracing::debug!("Inserting new link with short_code: {}", short_code); 47 let link = sqlx::query_as::<_, Link>( 48 + "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *" 49 ) 50 .bind(&payload.url) 51 .bind(&short_code) 52 + .bind(user.user_id) 53 .fetch_one(&mut *tx) 54 .await?; 55 + 56 if let Some(ref source) = payload.source { 57 + tracing::debug!("Adding click source: {}", source); 58 sqlx::query( 59 "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" 60 ) ··· 63 .execute(&mut *tx) 64 .await?; 65 } 66 + 67 tx.commit().await?; 68 Ok(HttpResponse::Created().json(link)) 69 } ··· 103 ) -> Result<impl Responder, AppError> { 104 let short_code = path.into_inner(); 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 + 112 let mut tx = state.db.begin().await?; 113 114 let link = sqlx::query_as::<_, Link>( ··· 120 121 match link { 122 Some(link) => { 123 + // Record click with both user agent and query source 124 let user_agent = req.headers() 125 .get("user-agent") 126 .and_then(|h| h.to_str().ok()) ··· 128 .to_string(); 129 130 sqlx::query( 131 + "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)" 132 ) 133 .bind(link.id) 134 .bind(user_agent) 135 + .bind(query_source) 136 .execute(&mut *tx) 137 .await?; 138 ··· 148 149 pub async fn get_all_links( 150 state: web::Data<AppState>, 151 + user: AuthenticatedUser, 152 ) -> Result<impl Responder, AppError> { 153 let links = sqlx::query_as::<_, Link>( 154 + "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC" 155 ) 156 + .bind(user.user_id) 157 .fetch_all(&state.db) 158 .await?; 159 ··· 176 let uuid = Uuid::new_v4(); 177 encode(uuid.as_u128() as u64).chars().take(8).collect() 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 mod error; 8 mod handlers; 9 mod models; 10 11 #[derive(Clone)] 12 pub struct AppState { ··· 35 .await?; 36 37 // Run database migrations 38 - sqlx::migrate!("./migrations").run(&pool).await?; 39 40 let state = AppState { db: pool }; 41 ··· 55 .service( 56 web::scope("/api") 57 .route("/shorten", web::post().to(handlers::create_short_url)) 58 - .route("/links", web::get().to(handlers::get_all_links)), 59 60 ) 61 .service(
··· 7 mod error; 8 mod handlers; 9 mod models; 10 + mod auth; 11 12 #[derive(Clone)] 13 pub struct AppState { ··· 36 .await?; 37 38 // Run database migrations 39 + //sqlx::migrate!("./migrations").run(&pool).await?; 40 41 let state = AppState { db: pool }; 42 ··· 56 .service( 57 web::scope("/api") 58 .route("/shorten", web::post().to(handlers::create_short_url)) 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)), 62 63 ) 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 serde::{Deserialize, Serialize}; 2 use sqlx::FromRow; 3 4 #[derive(Deserialize)] 5 pub struct CreateLink { 6 pub url: String, ··· 11 #[derive(Serialize, FromRow)] 12 pub struct Link { 13 pub id: i32, 14 - pub user_id: i32, 15 pub original_url: String, 16 pub short_code: String, 17 pub created_at: chrono::DateTime<chrono::Utc>,
··· 1 + use std::time::{SystemTime, UNIX_EPOCH}; 2 + 3 use serde::{Deserialize, Serialize}; 4 use sqlx::FromRow; 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 + 26 #[derive(Deserialize)] 27 pub struct CreateLink { 28 pub url: String, ··· 33 #[derive(Serialize, FromRow)] 34 pub struct Link { 35 pub id: i32, 36 + pub user_id: Option<i32>, 37 pub original_url: String, 38 pub short_code: String, 39 pub created_at: chrono::DateTime<chrono::Utc>,