mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-28 00:02:12 +00:00
fix: bound site JSON response bodies
This commit is contained in:
@@ -174,10 +174,7 @@ async fn cookie() -> Option<String> {
|
|||||||
/// requests go out without a cookie.
|
/// requests go out without a cookie.
|
||||||
async fn fetch_buvid() -> Result<Option<String>, FetchError> {
|
async fn fetch_buvid() -> Result<Option<String>, FetchError> {
|
||||||
let response = crate::site::CLIENT.get(SPI_URL).send().await?;
|
let response = crate::site::CLIENT.get(SPI_URL).send().await?;
|
||||||
let fingerprint: model::Fingerprint = response.json().await.map_err(|e| FetchError::Site {
|
let fingerprint: model::Fingerprint = crate::site::response_json(response, "bilibili").await?;
|
||||||
site: "bilibili",
|
|
||||||
error: Box::new(e),
|
|
||||||
})?;
|
|
||||||
Ok(buvid_cookie(&fingerprint))
|
Ok(buvid_cookie(&fingerprint))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,17 +210,10 @@ pub async fn fetch(dynamic_id: &str) -> Result<model::Item, FetchError> {
|
|||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(match status.as_u16() {
|
return Err(match status.as_u16() {
|
||||||
412 => risk_control("412"),
|
412 => risk_control("412"),
|
||||||
// Everything else shares the central classes (refusals and gone
|
|
||||||
// posts permanent, 429/5xx retried). The local fallback used to
|
|
||||||
// disagree: a bilibili 404 came back Transient here. 412 above is
|
|
||||||
// bilibili's risk control, which does clear on its own.
|
|
||||||
_ => crate::site::status_error("bilibili", &response),
|
_ => crate::site::status_error("bilibili", &response),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let detail: model::Detail = response.json().await.map_err(|e| FetchError::Site {
|
let detail: model::Detail = crate::site::response_json(response, "bilibili").await?;
|
||||||
site: "bilibili",
|
|
||||||
error: Box::new(e),
|
|
||||||
})?;
|
|
||||||
if let Some(err) = code_error(detail.code, detail.message.as_deref().unwrap_or_default()) {
|
if let Some(err) = code_error(detail.code, detail.message.as_deref().unwrap_or_default()) {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ pub async fn fetch(handle: &str, rkey: &str) -> Result<Post, FetchError> {
|
|||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(crate::site::status_error("bsky", &response));
|
return Err(crate::site::status_error("bsky", &response));
|
||||||
}
|
}
|
||||||
let text = response.text().await?;
|
let text = crate::site::response_text(response, "bsky").await?;
|
||||||
Post::from_json(&text, rkey.to_string())
|
Post::from_json(&text, rkey.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,32 @@ async fn next_chunk(response: &mut reqwest::Response) -> Result<Option<bytes::By
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a successful API response body with a hard byte cap.
|
||||||
|
pub(crate) async fn send_json_response(
|
||||||
|
mut response: reqwest::Response,
|
||||||
|
site: &'static str,
|
||||||
|
) -> Result<bytes::Bytes, FetchError> {
|
||||||
|
if let Some(len) = response.content_length()
|
||||||
|
&& len > crate::site::MAX_SITE_JSON_BYTES as u64
|
||||||
|
{
|
||||||
|
return Err(FetchError::Site {
|
||||||
|
site,
|
||||||
|
error: "site response exceeds JSON size cap".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut body = Vec::new();
|
||||||
|
while let Some(chunk) = next_chunk(&mut response).await? {
|
||||||
|
if body.len().saturating_add(chunk.len()) > crate::site::MAX_SITE_JSON_BYTES {
|
||||||
|
return Err(FetchError::Site {
|
||||||
|
site,
|
||||||
|
error: "site response exceeds JSON size cap".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(bytes::Bytes::from(body))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether an address must never be fetched. Media URLs come from a site's own
|
/// Whether an address must never be fetched. Media URLs come from a site's own
|
||||||
/// API response and the bytes are uploaded to Telegram, so following one into
|
/// API response and the bytes are uploaded to Telegram, so following one into
|
||||||
/// the host's own network would turn the bot into a proxy for it: a cloud
|
/// the host's own network would turn the bot into a proxy for it: a cloud
|
||||||
|
|||||||
@@ -69,16 +69,13 @@ pub async fn fetch(note_id: &str) -> Result<model::Note, FetchError> {
|
|||||||
_ => crate::site::status_error("misskey", &response),
|
_ => crate::site::status_error("misskey", &response),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
response.json().await.map_err(|e| FetchError::Site {
|
crate::site::response_json(response, "misskey").await
|
||||||
site: "misskey",
|
|
||||||
error: Box::new(e),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a 400 response: NO_SUCH_NOTE is permanent NotFound, any other 400 is
|
/// Maps a 400 response: NO_SUCH_NOTE is permanent NotFound, any other 400 is
|
||||||
/// a site error (permanent — retrying a rejected request cannot succeed).
|
/// a site error (permanent — retrying a rejected request cannot succeed).
|
||||||
async fn not_found_or_invalid(response: reqwest::Response) -> FetchError {
|
async fn not_found_or_invalid(response: reqwest::Response) -> FetchError {
|
||||||
match response.json::<serde_json::Value>().await {
|
match crate::site::response_json::<serde_json::Value>(response, "misskey").await {
|
||||||
Ok(v) if v["error"]["code"] == "NO_SUCH_NOTE" => FetchError::NotFound,
|
Ok(v) if v["error"]["code"] == "NO_SUCH_NOTE" => FetchError::NotFound,
|
||||||
_ => FetchError::Site {
|
_ => FetchError::Site {
|
||||||
site: "misskey",
|
site: "misskey",
|
||||||
|
|||||||
@@ -224,6 +224,36 @@ pub fn caption_from_fields(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum decoded JSON response accepted from a site API. Metadata is
|
||||||
|
/// expected to be much smaller; this keeps a compromised or malformed API
|
||||||
|
/// from growing an unbounded `String` before serde gets a chance to reject it.
|
||||||
|
pub(crate) const MAX_SITE_JSON_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Reads a successful site response as a bounded UTF-8 JSON value.
|
||||||
|
pub(crate) async fn response_json<T: serde::de::DeserializeOwned>(
|
||||||
|
response: reqwest::Response,
|
||||||
|
site: &'static str,
|
||||||
|
) -> Result<T, FetchError> {
|
||||||
|
let body = crate::site::download::send_json_response(response, site).await?;
|
||||||
|
serde_json::from_slice(&body).map_err(|e| FetchError::Site {
|
||||||
|
site,
|
||||||
|
error: Box::new(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same bounded response reader for endpoints that need a text body before
|
||||||
|
/// classification or parsing.
|
||||||
|
pub(crate) async fn response_text(
|
||||||
|
response: reqwest::Response,
|
||||||
|
site: &'static str,
|
||||||
|
) -> Result<String, FetchError> {
|
||||||
|
let body = crate::site::download::send_json_response(response, site).await?;
|
||||||
|
String::from_utf8(body.to_vec()).map_err(|e| FetchError::Site {
|
||||||
|
site,
|
||||||
|
error: Box::new(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable per-post cache key derived from any supported URL, so variant
|
/// Stable per-post cache key derived from any supported URL, so variant
|
||||||
/// domains (x.com / twitter.com / fxtwitter.com, mobile, `/photo/N`
|
/// domains (x.com / twitter.com / fxtwitter.com, mobile, `/photo/N`
|
||||||
/// suffixes) map to the same post. Delegates to each registered site's
|
/// suffixes) map to the same post. Delegates to each registered site's
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ pub enum PixivError {
|
|||||||
Transient(String),
|
Transient(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_response_error(error: FetchError) -> PixivError {
|
||||||
|
match error {
|
||||||
|
FetchError::Http(e) => PixivError::Http(e),
|
||||||
|
FetchError::Transient(message) => PixivError::Transient(message),
|
||||||
|
FetchError::RateLimited { .. } => PixivError::Transient("rate limited".into()),
|
||||||
|
other => PixivError::Api(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Native pixiv app-API client.
|
/// Native pixiv app-API client.
|
||||||
pub struct PixivAPI {
|
pub struct PixivAPI {
|
||||||
refresh_token: String,
|
refresh_token: String,
|
||||||
@@ -94,14 +103,12 @@ impl PixivAPI {
|
|||||||
.header("User-Agent", AUTH_USER_AGENT)
|
.header("User-Agent", AUTH_USER_AGENT)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
// Check the status *before* reading the body: a 429/5xx from the
|
|
||||||
// token endpoint is worth retrying (the class comes from
|
|
||||||
// `is_retryable`), while parsing a maintenance page as JSON turned it
|
|
||||||
// into a permanent `Api`/`Json` error with no retry at all.
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(PixivError::Status(response.status().as_u16()));
|
return Err(PixivError::Status(response.status().as_u16()));
|
||||||
}
|
}
|
||||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
let json: serde_json::Value = crate::site::response_json(response, "pixiv")
|
||||||
|
.await
|
||||||
|
.map_err(map_response_error)?;
|
||||||
let access_token = json
|
let access_token = json
|
||||||
.get("access_token")
|
.get("access_token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -140,7 +147,9 @@ impl PixivAPI {
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(PixivError::Status(response.status().as_u16()));
|
return Err(PixivError::Status(response.status().as_u16()));
|
||||||
}
|
}
|
||||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
let json: serde_json::Value = crate::site::response_json(response, "pixiv")
|
||||||
|
.await
|
||||||
|
.map_err(map_response_error)?;
|
||||||
if json.get("error").is_some() {
|
if json.get("error").is_some() {
|
||||||
let message = json
|
let message = json
|
||||||
.get("message")
|
.get("message")
|
||||||
@@ -198,7 +207,9 @@ impl PixivAPI {
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(PixivError::Status(response.status().as_u16()));
|
return Err(PixivError::Status(response.status().as_u16()));
|
||||||
}
|
}
|
||||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
let json: serde_json::Value = crate::site::response_json(response, "pixiv")
|
||||||
|
.await
|
||||||
|
.map_err(map_response_error)?;
|
||||||
if json.get("error").is_some() {
|
if json.get("error").is_some() {
|
||||||
let message = json
|
let message = json
|
||||||
.get("message")
|
.get("message")
|
||||||
@@ -484,6 +495,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_read_transport_errors_stay_retryable() {
|
||||||
|
let mapped = map_response_error(FetchError::Transient("reset".into()));
|
||||||
|
assert!(matches!(mapped, PixivError::Transient(_)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ugoira_budget_rejects_too_many_frames() {
|
fn ugoira_budget_rejects_too_many_frames() {
|
||||||
assert!(check_ugoira_archive_size(MAX_UGOIRA_FRAMES, 0).is_ok());
|
assert!(check_ugoira_archive_size(MAX_UGOIRA_FRAMES, 0).is_ok());
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
|||||||
log::warn!("twitter auth fetch {id}: HTTP {status}");
|
log::warn!("twitter auth fetch {id}: HTTP {status}");
|
||||||
return Err(crate::site::status_error("twitter auth", &response));
|
return Err(crate::site::status_error("twitter auth", &response));
|
||||||
}
|
}
|
||||||
let text = response.text().await?;
|
let text = crate::site::response_text(response, "twitter auth").await?;
|
||||||
let json: Value = serde_json::from_str(&text)?;
|
let json: Value = serde_json::from_str(&text)?;
|
||||||
let result = parse_tweet_result(&json, id)?;
|
let result = parse_tweet_result(&json, id)?;
|
||||||
let syndication_shape = to_syndication_shape(&result).ok_or_else(|| {
|
let syndication_shape = to_syndication_shape(&result).ok_or_else(|| {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
|||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(crate::site::status_error("twitter", &response));
|
return Err(crate::site::status_error("twitter", &response));
|
||||||
}
|
}
|
||||||
let text = response.text().await?;
|
let text = crate::site::response_text(response, "twitter").await?;
|
||||||
// Classify before building the tweet (see [`parse_syndication_body`]), and
|
// Classify before building the tweet (see [`parse_syndication_body`]), and
|
||||||
// build it from the value that classification already parsed: this used to
|
// build it from the value that classification already parsed: this used to
|
||||||
// scan and allocate the whole body twice.
|
// scan and allocate the whole body twice.
|
||||||
|
|||||||
Reference in New Issue
Block a user