Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/analytics/aggregator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion src/api/analytics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
189 changes: 188 additions & 1 deletion src/api/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -342,6 +345,190 @@ pub async fn reactivate_url(
}
}

/// Update a shortened URL destination
pub async fn update_url(
State(state): State<Arc<AppState>>,
Extension(claims): Extension<Option<AuthClaims>>,
Path(encoded_code): Path<String>,
Json(payload): Json<UpdateUrlRequest>,
) -> Result<Json<ShortenedUrlResponse>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Extension(claims): Extension<Option<AuthClaims>>,
Path(encoded_code): Path<String>,
) -> Result<Json<Vec<UrlHistoryEntry>>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Extension(claims): Extension<Option<AuthClaims>>,
Path(encoded_code): Path<String>,
Json(payload): Json<RestoreUrlRequest>,
) -> Result<Json<ShortenedUrlResponse>, (StatusCode, Json<ErrorResponse>)> {
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<Arc<AppState>>,
Expand Down
7 changes: 5 additions & 2 deletions src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
pub mod url;

pub use url::{CreateUrlRequest, DeactivateUrlRequest, ShortenedUrl};
pub use url::{
CreateUrlRequest, DeactivateUrlRequest, RestoreUrlRequest, ShortenedUrl, UpdateUrlRequest,
UrlHistoryEntry,
};
19 changes: 19 additions & 0 deletions src/models/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,22 @@ pub struct CreateUrlRequest {
pub struct DeactivateUrlRequest {
pub reason: Option<String>,
}

#[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<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdateUrlRequest {
pub url: String,
}

#[derive(Debug, Deserialize)]
pub struct RestoreUrlRequest {
pub history_id: i64,
}
34 changes: 33 additions & 1 deletion src/storage/cached.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::models::ShortenedUrl;
use crate::models::{ShortenedUrl, UrlHistoryEntry};
use crate::storage::{
LookupMetadata, LookupResult, SearchParams, SearchResult, Storage, StorageResult,
};
Expand Down Expand Up @@ -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<Arc<ShortenedUrl>> {
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<Vec<UrlHistoryEntry>> {
self.inner.get_url_history(short_code).await
}

async fn restore_url(
&self,
short_code: &str,
history_id: i64,
restored_by: Option<&str>,
) -> Result<Arc<ShortenedUrl>> {
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(());
Expand Down
Loading
Loading