Reach catbox.moe over the transport that works

Sending a file to IRC uploads it and sends the link, and that upload was
failing for every file - image or video alike, since the file type was
never what it turned on.

catbox.moe's HTTP/1.1 path is broken. It answers a completed upload with
a chunked 500 and then drops the TLS connection without a close_notify,
which rustls correctly reports as a truncated stream - so the reply was
never read at all and the link, which the server had already sent, was
lost with it. The same request over HTTP/2 gets a clean 200. reqwest was
built without its http2 feature and so could only speak 1.1; curl
negotiates h2, which is why doing it by hand always looked fine.

Enabling that feature is compile-time and would have let every reqwest
client here negotiate h2 as a side effect, so Discord's and Matrix's are
pinned to 1.1 - they work as they stand and changing their transport is
not part of fixing an upload.

Errors on this path now carry their cause chain. "uploading to catbox"
was the entire report of a failure that turned out to be four layers
down, and finding it meant rebuilding the daemon to ask.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-28 23:16:24 -04:00
co-authored by Claude Opus 5
parent e90d0edd4d
commit 030707ff62
5 changed files with 76 additions and 4 deletions
Generated
+21
View File
@@ -2154,6 +2154,25 @@ dependencies = [
"xxhash-rust",
]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -2478,6 +2497,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -5010,6 +5030,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
+1 -1
View File
@@ -28,7 +28,7 @@ futures = "0.3"
# Discord backend (QR remote-auth login + gateway client)
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart", "http2"] }
# Pulled in transitively by both of the above, but with conflicting default
# crypto-provider features (ring vs aws-lc-rs) - direct so main.rs can pin
# one explicitly at startup (see rustls::crypto::ring::default_provider()
+6
View File
@@ -60,6 +60,12 @@ fn http_client() -> &'static reqwest::Client {
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.user_agent(USER_AGENT)
// Pinned to HTTP/1.1. Enabling reqwest's http2 feature (which the
// file-upload path needs - see upload::http_client) would otherwise
// let every client here negotiate h2 as a side effect, changing the
// transport under a backend that works and is tested as it stands.
// Nothing here wants h2; if it ever does, that is its own change.
.http1_only()
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
+8 -1
View File
@@ -9,7 +9,14 @@ use serde_json::Value;
pub fn http_client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
CLIENT.get_or_init(|| {
// Pinned to HTTP/1.1. Enabling reqwest's http2 feature (which the
// file-upload path needs - see upload::http_client) would otherwise
// let every client here negotiate h2 as a side effect, changing the
// transport under a backend that works and is tested as it stands.
// Nothing here wants h2; if it ever does, that is its own change.
reqwest::Client::builder().http1_only().build().unwrap_or_else(|_| reqwest::Client::new())
})
}
/// The Matrix C-S API's standard error body shape (`{"errcode": "M_...",
+40 -2
View File
@@ -117,6 +117,25 @@ pub async fn upload(host: Host, path: &str, retention: Option<&str>) -> Result<S
}
}
/// The client these hosts are talked to over.
///
/// HTTP/2 deliberately, and it is not a preference: catbox.moe's HTTP/1.1
/// path is broken. Over 1.1 it answers a completed upload with a chunked 500
/// and then drops the TLS connection without a close_notify, which rustls
/// correctly reports as a truncated stream - so the reply is never read at
/// all and every attachment sent to IRC failed. The same request over HTTP/2
/// gets a clean 200 with the link in it. Negotiated by ALPN, so a host that
/// only speaks 1.1 still works.
fn http_client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"), " (nobilis)"))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
/// catbox.moe and its temporary sibling share one API: a multipart form with a
/// `reqtype`, and the finished URL as the whole of the response body.
async fn catbox_family(host: Host, file_name: String, bytes: Vec<u8>, retention: Option<&str>) -> Result<String> {
@@ -130,12 +149,17 @@ async fn catbox_family(host: Host, file_name: String, bytes: Vec<u8>, retention:
form = form.text("time", retention.unwrap_or("72h").to_string());
}
let resp = reqwest::Client::new()
let resp = http_client()
.post(url)
.multipart(form)
.send()
.await
.with_context(|| format!("uploading to {}", host.id()))?;
// The cause, not just the intent: "uploading to catbox" on its own
// leaves somebody staring at a failure that could equally be a dead
// network, a proxy, or the host being down. reqwest keeps the actual
// reason in the error's source chain rather than its Display, so
// without walking it every transport failure reads identically.
.map_err(|e| anyhow!("couldn't reach {}: {}", host.id(), with_causes(&e)))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
link_from_response(host, status, &body)
@@ -164,6 +188,20 @@ fn link_from_response(host: Host, status: reqwest::StatusCode, body: &str) -> Re
bail!("{} did not return a link: {}", host.id(), snippet(link));
}
/// An error together with everything underneath it.
///
/// The interesting half of a failed request - the DNS answer, the refused
/// connection, the certificate - is never in the top error's own message.
fn with_causes(err: &dyn std::error::Error) -> String {
let mut out = err.to_string();
let mut cause = err.source();
while let Some(c) = cause {
out.push_str(&format!(": {c}"));
cause = c.source();
}
out
}
/// The first part of a reply, for an error message.
///
/// Cut on a character boundary rather than a byte one: these are error pages