feat(redirect_links): SQLite-backed URL shortener for token-heavy links (#870)

This commit is contained in:
Steven Enamakel
2026-04-24 00:05:55 -07:00
committed by GitHub
parent 478c92e533
commit b81d04df54
7 changed files with 983 additions and 0 deletions
+8
View File
@@ -125,6 +125,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
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<ControllerSchema> {
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/<id>` 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());
+1
View File
@@ -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;
+21
View File
@@ -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/<id>` 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};
+276
View File
@@ -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/<id>`
/// 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<Regex> = 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<Regex> = 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<RedirectLink> {
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<Option<RedirectLink>> {
store::expand(config, id)
}
/// Rewrite every long URL in `text` to `openhuman://link/<id>`, using the
/// default length threshold.
pub fn rewrite_inbound(config: &Config, text: &str) -> Result<RewriteResult> {
rewrite_inbound_with_threshold(config, text, DEFAULT_MIN_URL_LEN)
}
pub fn rewrite_inbound_with_threshold(
config: &Config,
text: &str,
min_len: usize,
) -> Result<RewriteResult> {
let re = url_regex();
let mut replacements: Vec<RewriteReplacement> = 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/<id>` placeholder with its stored URL.
/// Unknown ids are left as-is so nothing silently disappears.
pub fn rewrite_outbound(config: &Config, text: &str) -> Result<RewriteResult> {
let re = short_url_regex();
let mut replacements: Vec<RewriteReplacement> = 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<RpcOutcome<RedirectLink>, 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<RpcOutcome<Value>, 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<usize>) -> Result<RpcOutcome<Value>, 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<RpcOutcome<Value>, 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<usize>,
) -> Result<RpcOutcome<RewriteResult>, 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<RpcOutcome<RewriteResult>, 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);
}
}
+315
View File
@@ -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<ControllerSchema> {
vec![
schemas("shorten"),
schemas("expand"),
schemas("list"),
schemas("remove"),
schemas("rewrite_inbound"),
schemas("rewrite_outbound"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
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/<id>` 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/<id>` 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/<id>` 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let url = read_required::<String>(&params, "url")?;
to_json(rl_ops::rl_shorten(&config, url.trim()).await?)
})
}
fn handle_expand(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(&params, "id")?;
to_json(rl_ops::rl_expand(&config, id.trim()).await?)
})
}
fn handle_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let limit = read_optional_u64(&params, "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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(&params, "id")?;
to_json(rl_ops::rl_remove(&config, id.trim()).await?)
})
}
fn handle_rewrite_inbound(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let text = read_required::<String>(&params, "text")?;
let min_len = read_optional_u64(&params, "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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let text = read_required::<String>(&params, "text")?;
to_json(rl_ops::rl_rewrite_outbound(&config, &text).await?)
})
}
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
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<String, Value>, key: &str) -> Result<Option<u64>, 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<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
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);
}
}
+337
View File
@@ -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/<id>` and bare `<id>` (hex only).
pub fn id_from_short(short: &str) -> Option<String> {
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<RedirectLink> {
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<Option<RedirectLink>> {
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<Option<RedirectLink>> {
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<Vec<RedirectLink>> {
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::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
})
}
pub fn remove(config: &Config, id: &str) -> Result<bool> {
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<Option<RedirectLink>> {
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<Option<RedirectLink>> {
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<RedirectLink> {
let id: String = row.get(0)?;
let url: String = row.get(1)?;
let created_at: String = row.get(2)?;
let last_used_at: Option<String> = 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<Utc>> {
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<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
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<String> = 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());
}
}
+25
View File
@@ -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<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
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<RewriteReplacement>,
}