//! High-level URL shortener operations. //! //! Each function combines local storage and DHT interaction. use std::time::Duration; use tesseras_dht::Node; use crate::store::UrlStore; use crate::url_entry::{MAX_URL_SIZE, UrlEntry}; /// Timeout for blocking DHT lookups. const OP_TIMEOUT: Duration = Duration::from_secs(30); /// Errors from URL operations. #[derive(Debug)] pub enum UrlError { InvalidSlug, InvalidUrl, NotFound, Expired, TooLarge, Internal(String), } impl std::fmt::Display for UrlError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidSlug => write!(f, "invalid slug"), Self::InvalidUrl => write!(f, "invalid url"), Self::NotFound => write!(f, "not found"), Self::Expired => write!(f, "expired"), Self::TooLarge => write!(f, "url too large"), Self::Internal(msg) => write!(f, "internal: {msg}"), } } } /// Create a shortened URL. Returns the slug. pub fn shorten_url( node: &mut Node, store: &UrlStore, target_url: &str, ttl_secs: u64, slug: &str, ) -> Result { if target_url.len() > MAX_URL_SIZE { return Err(UrlError::TooLarge); } if !target_url.starts_with("http://") && !target_url.starts_with("https://") { return Err(UrlError::InvalidUrl); } let slug = if slug == "auto" { UrlEntry::auto_slug(target_url) } else { slug.to_string() }; if slug.is_empty() { return Err(UrlError::InvalidSlug); } let dht_key = UrlEntry::dht_key(&slug); let entry = UrlEntry::new(slug.clone(), target_url.to_string(), ttl_secs); let serialized = entry.to_bytes(); store .put_entry(&dht_key, &serialized) .map_err(|e| UrlError::Internal(e.to_string()))?; let dht_ttl = if ttl_secs == 0 { u16::MAX } else { std::cmp::min(ttl_secs, u16::MAX as u64) as u16 }; node.put(&dht_key, &serialized, dht_ttl, false); log::info!( "shorten: {} -> {} (ttl={})", slug, target_url, if ttl_secs == 0 { "forever".to_string() } else { format!("{ttl_secs}s") } ); Ok(slug) } /// Resolve a slug to its target URL. /// Tries local store first, then falls back to DHT lookup. pub fn resolve_url( node: &mut Node, store: &UrlStore, slug: &str, ) -> Result { let dht_key = UrlEntry::dht_key(slug); let data = if let Some(local) = store.get_entry(&dht_key) { local } else { let vals = node.get_blocking(&dht_key, OP_TIMEOUT); if vals.is_empty() { return Err(UrlError::NotFound); } match vals.iter().find(|v| { UrlEntry::from_bytes(v) .map(|e| e.slug == slug) .unwrap_or(false) }) { Some(v) => v.clone(), None => return Err(UrlError::NotFound), } }; let entry = UrlEntry::from_bytes(&data).ok_or(UrlError::NotFound)?; if entry.is_expired() { return Err(UrlError::Expired); } Ok(entry.target_url) } /// Delete a URL entry from local store and the DHT. pub fn delete_url( node: &mut Node, store: &UrlStore, slug: &str, ) -> Result<(), UrlError> { let dht_key = UrlEntry::dht_key(slug); store.remove_entry(&dht_key); node.delete(&dht_key); log::info!("del: removed slug {slug}"); Ok(()) }