Skip to main content

cp_cli_platform_codechef/
client.rs

1use 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    /// List at most twenty public discussions from CodeChef Discuss
34    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    /// Fetch one public discussion and its original post from CodeChef Discuss
58    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    /// Fetch the bounded, public statistics rendered on a CodeChef profile
100    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}