eggbug/
client.rs

1use crate::{Error, Post, Session};
2use reqwest::{Method, RequestBuilder};
3use serde::{Deserialize, Serialize};
4use std::borrow::Cow;
5
6const PBKDF2_ITERATIONS: u32 = 200_000;
7const PBKDF2_KEY_LENGTH: usize = 128;
8
9macro_rules! request_impl {
10    ($($f:ident),* $(,)*) => {
11        $(
12            #[inline]
13            pub(crate) fn $f(&self, path: &str) -> RequestBuilder {
14                tracing::info!(path, concat!("Client::", stringify!($f)));
15                self.client.$f(format!("{}{}", self.base_url, path))
16            }
17        )*
18    };
19}
20
21/// HTTP client.
22#[derive(Debug, Clone)]
23pub struct Client {
24    pub(crate) base_url: Cow<'static, str>,
25    pub(crate) client: reqwest::Client,
26    logged_in: bool,
27}
28
29impl Client {
30    /// Creates a new `Client` with the default base URL, `https://cohost.org/api/v1/`. Use
31    /// [`Client::with_base_url`] to change the base URL.
32    #[must_use]
33    #[allow(clippy::missing_panics_doc)] // tested to not panic
34    pub fn new() -> Client {
35        const USER_AGENT: &str = concat!(
36            "eggbug-rs/",
37            env!("CARGO_PKG_VERSION"),
38            " (https://github.com/iliana/eggbug-rs)",
39        );
40
41        Client {
42            base_url: Cow::Borrowed("https://cohost.org/api/v1/"),
43            client: reqwest::Client::builder()
44                .cookie_store(true)
45                .user_agent(USER_AGENT)
46                .build()
47                .unwrap(),
48            logged_in: false,
49        }
50    }
51
52    /// Creates a new `Client` with a custom base URL.
53    #[must_use]
54    pub fn with_base_url(mut self, mut base_url: String) -> Client {
55        if !base_url.ends_with('/') {
56            base_url.push('/');
57        }
58        self.base_url = Cow::Owned(base_url);
59        self
60    }
61
62    /// Logs into cohost with an email and password, returning a [`Session`].
63    ///
64    /// Securely storing the user's password is an exercise left to the caller.
65    #[tracing::instrument(skip(self, password))]
66    pub async fn login(mut self, email: &str, password: &str) -> Result<Session, Error> {
67        let SaltResponse { salt } = self
68            .get("login/salt")
69            .query(&[("email", email)])
70            .send()
71            .await?
72            .error_for_status()?
73            .json()
74            .await?;
75
76        let mut client_hash = [0; PBKDF2_KEY_LENGTH];
77        pbkdf2::pbkdf2::<hmac::Hmac<sha2::Sha384>>(
78            password.as_bytes(),
79            &decode_salt(&salt)?,
80            PBKDF2_ITERATIONS,
81            &mut client_hash,
82        );
83        let client_hash = base64::encode(client_hash);
84
85        let LoginResponse { user_id } = self
86            .post("login")
87            .json(&LoginRequest { email, client_hash })
88            .send()
89            .await?
90            .error_for_status()?
91            .json()
92            .await?;
93        tracing::info!(user_id, "logged in");
94        self.logged_in = true;
95
96        Ok(Session { client: self })
97    }
98
99    /// Returns true if this client has logged in before.
100    ///
101    /// This can be used to differentiate a reference as returned from [`Session::as_client`] or as
102    /// never logged in.
103    #[must_use]
104    pub fn has_logged_in(&self) -> bool {
105        self.logged_in
106    }
107
108    /// Get a page of posts from the given project.
109    ///
110    /// Pages start at 0. Once you get an empty page, there are no more pages after that to get;
111    /// they will all be empty.
112    #[tracing::instrument(skip(self))]
113    pub async fn get_posts_page(&self, project: &str, page: u64) -> Result<Vec<Post>, Error> {
114        let posts_page: crate::post::PostPage = self
115            .get(&format!("project/{}/posts", project))
116            .query(&[("page", page.to_string())])
117            .send()
118            .await?
119            .error_for_status()?
120            .json()
121            .await?;
122        Ok(posts_page.into())
123    }
124
125    #[inline]
126    pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
127        tracing::info!(%method, path, "Client::request");
128        self.client
129            .request(method, format!("{}{}", self.base_url, path))
130    }
131
132    request_impl!(delete, get, post, put);
133}
134
135impl Default for Client {
136    fn default() -> Client {
137        Client::new()
138    }
139}
140
141/// There is a subtle bug(?) in cohost:
142/// - The salt returned from the `login/salt` endpoint returns a string that _appears_ to be
143///   using the URL-safe Base64 alphabet with no padding.
144/// - However, the salt is being decoded with some JavaScript code that uses the standard
145///   (`+/`) alphabet.
146/// - This code uses a lookup table to go from a Base64 character to a 6-bit value. If the
147///   character is not in the lookup table, the lookup returns `undefined`. The code then
148///   performs bitwise operations on the returned value, which is coerced to 0 if not present
149///   in the lookup table.
150///
151/// We can replicate this effect by replacing hyphens and underscores with the `A`, the
152/// Base64 character representing 0.
153///
154/// mogery seemed to know about this when writing cohost.js (see lib/b64arraybuffer.js):
155/// <https://github.com/mogery/cohost.js/commit/c0063a38ae334b4424989242821d0ac1aba78f03>
156fn decode_salt(salt: &str) -> Result<Vec<u8>, Error> {
157    Ok(base64::decode_config(
158        salt.replace(['-', '_'], "A"),
159        base64::STANDARD_NO_PAD,
160    )?)
161}
162
163#[cfg(test)]
164#[test]
165fn test_decode_salt() {
166    assert_eq!(
167        decode_salt("JGhosofJGYFsyBlZspFVYg").unwrap(),
168        base64::decode_config("JGhosofJGYFsyBlZspFVYg", base64::URL_SAFE_NO_PAD).unwrap()
169    );
170    assert_eq!(
171        decode_salt("dg6y2aIj_iKzcgaL_MM8_Q").unwrap(),
172        base64::decode_config("dg6y2aIjAiKzcgaLAMM8AQ", base64::URL_SAFE_NO_PAD).unwrap()
173    );
174}
175
176#[derive(Deserialize)]
177#[serde(rename_all = "camelCase")]
178struct SaltResponse {
179    salt: String,
180}
181
182#[derive(Serialize)]
183#[serde(rename_all = "camelCase")]
184struct LoginRequest<'a> {
185    email: &'a str,
186    client_hash: String,
187}
188
189#[derive(Deserialize)]
190#[serde(rename_all = "camelCase")]
191struct LoginResponse {
192    user_id: u64,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::Client;
198
199    #[test]
200    fn client_new_doesnt_panic() {
201        drop(Client::new());
202    }
203}