Skip to content

Commit 250ec7c

Browse files
authored
Implement content and session PO tokens (#6931)
* Implement content and session PO tokens * Remove timeout after BgUtils update * Trigger GitHub CI
1 parent 6ee87be commit 250ec7c

File tree

5 files changed

+126
-79
lines changed

5 files changed

+126
-79
lines changed

src/botGuardScript.js

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,90 @@
1-
import { BG } from 'bgutils-js'
1+
import { BG, buildURL, GOOG_API_KEY } from 'bgutils-js'
22

33
// This script has it's own webpack config, as it gets passed as a string to Electron's evaluateJavaScript function
44
// in src/main/poTokenGenerator.js
5-
export default async function(visitorData) {
5+
6+
/**
7+
* Based on: https://github.com/LuanRT/BgUtils/blob/main/examples/node/innertube-challenge-fetcher-example.ts
8+
* @param {string} videoId
9+
* @param {string} visitorData
10+
* @param {import('youtubei.js').Session['context']} context
11+
*/
12+
export default async function (videoId, visitorData, context) {
613
const requestKey = 'O43z0dpjhgX20SCx4KAo'
714

8-
const bgConfig = {
9-
fetch: (input, init) => fetch(input, init),
10-
requestKey,
11-
globalObj: window,
12-
identifier: visitorData
15+
const challengeResponse = await fetch(
16+
'https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json',
17+
{
18+
method: 'POST',
19+
headers: {
20+
Accept: '*/*',
21+
'Content-Type': 'application/json',
22+
'X-Goog-Visitor-Id': visitorData,
23+
'X-Youtube-Client-Version': context.client.clientVersion,
24+
'X-Youtube-Client-Name': '1'
25+
},
26+
body: JSON.stringify({
27+
engagementType: 'ENGAGEMENT_TYPE_UNBOUND',
28+
context
29+
}),
30+
}
31+
)
32+
33+
if (!challengeResponse.ok) {
34+
throw new Error(`Request to ${challengeResponse.url} failed with status ${challengeResponse.status}\n${await challengeResponse.text()}`)
1335
}
1436

15-
const challenge = await BG.Challenge.create(bgConfig)
37+
const challengeData = await challengeResponse.json()
1638

17-
if (!challenge) {
18-
throw new Error('Could not get challenge')
39+
if (!challengeData.bgChallenge) {
40+
throw new Error('Failed to get BotGuard challenge')
1941
}
2042

21-
const interpreterJavascript = challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue
43+
let interpreterUrl = challengeData.bgChallenge.interpreterUrl.privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
44+
45+
if (interpreterUrl.startsWith('//')) {
46+
interpreterUrl = `https:${interpreterUrl}`
47+
}
48+
49+
const bgScriptResponse = await fetch(interpreterUrl)
50+
const interpreterJavascript = await bgScriptResponse.text()
2251

2352
if (interpreterJavascript) {
2453
// eslint-disable-next-line no-new-func
2554
new Function(interpreterJavascript)()
2655
} else {
27-
console.warn('Unable to load VM.')
56+
throw new Error('Could not load VM.')
2857
}
2958

30-
const poTokenResult = await BG.PoToken.generate({
31-
program: challenge.program,
32-
globalName: challenge.globalName,
33-
bgConfig
59+
const botGuard = await BG.BotGuardClient.create({
60+
program: challengeData.bgChallenge.program,
61+
globalName: challengeData.bgChallenge.globalName,
62+
globalObj: window
3463
})
3564

36-
return poTokenResult.poToken
65+
const webPoSignalOutput = []
66+
const botGuardResponse = await botGuard.snapshot({ webPoSignalOutput })
67+
68+
const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), {
69+
method: 'POST',
70+
headers: {
71+
'content-type': 'application/json+protobuf',
72+
'x-goog-api-key': GOOG_API_KEY,
73+
'x-user-agent': 'grpc-web-javascript/0.1',
74+
},
75+
body: JSON.stringify([requestKey, botGuardResponse])
76+
})
77+
78+
const response = await integrityTokenResponse.json()
79+
80+
if (typeof response[0] !== 'string') {
81+
throw new Error('Could not get integrity token')
82+
}
83+
84+
const integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: response[0] }, webPoSignalOutput)
85+
86+
const contentPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(videoId)
87+
const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(visitorData)
88+
89+
return { contentPoToken, sessionPoToken }
3790
}

src/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const IpcChannels = {
4545

4646
SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization',
4747

48-
GENERATE_PO_TOKEN: 'generate-po-token',
48+
GENERATE_PO_TOKENS: 'generate-po-tokens',
4949

5050
GET_SCREENSHOT_FALLBACK_FOLDER: 'get-screenshot-fallback-folder',
5151
CHOOSE_DEFAULT_FOLDER: 'choose-default-folder',

src/main/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -901,8 +901,8 @@ function runApp() {
901901
})
902902
})
903903

904-
ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (_, visitorData) => {
905-
return generatePoToken(visitorData, proxyUrl)
904+
ipcMain.handle(IpcChannels.GENERATE_PO_TOKENS, (_, videoId, visitorData, context) => {
905+
return generatePoToken(videoId, visitorData, context, proxyUrl)
906906
})
907907

908908
ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => {

src/main/poTokenGenerator.js

Lines changed: 36 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import { join } from 'path'
99
* This is intentionally split out into it's own thing, with it's own temporary in-memory session,
1010
* as the BotGuard stuff accesses the global `document` and `window` objects and also requires making some requests.
1111
* So we definitely don't want it running in the same places as the rest of the FreeTube code with the user data.
12+
* @param {string} videoId
1213
* @param {string} visitorData
14+
* @param {string} context
1315
* @param {string|undefined} proxyUrl
14-
* @returns {Promise<string>}
16+
* @returns {Promise<{ contentPoToken: string, sessionPoToken: string }>}
1517
*/
16-
export async function generatePoToken(visitorData, proxyUrl) {
18+
export async function generatePoToken(videoId, visitorData, context, proxyUrl) {
1719
const sessionUuid = crypto.randomUUID()
1820

1921
const theSession = session.fromPartition(`potoken-${sessionUuid}`, { cache: false })
@@ -22,27 +24,44 @@ export async function generatePoToken(visitorData, proxyUrl) {
2224
// eslint-disable-next-line n/no-callback-literal
2325
theSession.setPermissionRequestHandler((webContents, permission, callback) => callback(false))
2426

25-
theSession.setUserAgent(
26-
theSession.getUserAgent()
27-
.split(' ')
28-
.filter(part => !part.includes('Electron'))
29-
.join(' ')
30-
)
27+
theSession.setUserAgent(session.defaultSession.getUserAgent())
3128

3229
if (proxyUrl) {
3330
await theSession.setProxy({
3431
proxyRules: proxyUrl
3532
})
3633
}
3734

35+
theSession.webRequest.onBeforeSendHeaders({
36+
urls: ['https://www.google.com/js/*', 'https://www.youtube.com/youtubei/*']
37+
}, ({ requestHeaders, url }, callback) => {
38+
if (url.startsWith('https://www.youtube.com/youtubei/')) {
39+
// make InnerTube requests work with the fetch function
40+
// InnerTube rejects requests if the referer isn't YouTube or empty
41+
requestHeaders.Referer = 'https://www.youtube.com/'
42+
requestHeaders.Origin = 'https://www.youtube.com'
43+
44+
requestHeaders['Sec-Fetch-Site'] = 'same-origin'
45+
requestHeaders['Sec-Fetch-Mode'] = 'same-origin'
46+
requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false'
47+
} else {
48+
requestHeaders['Sec-Fetch-Dest'] = 'script'
49+
requestHeaders['Sec-Fetch-Site'] = 'cross-site'
50+
requestHeaders['Accept-Language'] = '*'
51+
}
52+
53+
callback({ requestHeaders })
54+
})
55+
3856
const webContentsView = new WebContentsView({
3957
webPreferences: {
4058
backgroundThrottling: false,
4159
safeDialogs: true,
4260
sandbox: true,
4361
v8CacheOptions: 'none',
4462
session: theSession,
45-
offscreen: true
63+
offscreen: true,
64+
webSecurity: false
4665
}
4766
})
4867

@@ -58,43 +77,8 @@ export async function generatePoToken(visitorData, proxyUrl) {
5877

5978
webContentsView.webContents.debugger.attach()
6079

61-
await webContentsView.webContents.loadURL('data:text/html,', {
62-
baseURLForDataURL: 'https://www.youtube.com'
63-
})
64-
65-
await webContentsView.webContents.debugger.sendCommand('Emulation.setUserAgentOverride', {
66-
userAgent: theSession.getUserAgent(),
67-
acceptLanguage: 'en-US',
68-
platform: 'Win32',
69-
userAgentMetadata: {
70-
brands: [
71-
{
72-
brand: 'Not/A)Brand',
73-
version: '99'
74-
},
75-
{
76-
brand: 'Chromium',
77-
version: process.versions.chrome.split('.')[0]
78-
}
79-
],
80-
fullVersionList: [
81-
{
82-
brand: 'Not/A)Brand',
83-
version: '99.0.0.0'
84-
},
85-
{
86-
brand: 'Chromium',
87-
version: process.versions.chrome
88-
}
89-
],
90-
platform: 'Windows',
91-
platformVersion: '10.0.0',
92-
architecture: 'x86',
93-
model: '',
94-
mobile: false,
95-
bitness: '64',
96-
wow64: false
97-
}
80+
await webContentsView.webContents.loadURL('data:text/html,<!DOCTYPE html><html lang="en"><head><title></title></head><body></body></html>', {
81+
baseURLForDataURL: 'https://www.youtube.com/'
9882
})
9983

10084
await webContentsView.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
@@ -112,7 +96,7 @@ export async function generatePoToken(visitorData, proxyUrl) {
11296
}
11397
})
11498

115-
const script = await getScript(visitorData)
99+
const script = await getScript(videoId, visitorData, context)
116100

117101
const response = await webContentsView.webContents.executeJavaScript(script)
118102

@@ -125,9 +109,11 @@ export async function generatePoToken(visitorData, proxyUrl) {
125109
let cachedScript
126110

127111
/**
112+
* @param {string} videoId
128113
* @param {string} visitorData
114+
* @param {string} context
129115
*/
130-
async function getScript(visitorData) {
116+
async function getScript(videoId, visitorData, context) {
131117
if (!cachedScript) {
132118
const pathToScript = process.env.NODE_ENV === 'development'
133119
? join(__dirname, '../../dist/botGuardScript.js')
@@ -140,8 +126,8 @@ async function getScript(visitorData) {
140126

141127
const functionName = match[1]
142128

143-
cachedScript = content.replace(match[0], `;${functionName}("FT_VISITOR_DATA")`)
129+
cachedScript = content.replace(match[0], `;${functionName}(FT_PARAMS)`)
144130
}
145131

146-
return cachedScript.replace('FT_VISITOR_DATA', visitorData)
132+
return cachedScript.replace('FT_PARAMS', `"${videoId}","${visitorData}",${context}`)
147133
}

src/renderer/helpers/api/local.js

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -198,16 +198,24 @@ export async function getLocalSearchContinuation(continuationData) {
198198
export async function getLocalVideoInfo(id) {
199199
const webInnertube = await createInnertube({ withPlayer: true, generateSessionLocally: false })
200200

201-
let poToken
201+
// based on the videoId (added to the body of the /player request)
202+
let contentPoToken
203+
// based on the visitor data (added to the streaming URLs)
204+
let sessionPoToken
202205

203206
if (process.env.IS_ELECTRON) {
204207
const { ipcRenderer } = require('electron')
205208

206209
try {
207-
poToken = await ipcRenderer.invoke(IpcChannels.GENERATE_PO_TOKEN, webInnertube.session.context.client.visitorData)
208-
209-
webInnertube.session.po_token = poToken
210-
webInnertube.session.player.po_token = poToken
210+
({ contentPoToken, sessionPoToken } = await ipcRenderer.invoke(
211+
IpcChannels.GENERATE_PO_TOKENS,
212+
id,
213+
webInnertube.session.context.client.visitorData,
214+
JSON.stringify(webInnertube.session.context)
215+
))
216+
217+
webInnertube.session.po_token = contentPoToken
218+
webInnertube.session.player.po_token = sessionPoToken
211219
} catch (error) {
212220
console.error('Local API, poToken generation failed', error)
213221
throw error
@@ -227,8 +235,8 @@ export async function getLocalVideoInfo(id) {
227235
const webEmbeddedInnertube = await createInnertube({ clientType: ClientType.WEB_EMBEDDED })
228236
webEmbeddedInnertube.session.context.client.visitorData = webInnertube.session.context.client.visitorData
229237

230-
if (poToken) {
231-
webEmbeddedInnertube.session.po_token = poToken
238+
if (contentPoToken) {
239+
webEmbeddedInnertube.session.po_token = contentPoToken
232240
}
233241

234242
const videoId = hasTrailer && trailerIsAgeRestricted ? info.playability_status.error_screen.video_id : id
@@ -283,9 +291,9 @@ export async function getLocalVideoInfo(id) {
283291
let url = info.streaming_data.dash_manifest_url
284292

285293
if (url.includes('?')) {
286-
url += `&pot=${encodeURIComponent(poToken)}&mpd_version=7`
294+
url += `&pot=${encodeURIComponent(sessionPoToken)}&mpd_version=7`
287295
} else {
288-
url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(poToken)}/mpd_version/7`
296+
url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(sessionPoToken)}/mpd_version/7`
289297
}
290298

291299
info.streaming_data.dash_manifest_url = url

0 commit comments

Comments
 (0)