+1
.preludeignore
+1
.preludeignore
···
1
+
.sqlx
+23
.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json
+23
.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id FROM links WHERE id = $1 AND user_id = $2",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Int4"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Int4",
15
+
"Int4"
16
+
]
17
+
},
18
+
"nullable": [
19
+
false
20
+
]
21
+
},
22
+
"hash": "a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b"
23
+
}
+14
.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json
+14
.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json
+14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
+14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM clicks WHERE link_id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Int4"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5"
14
+
}
+118
-74
src/handlers.rs
+118
-74
src/handlers.rs
···
1
-
use actix_web::{web, HttpResponse, Responder, HttpRequest};
2
-
use jsonwebtoken::{encode, Header, EncodingKey};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
1
use crate::auth::AuthenticatedUser;
2
+
use crate::{
3
+
error::AppError,
4
+
models::{
5
+
AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse,
6
+
},
7
+
AppState,
8
+
};
9
+
use actix_web::{web, HttpRequest, HttpResponse, Responder};
10
+
use argon2::{
11
+
password_hash::{rand_core::OsRng, SaltString},
12
+
PasswordVerifier,
13
+
};
14
+
use argon2::{Argon2, PasswordHash, PasswordHasher};
15
+
use jsonwebtoken::{encode, EncodingKey, Header};
16
+
use lazy_static::lazy_static;
17
+
use regex::Regex;
8
18
9
19
lazy_static! {
10
20
static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
···
16
26
payload: web::Json<CreateLink>,
17
27
) -> Result<impl Responder, AppError> {
18
28
tracing::debug!("Creating short URL with user_id: {}", user.user_id);
19
-
29
+
20
30
validate_url(&payload.url)?;
21
-
31
+
22
32
let short_code = if let Some(ref custom_code) = payload.custom_code {
23
33
validate_custom_code(custom_code)?;
24
-
34
+
25
35
tracing::debug!("Checking if custom code {} exists", custom_code);
26
36
// Check if code is already taken
27
-
if let Some(_) = sqlx::query_as::<_, Link>(
28
-
"SELECT * FROM links WHERE short_code = $1"
29
-
)
30
-
.bind(custom_code)
31
-
.fetch_optional(&state.db)
32
-
.await? {
37
+
if let Some(_) = sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1")
38
+
.bind(custom_code)
39
+
.fetch_optional(&state.db)
40
+
.await?
41
+
{
33
42
return Err(AppError::InvalidInput(
34
-
"Custom code already taken".to_string()
43
+
"Custom code already taken".to_string(),
35
44
));
36
45
}
37
-
46
+
38
47
custom_code.clone()
39
48
} else {
40
49
generate_short_code()
41
50
};
42
-
51
+
43
52
// Start transaction
44
53
let mut tx = state.db.begin().await?;
45
-
54
+
46
55
tracing::debug!("Inserting new link with short_code: {}", short_code);
47
56
let link = sqlx::query_as::<_, Link>(
48
-
"INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *"
57
+
"INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *",
49
58
)
50
59
.bind(&payload.url)
51
60
.bind(&short_code)
52
61
.bind(user.user_id)
53
62
.fetch_one(&mut *tx)
54
63
.await?;
55
-
64
+
56
65
if let Some(ref source) = payload.source {
57
66
tracing::debug!("Adding click source: {}", source);
58
-
sqlx::query(
59
-
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)"
60
-
)
61
-
.bind(link.id)
62
-
.bind(source)
63
-
.execute(&mut *tx)
64
-
.await?;
67
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
68
+
.bind(link.id)
69
+
.bind(source)
70
+
.execute(&mut *tx)
71
+
.await?;
65
72
}
66
-
73
+
67
74
tx.commit().await?;
68
75
Ok(HttpResponse::Created().json(link))
69
76
}
···
74
81
"Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string()
75
82
));
76
83
}
77
-
84
+
78
85
// Add reserved words check
79
86
let reserved_words = ["api", "health", "admin", "static", "assets"];
80
87
if reserved_words.contains(&code.to_lowercase().as_str()) {
81
88
return Err(AppError::InvalidInput(
82
-
"This code is reserved and cannot be used".to_string()
89
+
"This code is reserved and cannot be used".to_string(),
83
90
));
84
91
}
85
-
92
+
86
93
Ok(())
87
94
}
88
95
···
91
98
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
92
99
}
93
100
if !url.starts_with("http://") && !url.starts_with("https://") {
94
-
return Err(AppError::InvalidInput("URL must start with http:// or https://".to_string()));
101
+
return Err(AppError::InvalidInput(
102
+
"URL must start with http:// or https://".to_string(),
103
+
));
95
104
}
96
105
Ok(())
97
106
}
···
102
111
req: HttpRequest,
103
112
) -> Result<impl Responder, AppError> {
104
113
let short_code = path.into_inner();
105
-
114
+
106
115
// Extract query source if present
107
-
let query_source = req.uri()
116
+
let query_source = req
117
+
.uri()
108
118
.query()
109
119
.and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok())
110
120
.and_then(|params| params.get("source").cloned());
···
112
122
let mut tx = state.db.begin().await?;
113
123
114
124
let link = sqlx::query_as::<_, Link>(
115
-
"UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *"
125
+
"UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *",
116
126
)
117
127
.bind(&short_code)
118
128
.fetch_optional(&mut *tx)
···
121
131
match link {
122
132
Some(link) => {
123
133
// Record click with both user agent and query source
124
-
let user_agent = req.headers()
134
+
let user_agent = req
135
+
.headers()
125
136
.get("user-agent")
126
137
.and_then(|h| h.to_str().ok())
127
138
.unwrap_or("unknown")
128
139
.to_string();
129
140
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?;
141
+
sqlx::query("INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)")
142
+
.bind(link.id)
143
+
.bind(user_agent)
144
+
.bind(query_source)
145
+
.execute(&mut *tx)
146
+
.await?;
138
147
139
148
tx.commit().await?;
140
149
141
150
Ok(HttpResponse::TemporaryRedirect()
142
151
.append_header(("Location", link.original_url))
143
152
.finish())
144
-
},
153
+
}
145
154
None => Err(AppError::NotFound),
146
155
}
147
156
}
···
151
160
user: AuthenticatedUser,
152
161
) -> Result<impl Responder, AppError> {
153
162
let links = sqlx::query_as::<_, Link>(
154
-
"SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC"
163
+
"SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC",
155
164
)
156
165
.bind(user.user_id)
157
166
.fetch_all(&state.db)
···
160
169
Ok(HttpResponse::Ok().json(links))
161
170
}
162
171
163
-
pub async fn health_check(
164
-
state: web::Data<AppState>,
165
-
) -> impl Responder {
172
+
pub async fn health_check(state: web::Data<AppState>) -> impl Responder {
166
173
match sqlx::query("SELECT 1").execute(&state.db).await {
167
174
Ok(_) => HttpResponse::Ok().json("Healthy"),
168
175
Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"),
···
172
179
fn generate_short_code() -> String {
173
180
use base62::encode;
174
181
use uuid::Uuid;
175
-
182
+
176
183
let uuid = Uuid::new_v4();
177
184
encode(uuid.as_u128() as u64).chars().take(8).collect()
178
185
}
···
181
188
state: web::Data<AppState>,
182
189
payload: web::Json<RegisterRequest>,
183
190
) -> 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?;
191
+
let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email)
192
+
.fetch_optional(&state.db)
193
+
.await?;
190
194
191
195
if exists.is_some() {
192
196
return Err(AppError::Auth("Email already registered".to_string()));
···
194
198
195
199
let salt = SaltString::generate(&mut OsRng);
196
200
let argon2 = Argon2::default();
197
-
let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt)
201
+
let password_hash = argon2
202
+
.hash_password(payload.password.as_bytes(), &salt)
198
203
.map_err(|e| AppError::Auth(e.to_string()))?
199
204
.to_string();
200
205
···
212
217
let token = encode(
213
218
&Header::default(),
214
219
&claims,
215
-
&EncodingKey::from_secret(secret.as_bytes())
216
-
).map_err(|e| AppError::Auth(e.to_string()))?;
220
+
&EncodingKey::from_secret(secret.as_bytes()),
221
+
)
222
+
.map_err(|e| AppError::Auth(e.to_string()))?;
217
223
218
224
Ok(HttpResponse::Ok().json(AuthResponse {
219
225
token,
···
228
234
state: web::Data<AppState>,
229
235
payload: web::Json<LoginRequest>,
230
236
) -> 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()))?;
237
+
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", payload.email)
238
+
.fetch_optional(&state.db)
239
+
.await?
240
+
.ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?;
239
241
240
242
let argon2 = Argon2::default();
241
-
let parsed_hash = PasswordHash::new(&user.password_hash)
242
-
.map_err(|e| AppError::Auth(e.to_string()))?;
243
+
let parsed_hash =
244
+
PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?;
243
245
244
-
if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() {
246
+
if argon2
247
+
.verify_password(payload.password.as_bytes(), &parsed_hash)
248
+
.is_err()
249
+
{
245
250
return Err(AppError::Auth("Invalid credentials".to_string()));
246
251
}
247
252
···
250
255
let token = encode(
251
256
&Header::default(),
252
257
&claims,
253
-
&EncodingKey::from_secret(secret.as_bytes())
254
-
).map_err(|e| AppError::Auth(e.to_string()))?;
258
+
&EncodingKey::from_secret(secret.as_bytes()),
259
+
)
260
+
.map_err(|e| AppError::Auth(e.to_string()))?;
255
261
256
262
Ok(HttpResponse::Ok().json(AuthResponse {
257
263
token,
···
260
266
email: user.email,
261
267
},
262
268
}))
263
-
}
269
+
}
270
+
271
+
pub async fn delete_link(
272
+
state: web::Data<AppState>,
273
+
user: AuthenticatedUser,
274
+
path: web::Path<i32>,
275
+
) -> Result<impl Responder, AppError> {
276
+
let link_id = path.into_inner();
277
+
278
+
// Start transaction
279
+
let mut tx = state.db.begin().await?;
280
+
281
+
// Verify the link belongs to the user
282
+
let link = sqlx::query!(
283
+
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
284
+
link_id,
285
+
user.user_id
286
+
)
287
+
.fetch_optional(&mut *tx)
288
+
.await?;
289
+
290
+
if link.is_none() {
291
+
return Err(AppError::NotFound);
292
+
}
293
+
294
+
// Delete associated clicks first due to foreign key constraint
295
+
sqlx::query!("DELETE FROM clicks WHERE link_id = $1", link_id)
296
+
.execute(&mut *tx)
297
+
.await?;
298
+
299
+
// Delete the link
300
+
sqlx::query!("DELETE FROM links WHERE id = $1", link_id)
301
+
.execute(&mut *tx)
302
+
.await?;
303
+
304
+
tx.commit().await?;
305
+
306
+
Ok(HttpResponse::NoContent().finish())
307
+
}
+5
-7
src/main.rs
+5
-7
src/main.rs
···
1
-
use actix_web::{web, App, HttpServer};
2
1
use actix_cors::Cors;
2
+
use actix_web::{web, App, HttpServer};
3
3
use anyhow::Result;
4
+
use simple_link::{handlers, AppState};
4
5
use sqlx::postgres::PgPoolOptions;
5
-
use simple_link::{AppState, handlers};
6
6
use tracing::info;
7
7
8
8
#[actix_web::main]
···
37
37
.allow_any_method()
38
38
.allow_any_header()
39
39
.max_age(3600);
40
-
40
+
41
41
App::new()
42
42
.wrap(cors)
43
43
.app_data(web::Data::new(state.clone()))
···
45
45
web::scope("/api")
46
46
.route("/shorten", web::post().to(handlers::create_short_url))
47
47
.route("/links", web::get().to(handlers::get_all_links))
48
+
.route("/links/{id}", web::delete().to(handlers::delete_link))
48
49
.route("/auth/register", web::post().to(handlers::register))
49
50
.route("/auth/login", web::post().to(handlers::login))
50
51
.route("/health", web::get().to(handlers::health_check)),
51
52
)
52
-
.service(
53
-
web::resource("/{short_code}")
54
-
.route(web::get().to(handlers::redirect_to_url))
55
-
)
53
+
.service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url)))
56
54
})
57
55
.workers(2)
58
56
.backlog(10_000)