cp_cli_platform_codechef/
client.rs1use std::{collections::HashMap, time::Duration};
2
3use reqwest::{Client as HttpClient, ClientBuilder, redirect::Policy};
4use serde::Deserialize;
5
6use crate::{Discussion, DiscussionList, DiscussionSummary, Error, UserStats};
7
8pub(crate) const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
9const MAX_TOPICS: usize = 100;
10const MAX_RENDERED_TOPICS: usize = 20;
11const MAX_POSTS: usize = 200;
12const MAX_TITLE_BYTES: usize = 256;
13const MAX_SLUG_BYTES: usize = 256;
14const MAX_NAME_BYTES: usize = 128;
15const MAX_TIMESTAMP_BYTES: usize = 64;
16const MAX_BODY_BYTES: usize = 768 * 1024;
17
18pub struct Client {
19 pub(crate) http: HttpClient,
20 pub(crate) endpoint: Box<str>,
21 pub(crate) profile_endpoint: Box<str>,
22}
23
24impl Client {
25 pub fn new() -> Result<Self, Error> {
26 Ok(Self {
27 http: http_builder().build()?,
28 endpoint: "https://discuss.codechef.com/".into(),
29 profile_endpoint: "https://www.codechef.com/".into(),
30 })
31 }
32
33 pub async fn latest(
35 &self,
36 progress: impl FnMut(usize, Option<u64>),
37 ) -> Result<DiscussionList, Error> {
38 let response: LatestResponse = self.get_json("latest.json", progress).await?;
39 if response.topic_list.topics.len() > MAX_TOPICS || response.users.len() > MAX_TOPICS {
40 return Err(Error::InvalidResponse);
41 }
42 let users = response
43 .users
44 .into_iter()
45 .filter_map(valid_user)
46 .collect::<HashMap<_, _>>();
47 let discussions = response
48 .topic_list
49 .topics
50 .into_iter()
51 .take(MAX_RENDERED_TOPICS)
52 .map(|topic| topic.into_summary(&users))
53 .collect::<Result<Vec<_>, _>>()?;
54 Ok(DiscussionList { discussions })
55 }
56
57 pub async fn discussion(
59 &self,
60 id: u32,
61 progress: impl FnMut(usize, Option<u64>),
62 ) -> Result<Discussion, Error> {
63 if id == 0 {
64 return Err(Error::InvalidTopicId);
65 }
66 let response: TopicResponse = self.get_json(&format!("t/{id}.json"), progress).await?;
67 if response.id != id
68 || response.post_stream.posts.is_empty()
69 || response.post_stream.posts.len() > MAX_POSTS
70 {
71 return Err(Error::InvalidResponse);
72 }
73 let post = response
74 .post_stream
75 .posts
76 .into_iter()
77 .find(|post| post.post_number == 1)
78 .ok_or(Error::InvalidResponse)?;
79 let body_html = valid_body(post.cooked).ok_or(Error::InvalidResponse)?;
80 let author = display_name(post.name, Some(post.username));
81 let summary = TopicData {
82 id: response.id,
83 title: response.title,
84 slug: response.slug,
85 created_at: response.created_at,
86 last_posted_at: response.last_posted_at,
87 views: response.views,
88 posts_count: response.posts_count,
89 like_count: response.like_count,
90 posters: Vec::new(),
91 }
92 .into_summary(&HashMap::new())?;
93 Ok(Discussion {
94 summary: DiscussionSummary { author, ..summary },
95 body_html,
96 })
97 }
98
99 pub async fn user_stats(
101 &self,
102 handle: &str,
103 progress: impl FnMut(usize, Option<u64>),
104 ) -> Result<UserStats, Error> {
105 if !valid_handle(handle) {
106 return Err(Error::InvalidHandle);
107 }
108 let url = self.profile_url(handle)?;
109 let response = self.http.get(url).send().await?;
110 if response.status() == reqwest::StatusCode::NOT_FOUND {
111 return Err(Error::NotFound);
112 }
113 if !response.status().is_success() {
114 return Err(Error::Status(response.status()));
115 }
116 profile_from_html(handle, read_body(response, progress).await?)
117 }
118
119 async fn get_json<T: for<'de> Deserialize<'de>>(
120 &self,
121 path: &str,
122 progress: impl FnMut(usize, Option<u64>),
123 ) -> Result<T, Error> {
124 let url = self.endpoint_path(path)?;
125 let response = self.http.get(url).send().await?;
126 if response.status() == reqwest::StatusCode::NOT_FOUND {
127 return Err(Error::NotFound);
128 }
129 if !response.status().is_success() {
130 return Err(Error::Status(response.status()));
131 }
132 Ok(serde_json::from_slice(
133 &read_body(response, progress).await?,
134 )?)
135 }
136
137 fn endpoint_path(&self, path: &str) -> Result<reqwest::Url, Error> {
138 endpoint_path(&self.endpoint, path)
139 }
140
141 pub(crate) fn profile_url(&self, handle: &str) -> Result<reqwest::Url, Error> {
142 if !valid_handle(handle) {
143 return Err(Error::InvalidHandle);
144 }
145 endpoint_path(&self.profile_endpoint, &format!("users/{handle}"))
146 }
147}
148
149fn endpoint_path(endpoint: &str, path: &str) -> Result<reqwest::Url, Error> {
150 let mut url = reqwest::Url::parse(endpoint).map_err(|_| Error::InvalidResponse)?;
151 if url.scheme() != "https" && url.scheme() != "http" {
152 return Err(Error::InvalidResponse);
153 }
154 url.set_path(path);
155 url.set_query(None);
156 Ok(url)
157}
158
159pub(crate) fn http_builder() -> ClientBuilder {
160 HttpClient::builder()
161 .https_only(true)
162 .connect_timeout(Duration::from_secs(10))
163 .read_timeout(Duration::from_secs(20))
164 .timeout(Duration::from_secs(30))
165 .redirect(Policy::none())
166 .retry(reqwest::retry::never())
167 .no_gzip()
168 .no_brotli()
169 .no_deflate()
170 .no_zstd()
171 .pool_max_idle_per_host(2)
172 .user_agent(concat!(
173 env!("CARGO_PKG_NAME"),
174 "/",
175 env!("CARGO_PKG_VERSION")
176 ))
177}
178
179async fn read_body(
180 mut response: reqwest::Response,
181 mut progress: impl FnMut(usize, Option<u64>),
182) -> Result<Vec<u8>, Error> {
183 if response
184 .content_length()
185 .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
186 {
187 return Err(Error::ResponseTooLarge {
188 limit: MAX_RESPONSE_BYTES,
189 });
190 }
191 let total = response.content_length();
192 let mut body = Vec::with_capacity(8 * 1024);
193 progress(0, total);
194 while let Some(chunk) = response.chunk().await? {
195 if chunk.len() > MAX_RESPONSE_BYTES - body.len() {
196 return Err(Error::ResponseTooLarge {
197 limit: MAX_RESPONSE_BYTES,
198 });
199 }
200 body.extend_from_slice(&chunk);
201 progress(body.len(), total);
202 }
203 Ok(body)
204}
205
206fn valid_handle(handle: &str) -> bool {
207 !handle.is_empty()
208 && handle.len() <= 64
209 && handle
210 .bytes()
211 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
212}
213
214fn profile_from_html(handle: &str, body: Vec<u8>) -> Result<UserStats, Error> {
215 let html = std::str::from_utf8(&body).map_err(|_| Error::InvalidProfile)?;
216 let name =
217 text_between(html, "<h1 class=\"h2-style\">", "</h1>").ok_or(Error::InvalidProfile)?;
218 let solved = number_after(html, "Total Problems Solved:").ok_or(Error::InvalidProfile)?;
219 let rating = text_between(html, "<div class=\"rating-number\">", "</div>")
220 .and_then(|value| number_in(&value));
221 let highest_rating =
222 text_between(html, "(Highest Rating", ")").and_then(|value| number_in(&value));
223 let contests = text_between(html, "No. of Contests Participated: <b>", "</b>")
224 .and_then(|value| number_in(&value));
225 Ok(UserStats {
226 handle: handle.into(),
227 name,
228 solved,
229 rating,
230 highest_rating,
231 contests,
232 url: format!("https://www.codechef.com/users/{handle}").into(),
233 })
234}
235
236fn text_between(html: &str, start: &str, end: &str) -> Option<Box<str>> {
237 let value = html.split_once(start)?.1.split_once(end)?.0;
238 let text = value.split('<').next()?.trim();
239 (!text.is_empty() && text.len() <= MAX_NAME_BYTES).then(|| text.into())
240}
241
242fn number_after(html: &str, marker: &str) -> Option<u32> {
243 number_in(html.split_once(marker)?.1)
244}
245
246fn number_in(value: &str) -> Option<u32> {
247 let digits: String = value
248 .trim_start()
249 .bytes()
250 .take_while(u8::is_ascii_digit)
251 .map(char::from)
252 .collect();
253 (!digits.is_empty()).then(|| digits.parse().ok()).flatten()
254}
255
256#[derive(Deserialize)]
257struct LatestResponse {
258 topic_list: TopicList,
259 #[serde(default)]
260 users: Vec<UserData>,
261}
262
263#[derive(Deserialize)]
264struct TopicList {
265 topics: Vec<TopicData>,
266}
267
268#[derive(Deserialize)]
269struct TopicResponse {
270 id: u32,
271 title: Box<str>,
272 slug: Box<str>,
273 created_at: Box<str>,
274 last_posted_at: Box<str>,
275 #[serde(default)]
276 views: Option<u32>,
277 posts_count: u32,
278 #[serde(default)]
279 like_count: u32,
280 post_stream: PostStream,
281}
282
283#[derive(Deserialize)]
284struct TopicData {
285 id: u32,
286 title: Box<str>,
287 slug: Box<str>,
288 created_at: Box<str>,
289 last_posted_at: Box<str>,
290 #[serde(default)]
291 views: Option<u32>,
292 posts_count: u32,
293 #[serde(default)]
294 like_count: u32,
295 #[serde(default)]
296 posters: Vec<Poster>,
297}
298
299#[derive(Deserialize)]
300struct Poster {
301 user_id: u32,
302}
303
304#[derive(Deserialize)]
305struct UserData {
306 id: u32,
307 #[serde(default)]
308 name: Option<Box<str>>,
309 #[serde(default)]
310 username: Option<Box<str>>,
311}
312
313#[derive(Deserialize)]
314struct PostStream {
315 posts: Vec<PostData>,
316}
317
318#[derive(Deserialize)]
319struct PostData {
320 post_number: u32,
321 cooked: Box<str>,
322 #[serde(default)]
323 name: Option<Box<str>>,
324 username: Box<str>,
325}
326
327impl TopicData {
328 fn into_summary(self, users: &HashMap<u32, Box<str>>) -> Result<DiscussionSummary, Error> {
329 if self.id == 0 || self.posts_count == 0 || self.posters.len() > MAX_POSTS {
330 return Err(Error::InvalidResponse);
331 }
332 let title = valid_text(self.title, MAX_TITLE_BYTES).ok_or(Error::InvalidResponse)?;
333 let slug = valid_slug(self.slug).ok_or(Error::InvalidResponse)?;
334 let created_at =
335 valid_text(self.created_at, MAX_TIMESTAMP_BYTES).ok_or(Error::InvalidResponse)?;
336 let activity_at =
337 valid_text(self.last_posted_at, MAX_TIMESTAMP_BYTES).ok_or(Error::InvalidResponse)?;
338 let author = self
339 .posters
340 .first()
341 .and_then(|poster| users.get(&poster.user_id))
342 .cloned();
343 let url = format!("https://discuss.codechef.com/t/{slug}/{}", self.id).into();
344 Ok(DiscussionSummary {
345 id: self.id,
346 title,
347 slug,
348 author,
349 created_at,
350 activity_at,
351 views: self.views,
352 posts: self.posts_count,
353 like_count: self.like_count,
354 url,
355 })
356 }
357}
358
359fn valid_user(user: UserData) -> Option<(u32, Box<str>)> {
360 if user.id == 0 {
361 return None;
362 }
363 display_name(user.name, user.username).map(|name| (user.id, name))
364}
365
366fn display_name(name: Option<Box<str>>, username: Option<Box<str>>) -> Option<Box<str>> {
367 name.and_then(|name| valid_text(name, MAX_NAME_BYTES))
368 .or_else(|| username.and_then(|username| valid_text(username, MAX_NAME_BYTES)))
369}
370
371fn valid_text(value: Box<str>, limit: usize) -> Option<Box<str>> {
372 (!value.is_empty() && value.len() <= limit && !value.chars().any(char::is_control))
373 .then_some(value)
374}
375
376fn valid_slug(value: Box<str>) -> Option<Box<str>> {
377 (value.len() <= MAX_SLUG_BYTES
378 && !value.is_empty()
379 && value
380 .bytes()
381 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'))
382 .then_some(value)
383}
384
385fn valid_body(value: Box<str>) -> Option<Box<str>> {
386 (!value.is_empty() && value.len() <= MAX_BODY_BYTES && !value.contains('\0')).then_some(value)
387}