mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
chore(core): remove zero-reference deps and redirect_links domain (#5052)
This commit is contained in:
@@ -575,12 +575,6 @@ fn build_registered_controllers() -> Vec<GroupedController> {
|
||||
DomainGroup::Memory,
|
||||
crate::openhuman::memory_diff::all_memory_diff_registered_controllers(),
|
||||
);
|
||||
// Link shortener for long tracking URLs — saves LLM tokens
|
||||
push(
|
||||
&mut controllers,
|
||||
DomainGroup::Platform,
|
||||
crate::openhuman::redirect_links::all_redirect_links_registered_controllers(),
|
||||
);
|
||||
// Referral and growth tracking
|
||||
push(
|
||||
&mut controllers,
|
||||
@@ -951,9 +945,6 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
|
||||
"memory_diff" => Some(
|
||||
"Snapshot-based change tracking for memory sources — capture state, compute diffs, and surface changes to agents.",
|
||||
),
|
||||
"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."),
|
||||
"run_ledger" => Some(
|
||||
"Durable agent and workflow run state, child lineage, events, telemetry, and checkpoint references.",
|
||||
|
||||
@@ -119,7 +119,6 @@ fn registered_controller_rpc_method_name() {
|
||||
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());
|
||||
|
||||
@@ -100,7 +100,6 @@ pub mod profiles;
|
||||
pub mod prompt_injection;
|
||||
pub mod provider_surfaces;
|
||||
pub mod recall_calendar;
|
||||
pub mod redirect_links;
|
||||
pub mod referral;
|
||||
#[cfg(feature = "flows")]
|
||||
pub mod rhai_workflows;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# redirect_links
|
||||
|
||||
Redirect-link shortener for token-heavy URLs. Long tracking URLs (e.g. `trip.com/forward/...?bizData=...`) burn model tokens every time they pass through a prompt. This domain encodes them to a short `openhuman://link/<id>` placeholder on inbound text, keeps the full URL in a local SQLite store, and expands the placeholder back to the original URL on outbound messages so the user never sees the placeholder. It also has a separate helper for tagging public `openhm.xyz` short links with a `?u=<user_id>` attribution param on the way out.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- **Shorten** a long URL to a content-addressed `openhuman://link/<id>` form and persist it (idempotent / deterministic by URL).
|
||||
- **Expand** a short id back to its full URL, bumping a hit counter + `last_used_at`.
|
||||
- **Inbound rewrite**: replace every long URL (≥ `min_len`, default 80) in a text blob with its placeholder, preserving surrounding prose and trailing sentence punctuation.
|
||||
- **Outbound rewrite**: replace every `openhuman://link/<id>` placeholder in text back to the stored URL; unknown ids are left untouched (nothing silently disappears).
|
||||
- **Public-link attribution**: append `?u=<user_id>` (URL-encoded, idempotent, fragment-safe) to `openhm.xyz/<id>` URLs, guarding against lookalike domains.
|
||||
- List and remove stored links; expose all of the above over JSON-RPC.
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/openhuman/redirect_links/mod.rs` | Export-focused: module docstring, `mod` decls, `pub use` re-exports of ops + schemas + types; aliases `ops as rpc`. |
|
||||
| `src/openhuman/redirect_links/types.rs` | Serde types: `RedirectLink`, `RewriteReplacement`, `RewriteResult`. |
|
||||
| `src/openhuman/redirect_links/ops.rs` | Business logic: URL/short-URL/public-URL regexes, inbound/outbound rewrite, `append_user_id_to_public_links`, and the `rl_*` RPC handlers returning `RpcOutcome`. Holds `DEFAULT_MIN_URL_LEN = 80`. |
|
||||
| `src/openhuman/redirect_links/store.rs` | SQLite persistence: `shorten`/`expand`/`peek`/`list`/`remove`, content-addressed id allocation (SHA-256 hex prefix), schema bootstrap, id<->short-URL helpers (`short_url_for`, `id_from_short`, `SHORT_URL_PREFIX`). |
|
||||
| `src/openhuman/redirect_links/schemas.rs` | Controller schemas (`all_controller_schemas`, `all_registered_controllers`, `schemas`) + `handle_*` fns delegating to `ops.rs`. |
|
||||
|
||||
## Public surface
|
||||
|
||||
Re-exported from `mod.rs`:
|
||||
|
||||
- Functions (via `pub use ops::…`): `shorten_url`, `expand_link`, `rewrite_inbound`, `rewrite_outbound`, `rewrite_outbound_for_user`, `append_user_id_to_public_links`.
|
||||
- `pub use ops as rpc` — exposes the `rl_*` handlers under `redirect_links::rpc`.
|
||||
- Schemas: `all_redirect_links_controller_schemas`, `all_redirect_links_registered_controllers`, `redirect_links_schemas`.
|
||||
- Types: `RedirectLink`, `RewriteReplacement`, `RewriteResult`.
|
||||
|
||||
Also defined (not re-exported through `mod.rs`): `ops::rewrite_inbound_with_threshold`, `ops::DEFAULT_MIN_URL_LEN`; `store::{shorten, expand, peek, list, remove, short_url_for, id_from_short, SHORT_URL_PREFIX}`.
|
||||
|
||||
## RPC / controllers
|
||||
|
||||
Namespace `redirect_links` (RPC methods `openhuman.redirect_links_<function>`):
|
||||
|
||||
| Function | Inputs | Output |
|
||||
| --- | --- | --- |
|
||||
| `shorten` | `url: String` | `link: RedirectLink` |
|
||||
| `expand` | `id: String` | `link: RedirectLink` (errors if not found) |
|
||||
| `list` | `limit?: u64` (default 50, max 1000) | `links: RedirectLink[]` (newest first) |
|
||||
| `remove` | `id: String` | `{ id, removed: bool }` |
|
||||
| `rewrite_inbound` | `text: String`, `min_len?: u64` (default 80) | `result: RewriteResult` |
|
||||
| `rewrite_outbound` | `text: String` | `result: RewriteResult` |
|
||||
|
||||
Handlers load config via `config::rpc::load_config_with_timeout()` and return `RpcOutcome<T>` serialized with `into_cli_compatible_json()`.
|
||||
|
||||
## Persistence
|
||||
|
||||
SQLite DB at `{config.workspace_dir}/redirect_links/links.db`, table `redirect_links`:
|
||||
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `id` | TEXT PRIMARY KEY — SHA-256(url) hex prefix, 8 chars default, grown by 2 up to 32 on prefix collision with a different URL. |
|
||||
| `url` | TEXT NOT NULL UNIQUE (indexed). |
|
||||
| `created_at` | RFC3339 TEXT. |
|
||||
| `last_used_at` | RFC3339 TEXT, nullable; set on each expand. |
|
||||
| `hit_count` | INTEGER, bumped on each expand. |
|
||||
|
||||
Insert is atomic (`ON CONFLICT DO NOTHING`) so concurrent shortens of the same URL converge on one id with no PRIMARY KEY / UNIQUE error (regression-tested). The connection is opened per call and the schema is created if missing.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `crate::openhuman::config::Config` — supplies `workspace_dir` for the DB path; handlers call `config::rpc::load_config_with_timeout`.
|
||||
- `crate::core::all` (`ControllerFuture`, `RegisteredController`) and `crate::core::{ControllerSchema, FieldSchema, TypeSchema}` — controller registry wiring.
|
||||
- `crate::rpc::RpcOutcome` — RPC return envelope.
|
||||
- External crates: `rusqlite` (SQLite), `sha2` + `hex` (content-addressed ids), `regex` (URL matching), `chrono` (timestamps), `urlencoding` (user-id encoding), `serde`/`serde_json`, `anyhow`.
|
||||
|
||||
## Used by
|
||||
|
||||
- `src/core/all.rs` registers the controllers + schemas and maps the `"redirect_links"` namespace description; `src/openhuman/mod.rs` declares the module. No other in-tree Rust callers of the rewrite/shorten functions were found — the rewrite pipeline is currently reachable via RPC rather than wired into an inbound/outbound message path inside the core.
|
||||
|
||||
## Notes / gotchas
|
||||
|
||||
- **Ids are content-addressed, not random**: same URL → same id (deterministic, deduped). Removing a link and re-shortening the same URL yields the same id again.
|
||||
- **Length threshold guards token waste**: the placeholder is ~24 bytes, so URLs below `DEFAULT_MIN_URL_LEN` (80) are left untouched by inbound rewrite.
|
||||
- **Trailing punctuation handling**: inbound rewrite and public-link tagging strip trailing `. , ; : !` so prose with a URL followed by a period doesn't capture the period into the stored/tagged URL.
|
||||
- **`append_user_id_to_public_links` is anchored to `openhm.xyz`** specifically and rejects lookalikes (`evil-openhm.xyz`, `openhm.xyz.evil.com`); it splits off `#fragment` so `?u=` always lands in the query, and is idempotent against existing `?u=`/`&u=`.
|
||||
- **`id_from_short`** accepts both `openhuman://link/<id>` and a bare hex `<id>`, lowercasing the result; non-hex input returns `None`.
|
||||
- **No agent tools, no event-bus subscribers, no `bus.rs`/`tools.rs`** — this domain is store + ops + RPC only.
|
||||
@@ -1,24 +0,0 @@
|
||||
//! 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::{
|
||||
append_user_id_to_public_links, expand_link, rewrite_inbound, rewrite_outbound,
|
||||
rewrite_outbound_for_user, 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};
|
||||
@@ -1,464 +0,0 @@
|
||||
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())
|
||||
}
|
||||
|
||||
fn public_url_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
// Anchor on `https?://` and match the `openhm.xyz` domain specifically to
|
||||
// avoid lookalikes (evil-openhm.xyz) or mid-token matches. Capture optional
|
||||
// query and fragment as separate tail parts so callers can safely insert
|
||||
// `?u=` into the query without polluting the fragment.
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(
|
||||
r#"https?://openhm\.xyz/[A-Za-z0-9_-]+(?:\?[\w\d./\?=%\-&:+@~!,;]*)?(?:#[\w\d./\?=%\-&:+@~!,;]*)?"#,
|
||||
)
|
||||
.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(['.', ',', ';', ':', '!'])
|
||||
}
|
||||
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience wrapper that runs `rewrite_outbound` and then appends the
|
||||
/// `user_id` to any public `openhm.xyz` links in the result.
|
||||
pub fn rewrite_outbound_for_user(
|
||||
config: &Config,
|
||||
text: &str,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<RewriteResult> {
|
||||
let mut result = rewrite_outbound(config, text)?;
|
||||
result.text = append_user_id_to_public_links(&result.text, user_id);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Append `?u=<user_id>` to every `openhm.xyz/<id>` URL in a string.
|
||||
/// If `user_id` is `None`, the text is returned unchanged.
|
||||
/// Idempotent: URLs already containing a `u=` query parameter are left alone.
|
||||
pub fn append_user_id_to_public_links(text: &str, user_id: Option<&str>) -> String {
|
||||
let Some(user_id) = user_id else {
|
||||
return text.to_string();
|
||||
};
|
||||
|
||||
let re = public_url_regex();
|
||||
let encoded_user_id = urlencoding::encode(user_id);
|
||||
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()..];
|
||||
|
||||
// Split off any fragment (#…) so `?u=` lands in the query, not the fragment.
|
||||
let (base, fragment) = match url.split_once('#') {
|
||||
Some((b, f)) => (b, Some(f)),
|
||||
None => (url, None),
|
||||
};
|
||||
|
||||
if !base.contains("?u=") && !base.contains("&u=") {
|
||||
let separator = if base.contains('?') { "&" } else { "?" };
|
||||
out.push_str(base);
|
||||
out.push_str(separator);
|
||||
out.push_str("u=");
|
||||
out.push_str(&encoded_user_id);
|
||||
} else {
|
||||
out.push_str(base);
|
||||
}
|
||||
if let Some(frag) = fragment {
|
||||
out.push('#');
|
||||
out.push_str(frag);
|
||||
}
|
||||
out.push_str(trailing);
|
||||
cursor = m.end();
|
||||
}
|
||||
out.push_str(&text[cursor..]);
|
||||
out
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_bare() {
|
||||
let text = "https://openhm.xyz/abc";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, "https://openhm.xyz/abc?u=nikhil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_query() {
|
||||
let text = "https://openhm.xyz/abc?foo=bar";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, "https://openhm.xyz/abc?foo=bar&u=nikhil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_idempotent() {
|
||||
// Already ?u=
|
||||
let text = "https://openhm.xyz/abc?u=existing";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, text);
|
||||
|
||||
// Already &u=
|
||||
let text = "https://openhm.xyz/abc?foo=bar&u=existing";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_none() {
|
||||
let text = "https://openhm.xyz/abc";
|
||||
let got = append_user_id_to_public_links(text, None);
|
||||
assert_eq!(got, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_no_match() {
|
||||
let text = "https://example.com/abc";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_multiple() {
|
||||
let text = "https://openhm.xyz/a and https://openhm.xyz/b?x=y";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(
|
||||
got,
|
||||
"https://openhm.xyz/a?u=nikhil and https://openhm.xyz/b?x=y&u=nikhil"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_encoding() {
|
||||
let text = "https://openhm.xyz/abc";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil@example.com + space"));
|
||||
assert_eq!(
|
||||
got,
|
||||
"https://openhm.xyz/abc?u=nikhil%40example.com%20%2B%20space"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_lookalikes() {
|
||||
let text = "https://evil-openhm.xyz/abc and openhm.xyz.evil.com/abc";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_punctuation() {
|
||||
let text = "Click https://openhm.xyz/abc.";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, "Click https://openhm.xyz/abc?u=nikhil.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_query_with_fragment() {
|
||||
let text = "https://openhm.xyz/abc?foo=bar#frag";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, "https://openhm.xyz/abc?foo=bar&u=nikhil#frag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_user_id_to_public_links_bare_with_fragment() {
|
||||
let text = "https://openhm.xyz/abc#frag";
|
||||
let got = append_user_id_to_public_links(text, Some("nikhil"));
|
||||
assert_eq!(got, "https://openhm.xyz/abc?u=nikhil#frag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_outbound_for_user_expands_placeholder_and_tags_public_url() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = test_config(&tmp);
|
||||
|
||||
// Shorten LONG into an openhuman:// placeholder, then craft outbound text
|
||||
// that mixes the placeholder with a public openhm.xyz URL.
|
||||
let inbound = rewrite_inbound(&cfg, LONG).unwrap();
|
||||
let placeholder = &inbound.replacements[0].replacement;
|
||||
let text = format!("see {placeholder} and https://openhm.xyz/abc");
|
||||
|
||||
let result = rewrite_outbound_for_user(&cfg, &text, Some("nikhil")).unwrap();
|
||||
assert!(
|
||||
result.text.contains(LONG),
|
||||
"placeholder must expand back to LONG"
|
||||
);
|
||||
assert!(
|
||||
result.text.contains("https://openhm.xyz/abc?u=nikhil"),
|
||||
"openhm.xyz URL must carry ?u= tag"
|
||||
);
|
||||
|
||||
// None user_id leaves openhm.xyz untouched but still expands placeholder.
|
||||
let result_none = rewrite_outbound_for_user(&cfg, &text, None).unwrap();
|
||||
assert!(result_none.text.contains(LONG));
|
||||
assert!(result_none.text.contains("https://openhm.xyz/abc"));
|
||||
assert!(!result_none.text.contains("?u="));
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
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>(¶ms, "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>(¶ms, "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(¶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<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let id = read_required::<String>(¶ms, "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>(¶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<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let text = read_required::<String>(¶ms, "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);
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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>,
|
||||
}
|
||||
@@ -184,6 +184,10 @@ pub(super) const WORKSPACE_INTERNAL_DIRS: &[&str] = &[
|
||||
"vault",
|
||||
"task_sources",
|
||||
"whatsapp_data",
|
||||
// The redirect_links domain was removed (#5051), but an upgraded profile can
|
||||
// still hold a legacy `redirect_links/links.db` (stored URL history) written
|
||||
// by an older version. Keep the directory on the internal denylist so agents
|
||||
// with workspace access cannot read or overwrite that leftover state.
|
||||
"redirect_links",
|
||||
"codegraph",
|
||||
".openhuman",
|
||||
|
||||
Reference in New Issue
Block a user