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#[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 #[must_use]
33 #[allow(clippy::missing_panics_doc)] 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 #[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 #[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 #[must_use]
104 pub fn has_logged_in(&self) -> bool {
105 self.logged_in
106 }
107
108 #[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
141fn 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}