// Copyright (c) 2021 MillenniumEarl // // This software is released under the MIT License. // https://opensource.org/licenses/MIT "use strict"; // Public modules from npm import axios, { AxiosResponse } from "axios"; import cheerio from "cheerio"; import axiosCookieJarSupport from "axios-cookiejar-support"; // Modules from file import shared from "./shared"; import { urls } from "./constants/url"; import { GENERIC } from "./constants/css-selector"; import LoginResult from "./classes/login-result"; import { failure, Result, success } from "./classes/result"; import { GenericAxiosError, InvalidF95Token, UnexpectedResponseContentType } from "./classes/errors"; import Credentials from "./classes/credentials"; // Configure axios to use the cookie jar axiosCookieJarSupport(axios); // Types type LookupMapCodeT = { code: number; message: string; }; type ProviderT = "auto" | "totp" | "email"; // Global variables const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0 Safari/605.1.15"; const AUTH_SUCCESSFUL_MESSAGE = "Authentication successful"; const INVALID_2FA_CODE_MESSAGE = "The two-step verification value could not be confirmed. Please try again"; const INCORRECT_CREDENTIALS_MESSAGE = "Incorrect password. Please try again."; /** * Common configuration used to send request via Axios. */ const commonConfig = { /** * Headers to add to the request. */ headers: { "User-Agent": USER_AGENT, Connection: "keep-alive" }, /** * Specify if send credentials along the request. */ withCredentials: true, /** * Jar of cookies to send along the request. */ jar: shared.session.cookieJar, validateStatus: function (status: number) { return status < 500; // Resolve only if the status code is less than 500 }, timeout: 5000 }; /** * Gets the HTML code of a page. */ export async function fetchHTML( url: string ): Promise> { // Fetch the response of the platform const response = await fetchGETResponse(url); if (response.isSuccess()) { // Check if the response is a HTML source code const isHTML = response.value.headers["content-type"].includes("text/html"); const unexpectedResponseError = new UnexpectedResponseContentType({ id: 2, message: `Expected HTML but received ${response.value["content-type"]}`, error: null }); return isHTML ? success(response.value.data as string) : failure(unexpectedResponseError); } else return failure(response.value as GenericAxiosError); } /** * It authenticates to the platform using the credentials * and token obtained previously. Save cookies on your * device after authentication. * @param {Credentials} credentials Platform access credentials * @param {Boolean} force Specifies whether the request should be forced, ignoring any saved cookies * @returns {Promise} Result of the operation */ export async function authenticate( credentials: Credentials, force: boolean = false ): Promise { shared.logger.info(`Authenticating with user ${credentials.username}`); if (!credentials.token) throw new InvalidF95Token(`Invalid token for auth: ${credentials.token}`); // Secure the URL const secureURL = enforceHttpsUrl(urls.LOGIN); // Prepare the parameters to send to the platform to authenticate const params = { login: credentials.username, url: "", password: credentials.password, password_confirm: "", additional_security: "", remember: "1", _xfRedirect: "https://f95zone.to/", website_code: "", _xfToken: credentials.token }; // Try to log-in let authResult: LoginResult = null; // Fetch the response to the login request const response = await fetchPOSTResponse(secureURL, params, force); // Parse the response const result = response.applyOnSuccess((r) => manageLoginPOSTResponse(r)); // Manage result if (result.isFailure()) { const message = `Error ${result.value.message} occurred while authenticating`; shared.logger.error(message); authResult = new LoginResult(false, LoginResult.UNKNOWN_ERROR, message); } else authResult = result.value; return authResult; } /** * Send an OTP code if the login procedure requires it. * @param code OTP code. * @param token Unique token for the session associated with the credentials in use. * @param provider Provider used to generate the access code. * @param trustedDevice If the device in use is trusted, 2FA authentication is not required for 30 days. */ export async function send2faCode( code: number, token: string, provider: ProviderT = "auto", trustedDevice: boolean = false ): Promise> { // Prepare the parameters to send via POST request const params = { _xfRedirect: urls.BASE, _xfRequestUri: "/login/two-step?_xfRedirect=https%3A%2F%2Ff95zone.to%2F&remember=1", _xfResponseType: "json", _xfToken: token, _xfWithData: "1", code: code.toString(), confirm: "1", provider: provider, remember: "1", trust: trustedDevice ? "1" : "0" }; // Send 2FA params const response = await fetchPOSTResponse(urls.LOGIN_2FA, params); // Check if the authentication is valid const validAuth = response.applyOnSuccess((r) => manage2faResponse(r)); if (validAuth.isSuccess() && validAuth.value.isSuccess()) { // Valid login return success(validAuth.value.value); } else if (validAuth.isSuccess() && validAuth.value.isFailure()) { // Wrong provider, try with another const expectedProvider = validAuth.value.value; return await send2faCode(code, token, expectedProvider, trustedDevice); } else failure(validAuth.value); } /** * Obtain the token used to authenticate the user to the platform. */ export async function getF95Token(): Promise { // Fetch the response of the platform const response = await fetchGETResponse(urls.LOGIN); if (response.isSuccess()) { // The response is a HTML page, we need to find the with name "_xfToken" const $ = cheerio.load(response.value.data as string); return $("body").find(GENERIC.GET_REQUEST_TOKEN).attr("value"); } else throw response.value; } //#region Utility methods /** * Performs a GET request to a specific URL and returns the response. */ export async function fetchGETResponse( url: string ): Promise>> { // Secure the URL const secureURL = enforceHttpsUrl(url); try { // Fetch and return the response commonConfig.jar = shared.session.cookieJar; const response = await axios.get(secureURL, commonConfig); return success(response); } catch (e) { shared.logger.error(`(GET) Error ${e.message} occurred while trying to fetch ${secureURL}`); const genericError = new GenericAxiosError({ id: 1, message: `(GET) Error ${e.message} occurred while trying to fetch ${secureURL}`, error: e }); return failure(genericError); } } /** * Performs a POST request through axios. * @param url URL to request * @param params List of value pairs to send with the request * @param force If `true`, the request ignores the sending of cookies already present on the device. */ export async function fetchPOSTResponse( url: string, params: { [s: string]: string }, force = false ): Promise>> { // Secure the URL const secureURL = enforceHttpsUrl(url); // Prepare the parameters for the POST request const urlParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) urlParams.append(key, value); // Shallow copy of the common configuration object commonConfig.jar = shared.session.cookieJar; const config = Object.assign({}, commonConfig); // Remove the cookies if forced if (force) delete config.jar; // Send the POST request and await the response try { const response = await axios.post(secureURL, urlParams, config); return success(response); } catch (e) { const message = `(POST) Error ${e.message} occurred while trying to fetch ${secureURL}`; shared.logger.error(message); const genericError = new GenericAxiosError({ id: 3, message: message, error: e }); return failure(genericError); } } /** * Enforces the scheme of the URL is https and returns the new URL. */ export function enforceHttpsUrl(url: string): string { if (isStringAValidURL(url)) return url.replace(/^(https?:)?\/\//, "https://"); else throw new Error(`${url} is not a valid URL`); } /** * Check if the url belongs to the domain of the F95 platform. */ export function isF95URL(url: string): boolean { return url.startsWith(urls.BASE); } /** * Checks if the string passed by parameter has a * properly formatted and valid path to a URL (HTTP/HTTPS). * * @author Daveo * @see https://preview.tinyurl.com/y2f2e2pc */ export function isStringAValidURL(url: string): boolean { // Many thanks to Daveo at StackOverflow (https://preview.tinyurl.com/y2f2e2pc) const expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; const regex = new RegExp(expression); return url.match(regex).length > 0; } /** * Check if a particular URL is valid and reachable on the web. * @param {string} url URL to check * @param {boolean} [checkRedirect] * If `true`, the function will consider redirects a violation and return `false`. * Default: `false` */ export async function urlExists(url: string, checkRedirect: boolean = false): Promise { // Local variables let valid = false; if (isStringAValidURL(url)) { valid = await axiosUrlExists(url); if (valid && checkRedirect) { const redirectUrl = await getUrlRedirect(url); valid = redirectUrl === url; } } return valid; } /** * Check if the URL has a redirect to another page. * @param {String} url URL to check for redirect * @returns {Promise} Redirect URL or the passed URL */ export async function getUrlRedirect(url: string): Promise { commonConfig.jar = shared.session.cookieJar; const response = await axios.head(url, commonConfig); return response.config.url; } //#endregion Utility methods //#region Private methods /** * Check with Axios if a URL exists. */ async function axiosUrlExists(url: string): Promise { // Local variables const ERROR_CODES = ["ENOTFOUND", "ETIMEDOUT"]; let valid = false; try { commonConfig.jar = shared.session.cookieJar; const response = await axios.head(url, commonConfig); valid = response && !/4\d\d/.test(response.status.toString()); } catch (error) { // Throw error only if the error is unknown if (!ERROR_CODES.includes(error.code)) throw error; } return valid; } /** * Manages the response obtained from the server after requesting authentication. */ function manageLoginPOSTResponse(response: AxiosResponse) { // Parse the response HTML const $ = cheerio.load(response.data as string); // Check if 2 factor authentication is required if (response.config.url.startsWith(urls.LOGIN_2FA)) { return new LoginResult( false, LoginResult.REQUIRE_2FA, "Two-factor authentication is needed to continue" ); } // Get the error message (if any) and remove the new line chars const errorMessage = $("body").find(GENERIC.LOGIN_MESSAGE_ERROR).text().replace(/\n/g, ""); // Return the result of the authentication const result = errorMessage.trim() === ""; const message = result ? AUTH_SUCCESSFUL_MESSAGE : errorMessage; const code = messageToCode(message); return new LoginResult(result, code, message); } /** * Given the login message response of the * platform, return the login result code. */ function messageToCode(message: string): number { // Prepare the lookup dict const mapDict: LookupMapCodeT[] = []; mapDict.push({ code: LoginResult.AUTH_SUCCESSFUL, message: AUTH_SUCCESSFUL_MESSAGE }); mapDict.push({ code: LoginResult.INCORRECT_CREDENTIALS, message: INCORRECT_CREDENTIALS_MESSAGE }); mapDict.push({ code: LoginResult.INCORRECT_2FA_CODE, message: INVALID_2FA_CODE_MESSAGE }); const result = mapDict.find((e) => e.message === message); return result ? result.code : LoginResult.UNKNOWN_ERROR; } /** * Manage the response given by the platform when the 2FA is required. */ function manage2faResponse(r: AxiosResponse): Result { // The html property exists only if the provider is wrong const rightProvider = !("html" in r.data); // Wrong provider! if (!rightProvider) { const $ = cheerio.load(r.data.html.content); const expectedProvider = $(GENERIC.EXPECTED_2FA_PROVIDER).attr("value"); return failure(expectedProvider as ProviderT); } // r.data.status is 'ok' if the authentication is successful const result = r.data.status === "ok"; const message: string = result ? AUTH_SUCCESSFUL_MESSAGE : r.data.errors.join(","); const loginCode = messageToCode(message); return success(new LoginResult(result, loginCode, message)); } //#endregion