mastodon_async/
registration.rs

1use std::borrow::Cow;
2
3use log::{as_debug, as_serde, debug, error, trace};
4use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
5use reqwest::Client;
6use uuid::Uuid;
7
8use crate::{
9    apps::{App, AppBuilder},
10    helpers::read_response::read_response,
11    log_serde,
12    scopes::Scopes,
13    Data, Error, Mastodon, Result,
14};
15
16const DEFAULT_REDIRECT_URI: &str = "urn:ietf:wg:oauth:2.0:oob";
17
18/// Handles registering your mastodon app to your instance. It is recommended
19/// you cache your data struct to avoid registering on every run.
20#[derive(Debug, Clone)]
21pub struct Registration<'a> {
22    base: String,
23    client: Client,
24    app_builder: AppBuilder<'a>,
25    force_login: bool,
26}
27
28#[derive(Serialize, Deserialize)]
29struct OAuth {
30    client_id: String,
31    client_secret: String,
32    #[serde(default = "default_redirect_uri")]
33    redirect_uri: String,
34}
35
36fn default_redirect_uri() -> String {
37    DEFAULT_REDIRECT_URI.to_string()
38}
39
40#[derive(Serialize, Deserialize)]
41struct AccessToken {
42    access_token: String,
43}
44
45impl<'a> Registration<'a> {
46    /// Construct a new registration process to the instance of the `base` url.
47    /// ```
48    /// use mastodon_async::prelude::*;
49    ///
50    /// let registration = Registration::new("https://botsin.space");
51    /// ```
52    pub fn new<I: Into<String>>(base: I) -> Self {
53        Registration::new_with_client(base, Client::new())
54    }
55
56    /// Construct a new registration process to the instance of the `base` url,
57    /// using the provided [Client].
58    /// ```
59    /// use mastodon_async::prelude::*;
60    ///
61    /// let client = reqwest::Client::builder().user_agent("my cool app").build().unwrap();
62    /// let registration = Registration::new_with_client("https://botsin.space", client);
63    /// ```
64    pub fn new_with_client<I: Into<String>>(base: I, client: Client) -> Self {
65        Registration {
66            base: base.into(),
67            client,
68            app_builder: AppBuilder::new(),
69            force_login: false,
70        }
71    }
72}
73
74impl<'a> Registration<'a> {
75    #[allow(dead_code)]
76    pub(crate) fn with_sender<I: Into<String>>(base: I) -> Self {
77        Registration {
78            base: base.into(),
79            client: Client::new(),
80            app_builder: AppBuilder::new(),
81            force_login: false,
82        }
83    }
84
85    /// Sets the name of this app
86    ///
87    /// This is required, and if this isn't set then the AppBuilder::build
88    /// method will fail
89    pub fn client_name<I: Into<Cow<'a, str>>>(&mut self, name: I) -> &mut Self {
90        self.app_builder.client_name(name.into());
91        self
92    }
93
94    /// Sets the redirect uris that this app uses
95    pub fn redirect_uris<I: Into<Cow<'a, str>>>(&mut self, uris: I) -> &mut Self {
96        self.app_builder.redirect_uris(uris);
97        self
98    }
99
100    /// Sets the scopes that this app requires
101    ///
102    /// The default for an app is Scopes::Read
103    pub fn scopes(&mut self, scopes: Scopes) -> &mut Self {
104        self.app_builder.scopes(scopes);
105        self
106    }
107
108    /// Sets the optional "website" to register the app with
109    pub fn website<I: Into<Cow<'a, str>>>(&mut self, website: I) -> &mut Self {
110        self.app_builder.website(website);
111        self
112    }
113
114    /// Forces the user to re-login (useful if you need to re-auth as a
115    /// different user on the same instance
116    pub fn force_login(&mut self, force_login: bool) -> &mut Self {
117        self.force_login = force_login;
118        self
119    }
120
121    /// Register the given application
122    ///
123    /// ```no_run
124    /// use mastodon_async::{apps::App, prelude::*};
125    ///
126    /// tokio_test::block_on(async {
127    ///     let mut app = App::builder();
128    ///     app.client_name("mastodon-async_test");
129    ///
130    ///     let registration = Registration::new("https://botsin.space")
131    ///         .register(app)
132    ///         .await
133    ///         .unwrap();
134    ///     let url = registration.authorize_url().unwrap();
135    ///     // Here you now need to open the url in the browser
136    ///     // And handle a the redirect url coming back with the code.
137    ///     let code = String::from("RETURNED_FROM_BROWSER");
138    ///     let mastodon = registration.complete(&code).await.unwrap();
139    ///
140    ///     println!("{:?}", mastodon.get_home_timeline().await.unwrap().initial_items);
141    /// });
142    /// ```
143    pub async fn register<I: TryInto<App>>(&mut self, app: I) -> Result<Registered>
144    where
145        Error: From<<I as TryInto<App>>::Error>,
146    {
147        let app = app.try_into()?;
148        let oauth = self.send_app(&app).await?;
149
150        Ok(Registered {
151            base: self.base.clone(),
152            client: self.client.clone(),
153            client_id: oauth.client_id,
154            client_secret: oauth.client_secret,
155            redirect: oauth.redirect_uri,
156            scopes: app.scopes().clone(),
157            force_login: self.force_login,
158        })
159    }
160
161    /// Register the application with the server from the `base` url.
162    ///
163    /// ```no_run
164    /// use mastodon_async::prelude::*;
165    ///
166    /// tokio_test::block_on(async {
167    ///     let registration = Registration::new("https://botsin.space")
168    ///         .client_name("mastodon-async_test")
169    ///         .build()
170    ///         .await
171    ///         .unwrap();
172    ///     let url = registration.authorize_url().unwrap();
173    ///     // Here you now need to open the url in the browser
174    ///     // And handle a the redirect url coming back with the code.
175    ///     let code = String::from("RETURNED_FROM_BROWSER");
176    ///     let mastodon = registration.complete(&code).await.unwrap();
177    ///
178    ///     println!("{:?}", mastodon.get_home_timeline().await.unwrap().initial_items);
179    /// });
180    /// ```
181    pub async fn build(&mut self) -> Result<Registered> {
182        let app: App = self.app_builder.clone().build()?;
183        let oauth = self.send_app(&app).await?;
184
185        Ok(Registered {
186            base: self.base.clone(),
187            client: self.client.clone(),
188            client_id: oauth.client_id,
189            client_secret: oauth.client_secret,
190            redirect: oauth.redirect_uri,
191            scopes: app.scopes().clone(),
192            force_login: self.force_login,
193        })
194    }
195
196    async fn send_app(&self, app: &App) -> Result<OAuth> {
197        let url = format!("{}/api/v1/apps", self.base);
198        let call_id = Uuid::new_v4();
199        debug!(url = url, app = as_serde!(app), call_id = as_debug!(call_id); "registering app");
200        let response = self.client.post(&url).json(&app).send().await?;
201
202        match response.error_for_status() {
203            Ok(response) => {
204                let response = read_response(response).await?;
205                debug!(
206                    response = as_serde!(response), app = as_serde!(app),
207                    url = url, method = stringify!($method),
208                    call_id = as_debug!(call_id);
209                    "received API response"
210                );
211                Ok(response)
212            }
213            Err(err) => {
214                error!(
215                    err = as_debug!(err), url = url, method = stringify!($method),
216                    call_id = as_debug!(call_id);
217                    "error making API request"
218                );
219                Err(err.into())
220            }
221        }
222    }
223}
224
225impl Registered {
226    /// Skip having to retrieve the client id and secret from the server by
227    /// creating a `Registered` struct directly
228    ///
229    /// // Example
230    ///
231    /// ```no_run
232    /// use mastodon_async::{prelude::*, registration::Registered};
233    ///
234    /// tokio_test::block_on(async {
235    ///     let registration = Registered::from_parts(
236    ///         "https://example.com",
237    ///         "the-client-id",
238    ///         "the-client-secret",
239    ///         "https://example.com/redirect",
240    ///         Scopes::read_all(),
241    ///         false,
242    ///     );
243    ///     let url = registration.authorize_url().unwrap();
244    ///     // Here you now need to open the url in the browser
245    ///     // And handle a the redirect url coming back with the code.
246    ///     let code = String::from("RETURNED_FROM_BROWSER");
247    ///     let mastodon = registration.complete(&code).await.unwrap();
248    ///
249    ///     println!("{:?}", mastodon.get_home_timeline().await.unwrap().initial_items);
250    /// });
251    /// ```
252    pub fn from_parts(
253        base: &str,
254        client_id: &str,
255        client_secret: &str,
256        redirect: &str,
257        scopes: Scopes,
258        force_login: bool,
259    ) -> Registered {
260        Registered {
261            base: base.to_string(),
262            client: Client::new(),
263            client_id: client_id.to_string(),
264            client_secret: client_secret.to_string(),
265            redirect: redirect.to_string(),
266            scopes,
267            force_login,
268        }
269    }
270}
271
272impl Registered {
273    /// Returns the parts of the `Registered` struct that can be used to
274    /// recreate another `Registered` struct
275    ///
276    /// // Example
277    ///
278    /// ```
279    /// use mastodon_async::{prelude::*, registration::Registered};
280    ///
281    /// let orig_base = "https://example.social";
282    /// let orig_client_id = "some-client_id";
283    /// let orig_client_secret = "some-client-secret";
284    /// let orig_redirect = "https://example.social/redirect";
285    /// let orig_scopes = Scopes::all();
286    /// let orig_force_login = false;
287    ///
288    /// let registered = Registered::from_parts(
289    ///     orig_base,
290    ///     orig_client_id,
291    ///     orig_client_secret,
292    ///     orig_redirect,
293    ///     orig_scopes.clone(),
294    ///     orig_force_login,
295    /// );
296    ///
297    /// let (base, client_id, client_secret, redirect, scopes, force_login) = registered.into_parts();
298    ///
299    /// assert_eq!(orig_base, &base);
300    /// assert_eq!(orig_client_id, &client_id);
301    /// assert_eq!(orig_client_secret, &client_secret);
302    /// assert_eq!(orig_redirect, &redirect);
303    /// assert_eq!(orig_scopes, scopes);
304    /// assert_eq!(orig_force_login, force_login);
305    /// ```
306    pub fn into_parts(self) -> (String, String, String, String, Scopes, bool) {
307        (
308            self.base,
309            self.client_id,
310            self.client_secret,
311            self.redirect,
312            self.scopes,
313            self.force_login,
314        )
315    }
316
317    /// Returns the full url needed for authorization. This needs to be opened
318    /// in a browser.
319    pub fn authorize_url(&self) -> Result<String> {
320        let scopes = format!("{}", self.scopes);
321        let scopes: String = utf8_percent_encode(&scopes, NON_ALPHANUMERIC).collect();
322        let url = if self.force_login {
323            format!(
324                "{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&force_login=true&\
325                 response_type=code",
326                self.base, self.client_id, self.redirect, scopes,
327            )
328        } else {
329            format!(
330                "{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&response_type=code",
331                self.base, self.client_id, self.redirect, scopes,
332            )
333        };
334
335        Ok(url)
336    }
337
338    /// Construct authentication data once token is known
339    fn registered(&self, token: String) -> Data {
340        Data {
341            base: self.base.clone().into(),
342            client_id: self.client_id.clone().into(),
343            client_secret: self.client_secret.clone().into(),
344            redirect: self.redirect.clone().into(),
345            token: token.into(),
346        }
347    }
348
349    /// Create an access token from the client id, client secret, and code
350    /// provided by the authorization url.
351    pub async fn complete<C>(&self, code: C) -> Result<Mastodon>
352    where
353        C: AsRef<str>,
354    {
355        let url =
356            format!(
357            "{}/oauth/token?client_id={}&client_secret={}&code={}&grant_type=authorization_code&\
358             redirect_uri={}",
359            self.base, self.client_id, self.client_secret, code.as_ref(), self.redirect
360        );
361        debug!(url = url; "completing registration");
362        let response = self.client.post(&url).send().await?;
363        debug!(
364            status = log_serde!(response Status), url = url,
365            headers = log_serde!(response Headers);
366            "received API response"
367        );
368        let token: AccessToken = read_response(response).await?;
369        debug!(url = url, body = as_serde!(token); "parsed response body");
370        let data = self.registered(token.access_token);
371        trace!(auth_data = as_serde!(data); "registered");
372
373        Ok(Mastodon::new(self.client.clone(), data))
374    }
375}
376
377/// Represents the state of the auth flow when the app has been registered but
378/// the user is not authenticated
379#[derive(Debug, Clone)]
380pub struct Registered {
381    base: String,
382    client: Client,
383    client_id: String,
384    client_secret: String,
385    redirect: String,
386    scopes: Scopes,
387    force_login: bool,
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_registration_new() {
396        let r = Registration::new("https://example.com");
397        assert_eq!(r.base, "https://example.com".to_string());
398        assert_eq!(r.app_builder, AppBuilder::new());
399    }
400
401    #[test]
402    fn test_registration_with_sender() {
403        let r = Registration::with_sender("https://example.com");
404        assert_eq!(r.base, "https://example.com".to_string());
405        assert_eq!(r.app_builder, AppBuilder::new());
406    }
407
408    #[test]
409    fn test_set_client_name() {
410        let mut r = Registration::new("https://example.com");
411        r.client_name("foo-test");
412
413        assert_eq!(r.base, "https://example.com".to_string());
414        assert_eq!(
415            &mut r.app_builder,
416            AppBuilder::new().client_name("foo-test")
417        );
418    }
419
420    #[test]
421    fn test_set_redirect_uris() {
422        let mut r = Registration::new("https://example.com");
423        r.redirect_uris("https://foo.com");
424
425        assert_eq!(r.base, "https://example.com".to_string());
426        assert_eq!(
427            &mut r.app_builder,
428            AppBuilder::new().redirect_uris("https://foo.com")
429        );
430    }
431
432    #[test]
433    fn test_set_scopes() {
434        let mut r = Registration::new("https://example.com");
435        r.scopes(Scopes::all());
436
437        assert_eq!(r.base, "https://example.com".to_string());
438        assert_eq!(&mut r.app_builder, AppBuilder::new().scopes(Scopes::all()));
439    }
440
441    #[test]
442    fn test_set_website() {
443        let mut r = Registration::new("https://example.com");
444        r.website("https://website.example.com");
445
446        assert_eq!(r.base, "https://example.com".to_string());
447        assert_eq!(
448            &mut r.app_builder,
449            AppBuilder::new().website("https://website.example.com")
450        );
451    }
452
453    #[test]
454    fn test_default_redirect_uri() {
455        assert_eq!(&default_redirect_uri()[..], DEFAULT_REDIRECT_URI);
456    }
457}