use std::sync::Arc;
use serde_json::Value;
use crate::cache::{Cache, CacheHandle, CacheKey, CacheMode, Fetched};
use crate::config::{Config, DEFAULT_CDP, HydraMode};
use crate::error::{Error, ErrorKind, Result};
use crate::http::{Allowlist, Client, Hydra, Request};
use crate::jwt;
use crate::session::{LOCATION_COOKIE, Session};
use crate::store::SessionStore;
use crate::{cdp, hydra, sso};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResolvedHydra {
Native,
Cdp,
}
pub struct Context {
pub config: Config,
session: Session,
store: Arc<dyn SessionStore>,
client: Arc<Client>,
allow: Allowlist,
hydra_skew: i64,
hydra_key: Option<String>,
cache: CacheHandle,
refresh_tried: bool,
}
const REFRESH_MARGIN_SECS: i64 = 15 * 60;
impl Context {
pub async fn new(
config: Config,
store: Arc<dyn SessionStore>,
cache: Arc<dyn Cache>,
) -> Result<Self> {
let session = store.load().await?.ok_or_else(|| {
Error::new(
ErrorKind::AuthMissing,
"no Grab session: log in first (`grab auth login`, or `grab auth import`)",
)
})?;
let allow = Allowlist::new();
let client = Arc::new(Client::new(&session, &config, allow.clone())?);
let cache = CacheHandle::new(cache, config.cache.clone(), CacheMode::Normal);
Ok(Self {
cache,
config,
session,
store,
client,
allow,
hydra_skew: 30,
hydra_key: None,
refresh_tried: false,
})
}
pub fn with_allowlist(mut self, allow: &Allowlist) -> Result<Self> {
self.allow.extend(allow);
self.rebuild_client()?;
Ok(self)
}
#[must_use]
pub fn with_hydra_key(mut self, pem: Option<String>) -> Self {
self.hydra_key = pem;
self
}
fn rebuild_client(&mut self) -> Result<()> {
self.client = Arc::new(Client::new(
&self.session,
&self.config,
self.allow.clone(),
)?);
Ok(())
}
async fn persist(&mut self) -> Result<()> {
self.store.save(&self.session).await?;
self.rebuild_client()
}
pub async fn set_session(&mut self, session: Session) -> Result<()> {
self.session = session;
self.refresh_tried = false;
self.persist().await
}
pub async fn refresh_session(&mut self) -> Result<()> {
self.refresh_tried = true;
let ua = self.user_agent();
sso::silent_refresh(&mut self.session, &ua).await?;
self.persist().await
}
fn user_agent(&self) -> String {
self.config
.user_agent
.clone()
.or_else(|| self.session.user_agent.clone())
.unwrap_or_else(|| crate::http::DEFAULT_UA.to_string())
}
pub async fn restore_address_from_browser(&mut self, cdp_base: &str) -> Result<bool> {
let (cookies, _) = cdp::default_context_cookies(cdp_base).await?;
if !self
.session
.replace_named_cookies(LOCATION_COOKIE, &cookies)
{
return Ok(false);
}
self.persist().await?;
Ok(true)
}
pub async fn set_location(&mut self, loc: &Value) -> Result<()> {
let json = serde_json::to_string(loc).map_err(|e| Error::parse(e.to_string()))?;
let value = urlencoding::encode(&json).into_owned();
self.session.set_location_cookie(&value);
self.persist().await
}
pub async fn after_login(&mut self) -> Vec<String> {
let mut notes = Vec::new();
if self.session.cookie(LOCATION_COOKIE).is_none() {
let base = self.cdp_base();
match self.restore_address_from_browser(&base).await {
Ok(true) => notes.push("copied the saved delivery address from the browser".into()),
Ok(false) => notes.push(
"no saved delivery address in the session or the browser; set one on \
food.grab.com, then `grab auth import --address-only`"
.into(),
),
Err(_) => notes.push(
"no saved delivery address and the browser was unreachable; later run \
`grab auth import --address-only`"
.into(),
),
}
}
if !self.has_mart_session()
&& let Err(e) = self.login_mart().await
{
notes.push(if e.kind == ErrorKind::AuthMissing {
format!("GrabMart: {}", e.message)
} else {
format!(
"GrabMart session not set up now ({}); `grab mart quote` retries it",
e.kind.as_str()
)
});
}
if !self.session.hydra_valid(self.hydra_skew)
&& let Err(e) = self.mint_hydra().await
{
notes.push(format!(
"hydra not minted now ({}); commands mint it on demand",
e.kind.as_str()
));
}
notes
}
pub async fn login_mart(&mut self) -> Result<()> {
let ua = self.user_agent();
sso::silent_login(sso::Service::Mart, &mut self.session, &ua).await?;
self.persist().await
}
pub fn has_mart_session(&self) -> bool {
!sso::mart_session_cookies(&self.session).is_empty()
}
fn session_expiring(&self) -> bool {
self.session.gfc_logged_in()
&& self
.session
.gfc_expires_at()
.is_some_and(|e| e - jwt::now() < REFRESH_MARGIN_SECS)
}
#[must_use]
pub fn with_cache_mode(mut self, mode: CacheMode) -> Self {
self.cache = self.cache.with_mode(mode);
self
}
pub fn set_cache_mode(&mut self, mode: CacheMode) {
self.cache = self.cache.with_mode(mode);
}
pub fn cache(&self) -> &CacheHandle {
&self.cache
}
pub async fn cached_call(&mut self, key: &CacheKey, req: &Request) -> Result<Value> {
let cache = self.cache.clone();
cache.get_or_fetch(key, || self.call(req)).await
}
pub async fn cached_call_meta(
&mut self,
key: &CacheKey,
req: &Request,
) -> Result<Fetched<Value>> {
let cache = self.cache.clone();
cache.get_or_fetch_meta(key, || self.call(req)).await
}
pub fn session(&self) -> &Session {
&self.session
}
pub fn cdp_base(&self) -> String {
self.config
.cdp
.clone()
.unwrap_or_else(|| DEFAULT_CDP.to_string())
}
fn resolve_hydra_mode(&self) -> ResolvedHydra {
let key_present = cfg!(feature = "native-hydra") && self.hydra_key_pem().is_some();
match self.config.hydra_mode.unwrap_or_default() {
HydraMode::Native => ResolvedHydra::Native,
HydraMode::Cdp => ResolvedHydra::Cdp,
HydraMode::Auto => {
if key_present {
ResolvedHydra::Native
} else {
ResolvedHydra::Cdp
}
}
}
}
fn hydra_key_pem(&self) -> Option<String> {
self.hydra_key.clone()
}
pub async fn ensure_hydra(&mut self) -> Result<String> {
if self.session.hydra_valid(self.hydra_skew)
&& let Some(tok) = &self.session.hydra_jwt
{
return Ok(tok.clone());
}
self.mint_hydra().await
}
pub async fn mint_hydra(&mut self) -> Result<String> {
match self.resolve_hydra_mode() {
ResolvedHydra::Native => {
let pem = self.hydra_key_pem().ok_or_else(|| {
Error::new(
ErrorKind::HydraExpired,
"native hydra minting needs a signing key (the CLI reads \
<config>/hydra-key.pem, see docs/hydra.md); or set \
hydra_mode = \"cdp\"",
)
})?;
let ver = hydra::DEFAULT_VER;
let (tok, exp) = hydra::mint_native(&pem, &hydra::DeviceFields::random(), ver)?;
self.session.hydra_jwt = Some(tok.clone());
self.session.hydra_exp = Some(exp);
self.store.save(&self.session).await?;
Ok(tok)
}
ResolvedHydra::Cdp => {
let base = self.cdp_base();
let page = self
.config
.hydra_page
.clone()
.unwrap_or_else(|| "https://food.grab.com/ph/en/".to_string());
cdp::mint_hydra(&base, &page, &mut self.session).await?;
self.store.save(&self.session).await?;
self.session.hydra_jwt.clone().ok_or_else(|| {
Error::new(ErrorKind::HydraExpired, "hydra mint produced no token")
})
}
}
}
pub async fn call(&mut self, req: &Request) -> Result<Value> {
if self.config.auto_refresh() && !self.refresh_tried && self.session_expiring() {
let _ = self.refresh_session().await;
}
let token = if req.hydra == Hydra::Required {
Some(self.ensure_hydra().await?)
} else {
None
};
match self.client.send(req, token.as_deref()).await {
Err(e)
if e.kind == ErrorKind::HydraExpired
&& req.hydra == Hydra::Required
&& self.config.auto_refresh() =>
{
let fresh = self.mint_hydra().await?;
self.client.send(req, Some(&fresh)).await
}
Err(e)
if e.kind == ErrorKind::AuthExpired
&& req.refreshable
&& !self.refresh_tried
&& self.config.auto_refresh() =>
{
self.refresh_session().await?;
let token = if req.hydra == Hydra::Required {
Some(self.ensure_hydra().await?)
} else {
None
};
self.client.send(req, token.as_deref()).await
}
other => other,
}
}
pub fn auth_status(&self) -> Value {
let now = jwt::now();
let loc = crate::location::Location::from_session(&self.session).ok();
let cookies: Vec<Value> = self
.session
.cookies
.iter()
.map(|c| {
let exp = if c.expires > 0.0 {
Some(c.expires as i64)
} else {
None
};
serde_json::json!({
"name": c.name,
"domain": c.domain,
"expires": exp,
"expires_in_secs": exp.map(|e| e - now),
"expired": exp.is_some_and(|e| e <= now),
})
})
.collect();
serde_json::json!({
"cookies": cookies,
"gfc_session_present": self.session.cookie("gfc_session").is_some(),
"gfc_session_logged_in": self.session.gfc_logged_in(),
"gfc_session_exp": self.session.gfc_expires_at(),
"gfc_session_expires_in_secs": self.session.gfc_expires_at().map(|e| e - now),
"hydra_exp": self.session.hydra_exp,
"hydra_expires_in_secs": self.session.hydra_exp.map(|e| e - now),
"hydra_valid": self.session.hydra_valid(self.hydra_skew),
"web_app_version_present": self.session.web_app_version.is_some(),
"payment_type_id_present":
self.session.payment_type_id.is_some() || self.config.payment_type_id.is_some(),
"has_saved_address": self.session.cookie("location").is_some(),
"address_detail": loc.as_ref().and_then(|l| l.address_detail.clone()),
"note_to_driver": loc.as_ref().and_then(|l| l.note_to_driver.clone()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::MemoryCache;
#[cfg(feature = "native-hydra")]
use crate::config::HydraMode;
use crate::store::MemorySessionStore;
fn store_with(session: Option<Session>) -> Arc<MemorySessionStore> {
Arc::new(MemorySessionStore::new(session))
}
async fn ctx(store: &Arc<MemorySessionStore>, config: Config) -> Result<Context> {
Context::new(config, store.clone(), Arc::new(MemoryCache::default())).await
}
#[tokio::test]
async fn new_needs_a_stored_session() {
let e = ctx(&store_with(None), Config::default()).await.err();
assert_eq!(e.map(|e| e.kind), Some(ErrorKind::AuthMissing));
}
#[tokio::test]
async fn location_change_is_saved_through_the_store() -> Result<()> {
let store = store_with(Some(Session::default()));
let mut c = ctx(&store, Config::default()).await?;
assert_eq!(store.save_count(), 0);
c.set_location(&serde_json::json!({"latitude": 1.0, "longitude": 2.0}))
.await?;
assert_eq!(store.save_count(), 1);
let saved = store.get().ok_or_else(|| Error::invalid("none"))?;
assert!(saved.cookie(LOCATION_COOKIE).is_some());
Ok(())
}
#[cfg(feature = "native-hydra")]
#[tokio::test]
async fn hydra_mint_is_saved_through_the_store() -> Result<()> {
let (pem, _) = crate::hydra::tests::test_key();
let store = store_with(Some(Session::default()));
let config = Config {
hydra_mode: Some(HydraMode::Native),
..Config::default()
};
let mut c = ctx(&store, config).await?.with_hydra_key(Some(pem));
let tok = c.ensure_hydra().await?;
assert_eq!(store.save_count(), 1);
let saved = store.get().ok_or_else(|| Error::invalid("none"))?;
assert_eq!(saved.hydra_jwt.as_deref(), Some(tok.as_str()));
c.ensure_hydra().await?;
assert_eq!(store.save_count(), 1);
Ok(())
}
#[tokio::test]
async fn failed_refresh_saves_nothing() -> Result<()> {
let store = store_with(Some(Session::default()));
let mut c = ctx(&store, Config::default()).await?;
let e = c.refresh_session().await.err();
assert_eq!(e.map(|e| e.kind), Some(ErrorKind::AuthExpired));
assert_eq!(store.save_count(), 0);
Ok(())
}
#[tokio::test]
async fn set_session_replaces_and_saves() -> Result<()> {
let store = store_with(Some(Session::default()));
let mut c = ctx(&store, Config::default()).await?;
let s = Session {
web_app_version: Some("build-2".into()),
..Session::default()
};
c.set_session(s).await?;
assert_eq!(c.session().web_app_version.as_deref(), Some("build-2"));
assert_eq!(store.save_count(), 1);
Ok(())
}
}