acick_atcoder/
actor.rs

1use std::io::Write as _;
2
3use anyhow::{anyhow, Context as _};
4use lazy_static::lazy_static;
5use maplit::hashmap;
6use reqwest::blocking::{Client, Response};
7use reqwest::redirect::Policy;
8use reqwest::{StatusCode, Url};
9
10use crate::abs_path::AbsPathBuf;
11use crate::config::SessionConfig;
12use crate::dropbox::DbxAuthorizer;
13use crate::full::{fetch_full, TestcaseIter};
14use crate::model::{Contest, ContestId, LangName, LangNameRef, Problem, ProblemId};
15use crate::page::{ExtractCsrfToken as _, ExtractLangId as _};
16use crate::page::{
17    HasHeader as _, LoginPageBuilder, SettingsPageBuilder, SubmitPageBuilder, TasksPageBuilder,
18    TasksPrintPageBuilder, BASE_URL,
19};
20use crate::service::session::WithRetry as _;
21use crate::service::{Act, ResponseExt as _};
22use crate::web::open_in_browser;
23use crate::{Config, Console, Error, Result};
24
25// TODO: remove allow(clippy::unknown_clippy_lints)
26// when clippy::option_env_unwrap comes into stable
27lazy_static! {
28    // Use option_env for builds on crates.io.
29    // crates.io does not know these secrets.
30    static ref DBX_APP_KEY: &'static str = {
31        #[allow(clippy::unknown_clippy_lints)]
32        #[allow(clippy::option_env_unwrap)]
33        option_env!("ACICK_DBX_APP_KEY").unwrap()
34    };
35    static ref DBX_APP_SECRET: &'static str = {
36        #[allow(clippy::unknown_clippy_lints)]
37        #[allow(clippy::option_env_unwrap)]
38        option_env!("ACICK_DBX_APP_SECRET").unwrap()
39    };
40}
41
42static USER_AGENT: &str = concat!(
43    env!("CARGO_PKG_NAME"),
44    "-",
45    env!("CARGO_PKG_VERSION"),
46    " (",
47    env!("CARGO_PKG_REPOSITORY"),
48    ")"
49);
50static DBX_REDIRECT_PORT: u16 = 4100;
51static DBX_REDIRECT_PATH: &str = "/oauth2/callback";
52
53#[derive(Debug)]
54pub struct AtcoderActor<'a> {
55    client: Client,
56    session: &'a SessionConfig,
57}
58
59impl<'a> AtcoderActor<'a> {
60    pub fn new(session: &'a SessionConfig) -> Self {
61        let client = Client::builder()
62            .referer(false)
63            .redirect(Policy::none()) // redirects manually
64            .user_agent(USER_AGENT)
65            .timeout(Some(session.timeout()))
66            .build()
67            .expect("Could not setup client. \
68                TLS backend cannot be initialized, or the resolver cannot load the system configuration.");
69        AtcoderActor { client, session }
70    }
71}
72
73impl AtcoderActor<'_> {
74    fn problem_url(contest_id: &ContestId, problem: &Problem) -> Result<Url> {
75        let path = format!("/contests/{}/tasks/{}", contest_id, &problem.url_name());
76        BASE_URL
77            .join(&path)
78            .context(format!("Could not parse problem url : {}", path))
79    }
80
81    fn submissions_url(contest_id: &ContestId) -> Result<Url> {
82        let path = format!("/contests/{}/submissions/me", contest_id);
83        BASE_URL
84            .join(&path)
85            .context(format!("Could not parse submissions url : {}", path))
86    }
87
88    fn validate_login_response(res: &Response) -> Result<()> {
89        if res.status() != StatusCode::FOUND {
90            return Err(Error::msg("Received invalid response code"));
91        }
92        Ok(())
93    }
94
95    fn validate_submit_response(res: &Response, contest_id: &ContestId) -> Result<()> {
96        if res.status() != StatusCode::FOUND {
97            return Err(Error::msg("Received invalid response code"));
98        }
99        let loc_url = res
100            .location_url(&BASE_URL)
101            .context("Could not extract redirection url from response")?;
102        if loc_url != Self::submissions_url(contest_id)? {
103            return Err(Error::msg("Found invalid redirection url"));
104        }
105        Ok(())
106    }
107
108    pub fn fetch_full(
109        contest_id: &ContestId,
110        problems: &[Problem],
111        token_path: &AbsPathBuf,
112        access_token: Option<String>,
113        conf: &Config,
114        cnsl: &mut Console,
115    ) -> Result<()> {
116        // authorize Dropbox account
117        let dropbox = DbxAuthorizer::new(
118            &DBX_APP_KEY,
119            &DBX_APP_SECRET,
120            DBX_REDIRECT_PORT,
121            DBX_REDIRECT_PATH,
122            token_path,
123        )
124        .load_or_request(access_token, cnsl)?;
125
126        fetch_full(&dropbox, contest_id, problems, conf, cnsl)
127    }
128
129    pub fn load_testcases(
130        testcases_dir: AbsPathBuf,
131        sample_name: &Option<String>,
132    ) -> Result<TestcaseIter> {
133        TestcaseIter::load(testcases_dir, sample_name)
134    }
135}
136
137impl Act for AtcoderActor<'_> {
138    fn current_user(&self, cnsl: &mut Console) -> Result<Option<String>> {
139        let Self { client, session } = self;
140        let login_page = LoginPageBuilder::new(session).build(client, cnsl)?;
141        login_page.current_user()
142    }
143
144    fn login(&self, user: String, pass: String, cnsl: &mut Console) -> Result<bool> {
145        let Self { client, session } = self;
146
147        // check if user is already logged in
148        let login_page = LoginPageBuilder::new(session).build(client, cnsl)?;
149        let current_user = login_page.current_user()?;
150        if let Some(current_user) = current_user {
151            // already logged in
152            if current_user != user {
153                return Err(anyhow!("Logged in as another user: {}", current_user));
154            }
155            return Ok(false);
156        }
157
158        // prepare payload
159        let csrf_token = login_page.extract_csrf_token()?;
160        let payload = hashmap!(
161            "csrf_token" => csrf_token,
162            "username" => user.as_str(),
163            "password" => pass.as_str(),
164        );
165
166        // post credentials
167        let res = client
168            .post(login_page.url()?)
169            .form(&payload)
170            .with_retry(
171                client,
172                session.cookies_path(),
173                session.retry_limit(),
174                session.retry_interval(),
175            )
176            .retry_send(cnsl)?;
177
178        // check if login succeeded
179        Self::validate_login_response(&res).context("Login rejected by service")?;
180        let settings_page = SettingsPageBuilder::new(session).build(client, cnsl)?;
181        let current_user = settings_page.current_user()?;
182        match current_user {
183            None => Err(anyhow!("Failed to log in")),
184            Some(current_user) if current_user != user => {
185                Err(anyhow!("Logged in as another user: {}", current_user))
186            }
187            _ => Ok(true),
188        }
189    }
190
191    fn fetch(
192        &self,
193        contest_id: &ContestId,
194        problem_id: &Option<ProblemId>,
195        cnsl: &mut Console,
196    ) -> Result<(Contest, Vec<Problem>)> {
197        let Self { client, session } = self;
198
199        let tasks_page = TasksPageBuilder::new(contest_id, session).build(client, cnsl)?;
200        let contest_name = tasks_page
201            .extract_contest_name()
202            .context("Could not extract contest name")?;
203        let mut problems: Vec<Problem> = tasks_page
204            .extract_problems(cnsl)?
205            .into_iter()
206            .filter(|problem| {
207                if let Some(problem_id) = problem_id {
208                    problem.id() == problem_id
209                } else {
210                    true
211                }
212            })
213            .collect();
214        if problems.is_empty() {
215            let err = if let Some(problem_id) = problem_id {
216                Err(anyhow!(
217                    "Could not find problem \"{}\" in contest {}",
218                    problem_id,
219                    contest_id
220                ))
221            } else {
222                Err(anyhow!(
223                    "Could not find any problems in contest {}",
224                    contest_id
225                ))
226            };
227            return err;
228        }
229
230        let tasks_print_page =
231            TasksPrintPageBuilder::new(contest_id, session).build(client, cnsl)?;
232        let mut samples_map = tasks_print_page.extract_samples_map()?;
233        for problem in problems.iter_mut() {
234            if let Some(samples) = samples_map.remove(problem.id()) {
235                problem.set_samples(samples);
236            } else {
237                // found problem on TasksPage but not found on TasksPrintPage
238                return Err(anyhow!(
239                    "Could not extract samples for problem : {}",
240                    problem.id()
241                ));
242            }
243        }
244
245        let contest = Contest::new(contest_id.to_owned(), contest_name);
246        Ok((contest, problems))
247    }
248
249    fn submit<'a>(
250        &self,
251        contest_id: &ContestId,
252        problem: &Problem,
253        lang_names: &'a [LangName],
254        source: &str,
255        cnsl: &mut Console,
256    ) -> Result<LangNameRef<'a>> {
257        let Self { client, session } = self;
258
259        // get submit page
260        let submit_page = SubmitPageBuilder::new(contest_id, session).build(client, cnsl)?;
261
262        // extract lang id
263        let (lang_id, lang_name) = lang_names
264            .iter()
265            .find_map(|lang_name| {
266                submit_page
267                    .extract_lang_id(lang_name)
268                    .map(|lang_id| (lang_id, lang_name))
269            })
270            .with_context(|| {
271                format!(
272                    "Could not find available language from the given language list: {}",
273                    lang_names.join(", ")
274                )
275            })?;
276
277        // prepare payload
278        let csrf_token = submit_page.extract_csrf_token()?;
279        let payload = hashmap!(
280            "csrf_token" => csrf_token,
281            "data.TaskScreenName" => problem.url_name().as_str(),
282            "data.LanguageId" => lang_id.as_str(),
283            "sourceCode" => source,
284        );
285
286        // submit source code
287        let res = client
288            .post(submit_page.url()?)
289            .form(&payload)
290            .with_retry(
291                client,
292                session.cookies_path(),
293                session.retry_limit(),
294                session.retry_interval(),
295            )
296            .retry_send(cnsl)?;
297
298        // check response
299        Self::validate_submit_response(&res, contest_id)
300            .context("Submission rejected by service")?;
301
302        Ok(lang_name)
303    }
304
305    fn open_problem_url(
306        &self,
307        contest_id: &ContestId,
308        problem: &Problem,
309        cnsl: &mut Console,
310    ) -> Result<()> {
311        open_in_browser(Self::problem_url(contest_id, problem)?.as_str())?;
312        writeln!(cnsl, "Opened problem page in web browser.")?;
313        Ok(())
314    }
315
316    fn open_submissions_url(&self, contest_id: &ContestId, cnsl: &mut Console) -> Result<()> {
317        open_in_browser(Self::submissions_url(contest_id)?.as_str())?;
318        writeln!(cnsl, "Opened submissions page in web browser.")?;
319        Ok(())
320    }
321}