Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ dependencies {
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
// the corresponding commit hash, since JitPack is sometimes buggy
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.4'
implementation 'com.github.AudricV:NewPipeExtractor:15e35a28df1205e8a0cf4680dd240554a818a120'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'

/** Checkstyle **/
Expand Down
211 changes: 211 additions & 0 deletions app/src/main/assets/po_token.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="en"><head><title></title><script>
class BotGuardClient {
constructor(options) {
this.userInteractionElement = options.userInteractionElement;
this.vm = options.globalObj[options.globalName];
this.program = options.program;
this.vmFunctions = {};
this.syncSnapshotFunction = null;
}

/**
* Factory method to create and load a BotGuardClient instance.
* @param options - Configuration options for the BotGuardClient.
* @returns A promise that resolves to a loaded BotGuardClient instance.
*/
static async create(options) {
return await new BotGuardClient(options).load();
}

async load() {
if (!this.vm)
throw new Error('[BotGuardClient]: VM not found in the global object');

if (!this.vm.a)
throw new Error('[BotGuardClient]: Could not load program');

const vmFunctionsCallback = (
asyncSnapshotFunction,
shutdownFunction,
passEventFunction,
checkCameraFunction
) => {
this.vmFunctions = {
asyncSnapshotFunction: asyncSnapshotFunction,
shutdownFunction: shutdownFunction,
passEventFunction: passEventFunction,
checkCameraFunction: checkCameraFunction
};
};

try {
this.syncSnapshotFunction = await this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, () => {/** no-op */ }, [ [], [] ])[0];
} catch (error) {
throw new Error(`[BotGuardClient]: Failed to load program (${error.message})`);
}

// an asynchronous function runs in the background and it will eventually call
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
// control to the things running in the background by interrupting this async
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
// needed but is there just because.
for (let i = 0; i < 10000 && !this.vmFunctions.asyncSnapshotFunction; ++i) {
await new Promise(f => setTimeout(f, 1))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I … don’t think this is how async works. The timeout is just gonna be scheduled on a new task, but the code before the loop still runs on a microtask on the previous task.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but this.vm.a seems to start a standalone task in the background or something like that, and we need to explicitly pass control back to the event loop by pausing this async execution, for the background task to finish executing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop actually executes only once as far as I know, I still put a loop because you never know


return this;
}

/**
* Takes a snapshot asynchronously.
* @returns The snapshot result.
* @example
* ```ts
* const result = await botguard.snapshot({
* contentBinding: {
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
* encryptedVideoId: "P-vC09ZJcnM"
* }
* });
*
* console.log(result);
* ```
*/
async snapshot(args) {
return new Promise((resolve, reject) => {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));

this.vmFunctions.asyncSnapshotFunction((response) => resolve(response), [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);
});
}
}
/**
* Parses the challenge data from the provided response data.
*/
function parseChallengeData(rawData) {
let challengeData = [];

if (rawData.length > 1 && typeof rawData[1] === 'string') {
const descrambled = descramble(rawData[1]);
challengeData = JSON.parse(descrambled || '[]');
} else if (rawData.length && typeof rawData[0] === 'object') {
challengeData = rawData[0];
}

const [ messageId, wrappedScript, wrappedUrl, interpreterHash, program, globalName, , clientExperimentsStateBlob ] = challengeData;

const privateDoNotAccessOrElseSafeScriptWrappedValue = Array.isArray(wrappedScript) ? wrappedScript.find((value) => value && typeof value === 'string') : null;
const privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = Array.isArray(wrappedUrl) ? wrappedUrl.find((value) => value && typeof value === 'string') : null;

return {
messageId,
interpreterJavascript: {
privateDoNotAccessOrElseSafeScriptWrappedValue,
privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
},
interpreterHash,
program,
globalName,
clientExperimentsStateBlob
};
}

/**
* Descrambles the given challenge data.
*/
function descramble(scrambledChallenge) {
const buffer = base64ToU8(scrambledChallenge);
if (buffer.length)
return new TextDecoder().decode(buffer.map((b) => b + 97));
}

const base64urlCharRegex = /[-_.]/g;

const base64urlToBase64Map = {
'-': '+',
_: '/',
'.': '='
};

function base64ToU8(base64) {
let base64Mod;

if (base64urlCharRegex.test(base64)) {
base64Mod = base64.replace(base64urlCharRegex, function (match) {
return base64urlToBase64Map[match];
});
} else {
base64Mod = base64;
}

base64Mod = atob(base64Mod);

return new Uint8Array(
[ ...base64Mod ].map(
(char) => char.charCodeAt(0)
)
);
}

function u8ToBase64(u8, base64url = false) {
const result = btoa(String.fromCharCode(...u8));

if (base64url) {
return result
.replace(/\+/g, '-')
.replace(/\//g, '_');
}

return result;
}

async function runBotGuard(rawChallengeData) {
const challengeData = parseChallengeData(rawChallengeData)
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;

if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw new Error('Could not load VM');

const botguard = await BotGuardClient.create({
globalName: challengeData.globalName,
globalObj: this,
program: challengeData.program
});

const webPoSignalOutput = [];
const botguardResponse = await botguard.snapshot({ webPoSignalOutput });
return { webPoSignalOutput, botguardResponse }
}

async function obtainPoToken(webPoSignalOutput, integrityTokenResponse, identifier) {
const integrityToken = integrityTokenResponse[0];
const getMinter = webPoSignalOutput[0];

if (!getMinter)
throw new Error('PMD:Undefined');

const mintCallback = await getMinter(base64ToU8(integrityToken));

if (!(mintCallback instanceof Function))
throw new Error('APF:Failed');

const result = await mintCallback(new TextEncoder().encode(identifier));

if (!result)
throw new Error('YNJ:Undefined');

if (!(result instanceof Uint8Array))
throw new Error('ODM:Invalid');

return u8ToBase64(result, true);
}
</script></head><body></body></html>
4 changes: 4 additions & 0 deletions app/src/main/java/org/schabi/newpipe/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.BridgeStateSaverInitializer;
Expand All @@ -26,6 +27,7 @@
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl;

import java.io.IOException;
import java.io.InterruptedIOException;
Expand Down Expand Up @@ -118,6 +120,8 @@ public void onCreate() {
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));

configureRxJavaErrorHandler();

YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
import static com.google.android.exoplayer2.util.Util.castNonNull;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTvHtml5UserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5StreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl;
import static java.lang.Math.min;

import android.net.Uri;
Expand Down Expand Up @@ -270,6 +272,7 @@ public YoutubeHttpDataSource createDataSource() {

private static final String RN_PARAMETER = "&rn=";
private static final String YOUTUBE_BASE_URL = "https://www.youtube.com";
private static final byte[] POST_BODY = new byte[] {0x78, 0};

private final boolean allowCrossProtocolRedirects;
private final boolean rangeParameterEnabled;
Expand Down Expand Up @@ -658,8 +661,11 @@ private HttpURLConnection makeConnection(
}
}

final boolean isTvHtml5StreamingUrl = isTvHtml5StreamingUrl(requestUrl);

if (isWebStreamingUrl(requestUrl)
|| isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) {
|| isTvHtml5StreamingUrl
|| isWebEmbeddedPlayerStreamingUrl(requestUrl)) {
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty");
Expand All @@ -679,6 +685,9 @@ private HttpURLConnection makeConnection(
} else if (isIosStreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getIosUserAgent(null));
} else if (isTvHtml5StreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getTvHtml5UserAgent());
} else {
// non-mobile user agent
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT);
Expand All @@ -687,22 +696,16 @@ private HttpURLConnection makeConnection(
httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING,
allowGzip ? "gzip" : "identity");
httpURLConnection.setInstanceFollowRedirects(followRedirects);
httpURLConnection.setDoOutput(httpBody != null);

// Mobile clients uses POST requests to fetch contents
httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl
? "POST"
: DataSpec.getStringForHttpMethod(httpMethod));

if (httpBody != null) {
httpURLConnection.setFixedLengthStreamingMode(httpBody.length);
httpURLConnection.connect();
final OutputStream os = httpURLConnection.getOutputStream();
os.write(httpBody);
os.close();
} else {
httpURLConnection.connect();
}
// Most clients use POST requests to fetch contents
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setFixedLengthStreamingMode(POST_BODY.length);
httpURLConnection.connect();

final OutputStream os = httpURLConnection.getOutputStream();
os.write(POST_BODY);
os.close();

return httpURLConnection;
}

Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import android.view.KeyEvent;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.webkit.CookieManager;

import androidx.annotation.Dimension;
import androidx.annotation.NonNull;
Expand Down Expand Up @@ -335,4 +336,17 @@ public static boolean shouldSupportMediaTunneling() {
&& !TX_50JXW834
&& !HMB9213NW;
}

/**
* @return whether the device has support for WebView, see
* <a href="https://stackoverflow.com/a/69626735">https://stackoverflow.com/a/69626735</a>
*/
public static boolean supportsWebView() {
try {
CookieManager.getInstance();
return true;
} catch (final Throwable ignored) {
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.schabi.newpipe.util.potoken

class PoTokenException(message: String) : Exception(message)

// to be thrown if the WebView provided by the system is broken
class BadWebViewException(message: String) : Exception(message)

fun buildExceptionForJsError(error: String): Exception {
return if (error.contains("SyntaxError"))
BadWebViewException(error)
else
PoTokenException(error)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.schabi.newpipe.util.potoken

import android.content.Context
import io.reactivex.rxjava3.core.Single
import java.io.Closeable

/**
* This interface was created to allow for multiple methods to generate poTokens in the future (e.g.
* via WebView and via a local DOM implementation)
*/
interface PoTokenGenerator : Closeable {
/**
* Generates a poToken for the provided identifier, using the `integrityToken` and
* `webPoSignalOutput` previously obtained in the initialization of [PoTokenWebView]. Can be
* called multiple times.
*/
fun generatePoToken(identifier: String): Single<String>
Comment thread
AudricV marked this conversation as resolved.

/**
* @return whether the `integrityToken` is expired, in which case all tokens generated by
* [generatePoToken] will be invalid
*/
fun isExpired(): Boolean

interface Factory {
/**
* Initializes a [PoTokenGenerator] by loading the BotGuard VM, running it, and obtaining
* an `integrityToken`. Can then be used multiple times to generate multiple poTokens with
* [generatePoToken].
*
* @param context used e.g. to load the HTML asset or to instantiate a WebView
*/
fun newPoTokenGenerator(context: Context): Single<PoTokenGenerator>
}
}
Loading