427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
// 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<Result<GenericAxiosError | UnexpectedResponseContentType, string>> {
|
|
// 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<LoginResult>} Result of the operation
|
|
*/
|
|
export async function authenticate(
|
|
credentials: Credentials,
|
|
force: boolean = false
|
|
): Promise<LoginResult> {
|
|
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<Result<GenericAxiosError, LoginResult>> {
|
|
// 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<string> {
|
|
// 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 <input> 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<Result<GenericAxiosError, AxiosResponse<any>>> {
|
|
// 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<Result<GenericAxiosError, AxiosResponse<any>>> {
|
|
// 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<boolean> {
|
|
// 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<String>} Redirect URL or the passed URL
|
|
*/
|
|
export async function getUrlRedirect(url: string): Promise<string> {
|
|
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<boolean> {
|
|
// 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<any>) {
|
|
// 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<any>): Result<ProviderT, LoginResult> {
|
|
// 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
|