1use std::sync::Arc;
13
14use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
15use serde_json::Value;
16
17use crate::config::Config;
18use crate::error::{Error, ErrorKind, Result};
19use crate::session::Session;
20
21pub const DEFAULT_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
22 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36";
23
24#[derive(Debug, Clone)]
26pub enum Rule {
27 Exact(String),
29 Prefix(String),
32}
33
34impl Rule {
35 fn matches(&self, url_no_query: &str) -> bool {
36 match self {
37 Rule::Exact(u) => url_no_query == u,
38 Rule::Prefix(p) => url_no_query.starts_with(p),
39 }
40 }
41}
42
43#[derive(Debug, Default, Clone)]
45pub struct Allowlist {
46 rules: Vec<Rule>,
47}
48
49impl Allowlist {
50 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn allow_exact(&mut self, url: impl Into<String>) -> &mut Self {
55 self.rules.push(Rule::Exact(url.into()));
56 self
57 }
58
59 pub fn allow_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
60 self.rules.push(Rule::Prefix(prefix.into()));
61 self
62 }
63
64 pub fn is_allowed(&self, url: &str) -> bool {
65 let no_query = url.split('?').next().unwrap_or(url);
66 self.rules.iter().any(|r| r.matches(no_query))
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Hydra {
73 Required,
74 NotNeeded,
75}
76
77pub struct Request {
79 pub method: reqwest::Method,
80 pub url: String,
81 pub body: Option<Value>,
82 pub hydra: Hydra,
83 pub extra_headers: Vec<(String, String)>,
85 pub refreshable: bool,
88}
89
90impl Request {
91 pub fn get(url: impl Into<String>, hydra: Hydra) -> Self {
92 Self {
93 method: reqwest::Method::GET,
94 url: url.into(),
95 body: None,
96 hydra,
97 extra_headers: Vec::new(),
98 refreshable: true,
99 }
100 }
101
102 pub fn post(url: impl Into<String>, body: Value, hydra: Hydra) -> Self {
103 Self {
104 method: reqwest::Method::POST,
105 url: url.into(),
106 body: Some(body),
107 hydra,
108 extra_headers: Vec::new(),
109 refreshable: true,
110 }
111 }
112
113 pub fn no_refresh(mut self) -> Self {
116 self.refreshable = false;
117 self
118 }
119}
120
121#[derive(Debug, Clone)]
123pub struct RawCookie {
124 pub name: String,
125 pub value: String,
126 pub domain: String,
127 pub path: String,
128 pub expires: f64,
130 pub deleted: bool,
132}
133
134pub struct Client {
137 inner: reqwest::Client,
138 allow: Allowlist,
139 user_agent: String,
140 web_app_version: Option<String>,
141 session: Session,
144 gfc_session_key: Option<String>,
147}
148
149impl Client {
150 pub fn new(session: &Session, config: &Config, allow: Allowlist) -> Result<Self> {
152 let inner = reqwest::Client::builder()
153 .gzip(true)
154 .build()
155 .map_err(Error::from)?;
156 let user_agent = config
157 .user_agent
158 .clone()
159 .or_else(|| session.user_agent.clone())
160 .unwrap_or_else(|| DEFAULT_UA.to_string());
161 Ok(Self {
162 inner,
163 allow,
164 user_agent,
165 web_app_version: session.web_app_version.clone(),
166 session: session.clone(),
167 gfc_session_key: session.gfc_session_key().ok(),
168 })
169 }
170
171 pub fn allowlist(&self) -> &Allowlist {
172 &self.allow
173 }
174
175 fn headers(&self, req: &Request, hydra_jwt: Option<&str>) -> Result<HeaderMap> {
176 let mut h = HeaderMap::new();
177 let mut set = |k: &'static str, v: &str| -> Result<()> {
178 let val = HeaderValue::from_str(v)
179 .map_err(|e| Error::invalid(format!("bad header {k}: {e}")))?;
180 h.insert(HeaderName::from_static(k), val);
181 Ok(())
182 };
183 set("accept", "application/json, text/plain, */*")?;
184 set("accept-language", "en")?;
185 set("user-agent", &self.user_agent)?;
186 set("origin", "https://food.grab.com")?;
187 set("referer", "https://food.grab.com/")?;
188 set("x-gfc-country", "PH")?;
189 set("x-country-code", "PH")?;
190 if let Some(key) = &self.gfc_session_key {
191 set("x-gfc-session", key)?;
192 }
193 let cookie = self.session.cookie_header_for(&req.url);
194 if !cookie.is_empty() {
195 set("cookie", &cookie)?;
196 }
197 if let Some(v) = &self.web_app_version {
198 set("x-grab-web-app-version", v)?;
199 }
200 if req.body.is_some() {
201 set("content-type", "application/json;charset=UTF-8")?;
202 }
203 if req.hydra == Hydra::Required {
204 let jwt = hydra_jwt.ok_or_else(|| {
205 Error::new(ErrorKind::HydraExpired, "this call needs a hydra JWT")
206 })?;
207 set("x-hydra-jwt", jwt)?;
208 }
209 for (k, v) in &req.extra_headers {
210 let name = HeaderName::from_bytes(k.as_bytes())
211 .map_err(|e| Error::invalid(format!("bad header {k}: {e}")))?;
212 let val = HeaderValue::from_str(v)
213 .map_err(|e| Error::invalid(format!("bad header value: {e}")))?;
214 h.insert(name, val);
215 }
216 Ok(h)
217 }
218
219 pub async fn send(&self, req: &Request, hydra_jwt: Option<&str>) -> Result<Value> {
222 if !self.allow.is_allowed(&req.url) {
223 let path = req.url.split('?').next().unwrap_or(&req.url).to_string();
224 return Err(Error::new(
225 ErrorKind::UrlNotAllowed,
226 format!("refusing non-allowlisted URL: {path}"),
227 ));
228 }
229 let headers = self.headers(req, hydra_jwt)?;
230 let mut builder = self
231 .inner
232 .request(req.method.clone(), &req.url)
233 .headers(headers);
234 if let Some(body) = &req.body {
235 builder = builder.json(body);
236 }
237 let resp = builder.send().await?;
238 let status = resp.status();
239 let bytes = resp.bytes().await?;
240
241 if status == reqwest::StatusCode::UNAUTHORIZED {
242 return Err(Error::new(
243 ErrorKind::AuthExpired,
244 "401 from Grab: the food.grab.com session has expired; run `grab auth refresh` (or `grab auth login` if that fails)",
245 ));
246 }
247 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
248 return Err(Error::new(
249 ErrorKind::RateLimited,
250 "429 from Grab: slow down and retry later",
251 ));
252 }
253
254 let value: Value = serde_json::from_slice(&bytes).map_err(|_| {
255 Error::new(
256 ErrorKind::Api,
257 format!(
258 "{} from {} with non-JSON body",
259 status.as_u16(),
260 req.url.split('?').next().unwrap_or(&req.url)
261 ),
262 )
263 })?;
264
265 if is_hydra_error(status, &value) {
268 return Err(Error::new(
269 ErrorKind::HydraExpired,
270 "Grab rejected the hydra JWT (invalid hydra jwt)",
271 ));
272 }
273 if !status.is_success() {
274 let msg = value
275 .get("message")
276 .or_else(|| value.get("reason"))
277 .and_then(Value::as_str)
278 .unwrap_or("request failed");
279 return Err(Error::new(
280 ErrorKind::Api,
281 format!("{} from Grab: {msg}", status.as_u16()),
282 ));
283 }
284 Ok(value)
285 }
286}
287
288pub fn parse_set_cookie(s: &str, host: &str) -> Option<RawCookie> {
295 parse_set_cookie_at(s, host, crate::jwt::now())
296}
297
298fn parse_set_cookie_at(s: &str, host: &str, now: i64) -> Option<RawCookie> {
299 let mut parts = s.split(';');
300 let nv = parts.next()?.trim();
301 let (name, value) = nv.split_once('=')?;
302 let name = name.trim();
303 if name.is_empty() {
304 return None;
305 }
306 let mut domain = host.to_ascii_lowercase();
307 let mut path = "/".to_string();
308 let mut max_age: Option<i64> = None;
309 let mut expires_at: Option<i64> = None;
310 for attr in parts {
311 let attr = attr.trim();
312 let Some((k, v)) = attr.split_once('=') else {
313 continue;
314 };
315 let v = v.trim();
316 match k.trim().to_ascii_lowercase().as_str() {
317 "domain" => {
318 let bare = v.trim_start_matches('.').to_ascii_lowercase();
319 if !bare.is_empty() {
320 domain = format!(".{bare}");
321 }
322 }
323 "path" if v.starts_with('/') => path = v.to_string(),
324 "max-age" => max_age = v.parse::<i64>().ok(),
325 "expires" => expires_at = parse_http_date(v),
326 _ => {}
327 }
328 }
329 let (expires, deleted) = match (max_age, expires_at) {
330 (Some(secs), _) => ((now + secs) as f64, secs <= 0),
331 (None, Some(at)) => (at as f64, at <= now),
332 (None, None) => (0.0, false),
333 };
334 Some(RawCookie {
335 name: name.to_string(),
336 value: value.trim().to_string(),
337 domain,
338 path,
339 expires,
340 deleted,
341 })
342}
343
344pub fn parse_http_date(s: &str) -> Option<i64> {
347 let s = s.trim();
348 let rest = s.split_once(',').map_or(s, |(_, r)| r).trim();
350 let mut fields = rest.split([' ', '-']).filter(|f| !f.is_empty());
351 let day: i64 = fields.next()?.parse().ok()?;
352 let mon = fields.next()?.to_ascii_lowercase();
353 let month = [
354 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
355 ]
356 .iter()
357 .position(|m| mon.starts_with(m))? as i64
358 + 1;
359 let mut year: i64 = fields.next()?.parse().ok()?;
360 if year < 100 {
361 year += if year < 70 { 2000 } else { 1900 };
362 }
363 let mut hms = fields.next()?.split(':').map(|x| x.parse::<i64>().ok());
364 let (h, m, sec) = (hms.next()??, hms.next()??, hms.next()??);
365 if !(1..=31).contains(&day) || h > 23 || m > 59 || sec > 60 {
366 return None;
367 }
368 Some(days_from_civil(year, month, day) * 86_400 + h * 3600 + m * 60 + sec)
369}
370
371fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
373 let y = if m <= 2 { y - 1 } else { y };
374 let era = y.div_euclid(400);
375 let yoe = y - era * 400;
376 let mp = (m + 9) % 12;
377 let doy = (153 * mp + 2) / 5 + d - 1;
378 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
379 era * 146_097 + doe - 719_468
380}
381
382fn is_hydra_error(status: reqwest::StatusCode, value: &Value) -> bool {
383 if status != reqwest::StatusCode::BAD_REQUEST {
384 return false;
385 }
386 let text = |k: &str| {
387 value
388 .get(k)
389 .and_then(Value::as_str)
390 .unwrap_or("")
391 .to_ascii_lowercase()
392 };
393 text("message").contains("hydra") || text("reason").contains("hydra")
394}
395
396pub type SharedClient = Arc<Client>;
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn allowlist_matches_exact_and_prefix_ignoring_query() {
405 let mut a = Allowlist::new();
406 a.allow_exact("https://food.grab.com/proxy/foodweb/v2/order/cart")
407 .allow_prefix("https://food.grab.com/proxy/foodweb/v2/order/merchants/");
408 assert!(a.is_allowed("https://food.grab.com/proxy/foodweb/v2/order/cart"));
409 assert!(a.is_allowed(
410 "https://food.grab.com/proxy/foodweb/v2/order/merchants/2-EXAMPLE?latlng=1,2"
411 ));
412 assert!(!a.is_allowed("https://food.grab.com/proxy/foodweb/v2/order/orders"));
414 assert!(!a.is_allowed("https://evil.example.com/"));
415 }
416
417 #[test]
418 fn hydra_error_detection() {
419 let v = serde_json::json!({"reason":"invalid_argument","message":"invalid hydra jwt"});
420 assert!(is_hydra_error(reqwest::StatusCode::BAD_REQUEST, &v));
421 assert!(!is_hydra_error(reqwest::StatusCode::OK, &v));
422 }
423
424 #[test]
425 fn parses_set_cookie_with_attrs() {
426 let c = parse_set_cookie_at(
427 "gfc_session=abc.def; Max-Age=7200; Path=/; Domain=food.grab.com; HttpOnly; Secure",
428 "food.grab.com",
429 1_000,
430 )
431 .expect("parse");
432 assert_eq!(c.name, "gfc_session");
433 assert_eq!(c.value, "abc.def");
434 assert_eq!(c.domain, ".food.grab.com");
435 assert_eq!(c.path, "/");
436 assert_eq!(c.expires, 8_200.0);
437 assert!(!c.deleted);
438 }
439
440 #[test]
441 fn parses_set_cookie_minimal_is_host_only_session() {
442 let c = parse_set_cookie_at("k=v", "api.grab.com", 1_000).expect("parse");
443 assert_eq!((c.name.as_str(), c.value.as_str()), ("k", "v"));
444 assert_eq!(c.domain, "api.grab.com");
445 assert_eq!(c.expires, 0.0);
446 assert!(parse_set_cookie("=nope", "food.grab.com").is_none());
447 }
448
449 #[test]
450 fn leading_dot_domain_is_kept_as_domain_cookie() {
451 for raw in [
452 "a=1; Domain=.grab.com",
453 "a=1; Domain=grab.com",
454 "a=1; domain=GRAB.com",
455 ] {
456 let c = parse_set_cookie_at(raw, "partner-api.grab.com", 0).expect("parse");
457 assert_eq!(c.domain, ".grab.com", "{raw}");
458 }
459 }
460
461 #[test]
462 fn expires_attribute_is_read() {
463 let c = parse_set_cookie_at(
465 "gfc_country=X; Max-Age=31536000; Path=/; Expires=Sun, 17 Oct 2027 14:58:04 GMT; HttpOnly",
466 "food.grab.com",
467 1_792_248_684,
468 )
469 .expect("parse");
470 assert_eq!(c.expires, (1_792_248_684 + 31_536_000) as f64);
472
473 let c = parse_set_cookie_at(
474 "next-i18next=en; path=/; expires=Sun, 17 Oct 2027 14:58:04 GMT",
475 "food.grab.com",
476 0,
477 )
478 .expect("parse");
479 assert_eq!(c.expires, 1_823_785_084.0);
480 assert!(!c.deleted);
481 }
482
483 #[test]
484 fn deletion_by_max_age_or_past_expires() {
485 let now = 1_792_248_684; let del = parse_set_cookie_at(
487 "gfc_session=; Max-Age=0; Path=/; Expires=Sat, 17 Oct 2026 14:58:04 GMT",
488 "food.grab.com",
489 now,
490 )
491 .expect("parse");
492 assert!(del.deleted);
493 let past = parse_set_cookie_at(
494 "x=1; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
495 "food.grab.com",
496 now,
497 )
498 .expect("parse");
499 assert!(past.deleted);
500 }
501
502 #[test]
503 fn http_dates() {
504 assert_eq!(parse_http_date("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0));
505 assert_eq!(
506 parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"),
507 Some(784_111_777)
508 );
509 assert_eq!(
510 parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT"),
511 Some(784_111_777)
512 );
513 assert_eq!(
514 parse_http_date("Sat, 29 Feb 2028 12:00:00 GMT"),
515 Some(1_835_438_400)
516 );
517 assert_eq!(parse_http_date("garbage"), None);
518 }
519}