Skip to main content

egs_api/api/
cosmos.rs

1use crate::api::error::EpicAPIError;
2use crate::api::types::cosmos::{
3    CosmosAccount, CosmosAuthResponse, CosmosCommOptIn, CosmosEulaResponse, CosmosSearchResults,
4    CosmosPolicyAodc,
5};
6use crate::api::types::engine_blob::EngineBlobsResponse;
7use crate::api::EpicAPI;
8use log::{debug, error, warn};
9use serde::Deserialize;
10
11#[derive(Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13struct RedirectResponse {
14    #[allow(dead_code)]
15    redirect_url: Option<String>,
16    #[allow(dead_code)]
17    authorization_code: Option<serde_json::Value>,
18    sid: Option<String>,
19}
20
21impl EpicAPI {
22    /// Set up a Cosmos cookie session from an exchange code.
23    ///
24    /// Performs the full web-based flow:
25    /// 1. `GET /id/api/reputation` — sets XSRF-TOKEN cookie
26    /// 2. `POST /id/api/exchange` — sends exchange code with XSRF
27    /// 3. `GET /id/api/redirect` — gets SID
28    /// 4. `GET /id/api/set-sid?sid=X` — sets session cookies on unrealengine.com
29    /// 5. `GET /api/cosmos/auth` — upgrades bearer token, sets EPIC_EG1 JWTs
30    ///
31    /// After success, all Cosmos endpoints (`cosmos_*` methods)
32    /// work automatically via cookies in the client's cookie jar.
33    pub async fn cosmos_session_setup(
34        &self,
35        exchange_code: &str,
36    ) -> Result<CosmosAuthResponse, EpicAPIError> {
37        let rep_response = self
38            .client
39            .get("https://www.epicgames.com/id/api/reputation")
40            .send()
41            .await
42            .map_err(|e| {
43                error!("Failed to get XSRF token: {:?}", e);
44                EpicAPIError::NetworkError(e)
45            })?;
46
47        let xsrf = rep_response
48            .cookies()
49            .find(|c| c.name() == "XSRF-TOKEN")
50            .map(|c| c.value().to_string())
51            .ok_or_else(|| {
52                error!("XSRF-TOKEN cookie not found in reputation response");
53                EpicAPIError::InvalidCredentials
54            })?;
55
56        let exchange_body = serde_json::json!({"exchangeCode": exchange_code});
57        let exchange_resp = self
58            .client
59            .post("https://www.epicgames.com/id/api/exchange")
60            .json(&exchange_body)
61            .header("x-xsrf-token", &xsrf)
62            .send()
63            .await
64            .map_err(|e| {
65                error!("Failed exchange code: {:?}", e);
66                EpicAPIError::NetworkError(e)
67            })?;
68
69        if !exchange_resp.status().is_success() {
70            let status = exchange_resp.status();
71            let body = exchange_resp.text().await.unwrap_or_default();
72            warn!("Exchange failed: {} {}", status, body);
73            return Err(EpicAPIError::HttpError { status, body });
74        }
75
76        let redirect_resp = self
77            .client
78            .get("https://www.epicgames.com/id/api/redirect?")
79            .send()
80            .await
81            .map_err(|e| {
82                error!("Failed redirect: {:?}", e);
83                EpicAPIError::NetworkError(e)
84            })?;
85
86        let redirect: RedirectResponse = redirect_resp.json().await.map_err(|e| {
87            error!("Failed to parse redirect response: {:?}", e);
88            EpicAPIError::DeserializationError(format!("{}", e))
89        })?;
90
91        let sid = redirect.sid.ok_or_else(|| {
92            error!("No SID in redirect response");
93            EpicAPIError::InvalidCredentials
94        })?;
95
96        let set_sid_resp = self
97            .client
98            .get(format!(
99                "https://www.unrealengine.com/id/api/set-sid?sid={}",
100                sid
101            ))
102            .send()
103            .await
104            .map_err(|e| {
105                error!("Failed set-sid: {:?}", e);
106                EpicAPIError::NetworkError(e)
107            })?;
108        debug!("set-sid status={}", set_sid_resp.status());
109
110        self.cosmos_auth_upgrade().await
111    }
112
113    /// Upgrade the session by calling `GET /api/cosmos/auth`.
114    ///
115    /// Converts base session cookies (from `set-sid`) into upgraded EPIC_EG1 JWTs.
116    pub async fn cosmos_auth_upgrade(&self) -> Result<CosmosAuthResponse, EpicAPIError> {
117        let response = self
118            .client
119            .get("https://www.unrealengine.com/api/cosmos/auth")
120            .header("Accept", "application/json")
121            .send()
122            .await
123            .map_err(|e| {
124                error!("Failed cosmos auth: {:?}", e);
125                EpicAPIError::NetworkError(e)
126            })?;
127
128        if response.status().is_success() {
129            response.json::<CosmosAuthResponse>().await.map_err(|e| {
130                error!("Failed to parse cosmos auth response: {:?}", e);
131                EpicAPIError::DeserializationError(format!("{}", e))
132            })
133        } else {
134            let status = response.status();
135            let body = response.text().await.unwrap_or_default();
136            warn!("cosmos/auth failed: {} {}", status, body);
137            Err(EpicAPIError::HttpError { status, body })
138        }
139    }
140
141    /// Check if a EULA has been accepted.
142    ///
143    /// Requires an active Cosmos session (call `cosmos_session_setup` first).
144    /// Known EULA IDs: `unreal_engine`, `unreal_engine2`, `realityscan`, `mhc`, `content`.
145    pub async fn cosmos_eula_check(
146        &self,
147        eula_id: &str,
148        locale: &str,
149    ) -> Result<CosmosEulaResponse, EpicAPIError> {
150        let url = format!(
151            "https://www.unrealengine.com/api/cosmos/eula/accept?eulaId={}&locale={}",
152            eula_id, locale
153        );
154        let response = self
155            .client
156            .get(&url)
157            .header("Accept", "application/json")
158            .send()
159            .await
160            .map_err(|e| {
161                error!("Failed EULA check: {:?}", e);
162                EpicAPIError::NetworkError(e)
163            })?;
164
165        if response.status().is_success() {
166            response.json::<CosmosEulaResponse>().await.map_err(|e| {
167                error!("Failed to parse EULA response: {:?}", e);
168                EpicAPIError::DeserializationError(format!("{}", e))
169            })
170        } else {
171            let status = response.status();
172            let body = response.text().await.unwrap_or_default();
173            warn!("EULA check failed: {} {}", status, body);
174            Err(EpicAPIError::HttpError { status, body })
175        }
176    }
177
178    /// Accept a EULA.
179    ///
180    /// Requires an active Cosmos session. The web UI sends
181    /// `eulaId=unreal_engine2&locale=en&version=3`.
182    pub async fn cosmos_eula_accept(
183        &self,
184        eula_id: &str,
185        locale: &str,
186        version: u32,
187    ) -> Result<CosmosEulaResponse, EpicAPIError> {
188        let url = format!(
189            "https://www.unrealengine.com/api/cosmos/eula/accept?eulaId={}&locale={}&version={}",
190            eula_id, locale, version
191        );
192        let response = self
193            .client
194            .post(&url)
195            .header("Accept", "application/json")
196            .send()
197            .await
198            .map_err(|e| {
199                error!("Failed EULA accept: {:?}", e);
200                EpicAPIError::NetworkError(e)
201            })?;
202
203        if response.status().is_success() {
204            response.json::<CosmosEulaResponse>().await.map_err(|e| {
205                error!("Failed to parse EULA accept response: {:?}", e);
206                EpicAPIError::DeserializationError(format!("{}", e))
207            })
208        } else {
209            let status = response.status();
210            let body = response.text().await.unwrap_or_default();
211            warn!("EULA accept failed: {} {}", status, body);
212            Err(EpicAPIError::HttpError { status, body })
213        }
214    }
215
216    /// Get Cosmos account details. Requires an active Cosmos session.
217    pub async fn cosmos_account(&self) -> Result<CosmosAccount, EpicAPIError> {
218        let response = self
219            .client
220            .get("https://www.unrealengine.com/api/cosmos/account")
221            .header("Accept", "application/json")
222            .send()
223            .await
224            .map_err(|e| {
225                error!("Failed cosmos account: {:?}", e);
226                EpicAPIError::NetworkError(e)
227            })?;
228
229        if response.status().is_success() {
230            response.json::<CosmosAccount>().await.map_err(|e| {
231                error!("Failed to parse cosmos account response: {:?}", e);
232                EpicAPIError::DeserializationError(format!("{}", e))
233            })
234        } else {
235            let status = response.status();
236            let body = response.text().await.unwrap_or_default();
237            warn!("cosmos/account failed: {} {}", status, body);
238            Err(EpicAPIError::HttpError { status, body })
239        }
240    }
241
242    /// Check Age of Digital Consent policy. Requires an active Cosmos session.
243    pub async fn cosmos_policy_aodc(&self) -> Result<CosmosPolicyAodc, EpicAPIError> {
244        let response = self
245            .client
246            .get("https://www.unrealengine.com/api/cosmos/policy/aodc")
247            .header("Accept", "application/json")
248            .send()
249            .await
250            .map_err(|e| {
251                error!("Failed cosmos policy check: {:?}", e);
252                EpicAPIError::NetworkError(e)
253            })?;
254
255        if response.status().is_success() {
256            response.json::<CosmosPolicyAodc>().await.map_err(|e| {
257                error!("Failed to parse policy response: {:?}", e);
258                EpicAPIError::DeserializationError(format!("{}", e))
259            })
260        } else {
261            let status = response.status();
262            let body = response.text().await.unwrap_or_default();
263            warn!("cosmos/policy/aodc failed: {} {}", status, body);
264            Err(EpicAPIError::HttpError { status, body })
265        }
266    }
267
268    /// Check communication opt-in status.
269    ///
270    /// Known settings: `email:ue` (Unreal Engine), likely also `email:fn` (Fortnite).
271    /// Requires an active Cosmos session.
272    pub async fn cosmos_comm_opt_in(
273        &self,
274        setting: &str,
275    ) -> Result<CosmosCommOptIn, EpicAPIError> {
276        let url = format!(
277            "https://www.unrealengine.com/api/cosmos/communication/opt-in?setting={}",
278            setting
279        );
280        let response = self
281            .client
282            .get(&url)
283            .header("Accept", "application/json")
284            .send()
285            .await
286            .map_err(|e| {
287                error!("Failed cosmos comm opt-in: {:?}", e);
288                EpicAPIError::NetworkError(e)
289            })?;
290
291        if response.status().is_success() {
292            response.json::<CosmosCommOptIn>().await.map_err(|e| {
293                error!("Failed to parse comm opt-in response: {:?}", e);
294                EpicAPIError::DeserializationError(format!("{}", e))
295            })
296        } else {
297            let status = response.status();
298            let body = response.text().await.unwrap_or_default();
299            warn!("cosmos/communication/opt-in failed: {} {}", status, body);
300            Err(EpicAPIError::HttpError { status, body })
301        }
302    }
303
304    /// Fetch engine version blobs (download URLs) for a platform.
305    ///
306    /// Requires an active Cosmos session (cookies from set-sid + cosmos/auth).
307    /// Platform values: `linux`, `windows`, `mac`
308    pub async fn engine_versions(
309        &self,
310        platform: &str,
311    ) -> Result<EngineBlobsResponse, EpicAPIError> {
312        let url = format!("https://www.unrealengine.com/api/blobs/{}", platform);
313        let response = self
314            .client
315            .get(&url)
316            .header("Accept", "application/json")
317            .send()
318            .await
319            .map_err(|e| {
320                error!("Failed engine versions: {:?}", e);
321                EpicAPIError::NetworkError(e)
322            })?;
323
324        if response.status().is_success() {
325            response.json::<EngineBlobsResponse>().await.map_err(|e| {
326                error!("Failed to parse engine versions response: {:?}", e);
327                EpicAPIError::DeserializationError(format!("{}", e))
328            })
329        } else {
330            let status = response.status();
331            let body = response.text().await.unwrap_or_default();
332            warn!("blobs/{} failed: {} {}", platform, status, body);
333            Err(EpicAPIError::HttpError { status, body })
334        }
335    }
336
337    /// Search unrealengine.com content. Requires an active Cosmos session.
338    pub async fn cosmos_search(
339        &self,
340        query: &str,
341        slug: Option<&str>,
342        locale: Option<&str>,
343        filter: Option<&str>,
344    ) -> Result<CosmosSearchResults, EpicAPIError> {
345        let mut url = format!(
346            "https://www.unrealengine.com/api/cosmos/search?query={}",
347            query
348        );
349        if let Some(s) = slug {
350            url.push_str(&format!("&slug={}", s));
351        }
352        if let Some(l) = locale {
353            url.push_str(&format!("&locale={}", l));
354        }
355        if let Some(f) = filter {
356            url.push_str(&format!("&filter={}", f));
357        }
358        let response = self
359            .client
360            .get(&url)
361            .header("Accept", "application/json")
362            .send()
363            .await
364            .map_err(|e| {
365                error!("Failed cosmos search: {:?}", e);
366                EpicAPIError::NetworkError(e)
367            })?;
368
369        if response.status().is_success() {
370            response
371                .json::<CosmosSearchResults>()
372                .await
373                .map_err(|e| {
374                    error!("Failed to parse cosmos search response: {:?}", e);
375                    EpicAPIError::DeserializationError(format!("{}", e))
376                })
377        } else {
378            let status = response.status();
379            let body = response.text().await.unwrap_or_default();
380            warn!("cosmos/search failed: {} {}", status, body);
381            Err(EpicAPIError::HttpError { status, body })
382        }
383    }
384}