Add delete
This commit is contained in:
parent
9ca7e2753b
commit
e5701206e6
6 changed files with 175 additions and 81 deletions
1
.preludeignore
Normal file
1
.preludeignore
Normal file
|
@ -0,0 +1 @@
|
|||
.sqlx
|
23
.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json
generated
Normal file
23
.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json
generated
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"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
generated
Normal file
14
.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json
generated
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM links WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85"
|
||||
}
|
14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
generated
Normal file
14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
generated
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM clicks WHERE link_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5"
|
||||
}
|
166
src/handlers.rs
166
src/handlers.rs
|
@ -1,10 +1,20 @@
|
|||
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();
|
||||
|
@ -24,14 +34,13 @@ pub async fn create_short_url(
|
|||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -45,7 +54,7 @@ pub async fn create_short_url(
|
|||
|
||||
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)
|
||||
|
@ -55,13 +64,11 @@ pub async fn create_short_url(
|
|||
|
||||
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?;
|
||||
|
@ -79,7 +86,7 @@ fn validate_custom_code(code: &str) -> Result<(), AppError> {
|
|||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -91,7 +98,9 @@ fn validate_url(url: &String) -> Result<(), AppError> {
|
|||
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(())
|
||||
}
|
||||
|
@ -104,7 +113,8 @@ pub async fn redirect_to_url(
|
|||
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());
|
||||
|
@ -112,7 +122,7 @@ pub async fn redirect_to_url(
|
|||
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)
|
||||
|
@ -121,27 +131,26 @@ pub async fn redirect_to_url(
|
|||
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),
|
||||
}
|
||||
}
|
||||
|
@ -151,7 +160,7 @@ pub async fn get_all_links(
|
|||
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)
|
||||
|
@ -160,9 +169,7 @@ pub async fn get_all_links(
|
|||
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"),
|
||||
|
@ -181,12 +188,9 @@ pub async fn register(
|
|||
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()));
|
||||
|
@ -194,7 +198,8 @@ pub async fn register(
|
|||
|
||||
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();
|
||||
|
||||
|
@ -212,8 +217,9 @@ pub async fn register(
|
|||
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,
|
||||
|
@ -228,20 +234,19 @@ pub async fn login(
|
|||
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()));
|
||||
}
|
||||
|
||||
|
@ -250,8 +255,9 @@ pub async fn login(
|
|||
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,
|
||||
|
@ -261,3 +267,41 @@ pub async fn login(
|
|||
},
|
||||
}))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
10
src/main.rs
10
src/main.rs
|
@ -1,8 +1,8 @@
|
|||
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]
|
||||
|
@ -45,14 +45,12 @@ async fn main() -> Result<()> {
|
|||
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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue