+119
-14
API.md
+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
+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
+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
-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:
-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
+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
+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
+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
+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
+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
+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>,