Anakunda / gazelleApiLib

// ==UserScript==
// ==UserLibrary==
// @name         gazelleApiLib
// @namespace    https://openuserjs.org/users/Anakunda
// @version      1.12.09
// @author       Anakunda
// @license      GPL-3.0-or-later
// @copyright    2021, Anakunda (https://openuserjs.org/users/Anakunda)
// @exclude      *
// ==/UserScript==
// ==/UserLibrary==

'use strict';

function getConfigValue(name, _default) {
	if (!name) throw 'Invalid argument'; else if (name in localStorage) try {
		var value = eval(localStorage.getItem(name));
		if (!isNaN(value)) return value;
	} catch(e) { console.warn(e) }
	const mangledName = name.replace(/_+(\w)/ig, (m, ch) => ch.toUpperCase());
	if (mangledName != name && mangledName in localStorage) try {
		if (!isNaN(value = eval(localStorage.getItem(mangledName)))) return value;
	} catch(e) { console.warn(e) }
	return typeof GM_getValue == 'function' ? GM_getValue(name, _default) : _default;
}

const ajaxRequestsCache = new Map;
if (typeof GM_getValue == 'function') switch (document.domain) {
	case 'redacted.ch':
		var ajaxApiKey = GM_getValue('redacted_api_key');
		if (ajaxApiKey === undefined && typeof GM_setValue == 'function') GM_setValue('redacted_api_key', '');
		break;
}
if (!ajaxApiKey) for (let key of ['ajaxApiKey', 'ajax_api_key', 'apiKey', 'api_key'])
	if (key in window.localStorage && (ajaxApiKey = window.localStorage.getItem(key))) break;

let gazelleApiQuota = getConfigValue('gazelle_api_quota', ajaxApiKey ? 10 : 5),
		gazelleApiTimeWindow = getConfigValue('gazelle_api_timewindow', 10),
		gazelleApiTimeout = getConfigValue('gazelle_api_timeout', 90),
		gazelleApiMaxRetries = getConfigValue('gazelle_api_max_retries', 100),
		errorRetryDelay = getConfigValue('gazelle_api_retry_delay', 1000),
		shoutApiRateViolations = getConfigValue('gazelle_api_alert_rate_exceeds', false);
let gazelleApiMutex = createMutex(), ajaxApiLogger,
		ajaxCache, domStorageLimitReached = false, ajaxLastCachedSuccess;

const setAjaxApiLogger = callback => { ajaxApiLogger = typeof callback == 'function' ? callback : undefined };

function queryAjaxAPI(action, params, postData, useCache = 0) {
	if (!action) throw 'Action is missing';
	params = new URLSearchParams(Object.assign({ action: action }, typeof params == 'object' ? params : null));
	if (useCache >= 1 && !postData) {
		if (ajaxRequestsCache.has(params.toString())) return ajaxRequestsCache.get(params.toString());
		if (useCache >= 2) {
			if (!ajaxCache && 'ajaxCache' in sessionStorage) try {
				ajaxCache = new Map(JSON.parse(sessionStorage.getItem('ajaxCache')));
			} catch(e) { console.warn('AJAX static cache is invalid:', e) }
			if (!ajaxCache) ajaxCache = new Map;
			if (ajaxCache.has(params.toString())) return Promise.resolve(ajaxCache.get(params.toString()));
		}
	}
	const recoverableHttpErrors = [
        0,
        500, 502, 503, 504,
        520, /*521, */522, 523, 524, 525, /*526, */527, 530,
    ];
	const worker = new Promise(function(resolve, reject) {
		const xhr = new XMLHttpRequest, url = 'ajax.php?' + params.toString();
		if (postData && !(postData instanceof URLSearchParams) && !(postData instanceof FormData)) switch (typeof postData) {
			case 'object': postData = new URLSearchParams(postData); break;
			case 'string': try { postData = new URLSearchParams(JSON.parse(postData)) } catch(e) { } break;
		}
		let retryCounter = 0;
		const canRetry = () => !(gazelleApiMaxRetries > 0) || ++retryCounter < gazelleApiMaxRetries;
		const lockedUpdate = updater => new Promise(function(resolve) {
			gazelleApiMutex.lock(function() {
				if ('ajaxTimeFrame' in window.localStorage) try {
					var apiTimeFrame = JSON.parse(window.localStorage.getItem('ajaxTimeFrame'));
				} catch(e) {
					apiTimeFrame = { };
					console.warn(e);
				} else apiTimeFrame = { };
				if (typeof updater == 'function') {
					const retVal = updater(apiTimeFrame);
					if (retVal) {
						if (typeof retVal == 'object') apiTimeFrame = retVal;
						window.localStorage.setItem('ajaxTimeFrame', JSON.stringify(apiTimeFrame));
					}
				}
				gazelleApiMutex.unlock();
				resolve(apiTimeFrame);
			});
		});
		const request = (counter = 1) => lockedUpdate(function(apiTimeFrame) {
			if (!('expiresAt' in apiTimeFrame) || Date.now() >= apiTimeFrame.expiresAt) return {
				expiresAt: Date.now() + (gazelleApiTimeout > 0 ? gazelleApiTimeout : 120) * 1000,
				requestCounter: 1,
			}; else ++apiTimeFrame.requestCounter;
			return true;
		}).then(function(apiTimeFrame) {
			const timeStamps = [Date.now()];
			if (apiTimeFrame.requestCounter <= gazelleApiQuota) {
				xhr.open(postData ? 'POST' : 'GET', url, true);
				if (action != 'preview') {
					xhr.responseType = 'json';
					xhr.setRequestHeader('Accept', 'application/json');
				} else {
					xhr.responseType = 'document';
					xhr.setRequestHeader('Accept', 'text/html');
					xhr.overrideMimeType('text/html');
				}
				if (ajaxApiKey) xhr.setRequestHeader('Authorization', ajaxApiKey);
				xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
				xhr.onreadystatechange = function() {
					if (this.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
					let timeStamp = Date.now();
					timeStamps.push(timeStamp);
					if (this.readyState < XMLHttpRequest.DONE
							|| (timeStamp += gazelleApiTimeWindow * 1000) >= apiTimeFrame.expiresAt) return;
					apiTimeFrame.expiresAt = timeStamp;
					lockedUpdate(function(_apiTimeFrame) {
						if (_apiTimeFrame.expiresAt <= apiTimeFrame.expiresAt) return false;
						_apiTimeFrame.expiresAt = apiTimeFrame.expiresAt;
						return true;
					});
				};
				xhr.onload = function() {
					let response = this.response;
					if (this.status >= 200 && this.status < 400) {
						if (!response) {
							if ('responseText' in this && this.responseText) {
								response = new DOMParser().parseFromString(this.responseText, 'text/html');
								let elem = response.body.querySelector('div#content div.header > h2');
								if (elem != null && elem.textContent.trim() == 'Error'
										&& (elem = response.body.querySelector('div#content div.header + div.box.pad')) != null)
									return reject('Error: ' + elem.textContent.trim());
							}
							return reject('Malformed response (not JSON)');
						} else if (action == 'preview') resolve(response.body); else if (response.status == 'success') {
							if (useCache >= 2 && !domStorageLimitReached && !postData) {
								ajaxCache.set(params.toString(), response.response);
								const serialized = JSON.stringify(Array.from(ajaxCache));
								try {
									sessionStorage.setItem('ajaxCache', serialized);
									ajaxLastCachedSuccess = serialized;
								} catch(e) {
									console.warn(e, `(${serialized.length})`);
									if (/\b(?:NS_ERROR_DOM_QUOTA_REACHED)\b/.test(e)
											|| e instanceof DOMException && e.name == 'QuotaExceededError') {
										domStorageLimitReached = true;
										if (ajaxLastCachedSuccess) {
											sessionStorage.setItem('ajaxCache', ajaxLastCachedSuccess);
											ajaxLastCachedSuccess = undefined;
										}
									}
								}
							}
							resolve(response.response);
						} else {
							console.warn('ajax.php says %s (%s): action=%s', response.error, response.status,
								action, apiTimeFrame, timeStamps[1] || timeStamps[0], retryCounter, counter);
							reject(response.error);
						}
					} else if (this.status == 404) { // status="failure"
						if (response.error == 'endpoint not found') reject(response.error); else reject('not found');
					} else if (this.status == 429) { // status="failure", erros="rate limit exceeded"
						if (canRetry()) {
							setTimeout(request, gazelleApiTimeWindow * 1000, counter + 1);
							if (typeof ajaxApiLogger == 'function')
								ajaxApiLogger(action, apiTimeFrame, timeStamps[1] || timeStamps[0], counter);
						} else reject(defaultErrorHandler(this));
						console.warn('ajax.php says %s (%s): action=%s', response.error, this.status,
							action, apiTimeFrame, timeStamps[1] || timeStamps[0], retryCounter, counter);
						if (shoutApiRateViolations) Promise.resolve(`
ajax.php says: ${response.error} (HTTP/${this.status})
action=${action}
timestamps=${timeStamps}
expiresAt=${apiTimeFrame.expiresAt}
counter=${counter}
retryCounter=${retryCounter}
`).then(alert);
					} else if (recoverableHttpErrors.includes(this.status) && canRetry()) {
						setTimeout(request, errorRetryDelay, counter + 1);
						defaultErrorHandler(this);
					} else reject(defaultErrorHandler(this));
				};
				xhr.onerror = function() {
					if (recoverableHttpErrors.includes(this.status) && canRetry()) {
						defaultErrorHandler(this);
						setTimeout(request, errorRetryDelay, counter + 1);
					} else reject(defaultErrorHandler(this));
				};
				xhr.ontimeout = function() {
					if (canRetry()) {
						defaultTimeoutHandler(this);
						request(counter + 1);
					} else reject(defaultTimeoutHandler(this));
				};
				if (gazelleApiTimeout > 0) xhr.timeout = gazelleApiTimeout * 1000;
				xhr.send(postData);
			} else {
				setTimeout(request, Math.min(apiTimeFrame.expiresAt - timeStamps[0], gazelleApiTimeWindow * 1000), counter + 1);
				if (typeof ajaxApiLogger == 'function') ajaxApiLogger(action, apiTimeFrame, timeStamps[0], counter);
				console.debug('AJAX API request quota exceeded: action=' + action, apiTimeFrame, timeStamps[0],
					retryCounter, counter);
			}
		}, alert);
		request();
	});
	if (useCache >= 1 && !postData) ajaxRequestsCache.set(params.toString(), worker);
	return worker;
}
const queryAjaxAPICached = (action, params, useStaticCache = false) =>
	queryAjaxAPI(action, params, undefined, useStaticCache ? 2 : 1);

const ajaxGetArtist = artistName => queryAjaxAPI('artist', { artistname: artistName });
const ajaxGetRequest = id => queryAjaxAPI('request', { id: id });

function defaultErrorHandler(response) {
	let reason = 'HTTP error ' + response.status;
	console.error(reason, response);
	if (response.status == 0) reason += '/' + response.readyState;
	let statusText = response.statusText;
	if (response.response) try {
		if (typeof response.response.error == 'string') statusText = response.response.error;
	} catch(e) { }
	if (statusText) reason += ' (' + statusText + ')';
	return reason;
}
function defaultTimeoutHandler(response) {
	console.error('HTTP timeout:', response);
	let reason = 'HTTP timeout';
	if (response.timeout) reason += ' (' + response.timeout + ')';
	return reason;
}

for (const key of ['AJAX time frame']) if (key in localStorage) localStorage.removeItem(key);


//////////////////////////////////////////////////////////////////////////
////////////////////////////// SAFE PADDING //////////////////////////////
//////////////////////////////////////////////////////////////////////////