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, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse,
6 },
7 AppState,
8};
9use actix_web::{web, HttpRequest, HttpResponse, Responder};
10use argon2::{
11 password_hash::{rand_core::OsRng, SaltString},
12 PasswordVerifier,
13};
14use argon2::{Argon2, PasswordHash, PasswordHasher};
15use jsonwebtoken::{encode, EncodingKey, Header};
16use lazy_static::lazy_static;
17use regex::Regex;
18
19lazy_static! {
20 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
21}
22
23pub async fn create_short_url(
24 state: web::Data<AppState>,
25 user: AuthenticatedUser,
26 payload: web::Json<CreateLink>,
27) -> Result<impl Responder, AppError> {
28 tracing::debug!("Creating short URL with user_id: {}", user.user_id);
29
30 validate_url(&payload.url)?;
31
32 let short_code = if let Some(ref custom_code) = payload.custom_code {
33 validate_custom_code(custom_code)?;
34
35 tracing::debug!("Checking if custom code {} exists", custom_code);
36 // Check if code is already taken
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 {
42 return Err(AppError::InvalidInput(
43 "Custom code already taken".to_string(),
44 ));
45 }
46
47 custom_code.clone()
48 } else {
49 generate_short_code()
50 };
51
52 // Start transaction
53 let mut tx = state.db.begin().await?;
54
55 tracing::debug!("Inserting new link with short_code: {}", short_code);
56 let link = sqlx::query_as::<_, Link>(
57 "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *",
58 )
59 .bind(&payload.url)
60 .bind(&short_code)
61 .bind(user.user_id)
62 .fetch_one(&mut *tx)
63 .await?;
64
65 if let Some(ref source) = payload.source {
66 tracing::debug!("Adding click source: {}", source);
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?;
72 }
73
74 tx.commit().await?;
75 Ok(HttpResponse::Created().json(link))
76}
77
78fn validate_custom_code(code: &str) -> Result<(), AppError> {
79 if !VALID_CODE_REGEX.is_match(code) {
80 return Err(AppError::InvalidInput(
81 "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string()
82 ));
83 }
84
85 // Add reserved words check
86 let reserved_words = ["api", "health", "admin", "static", "assets"];
87 if reserved_words.contains(&code.to_lowercase().as_str()) {
88 return Err(AppError::InvalidInput(
89 "This code is reserved and cannot be used".to_string(),
90 ));
91 }
92
93 Ok(())
94}
95
96fn validate_url(url: &String) -> Result<(), AppError> {
97 if url.is_empty() {
98 return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
99 }
100 if !url.starts_with("http://") && !url.starts_with("https://") {
101 return Err(AppError::InvalidInput(
102 "URL must start with http:// or https://".to_string(),
103 ));
104 }
105 Ok(())
106}
107
108pub async fn redirect_to_url(
109 state: web::Data<AppState>,
110 path: web::Path<String>,
111 req: HttpRequest,
112) -> Result<impl Responder, AppError> {
113 let short_code = path.into_inner();
114
115 // Extract query source if present
116 let query_source = req
117 .uri()
118 .query()
119 .and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok())
120 .and_then(|params| params.get("source").cloned());
121
122 let mut tx = state.db.begin().await?;
123
124 let link = sqlx::query_as::<_, Link>(
125 "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *",
126 )
127 .bind(&short_code)
128 .fetch_optional(&mut *tx)
129 .await?;
130
131 match link {
132 Some(link) => {
133 // Record click with both user agent and query source
134 let user_agent = req
135 .headers()
136 .get("user-agent")
137 .and_then(|h| h.to_str().ok())
138 .unwrap_or("unknown")
139 .to_string();
140
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?;
147
148 tx.commit().await?;
149
150 Ok(HttpResponse::TemporaryRedirect()
151 .append_header(("Location", link.original_url))
152 .finish())
153 }
154 None => Err(AppError::NotFound),
155 }
156}
157
158pub async fn get_all_links(
159 state: web::Data<AppState>,
160 user: AuthenticatedUser,
161) -> Result<impl Responder, AppError> {
162 let links = sqlx::query_as::<_, Link>(
163 "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC",
164 )
165 .bind(user.user_id)
166 .fetch_all(&state.db)
167 .await?;
168
169 Ok(HttpResponse::Ok().json(links))
170}
171
172pub async fn health_check(state: web::Data<AppState>) -> impl Responder {
173 match sqlx::query("SELECT 1").execute(&state.db).await {
174 Ok(_) => HttpResponse::Ok().json("Healthy"),
175 Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"),
176 }
177}
178
179fn generate_short_code() -> String {
180 use base62::encode;
181 use uuid::Uuid;
182
183 let uuid = Uuid::new_v4();
184 encode(uuid.as_u128() as u64).chars().take(8).collect()
185}
186
187pub async fn register(
188 state: web::Data<AppState>,
189 payload: web::Json<RegisterRequest>,
190) -> Result<impl Responder, AppError> {
191 let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email)
192 .fetch_optional(&state.db)
193 .await?;
194
195 if exists.is_some() {
196 return Err(AppError::Auth("Email already registered".to_string()));
197 }
198
199 let salt = SaltString::generate(&mut OsRng);
200 let argon2 = Argon2::default();
201 let password_hash = argon2
202 .hash_password(payload.password.as_bytes(), &salt)
203 .map_err(|e| AppError::Auth(e.to_string()))?
204 .to_string();
205
206 let user = sqlx::query_as!(
207 User,
208 "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *",
209 payload.email,
210 password_hash
211 )
212 .fetch_one(&state.db)
213 .await?;
214
215 let claims = Claims::new(user.id);
216 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
217 let token = encode(
218 &Header::default(),
219 &claims,
220 &EncodingKey::from_secret(secret.as_bytes()),
221 )
222 .map_err(|e| AppError::Auth(e.to_string()))?;
223
224 Ok(HttpResponse::Ok().json(AuthResponse {
225 token,
226 user: UserResponse {
227 id: user.id,
228 email: user.email,
229 },
230 }))
231}
232
233pub async fn login(
234 state: web::Data<AppState>,
235 payload: web::Json<LoginRequest>,
236) -> Result<impl Responder, AppError> {
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()))?;
241
242 let argon2 = Argon2::default();
243 let parsed_hash =
244 PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?;
245
246 if argon2
247 .verify_password(payload.password.as_bytes(), &parsed_hash)
248 .is_err()
249 {
250 return Err(AppError::Auth("Invalid credentials".to_string()));
251 }
252
253 let claims = Claims::new(user.id);
254 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
255 let token = encode(
256 &Header::default(),
257 &claims,
258 &EncodingKey::from_secret(secret.as_bytes()),
259 )
260 .map_err(|e| AppError::Auth(e.to_string()))?;
261
262 Ok(HttpResponse::Ok().json(AuthResponse {
263 token,
264 user: UserResponse {
265 id: user.id,
266 email: user.email,
267 },
268 }))
269}
270
271pub 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}