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, body_text
350 );
351 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}