From 935314f7a0dae3b4ddee116a78a8a22168521316 Mon Sep 17 00:00:00 2001 From: GloriousEggroll Date: Sat, 3 Jan 2026 03:27:52 -0500 Subject: [PATCH] provide shortened url if a shortener is used on the host and --shorten is specified This takes advantage of if the host has shlink or yourl configured via it's local proxy (shortenviayourls or shortenviashlink) and provides the shortened url if found. If --shorten is applied and there is no proxy it will fall back to the default url. Likewise if a config file has --shorten and you want to retrieve the full url --no-shorten can be used. --- Cargo.toml | 1 + src/main.rs | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/opts.rs | 7 ++++ 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a8a3415..2198b98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,3 +50,4 @@ openssl = { version = "0.10", features = ["vendored"] } directories = "5.0.1" log = "0.4.22" scraper = "0.21.0" +ureq = "2" diff --git a/src/main.rs b/src/main.rs index 640fc6c..bac8f22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,14 @@ use clap::Parser; use data_url::DataUrl; +use url::Url; +use reqwest::blocking::Client; use pbcli::api::API; use pbcli::error::{PasteError, PbResult}; use pbcli::opts::Opts; use pbcli::privatebin::{DecryptedComment, DecryptedCommentsMap, DecryptedPaste}; use pbcli::util::check_filesize; use serde_json::Value; +use std::time::Duration; use std::ffi::OsString; use std::io::IsTerminal; use std::io::{Read, Write}; @@ -99,6 +102,86 @@ fn handle_get(opts: &Opts) -> PbResult<()> { Ok(()) } +fn shorten_via_privatebin(opts: &Opts, long_url: &str) -> PbResult { + fn try_method(opts: &Opts, long_url: &str, method: &str) -> PbResult { + let encoded = url::form_urlencoded::byte_serialize(long_url.as_bytes()).collect::(); + + // Always shorten on the same host as --host + let mut endpoint = opts.get_url().clone(); + endpoint.set_fragment(None); + endpoint.set_path("/"); + endpoint.set_query(Some(&format!("{method}&link={encoded}"))); + + let client = Client::builder().timeout(Duration::from_secs(5)).build()?; + let resp_text = client + .get(endpoint.clone()) + .send()? + .error_for_status()? + .text()?; + + let text = resp_text.trim(); + + log::debug!("shortener ({}) GET: {}", method, endpoint); + log::debug!("shortener ({}) raw response: {}", method, text); + + // JSON first (some proxies return JSON) + if let Ok(v) = serde_json::from_str::(text) { + if let Some(s) = v.get("shorturl").and_then(|x| x.as_str()) { + return Ok(s.to_string()); + } + if let Some(s) = v.get("url").and_then(|x| x.as_str()) { + return Ok(s.to_string()); + } + } + + // HTML: accept only + if let Some(id_pos) = text.find("id=\"pasteurl\"") { + let after_id = &text[id_pos..]; + + if let Some(href_pos) = after_id.find("href=\"") { + let after_href = &after_id[href_pos + "href=\"".len()..]; + if let Some(end) = after_href.find('"') { + let url = &after_href[..end]; + if url.starts_with("https://") || url.starts_with("http://") { + return Ok(url.to_string()); + } + } + } + + if let Some(href_pos) = after_id.find("href='") { + let after_href = &after_id[href_pos + "href='".len()..]; + if let Some(end) = after_href.find('\'') { + let url = &after_href[..end]; + if url.starts_with("https://") || url.starts_with("http://") { + return Ok(url.to_string()); + } + } + } + } + + Err(PasteError::InvalidData) + } + + // 1) try YOURLS proxy first + match try_method(opts, long_url, "shortenviayourls") { + Ok(u) => Ok(u), + Err(e1) => { + log::debug!("YOURLS shorten failed ({e1:?}), trying shlink…"); + + // 2) fall back to shlink proxy + match try_method(opts, long_url, "shortenviashlink") { + Ok(u) => Ok(u), + Err(e2) => { + // preserve useful debug, but return a single error + log::debug!("Shlink shorten also failed ({e2:?})"); + Err(PasteError::InvalidData) + } + } + } + } +} + + fn handle_post(opts: &Opts) -> PbResult<()> { let url = opts.get_url(); let stdin = get_stdin()?; @@ -135,14 +218,39 @@ fn handle_post(opts: &Opts) -> PbResult<()> { } let res = api.post_paste(&paste, password, opts)?; + let long_url = res.to_paste_url().to_string(); + + let should_shorten = opts.shorten && !opts.no_shorten; + + let short_url = if should_shorten { + match shorten_via_privatebin(opts, &long_url) { + Ok(s) => Some(s), + Err(e) => { + log::debug!("shorten failed, falling back to long URL: {e:?}"); + + // User-facing notice (stderr so stdout remains just the URL) + eprintln!( + "--shorten specified but no URL shortener was found configured on host, falling back to host-provided URL." + ); + + None + } + } + } else { + None + }; if opts.json { let mut output: Value = serde_json::to_value(res.clone())?; - output["pasteurl"] = Value::String(res.to_paste_url().to_string()); + output["pasteurl"] = Value::String(long_url.clone()); output["deleteurl"] = Value::String(res.to_delete_url().to_string()); + if let Some(s) = &short_url { + output["shorturl"] = Value::String(s.clone()); + } std::io::stdout().write_all(serde_json::to_string_pretty(&output)?.as_bytes())?; } else { - std::io::stdout().write_all(res.to_paste_url().as_str().as_bytes())?; + let to_print = short_url.as_deref().unwrap_or(&long_url); + std::io::stdout().write_all(to_print.as_bytes())?; writeln!(std::io::stdout())?; } diff --git a/src/opts.rs b/src/opts.rs index 77ada4b..7a46a98 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -59,6 +59,13 @@ pub struct Opts { #[clap(help("disable burn if set"))] pub no_discussion: bool, + #[cfg_attr(feature = "uniffi", uniffi(default = false))] + #[clap(long, help("Shorten the resulting paste URL via PrivateBin's YOURLS proxy"))] + pub shorten: bool, + + #[clap(long, overrides_with = "shorten")] + pub no_shorten: bool, + #[cfg_attr(feature = "uniffi", uniffi(default = false))] #[clap(long, requires("url"))] #[clap(help("make new comment on existing paste"))]