something_awful/
client.rs

1use crate::{post_list::Post, thread_list::Thread, Error};
2use reqwest_cookie_store::{CookieStore, CookieStoreMutex};
3use std::{
4    io::{BufRead, Write},
5    sync::Arc,
6};
7use url::Url;
8
9/// Manages access to the Something Awful forums.
10pub struct Client {
11    base: Url,
12    client: reqwest::Client,
13    cookie_store: Arc<CookieStoreMutex>,
14}
15
16/// References a forum user.
17pub enum User<'a> {
18    /// References the current logged-in user.
19    CurrentUser,
20
21    /// References a user ID.
22    UserID(&'a str),
23
24    /// References a username.
25    Username(&'a str),
26}
27
28/// References a page of posts within a thread.
29pub enum ThreadPage {
30    /// The first page of the thread.
31    First,
32
33    /// The last page of the thread.
34    Last,
35
36    /// The first unseen page of the thread.
37    New,
38
39    /// A specific page of the thread. This should be between 1 and the maximum
40    /// page inclusive.
41    Page(usize),
42}
43
44/// Contains all data in a user's public profile.
45#[derive(Debug, serde::Serialize, serde::Deserialize)]
46pub struct Profile {
47    pub userid: i64,
48    pub username: String,
49    pub homepage: String,
50    pub icq: String,
51    pub aim: String,
52    pub yahoo: String,
53    pub gender: String,
54    pub usertitle: String,
55    pub joindate: i64,
56    pub lastpost: i64,
57    pub posts: i64,
58    pub receivepm: i64,
59    pub postsperday: f64,
60    pub role: String,
61    pub biography: String,
62    pub location: String,
63    pub interests: String,
64    pub occupation: String,
65    pub picture: String,
66    pub avpath: String,
67}
68
69impl Client {
70    /// Constructs an unauthenticated client. The user must either login or load
71    /// credentials before using other API functions.
72    pub fn new() -> Result<Client, Error> {
73        let cookie_store = Arc::new(CookieStoreMutex::new(CookieStore::new(None)));
74        Ok(Client {
75            base: Url::parse("https://forums.somethingawful.com")?,
76            client: reqwest::Client::builder()
77                .cookie_provider(cookie_store.clone())
78                .build()?,
79            cookie_store,
80        })
81    }
82
83    /// Attempts to login. Returns ReqwestError on a communication error or
84    /// LoginError if the login request failed.
85    pub async fn login(&self, username: &str, password: &str) -> Result<(), Error> {
86        let response = self
87            .client
88            .post(self.base.join("account.php?json=1")?)
89            .form(&[
90                ("action", "login"),
91                ("username", username),
92                ("password", password),
93                ("next", "/index.php?json=1"),
94            ])
95            .send()
96            .await?;
97
98        if response.error_for_status().is_err() {
99            Err(Error::LoginError)
100        } else {
101            Ok(())
102        }
103    }
104
105    /// Returns the profile of a user, or None if that user cannot be found.
106    pub async fn fetch_profile<'a>(&self, user: User<'a>) -> Result<Option<Profile>, Error> {
107        let query = match user {
108            User::CurrentUser => vec![("action", "getinfo"), ("json", "1")],
109            User::UserID(userid) => vec![("action", "getinfo"), ("userid", userid), ("json", "1")],
110            User::Username(username) => {
111                vec![("action", "getinfo"), ("username", username), ("json", "1")]
112            }
113        };
114        let response = self
115            .client
116            .get(self.base.join("member.php")?)
117            .query(&query)
118            .send()
119            .await?;
120
121        // If the username doesn't exist, we get an HTML page that will decode
122        // incorrectly.
123        match response.json().await {
124            Ok(res) => Ok(res),
125            Err(err) => {
126                if err.is_decode() {
127                    Ok(None)
128                } else {
129                    Err(err.into())
130                }
131            }
132        }
133    }
134
135    /// Returns all posts on a given page of a thread.
136    pub async fn fetch_posts(
137        &self,
138        thread_id: &str,
139        index: ThreadPage,
140    ) -> Result<Vec<Post>, Error> {
141        let mut _page_string = None;
142        let query = match index {
143            ThreadPage::First => {
144                vec![("threadid", thread_id), ("perpage", "40")]
145            }
146            ThreadPage::Last => {
147                vec![
148                    ("threadid", thread_id),
149                    ("perpage", "40"),
150                    ("goto", "lastpost"),
151                ]
152            }
153            ThreadPage::New => {
154                vec![
155                    ("threadid", thread_id),
156                    ("perpage", "40"),
157                    ("goto", "newpost"),
158                ]
159            }
160            ThreadPage::Page(page) => {
161                _page_string = Some(format!("{page}"));
162                vec![
163                    ("threadid", thread_id),
164                    ("perpage", "40"),
165                    ("pagenumber", _page_string.as_ref().unwrap()),
166                ]
167            }
168        };
169        let response = self
170            .client
171            .get(self.base.join("showthread.php")?)
172            .query(&query)
173            .send()
174            .await?
175            .text()
176            .await?;
177
178        Post::parse_list(&response)
179    }
180
181    /// Returns metadata about all bookmarked threads.
182    pub async fn fetch_bookmarked_threads(&self) -> Result<Vec<Thread>, Error> {
183        let mut bookmarked_threads = Vec::new();
184        let mut page = 1;
185        loop {
186            let response = self
187                .client
188                .get(self.base.join("bookmarkthreads.php")?)
189                .query(&[
190                    ("action", "view"),
191                    ("perpage", "40"),
192                    ("pagenumber", &format!("{page}")),
193                ])
194                .send()
195                .await?
196                .text()
197                .await?;
198
199            let mut threads = Thread::parse_list(&response)?;
200            let fetch_next = threads.len() == 40;
201            bookmarked_threads.append(&mut threads);
202            if fetch_next {
203                page += 1;
204            } else {
205                break;
206            }
207        }
208        Ok(bookmarked_threads)
209    }
210
211    /// Saves credentials to JSON. The user must be logged in for the
212    /// credentials to be useful.
213    pub fn save_credentials<W: Write>(&self, writer: &mut W) -> Result<(), Error> {
214        let store = self.cookie_store.lock().expect("BUG: lock failed");
215        store.save_json(writer).map_err(Error::CookieIOError)?;
216        Ok(())
217    }
218
219    /// Loads credentials from JSON. The JSON must have been written with
220    /// save_credentials.
221    pub fn load_credentials<R: BufRead>(&self, reader: R) -> Result<(), Error> {
222        let loaded = CookieStore::load_json(reader).map_err(Error::CookieIOError)?;
223        let mut store = self.cookie_store.lock().expect("BUG: lock failed");
224        for cookie in loaded.iter_unexpired() {
225            store.insert(cookie.clone(), &self.base)?;
226        }
227        Ok(())
228    }
229}