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