mirror of
https://github.com/younesaassila/ttv-lol-pro.git
synced 2025-04-29 22:14:27 +02:00
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import acceptFlag from "../common/ts/acceptFlag";
|
|
import findChannelFromUsherUrl from "../common/ts/findChannelFromUsherUrl";
|
|
import generateRandomString from "../common/ts/generateRandomString";
|
|
import getHostFromUrl from "../common/ts/getHostFromUrl";
|
|
import {
|
|
twitchGqlHostRegex,
|
|
usherHostRegex,
|
|
videoWeaverHostRegex,
|
|
videoWeaverUrlRegex,
|
|
} from "../common/ts/regexes";
|
|
import { State } from "../store/types";
|
|
|
|
const NATIVE_FETCH = self.fetch;
|
|
const IS_CHROMIUM = !!self.chrome;
|
|
|
|
export interface FetchOptions {
|
|
scope: "page" | "worker";
|
|
shouldWaitForStore: boolean;
|
|
state?: State;
|
|
}
|
|
|
|
export function getFetch(options: FetchOptions): typeof fetch {
|
|
// TODO: Clear variables on navigation.
|
|
const knownVideoWeaverUrls = new Set<string>();
|
|
const videoWeaverUrlsToFlag = new Map<string, number>(); // Video Weaver URLs to flag -> number of times flagged.
|
|
const videoWeaverUrlsToIgnore = new Set<string>(); // No response check.
|
|
|
|
if (options.shouldWaitForStore) {
|
|
setTimeout(() => {
|
|
options.shouldWaitForStore = false;
|
|
}, 5000);
|
|
}
|
|
|
|
return async function fetch(
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit
|
|
): Promise<Response> {
|
|
const url = input instanceof Request ? input.url : input.toString();
|
|
// Firefox doesn't support relative URLs in content scripts (workers too!).
|
|
// See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#content_script_https_requests
|
|
if (url.startsWith("//")) {
|
|
// Missing protocol.
|
|
const newUrl = `${location.protocol}${url}`;
|
|
if (input instanceof Request) input = new Request(newUrl, input);
|
|
else input = newUrl;
|
|
} else if (url.startsWith("/")) {
|
|
// Missing origin.
|
|
const newUrl = `${location.origin}${url}`;
|
|
if (input instanceof Request) input = new Request(newUrl, input);
|
|
else input = newUrl;
|
|
}
|
|
const host = getHostFromUrl(url);
|
|
const headersMap = getHeadersMap(input, init);
|
|
|
|
// Reading the request body can be expensive, so we only do it if we need to.
|
|
let requestBody: string | null | undefined = undefined;
|
|
const readRequestBody = async (): Promise<string | null> => {
|
|
if (requestBody !== undefined) return requestBody;
|
|
return getRequestBodyText(input, init);
|
|
};
|
|
|
|
//#region Requests
|
|
|
|
// Twitch GraphQL requests.
|
|
if (host != null && twitchGqlHostRegex.test(host)) {
|
|
requestBody = await readRequestBody();
|
|
// Integrity requests.
|
|
if (url === "https://gql.twitch.tv/integrity") {
|
|
console.debug(
|
|
"[TTV LOL PRO] 🥅 Caught GraphQL integrity request. Flagging…"
|
|
);
|
|
flagRequest(headersMap);
|
|
}
|
|
// Requests with Client-Integrity header.
|
|
const integrityHeader = getHeaderFromMap(headersMap, "Client-Integrity");
|
|
if (integrityHeader != null) {
|
|
console.debug(
|
|
"[TTV LOL PRO] 🥅 Caught GraphQL request with Client-Integrity header. Flagging…"
|
|
);
|
|
flagRequest(headersMap);
|
|
}
|
|
// PlaybackAccessToken requests.
|
|
if (
|
|
requestBody != null &&
|
|
requestBody.includes("PlaybackAccessToken_Template")
|
|
) {
|
|
console.debug(
|
|
"[TTV LOL PRO] 🥅 Caught GraphQL PlaybackAccessToken_Template request. Flagging…"
|
|
);
|
|
|
|
while (options.shouldWaitForStore) await sleep(100);
|
|
let graphQlBody = null;
|
|
try {
|
|
graphQlBody = JSON.parse(requestBody);
|
|
} catch {}
|
|
const channelName = graphQlBody?.variables?.login as string | undefined;
|
|
const whitelistedChannelsLower = options.state?.whitelistedChannels.map(
|
|
channel => channel.toLowerCase()
|
|
);
|
|
const isWhitelisted =
|
|
channelName != null &&
|
|
whitelistedChannelsLower != null &&
|
|
whitelistedChannelsLower.includes(channelName.toLowerCase());
|
|
|
|
if (options.state?.anonymousMode === true) {
|
|
if (!isWhitelisted) {
|
|
console.log("[TTV LOL PRO] 🕵️ Anonymous mode is enabled.");
|
|
setHeaderToMap(headersMap, "Authorization", "undefined");
|
|
removeHeaderFromMap(headersMap, "Client-Session-Id");
|
|
removeHeaderFromMap(headersMap, "Client-Version");
|
|
setHeaderToMap(headersMap, "Device-ID", generateRandomString(32));
|
|
removeHeaderFromMap(headersMap, "Sec-GPC");
|
|
removeHeaderFromMap(headersMap, "X-Device-Id");
|
|
} else {
|
|
console.log(
|
|
"[TTV LOL PRO] 🕵️✋ Anonymous mode is enabled but channel is whitelisted."
|
|
);
|
|
}
|
|
}
|
|
flagRequest(headersMap);
|
|
} else if (
|
|
requestBody != null &&
|
|
requestBody.includes("PlaybackAccessToken")
|
|
) {
|
|
console.debug(
|
|
"[TTV LOL PRO] 🥅 Caught GraphQL PlaybackAccessToken request. Flagging…"
|
|
);
|
|
flagRequest(headersMap);
|
|
}
|
|
}
|
|
|
|
// Usher requests.
|
|
if (host != null && usherHostRegex.test(host)) {
|
|
console.debug("[TTV LOL PRO] 🥅 Caught Usher request.");
|
|
}
|
|
|
|
// Video Weaver requests.
|
|
if (host != null && videoWeaverHostRegex.test(host)) {
|
|
const isIgnoredUrl = videoWeaverUrlsToIgnore.has(url);
|
|
const isNewUrl = !knownVideoWeaverUrls.has(url);
|
|
const isFlaggedUrl = videoWeaverUrlsToFlag.has(url);
|
|
|
|
if (!isIgnoredUrl && (isNewUrl || isFlaggedUrl)) {
|
|
console.log(
|
|
`[TTV LOL PRO] 🥅 Caught ${
|
|
isNewUrl
|
|
? "first request to Video Weaver URL"
|
|
: "Video Weaver request to flag"
|
|
}. Flagging…`
|
|
);
|
|
flagRequest(headersMap);
|
|
videoWeaverUrlsToFlag.set(
|
|
url,
|
|
(videoWeaverUrlsToFlag.get(url) ?? 0) + 1
|
|
);
|
|
if (isNewUrl) knownVideoWeaverUrls.add(url);
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
const response = await NATIVE_FETCH(input, {
|
|
...init,
|
|
headers: Object.fromEntries(headersMap),
|
|
});
|
|
|
|
// Reading the response body can be expensive, so we only do it if we need to.
|
|
let responseBody: string | undefined = undefined;
|
|
const readResponseBody = async (): Promise<string> => {
|
|
if (responseBody !== undefined) return responseBody;
|
|
const clonedResponse = response.clone();
|
|
return clonedResponse.text();
|
|
};
|
|
|
|
//#region Responses
|
|
|
|
// Usher responses.
|
|
if (host != null && usherHostRegex.test(host)) {
|
|
responseBody = await readResponseBody();
|
|
console.debug("[TTV LOL PRO] 🥅 Caught Usher response.");
|
|
const videoWeaverUrls = responseBody
|
|
.split("\n")
|
|
.filter(line => videoWeaverUrlRegex.test(line));
|
|
// Send Video Weaver URLs to content script.
|
|
sendMessageToContentScript(options.scope, {
|
|
type: "UsherResponse",
|
|
channel: findChannelFromUsherUrl(url),
|
|
videoWeaverUrls,
|
|
proxyCountry:
|
|
/USER-COUNTRY="([A-Z]+)"/i.exec(responseBody)?.[1] || null,
|
|
});
|
|
// Remove all Video Weaver URLs from known URLs.
|
|
videoWeaverUrls.forEach(url => knownVideoWeaverUrls.delete(url));
|
|
}
|
|
|
|
// Video Weaver responses.
|
|
if (host != null && videoWeaverHostRegex.test(host)) {
|
|
responseBody = await readResponseBody();
|
|
// Check if response contains ad.
|
|
if (responseBody.includes("stitched-ad")) {
|
|
console.log(
|
|
"[TTV LOL PRO] 🥅 Caught Video Weaver response containing ad."
|
|
);
|
|
if (videoWeaverUrlsToIgnore.has(url)) return response;
|
|
if (!videoWeaverUrlsToFlag.has(url)) {
|
|
// Let's proxy the next request for this URL, 2 attempts left.
|
|
videoWeaverUrlsToFlag.set(url, 0);
|
|
cancelRequest();
|
|
}
|
|
// FIXME: This workaround doesn't work. Let's find another way.
|
|
// 0: First attempt, not proxied, cancelled.
|
|
// 1: Second attempt, proxied, cancelled.
|
|
// 2: Third attempt, proxied, last attempt by Twitch client.
|
|
// If the third attempt contains an ad, we have to let it through.
|
|
const isCancellable = videoWeaverUrlsToFlag.get(url)! < 2;
|
|
if (isCancellable) {
|
|
cancelRequest();
|
|
} else {
|
|
console.error(
|
|
"[TTV LOL PRO] ❌ Could not cancel Video Weaver response containing ad. All attempts used."
|
|
);
|
|
videoWeaverUrlsToFlag.delete(url); // Clear attempts.
|
|
videoWeaverUrlsToIgnore.add(url); // Ignore this URL, there's nothing we can do.
|
|
}
|
|
} else {
|
|
// No ad, remove from flagged list.
|
|
videoWeaverUrlsToFlag.delete(url);
|
|
videoWeaverUrlsToIgnore.delete(url);
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
return response;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Converts a HeadersInit to a map.
|
|
* @param headers
|
|
* @returns
|
|
*/
|
|
function getHeadersMap(
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit
|
|
): Map<string, string> {
|
|
const headers = input instanceof Request ? input.headers : init?.headers;
|
|
if (!headers) return new Map();
|
|
if (headers instanceof Headers) {
|
|
return new Map(headers.entries());
|
|
}
|
|
if (Array.isArray(headers)) {
|
|
return new Map(headers);
|
|
}
|
|
return new Map(Object.entries(headers));
|
|
}
|
|
|
|
/**
|
|
* Converts a BodyInit to a string.
|
|
* @param body
|
|
* @returns
|
|
*/
|
|
async function getRequestBodyText(
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit
|
|
): Promise<string | null> {
|
|
if (input instanceof Request) {
|
|
const clonedRequest = input.clone();
|
|
return clonedRequest.text();
|
|
}
|
|
const body = init?.body;
|
|
if (body == null) return null;
|
|
if (body instanceof Blob) {
|
|
return body.text();
|
|
}
|
|
if (body instanceof ArrayBuffer) {
|
|
return new TextDecoder().decode(body);
|
|
}
|
|
if (body instanceof FormData) {
|
|
const entries = [...body.entries()];
|
|
return entries.map(e => `${e[0]}=${e[1]}`).join("&");
|
|
}
|
|
return body.toString();
|
|
}
|
|
|
|
function findHeaderFromMap(
|
|
headersMap: Map<string, string>,
|
|
name: string
|
|
): string | undefined {
|
|
return [...headersMap.keys()].find(
|
|
header => header.toLowerCase() === name.toLowerCase()
|
|
);
|
|
}
|
|
|
|
function getHeaderFromMap(
|
|
headersMap: Map<string, string>,
|
|
name: string
|
|
): string | null {
|
|
const header = findHeaderFromMap(headersMap, name);
|
|
return header != null ? headersMap.get(header)! : null;
|
|
}
|
|
|
|
function setHeaderToMap(
|
|
headersMap: Map<string, string>,
|
|
name: string,
|
|
value: string
|
|
) {
|
|
const header = findHeaderFromMap(headersMap, name);
|
|
headersMap.set(header ?? name, value);
|
|
}
|
|
|
|
function removeHeaderFromMap(headersMap: Map<string, string>, name: string) {
|
|
const header = findHeaderFromMap(headersMap, name);
|
|
if (header != null) {
|
|
headersMap.delete(header);
|
|
}
|
|
}
|
|
|
|
function sendMessageToContentScript(scope: "page" | "worker", message: any) {
|
|
if (scope === "page") {
|
|
self.postMessage(message);
|
|
} else {
|
|
self.postMessage({
|
|
type: "ContentScriptMessage",
|
|
message,
|
|
});
|
|
}
|
|
}
|
|
|
|
function flagRequest(headersMap: Map<string, string>) {
|
|
if (IS_CHROMIUM) {
|
|
console.debug(
|
|
"[TTV LOL PRO] 🚩 Request flagging is not supported on Chromium. Ignoring…"
|
|
);
|
|
return;
|
|
}
|
|
const accept = getHeaderFromMap(headersMap, "Accept");
|
|
setHeaderToMap(headersMap, "Accept", `${accept || ""}${acceptFlag}`);
|
|
}
|
|
|
|
function cancelRequest(): never {
|
|
throw new Error();
|
|
}
|
|
|
|
async function sleep(ms: number) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|