A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

Add delete

+1
.preludeignore
···
+
.sqlx
+23
.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json
···
+
{
+
"db_name": "PostgreSQL",
+
"query": "SELECT id FROM links WHERE id = $1 AND user_id = $2",
+
"describe": {
+
"columns": [
+
{
+
"ordinal": 0,
+
"name": "id",
+
"type_info": "Int4"
+
}
+
],
+
"parameters": {
+
"Left": [
+
"Int4",
+
"Int4"
+
]
+
},
+
"nullable": [
+
false
+
]
+
},
+
"hash": "a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b"
+
}
+14
.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json
···
+
{
+
"db_name": "PostgreSQL",
+
"query": "DELETE FROM links WHERE id = $1",
+
"describe": {
+
"columns": [],
+
"parameters": {
+
"Left": [
+
"Int4"
+
]
+
},
+
"nullable": []
+
},
+
"hash": "d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85"
+
}
+14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
···
+
{
+
"db_name": "PostgreSQL",
+
"query": "DELETE FROM clicks WHERE link_id = $1",
+
"describe": {
+
"columns": [],
+
"parameters": {
+
"Left": [
+
"Int4"
+
]
+
},
+
"nullable": []
+
},
+
"hash": "eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5"
+
}
+118 -74
src/handlers.rs
···
-
use actix_web::{web, HttpResponse, Responder, HttpRequest};
-
use jsonwebtoken::{encode, Header, EncodingKey};use crate::{error::AppError, models::{AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse}, AppState};
-
use regex::Regex;
-
use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier};
-
use lazy_static::lazy_static;
-
use argon2::{Argon2, PasswordHash, PasswordHasher};
use crate::auth::AuthenticatedUser;
+
use crate::{
+
error::AppError,
+
models::{
+
AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse,
+
},
+
AppState,
+
};
+
use actix_web::{web, HttpRequest, HttpResponse, Responder};
+
use argon2::{
+
password_hash::{rand_core::OsRng, SaltString},
+
PasswordVerifier,
+
};
+
use argon2::{Argon2, PasswordHash, PasswordHasher};
+
use jsonwebtoken::{encode, EncodingKey, Header};
+
use lazy_static::lazy_static;
+
use regex::Regex;
lazy_static! {
static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
···
payload: web::Json<CreateLink>,
) -> Result<impl Responder, AppError> {
tracing::debug!("Creating short URL with user_id: {}", user.user_id);
-
+
validate_url(&payload.url)?;
-
+
let short_code = if let Some(ref custom_code) = payload.custom_code {
validate_custom_code(custom_code)?;
-
+
tracing::debug!("Checking if custom code {} exists", custom_code);
// Check if code is already taken
-
if let Some(_) = sqlx::query_as::<_, Link>(
-
"SELECT * FROM links WHERE short_code = $1"
-
)
-
.bind(custom_code)
-
.fetch_optional(&state.db)
-
.await? {
+
if let Some(_) = sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1")
+
.bind(custom_code)
+
.fetch_optional(&state.db)
+
.await?
+
{
return Err(AppError::InvalidInput(
-
"Custom code already taken".to_string()
+
"Custom code already taken".to_string(),
));
}
-
+
custom_code.clone()
} else {
generate_short_code()
};
-
+
// Start transaction
let mut tx = state.db.begin().await?;
-
+
tracing::debug!("Inserting new link with short_code: {}", short_code);
let link = sqlx::query_as::<_, Link>(
-
"INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *"
+
"INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *",
)
.bind(&payload.url)
.bind(&short_code)
.bind(user.user_id)
.fetch_one(&mut *tx)
.await?;
-
+
if let Some(ref source) = payload.source {
tracing::debug!("Adding click source: {}", source);
-
sqlx::query(
-
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)"
-
)
-
.bind(link.id)
-
.bind(source)
-
.execute(&mut *tx)
-
.await?;
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
+
.bind(link.id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
}
-
+
tx.commit().await?;
Ok(HttpResponse::Created().json(link))
}
···
"Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string()
));
}
-
+
// Add reserved words check
let reserved_words = ["api", "health", "admin", "static", "assets"];
if reserved_words.contains(&code.to_lowercase().as_str()) {
return Err(AppError::InvalidInput(
-
"This code is reserved and cannot be used".to_string()
+
"This code is reserved and cannot be used".to_string(),
));
}
-
+
Ok(())
}
···
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
}
if !url.starts_with("http://") && !url.starts_with("https://") {
-
return Err(AppError::InvalidInput("URL must start with http:// or https://".to_string()));
+
return Err(AppError::InvalidInput(
+
"URL must start with http:// or https://".to_string(),
+
));
}
Ok(())
}
···
req: HttpRequest,
) -> Result<impl Responder, AppError> {
let short_code = path.into_inner();
-
+
// Extract query source if present
-
let query_source = req.uri()
+
let query_source = req
+
.uri()
.query()
.and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok())
.and_then(|params| params.get("source").cloned());
···
let mut tx = state.db.begin().await?;
let link = sqlx::query_as::<_, Link>(
-
"UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *"
+
"UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *",
)
.bind(&short_code)
.fetch_optional(&mut *tx)
···
match link {
Some(link) => {
// Record click with both user agent and query source
-
let user_agent = req.headers()
+
let user_agent = req
+
.headers()
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("unknown")
.to_string();
-
sqlx::query(
-
"INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)"
-
)
-
.bind(link.id)
-
.bind(user_agent)
-
.bind(query_source)
-
.execute(&mut *tx)
-
.await?;
+
sqlx::query("INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)")
+
.bind(link.id)
+
.bind(user_agent)
+
.bind(query_source)
+
.execute(&mut *tx)
+
.await?;
tx.commit().await?;
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", link.original_url))
.finish())
-
},
+
}
None => Err(AppError::NotFound),
}
}
···
user: AuthenticatedUser,
) -> Result<impl Responder, AppError> {
let links = sqlx::query_as::<_, Link>(
-
"SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC"
+
"SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC",
)
.bind(user.user_id)
.fetch_all(&state.db)
···
Ok(HttpResponse::Ok().json(links))
}
-
pub async fn health_check(
-
state: web::Data<AppState>,
-
) -> impl Responder {
+
pub async fn health_check(state: web::Data<AppState>) -> impl Responder {
match sqlx::query("SELECT 1").execute(&state.db).await {
Ok(_) => HttpResponse::Ok().json("Healthy"),
Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"),
···
fn generate_short_code() -> String {
use base62::encode;
use uuid::Uuid;
-
+
let uuid = Uuid::new_v4();
encode(uuid.as_u128() as u64).chars().take(8).collect()
}
···
state: web::Data<AppState>,
payload: web::Json<RegisterRequest>,
) -> Result<impl Responder, AppError> {
-
let exists = sqlx::query!(
-
"SELECT id FROM users WHERE email = $1",
-
payload.email
-
)
-
.fetch_optional(&state.db)
-
.await?;
+
let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email)
+
.fetch_optional(&state.db)
+
.await?;
if exists.is_some() {
return Err(AppError::Auth("Email already registered".to_string()));
···
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
-
let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt)
+
let password_hash = argon2
+
.hash_password(payload.password.as_bytes(), &salt)
.map_err(|e| AppError::Auth(e.to_string()))?
.to_string();
···
let token = encode(
&Header::default(),
&claims,
-
&EncodingKey::from_secret(secret.as_bytes())
-
).map_err(|e| AppError::Auth(e.to_string()))?;
+
&EncodingKey::from_secret(secret.as_bytes()),
+
)
+
.map_err(|e| AppError::Auth(e.to_string()))?;
Ok(HttpResponse::Ok().json(AuthResponse {
token,
···
state: web::Data<AppState>,
payload: web::Json<LoginRequest>,
) -> Result<impl Responder, AppError> {
-
let user = sqlx::query_as!(
-
User,
-
"SELECT * FROM users WHERE email = $1",
-
payload.email
-
)
-
.fetch_optional(&state.db)
-
.await?
-
.ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?;
+
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", payload.email)
+
.fetch_optional(&state.db)
+
.await?
+
.ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?;
let argon2 = Argon2::default();
-
let parsed_hash = PasswordHash::new(&user.password_hash)
-
.map_err(|e| AppError::Auth(e.to_string()))?;
+
let parsed_hash =
+
PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?;
-
if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() {
+
if argon2
+
.verify_password(payload.password.as_bytes(), &parsed_hash)
+
.is_err()
+
{
return Err(AppError::Auth("Invalid credentials".to_string()));
}
···
let token = encode(
&Header::default(),
&claims,
-
&EncodingKey::from_secret(secret.as_bytes())
-
).map_err(|e| AppError::Auth(e.to_string()))?;
+
&EncodingKey::from_secret(secret.as_bytes()),
+
)
+
.map_err(|e| AppError::Auth(e.to_string()))?;
Ok(HttpResponse::Ok().json(AuthResponse {
token,
···
email: user.email,
},
}))
-
}
+
}
+
+
pub async fn delete_link(
+
state: web::Data<AppState>,
+
user: AuthenticatedUser,
+
path: web::Path<i32>,
+
) -> Result<impl Responder, AppError> {
+
let link_id = path.into_inner();
+
+
// Start transaction
+
let mut tx = state.db.begin().await?;
+
+
// Verify the link belongs to the user
+
let link = sqlx::query!(
+
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
+
link_id,
+
user.user_id
+
)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Delete associated clicks first due to foreign key constraint
+
sqlx::query!("DELETE FROM clicks WHERE link_id = $1", link_id)
+
.execute(&mut *tx)
+
.await?;
+
+
// Delete the link
+
sqlx::query!("DELETE FROM links WHERE id = $1", link_id)
+
.execute(&mut *tx)
+
.await?;
+
+
tx.commit().await?;
+
+
Ok(HttpResponse::NoContent().finish())
+
}
+5 -7
src/main.rs
···
-
use actix_web::{web, App, HttpServer};
use actix_cors::Cors;
+
use actix_web::{web, App, HttpServer};
use anyhow::Result;
+
use simple_link::{handlers, AppState};
use sqlx::postgres::PgPoolOptions;
-
use simple_link::{AppState, handlers};
use tracing::info;
#[actix_web::main]
···
.allow_any_method()
.allow_any_header()
.max_age(3600);
-
+
App::new()
.wrap(cors)
.app_data(web::Data::new(state.clone()))
···
web::scope("/api")
.route("/shorten", web::post().to(handlers::create_short_url))
.route("/links", web::get().to(handlers::get_all_links))
+
.route("/links/{id}", web::delete().to(handlers::delete_link))
.route("/auth/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login))
.route("/health", web::get().to(handlers::health_check)),
)
-
.service(
-
web::resource("/{short_code}")
-
.route(web::get().to(handlers::redirect_to_url))
-
)
+
.service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url)))
})
.workers(2)
.backlog(10_000)