Skip to content

Commit

Permalink
Accept and notify for EULA (#429)
Browse files Browse the repository at this point in the history
* Notify when corrective action is required

* Get EULA status from account API. Remove deprecated cookie functions.

* Fix double notification

* Use backend API to check and accept EULA

* Attempt device auth refresh first

* Always set access token as bearer token

* More logging for login
  • Loading branch information
claabs authored Feb 28, 2025
1 parent a5c7626 commit c64d897
Show file tree
Hide file tree
Showing 25 changed files with 307 additions and 148 deletions.
10 changes: 10 additions & 0 deletions src/common/config/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,16 @@ export class AppConfig {
@IsString()
errorsDir = process.env.ERRORS_DIR || path.join(CONFIG_DIR, 'errors');

/**
* If false, EULA updates will be automatically accepted. If true, the user will receive a notification to accept the EULA.
* @example true
* @default false
* @env NOTIFY_EULA
*/
@IsBoolean()
@IsOptional()
notifyEula = process.env.NOTIFY_EULA?.toLowerCase() === 'true' || false;

/**
* @hidden
*/
Expand Down
3 changes: 3 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ export const ACCOUNT_OAUTH_TOKEN =
export const ACCOUNT_OAUTH_DEVICE_AUTH =
'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization';
export const ID_LOGIN_ENDPOINT = 'https://www.epicgames.com/id/login';
export const EULA_AGREEMENTS_ENDPOINT =
'https://eulatracking-public-service-prod-m.ol.epicgames.com/eulatracking/api/public/agreements';
export const REQUIRED_EULAS = ['epicgames_privacy_policy_no_table', 'egstore'];
4 changes: 2 additions & 2 deletions src/common/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fsx from 'fs-extra/esm';
import fs from 'node:fs';
import filenamify from 'filenamify';
import objectAssignDeep from 'object-assign-deep';
import { Protocol } from 'puppeteer';
import { Cookie } from 'puppeteer';
import path from 'path';
import L from './logger.js';
import { CONFIG_DIR } from './config/index.js';
Expand Down Expand Up @@ -140,7 +140,7 @@ export function setCookie(username: string, key: string, value: string): void {
);
}

export function setPuppeteerCookies(username: string, newCookies: Protocol.Network.Cookie[]): void {
export function setPuppeteerCookies(username: string, newCookies: Cookie[]): void {
const cookieJar = getCookieJar(username);
newCookies.forEach((cookie) => {
const domain = cookie.domain.replace(/^\./, '');
Expand Down
23 changes: 14 additions & 9 deletions src/common/puppeteer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import _puppeteer, { PuppeteerExtra } from 'puppeteer-extra';
import { Page, Browser, executablePath, CookieParam } from 'puppeteer';
import { Page, Browser, executablePath, Cookie } from 'puppeteer';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import { Logger } from 'pino';
import pTimeout, { TimeoutError } from 'p-timeout';
Expand All @@ -20,28 +20,33 @@ puppeteer.use(stealth);

export default puppeteer;

export function toughCookieFileStoreToPuppeteerCookie(tcfs: ToughCookieFileStore): CookieParam[] {
const puppetCookies: CookieParam[] = [];
export function toughCookieFileStoreToPuppeteerCookie(tcfs: ToughCookieFileStore): Cookie[] {
const puppetCookies: Cookie[] = [];
Object.values(tcfs).forEach((domain) => {
Object.values(domain).forEach((urlPath) => {
Object.values(urlPath).forEach((tcfsCookie) => {
const name = tcfsCookie.key;
const { value } = tcfsCookie;
const size = name.length + value.length;
puppetCookies.push({
name: tcfsCookie.key,
value: tcfsCookie.value,
expires: tcfsCookie.expires ? new Date(tcfsCookie.expires).getTime() / 1000 : undefined,
name,
value,
expires: tcfsCookie.expires ? new Date(tcfsCookie.expires).getTime() / 1000 : -1,
domain: `${!tcfsCookie.hostOnly ? '.' : ''}${tcfsCookie.domain}`,
path: tcfsCookie.path,
secure: tcfsCookie.secure,
httpOnly: tcfsCookie.httpOnly,
secure: tcfsCookie.secure ?? false,
httpOnly: tcfsCookie.httpOnly ?? true,
sameSite: 'Lax',
session: !tcfsCookie.expires,
size,
});
});
});
});
return puppetCookies;
}

export function puppeteerCookieToEditThisCookie(puppetCookies: CookieParam[]): ETCCookie[] {
export function puppeteerCookieToEditThisCookie(puppetCookies: Cookie[]): ETCCookie[] {
return puppetCookies.map(
(puppetCookie, index): ETCCookie => ({
domain: puppetCookie.domain || '',
Expand Down
23 changes: 15 additions & 8 deletions src/device-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,15 +172,15 @@ export class DeviceLogin {
pendingRedirects.delete(reqId);
}

private async notify(reason: NotificationReason, inUrl?: string): Promise<void> {
let url: string | undefined;
if (inUrl) {
if (config.webPortalConfig?.localtunnel) {
url = await getLocaltunnelUrl(inUrl);
} else {
url = inUrl;
}
private async notify(reason: NotificationReason, inUrl: string): Promise<void> {
let url: string;

if (config.webPortalConfig?.localtunnel) {
url = await getLocaltunnelUrl(inUrl);
} else {
url = inUrl;
}

this.L.info({ reason, url }, 'Dispatching notification');
await sendNotification(this.user, reason, url);
}
Expand Down Expand Up @@ -227,6 +227,13 @@ export class DeviceLogin {
public async refreshDeviceAuth(): Promise<boolean> {
try {
const existingAuth = getAccountAuth(this.user);
this.L.trace(
{
existingAuthRefreshExpiry: existingAuth?.refresh_expires_at,
existingAuthAccessExpiry: existingAuth?.expires_at,
},
'Pre-refresh auth expiry',
);
if (!(existingAuth && new Date(existingAuth.refresh_expires_at) > new Date())) return false;

const reqConfig: AxiosRequestConfig = {
Expand Down
106 changes: 106 additions & 0 deletions src/eula-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import axios from 'axios';
import { Logger } from 'pino';
import { EULA_AGREEMENTS_ENDPOINT, REQUIRED_EULAS, STORE_HOMEPAGE } from './common/constants.js';
import logger from './common/logger.js';
import { getAccountAuth } from './common/device-auths.js';
import { config } from './common/config/setup.js';
import { generateLoginRedirect } from './purchase.js';
import { sendNotification } from './notify.js';
import { NotificationReason } from './interfaces/notification-reason.js';

export interface EulaVersion {
key: string;
version: number;
locale: string;
}

export interface EulaAgreementResponse {
key: string;
version: number;
revision: number;
title: string;
body: string;
locale: string;
createdTimestamp: string;
lastModifiedTimestamp: string;
status: string;
description: string;
custom: boolean;
url: string;
wasDeclined: boolean;
operatorId: string;
notes: string;
hasResponse: boolean;
}

export class EulaManager {
private accountId: string;

private accessToken: string;

private email: string;

private L: Logger;

constructor(email: string) {
this.L = logger.child({ user: email });
const deviceAuth = getAccountAuth(email);
if (!deviceAuth) throw new Error('Device auth not found');
this.accountId = deviceAuth.account_id;
this.accessToken = deviceAuth.access_token;
this.email = email;
}

public async checkEulaStatus(): Promise<void> {
this.L.debug('Checking EULA status');
const pendingEulas = await this.fetchPendingEulas();

if (pendingEulas.length) {
if (config.notifyEula) {
this.L.error('User needs to log in an accept an updated EULA');
const actionUrl = generateLoginRedirect(STORE_HOMEPAGE);
await sendNotification(this.email, NotificationReason.PRIVACY_POLICY_ACCEPTANCE, actionUrl);
throw new Error(`${this.email} needs to accept an updated EULA`);
} else {
this.L.info({ pendingEulas }, 'Accepting EULAs');
await this.acceptEulas(pendingEulas);
}
}
}

private async fetchPendingEulas() {
const eulaStatuses: (EulaVersion | undefined)[] = await Promise.all(
REQUIRED_EULAS.map(async (key) => {
const url = `${EULA_AGREEMENTS_ENDPOINT}/${key}/account/${this.accountId}`;
this.L.trace({ url }, 'Checking EULA status');
const response = await axios.get<EulaAgreementResponse | undefined>(url, {
headers: { Authorization: `Bearer ${this.accessToken}` },
});
if (!response.data) return undefined;
this.L.debug({ key }, 'EULA is not accepted');
return {
key,
version: response.data.version,
locale: response.data.locale,
};
}),
);
const pendingEulas = eulaStatuses.filter((eula): eula is EulaVersion => eula !== undefined);
this.L.trace({ pendingEulas }, 'Pending EULAs');
return pendingEulas;
}

private async acceptEulas(eulaVersions: EulaVersion[]): Promise<void> {
await Promise.all(
eulaVersions.map(async (eulaVersion) => {
const url = `${EULA_AGREEMENTS_ENDPOINT}/${eulaVersion.key}/version/${eulaVersion.version}/account/${this.accountId}/accept`;
this.L.trace({ url }, 'Accepting EULA');
await axios.post(url, undefined, {
params: { locale: eulaVersion.locale },
headers: { Authorization: `Bearer ${this.accessToken}` },
});
this.L.debug({ key: eulaVersion.key }, 'EULA accepted');
}),
);
}
}
17 changes: 14 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { convertImportCookies } from './common/cookie.js';
import { DeviceLogin } from './device-login.js';
import { generateCheckoutUrl } from './purchase.js';
import { NotificationReason } from './interfaces/notification-reason.js';
import { EulaManager } from './eula-manager.js';

export async function redeemAccount(account: AccountConfig): Promise<void> {
const L = logger.child({ user: account.email });
Expand All @@ -32,14 +33,24 @@ export async function redeemAccount(account: AccountConfig): Promise<void> {
});

// Login
let successfulLogin = await cookieLogin.refreshCookieLogin();
let usedDeviceAuth = false;
// attempt token refresh
let successfulLogin = await deviceLogin.refreshDeviceAuth();
L.trace({ successfulLogin }, 'Device auth refresh result');
usedDeviceAuth = successfulLogin;
if (!successfulLogin) {
// attempt token refresh
successfulLogin = await deviceLogin.refreshDeviceAuth();
successfulLogin = await cookieLogin.refreshCookieLogin();
L.trace({ successfulLogin }, 'Cookie auth refresh result');
}
if (!successfulLogin) {
// get new device auth
await deviceLogin.newDeviceAuthLogin();
usedDeviceAuth = true;
}

if (usedDeviceAuth) {
const eulaManager = new EulaManager(account.email);
await eulaManager.checkEulaStatus();
}

// Get purchasable offers
Expand Down
3 changes: 1 addition & 2 deletions src/interfaces/notification-reason.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export enum NotificationReason {
LOGIN = 'LOGIN',
PURCHASE = 'PURCHASE',
CREATE_ACCOUNT = 'CREATE_ACCOUNT',
TEST = 'TEST',
PURCHASE_ERROR = 'PURCHASE ERROR',
PRIVACY_POLICY_ACCEPTANCE = 'PRIVACY_POLICY_ACCEPTANCE',
}
10 changes: 3 additions & 7 deletions src/notifiers/apprise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class AppriseNotifier extends NotifierService {
/**
* @ignore
*/
async sendNotification(account: string, reason: NotificationReason, url?: string): Promise<void> {
async sendNotification(account: string, reason: NotificationReason, url: string): Promise<void> {
const L = logger.child({ user: account, reason });
L.trace('Sending Apprise notification');

Expand All @@ -25,12 +25,8 @@ export class AppriseNotifier extends NotifierService {
title: 'epicgames-freegames-node',
body: `epicgames-freegames-node needs an action performed.
reason: ${reason}
account: ${account}${
url
? `
url: ${url}`
: ''
}`,
account: ${account}
url: ${url}`,
format: 'text', // The text format is ugly, but all the platforms support it.
type: 'info',
};
Expand Down
6 changes: 2 additions & 4 deletions src/notifiers/bark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,15 @@ export class BarkNotifier extends NotifierService {
this.config = config;
}

async sendNotification(account: string, reason: NotificationReason, url?: string): Promise<void> {
async sendNotification(account: string, reason: NotificationReason, url: string): Promise<void> {
const L = logger.child({ user: account, reason });
L.trace('Sending Bark notification');

const message = encodeURIComponent(`reason: ${reason}, account: ${account}`);

const requestUrl = `${this.config.apiUrl}/${encodeURIComponent(
this.config.key,
)}/${encodeURIComponent(this.config.title)}/${message}?${
url ? `url=${encodeURIComponent(url)}&` : ''
}group=${encodeURIComponent(this.config.group)}`;
)}/${encodeURIComponent(this.config.title)}/${message}?url=${encodeURIComponent(url)}&group=${encodeURIComponent(this.config.group)}`;

L.trace({ requestUrl }, 'Sending request');
try {
Expand Down
4 changes: 2 additions & 2 deletions src/notifiers/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class DiscordNotifier extends NotifierService {
this.config = config;
}

async sendNotification(account: string, reason: NotificationReason, url?: string): Promise<void> {
async sendNotification(account: string, reason: NotificationReason, url: string): Promise<void> {
const L = logger.child({ user: account, reason });
L.trace('Sending discord notification');

Expand All @@ -28,7 +28,7 @@ export class DiscordNotifier extends NotifierService {
await axios.post(
this.config.webhookUrl,
{
content: `${mentions}epicgames-freegames-node needs an action performed. ${this.config.showUrl && url ? `\n${url}` : ''}`,
content: `${mentions}epicgames-freegames-node needs an action performed. ${this.config.showUrl ? `\n${url}` : ''}`,
embeds: [
{
fields: [
Expand Down
2 changes: 1 addition & 1 deletion src/notifiers/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class EmailNotifier extends NotifierService {
});
}

async sendNotification(account: string, reason: NotificationReason, url?: string): Promise<void> {
async sendNotification(account: string, reason: NotificationReason, url: string): Promise<void> {
const L = logger.child({ user: account, reason });
L.trace('Sending email');

Expand Down
2 changes: 1 addition & 1 deletion src/notifiers/gotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class GotifyNotifier extends NotifierService {
this.config = config;
}

async sendNotification(account: string, reason: NotificationReason, url?: string): Promise<void> {
async sendNotification(account: string, reason: NotificationReason, url: string): Promise<void> {
const L = logger.child({ user: account, reason });
L.trace('Sending Gotify notification');
const jsonPayload = {
Expand Down
2 changes: 1 addition & 1 deletion src/notifiers/homeassistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class HomeassistantNotifier extends NotifierService {
this.config = config;
}

async sendNotification(account: string, reason: NotificationReason, url?: string): Promise<void> {
async sendNotification(account: string, reason: NotificationReason, url: string): Promise<void> {
const L = logger.child({ user: account, reason });
L.trace('Sending homeassistant notification');

Expand Down
6 changes: 2 additions & 4 deletions src/notifiers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ export class LocalNotifier extends NotifierService {
async sendNotification(
_account: string,
_reason: NotificationReason,
url?: string,
url: string,
): Promise<void> {
if (url) {
await open(url);
}
await open(url);
}
}
2 changes: 1 addition & 1 deletion src/notifiers/notifier-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export abstract class NotifierService {
abstract sendNotification(
account: string,
reason: NotificationReason,
url?: string,
url: string,
): Promise<void>;
}
2 changes: 1 addition & 1 deletion src/notifiers/ntfy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class NtfyNotifier extends NotifierService {
this.config = config;
}

async sendNotification(account: string, reason: NotificationReason, url?: string): Promise<void> {
async sendNotification(account: string, reason: NotificationReason, url: string): Promise<void> {
const L = logger.child({ user: account, reason });
L.trace('Sending Ntfy notification');

Expand Down
Loading

0 comments on commit c64d897

Please sign in to comment.