A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.
1use crate::auth::AuthenticatedUser;
2use crate::{
3 error::AppError,
4 models::{
5 AuthResponse, Claims, ClickStats, CreateLink, Link, LoginRequest, RegisterRequest,
6 SourceStats, User, UserResponse,
7 },
8 AppState,
9};
10use actix_web::{web, HttpRequest, HttpResponse, Responder};
11use argon2::{
12 password_hash::{rand_core::OsRng, SaltString},
13 PasswordVerifier,
14};
15use argon2::{Argon2, PasswordHash, PasswordHasher};
16use jsonwebtoken::{encode, EncodingKey, Header};
17use lazy_static::lazy_static;
18use regex::Regex;
19
20lazy_static! {
21 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
22}
23
24pub async fn create_short_url(
25 state: web::Data<AppState>,
26 user: AuthenticatedUser,
27 payload: web::Json<CreateLink>,
28) -> Result<impl Responder, AppError> {
29 tracing::debug!("Creating short URL with user_id: {}", user.user_id);
30
31 validate_url(&payload.url)?;
32
33 let short_code = if let Some(ref custom_code) = payload.custom_code {
34 validate_custom_code(custom_code)?;
35
36 tracing::debug!("Checking if custom code {} exists", custom_code);
37 // Check if code is already taken
38 if let Some(_) = sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1")
39 .bind(custom_code)
40 .fetch_optional(&state.db)
41 .await?
42 {
43 return Err(AppError::InvalidInput(
44 "Custom code already taken".to_string(),
45 ));
46 }
47
48 custom_code.clone()
49 } else {
50 generate_short_code()
51 };
52
53 // Start transaction
54 let mut tx = state.db.begin().await?;
55
56 tracing::debug!("Inserting new link with short_code: {}", short_code);
57 let link = sqlx::query_as::<_, Link>(
58 "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *",
59 )
60 .bind(&payload.url)
61 .bind(&short_code)
62 .bind(user.user_id)
63 .fetch_one(&mut *tx)
64 .await?;
65
66 if let Some(ref source) = payload.source {
67 tracing::debug!("Adding click source: {}", source);
68 sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
69 .bind(link.id)
70 .bind(source)
71 .execute(&mut *tx)
72 .await?;
73 }
74
75 tx.commit().await?;
76 Ok(HttpResponse::Created().json(link))
77}
78
79fn validate_custom_code(code: &str) -> Result<(), AppError> {
80 if !VALID_CODE_REGEX.is_match(code) {
81 return Err(AppError::InvalidInput(
82 "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string()
83 ));
84 }
85
86 // Add reserved words check
87 let reserved_words = ["api", "health", "admin", "static", "assets"];
88 if reserved_words.contains(&code.to_lowercase().as_str()) {
89 return Err(AppError::InvalidInput(
90 "This code is reserved and cannot be used".to_string(),
91 ));
92 }
93
94 Ok(())
95}
96
97fn validate_url(url: &String) -> Result<(), AppError> {
98 if url.is_empty() {
99 return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
100 }
101 if !url.starts_with("http://") && !url.starts_with("https://") {
102 return Err(AppError::InvalidInput(
103 "URL must start with http:// or https://".to_string(),
104 ));
105 }
106 Ok(())
107}
108
109pub async fn redirect_to_url(
110 state: web::Data<AppState>,
111 path: web::Path<String>,
112 req: HttpRequest,
113) -> Result<impl Responder, AppError> {
114 let short_code = path.into_inner();
115
116 // Extract query source if present
117 let query_source = req
118 .uri()
119 .query()
120 .and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok())
121 .and_then(|params| params.get("source").cloned());
122
123 let mut tx = state.db.begin().await?;
124
125 let link = sqlx::query_as::<_, Link>(
126 "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *",
127 )
128 .bind(&short_code)
129 .fetch_optional(&mut *tx)
130 .await?;
131
132 match link {
133 Some(link) => {
134 // Record click with both user agent and query source
135 let user_agent = req
136 .headers()
137 .get("user-agent")
138 .and_then(|h| h.to_str().ok())
139 .unwrap_or("unknown")
140 .to_string();
141
142 sqlx::query("INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)")
143 .bind(link.id)
144 .bind(user_agent)
145 .bind(query_source)
146 .execute(&mut *tx)
147 .await?;
148
149 tx.commit().await?;
150
151 Ok(HttpResponse::TemporaryRedirect()
152 .append_header(("Location", link.original_url))
153 .finish())
154 }
155 None => Err(AppError::NotFound),
156 }
157}
158
159pub async fn get_all_links(
160 state: web::Data<AppState>,
161 user: AuthenticatedUser,
162) -> Result<impl Responder, AppError> {
163 let links = sqlx::query_as::<_, Link>(
164 "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC",
165 )
166 .bind(user.user_id)
167 .fetch_all(&state.db)
168 .await?;
169
170 Ok(HttpResponse::Ok().json(links))
171}
172
173pub async fn health_check(state: web::Data<AppState>) -> impl Responder {
174 match sqlx::query("SELECT 1").execute(&state.db).await {
175 Ok(_) => HttpResponse::Ok().json("Healthy"),
176 Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"),
177 }
178}
179
180fn generate_short_code() -> String {
181 use base62::encode;
182 use uuid::Uuid;
183
184 let uuid = Uuid::new_v4();
185 encode(uuid.as_u128() as u64).chars().take(8).collect()
186}
187
188pub async fn register(
189 state: web::Data<AppState>,
190 payload: web::Json<RegisterRequest>,
191) -> Result<impl Responder, AppError> {
192 let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email)
193 .fetch_optional(&state.db)
194 .await?;
195
196 if exists.is_some() {
197 return Err(AppError::Auth("Email already registered".to_string()));
198 }
199
200 let salt = SaltString::generate(&mut OsRng);
201 let argon2 = Argon2::default();
202 let password_hash = argon2
203 .hash_password(payload.password.as_bytes(), &salt)
204 .map_err(|e| AppError::Auth(e.to_string()))?
205 .to_string();
206
207 let user = sqlx::query_as!(
208 User,
209 "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *",
210 payload.email,
211 password_hash
212 )
213 .fetch_one(&state.db)
214 .await?;
215
216 let claims = Claims::new(user.id);
217 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
218 let token = encode(
219 &Header::default(),
220 &claims,
221 &EncodingKey::from_secret(secret.as_bytes()),
222 )
223 .map_err(|e| AppError::Auth(e.to_string()))?;
224
225 Ok(HttpResponse::Ok().json(AuthResponse {
226 token,
227 user: UserResponse {
228 id: user.id,
229 email: user.email,
230 },
231 }))
232}
233
234pub async fn login(
235 state: web::Data<AppState>,
236 payload: web::Json<LoginRequest>,
237) -> Result<impl Responder, AppError> {
238 let user = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", payload.email)
239 .fetch_optional(&state.db)
240 .await?
241 .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?;
242
243 let argon2 = Argon2::default();
244 let parsed_hash =
245 PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?;
246
247 if argon2
248 .verify_password(payload.password.as_bytes(), &parsed_hash)
249 .is_err()
250 {
251 return Err(AppError::Auth("Invalid credentials".to_string()));
252 }
253
254 let claims = Claims::new(user.id);
255 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
256 let token = encode(
257 &Header::default(),
258 &claims,
259 &EncodingKey::from_secret(secret.as_bytes()),
260 )
261 .map_err(|e| AppError::Auth(e.to_string()))?;
262
263 Ok(HttpResponse::Ok().json(AuthResponse {
264 token,
265 user: UserResponse {
266 id: user.id,
267 email: user.email,
268 },
269 }))
270}
271
272pub async fn delete_link(
273 state: web::Data<AppState>,
274 user: AuthenticatedUser,
275 path: web::Path<i32>,
276) -> Result<impl Responder, AppError> {
277 let link_id = path.into_inner();
278
279 // Start transaction
280 let mut tx = state.db.begin().await?;
281
282 // Verify the link belongs to the user
283 let link = sqlx::query!(
284 "SELECT id FROM links WHERE id = $1 AND user_id = $2",
285 link_id,
286 user.user_id
287 )
288 .fetch_optional(&mut *tx)
289 .await?;
290
291 if link.is_none() {
292 return Err(AppError::NotFound);
293 }
294
295 // Delete associated clicks first due to foreign key constraint
296 sqlx::query!("DELETE FROM clicks WHERE link_id = $1", link_id)
297 .execute(&mut *tx)
298 .await?;
299
300 // Delete the link
301 sqlx::query!("DELETE FROM links WHERE id = $1", link_id)
302 .execute(&mut *tx)
303 .await?;
304
305 tx.commit().await?;
306
307 Ok(HttpResponse::NoContent().finish())
308}
309
310pub async fn get_link_clicks(
311 state: web::Data<AppState>,
312 user: AuthenticatedUser,
313 path: web::Path<i32>,
314) -> Result<impl Responder, AppError> {
315 let link_id = path.into_inner();
316
317 // Verify the link belongs to the user
318 let link = sqlx::query!(
319 "SELECT id FROM links WHERE id = $1 AND user_id = $2",
320 link_id,
321 user.user_id
322 )
323 .fetch_optional(&state.db)
324 .await?;
325
326 if link.is_none() {
327 return Err(AppError::NotFound);
328 }
329
330 let clicks = sqlx::query_as!(
331 ClickStats,
332 r#"
333 SELECT
334 DATE(created_at)::date as "date!",
335 COUNT(*)::bigint as "clicks!"
336 FROM clicks
337 WHERE link_id = $1
338 GROUP BY DATE(created_at)
339 ORDER BY DATE(created_at) ASC -- Changed from DESC to ASC
340 LIMIT 30
341 "#,
342 link_id
343 )
344 .fetch_all(&state.db)
345 .await?;
346
347 Ok(HttpResponse::Ok().json(clicks))
348}
349
350pub async fn get_link_sources(
351 state: web::Data<AppState>,
352 user: AuthenticatedUser,
353 path: web::Path<i32>,
354) -> Result<impl Responder, AppError> {
355 let link_id = path.into_inner();
356
357 // Verify the link belongs to the user
358 let link = sqlx::query!(
359 "SELECT id FROM links WHERE id = $1 AND user_id = $2",
360 link_id,
361 user.user_id
362 )
363 .fetch_optional(&state.db)
364 .await?;
365
366 if link.is_none() {
367 return Err(AppError::NotFound);
368 }
369
370 let sources = sqlx::query_as!(
371 SourceStats,
372 r#"
373 SELECT
374 query_source as "source!",
375 COUNT(*)::bigint as "count!"
376 FROM clicks
377 WHERE link_id = $1
378 AND query_source IS NOT NULL
379 AND query_source != ''
380 GROUP BY query_source
381 ORDER BY COUNT(*) DESC
382 LIMIT 10
383 "#,
384 link_id
385 )
386 .fetch_all(&state.db)
387 .await?;
388
389 Ok(HttpResponse::Ok().json(sources))
390}