mal-cli-rs 0.2.1

CLI tool for myanimelist
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
/// structs and methods for oauth2 authentication flow
pub mod redirect;

/// structs and methods for token management
pub mod token;

/// methods for cache
pub mod cache;

use crate::config::oauth_config::AuthConfig;
use color_eyre::Result;
use rand::{distr::Alphanumeric, rng, Rng};
use serde::{Deserialize, Serialize};
use serde_json;
use serde_urlencoded;
use std::{io::Error, iter, str::FromStr}; // process::Output
use token::{Token, TokenWrapper};
use url::Url;

const USER_AGENT: &str = "mal-cli";
const AUTHORIZE_URL: &str = "https://myanimelist.net/v1/oauth2/authorize";
const TOKEN_URL: &str = "https://myanimelist.net/v1/oauth2/token";

#[derive(Clone, Debug)]
pub enum AuthError {
    UnknownError,
    NetworkTimeout,
    InvalidResponse(String),
    AuthNotPresent,
    TokenNotPresent,
}

impl From<reqwest::Error> for AuthError {
    fn from(e: reqwest::Error) -> Self {
        if e.is_timeout() {
            AuthError::NetworkTimeout
        } else {
            AuthError::UnknownError
        }
    }
}

impl std::error::Error for AuthError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match *self {
            AuthError::UnknownError => None,
            AuthError::NetworkTimeout => None,
            AuthError::InvalidResponse(_) => None,
            AuthError::AuthNotPresent => None,
            AuthError::TokenNotPresent => None,
        }
    }
}

impl std::fmt::Display for AuthError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match *self {
            AuthError::UnknownError => write!(f, "Unknown Error"),
            AuthError::NetworkTimeout => write!(f, "Network Timeout"),
            AuthError::InvalidResponse(ref err) => err.fmt(f),
            AuthError::AuthNotPresent => write!(f, "Auth is not present"),
            AuthError::TokenNotPresent => write!(f, "Token is not present"),
        }
    }
}

const CODE_CHALLENGE_LENGTH: usize = 128;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuth {
    pub client_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_secret: Option<String>,
    pub redirect_url: String,
    pub user_agent: String,
    pub challenge: String,
    pub state: String,
    pub auth_code: Option<String>,
    pub token: Option<TokenWrapper>,
}

impl OAuth {
    /// Start of a new oauth2 flow
    /// # Parameters
    /// * `user`
    pub fn new<A: ToString>(
        user_agent: A,
        client_id: A,
        client_secret: Option<A>,
        redirect_url: A,
    ) -> Self {
        OAuth {
            client_id: client_id.to_string(),
            client_secret: client_secret.map(|cs| cs.to_string()),
            redirect_url: redirect_url.to_string(),
            user_agent: user_agent.to_string(),
            challenge: Self::new_challenge(CODE_CHALLENGE_LENGTH),
            state: "AUTHSTART".to_string(),
            auth_code: None,
            token: None,
        }
    }

    /// Generates a new base64-encoded SHA-256 PKCE code
    /// # Panic
    /// `len` needs to be a value between 48 and 128
    fn new_challenge(len: usize) -> String {
        // Check whether the len in in between the valid length for a
        // PKCE code (43 chars - 128 chars)
        if !(48..=128).contains(&len) {
            panic!("len is not in between 48 and 128");
        }
        let mut rng = rng();
        // needs to be url safe so we use Alphanumeric
        let challenge: String = iter::repeat(())
            .map(|()| rng.sample(Alphanumeric) as char)
            .take(len)
            .collect();
        challenge
    }

    /// Returns user agent
    pub fn user_agent(&self) -> &String {
        &self.user_agent
    }

    /// Creates a new authorization url
    pub fn get_auth_url(&self) -> Url {
        #[derive(Serialize, Debug)]
        struct AuthQuery {
            response_type: String,
            client_id: String,
            code_challenge: String,
            state: String,
            redirect_url: String,
            code_challenge_method: String,
        }

        let auth_query = AuthQuery {
            response_type: "code".to_string(),
            client_id: self.client_id.clone(),
            code_challenge: self.challenge.clone(),
            state: self.state.to_string(),
            redirect_url: self.redirect_url.clone(),
            // mal only supports plain
            code_challenge_method: "plain".to_string(),
        };

        url::Url::from_str(&format!(
            "{}?{}",
            AUTHORIZE_URL,
            serde_urlencoded::to_string(auth_query).unwrap()
        ))
        .unwrap()
    }

    /// Parses redirection url
    pub fn parse_redirect_query_string(&mut self, query_string: &str) -> Result<(), AuthError> {
        #[derive(Deserialize, Debug)]
        struct AuthResponse {
            code: String,
            state: String,
        }

        let auth_response = match serde_urlencoded::from_str::<AuthResponse>(query_string) {
            Ok(r) => r,
            Err(e) => {
                return Err(AuthError::InvalidResponse(e.to_string()));
            }
        };

        if auth_response.state != self.state {
            return Err(AuthError::InvalidResponse("State Mismatch".to_string()));
        }

        self.auth_code = Some(auth_response.code);
        Ok(())
    }

    /// Creates a new url to get the token
    pub fn get_token_query_string(&self) -> Result<String, AuthError> {
        #[derive(Serialize, Debug)]
        struct TokenRequest {
            client_id: String,
            #[serde(skip_serializing_if = "Option::is_none")]
            client_secret: Option<String>,
            code: String,
            code_verifier: String,
            grant_type: String,
        }

        if self.auth_code.is_none() {
            return Err(AuthError::AuthNotPresent);
        }

        let query = TokenRequest {
            client_id: self.client_id.clone(),
            client_secret: self.client_secret.clone(),
            code: self.auth_code.as_ref().unwrap().clone(),
            code_verifier: self.challenge.clone(),
            grant_type: "authorization_code".to_string(),
        };

        Ok(serde_urlencoded::to_string(query).unwrap())
    }

    /// Get access token
    pub fn get_access_token(&mut self) -> Result<(), AuthError> {
        let request = reqwest::blocking::ClientBuilder::new()
            .user_agent(USER_AGENT)
            .build()?
            .post(TOKEN_URL)
            .header(reqwest::header::ACCEPT, "application/json")
            .header(
                reqwest::header::CONTENT_TYPE,
                "application/x-www-form-urlencoded",
            )
            .body(self.get_token_query_string()?);

        let response = request.send()?;
        let success = response.status().is_success();
        let body = response.text()?;
        self.handle_response(success, &body)
    }

    /// Refresh the token (async)
    pub async fn get_access_token_async(&mut self) -> Result<(), AuthError> {
        let request = reqwest::ClientBuilder::new()
            .user_agent(USER_AGENT)
            .build()?
            .post(TOKEN_URL)
            .header(reqwest::header::ACCEPT, "application/json")
            .header(
                reqwest::header::CONTENT_TYPE,
                "application/x-www-form-urlencoded",
            )
            .body(self.get_token_query_string()?);

        let response = request.send().await?;
        let success = response.status().is_success();
        let body = response.text().await?;
        self.handle_response(success, &body)
    }

    /// Handle a repsonse for get_access_token()
    pub fn handle_response(&mut self, success: bool, body: &str) -> Result<(), AuthError> {
        if success {
            match serde_json::from_str::<Token>(body) {
                Ok(result) => {
                    self.token = Some(TokenWrapper::new(result));
                    Ok(())
                }
                Err(e) => Err(AuthError::InvalidResponse(e.to_string())),
            }
        } else {
            println!("{}", body);
            Err(AuthError::UnknownError)
        }
    }

    /// Get a token reference
    pub fn token(&self) -> Option<&TokenWrapper> {
        self.token.as_ref()
    }

    pub fn get_token_refresh_query_string(&self) -> Result<String, AuthError> {
        #[derive(Serialize, Debug)]
        struct TokenRequest {
            client_id: String,
            #[serde(skip_serializing_if = "Option::is_none")]
            client_secret: Option<String>,
            code: String,
            code_verifier: String,
            grant_type: String,
            refresh_token: String,
        }

        if self.auth_code.is_none() {
            return Err(AuthError::AuthNotPresent);
        }
        if self.token.is_none() {
            return Err(AuthError::TokenNotPresent);
        }

        let query = TokenRequest {
            client_id: self.client_id.clone(),
            client_secret: self.client_secret.clone(),
            code: self.auth_code.as_ref().unwrap().clone(),
            code_verifier: self.challenge.clone(),
            grant_type: "refresh_token".to_string(),
            refresh_token: self.token().unwrap().token.refresh_token.clone(),
        };

        Ok(serde_urlencoded::to_string(query).unwrap())
    }

    /// Refresh the token
    pub fn refresh(&mut self) -> Result<(), AuthError> {
        if self.token().unwrap().expired() {
            let request = reqwest::blocking::ClientBuilder::new()
                .user_agent(USER_AGENT)
                .build()?
                .post(TOKEN_URL)
                .header(reqwest::header::ACCEPT, "application/json")
                .header(
                    reqwest::header::CONTENT_TYPE,
                    "application/x-www-form-urlencoded",
                )
                .body(self.get_token_refresh_query_string()?);

            let response = request.send()?;
            let success = response.status().is_success();
            let body = response.text()?;
            self.handle_response(success, &body)
        } else {
            Ok(())
        }
    }

    /// Refresh the token (async)
    pub async fn refresh_async(&mut self) -> Result<(), AuthError> {
        if self.token().unwrap().expired() {
            let request = reqwest::ClientBuilder::new()
                .user_agent(USER_AGENT)
                .build()?
                .post(TOKEN_URL)
                .header(reqwest::header::ACCEPT, "application/json")
                .header(
                    reqwest::header::CONTENT_TYPE,
                    "application/x-www-form-urlencoded",
                )
                .body(self.get_token_refresh_query_string()?);

            let response = request.send().await?;
            let success = response.status().is_success();
            let body = response.text().await?;
            self.handle_response(success, &body)
        } else {
            Ok(())
        }
    }

    pub async fn get_auth_async(config: AuthConfig) -> Result<OAuth, AuthError> {
        if let Some(mut auth) = cache::load_cached_auth() {
            auth.refresh_async().await?;
            Ok(auth)
        } else {
            let auth = OAuth::new(
                config.get_user_agent(),
                config.client_id.clone(),
                None,
                config.get_redirect_uri(),
            );

            let url = auth.get_auth_url();

            if test_oauth_url(&url).await {
                open(&url).unwrap();
            } else {
                println!("==> Please verify your creds and retry.");
                println!("==> Note: cached auth file will be deleted.");
                // delete oauth cache file
                cache::delete_cached_auth();
                // If the URL cannot be opened, return an error
                return Err(AuthError::InvalidResponse("Failed to open URL".to_string()));
            }
            let mut auth = redirect::Server::new(config.get_user_agent(), auth)
                .go()
                .unwrap();

            auth.get_access_token_async().await.unwrap();

            cache::cache_auth(&auth);

            Ok(auth)
        }
    }

    // for tests
    pub fn get_auth(config: AuthConfig) -> Result<OAuth, AuthError> {
        if let Some(mut auth) = cache::load_cached_auth() {
            auth.refresh()?;
            Ok(auth)
        } else {
            let auth = OAuth::new(
                config.get_user_agent(),
                config.client_id.clone(),
                None,
                config.get_redirect_uri(),
            );

            let url = auth.get_auth_url();
            open(&url).unwrap();

            let mut auth = redirect::Server::new(config.get_user_agent(), auth)
                .go()
                .unwrap();

            auth.get_access_token().unwrap();

            cache::cache_auth(&auth);

            Ok(auth)
        }
    }
}

pub async fn test_oauth_url(url: &Url) -> bool {
    let res = reqwest::ClientBuilder::new()
        .user_agent(USER_AGENT)
        .build()
        .unwrap()
        .get(url.as_ref())
        .send()
        .await;

    match res {
        Ok(response) => response.status().is_success(),
        Err(_) => false,
    }
}
/// use webbrowser crate to open url in browser
pub fn open(url: &Url) -> Result<(), Error> {
    webbrowser::open(url.as_ref())
}

#[cfg(test)]
pub mod tests {
    use super::*;
    pub fn get_auth() -> OAuth {
        let config = AuthConfig::load().unwrap();
        OAuth::get_auth(config).unwrap()
    }

    #[test]
    fn test_refresh_token() {
        let mut auth = get_auth();
        auth.refresh().unwrap();
        println!("{}", serde_json::to_string(&auth).unwrap());
    }
    #[test]
    fn test_get_auth() {
        // Get config from file
        let config = AuthConfig::load().unwrap();

        // make auth
        let auth = OAuth::new(
            config.get_user_agent(),
            config.client_id.clone(),
            None,
            config.get_redirect_uri(),
        );

        println!("{}", auth.redirect_url);

        // create and open url
        let url = auth.get_auth_url();
        open(&url).unwrap();

        // wait for redirect
        let mut auth = redirect::Server::new(config.get_user_agent(), auth)
            .go()
            .unwrap();

        // get access token
        auth.get_access_token().unwrap();
        println!("{}", serde_json::to_string(&auth).unwrap());

        // get refresh token
        auth.refresh().unwrap();
        println!("{}", serde_json::to_string(&auth).unwrap());

        cache::cache_auth(&auth);
    }

    #[test]
    fn test_challenge() {
        let challenge = OAuth::new_challenge(CODE_CHALLENGE_LENGTH);

        assert!(challenge.len() == CODE_CHALLENGE_LENGTH);
        println!("{}", challenge);
        println!(
            "len: {}, CODE_CHALLENGE_LEN: {}",
            challenge.len(),
            CODE_CHALLENGE_LENGTH
        );
    }
    #[test]
    #[should_panic(expected = "len is not in between 48 and 128")]
    fn test_challenge_len() {
        // should panic
        let _challenge = OAuth::new_challenge(5);
    }
}