funpay_client/client/
http.rs

1use crate::client::urls::UrlBuilder;
2use crate::client::FunpayGateway;
3use crate::config::FunPayConfig;
4use crate::error::FunPayError;
5use crate::models::OfferSaveRequest;
6use async_trait::async_trait;
7use reqwest::{header, redirect::Policy, Client, StatusCode};
8use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
9use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
10use serde_json::Value;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13pub struct ReqwestGateway {
14    pub client: ClientWithMiddleware,
15    pub urls: UrlBuilder,
16}
17
18impl ReqwestGateway {
19    pub fn new() -> Self {
20        Self::with_config(&FunPayConfig::default())
21    }
22
23    pub fn with_config(config: &FunPayConfig) -> Self {
24        let retry_policy = ExponentialBackoff::builder()
25            .base(config.retry_base_ms)
26            .build_with_max_retries(config.max_retries);
27
28        let client = ClientBuilder::new(
29            Client::builder()
30                .redirect(Policy::limited(config.redirect_limit))
31                .build()
32                .unwrap(),
33        )
34        .with(RetryTransientMiddleware::new_with_policy(retry_policy))
35        .build();
36
37        Self {
38            client,
39            urls: UrlBuilder::new(&config.base_url),
40        }
41    }
42
43    pub fn with_proxy(proxy_url: &str) -> Self {
44        Self::with_proxy_and_config(proxy_url, &FunPayConfig::default())
45    }
46
47    pub fn with_proxy_and_config(proxy_url: &str, config: &FunPayConfig) -> Self {
48        fn normalize_proxy_url(raw: &str) -> String {
49            if raw.contains('@') {
50                return raw.to_string();
51            }
52            if let Some((scheme, rest)) = raw.split_once("://") {
53                let parts: Vec<&str> = rest.split(':').collect();
54                if parts.len() == 4 {
55                    let host = parts[0];
56                    let port = parts[1];
57                    let user = parts[2];
58                    let pass = parts[3];
59                    return format!("{scheme}://{user}:{pass}@{host}:{port}");
60                }
61            }
62            raw.to_string()
63        }
64
65        let retry_policy = ExponentialBackoff::builder()
66            .base(config.retry_base_ms)
67            .build_with_max_retries(config.max_retries);
68
69        let normalized = normalize_proxy_url(proxy_url);
70        let client = ClientBuilder::new(
71            Client::builder()
72                .redirect(Policy::limited(config.redirect_limit))
73                .proxy(reqwest::Proxy::all(&normalized).expect("invalid proxy url"))
74                .build()
75                .expect("failed to build reqwest client with proxy"),
76        )
77        .with(RetryTransientMiddleware::new_with_policy(retry_policy))
78        .build();
79
80        Self {
81            client,
82            urls: UrlBuilder::new(&config.base_url),
83        }
84    }
85
86    fn add_common_headers(
87        &self,
88        builder: reqwest_middleware::RequestBuilder,
89        golden_key: &str,
90        user_agent: &str,
91        phpsessid: Option<&str>,
92    ) -> reqwest_middleware::RequestBuilder {
93        let cookie = if let Some(sess) = phpsessid {
94            format!("golden_key={golden_key}; cookie_prefs=1; PHPSESSID={sess}")
95        } else {
96            format!("golden_key={golden_key}; cookie_prefs=1")
97        };
98
99        builder
100            .header(header::COOKIE, cookie)
101            .header(header::USER_AGENT, user_agent)
102    }
103
104    async fn execute(
105        &self,
106        builder: reqwest_middleware::RequestBuilder,
107    ) -> Result<reqwest::Response, FunPayError> {
108        let resp = builder.send().await?;
109        if resp.status() == StatusCode::FORBIDDEN {
110            return Err(FunPayError::Unauthorized);
111        }
112        if !resp.status().is_success() {
113            let status = resp.status();
114            let url = resp.url().to_string();
115            let body = resp.text().await.unwrap_or_default();
116            return Err(FunPayError::RequestFailed { status, body, url });
117        }
118        Ok(resp)
119    }
120}
121
122impl Default for ReqwestGateway {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128#[async_trait]
129impl FunpayGateway for ReqwestGateway {
130    async fn get_home(
131        &self,
132        golden_key: &str,
133        user_agent: &str,
134    ) -> Result<(String, Vec<String>), FunPayError> {
135        let url = self.urls.home();
136        let req = self.client.get(&url);
137        let req = self.add_common_headers(req, golden_key, user_agent, None);
138        let resp = self.execute(req).await?;
139
140        let set_cookies: Vec<String> = resp
141            .headers()
142            .get_all(header::SET_COOKIE)
143            .iter()
144            .filter_map(|v| v.to_str().ok().map(|s| s.to_string()))
145            .collect();
146        let body = resp.text().await?;
147        Ok((body, set_cookies))
148    }
149
150    async fn get_chat_page(
151        &self,
152        golden_key: &str,
153        user_agent: &str,
154        chat_id: &str,
155    ) -> Result<(String, Vec<String>), FunPayError> {
156        let chat_url = self.urls.chat_page(chat_id);
157        let req = self.client.get(&chat_url).header(header::ACCEPT, "*/*");
158        let req = self.add_common_headers(req, golden_key, user_agent, None);
159        let resp = self.execute(req).await?;
160
161        let set_cookies: Vec<String> = resp
162            .headers()
163            .get_all(header::SET_COOKIE)
164            .iter()
165            .filter_map(|v| v.to_str().ok().map(|s| s.to_string()))
166            .collect();
167        let body = resp.text().await.unwrap_or_default();
168        Ok((body, set_cookies))
169    }
170
171    async fn get_orders_trade(
172        &self,
173        golden_key: &str,
174        user_agent: &str,
175    ) -> Result<String, FunPayError> {
176        let url = self.urls.orders_trade();
177        let req = self.client.get(&url).header(header::ACCEPT, "*/*");
178        let req = self.add_common_headers(req, golden_key, user_agent, None);
179        let resp = self.execute(req).await?;
180        let body = resp.text().await?;
181        Ok(body)
182    }
183
184    async fn get_order_page(
185        &self,
186        golden_key: &str,
187        user_agent: &str,
188        order_id: &str,
189    ) -> Result<String, FunPayError> {
190        let url = self.urls.order_page(order_id);
191        let req = self.client.get(&url).header(header::ACCEPT, "*/*");
192        let req = self.add_common_headers(req, golden_key, user_agent, None);
193        let resp = self.execute(req).await?;
194        let body = resp.text().await?;
195        Ok(body)
196    }
197
198    async fn post_runner(
199        &self,
200        golden_key: &str,
201        user_agent: &str,
202        csrf: &str,
203        phpsessid: Option<&str>,
204        objects_json: &str,
205        request_json: Option<&str>,
206    ) -> Result<Value, FunPayError> {
207        let url = self.urls.runner();
208        let payload = match request_json {
209            Some(req) => format!(
210                "objects={}&request={}&csrf_token={}",
211                urlencoding::encode(objects_json),
212                urlencoding::encode(req),
213                urlencoding::encode(csrf)
214            ),
215            None => format!(
216                "objects={}&request=false&csrf_token={}",
217                urlencoding::encode(objects_json),
218                urlencoding::encode(csrf)
219            ),
220        };
221
222        let req = self
223            .client
224            .post(&url)
225            .header(
226                header::CONTENT_TYPE,
227                "application/x-www-form-urlencoded; charset=UTF-8",
228            )
229            .header("x-requested-with", "XMLHttpRequest")
230            .header(header::ACCEPT, "*/*")
231            .header(header::ORIGIN, self.urls.base_url())
232            .header(header::REFERER, format!("{}/chat/", self.urls.base_url()))
233            .body(payload);
234
235        let req = self.add_common_headers(req, golden_key, user_agent, phpsessid);
236        let resp = self.execute(req).await?;
237        let v: Value = resp.json().await?;
238        Ok(v)
239    }
240
241    async fn post_offer_save(&self, request: OfferSaveRequest<'_>) -> Result<Value, FunPayError> {
242        let url = self.urls.offer_save();
243        let form_created_at = SystemTime::now()
244            .duration_since(UNIX_EPOCH)
245            .map(|d| d.as_secs())
246            .unwrap_or(0);
247
248        let field = |key: &str, val: Option<&str>| {
249            format!(
250                "{}={}",
251                urlencoding::encode(key),
252                urlencoding::encode(val.unwrap_or(""))
253            )
254        };
255
256        let mut form_parts = vec![
257            format!("csrf_token={}", urlencoding::encode(request.csrf)),
258            format!("form_created_at={form_created_at}"),
259            format!("offer_id={}", request.offer_id),
260            format!("node_id={}", request.node_id),
261            field("location", request.params.location.as_deref()),
262            format!(
263                "deleted={}",
264                if request.params.deleted.unwrap_or(false) {
265                    "1"
266                } else {
267                    ""
268                }
269            ),
270            field("fields[quantity]", request.params.quantity.as_deref()),
271            field("fields[quantity2]", request.params.quantity2.as_deref()),
272            field("fields[method]", request.params.method.as_deref()),
273            field("fields[type]", request.params.offer_type.as_deref()),
274            field("server_id", request.params.server_id.as_deref()),
275            field("fields[desc][ru]", request.params.desc_ru.as_deref()),
276            field("fields[desc][en]", request.params.desc_en.as_deref()),
277            field(
278                "fields[payment_msg][ru]",
279                request.params.payment_msg_ru.as_deref(),
280            ),
281            field(
282                "fields[payment_msg][en]",
283                request.params.payment_msg_en.as_deref(),
284            ),
285            field("fields[summary][ru]", request.params.summary_ru.as_deref()),
286            field("fields[summary][en]", request.params.summary_en.as_deref()),
287            field("fields[game]", request.params.game.as_deref()),
288            field("fields[images]", request.params.images.as_deref()),
289            field("price", request.params.price.as_deref()),
290        ];
291
292        if request.params.deactivate_after_sale.unwrap_or(false) {
293            form_parts.push(field("deactivate_after_sale[]", None));
294            form_parts.push(field("deactivate_after_sale[]", Some("on")));
295        } else {
296            form_parts.push(field("deactivate_after_sale", None));
297        }
298
299        if request.params.active.unwrap_or(true) {
300            form_parts.push(field("active", Some("on")));
301        } else {
302            form_parts.push(field("active", None));
303        }
304
305        let payload = form_parts.join("&");
306        let referer = self.urls.offer_edit(request.node_id, request.offer_id);
307
308        log::debug!(
309            target: "funpay_client",
310            "POST {} | offer_id={} node_id={} price={:?}\nPayload: {}",
311            url,
312            request.offer_id,
313            request.node_id,
314            request.params.price,
315            payload
316        );
317
318        let req = self
319            .client
320            .post(&url)
321            .header(
322                header::CONTENT_TYPE,
323                "application/x-www-form-urlencoded; charset=UTF-8",
324            )
325            .header("x-requested-with", "XMLHttpRequest")
326            .header(
327                header::ACCEPT,
328                "application/json, text/javascript, */*; q=0.01",
329            )
330            .header(header::ORIGIN, self.urls.base_url())
331            .header(header::REFERER, referer)
332            .body(payload);
333
334        let req = self.add_common_headers(
335            req,
336            request.golden_key,
337            request.user_agent,
338            request.phpsessid,
339        );
340        let resp = self.execute(req).await?;
341
342        let status = resp.status();
343        let body_text = resp.text().await.unwrap_or_default();
344        log::info!(
345            target: "funpay_client",
346            "Response from offerSave: status={} body={}",
347            status, // accessing status after check is fine since it's Copy, but I should've saved it or execute return logic...
348            // Wait, execute returns Response.
349            body_text
350        );
351        // execute already checks status.
352
353        let v: Value = serde_json::from_str(&body_text).unwrap_or(Value::Null);
354        Ok(v)
355    }
356
357    async fn get_offer_edit_page(
358        &self,
359        golden_key: &str,
360        user_agent: &str,
361        node_id: i64,
362        offer_id: i64,
363    ) -> Result<String, FunPayError> {
364        let url = self.urls.offer_edit(node_id, offer_id);
365        let req = self.client.get(&url).header(header::ACCEPT, "*/*");
366        let req = self.add_common_headers(req, golden_key, user_agent, None);
367        let resp = self.execute(req).await?;
368        let body = resp.text().await?;
369        Ok(body)
370    }
371
372    async fn get_lots_trade_page(
373        &self,
374        golden_key: &str,
375        user_agent: &str,
376        node_id: i64,
377    ) -> Result<String, FunPayError> {
378        let url = self.urls.lots_trade(node_id);
379        let req = self.client.get(&url).header(header::ACCEPT, "*/*");
380        let req = self.add_common_headers(req, golden_key, user_agent, None);
381        let resp = self.execute(req).await?;
382        let body = resp.text().await?;
383        Ok(body)
384    }
385
386    async fn get_lots_page(
387        &self,
388        golden_key: &str,
389        user_agent: &str,
390        node_id: i64,
391    ) -> Result<String, FunPayError> {
392        let url = self.urls.lots_page(node_id);
393        let req = self.client.get(&url).header(header::ACCEPT, "*/*");
394        let req = self.add_common_headers(req, golden_key, user_agent, None);
395        let resp = self.execute(req).await?;
396        let body = resp.text().await?;
397        Ok(body)
398    }
399}