From b81d04df548a23716e8207ba96c853dff591be07 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:05:55 -0700 Subject: [PATCH] feat(redirect_links): SQLite-backed URL shortener for token-heavy links (#870) --- src/core/all.rs | 8 + src/openhuman/mod.rs | 1 + src/openhuman/redirect_links/mod.rs | 21 ++ src/openhuman/redirect_links/ops.rs | 276 +++++++++++++++++++ src/openhuman/redirect_links/schemas.rs | 315 ++++++++++++++++++++++ src/openhuman/redirect_links/store.rs | 337 ++++++++++++++++++++++++ src/openhuman/redirect_links/types.rs | 25 ++ 7 files changed, 983 insertions(+) create mode 100644 src/openhuman/redirect_links/mod.rs create mode 100644 src/openhuman/redirect_links/ops.rs create mode 100644 src/openhuman/redirect_links/schemas.rs create mode 100644 src/openhuman/redirect_links/store.rs create mode 100644 src/openhuman/redirect_links/types.rs diff --git a/src/core/all.rs b/src/core/all.rs index 8a4c854cc..cab6456bc 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -125,6 +125,9 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::memory::all_memory_tree_registered_controllers()); // Memory tree retrieval layer (#710 — LLM-callable read tools over the tree) controllers.extend(crate::openhuman::memory::all_retrieval_registered_controllers()); + // Link shortener for long tracking URLs — saves LLM tokens + controllers + .extend(crate::openhuman::redirect_links::all_redirect_links_registered_controllers()); // Referral and growth tracking controllers.extend(crate::openhuman::referral::all_referral_registered_controllers()); // Billing and subscription management @@ -197,6 +200,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::memory::all_memory_controller_schemas()); schemas.extend(crate::openhuman::memory::all_memory_tree_controller_schemas()); schemas.extend(crate::openhuman::memory::all_retrieval_controller_schemas()); + schemas.extend(crate::openhuman::redirect_links::all_redirect_links_controller_schemas()); schemas.extend(crate::openhuman::referral::all_referral_controller_schemas()); schemas.extend(crate::openhuman::billing::all_billing_controller_schemas()); schemas.extend(crate::openhuman::team::all_team_controller_schemas()); @@ -264,6 +268,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "memory_tree" => Some( "Canonical chunk ingestion, provenance capture, and chunk retrieval for source-grounded memory.", ), + "redirect_links" => Some( + "Shorten long tracking URLs to `openhuman://link/` placeholders (SQLite-backed) to save tokens in prompts, with round-trip rewrite helpers.", + ), "referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."), "billing" => Some("Subscription plan, payment links, and credit top-up via the backend."), "team" => Some("Team member management, invites, and role changes via the backend."), @@ -556,6 +563,7 @@ mod tests { fn namespace_description_known_namespaces() { assert!(namespace_description("memory").is_some()); assert!(namespace_description("memory_tree").is_some()); + assert!(namespace_description("redirect_links").is_some()); assert!(namespace_description("billing").is_some()); assert!(namespace_description("config").is_some()); assert!(namespace_description("health").is_some()); diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 0d7d8a1f2..b6de7b0f1 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -44,6 +44,7 @@ pub mod notifications; pub mod overlay; pub mod provider_surfaces; pub mod providers; +pub mod redirect_links; pub mod referral; pub mod routing; pub mod screen_intelligence; diff --git a/src/openhuman/redirect_links/mod.rs b/src/openhuman/redirect_links/mod.rs new file mode 100644 index 000000000..5edd40fe6 --- /dev/null +++ b/src/openhuman/redirect_links/mod.rs @@ -0,0 +1,21 @@ +//! Redirect-link shortener for token-heavy URLs. +//! +//! Long tracking URLs (e.g. `trip.com/forward/...?bizData=...`) burn tokens +//! whenever they pass through a model. This domain encodes them to a short +//! `openhuman://link/` form for inbound prompts, keeps the full URL in +//! a local SQLite store, and expands them back on outbound messages so the +//! user never sees the placeholder. + +pub mod ops; +mod schemas; +mod store; +mod types; + +pub use ops as rpc; +pub use ops::{expand_link, rewrite_inbound, rewrite_outbound, shorten_url}; +pub use schemas::{ + all_controller_schemas as all_redirect_links_controller_schemas, + all_registered_controllers as all_redirect_links_registered_controllers, + schemas as redirect_links_schemas, +}; +pub use types::{RedirectLink, RewriteReplacement, RewriteResult}; diff --git a/src/openhuman/redirect_links/ops.rs b/src/openhuman/redirect_links/ops.rs new file mode 100644 index 000000000..b6502c3ee --- /dev/null +++ b/src/openhuman/redirect_links/ops.rs @@ -0,0 +1,276 @@ +use anyhow::Result; +use regex::Regex; +use serde_json::{json, Value}; +use std::sync::OnceLock; + +use crate::openhuman::config::Config; +use crate::openhuman::redirect_links::store; +use crate::openhuman::redirect_links::types::{RedirectLink, RewriteReplacement, RewriteResult}; +use crate::rpc::RpcOutcome; + +/// URLs shorter than this are not worth rewriting — the `openhuman://link/` +/// placeholder is ~24 bytes, so shortening below this just wastes work and +/// tokens. Callers may override via `rewrite_inbound_with_threshold`. +pub const DEFAULT_MIN_URL_LEN: usize = 80; + +fn url_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + // Wider than the reference regex to catch common tracking-URL characters + // (`#`, `:`, `+`, `@`, `~`, `!`, `,`, `;`). Trailing sentence punctuation + // is stripped below so regular prose doesn't get mangled. + RE.get_or_init(|| Regex::new(r#"https?://[\w\d./\?=%\-&#:+@~!,;]+"#).unwrap()) +} + +fn short_url_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"openhuman://link/([0-9a-f]+)").unwrap()) +} + +/// Strip trailing sentence punctuation (`.`, `,`, `;`, `:`, `!`) so that +/// "see https://example.com/path." doesn't capture the period. +fn trim_trailing_punct(s: &str) -> &str { + s.trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | '!')) +} + +/// Shorten a single URL, persisting it in the global store. Idempotent. +pub fn shorten_url(config: &Config, url: &str) -> Result { + store::shorten(config, url) +} + +/// Expand a previously-shortened id back to its full URL. Bumps hit count. +pub fn expand_link(config: &Config, id: &str) -> Result> { + store::expand(config, id) +} + +/// Rewrite every long URL in `text` to `openhuman://link/`, using the +/// default length threshold. +pub fn rewrite_inbound(config: &Config, text: &str) -> Result { + rewrite_inbound_with_threshold(config, text, DEFAULT_MIN_URL_LEN) +} + +pub fn rewrite_inbound_with_threshold( + config: &Config, + text: &str, + min_len: usize, +) -> Result { + let re = url_regex(); + let mut replacements: Vec = Vec::new(); + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + + for m in re.find_iter(text) { + out.push_str(&text[cursor..m.start()]); + let raw = m.as_str(); + let url = trim_trailing_punct(raw); + let trailing = &raw[url.len()..]; + + if url.len() >= min_len { + let link = store::shorten(config, url)?; + out.push_str(&link.short_url); + replacements.push(RewriteReplacement { + original: url.to_string(), + replacement: link.short_url, + id: link.id, + }); + } else { + out.push_str(url); + } + out.push_str(trailing); + cursor = m.end(); + } + out.push_str(&text[cursor..]); + + Ok(RewriteResult { + text: out, + replacements, + }) +} + +/// Replace every `openhuman://link/` placeholder with its stored URL. +/// Unknown ids are left as-is so nothing silently disappears. +pub fn rewrite_outbound(config: &Config, text: &str) -> Result { + let re = short_url_regex(); + let mut replacements: Vec = Vec::new(); + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + + for caps in re.captures_iter(text) { + let whole = caps.get(0).unwrap(); + let id = caps.get(1).unwrap().as_str(); + out.push_str(&text[cursor..whole.start()]); + + match store::expand(config, id)? { + Some(link) => { + out.push_str(&link.url); + replacements.push(RewriteReplacement { + original: whole.as_str().to_string(), + replacement: link.url, + id: link.id, + }); + } + None => { + out.push_str(whole.as_str()); + } + } + cursor = whole.end(); + } + out.push_str(&text[cursor..]); + + Ok(RewriteResult { + text: out, + replacements, + }) +} + +// ── RPC handlers ──────────────────────────────────────────────────────── + +pub async fn rl_shorten(config: &Config, url: &str) -> Result, String> { + let link = store::shorten(config, url).map_err(|e| e.to_string())?; + Ok(RpcOutcome::single_log( + link.clone(), + format!( + "[redirect_links][rpc][shorten] id={} short_url={} original_url_len={}", + link.id, + link.short_url, + link.url.len() + ), + )) +} + +pub async fn rl_expand(config: &Config, id: &str) -> Result, String> { + match store::expand(config, id).map_err(|e| e.to_string())? { + Some(link) => Ok(RpcOutcome::new( + serde_json::to_value(&link).map_err(|e| e.to_string())?, + vec![format!( + "[redirect_links][rpc][expand] id={} hit_count={}", + link.id, link.hit_count + )], + )), + None => Err(format!("[redirect_links][rpc][expand] not found: id={id}")), + } +} + +pub async fn rl_list(config: &Config, limit: Option) -> Result, String> { + let limit = limit.unwrap_or(50).clamp(1, 1_000); + let links = store::list(config, limit).map_err(|e| e.to_string())?; + Ok(RpcOutcome::new( + json!({ "links": links }), + vec![format!("[redirect_links][rpc][list] count={}", links.len())], + )) +} + +pub async fn rl_remove(config: &Config, id: &str) -> Result, String> { + let removed = store::remove(config, id).map_err(|e| e.to_string())?; + Ok(RpcOutcome::new( + json!({ "id": id, "removed": removed }), + vec![format!( + "[redirect_links][rpc][remove] id={id} removed={removed}" + )], + )) +} + +pub async fn rl_rewrite_inbound( + config: &Config, + text: &str, + min_len: Option, +) -> Result, String> { + let result = + rewrite_inbound_with_threshold(config, text, min_len.unwrap_or(DEFAULT_MIN_URL_LEN)) + .map_err(|e| e.to_string())?; + let count = result.replacements.len(); + Ok(RpcOutcome::single_log( + result, + format!("[redirect_links][rpc][rewrite_inbound] replaced={count}"), + )) +} + +pub async fn rl_rewrite_outbound( + config: &Config, + text: &str, +) -> Result, String> { + let result = rewrite_outbound(config, text).map_err(|e| e.to_string())?; + let count = result.replacements.len(); + Ok(RpcOutcome::single_log( + result, + format!("[redirect_links][rpc][rewrite_outbound] expanded={count}"), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::Config; + use tempfile::TempDir; + + fn test_config(tmp: &TempDir) -> Config { + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + cfg + } + + const LONG: &str = + "https://www.trip.com/forward/middlepages/channel/openEdm.gif?bizData=eyJldmVudCI6Im9wZW4iLCJmaWxlSWQiOiJmaWxlX2EwOD"; + + #[test] + fn inbound_shortens_long_urls_and_preserves_surrounding_text() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let text = format!("click here: {LONG} thanks"); + let result = rewrite_inbound(&cfg, &text).unwrap(); + assert!(result.text.starts_with("click here: openhuman://link/")); + assert!(result.text.ends_with(" thanks")); + assert_eq!(result.replacements.len(), 1); + } + + #[test] + fn inbound_leaves_short_urls_untouched() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let text = "see https://a.co/x for more"; + let result = rewrite_inbound(&cfg, text).unwrap(); + assert_eq!(result.text, text); + assert!(result.replacements.is_empty()); + } + + #[test] + fn inbound_trims_trailing_sentence_punctuation() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let text = format!("open {LONG}."); + let result = rewrite_inbound(&cfg, &text).unwrap(); + assert!(result.text.ends_with(".")); + // The stored URL must not carry the trailing period. + let link = &result.replacements[0]; + assert!(!link.original.ends_with('.')); + } + + #[test] + fn outbound_expands_placeholders_roundtrip() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let text = format!("go: {LONG}"); + let inbound = rewrite_inbound(&cfg, &text).unwrap(); + let outbound = rewrite_outbound(&cfg, &inbound.text).unwrap(); + assert_eq!(outbound.text, text); + } + + #[test] + fn outbound_leaves_unknown_ids_unchanged() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let text = "no match: openhuman://link/ffffffff"; + let result = rewrite_outbound(&cfg, text).unwrap(); + assert_eq!(result.text, text); + assert!(result.replacements.is_empty()); + } + + #[test] + fn inbound_handles_multiple_urls_in_one_string() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let text = format!("{LONG} and also {LONG}?extra=1234567890abcdef"); + let result = rewrite_inbound(&cfg, &text).unwrap(); + assert_eq!(result.replacements.len(), 2); + } +} diff --git a/src/openhuman/redirect_links/schemas.rs b/src/openhuman/redirect_links/schemas.rs new file mode 100644 index 000000000..8e94adfe2 --- /dev/null +++ b/src/openhuman/redirect_links/schemas.rs @@ -0,0 +1,315 @@ +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::redirect_links::ops as rl_ops; +use crate::rpc::RpcOutcome; + +pub fn all_controller_schemas() -> Vec { + vec![ + schemas("shorten"), + schemas("expand"), + schemas("list"), + schemas("remove"), + schemas("rewrite_inbound"), + schemas("rewrite_outbound"), + ] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("shorten"), + handler: handle_shorten, + }, + RegisteredController { + schema: schemas("expand"), + handler: handle_expand, + }, + RegisteredController { + schema: schemas("list"), + handler: handle_list, + }, + RegisteredController { + schema: schemas("remove"), + handler: handle_remove, + }, + RegisteredController { + schema: schemas("rewrite_inbound"), + handler: handle_rewrite_inbound, + }, + RegisteredController { + schema: schemas("rewrite_outbound"), + handler: handle_rewrite_outbound, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "shorten" => ControllerSchema { + namespace: "redirect_links", + function: "shorten", + description: "Persist a long URL and return its `openhuman://link/` short form.", + inputs: vec![FieldSchema { + name: "url", + ty: TypeSchema::String, + comment: "The full URL to shorten.", + required: true, + }], + outputs: vec![FieldSchema { + name: "link", + ty: TypeSchema::Ref("RedirectLink"), + comment: "The stored redirect link record.", + required: true, + }], + }, + "expand" => ControllerSchema { + namespace: "redirect_links", + function: "expand", + description: "Resolve a short id back to its full URL and bump hit count.", + inputs: vec![FieldSchema { + name: "id", + ty: TypeSchema::String, + comment: "The short id (the hex portion after `openhuman://link/`).", + required: true, + }], + outputs: vec![FieldSchema { + name: "link", + ty: TypeSchema::Ref("RedirectLink"), + comment: "The resolved redirect link record.", + required: true, + }], + }, + "list" => ControllerSchema { + namespace: "redirect_links", + function: "list", + description: "List stored redirect links, newest first.", + inputs: vec![FieldSchema { + name: "limit", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Maximum number of links to return (default 50, max 1000).", + required: false, + }], + outputs: vec![FieldSchema { + name: "links", + ty: TypeSchema::Array(Box::new(TypeSchema::Ref("RedirectLink"))), + comment: "Stored redirect links.", + required: true, + }], + }, + "remove" => ControllerSchema { + namespace: "redirect_links", + function: "remove", + description: "Delete a redirect link by id.", + inputs: vec![FieldSchema { + name: "id", + ty: TypeSchema::String, + comment: "Redirect link id to remove.", + required: true, + }], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Object { + fields: vec![ + FieldSchema { + name: "id", + ty: TypeSchema::String, + comment: "Id requested for removal.", + required: true, + }, + FieldSchema { + name: "removed", + ty: TypeSchema::Bool, + comment: "True when a row was deleted.", + required: true, + }, + ], + }, + comment: "Removal result.", + required: true, + }], + }, + "rewrite_inbound" => ControllerSchema { + namespace: "redirect_links", + function: "rewrite_inbound", + description: + "Rewrite every long URL in `text` to an `openhuman://link/` placeholder \ + to save tokens before a prompt hits the model. URLs shorter than `min_len` \ + are left untouched.", + inputs: vec![ + FieldSchema { + name: "text", + ty: TypeSchema::String, + comment: "Text to rewrite.", + required: true, + }, + FieldSchema { + name: "min_len", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Minimum URL length to shorten; defaults to 80.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Ref("RewriteResult"), + comment: "Rewritten text and per-URL replacement records.", + required: true, + }], + }, + "rewrite_outbound" => ControllerSchema { + namespace: "redirect_links", + function: "rewrite_outbound", + description: + "Expand every `openhuman://link/` placeholder in `text` back to its full \ + URL before the message reaches the user.", + inputs: vec![FieldSchema { + name: "text", + ty: TypeSchema::String, + comment: "Text to rewrite.", + required: true, + }], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Ref("RewriteResult"), + comment: "Rewritten text and per-placeholder expansion records.", + required: true, + }], + }, + _other => ControllerSchema { + namespace: "redirect_links", + function: "unknown", + description: "Unknown redirect_links controller function.", + inputs: vec![FieldSchema { + name: "function", + ty: TypeSchema::String, + comment: "Unknown function requested for schema lookup.", + required: true, + }], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_shorten(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let url = read_required::(¶ms, "url")?; + to_json(rl_ops::rl_shorten(&config, url.trim()).await?) + }) +} + +fn handle_expand(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let id = read_required::(¶ms, "id")?; + to_json(rl_ops::rl_expand(&config, id.trim()).await?) + }) +} + +fn handle_list(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let limit = read_optional_u64(¶ms, "limit")? + .map(|raw| usize::try_from(raw).map_err(|_| "limit is too large for usize".to_string())) + .transpose()?; + to_json(rl_ops::rl_list(&config, limit).await?) + }) +} + +fn handle_remove(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let id = read_required::(¶ms, "id")?; + to_json(rl_ops::rl_remove(&config, id.trim()).await?) + }) +} + +fn handle_rewrite_inbound(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let text = read_required::(¶ms, "text")?; + let min_len = read_optional_u64(¶ms, "min_len")? + .map(|raw| usize::try_from(raw).map_err(|_| "min_len too large for usize".to_string())) + .transpose()?; + to_json(rl_ops::rl_rewrite_inbound(&config, &text, min_len).await?) + }) +} + +fn handle_rewrite_outbound(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let text = read_required::(¶ms, "text")?; + to_json(rl_ops::rl_rewrite_outbound(&config, &text).await?) + }) +} + +fn read_required(params: &Map, key: &str) -> Result { + let value = params + .get(key) + .cloned() + .ok_or_else(|| format!("missing required param '{key}'"))?; + serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}")) +} + +fn read_optional_u64(params: &Map, key: &str) -> Result, String> { + match params.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(None), + Some(Value::Number(n)) => n + .as_u64() + .map(Some) + .ok_or_else(|| format!("invalid '{key}': expected unsigned integer")), + Some(_) => Err(format!("invalid '{key}': expected unsigned integer")), + } +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_and_controllers_cover_every_function() { + let names: Vec<_> = all_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!( + names, + vec![ + "shorten", + "expand", + "list", + "remove", + "rewrite_inbound", + "rewrite_outbound", + ], + ); + assert_eq!(all_registered_controllers().len(), 6); + } + + #[test] + fn schemas_unknown_returns_placeholder() { + let s = schemas("does-not-exist"); + assert_eq!(s.function, "unknown"); + } + + #[test] + fn shorten_schema_requires_url() { + let s = schemas("shorten"); + assert_eq!(s.inputs.len(), 1); + assert!(s.inputs[0].required); + } +} diff --git a/src/openhuman/redirect_links/store.rs b/src/openhuman/redirect_links/store.rs new file mode 100644 index 000000000..a88c78d55 --- /dev/null +++ b/src/openhuman/redirect_links/store.rs @@ -0,0 +1,337 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, OptionalExtension}; +use sha2::{Digest, Sha256}; + +use crate::openhuman::config::Config; +use crate::openhuman::redirect_links::types::RedirectLink; + +pub const SHORT_URL_PREFIX: &str = "openhuman://link/"; +const DEFAULT_ID_LEN: usize = 8; +const MAX_ID_LEN: usize = 32; + +/// Build the short URL representation for an id. +pub fn short_url_for(id: &str) -> String { + format!("{SHORT_URL_PREFIX}{id}") +} + +/// Parse a short URL back into its id component. Accepts both +/// `openhuman://link/` and bare `` (hex only). +pub fn id_from_short(short: &str) -> Option { + let trimmed = short.trim(); + let candidate = trimmed.strip_prefix(SHORT_URL_PREFIX).unwrap_or(trimmed); + if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { + Some(candidate.to_ascii_lowercase()) + } else { + None + } +} + +fn content_id(url: &str, len: usize) -> String { + let digest = Sha256::digest(url.as_bytes()); + hex::encode(digest)[..len.min(64)].to_string() +} + +pub fn shorten(config: &Config, url: &str) -> Result { + let url = url.trim(); + if url.is_empty() { + anyhow::bail!("url must not be empty"); + } + + with_connection(config, |conn| { + let mut len = DEFAULT_ID_LEN; + let now = Utc::now(); + loop { + if len > MAX_ID_LEN { + anyhow::bail!("failed to allocate unique redirect id after expansion"); + } + let id = content_id(url, len); + + // Atomic insert. If either `id` or `url` already exists, the + // statement becomes a no-op — no PRIMARY KEY / UNIQUE error under + // concurrent calls, so we don't need a pre-read. + let affected = conn + .execute( + "INSERT INTO redirect_links + (id, url, created_at, last_used_at, hit_count) + VALUES (?1, ?2, ?3, NULL, 0) + ON CONFLICT DO NOTHING", + params![id, url, now.to_rfc3339()], + ) + .context("failed to insert redirect_link")?; + + if affected > 0 { + return Ok(RedirectLink { + id: id.clone(), + url: url.to_string(), + short_url: short_url_for(&id), + created_at: now, + last_used_at: None, + hit_count: 0, + }); + } + + // Insert was a no-op. Either the URL is already stored (possibly + // under a longer id from a concurrent writer — idempotent return) + // or this id prefix collides with a different URL. + if let Some(existing) = find_by_url(conn, url)? { + return Ok(existing); + } + match get_by_id(conn, &id)? { + Some(existing) if existing.url == url => return Ok(existing), + Some(_) => { + // Hash-prefix collision with a different URL — lengthen. + len += 2; + continue; + } + None => { + // Race with a concurrent delete; retry this same length. + continue; + } + } + } + }) +} + +pub fn expand(config: &Config, id: &str) -> Result> { + let id = id.trim(); + if id.is_empty() { + return Ok(None); + } + with_connection(config, |conn| { + let found = get_by_id(conn, id)?; + if found.is_some() { + let now = Utc::now().to_rfc3339(); + conn.execute( + "UPDATE redirect_links + SET hit_count = hit_count + 1, last_used_at = ?2 + WHERE id = ?1", + params![id, now], + ) + .context("failed to bump redirect_link hit count")?; + } + Ok(found) + }) +} + +pub fn peek(config: &Config, id: &str) -> Result> { + let id = id.trim(); + if id.is_empty() { + return Ok(None); + } + with_connection(config, |conn| get_by_id(conn, id)) +} + +pub fn list(config: &Config, limit: usize) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT id, url, created_at, last_used_at, hit_count + FROM redirect_links + ORDER BY datetime(created_at) DESC + LIMIT ?1", + )?; + let rows = stmt + .query_map(params![limit as i64], row_to_link)? + .collect::, _>>()?; + Ok(rows) + }) +} + +pub fn remove(config: &Config, id: &str) -> Result { + with_connection(config, |conn| { + let affected = conn + .execute("DELETE FROM redirect_links WHERE id = ?1", params![id]) + .context("failed to delete redirect_link")?; + Ok(affected > 0) + }) +} + +fn get_by_id(conn: &Connection, id: &str) -> Result> { + conn.query_row( + "SELECT id, url, created_at, last_used_at, hit_count + FROM redirect_links WHERE id = ?1", + params![id], + row_to_link, + ) + .optional() + .map_err(Into::into) +} + +fn find_by_url(conn: &Connection, url: &str) -> Result> { + conn.query_row( + "SELECT id, url, created_at, last_used_at, hit_count + FROM redirect_links WHERE url = ?1", + params![url], + row_to_link, + ) + .optional() + .map_err(Into::into) +} + +fn row_to_link(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id: String = row.get(0)?; + let url: String = row.get(1)?; + let created_at: String = row.get(2)?; + let last_used_at: Option = row.get(3)?; + let hit_count: i64 = row.get(4)?; + let created_at = parse_ts(&created_at)?; + let last_used_at = last_used_at.as_deref().map(parse_ts).transpose()?; + Ok(RedirectLink { + short_url: short_url_for(&id), + id, + url, + created_at, + last_used_at, + hit_count: hit_count.max(0) as u64, + }) +} + +fn parse_ts(s: &str) -> rusqlite::Result> { + DateTime::parse_from_rfc3339(s) + .map(|t| t.with_timezone(&Utc)) + .map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)) + }) +} + +fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { + let db_path = config.workspace_dir.join("redirect_links").join("links.db"); + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create redirect_links directory: {}", + parent.display() + ) + })?; + } + + let conn = Connection::open(&db_path) + .with_context(|| format!("Failed to open redirect_links DB: {}", db_path.display()))?; + + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS redirect_links ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_used_at TEXT, + hit_count INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_redirect_links_url ON redirect_links(url);", + ) + .context("Failed to initialize redirect_links schema")?; + + f(&conn) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_config(tmp: &TempDir) -> Config { + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); + cfg + } + + #[test] + fn shorten_is_deterministic_and_dedupes() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let url = "https://www.trip.com/forward/middlepages/channel/openEdm.gif?bizData=eyJldmVudCI6Im9wZW4ifQ"; + let a = shorten(&cfg, url).unwrap(); + let b = shorten(&cfg, url).unwrap(); + assert_eq!(a.id, b.id); + assert_eq!(a.short_url, format!("openhuman://link/{}", a.id)); + assert_eq!(a.id.len(), DEFAULT_ID_LEN); + } + + #[test] + fn expand_returns_original_url_and_bumps_hits() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let link = shorten(&cfg, "https://example.com/a?x=1").unwrap(); + let got = expand(&cfg, &link.id).unwrap().expect("link exists"); + assert_eq!(got.url, "https://example.com/a?x=1"); + assert_eq!(got.hit_count, 0); + let got2 = expand(&cfg, &link.id).unwrap().unwrap(); + assert_eq!(got2.hit_count, 1); + } + + #[test] + fn expand_unknown_id_returns_none() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + assert!(expand(&cfg, "deadbeef").unwrap().is_none()); + } + + #[test] + fn id_from_short_accepts_scheme_and_rejects_others() { + assert_eq!( + id_from_short("openhuman://link/abc123"), + Some("abc123".into()) + ); + assert!(id_from_short("https://example.com/").is_none()); + assert!(id_from_short("openhuman://link/").is_none()); + assert!(id_from_short("openhuman://link/not-hex!").is_none()); + } + + #[test] + fn id_from_short_accepts_bare_id_and_normalizes_case() { + // The docstring promises bare-id acceptance — lock it in. + assert_eq!(id_from_short("abc123").as_deref(), Some("abc123")); + assert_eq!(id_from_short(" ABC123 ").as_deref(), Some("abc123")); + assert!(id_from_short("").is_none()); + assert!(id_from_short("not-hex").is_none()); + } + + #[test] + fn shorten_handles_concurrent_calls_without_primary_key_error() { + // Regression test: the previous check-then-insert path raced under + // concurrent calls and hit a PRIMARY KEY constraint error. The + // ON CONFLICT DO NOTHING path must return the same link for every + // concurrent caller with the same URL. + use std::sync::Arc; + use std::thread; + + let tmp = TempDir::new().unwrap(); + let cfg = Arc::new(test_config(&tmp)); + let url = "https://example.com/concurrent?x=1".to_string(); + + let mut handles = Vec::new(); + for _ in 0..8 { + let cfg = Arc::clone(&cfg); + let url = url.clone(); + handles.push(thread::spawn(move || shorten(&cfg, &url).unwrap())); + } + let ids: Vec = handles.into_iter().map(|h| h.join().unwrap().id).collect(); + // Every concurrent writer must agree on a single id for the URL. + assert!(ids.iter().all(|id| id == &ids[0])); + } + + #[test] + fn list_orders_newest_first_and_respects_limit() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + for i in 0..5 { + shorten( + &cfg, + &format!("https://example.com/{i}?v=xxxxxxxxxxxxxxxxxxxx"), + ) + .unwrap(); + } + let rows = list(&cfg, 3).unwrap(); + assert_eq!(rows.len(), 3); + } + + #[test] + fn remove_deletes_and_reports_affected() { + let tmp = TempDir::new().unwrap(); + let cfg = test_config(&tmp); + let link = shorten(&cfg, "https://example.com/rm").unwrap(); + assert!(remove(&cfg, &link.id).unwrap()); + assert!(!remove(&cfg, &link.id).unwrap()); + } +} diff --git a/src/openhuman/redirect_links/types.rs b/src/openhuman/redirect_links/types.rs new file mode 100644 index 000000000..6d631a767 --- /dev/null +++ b/src/openhuman/redirect_links/types.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedirectLink { + pub id: String, + pub url: String, + pub short_url: String, + pub created_at: DateTime, + pub last_used_at: Option>, + pub hit_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RewriteReplacement { + pub original: String, + pub replacement: String, + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RewriteResult { + pub text: String, + pub replacements: Vec, +}