Skip to content

Commit d538237

Browse files
committed
Implement content and session PO tokens
1 parent e4092aa commit d538237

File tree

5 files changed

+130
-79
lines changed

5 files changed

+130
-79
lines changed

src/botGuardScript.js

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,94 @@
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
63+
})
64+
65+
// DO NOT REMOVE
66+
// Without this timeout we get an "Async snapshot function not found" error
67+
await new Promise((resolve) => setTimeout(resolve, 1000))
68+
69+
const webPoSignalOutput = []
70+
const botGuardResponse = await botGuard.snapshot({ webPoSignalOutput })
71+
72+
const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), {
73+
method: 'POST',
74+
headers: {
75+
'content-type': 'application/json+protobuf',
76+
'x-goog-api-key': GOOG_API_KEY,
77+
'x-user-agent': 'grpc-web-javascript/0.1',
78+
},
79+
body: JSON.stringify([requestKey, botGuardResponse])
3480
})
3581

36-
return poTokenResult.poToken
82+
const response = await integrityTokenResponse.json()
83+
84+
if (typeof response[0] !== 'string') {
85+
throw new Error('Could not get integrity token')
86+
}
87+
88+
const integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: response[0] }, webPoSignalOutput)
89+
90+
const contentPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(videoId)
91+
const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(visitorData)
92+
93+
return { contentPoToken, sessionPoToken }
3794
}

src/constants.js

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

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

49-
GENERATE_PO_TOKEN: 'generate-po-token',
49+
GENERATE_PO_TOKENS: 'generate-po-tokens',
5050

5151
CHOOSE_DEFAULT_FOLDER: 'choose-default-folder',
5252
WRITE_SCREENSHOT: 'write-screenshot',

src/main/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -897,8 +897,8 @@ function runApp() {
897897
})
898898
})
899899

900-
ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (_, visitorData) => {
901-
return generatePoToken(visitorData)
900+
ipcMain.handle(IpcChannels.GENERATE_PO_TOKENS, (_, videoId, visitorData, context) => {
901+
return generatePoToken(videoId, visitorData, context)
902902
})
903903

904904
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,10 +9,12 @@ 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
13-
* @returns {Promise<string>}
14+
* @param {string} context
15+
* @returns {Promise<{ contentPoToken: string, sessionPoToken: string }>}
1416
*/
15-
export async function generatePoToken(visitorData) {
17+
export async function generatePoToken(videoId, visitorData, context) {
1618
const sessionUuid = crypto.randomUUID()
1719

1820
const theSession = session.fromPartition(`potoken-${sessionUuid}`, { cache: false })
@@ -21,12 +23,28 @@ export async function generatePoToken(visitorData) {
2123
// eslint-disable-next-line n/no-callback-literal
2224
theSession.setPermissionRequestHandler((webContents, permission, callback) => callback(false))
2325

24-
theSession.setUserAgent(
25-
theSession.getUserAgent()
26-
.split(' ')
27-
.filter(part => !part.includes('Electron'))
28-
.join(' ')
29-
)
26+
theSession.setUserAgent(session.defaultSession.getUserAgent())
27+
28+
theSession.webRequest.onBeforeSendHeaders({
29+
urls: ['https://www.google.com/js/*', 'https://www.youtube.com/youtubei/*']
30+
}, ({ requestHeaders, url }, callback) => {
31+
if (url.startsWith('https://www.youtube.com/youtubei/')) {
32+
// make InnerTube requests work with the fetch function
33+
// InnerTube rejects requests if the referer isn't YouTube or empty
34+
requestHeaders.Referer = 'https://www.youtube.com/'
35+
requestHeaders.Origin = 'https://www.youtube.com'
36+
37+
requestHeaders['Sec-Fetch-Site'] = 'same-origin'
38+
requestHeaders['Sec-Fetch-Mode'] = 'same-origin'
39+
requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false'
40+
} else {
41+
requestHeaders['Sec-Fetch-Dest'] = 'script'
42+
requestHeaders['Sec-Fetch-Site'] = 'cross-site'
43+
requestHeaders['Accept-Language'] = '*'
44+
}
45+
46+
callback({ requestHeaders })
47+
})
3048

3149
const webContentsView = new WebContentsView({
3250
webPreferences: {
@@ -35,7 +53,8 @@ export async function generatePoToken(visitorData) {
3553
sandbox: true,
3654
v8CacheOptions: 'none',
3755
session: theSession,
38-
offscreen: true
56+
offscreen: true,
57+
webSecurity: false
3958
}
4059
})
4160

@@ -51,43 +70,8 @@ export async function generatePoToken(visitorData) {
5170

5271
webContentsView.webContents.debugger.attach()
5372

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

9377
await webContentsView.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
@@ -105,7 +89,7 @@ export async function generatePoToken(visitorData) {
10589
}
10690
})
10791

108-
const script = await getScript(visitorData)
92+
const script = await getScript(videoId, visitorData, context)
10993

11094
const response = await webContentsView.webContents.executeJavaScript(script)
11195

@@ -118,9 +102,11 @@ export async function generatePoToken(visitorData) {
118102
let cachedScript
119103

120104
/**
105+
* @param {string} videoId
121106
* @param {string} visitorData
107+
* @param {string} context
122108
*/
123-
async function getScript(visitorData) {
109+
async function getScript(videoId, visitorData, context) {
124110
if (!cachedScript) {
125111
const pathToScript = process.env.NODE_ENV === 'development'
126112
? join(__dirname, '../../dist/botGuardScript.js')
@@ -133,8 +119,8 @@ async function getScript(visitorData) {
133119

134120
const functionName = match[1]
135121

136-
cachedScript = content.replace(match[0], `;${functionName}("FT_VISITOR_DATA")`)
122+
cachedScript = content.replace(match[0], `;${functionName}(FT_PARAMS)`)
137123
}
138124

139-
return cachedScript.replace('FT_VISITOR_DATA', visitorData)
125+
return cachedScript.replace('FT_PARAMS', `"${videoId}","${visitorData}",${context}`)
140126
}

src/renderer/helpers/api/local.js

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

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

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

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

229-
if (poToken) {
230-
webEmbeddedInnertube.session.po_token = poToken
237+
if (contentPoToken) {
238+
webEmbeddedInnertube.session.po_token = contentPoToken
231239
}
232240

233241
const videoId = hasTrailer && trailerIsAgeRestricted ? info.playability_status.error_screen.video_id : id
@@ -277,9 +285,9 @@ export async function getLocalVideoInfo(id) {
277285
let url = info.streaming_data.dash_manifest_url
278286

279287
if (url.includes('?')) {
280-
url += `&pot=${encodeURIComponent(poToken)}&mpd_version=7`
288+
url += `&pot=${encodeURIComponent(sessionPoToken)}&mpd_version=7`
281289
} else {
282-
url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(poToken)}/mpd_version/7`
290+
url += `${url.endsWith('/') ? '' : '/'}pot/${encodeURIComponent(sessionPoToken)}/mpd_version/7`
283291
}
284292

285293
info.streaming_data.dash_manifest_url = url

0 commit comments

Comments
 (0)