Skip to content

Commit 20f3296

Browse files
committed
Use backend API to check and accept EULA
1 parent ddae480 commit 20f3296

File tree

7 files changed

+139
-67
lines changed

7 files changed

+139
-67
lines changed

src/common/config/classes.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,16 @@ export class AppConfig {
981981
@IsString()
982982
errorsDir = process.env.ERRORS_DIR || path.join(CONFIG_DIR, 'errors');
983983

984+
/**
985+
* If false, EULA updates will be automatically accepted. If true, the user will receive a notification to accept the EULA.
986+
* @example true
987+
* @default false
988+
* @env NOTIFY_EULA
989+
*/
990+
@IsBoolean()
991+
@IsOptional()
992+
notifyEula = process.env.NOTIFY_EULA?.toLowerCase() === 'true' || false;
993+
984994
/**
985995
* @hidden
986996
*/

src/common/constants.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@ export const ACCOUNT_OAUTH_TOKEN =
4545
export const ACCOUNT_OAUTH_DEVICE_AUTH =
4646
'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization';
4747
export const ID_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/login';
48-
export const ACCOUNT_EULA_HISTORY_ENDPOINT =
49-
'https://www.epicgames.com/account/v2/eula/acceptance-history';
48+
export const EULA_AGREEMENTS_ENDPOINT =
49+
'https://eulatracking-public-service-prod-m.ol.epicgames.com/eulatracking/api/public/agreements';
50+
export const REQUIRED_EULAS = ['epicgames_privacy_policy_no_table', 'egstore'];

src/eula-manager.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import axios from 'axios';
2+
import { Logger } from 'pino';
3+
import { EULA_AGREEMENTS_ENDPOINT, REQUIRED_EULAS, STORE_HOMEPAGE } from './common/constants.js';
4+
import logger from './common/logger.js';
5+
import { getAccountAuth } from './common/device-auths.js';
6+
import { config } from './common/config/setup.js';
7+
import { generateLoginRedirect } from './purchase.js';
8+
import { sendNotification } from './notify.js';
9+
import { NotificationReason } from './interfaces/notification-reason.js';
10+
11+
export interface EulaVersion {
12+
key: string;
13+
version: number;
14+
locale: string;
15+
}
16+
17+
export interface EulaAgreementResponse {
18+
key: string;
19+
version: number;
20+
revision: number;
21+
title: string;
22+
body: string;
23+
locale: string;
24+
createdTimestamp: string;
25+
lastModifiedTimestamp: string;
26+
status: string;
27+
description: string;
28+
custom: boolean;
29+
url: string;
30+
wasDeclined: boolean;
31+
operatorId: string;
32+
notes: string;
33+
hasResponse: boolean;
34+
}
35+
36+
export class EulaManager {
37+
private accountId: string;
38+
39+
private accessToken: string;
40+
41+
private email: string;
42+
43+
private L: Logger;
44+
45+
constructor(email: string) {
46+
this.L = logger.child({ user: email });
47+
const deviceAuth = getAccountAuth(email);
48+
if (!deviceAuth) throw new Error('Device auth not found');
49+
this.accountId = deviceAuth.account_id;
50+
this.accessToken = deviceAuth.access_token;
51+
this.email = email;
52+
}
53+
54+
public async checkEulaStatus(): Promise<void> {
55+
const pendingEulas = await this.fetchPendingEulas();
56+
57+
if (pendingEulas.length) {
58+
if (config.notifyEula) {
59+
this.L.error('User needs to log in an accept an updated EULA');
60+
const actionUrl = generateLoginRedirect(STORE_HOMEPAGE);
61+
await sendNotification(this.email, NotificationReason.PRIVACY_POLICY_ACCEPTANCE, actionUrl);
62+
throw new Error(`${this.email} needs to accept an updated EULA`);
63+
} else {
64+
this.L.info({ pendingEulas }, 'Accepting EULAs');
65+
await this.acceptEulas(pendingEulas);
66+
}
67+
}
68+
}
69+
70+
private async fetchPendingEulas() {
71+
const eulaStatuses: (EulaVersion | undefined)[] = await Promise.all(
72+
REQUIRED_EULAS.map(async (key) => {
73+
const url = `${EULA_AGREEMENTS_ENDPOINT}/${key}/account/${this.accountId}`;
74+
this.L.trace({ url }, 'Checking EULA status');
75+
const response = await axios.get<EulaAgreementResponse | undefined>(url, {
76+
headers: { Authorization: `Bearer ${this.accessToken}` },
77+
});
78+
if (!response.data) return undefined;
79+
this.L.debug({ key }, 'EULA is not accepted');
80+
return {
81+
key,
82+
version: response.data.version,
83+
locale: response.data.locale,
84+
};
85+
}),
86+
);
87+
const pendingEulas = eulaStatuses.filter((eula): eula is EulaVersion => eula !== undefined);
88+
this.L.trace({ pendingEulas }, 'Pending EULAs');
89+
return pendingEulas;
90+
}
91+
92+
private async acceptEulas(eulaVersions: EulaVersion[]): Promise<void> {
93+
await Promise.all(
94+
eulaVersions.map(async (eulaVersion) => {
95+
const url = `${EULA_AGREEMENTS_ENDPOINT}/${eulaVersion.key}/version/${eulaVersion.version}/account/${this.accountId}/accept`;
96+
this.L.trace({ url }, 'Accepting EULA');
97+
await axios.post(url, undefined, {
98+
params: { locale: eulaVersion.locale },
99+
headers: { Authorization: `Bearer ${this.accessToken}` },
100+
});
101+
this.L.debug({ key: eulaVersion.key }, 'EULA accepted');
102+
}),
103+
);
104+
}
105+
}

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { convertImportCookies } from './common/cookie.js';
1212
import { DeviceLogin } from './device-login.js';
1313
import { generateCheckoutUrl } from './purchase.js';
1414
import { NotificationReason } from './interfaces/notification-reason.js';
15+
import { EulaManager } from './eula-manager.js';
1516

1617
export async function redeemAccount(account: AccountConfig): Promise<void> {
1718
const L = logger.child({ user: account.email });
@@ -32,14 +33,22 @@ export async function redeemAccount(account: AccountConfig): Promise<void> {
3233
});
3334

3435
// Login
36+
let usedDeviceAuth = false;
3537
let successfulLogin = await cookieLogin.refreshCookieLogin();
3638
if (!successfulLogin) {
3739
// attempt token refresh
3840
successfulLogin = await deviceLogin.refreshDeviceAuth();
41+
usedDeviceAuth = true;
3942
}
4043
if (!successfulLogin) {
4144
// get new device auth
4245
await deviceLogin.newDeviceAuthLogin();
46+
usedDeviceAuth = true;
47+
}
48+
49+
if (usedDeviceAuth) {
50+
const eulaManager = new EulaManager(account.email);
51+
await eulaManager.checkEulaStatus();
4352
}
4453

4554
// Get purchasable offers

src/interfaces/accounts.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

src/puppet/base.ts

Lines changed: 7 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,7 @@ import {
1111
import { getCookiesRaw, setPuppeteerCookies, userHasValidCookie } from '../common/cookie.js';
1212
import { config } from '../common/config/index.js';
1313
import { getAccountAuth } from '../common/device-auths.js';
14-
import {
15-
ACCOUNT_EULA_HISTORY_ENDPOINT,
16-
STORE_CART_EN,
17-
STORE_HOMEPAGE,
18-
} from '../common/constants.js';
19-
import { generateLoginRedirect } from '../purchase.js';
20-
import { sendNotification } from '../notify.js';
21-
import { NotificationReason } from '../interfaces/notification-reason.js';
22-
import { EulaResponse } from '../interfaces/accounts.js';
14+
import { STORE_CART_EN } from '../common/constants.js';
2315

2416
export interface PuppetBaseProps {
2517
browser: Browser;
@@ -100,34 +92,11 @@ export default class PuppetBase {
10092
return resp;
10193
}
10294

103-
private async checkEula(): Promise<void> {
104-
const REQUIRED_EULAS = ['epicgames_privacy_policy_no_table', 'egstore'];
105-
this.L.trace(
106-
{ url: ACCOUNT_EULA_HISTORY_ENDPOINT, requiredEulas: REQUIRED_EULAS },
107-
'Checking acount EULA history',
108-
);
109-
const resp = await this.request<EulaResponse>('GET', ACCOUNT_EULA_HISTORY_ENDPOINT);
110-
this.L.debug({ resp }, 'Eula history response');
111-
const acceptedEulaKeys = resp.data
112-
.filter((eulaEntry) => eulaEntry.accepted)
113-
.map((eulaEntry) => eulaEntry.key);
114-
const hasRequiredEulas = REQUIRED_EULAS.every((requiredKey) =>
115-
acceptedEulaKeys.includes(requiredKey),
116-
);
117-
118-
if (!hasRequiredEulas) {
119-
this.L.error('User needs to log in an accept an updated EULA');
120-
const actionUrl = generateLoginRedirect(STORE_HOMEPAGE);
121-
sendNotification(this.email, NotificationReason.PRIVACY_POLICY_ACCEPTANCE, actionUrl);
122-
throw new Error(`${this.email} needs to accept an updated EULA`);
123-
}
124-
}
125-
12695
protected async setupPage(): Promise<Page> {
12796
// Get cookies or latest access_token cookies
12897
let puppeteerCookies: Cookie[] = [];
12998
if (userHasValidCookie(this.email, 'EPIC_BEARER_TOKEN')) {
130-
this.L.debug('Setting auth from cookies');
99+
this.L.debug('Setting auth from bearer token cookies');
131100
const userCookies = await getCookiesRaw(this.email);
132101
puppeteerCookies = toughCookieFileStoreToPuppeteerCookie(userCookies);
133102
} else {
@@ -162,9 +131,8 @@ export default class PuppetBase {
162131
this.page = await safeNewPage(this.browser, this.L);
163132
try {
164133
this.L.trace(getDevtoolsUrl(this.page));
165-
await this.page.goto(STORE_CART_EN, { waitUntil: 'networkidle0' });
166-
await this.browser.setCookie(...puppeteerCookies); // must happen **after** navigating
167-
await this.checkEula();
134+
await this.browser.setCookie(...puppeteerCookies);
135+
await this.page.goto(STORE_CART_EN, { waitUntil: 'networkidle0' }); // Get EG1 cookie
168136
return this.page;
169137
} catch (err) {
170138
await this.handlePageError(err);
@@ -179,15 +147,15 @@ export default class PuppetBase {
179147
const currentCookies = await this.browser.cookies();
180148
setPuppeteerCookies(this.email, currentCookies);
181149
this.L.trace('Saved cookies, closing browser');
182-
await this.page.close();
150+
// await this.page.close(); // Getting `Protocol error (Target.createTarget): Target closed` with this for some reason
183151
this.page = undefined;
184152
} catch (err) {
185153
await this.handlePageError(err);
186154
}
187155
}
188156

189157
protected async handlePageError(err: unknown) {
190-
if (this.page) {
158+
if (this.page && !this.page.isClosed()) {
191159
const errorFile = `error-${new Date().toISOString()}.png`;
192160
await ensureDir(config.errorsDir);
193161
await this.page.screenshot({
@@ -198,8 +166,8 @@ export default class PuppetBase {
198166
'Encountered an error during browser automation. Saved a screenshot for debugging purposes.',
199167
);
200168
await this.page.close();
201-
this.page = undefined;
202169
}
170+
this.page = undefined;
203171
throw err;
204172
}
205173
}

src/puppet/login.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export default class PuppetLogin extends PuppetBase {
1717
if (!userHasValidCookie(this.email, 'EPIC_SSO_RM')) return false;
1818
try {
1919
if (!this.page) this.page = await this.setupPage();
20+
const url = generateLoginRedirect(STORE_CART_EN);
21+
this.L.trace({ url }, 'Visiting login cart redirect');
22+
await this.page.goto(url, {
23+
waitUntil: 'networkidle0',
24+
});
2025
const currentCookies = await this.browser.cookies();
2126
if (currentCookies.find((c) => c.name === 'EPIC_BEARER_TOKEN')) {
2227
this.L.debug('Successfully refreshed cookie auth');
@@ -39,11 +44,6 @@ export default class PuppetLogin extends PuppetBase {
3944
this.page = await safeNewPage(this.browser, this.L);
4045
try {
4146
this.L.trace(getDevtoolsUrl(this.page));
42-
const url = generateLoginRedirect(STORE_CART_EN);
43-
this.L.trace({ url }, 'Visiting login cart redirect');
44-
await this.page.goto(url, {
45-
waitUntil: 'networkidle0',
46-
});
4747
await this.browser.setCookie(...puppeteerCookies);
4848
return this.page;
4949
} catch (err) {

0 commit comments

Comments
 (0)