diff --git a/src/analytics/aggregator.rs b/src/analytics/aggregator.rs index cf34945..05dced8 100644 --- a/src/analytics/aggregator.rs +++ b/src/analytics/aggregator.rs @@ -473,7 +473,7 @@ impl AnalyticsAggregator { // Convert to Vec and sort by count descending let mut result: Vec<(String, i64)> = grouped.into_iter().collect(); - result.sort_by(|a, b| b.1.cmp(&a.1)); + result.sort_by_key(|item| std::cmp::Reverse(item.1)); result } diff --git a/src/api/analytics.rs b/src/api/analytics.rs index f8b3632..805b782 100644 --- a/src/api/analytics.rs +++ b/src/api/analytics.rs @@ -164,7 +164,7 @@ pub async fn get_analytics_aggregate( .collect(); // Sort by visit_count descending - result.sort_by(|a, b| b.visit_count.cmp(&a.visit_count)); + result.sort_by_key(|item| std::cmp::Reverse(item.visit_count)); // Apply limit result.truncate(limit as usize); diff --git a/src/api/handlers.rs b/src/api/handlers.rs index 278ecf7..a049095 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -12,7 +12,10 @@ use rand::distr::{Alphanumeric, Distribution}; use crate::api::code_param::decode_code_path_param; use crate::auth::AuthClaims; use crate::config::Config; -use crate::models::{CreateUrlRequest, DeactivateUrlRequest, ShortenedUrl}; +use crate::models::{ + CreateUrlRequest, DeactivateUrlRequest, RestoreUrlRequest, ShortenedUrl, UpdateUrlRequest, + UrlHistoryEntry, +}; use crate::storage::{SearchParams, Storage, StorageError}; pub struct AppState { @@ -342,6 +345,190 @@ pub async fn reactivate_url( } } +/// Update a shortened URL destination +pub async fn update_url( + State(state): State>, + Extension(claims): Extension>, + Path(encoded_code): Path, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + let code = decode_code_path_param(&encoded_code)?; + + if payload.url.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "URL cannot be empty".to_string(), + }), + )); + } + + let is_admin = is_user_admin(state.storage.as_ref(), &claims).await; + let user_id = claims.as_ref().and_then(|c| c.user_id()); + + let existing = state.storage.get_authoritative(&code).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get URL: {}", e), + }), + ) + })?; + + let existing = match existing { + Some(url) => url, + None => { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "URL not found".to_string(), + }), + )) + } + }; + + if !is_admin && existing.created_by.as_deref() != user_id.as_deref() { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Only the URL owner or administrators can update URLs".to_string(), + }), + )); + } + + let updated = state + .storage + .update_url(&code, &payload.url, user_id.as_deref()) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to update URL: {}", e), + }), + ) + })?; + + Ok(Json(ShortenedUrlResponse::with_base( + updated, + Some(state.config.redirect_base_url.as_str()), + ))) +} + +/// Get the history of URL destinations for a short code +pub async fn get_url_history( + State(state): State>, + Extension(claims): Extension>, + Path(encoded_code): Path, +) -> Result>, (StatusCode, Json)> { + let code = decode_code_path_param(&encoded_code)?; + + let is_admin = is_user_admin(state.storage.as_ref(), &claims).await; + let user_id = claims.as_ref().and_then(|c| c.user_id()); + + let existing = state.storage.get_authoritative(&code).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get URL: {}", e), + }), + ) + })?; + + let existing = match existing { + Some(url) => url, + None => { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "URL not found".to_string(), + }), + )) + } + }; + + if !is_admin && existing.created_by.as_deref() != user_id.as_deref() { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Only the URL owner or administrators can view URL history".to_string(), + }), + )); + } + + let history = state.storage.get_url_history(&code).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get URL history: {}", e), + }), + ) + })?; + + Ok(Json(history)) +} + +/// Restore a shortened URL destination from history +pub async fn restore_url( + State(state): State>, + Extension(claims): Extension>, + Path(encoded_code): Path, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + let code = decode_code_path_param(&encoded_code)?; + + let is_admin = is_user_admin(state.storage.as_ref(), &claims).await; + let user_id = claims.as_ref().and_then(|c| c.user_id()); + + let existing = state.storage.get_authoritative(&code).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get URL: {}", e), + }), + ) + })?; + + let existing = match existing { + Some(url) => url, + None => { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "URL not found".to_string(), + }), + )) + } + }; + + if !is_admin && existing.created_by.as_deref() != user_id.as_deref() { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Only the URL owner or administrators can restore URLs".to_string(), + }), + )); + } + + let restored = state + .storage + .restore_url(&code, payload.history_id, user_id.as_deref()) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to restore URL: {}", e), + }), + ) + })?; + + Ok(Json(ShortenedUrlResponse::with_base( + restored, + Some(state.config.redirect_base_url.as_str()), + ))) +} + /// List all shortened URLs pub async fn list_urls( State(state): State>, diff --git a/src/api/routes.rs b/src/api/routes.rs index 63806c4..0df92c9 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -12,8 +12,8 @@ use crate::storage::Storage; use super::analytics::{get_analytics, get_analytics_aggregate, AnalyticsState}; use super::handlers::{ - create_url, deactivate_url, get_auth_mode, get_url, get_user_info, health_check, list_urls, - reactivate_url, search_urls, AppState, + create_url, deactivate_url, get_auth_mode, get_url, get_url_history, get_user_info, + health_check, list_urls, reactivate_url, restore_url, search_urls, update_url, AppState, }; use super::static_files::serve_static; @@ -41,8 +41,11 @@ pub fn create_api_router( .route("/urls", get(list_urls)) .route("/urls/search", get(search_urls)) .route("/urls/{code}", get(get_url)) + .route("/urls/{code}", put(update_url)) + .route("/urls/{code}/history", get(get_url_history)) .route("/urls/{code}/deactivate", put(deactivate_url)) .route("/urls/{code}/reactivate", put(reactivate_url)) + .route("/urls/{code}/restore", put(restore_url)) .route("/user/info", get(get_user_info)) .route_layer(middleware::from_fn(move |headers, req, next| { let auth = Arc::clone(&auth_service_clone1); diff --git a/src/models/mod.rs b/src/models/mod.rs index 6d1c2d5..72cdf6b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,6 @@ pub mod url; -pub use url::{CreateUrlRequest, DeactivateUrlRequest, ShortenedUrl}; +pub use url::{ + CreateUrlRequest, DeactivateUrlRequest, RestoreUrlRequest, ShortenedUrl, UpdateUrlRequest, + UrlHistoryEntry, +}; diff --git a/src/models/url.rs b/src/models/url.rs index b6843c1..b00f418 100644 --- a/src/models/url.rs +++ b/src/models/url.rs @@ -22,3 +22,22 @@ pub struct CreateUrlRequest { pub struct DeactivateUrlRequest { pub reason: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct UrlHistoryEntry { + pub id: i64, + pub short_code: String, + pub historic_url: String, + pub changed_at: i64, + pub changed_by: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUrlRequest { + pub url: String, +} + +#[derive(Debug, Deserialize)] +pub struct RestoreUrlRequest { + pub history_id: i64, +} diff --git a/src/storage/cached.rs b/src/storage/cached.rs index e3cee32..1f26d6d 100644 --- a/src/storage/cached.rs +++ b/src/storage/cached.rs @@ -1,4 +1,4 @@ -use crate::models::ShortenedUrl; +use crate::models::{ShortenedUrl, UrlHistoryEntry}; use crate::storage::{ LookupMetadata, LookupResult, SearchParams, SearchResult, Storage, StorageResult, }; @@ -335,6 +335,38 @@ impl Storage for CachedStorage { Ok(result) } + async fn update_url( + &self, + short_code: &str, + new_url: &str, + updated_by: Option<&str>, + ) -> Result> { + let result = self + .inner + .update_url(short_code, new_url, updated_by) + .await?; + self.invalidate_cache(short_code).await; + Ok(result) + } + + async fn get_url_history(&self, short_code: &str) -> Result> { + self.inner.get_url_history(short_code).await + } + + async fn restore_url( + &self, + short_code: &str, + history_id: i64, + restored_by: Option<&str>, + ) -> Result> { + let result = self + .inner + .restore_url(short_code, history_id, restored_by) + .await?; + self.invalidate_cache(short_code).await; + Ok(result) + } + async fn increment_clicks(&self, short_code: &str, amount: u64) -> Result<()> { if amount == 0 { return Ok(()); diff --git a/src/storage/postgres.rs b/src/storage/postgres.rs index 3feb6dc..4ffdaa6 100644 --- a/src/storage/postgres.rs +++ b/src/storage/postgres.rs @@ -1,5 +1,5 @@ use crate::analytics::{DEFAULT_IP_VERSION, DROPPED_DIMENSION_MARKER}; -use crate::models::ShortenedUrl; +use crate::models::{ShortenedUrl, UrlHistoryEntry}; use crate::storage::{ LookupMetadata, LookupResult, SearchParams, SearchResult, Storage, StorageError, StorageResult, }; @@ -1060,6 +1060,26 @@ impl Storage for PostgresStorage { .execute(self.pool.as_ref()) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS url_history ( + id BIGSERIAL PRIMARY KEY, + short_code TEXT NOT NULL, + historic_url TEXT NOT NULL, + changed_at BIGINT NOT NULL, + changed_by TEXT + ) + "#, + ) + .execute(self.pool.as_ref()) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_url_history_short_code ON url_history(short_code, changed_at DESC)", + ) + .execute(self.pool.as_ref()) + .await?; + // Index for cursor-based pagination (created_at DESC, id DESC) sqlx::query( "CREATE INDEX IF NOT EXISTS idx_urls_created_at_id ON urls(created_at DESC, id DESC)", @@ -1363,6 +1383,147 @@ impl Storage for PostgresStorage { Ok(result.rows_affected() > 0) } + async fn update_url( + &self, + short_code: &str, + new_url: &str, + updated_by: Option<&str>, + ) -> Result> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + + let mut tx = self.pool.begin().await?; + + let old_url = sqlx::query_scalar::<_, String>( + r#" + SELECT original_url + FROM urls + WHERE short_code = $1 + "#, + ) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + sqlx::query( + r#" + INSERT INTO url_history (short_code, historic_url, changed_at, changed_by) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(short_code) + .bind(&old_url) + .bind(now) + .bind(updated_by) + .execute(&mut *tx) + .await?; + + let updated = sqlx::query_as::<_, ShortenedUrl>( + r#" + UPDATE urls + SET original_url = $2 + WHERE short_code = $1 + RETURNING id, short_code, original_url, created_at, created_by, clicks, is_active + "#, + ) + .bind(short_code) + .bind(new_url) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + tx.commit().await?; + + Ok(Arc::new(updated)) + } + + async fn get_url_history(&self, short_code: &str) -> Result> { + let entries = sqlx::query_as::<_, UrlHistoryEntry>( + r#" + SELECT id, short_code, historic_url, changed_at, changed_by + FROM url_history + WHERE short_code = $1 + ORDER BY changed_at DESC + "#, + ) + .bind(short_code) + .fetch_all(self.pool.as_ref()) + .await?; + + Ok(entries) + } + + async fn restore_url( + &self, + short_code: &str, + history_id: i64, + restored_by: Option<&str>, + ) -> Result> { + let mut tx = self.pool.begin().await?; + + let historic_url = sqlx::query_scalar::<_, String>( + r#" + SELECT historic_url + FROM url_history + WHERE id = $1 AND short_code = $2 + "#, + ) + .bind(history_id) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("History entry not found")))?; + + let current_url = sqlx::query_scalar::<_, String>( + r#" + SELECT original_url + FROM urls + WHERE short_code = $1 + "#, + ) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + + sqlx::query( + r#" + INSERT INTO url_history (short_code, historic_url, changed_at, changed_by) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(short_code) + .bind(¤t_url) + .bind(now) + .bind(restored_by) + .execute(&mut *tx) + .await?; + + let updated = sqlx::query_as::<_, ShortenedUrl>( + r#" + UPDATE urls + SET original_url = $2 + WHERE short_code = $1 + RETURNING id, short_code, original_url, created_at, created_by, clicks, is_active + "#, + ) + .bind(short_code) + .bind(&historic_url) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + tx.commit().await?; + + Ok(Arc::new(updated)) + } + async fn increment_clicks(&self, short_code: &str, amount: u64) -> Result<()> { if amount == 0 { return Ok(()); diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 3d19c72..a56daa2 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -1,5 +1,5 @@ use crate::analytics::{DEFAULT_IP_VERSION, DROPPED_DIMENSION_MARKER}; -use crate::models::ShortenedUrl; +use crate::models::{ShortenedUrl, UrlHistoryEntry}; use crate::storage::{ LookupMetadata, LookupResult, SearchParams, SearchResult, Storage, StorageError, StorageResult, }; @@ -1230,6 +1230,26 @@ impl Storage for SqliteStorage { .execute(self.pool.as_ref()) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS url_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + short_code TEXT NOT NULL, + historic_url TEXT NOT NULL, + changed_at INTEGER NOT NULL, + changed_by TEXT + ) + "#, + ) + .execute(self.pool.as_ref()) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_url_history_short_code ON url_history(short_code, changed_at DESC)", + ) + .execute(self.pool.as_ref()) + .await?; + // Index for cursor-based pagination (created_at DESC, id DESC) sqlx::query( "CREATE INDEX IF NOT EXISTS idx_urls_created_at_id ON urls(created_at DESC, id DESC)", @@ -1503,6 +1523,147 @@ impl Storage for SqliteStorage { Ok(result.rows_affected() > 0) } + async fn update_url( + &self, + short_code: &str, + new_url: &str, + updated_by: Option<&str>, + ) -> Result> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + + let mut tx = self.pool.begin().await?; + + let old_url = sqlx::query_scalar::<_, String>( + r#" + SELECT original_url + FROM urls + WHERE short_code = ? + "#, + ) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + sqlx::query( + r#" + INSERT INTO url_history (short_code, historic_url, changed_at, changed_by) + VALUES (?, ?, ?, ?) + "#, + ) + .bind(short_code) + .bind(&old_url) + .bind(now) + .bind(updated_by) + .execute(&mut *tx) + .await?; + + let updated = sqlx::query_as::<_, ShortenedUrl>( + r#" + UPDATE urls + SET original_url = ? + WHERE short_code = ? + RETURNING id, short_code, original_url, created_at, created_by, clicks, is_active + "#, + ) + .bind(new_url) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + tx.commit().await?; + + Ok(Arc::new(updated)) + } + + async fn get_url_history(&self, short_code: &str) -> Result> { + let entries = sqlx::query_as::<_, UrlHistoryEntry>( + r#" + SELECT id, short_code, historic_url, changed_at, changed_by + FROM url_history + WHERE short_code = ? + ORDER BY changed_at DESC + "#, + ) + .bind(short_code) + .fetch_all(self.pool.as_ref()) + .await?; + + Ok(entries) + } + + async fn restore_url( + &self, + short_code: &str, + history_id: i64, + restored_by: Option<&str>, + ) -> Result> { + let mut tx = self.pool.begin().await?; + + let historic_url = sqlx::query_scalar::<_, String>( + r#" + SELECT historic_url + FROM url_history + WHERE id = ? AND short_code = ? + "#, + ) + .bind(history_id) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("History entry not found")))?; + + let current_url = sqlx::query_scalar::<_, String>( + r#" + SELECT original_url + FROM urls + WHERE short_code = ? + "#, + ) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + + sqlx::query( + r#" + INSERT INTO url_history (short_code, historic_url, changed_at, changed_by) + VALUES (?, ?, ?, ?) + "#, + ) + .bind(short_code) + .bind(¤t_url) + .bind(now) + .bind(restored_by) + .execute(&mut *tx) + .await?; + + let updated = sqlx::query_as::<_, ShortenedUrl>( + r#" + UPDATE urls + SET original_url = ? + WHERE short_code = ? + RETURNING id, short_code, original_url, created_at, created_by, clicks, is_active + "#, + ) + .bind(&historic_url) + .bind(short_code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| StorageError::Other(anyhow!("URL not found")))?; + + tx.commit().await?; + + Ok(Arc::new(updated)) + } + async fn increment_clicks(&self, short_code: &str, amount: u64) -> Result<()> { if amount == 0 { return Ok(()); diff --git a/src/storage/trait_def.rs b/src/storage/trait_def.rs index d438a7d..0dc41b3 100644 --- a/src/storage/trait_def.rs +++ b/src/storage/trait_def.rs @@ -1,4 +1,4 @@ -use crate::models::ShortenedUrl; +use crate::models::{ShortenedUrl, UrlHistoryEntry}; use anyhow::Result; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -93,6 +93,25 @@ pub trait Storage: Send + Sync { /// Reactivate a shortened URL async fn reactivate(&self, short_code: &str) -> Result; + /// Update the destination of an existing shortened URL + async fn update_url( + &self, + short_code: &str, + new_url: &str, + updated_by: Option<&str>, + ) -> Result>; + + /// Get the history of destinations for a short code, ordered by changed_at DESC + async fn get_url_history(&self, short_code: &str) -> Result>; + + /// Restore a URL to a historical destination + async fn restore_url( + &self, + short_code: &str, + history_id: i64, + restored_by: Option<&str>, + ) -> Result>; + /// Increment click count by the provided amount async fn increment_clicks(&self, short_code: &str, amount: u64) -> Result<()>;