Anakunda / imageHostUploader

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

'use strict';

const minUploadSpeed = parseFloat(GM_getValue('min_upload_speed')); // in Mbit/s
const rehostTimeout = GM_getValue('rehost_timeout', 60);
const httpParser = /^(https?:\/\/.+)$/i;
const nonWordStripper = /[\x00-\x2C\x2E\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]+/g;
var testRemoteSizes = GM_getValue('test_remote_sizes');
if (testRemoteSizes === undefined) GM_setValue('test_remote_sizes', (testRemoteSizes = false)); // time consuming
var inputDataHandler, textAreaDropHandler, textAreaPasteHandler, imageUrlResolver;
if ('verifiedImageUrls' in sessionStorage) try {
	var verifiedImageUrls = JSON.parse(sessionStorage.getItem('verifiedImageUrls'));
} catch(e) { console.warn(e) }
if (!verifiedImageUrls || typeof verifiedImageUrls != 'object') verifiedImageUrls = { };
if ('fileSizeCache' in sessionStorage) try {
	var fileSizeCache = new Map(JSON.parse(sessionStorage.getItem('fileSizeCache')));
} catch(e) { console.warn(e) }
if (!fileSizeCache) fileSizeCache = new Map;
if ('fileTypeCache' in sessionStorage) try {
	var fileTypeCache = new Map(JSON.parse(sessionStorage.getItem('fileTypeCache'))) ;
} catch(e) { console.warn(e) }
if (!fileTypeCache) fileTypeCache = new Map;

function imageHostUploaderInit(_inputDataHandler, _textAreaDropHandler, _textAreaPasteHandler, _imageUrlResolver) {
	inputDataHandler = _inputDataHandler;
	textAreaDropHandler = _textAreaDropHandler;
	textAreaPasteHandler = _textAreaPasteHandler;
	imageUrlResolver = _imageUrlResolver;
}

String.prototype.toASCII = function() {
	return this.normalize('NFKD').replace(/[\x00-\x1F\u0300-\u036F]/gu, '');
};

Array.prototype.flatten = function() {
	return this.reduce(function(flat, toFlatten) {
		return flat.concat(Array.isArray(toFlatten) ? toFlatten.flatten() : toFlatten);
	}, [ ]);
};

Blob.prototype.getContent = function() {
	return new Promise((resolve, reject) => {
		var reader = new FileReader;
		reader.onload = () => {
			if (reader.result.length != this.size)
				console.warn(`FileReader: binary string read length mismatch (${reader.result.length} ≠ ${this.size})`);
			resolve({ name: this.name, type: this.type, size: reader.result.length, data: reader.result });
		};
		reader.onerror = reader.ontimeout = () => { reject(`FileReader error (${this.name})`) };
		reader.readAsBinaryString(this);
	});
};
Blob.prototype.getText = function(encoding) {
	return new Promise((resolve, reject) => {
		var reader = new FileReader;
		reader.onload = () => {
			if (reader.result.length != this.size)
				console.warn(`FileReader: text string read length mismatch (${reader.result.length} ≠ ${this.size})`);
			resolve(reader.result);
		};
		reader.onerror = reader.ontimeout = () => { reject(`FileReader error (${this.name})`) };
		reader.readAsText(this, encoding);
	});
};

function getUploadTimeout(totalSize) {
	return minUploadSpeed > 0 && totalSize > 0 ?
		Math.max(Math.ceil(totalSize * 8000 / minUploadSpeed / 2**20), 10000) : undefined;
}

function uuid() {
	let dt = new Date().getTime();
	let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
		let r = (dt + Math.random() * 16) % 16 | 0;
		dt = Math.floor(dt / 16);
		return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
	});
	return uuid;
}

class PTPimg {
	constructor() {
		this.alias = 'PTPimg';
		this.origin = 'https://ptpimg.me';
		this.types = ['png', 'jpeg', 'gif', 'webp', 'bmp'];
		this.batchLimit = 20;
		this.whitelist = [
			'passthepopcorn.me', 'redacted.ch', 'orpheus.network', 'notwhat.cd', 'dicmusic.club', 'broadcasthe.net',
		];
		if (!(this.apiKey = GM_getValue('ptpimg_api_key')) && window.localStorage.ptpimg_it) try {
			if (this.apiKey = JSON.parse(window.localStorage.ptpimg_it).api_key) GM_setValue('ptpimg_api_key', this.apiKey);
		} catch(e) { console.debug(e) }
		if (this.apiKey === undefined) GM_setValue('ptpimg_api_key', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return this.setSession().then(apiKey => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			images.forEach((image, ndx) => {
				formData += 'Content-Disposition: form-data; name="file-upload[' + ndx + ']"; filename="' + image.name.toASCII() + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="api_key"\r\n\r\n';
			formData += apiKey + '\r\n';
			formData += '--' + boundary + '--\r\n';
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload.php',
				responseType: 'json',
				headers: {
					'Accept': 'application/json',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) reject(defaultErrorHandler(response));
					else if (response.response) {
						if (response.response.length < images.length) {
							console.warn('PTPimg returning incomplete list of images (', response.response, ')');
							return reject(`not all images uploaded (${response.response.length}/${images.length})`);
						}
						if (response.response.length > images.length)
							console.warn('PTPimg returns more links than expected (', response.response, images, ')');
						resolve(response.response.map((item, ndx) => {
							if (!item.ext && /\.([a-z]+)(?=$|[\#\?])/i.test(images[ndx].name)) item.ext = RegExp.$1;
							return this.origin + '/' + item.code + '.' + item.ext;
						}));
					} else {
						const htmlMessage = domParser.parseFromString(response.responseText, 'text/html').body.textContent.trim();
						if (/\b(?:Fatal error|Uncaught Exception):/i.test(htmlMessage)) {
							console.warn('PTPimg throws fatal error, trying to redirect.\n\n', htmlMessage);
							let redirects = GM_getValue('ptpimg_http500_redirects', [/*'imgcdn', */'imgbb', 'pixhost', 'postimage', 'gifyu']);
							if (typeof redirects == 'string') redirects = redirects.split(/\W+/).filter(Boolean);
							if (!Array.isArray(redirects)) return reject('invalid redirects list format');
							redirects = redirects.map(alias => alias.toLowerCase())
								.filter(alias => alias in imageHostHandlers && typeof imageHostHandlers[alias].upload == 'function')
								.map(alias => imageHostHandlers[alias]);
							if (redirects.length <= 0) throw 'Redirects list for PTPimg is empty';
							const redirect = (index = 0) => {
								if (!(index >= 0 && index < redirects.length)) return Promise.reject('all redirects failed');
								if (typeof progressHandler == 'function') progressHandler(null);
								return redirects[index].upload(images, progressHandler, true).catch(reason => redirect(index + 1));
							};
							redirect().then(results => this.rehost(results.map(directLinkGetter), null)).then(resolve, reject);
						} else reject('void or malformed response');
					}
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}));
	}

	rehost(urls) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		const redirect = urls => {
			if (typeof urls == 'string') urls = [urls];
			if (!Array.isArray(urls)) return Promise.reject('invalid argument');
			let redirects = GM_getValue('ptpimg_http500_redirects', ['pixhost', 'postimage', 'imgur']);
			if (typeof redirects == 'string') redirects = redirects.split(/\W+/).filter(Boolean);
			if (!Array.isArray(redirects)) throw 'invalid redirects list format';
			redirects = redirects
				.filter(alias => alias in imageHostHandlers && typeof imageHostHandlers[alias].rehost == 'function')
				.map(alias => imageHostHandlers[alias]);
			if (redirects.length <= 0) return Promise.reject('Redirects list for PTPimg is empty (forcing local reupload)');
			const redirect = (index = 0) => (index >= 0 && index < redirects.length ?
				redirects[index].rehost(urls, null, true).catch(reason => redirect(index + 1)) : this.reupload(urls))
					.then(results => results.map(directLinkGetter));
			return redirect();
		};
		return this.setSession().then(apiKey => {
			const rehost = (urls, allowFallback = false) => Promise.all(urls.map(url => {
				if (!httpParser.test(url)) return Promise.reject('URL not valid (' + url + ')');
				const domain = new URL(url).hostname;
				// known blocked hosts
				if (['img.discogs.com', 'i.discogs.com'].some(hostname => domain == hostname)
						|| [/*'.dzcdn.net', */'omdb.org'].some(hostname => domain.endsWith(hostname)))
					return redirect([url]).then(singleImageGetter);
				else if (!/\.(?:jpe?g|jfif|png|gif|bmp)$/i.test(url))
					return verifyImageUrl(url + '#.jpg').then(finalUrl => url + '#.jpg').catch(reason => {
						let redirects = [/*'imgcdn', */'imgbb', 'pixhost', 'gifyu']
							.filter(alias => alias in imageHostHandlers && typeof imageHostHandlers[alias].rehost == 'function')
							.map(alias => imageHostHandlers[alias]);
						const redirect = (index = 0) => (index >= 0 && index < redirects.length ?
							redirects[index].rehost([url], null, true).catch(reason => redirect(index + 1))
								: Promise.reject('redirection failed on all hosts')).then(results => results.map(directLinkGetter));
						return redirect();
					});
				return verifyImageUrl(url);
			})).then(imageUrls => {
				console.debug('PTPimg.rehost(...) input:', imageUrls);
				let formData = new URLSearchParams({
					'link-upload': imageUrls.join('\r\n'),
					'api_key': apiKey,
				});
				return globalXHR(this.origin + '/upload.php', {
					responseType: 'json',
					timeout: imageUrls.length * rehostTimeout * 1000,
				}, formData).then(({response}) => {
					if (!response) return this.reupload(urls); //Promise.reject('void response');
					if (response.length < imageUrls.length) {
						console.warn('PTPimg returning incomplete list of images (', response, ')');
						return Promise.reject(`not all images rehosted to (${response.length}/${imageUrls.length})`)
					}
					if (response.length > imageUrls.length)
						console.warn('PTPimg returns more links than expected (', response, imageUrls, ')');
					return response.map((item, ndx) => {
						if (!item.ext && /\.([a-z]+)(?=$|[\#\?])/i.test(imageUrls[ndx])) item.ext = RegExp.$1;
						return this.origin + '/' + item.code + '.' + item.ext;
					});
				}, reason => {
					if (!allowFallback || !/^(?:HTTP error 500)\b/.test(reason)) return Promise.reject(reason);
					console.warn('PTPimg internal server error [' + reason +
						'] for one or more remote images => trying to redirect');
					return redirect(urls).then(rehost);
				});
			});
			return rehost(urls, true);
		});
	}

	reupload(urls) {
		return Array.isArray(urls) && urls.length > 0 ? Promise.all(urls.map(url => globalXHR(url, {
			responseType: 'blob',
			headers: { 'Accept': 'image/*' },
		}).then(response => {
			let image = {
				name: response.finalUrl.replace(/^.*\//, ''),
				data: response.responseText,
				size: response.getHeaderValue('Content-Length') || response.response.size,
				type: response.getHeaderValue('Content-Type'),
			};
			if (!image.type) switch (response.finalUrl.replace(/[\?\#].*$/, '').replace(/^.*\./, '').toLowerCase()) {
				case 'jpg': case 'jpeg': case 'jfif': image.type = 'image/jpeg'; break;
				case 'png': image.type = 'image/png'; break;
				case 'gif': image.type = 'image/gif'; break;
				case 'bmp': image.type = 'image/bmp'; break;
				default: return Promise.reject('Unsupported extension');
			}
			return image;
		}))).then(PTPimg.prototype.upload.bind(this)) : Promise.reject('invalid or void argument');
	}

	setSession() {
		return this.apiKey ? Promise.resolve(this.apiKey) : globalXHR(this.origin).then(({document}) => {
			const apiKey = document.getElementById('api_key') || document.querySelector('input[name="api_key"]');
			if (apiKey == null) {
				let counter = GM_getValue('eptpimg_reminder_read', 0);
				if (counter < 3) {
					alert(`PTPimg API key could not be captured. Please login to ${this.origin}/ and redo the action.`);
					GM_setValue('ptpimg_reminder_read', ++counter);
				}
				return Promise.reject('API key not configured');
			} else if (!(this.apiKey = apiKey.value)) return Promise.reject('assertion failed: empty PTPimg API key');
			GM_setValue('ptpimg_api_key', this.apiKey);
			Promise.resolve(this.apiKey)
				.then(apiKey => { alert(`Your PTPimg API key [${apiKey}] was successfully configured`) });
			return this.apiKey;
		});
	}
}

class Chevereto {
	constructor(hostName, alias = undefined, types = undefined, sizeLimit = undefined, params = undefined) {
		if (typeof hostName != 'string' || !hostName) throw 'Chevereto adapter: missing mandatory host name';
		this.origin = httpParser.test(hostName) ? hostName.replace(/\/+$/, '') : 'https://' + hostName;
		this.alias = alias;
		if (Array.isArray(types)) this.types = types;
		if (typeof params != 'object') params = { };
		this.sizeLimit = sizeLimit || params.sizeLimitAnonymous;
		if (!(this.sizeLimit > 0)) this.sizeLimit = undefined;
		if (params.sizeLimitAnonymous < this.sizeLimit) this.sizeLimitAnonymous = params.sizeLimitAnonymous;
		if (alias) var al = alias.replace(nonWordStripper, '');
		if (!params.configPrefix && al) params.configPrefix = al.toLowerCase();
		if (!params.configPrefix && /^(?:www\.)?([\w\-]+)(?:\.[\w\-]+)+$/.test(hostName))
			params.configPrefix = RegExp.$1.toLowerCase();
		if (this.configPrefix = params.configPrefix) {
			this.uid = GM_getValue(this.configPrefix + '_uid');
			if (this.uid === undefined && alias) GM_setValue(this.configPrefix + '_uid', '');
			this.password = GM_getValue(this.configPrefix + '_password');
			if (this.password === undefined && alias) GM_setValue(this.configPrefix + '_password', '');
			this.apiKey = GM_getValue(this.configPrefix + '_api_key');
			if (this.apiKey === undefined && alias && params.apiEndpoint) GM_setValue(this.configPrefix + '_api_key', '');
		} else console.warn('Chevereto adapter: config prefix could not be evaluated, authorized operations not available');
		this.jsonEndpoint = params.jsonEndpoint;
		this.apiEndpoint = params.apiEndpoint;
		this.apiFieldName = params.apiFieldName;
		this.apiResultKey = params.apiResultKey;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		return this.setSession(false).then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			if (this.sizeLimit > 0 && image.size > this.sizeLimit * 2**20
					|| this.sizeLimitAnonymous >= 0 && !session.username && !session.key
					&& image.size > this.sizeLimitAnonymous * 2**20)
				return Promise.reject(`image size exceeds site limit (${image.size})`);
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', params = Object.assign({
				action: 'upload',
				type: 'file',
				nsfw: 0,
				thumb_width: 200,
				//thumb_height: 200,
				format: 'json',
			}, session);
			Object.keys(params).forEach((field, index, arr) => {
				formData += 'Content-Disposition: form-data; name="' + field + '"\r\n\r\n';
				formData += params[field] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="' + (session.key && this.apiFieldName || 'source') +
				'"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n' + image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: session.key ? (this.apiEndpoint || this.origin + '/api/1') + '/upload'
					: this.jsonEndpoint || this.origin + '/json',
				responseType: 'json',
				headers: {
					'Accept': 'application/json',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) try {
						if (response.response.success)
							resolve(Chevereto.resultHandler(response.response[session.key && this.apiResultKey || 'image']));
						else reject((response.response.error ? response.response.error.message
							: response.response.status_txt) + ' (' + response.response.status_code + ')');
					} catch(e) { reject(e) } else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null, temporary = false) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return this.setSession(false).then(session => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
			let formData = new URLSearchParams(Object.assign({
				action: 'upload',
				type: 'url',
				nsfw: 0,
				thumb_width: 200,
				//thumb_height: 200,
				format: 'json',
			}, session));
			if (temporary) formData.set('expiration', 'PT5M');
			formData.set(session.key && this.apiFieldName || 'source', imageUrl);
			return globalXHR(session.key ? (this.apiEndpoint || this.origin + '/api/1') + '/upload' : this.jsonEndpoint || this.origin + '/json', {
				responseType: 'json',
				headers: { 'Referer': this.origin },
				timeout: urls.length * rehostTimeout * 1000,
			}, formData).then(({response}) => {
				if (!response.success)
					return Promise.reject(`${this.alias || this.origin}: ${response.error.message} (${response.status_code})`);
				if (typeof progressHandler == 'function') progressHandler(true);
				return Chevereto.resultHandler(response[session.key && this.apiResultKey || 'image']);
			});
		}))));
	}

	static resultHandler(result) {
		try {
			return {
				original: result.image && result.image.url || result.url,
				thumb: result.thumb.url,
				share: result.url_viewer,
			};
		} catch(e) { return result.url }
	}

	galleryResolver(url) {
		var albumId = /^\/(?:album|al)\/(\w+)\b/.test(url.pathname) && RegExp.$1;
		if (!albumId) return Promise.reject('Invlaid gallery URL');
		return this.setSession(true, false).then(session => {
			let formData = new URLSearchParams(Object.assign({
				action: 'get-album-contents',
				albumid: albumId,
			}, session));
			return globalXHR(this.origin + '/json', {
				responseType: 'json',
				headers: { 'Referer': url },
			}, formData).then(({response}) => {
				return response.status_txt == 'OK' && Array.isArray(response.contents) ?
					response.contents.map(image => image.url)
						: Promise.reject(`${this.alias || this.origin}: ${response.error.message} (${response.status_code})`);
			});
		}).catch(reason => {
			console.warn(this.alias || this.origin, 'gallery couldn\'t be resolved via API:', reason, '(falling back to HTML parser)');
			return new Promise((resolve, reject) => {
				var urls = [ ];
				getPage(url);

				function getPage(url) {
					GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'document', headers: { Referer: url },
						onload: response => {
							if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
							Array.prototype.push.apply(urls, Array.from(response.document.querySelectorAll('div.list-item-image > a.image-container')).map(a => a.href));
							const next = response.document.querySelector('a[data-pagination="next"][href]');
							if (next == null || !next.href) resolve(urls); else getPage(next.href);
						},
						onerror: response => { reject(defaultErrorHandler(response)) },
						ontimeout: response => { reject(defaultTimeoutHandler(response)) },
					});
				}
			}).then(urls => Promise.all(urls.map(url => imageUrlResolver(url))));
		});
	}

	setSession(requireToken = true, requireLogin = false) {
		let session = { timestamp: Date.now() };
		if (this.uid) session.login = this.uid;
		if (this.password) session.password = this.password;
		if (this.apiKey) {
			session.key = this.apiKey;
			if (!requireToken && !requireLogin) return Promise.resolve(session);
		}
		return globalXHR(this.origin).then(response => {
			let authToken = /\b(?:auth_token)\s*=\s*"(\S+?)"/m.exec(response.responseText);
			if (authToken != null) authToken = authToken[1]; else {
				authToken = response.document.querySelector('input[name="auth_token"][value]');
				if (authToken != null) authToken = authToken.value;
			}
			if (authToken) session.auth_token = authToken; else {
				console.warn('Chevereto auth_token detection failure:', this.alias || this.origin, '\n\n', response.responseText);
				return Promise.reject('auth_token detection failure');
			}
			if (getUser(response)) return session;
			if (!this.configPrefix || !this.uid || !this.password)
				return !requireLogin ? session : Promise.reject('not logged in');
			let payLoad = new URLSearchParams({
				'login-subject': this.uid,
				'password': this.password,
				'auth_token': session.auth_token,
			});
			return globalXHR(this.origin + '/login', {
				headers: { 'Referer': this.origin + '/login' },
				responseType: 'text',
			}, payLoad).then(response => {
				if (!getUser(response)) return Promise.reject('unknown reason');
				console.debug(this.alias || this.origin, 'login session:', session);
				return session;
			}).catch(reason => {
				console.warn('Chevereto login failed:', reason);
				return requireLogin ? Promise.reject('login failed (' + reason + ')') : session;
			});

			function getUser(response) {
				if (/\b(?:logged_user)\s*=\s*(\{.*\});/.test(response.responseText)) try {
					let logged_user = JSON.parse(RegExp.$1);
					session.username = logged_user.username;
					session.userid = logged_user.id;
					return Boolean(logged_user.username || logged_user.id);
				} catch(e) { console.warn(e) }
				return false;
			}
		});
	}
}

class PixHost {
	constructor() {
		this.alias = 'PixHost';
		this.origin = 'https://pixhost.to';
		this.types = ['png', 'jpeg', 'gif'];
		this.sizeLimit = 10;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="name"\r\n\r\n';
			formData += image.name.toASCII() + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="content_type"\r\n\r\n';
			formData += '0\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="ajax"\r\n\r\n';
			formData += 'yes\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: 'https://pixhost.to/new-upload/',
				responseType: 'json',
				headers: {
					'Accept': 'application/json',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve(PixHost.resultHandler(response.response));
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		})));
	}

	rehost(urls) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return verifyImageUrls(urls).then(imageUrls => {
			//console.debug('rehost2PixHost(...) input:', imageUrls.join('\n'));
			let formData = new URLSearchParams({
				imgs: imageUrls.join('\r\n'),
				content_type: 0,
				tos: 'on',
			});
			return globalXHR(this.origin + '/remote/', {
				responseType: 'text',
				timeout: imageUrls.length * rehostTimeout * 1000,
			}, formData).then(({responseText}) => {
				if (!/\b(?:upload_results)\s*=\s*(\{.*\});$/m.test(responseText))
					return Promise.reject('page parsing error');
				let images = JSON.parse(RegExp.$1).images;
				if (images.length < imageUrls.length)
					return Promise.reject(`not all images rehosted (${images.length}/${imageUrls.length})`);
				if (images.length > imageUrls.length)
					console.warn('PixHost: server returns more results than expected:', images.length, imageUrls.length);
				return Promise.all(images.map(PixHost.resultHandler));
			});
		});
	}

	static resultHandler(result) {
		try {
			return imageUrlResolver(result.show_url).then(imgUrl => ({
				original: imgUrl,
				thumb: result.th_url,
				share: result.show_url,
			}));
		} catch(e) { return Promise.reject(e) }
	}
}

class Catbox {
	constructor() {
		this.alias = 'Catbox';
		this.origin = 'https://catbox.moe';
		this.sizeLimit = 200;
		if ((this.userHash = GM_getValue('catbox_userhash')) === undefined) GM_setValue('catbox_userhash', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().catch(reason => undefined).then(userHash => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="reqtype"\r\n\r\n';
			formData += 'fileupload\r\n';
			formData += '--' + boundary + '\r\n';
			if (userHash) {
				formData += 'Content-Disposition: form-data; name="userhash"\r\n\r\n';
				formData += userHash + '\r\n';
				formData += '--' + boundary + '\r\n';
			}
			formData += 'Content-Disposition: form-data; name="fileToUpload"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/user/api.php',
				responseType: 'text',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve(response.responseText);
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return this.setSession().catch(reason => undefined).then(userHash => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
			let formData = new URLSearchParams({
				reqtype: 'urlupload',
				url: imageUrl,
			});
			if (userHash) formData.set('userhash', userHash);
			return globalXHR(this.origin + '/user/api.php', {
				responseType: 'text',
				timeout: urls.length * rehostTimeout * 1000,
			}, formData).then(({responseText}) => {
				if (typeof progressHandler == 'function') progressHandler(true);
				return responseText;
			});
		}))));
	}

	setSession() {
		return this.userHash ? Promise.resolve(this.userHash) : globalXHR(this.origin).then(response => {
			var userHash = response.document.querySelector('input[name="userhash"][value]');
			if (userHash == null) return Promise.reject('userhash not configured; please log-in to Catbox.moe to autodetect it');
			if (!(this.userHash = userHash.value)) return Promise.reject('assertion failed: empty userhash value');
			GM_setValue('catbox_userhash', this.userHash);
			return this.userHash;
		});
	}
}

class ImgBox {
	constructor() {
		this.alias = 'ImgBox';
		this.origin = 'https://imgbox.com';
		this.types = ['jpeg', 'gif', 'png'];
		this.sizeLimit = 10;
		if ((this.uid = GM_getValue('imgbox_uid')) === undefined) GM_setValue('imgbox_uid', '');
		if ((this.password = GM_getValue('imgbox_password')) === undefined) GM_setValue('imgbox_password', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			Object.keys(session.params).forEach((field, index, arr) => {
				formData += 'Content-Disposition: form-data; name="' + field + '"\r\n\r\n';
				formData += session.params[field] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="files[]"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload/process',
				headers: {
					'Accept': 'application/json',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'X-CSRF-Token': session.csrf_token,
				},
				data: formData,
				responseType: 'json',
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve({
						original: response.response.files[0].original_url,
						thumb: response.response.files[0].thumbnail_url,
						share: response.response.files[0].url,
					}); else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	setSession() {
		return globalXHR(this.origin + '/').then(response => {
			let csrfToken = response.document.querySelector('meta[name="csrf-token"]');
			if (csrfToken == null) return Promise.reject('ImgBox.com session token not found');
			console.debug('ImgBox.com session token:', csrfToken.content);
			if (response.document.querySelector('div.btn-group > ul.dropdown-menu') != null) return csrfToken.content;
			if (!this.uid || !this.password) return csrfToken.content;
			let formData = new URLSearchParams({
				"utf8": "✓",
				"authenticity_token": csrfToken.content,
				"user[login]": this.uid,
				"user[password]": this.password,
			});
			GM_xmlhttpRequest({ method: 'POST', url: 'https://imgbox.com/login', headers: {
				'Referer': 'https://imgbox.com/login',
				'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
			}, data: formData.toString() });
			return new Promise((resolve, reject) => {
				setTimeout(() => { globalXHR('http://imgbox.com/').then(response => {
					if (response.document.querySelector('div.btn-group > ul.dropdown-menu') == null)
						console.warn('ImgBox.com login failed, continuing as anonymous', response);
					if ((csrfToken = response.document.querySelector('meta[name="csrf-token"]')) != null) {
						console.debug('ImgBox.com session token after login:', csrfToken.content);
						resolve(csrfToken.content);
					} else reject('ImgBox.com session token not found');
				}) }, 1000);
			});
		}).then(csrfToken => globalXHR(this.origin + '/ajax/token/generate', {
			method: 'POST',
			responseType: 'json',
			headers: { 'X-CSRF-Token': csrfToken },
		}).then(({response}) => ({
			csrf_token: csrfToken,
			params: {
				token_id: response.token_id,
				token_secret: response.token_secret,
				content_type: 1,
				thumbnail_size: '150r',
				gallery_id: null,
				gallery_secret: null,
				comments_enabled: 0,
			},
		})));
	}
}

class Imgur {
	constructor() {
		this.alias = 'Imgur';
		this.origin = 'https://imgur.com';
		this.ulEndpoint = 'https://api.imgur.com/3/image';
		this.types = ['jpeg', 'png', 'gif', 'apng', 'tiff', 'bmp', 'icf', 'webp'];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(clientId => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="type"\r\n\r\n';
			formData += 'file\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="name"\r\n\r\n';
			formData += image.name + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="image"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.ulEndpoint + '?client_id=' + encodeURIComponent(clientId),
				responseType: 'json',
				headers: { 'Content-Type': 'multipart/form-data; boundary=' + boundary },
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) {
						if (response.response.success) resolve(this.resultHandler(response.response));
							else reject(response.response.status);
					} else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return this.setSession().then(clientId => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
			let formData = new URLSearchParams({ image: imageUrl, type: 'URL' });
			return globalXHR(this.ulEndpoint + '?client_id=' + encodeURIComponent(clientId), {
				responseType: 'json',
				timeout: urls.length * rehostTimeout * 1000,
			}, formData).then(({response}) => {
				if (!response.success) return Promise.reject(response.status);
				if (typeof progressHandler == 'function') progressHandler(true);
				return this.resultHandler(response);
			});
		}))));
	}

	setSession() {
		if ('imgurClientId' in sessionStorage) return Promise.resolve(sessionStorage.getItem('imgurClientId'));
		return globalXHR(this.origin).then(({document}) => {
			let jsMain = document.querySelector('body > script:last-of-type');
			return jsMain != null && jsMain.src.includes('/desktop-assets/js/main.') ? globalXHR(jsMain.src, {
				responseType: 'text',
			}).then(({responseText}) => {
				let clientId = /\b[a-z]="([a-z\d]{15})"/.exec(responseText);
				if (clientId == null) return Promise.reject('client id extraction failed');
				sessionStorage.setItem('imgurClientId', clientId[1]);
				return clientId[1];
			}) : Promise.reject('/js/main not located');
		});
	}

	resultHandler(result) {
		return {
			original: result.data.link,
			thumb: result.data.link.replace(/(?=\.\w+$)/, 't'),
			share: this.origin + '/' + result.data.id,
		};
	}
}

class PostImage {
	constructor() {
		this.alias = 'PostImage';
		this.origin = 'https://postimages.org';
		this.sizeLimit = 24;
		if ((this.uid = GM_getValue('postimg_uid')) === undefined) GM_setValue('postimg_uid', '');
		if ((this.password = GM_getValue('postimg_password')) === undefined) GM_setValue('postimg_password', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', payLoad = Object.assign({
				numfiles: images.length,
				optsize: 0,
				expire: 0,
			}, session);
			Object.keys(payLoad).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += payLoad[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/json/rr',
				responseType: 'json',
				headers: {
					'Accept': 'application/json',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.status == 'OK') resolve(response.response.url); else reject(response.response.error);
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))).then(PostImage.resultsHandler));
	}

	rehost(urls, progressHandler = null, temporary = false) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return this.setSession().then(session => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
			let formData = new URLSearchParams(Object.assign({
				url: imageUrl,
				numfiles: urls.length,
				optsize: 0,
				expire: 0,
			}, session));
			if (temporary) formData.set('expire', 1);
			return globalXHR(this.origin + '/json/rr', {
				responseType: 'json',
				timeout: urls.length * rehostTimeout * 1000,
			}, formData).then(({response}) => {
				if (response.status != 'OK') return Promise.reject(response.error);
				if (typeof progressHandler == 'function') progressHandler(true);
				return response.url;
			});
		}))).then(PostImage.resultsHandler));
	}

	static resultsHandler(urls) {
		const imgBase = 'https://i.postimg.cc/', shareBase = 'https://postimg.cc/';
		return Promise.all(urls.map(url => globalXHR(url).then(response => {
			try {
				let embed_value = /\b(?:embed_value)=(\{.+?\});/.exec(response.responseText);
				if (embed_value == null) throw 'no embed_value';
				var results1 = Object.keys(embed_value = JSON.parse(embed_value[1])).map(key => ({
					original: imgBase + embed_value[key][2] + '/' + embed_value[key][0] + '.' + embed_value[key][1],
					thumb: imgBase + key + '/' + embed_value[key][0] + '.' + embed_value[key][1],
					share: shareBase + key,
				}));
			} catch(e) { results1 = undefined }
			try {
				var results2 = Array.from(response.document.querySelectorAll('div#thumb-list > div.thumb-container')).map(div => ({
					original: imgBase + div.dataset.hotlink + '/' + div.dataset.name + '.' + div.dataset.ext,
					thumb: imgBase + div.dataset.image + '/' + div.dataset.name + '.' + div.dataset.ext,
					share: shareBase + div.dataset.image,
				}));
			} catch(e) { results2 = undefined }
			if (Array.isArray(results1) && results1.length > 0) return results1;
				else if (Array.isArray(results2) && results2.length > 0) return results2;
			let result = {
				thumb: 'input#code_web_thumb',
				share: 'input#code_html',
			};
			for (let key in result) try { result[key] = response.document.querySelector(result[key]).value.trim() } catch(e) {
				console.error(e, result[key]);
				result[key] = undefined;
			}
			return result.share ? globalXHR(result.share).then(function({document}) {
				result.original = document.querySelector('a#download');
				if (result.original == null) return Promise.reject('PostImage: full image not found');
				result.original = result.original.origin + result.original.pathname;
				if (result.thumb) result.thumb = /\b(?:src)='(.*?)'/i.exec(result.thumb) || /\b(?:src)="(.*?)"/i.exec(result.thumb);
				result.thumb = result.thumb ? result.thumb[1] : undefined;
				return [result];
			}) : Promise.reject('PostImage: share page link not found');
		}))).then(r => r.flatten());
	}

	setSession() {
		let session = {
			session_upload: Date.now(),
			upload_session: randomString(32),
			upload_referer: btoa(this.origin + '/'),
		};
		return globalXHR(this.origin).then(response => {
			if ((session.token = /"token"\s*,\s*"(\w+)"/.exec(response.responseText)) != null) session.token = session.token[1];
			if (response.document.querySelector('nav.authorized') != null || !this.uid || !this.password) return response;
			return globalXHR(this.origin + '/login').then(response => {
				let csrfHash = response.document.querySelector('input[name="csrf_hash"][value]');
				if (csrfHash == null) {
					console.warn('postImage: csrf hash not found');
					return response;
				}
				let payLoad = new URLSearchParams({
					'email': this.uid,
					'password': this.password,
					'csrf_hash': csrfHash.value,
				});
				return globalXHR(this.origin + '/login', { headers: { 'Referer': this.origin + '/login' } }, payLoad);
			});
		}).then(response => {
			if ((session.user = response.document.querySelector('nav.authorized a > i.fa-user')) != null)
				session.user = session.user.nextSibling.textContent.trim();
			return session;
		});
	}
}

class ImageVenue {
	constructor() {
		this.alias = 'ImageVenue';
		this.origin = 'https://www.imagevenue.com';
		this.types = ['jpeg', 'png', 'gif'];
		if ((this.uid = GM_getValue('imagevenue_uid')) === undefined) GM_setValue('imagevenue_uid', '');
		if ((this.password = GM_getValue('imagevenue_password')) === undefined) GM_setValue('imagevenue_password', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return this.setSession().then(session => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			Object.keys(session).forEach((field, index, arr) => {
				formData += 'Content-Disposition: form-data; name="' + field + '"\r\n\r\n';
				formData += session[field] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			images.forEach((image, index, arr) => {
				formData += 'Content-Disposition: form-data; name="files[' + index + ']"; filename="' + image.name.toASCII() + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary;
				if (index + 1 >= arr.length) formData += '--';
				formData += '\r\n';
			});
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload',
				headers: {
					'Accept': 'application/json',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
				},
				data: formData,
				responseType: 'json',
				binary: true,
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					resolve(response.response.success);
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		})).then(resultUrl => globalXHR(resultUrl)).then(response => {
			let thumbs = response.document.querySelectorAll('div.row > div > a > img');
			return Promise.all(Array.from(thumbs).map(img => imageUrlResolver(img.parentNode.href).then(imgUrl => ({
				original: imgUrl,
				thumb: img.src,
				share: img.parentNode.href,
			}))));
		});
	}

	setSession() {
		return globalXHR(this.origin + '/').then(response => {
			let csrfToken = response.document.querySelector('meta[name="csrf-token"]');
			if (csrfToken == null) return Promise.reject('ImageVenue.com session token not found');
			console.debug('ImageVenue.com session token:', csrfToken.content);
			if (response.document.getElementById('navbarDropdown') != null) return csrfToken.content;
			if (!this.uid || !this.password) return csrfToken.content;
			let formData = new URLSearchParams({
				'_token': csrfToken.content,
				'email': this.uid,
				'password': this.password,
			});
			GM_xmlhttpRequest({ method: 'POST', url: this.origin + '/auth/login', headers: {
				'Referer': this.origin + '/auth/login',
				'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
			}, data: formData.toString() });
			return new Promise((resolve, reject) => {
				setTimeout(() => {
					globalXHR(this.origin + '/').then(response => {
						if (response.document.getElementById('navbarDropdown') == null)
							console.warn('ImageVenue.com login failed, continuing as anonymous', response);
						if ((csrfToken = response.document.querySelector('meta[name="csrf-token"]')) != null) {
							console.debug('ImageVenue.com session token after login:', csrfToken.content);
							resolve(csrfToken.content);
						} else reject('ImageVenue.com session token not found');
					});
				}, 1000);
			});
		}).then(csrfToken => globalXHR(this.origin + '/upload/session', {
			responseType: 'json',
			headers: { 'X-CSRF-TOKEN': csrfToken },
		}, new URLSearchParams({
			thumbnail_size: 2,
			content_type: 'sfw',
			comments_enabled: false,
		})).then(({response}) => ({
			data: response.data,
			_token: csrfToken,
		})));
	}
}

class FastPic {
	constructor() {
		this.alias = 'FastPic';
		this.origin = 'https://fastpic.ru';
		this.types = ['jpeg', 'png', 'gif'];
		this.sizeLimit = 25;
		this.batchLimit = 30;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			images.forEach(image => {
				formData += 'Content-Disposition: form-data; name="file[]"; filename="' + image.name.toASCII() + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="uploading"\r\n\r\n';
			formData += '1\r\n';
			formData += '--' + boundary + '--\r\n';
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/uploadmulti',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (/^\s*(?:Refresh)\s*:\s*(\d+);url=(\S+)\s*$/im.test(response.responseHeaders)) resolve(RegExp.$2); else {
						console.warn('FastPic.ru invalid response header:', response.responseHeaders);
						reject('invalid response header');
					}
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(resultUrl => globalXHR(resultUrl).then(response => {
			let thumbs = Array.from(response.document.querySelectorAll('div.picinfo > div.dCenter > a > img')).map(img => img.src);
			return Promise.all(Array.from(response.document.querySelectorAll('ul.codes-list > li:first-of-type > input'))
					.map((input, index) => globalXHR(input.value).then(response => ({
				original: response.document.querySelector('img.image').src,
				thumb: thumbs[index],
				share: response.finalUrl,
			}))));
			console.warn(`FastPic.ru: not all images uploaded (${directLinks.length}/${images.length})`, response.finalUrl);
			return Promise.reject(`not all images uploaded (${directLinks.length}/${images.length})`);
		}));
	}
}

class NWCD {
	constructor() {
		this.alias = 'NotWhatCd';
		this.whitelist = ['notwhat.cd'];
		this.types = ['jpeg', 'png', 'gif'];
		this.sizeLimit = 5;
		this.upload.acceptFiles = true;
	}

	upload(files) {
		if (!Array.isArray(files)) return Promise.reject('invalid argument');
		files = files.filter(file => isSupportedType.call(this, file)
			&& (!(this.sizeLimit > 0) || !file.size || file.size <= this.sizeLimit * 2**20));
		if (files.length <= 0) return Promise.reject('nothing to upload');
		return NWCD.loadJS().then(upload => Promise.all(files.map(upload)).then(results =>
			results.map(result => result.url || Promise.reject('site image uploader returned void result'))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return NWCD.loadJS().then(upload => Promise.all(urls.map(url => verifyImageUrl(url).then(upload).then(result => {
			if (typeof progressHandler == 'function') progressHandler(true);
			return result.url || Promise.reject('site image uploader returned void result');
		}))));
	}

	static loadJS() {
		if (document.domain != 'notwhat.cd') return Promise.reject('uploadToImagehost not available');
		return typeof uploadToImagehost == 'function' ? Promise.resolve(uploadToImagehost) : new Promise((resolve, reject) => {
			let imageUpload = document.createElement('script');
			imageUpload.type = 'text/javascript';
			imageUpload.src = '/static/functions/image_upload.js';
			imageUpload.onload = evt => {
				if (typeof uploadToImagehost == 'function') resolve(uploadToImagehost);
					else reject('uploadToImagehost() not loaded'); // assertion fail
			};
			imageUpload.onerror = evt => { reject('Script load error: ' + evt.message) };
			document.head.append(imageUpload);
		});
	}
}

class Abload {
	constructor() {
		this.alias = 'Abload';
		this.origin = 'https://abload.de';
		this.types = ['bmp', 'bmp2', 'bmp3', 'gif', 'jpeg', 'png'];
		this.sizeLimit = 10;
		this.batchLimit = 20;
		if ((this.uid = GM_getValue('abload_uid')) === undefined) GM_setValue('abload_uid', '');
		if ((this.password = GM_getValue('abload_password')) === undefined) GM_setValue('abload_password', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let id = 'o_' + (index + 1).toString().padStart(2, '0') + randomString(28).toLowerCase(), params = {
				name: id, // + image.type.replace('image/', '.'),
				chunk: 0,
				chunks: 1,
			}, formData = '--' + boundary + '\r\n';
			Object.keys(params).forEach(field => {
				formData += 'Content-Disposition: form-data; name="' + field + '"\r\n\r\n';
				formData += params[field] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			Object.keys(session).forEach(field => {
				formData += 'Content-Disposition: form-data; name="' + field + '"\r\n\r\n';
				formData += session[field] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: 'https://' + session.server + '.abload.de/calls/newUpload.php',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin + '/',
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve({ id: id, name: image.name });
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))).then(uploadMapping => {
			let formData = new URLSearchParams(Object.assign({}, session, {
				resize: 'none',
				rules: 'on',
				gallery: '',
				upload_mapping: JSON.stringify(uploadMapping).replace(/"/g, '\\"'),
			}));
			return globalXHR('https://' + session.server + '.abload.de/flashUploadFinished.php?server=' + session.server, {
				headers: { Referer: this.origin + '/' },
			}, formData).then(Abload.resolveRedirect);
		}));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return this.setSession().then(session => verifyImageUrls(urls).then(imageUrls => {
			let formData = { };
			imageUrls.forEach((imageUrl, index) => { formData['img' + index] = imageUrl });
			formData = new URLSearchParams(Object.assign(formData, session, {
				resize: 'none',
				rules: 'on',
				gallery: '',
				upload_mapping: JSON.stringify([]),
			}));
			return globalXHR('https://' + session.server + '.abload.de/flashUploadFinished.php?server=' + session.server, {
				headers: { Referer: this.origin + '/' },
				timeout: imageUrls.length * rehostTimeout * 1000,
			}, formData).then(Abload.resolveRedirect);
		}));
	}

	static resolveRedirect(response) {
		let form = response.document.querySelector('form#weiter');
		if (form == null) return Promise.reject(response.responseText);
		let formData = new FormData(form);
		formData = new URLSearchParams(formData);
		return globalXHR(form.action, { headers: { Referer: response.finalUrl } }, formData).then(response =>
			Array.from(response.document.body.querySelectorAll('table.image_links > tbody > tr > td > input[type="text"]'))
				.filter(input => httpParser.test(input.value)
					&& input.parentNode.previousElementSibling.textContent.startsWith('Dire'))
				.map(input => ({
					original: input.value.trim(),
					thumb: input.value.trim().replace('/img/', '/thumb/'),
					share: input.value.trim().replace('/img/', '/image.php?img='),
				}))
		);
	}

	setSession() {
		return globalXHR(this.origin).then(response => {
			var session = { userID: randomUser(32) };
			if (!/^(?:Server)\s*:\s*(?:Abload)\s+(\w+)\b/im.test(response.responseHeaders))
				return Promise.reject('Invalid response header');
			session.server = RegExp.$1;
			if (/\b(?:user_logged_in)\s*=\s*true\b/.test(response.responseText)) return session;
			if (!this.uid || !this.password) return session;
			let formData = new URLSearchParams({ name: this.uid, password: this.password });
			return globalXHR(this.origin + '/login.php', {
				method: 'HEAD',
				headers: { 'Referer': this.origin },
			}, formData).catch(reason => { console.warn(reason) }).then(response => session);

			function randomUser(length) {
				const possible = "abcdefABCDEF0123456789";
				let text = "";
				for (let i = 0; i < length; ++i) text += possible.charAt(Math.floor(Math.random() * possible.length));
				return text;
			}
		});
	}
}

class Radikal {
	constructor() {
		this.alias = 'Radikal';
		this.origin = 'https://radikal.ru';
		this.sizeLimit = 40;
		if ((this.uid = GM_getValue('radikal_uid')) === undefined) GM_setValue('radikal_uid', '');
		if ((this.password = GM_getValue('radikal_password')) === undefined) GM_setValue('radikal_password', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', params = {
				OriginalFileName: image.name,
				MaxSize: 99999,
				PrevMaxSize: 500,
				IsPublic: false,
				NeedResize: false,
				Rotate: 0,
				RotateMetadataRelative: false,
			};
			Object.keys(params).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += params[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="File"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/Img/SaveImg2',
				responseType: 'json',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin + '/',
					'Cookie': 'USER_ID=' + session.Id || '',
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.IsError)
						return reject(`${response.response.ErrorSrvMsg} (${response.response.Errors._allerrors_.join(' / ')})`);
					resolve({
						original: response.response.Url,
						thumb: response.response.PublicPrevUrl,
						share: response.response.PrevPageUrl,
					});
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return this.setSession().then(session => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
			let formData = new URLSearchParams({
				OriginalFileName: imageUrl,
				MaxSize: 99999,
				PrevMaxSize: 500,
				IsPublic: false,
				NeedResize: false,
				Rotate: 0,
				RotateMetadataRelative: false,
				Url: imageUrl,
			});
			return globalXHR(this.origin + '/Img/SaveImg2', {
				responseType: 'json',
				headers: { 'Referer': this.origin },
				cookie: 'USER_ID=' + session.Id || '',
				timeout: urls.length * rehostTimeout * 1000,
			}, formData).then(({response}) => {
				if (response.IsError)
					return Promise.reject(`${response.ErrorSrvMsg} (${response.Errors._allerrors_.join(' / ')})`);
				if (typeof progressHandler == 'function') progressHandler(true);
				return {
					original: response.Url,
					thumb: response.PublicPrevUrl,
					share: response.PrevPageUrl,
				};
			});
		}))));
	}

	setSession() {
		return globalXHR(this.origin, { responseType: 'text' }).then(response => {
			let session = getUserInfo(response);
			if (!session) return Promise.reject('Invalida page format');
			if (!session.IsAnonym) return session;
			if (!this.uid || !this.password) return session;
			let formData = new URLSearchParams({
				Login: this.uid,
				Password:  this.password,
				IsRemember: false,
				ReturnUrl: '/',
			});
			return globalXHR(this.origin + '/Auth/Login', {
				responseType: 'json',
				headers: { 'Referer': this.origin },
			}, formData).then(({response}) => response.IsError ? session
				: globalXHR(this.origin, { responseType: 'text' }).then(response => getUserInfo(response) || session),
					reason => { console.warn(reason) });
		});

		function getUserInfo({responseText}) {
			if (/\b(?:var\s+serverVm)\s*=\s*(\{.*\});$/m.test(responseText)) try {
				return JSON.parse(RegExp.$1).CommonUserData;
			} catch(e) { console.warn(e) }
			return null;
		}
	}
}

class SVGshare {
	constructor() {
		this.alias = 'SVGshare';
		this.origin = 'https://svgshare.com';
		this.types = ['svg+xml'];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(submitUrl => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', params = {
				name: image.name,
				submit: 'Share',
			};
			Object.keys(params).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += params[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: submitUrl,
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				fetch: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					domParser.parseFromString(response.responseText, 'text/html')
						.querySelectorAll('ul#shares > li > input[type="text"]')
						.forEach(input => { if (/^(?:https?:\/\/.+\.svg)$/.test(input.value)) resolve(input.value) });
					reject('image URL could not be found');
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	setSession() {
		return globalXHR(this.origin).then(response => {
			let form = response.document.getElementById('filereader');
			return form != null && form.action || Promise.reject('Invalid document format');
		});
	}
}

class GeekPic {
	constructor() {
		this.alias = 'GeekPic';
		this.origin = 'https://geekpic.net';
		//this.types = [];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/ajax.php?PHPSESSID=' + randomString(26).toLowerCase(),
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				responseType: 'json',
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (!response.response.success) return reject(response.response.msg);
					resolve(this.origin + response.response.img);
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(imageUrlResolver)));
	}
}

class LightShot {
	constructor() {
		this.alias = 'LightShot';
		this.origin = 'https://prntscr.com';
		//this.types = [];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(userInfo => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="image"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload.php',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				responseType: 'json',
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.status == 'success') resolve(response.response.data);
						else reject(response.response.status);
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(imageUrlResolver))));
	}

	setSession() {
		let params = {
			id: 1,
			jsonrpc: '2.0',
			method: 'get_userinfo',
			params: {},
		};
		return globalXHR('https://api.prntscr.com/v1/', { responseType: 'json' }, JSON.stringify(params)).then(({response}) => {
			if (response.result.success) return response.result;
			// TODO: login
			return response.result;
		});
	}
}

class ImageBan {
	constructor() {
		this.alias = 'ImageBan';
		this.origin = 'https://imageban.ru';
		this.types = ['jpeg', 'png', 'gif', 'webp'];
		this.sizeLimit = 10;
		this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="Filedata"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/up',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				responseType: 'json',
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.files[0].error) return reject(response.response.files[0].error);
					resolve({
						original: response.response.files[0].link,
						thumb: response.response.files[0].thumbs,
						share: response.response.files[0].piclink,
					});
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject(`batch limit exceeded (${this.batchLimit})`);
		return this.setSession().then(session => verifyImageUrls(urls).then(imageUrls => {
			let formData = new URLSearchParams(Object.assign({
				u_url: imageUrls.join('\n'),
			}, session));
			return globalXHR(this.origin + '/urlup', {
				headers: { 'Referer': this.origin },
				timeout: imageUrls.length * rehostTimeout * 1000,
			}, formData).then(response => Array.from(response.document.querySelectorAll('div.container > div[align="left"] ~ div.row')).map(row => ({
				original: row.querySelector('div.input-group > input[id^="g"]').value,
				thumb: row.querySelector(':scope > a > img').src,
				share: row.querySelector('div.input-group > input[id^="a"]').value,
			})));
		}));
	}

	setSession() {
		return Promise.resolve({});
	}
}

class PicaBox {
	constructor() {
		this.alias = 'PicaBox';
		this.origin = 'https://picabox.ru';
		//this.types = ['jpeg', 'png', 'gif', 'webp'];
		//this.sizeLimit = 10;
		//this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			Object.keys(session).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += session[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="ImagesForm[imageFiles][]"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/image/load',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin + '/image/load',
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				fetch: true,
				cookie: '_csrf=' + session._csrf,
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve(this.extractLinks(response));
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(results => results[0]))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject(`batch limit exceeded (${this.batchLimit})`);
		return this.setSession().then(session => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
			let formData = new URLSearchParams(session);
			formData.set('ImagesForm[file_url]', imageUrl);
			return globalXHR(this.origin + '/image/load', {
				responseType: 'text',
				fetch: true,
				headers: { 'Referer': this.origin + '/image/load' },
				timeout: urls.length * rehostTimeout * 1000,
				cookie: '_csrf=' + session._csrf,
			}, formData).then(PicaBox.prototype.extractLinks.bind(this)).then(results => {
				if (typeof progressHandler == 'function') progressHandler(true);
				return results[0];
			});
		}))));
	}

	extractLinks({responseText}) {
		return Promise.all(Array.from(domParser.parseFromString(responseText, 'text/html')
				.querySelectorAll('input[name="url"]')).map(input => imageUrlResolver(input.value).then(imgUrl => ({
			original: imgUrl,
			thumb: this.origin + '/img_small/' + input.value.replace(/^.*\//, ''),
			share: input.value,
		}))));
	}

	setSession() {
		return globalXHR(this.origin + '/image/load').then(response => {
			let formData = response.document.querySelector('form[name="form_image"]');
			if (formData == null) return Promise.reject('Invalid document format');
			formData = new FormData(formData);
			let session = { }, val, it = formData.entries();
			for (let [key, val] of formData) session[key] = val;
			['ImagesForm[file_url]', 'ImagesForm[imageFiles][]', 'imagesform-text_color-source']
				.forEach(key => { delete session[key] });
			return session;
		});
	}
}

class PimpAndHost {
	constructor() {
		this.alias = 'PimpAndHost';
		this.origin = 'https://pimpandhost.com';
		this.types = ['jpeg', 'png', 'gif'];
		this.sizeLimit = 5 * 1000 / 2**10;
		this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', params = {
				fileId: `${index.toString().padStart(3, '0')}_${session.albumId}`,
				albumId: session.albumId,
			};
			Object.keys(params).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += params[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="files"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/image/upload-file',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin + '/album/' + session.albumId,
					'X-CSRF-Token': session['csrf-token'],
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				responseType: 'json',
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					resolve({
						original: 'https:' + response.response.files[0].image[0],
						thumb: 'https:' + response.response.files[0].image[180],
						share: response.response.files[0].pageUrl,
					});
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject(`batch limit exceeded (${this.batchLimit})`);
		return this.setSession().then(session => Promise.all(urls.map((url, index) => (() => {
			return['png', 'jpg', 'jpeg', 'jfif', 'gif'].some(ext => url.toLowerCase().endsWith('.' + ext)) ?
				verifyImageUrl(url) : imageHostHandlers.imgbb.rehost([url]).then(imgUrls => imgUrls[0])
					.catch(reason => imageHostHandlers.jerking.rehost([url]).then(imgUrls => imgUrls[0]))
					.catch(reason => imageHostHandlers.pixhost.rehost([url]).then(imgUrls => imgUrls[0]))
		})().then(imageUrl => {
			let formData = new URLSearchParams({
				url: imageUrl,
				field: `${index.toString().padStart(3, '0')}_${session.albumId}`,
				albumId: session.albumId,
			});
			return globalXHR(this.origin + '/image/upload-by-url', {
				responseType: 'json',
				headers: {
					'Referer': this.origin + '/album/' + session.albumId,
					'X-CSRF-Token': session['csrf-token'],
				},
				timeout: urls.length * rehostTimeout * 1000,
			}, formData).then(({response}) => {
				if (response.status != 'ok') return Promise.reject(response.message);
				if (response.file.error) return Promise.reject(response.file.error.title);
				if (typeof progressHandler == 'function') progressHandler(true);
				return {
					original: 'https:' + response.file.image[0],
					thumb: 'https:' + response.file.image[180],
					share: response.file.pageUrl,
				};
			});
		}))));
	}

	setSession() {
		return globalXHR(this.origin).then(response => {
			let meta = response.document.querySelector('meta[name="csrf-token"][content]');
			if (meta == null) return Promise.reject('Invalid document structure');
			let session = { 'csrf-token': meta.content };
			return globalXHR(this.origin + '/album/create-by-uploading', {
				headers: { 'X-CSRF-Token': session['csrf-token'] },
				responseType: 'json',
			}).then(({response}) => {
				session.albumId = response.albumId;
				return session;
			});
		});
	}
}

class ScreenCast {
	constructor() {
		this.alias = 'ScreenCast';
		this.origin = 'https://www.screencast.com';
		//this.types = [];
		//this.sizeLimit = 10;
		//this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
	}

	rehost(urls, progressHandler = null) {
	}

	setSession() {
	}
}

class GoogleAPI {
	constructor(scope) {
		this.origin = 'https://www.googleapis.com';
		this.clientId = '241768952066-r0pojdg0l8m4nqr31psf8rb01btt43c4.apps.googleusercontent.com';
		this.apiKey = 'lk9MZc7eSYzi6tDQ-H6jeC-2';
		this.scope = scope;
	}

	setSession() {
		return this.isTokenValid() ? Promise.resolve(this.token)
				: (this.auth ? Promise.resolve(this.auth) : (typeof gapi == 'object' ? Promise.resolve(gapi) : new Promise((resolve, reject) => {
			let gApi = document.createElement('script');
			gApi.type = 'text/javascript';
			gApi.src = 'https://apis.google.com/js/api.js';
			gApi.onload = evt => { if (typeof gapi == 'object') resolve(gapi); else reject('Google API loading error') };
			gApi.onerror = evt => { reject('Script load error: ' + evt.message ) };
			document.head.append(gApi);
		})).then(gapi => new Promise((resolve, reject) => gapi.load('client:auth2', {
			callback: () => {
				gapi.client.init({
					clientId: this.clientId,
					//apiKey: this.apiKey,
					scope: this.scope,
					discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'],
				}).then(() => { resolve(this.auth = gapi.auth2.getAuthInstance()) }, error => reject(JSON.stringify(error)));
			},
			onerror: () => { reject('Google API loading error') },
		})))).then(auth => auth.isSignedIn.get() ? auth.currentUser.get() : auth.signIn().catch(e => Promise.reject(e.error)))
			.then(user => (this.token = gapi.client.getToken()));
	}

	isTokenValid() {
		if (!this.token || typeof this.token != 'object' || !this.token.token_type || !this.token.access_token) return false;
		let now = new Date();
		return this.token.expires_at >= now.getTime() + now.getTimezoneOffset() * 60000 + 30000;
	}
}

class GoogleDrive extends GoogleAPI {
	constructor() {
		super('https://www.googleapis.com/auth/drive.file');
		this.alias = 'GoogleDrive';
		//this.types = [];
		//this.sizeLimit = 10;
		//this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(token => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', metadata = {
				'name' : image.name,
				'mimeType' : image.type,
			};
			formData += 'Content-Disposition: form-data; name="metadata"\r\n';
			formData += 'Content-Type: application/json; charset=UTF-8\r\n\r\n';
			formData += JSON.stringify(metadata) + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload/drive/v3/files?uploadType=multipart&fields=id,webContentLink',
				headers: {
					'Content-Type': 'multipart/related; boundary=' + boundary,
					'Authorization': token.token_type + ' ' + token.access_token,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				responseType: 'json',
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					gapi.client.drive.permissions.create({
						fileId: response.response.id,
						resource: { role: 'reader', type: 'anyone' },
					}).execute(result => {
						if (result.id == 'anyoneWithLink') resolve(response.response.webContentLink.replace(/&.*$/i, ''));
							else reject('failed to enable sharing for this file');
					}, error => { reject(JSON.stringify(error)) });
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}
}

class GooglePhotos extends GoogleAPI {
	constructor() {
		super('https://www.googleapis.com/auth/photoslibrary.sharing');
		this.alias = 'GooglePhotos';
		//this.types = [];
		//this.sizeLimit = 10;
		//this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(token => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			if (this.sizeLimit > 0 && image.size > this.sizeLimit * 2**20) throw 'size limit exceeded: ' + image.name;
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			// TODO
		}))));
	}
}

class DropBox {
	constructor() {
		this.alias = 'DropBox';
		this.origin = 'https://www.dropbox.com';
		//this.types = [];
		//this.sizeLimit = 10;
		//this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
	}

	rehost(urls, progressHandler = null) {
	}

	setSession() {
	}
}

class OneDrive {
	constructor() {
		this.alias = 'OneDrive';
		this.origin = 'https://onedrive.live.com';
		//this.types = [];
		//this.sizeLimit = 10;
		//this.batchLimit = 100;
	}

	upload(images, progressHandler = null) {
	}

	rehost(urls, progressHandler = null) {
	}

	setSession() {
	}
}

class VgyMe {
	constructor() {
		this.alias = 'Vgy.me';
		this.origin = 'https://vgy.me';
		this.types = ['jpeg', 'png', 'gif'];
		this.sizeLimit = 20;
		if ((this.userKey = GM_getValue('vgyme_user_key')) === undefined) GM_setValue('vgyme_user_key', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(userKey => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			images.forEach((image, index) => {
				formData += 'Content-Disposition: form-data; name="file[' + index + ']"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="userkey"\r\n\r\n';
			formData += userKey + '\r\n';
			formData += '--' + boundary + '--\r\n';
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				responseType: 'json',
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (!response.response.error) {
						if (Array.isArray(response.response.upload_list)) return resolve(response.response.upload_list);
						if (response.response.image) return resolve([response.response.image]);
						reject('Invalid response');
					} else reject('Error');
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}));
	}

	setSession() {
		return this.userKey ? Promise.resolve(this.userKey)
			: Promise.reject('user key not configured (https://vgy.me/account/details#userkeys)');
	}
}

class ImgURL {
	constructor() {
		this.alias = 'ImgURL';
		this.origin = 'https://www.png8.com';
		//this.origin = 'https://imgurl.org';
		this.types = ['jpeg', 'png', 'gif', 'bmp'];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload/localhost',
				//url: this.origin + '/upload/ftp',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				responseType: 'json',
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.code != 200) return reject('status: ' + response.response.code);
					resolve({
						original: response.response.url,
						thumb: response.response.thumbnail_url,
						share: this.origin + '/img/' + response.response.imgid,
					});
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	setSession() {
		return Promise.resolve({});
	}
}

class Slowpoke {
	constructor() {
		this.alias = 'Slowpoke';
		this.origin = 'https://slow.pics';
		//this.types = ['jpeg', 'png', 'gif', 'bmp'];
		if ((this.uid = GM_getValue('slowpoke_uid')) === undefined) GM_setValue('slowpoke_uid', '');
		if ((this.password = GM_getValue('slowpoke_password')) === undefined) GM_setValue('slowpoke_password', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return this.setSession().then(csrfToken => new Promise((resolve, reject) => {
			const now = Date.now(), boundary = '--------WebKitFormBoundary-' + now.toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', params = {
				collectionName: new Date(now).toISOString(),
				public: false,
				thumbnailSize: 180,
			};
			Object.keys(params).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += params[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			images.forEach((image, index) => {
				formData += 'Content-Disposition: form-data; name="images[' + index + '].name"\r\n\r\n';
				formData += image.name + '\r\n';
				formData += '--' + boundary + '\r\n';
				formData += 'Content-Disposition: form-data; name="images[' + index + '].file"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary;
				if (index >= images.length - 1) formData += '--';
				formData += '\r\n';
			});
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/api/collection',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
					'X-XSRF-TOKEN': csrfToken,
				},
				data: formData,
				binary: true,
				responseType: 'text',
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					let shareUrl = this.origin + '/c/' + response.responseText;
					console.log('Slowpoke upload gallery link:', shareUrl);
					imageUrlResolver(shareUrl).then(result => resolve(Array.isArray(result) ? result : [result]));
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}));
	}

	setSession() {
		return globalXHR(this.origin + '/login').then(response => {
			if (response.finalUrl.includes(this.origin)) return response;
			if (!this.uid || !this.password) return globalXHR(this.origin);
			let token = response.document.querySelector('input[name="_csrf"][value]');
			if (token == null) return Promise.reject('invlid page structure');
			return new Promise((resolve, reject) => {
				let formData = new URLSearchParams({
					_csrf: token.value,
					username: this.uid,
					password: this.password,
				});
				GM_xmlhttpRequest({ method: 'POST', url: response.finalUrl,
					headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
					data: formData.toString(),
					onload: response => {
						if (['my', 'exit'].some(p => response.finalUrl.endsWith('/' + p))) {
							console.log('Slowpoke successfull login:', response.finalUrl);
							resolve(globalXHR(this.origin + '/login'));
						} else if (response.finalUrl.endsWith('/login?credentials')) reject('invalid userid or password'); else {
							console.warn('Slowpoke unhandled redirect:', response);
							reject('unexpected redirect: ' + response.finalUrl);
						}
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
				});
			}).catch(reason => {
				console.warn('Slowpoke login failed:', reason);
				return globalXHR(this.origin);
			});
		}).then(response => {
			let token = response.document.querySelector('input[name="_csrf"][value]');
			return token != null ? token.value : Promise.reject('invlid page structure (' + response.finalUrl + ')');
		});
	}
}

class FunkyIMG {
	constructor() {
		this.alias = 'FunkyIMG';
		this.origin = 'https://funkyimg.com';
		this.sizeLimit = 4;
		this.types = ['jpeg', 'png', 'gif', 'bmp', 'tiff'];
		if ((this.uid = GM_getValue('funkyimg_uid')) === undefined) GM_setValue('funkyimg_uid', '');
		if ((this.password = GM_getValue('funkyimg_password')) === undefined) GM_setValue('funkyimg_password', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', params = {
				_images: image.name,
				wmText: '',
				wmPos: 'TOPRIGHT',
				wmLayout: 2,
				wmFontSize: 14,
				wmTransparency: 50,
				addInfoType: 'res',
				labelText: '',
			};
			Object.keys(params).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += params[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="images"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload/?' + session,
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				responseType: 'json',
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.success) resolve(this.resultHandler(response.response.jid));
						else reject('failure');
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject(`batch limit exceeded (${this.batchLimit})`);
		return this.setSession().then(session => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
			let formData = new URLSearchParams({
				url: imageUrl,
				wmText: '',
				wmPos: 'TOPRIGHT',
				wmLayout: 2,
				wmFontSize: 14,
				wmTransparency: 50,
				addInfoType: 'res',
				labelText: '',
			});
			return globalXHR(this.origin + '/upload/?' + session, {
				responseType: 'json',
				headers: { 'Referer': this.origin },
				timeout: urls.length * rehostTimeout * 1000,
			}, formData).then(({response}) => response.success ? this.resultHandler(response.jid) : Promise.reject('failure'));
		}))));
	}

	resultHandler(jid) {
		return new Promise((resolve, reject) => {
			//let queries = 0;
			check.call(this);

			function check() {
				globalXHR(this.origin + '/upload/check/' + jid + '?_=' + Date.now(), {
					headers: { 'Referer': this.origin },
					responseType: 'json',
				}).then(({response}) => {
					//++queries;
					if (response.success) try {
						//console.debug('FunkyIMG queries to success:', queries, jid);
						let dom = domParser.parseFromString(response.bit, 'text/html'), result = {
							original: 'ul > li:nth-of-type(2) > input',
							thumb: 'ul > li:nth-of-type(2) > input',
							share: 'ul > li:nth-of-type(1) > input',
						};
						Object.keys(result).forEach(key => { result[key] = dom.querySelector(result[key]).value.trim() });
						result.thumb = result.thumb.replace('/i/', '/p/');
						resolve(result);
					} catch(e) { reject('unexpected response: ' + e) } else setTimeout(check.bind(this), 200);
				}, reject);
			}
		});
	}

	setSession() {
		return globalXHR(this.origin).then(response => {
			let loggedUser = response.document.querySelector('div.inner > span.welcome > b');
			if (loggedUser == null || loggedUser.parentNode.style.display != 'none' || !this.uid || !this.password)
				return Promise.resolve(response);
			let params = new URLSearchParams({
				s: '',
				act: 'Login',
				CODE: '01',
			}), formData = new URLSearchParams({
				referer: this.origin + '/',
				UserName: this.uid,
				PassWord: this.password,
			});
			return globalXHR('https://forum.funkysouls.org/index.php?' + params, { headers: { 'Referer': this.origin } }, formData);
		}).then(response => {
			let loggedUser = response.document.querySelector('div.inner > span.welcome > b');
			loggedUser = loggedUser != null && loggedUser.parentNode.style.display != 'none' ?
				loggedUser.textContent.trim() : undefined;
			return 'fileapi' + Date.now();
		});
	}
}

class Gett {
	constructor() {
		this.alias = 'Ge.tt';
		this.origin = 'http://api.ge.tt';
		//this.sizeLimit = 4;
		//this.types = ['jpeg', 'png', 'gif', 'bmp', 'tiff'];
		if ((this.uid = GM_getValue('gett_uid')) === undefined) GM_setValue('gett_uid', '');
		if ((this.password = GM_getValue('gett_password')) === undefined) GM_setValue('gett_password', '');
		//if ((this.apiKey = GM_getValue('gett_apikey')) === undefined) GM_setValue('gett_apikey', '');
		this.apiKey = 't3bhhhlzg7lb78c0wimxjx4unmihwv6l93n150esdz15anka9k9';
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		if ((images = images.filter(isSupportedType.bind(this))).length <= 0) return Promise.reject('nothing to upload');
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => {
			const auth = 'accesstoken=' + encodeURIComponent(session.accesstoken);
			return globalXHR(this.origin + '/1/shares/create?' + auth, {
				method: 'POST',
				responseType: 'json',
			}).then(({response}) => Promise.all(images.map((image, index) => globalXHR(this.origin + '/1/files/' + response.sharename + '/create?' + auth, {
				responseType: 'json',
			}, {
				filename: image.name,
				size: image.size,
				type: image.type,
			}).then(({response}) => response).then(file => new Promise((resolve, reject) => {
				const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
				let formData = '--' + boundary + '\r\n';
				formData += 'Content-Disposition: form-data; name="' + file.fileid + '"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary + '--\r\n';
				if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
				GM_xmlhttpRequest({ method: 'POST', url: file.upload.posturl,
					headers: { 'Content-Type': 'multipart/form-data; boundary=' + boundary },
					data: formData,
					binary: true,
					timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
					onload: response => {
						if (response.status >= 200 && response.status < 400) resolve(file);
						else reject(defaultErrorHandler(response));
					},
						onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
						onerror: response => { reject(defaultErrorHandler(response)) },
						ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				});
			})).then(file => {
				let result = {
					original: this.origin + '/1/files/' + file.sharename + '/' + file.fileid + '/blob',
					thumb: this.origin + '/1/files/' + file.sharename + '/' + file.fileid + '/blob/thumb',
					share: file.getturl,
				};
				const url = this.origin + '/1/files/' + file.sharename + '/' + file.fileid +
					'/blob?download&referrer=' + encodeURIComponent(session.user.userid);
				return globalXHR(url, {
					method: 'HEAD',
					headers: { 'Referer': this.origin + '/1/files/' + file.sharename + '/' + file.fileid },
				}).then(response => Object.assign(result, { original: response.finalUrl }), reason => result);
			}))));
		});
	}

	setSession() {
		if (Gett.isSessionValid(this.session)) return Promise.resolve(this.session);
		const params = { responseType: 'json' };
		return (this.uid && this.password && this.apiKey ? globalXHR(this.origin + '/1/users/login', params, {
			apikey: this.apiKey,
			email: this.uid,
			password: this.password,
		}) : Promise.reject('user not configured')).catch(reason => globalXHR(this.origin + '/anon/signup', params, {
			apikey: this.apiKey,
		})).then(({response}) => {
			this.session = response;
			this.session.expires_at = Date.now() + this.session.expires;
			return Gett.isSessionValid(this.session) ? this.session : Promise.reject('login invalid');
		});
	}

	static isSessionValid(session) {
		return session && typeof session == 'object' && session.accesstoken && session.expires_at > Date.now() + 30000;
	}
}

class SavePhoto {
	constructor() {
		this.alias = 'SavePhoto';
		this.origin = 'http://savephoto.ru';
		//this.sizeLimit = 4;
		//this.types = ['jpeg', 'png', 'gif', 'bmp', 'tiff'];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		if ((images = images.filter(isSupportedType.bind(this))).length <= 0) return Promise.reject('nothing to upload');
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		const guid = uuid();
		return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="files[]"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({ method: 'POST', url: this.origin + '/Upload/UploadHandler.ashx?id=' + guid,
				headers: { 'Content-Type': 'multipart/form-data; boundary=' + boundary },
				data: formData,
				responseType: 'json',
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
				 if (response.status >= 200 && response.status < 400)
					 if (!response.response.error) resolve(response.response[0]); else reject(response.response.error);
				 else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(result => imageUrlResolver(result.pathFile).then(imageUrl => ({
			original: imageUrl,
			thumb: imageUrl.replace(result.name, result.name + '_t'),
			share: result.pathFile,
		})))));
	}
}

class UuploadIr {
	constructor() {
		this.alias = 'Uupload.ir';
		this.origin = 'https://uupload.ir';
		this.types = ['jpeg', 'gif', 'png', 'bmp', 'psd', 'tiff', 'ico'];
		//this.sizeLimit = 0;
		//this.batchLimit = 4;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			images.forEach((image, index) => {
				formData += 'Content-Disposition: form-data; name="__userfile[]"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="ittl"\r\n\r\n';
			formData += '0\r\n';
			//formData += '86400\r\n'; // for debugging
			formData += '--' + boundary + '--\r\n';
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/process.php',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
					'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				binary: true,
				responseType: 'document',
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					resolve(Array.from(domParser.parseFromString(response.responseText, 'text/html')
						.querySelectorAll('table#linkst > tbody > tr:nth-of-type(2) > td.wpk > input[type="text"]'))
							.map(input => input.value).filter(RegExp.prototype.test.bind(httpParser)));
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(imageUrls => {
			if (imageUrls.length < images.length) {
				console.warn('Upload.ir: server returned less links than expected', images, imgUrls);
				return Promise.reject('server returned less links than expected');
			}
			if (imageUrls.length > images.length)
				console.warn('Upload.ir: server returned more links than expected', images, imgUrls);
			return imageUrls.map(UuploadIr.resultHandler);
		});
	}

	static resultHandler(imageUrl) {
		return {
			original: imageUrl,
			thumb: imageUrl.replace(/(\.\w*)?$/, '_thumb$1'),
		};
	}
}

class CasImages {
	constructor() {
		this.alias = 'CasImages';
		this.origin = 'https://www.casimages.com';
		this.types = ['jpeg', 'gif', 'png'];
		this.sizeLimit = 10;
		//this.batchLimit = 4;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="Filedata"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({ method: 'POST', url: this.origin + '/upload_ano_multi.php',
				headers: {
				 'Content-Type': 'multipart/form-data; boundary=' + boundary,
				 'Referer': this.origin,
				 'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				responseType: 'text',
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve(response.responseText);
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(CasImages.prototype.resultHandler.bind(this))));
	}

	resultHandler(result) {
		let shareUrl = this.origin + '/i/' + result + '.html';
		return imageUrlResolver(shareUrl).then(imageUrl => ({
			original: imageUrl,
			thumb: imageUrl.replace(result, 'mini_' + result),
			share: shareUrl,
		}));
	}
}

class NoelShack {
	constructor() {
		this.alias = 'NoelShack';
		this.origin = 'https://www.noelshack.com';
		this.types = ['jpeg', 'gif', 'png', 'bmp', 'tiff', 'svg+xml'];
		this.sizeLimit = 4;
		//this.batchLimit = 4;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="fichier[]"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({ method: 'POST', url: this.origin + '/envoi.json',
				headers: {
				 'Content-Type': 'multipart/form-data; boundary=' + boundary,
				 'Referer': this.origin,
				 'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				responseType: 'json',
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve(NoelShack.resultHandler(response.response));
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		})));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => globalXHR(this.origin + '/telecharger.json?url=' + imageUrl, {
			responseType: 'json',
			headers: { 'Referer': this.origin },
			timeout: urls.length * rehostTimeout * 1000,
		}).then(({response}) => NoelShack.resultHandler(response)))));
	}

	static resultHandler(result) {
		return result.erreurs == 'null' ? {
			original: result.url,
			thumb: result.mini,
		} : Promise.reject(result.erreurs);
	}
}

class GetaPic {
	constructor() {
		this.alias = 'GetaPic';
		this.origin = 'https://getapic.me';
		//this.types = ['jpeg', 'png', 'gif', 'bmp'];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return this.setSession().then(session => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n', params = {
				getpreviewsize: 200,
				upload_quality: 100,
				getpreviewalt: 0,
				getreduceimage: 0,
				needreduce: 0,
				upload_angle: 0,
				upload_resizeside: 0,
				gettypeofdownload: 'N',
			};
			Object.keys(params).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += params[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			Object.keys(session).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += session[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			images.forEach((image, index) => {
				formData += 'Content-Disposition: form-data; name="file[]"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary;
				if (index >= images.length - 1) formData += '--';
				formData += '\r\n';
			});
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin,
					'X-Requested-With': 'XMLHttpRequest',
				},
				cookie: 'getapime=' + session.session,
				data: formData,
				binary: true,
				responseType: 'json',
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					try {
						if (!response.response.result.success) return reject(response.response.result.errors);
						resolve(response.response.result.data.url);
					} catch(e) {
						console.error('GetaPic invalid response structure:', response.response, e);
						reject(e);
					}
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}).then(resultUrl => globalXHR(resultUrl, { responseType: 'json' }).then(({response}) => {
			try {
				if (!response.result.success) return Promise.reject(response.result.errors);
				if (response.result.data.images.length < images.length) {
					console.warn('GetaPic returning incomplete list of images (', response.result.data.images, images, ')');
					return Promise.reject(`not all images uploaded (${response.result.data.images.length}/${images.length})`)
				}
				if (response.result.data.images.length > images.length)
					console.warn('GetaPic returns more links than expected (', response.result.data.images, images, ')');
				const forceSSL = [/^(?:http)(?=:)/i, 'https'];
				return response.result.data.images.map(image => ({
					original: image.url/*.replace(...forceSSL)*/,
					thumb: image.thumb ? image.thumb.url/*.replace(...forceSSL)*/ : undefined,
					share: 'http://getapic.me/v/'.concat(image.marker)/*.replace(...forceSSL)*/,
					optimal: image.optimal ? image.optimal.url/*.replace(...forceSSL)*/ : undefined,
				}));
			} catch(e) {
				console.error('GetaPic invalid response structure:', response, e);
				return Promise.reject(e);
			}
		})));
	}

	setSession() {
		return globalXHR(this.origin, { responseType: 'json' }).then(({response}) => response.result.data);
	}
}

class SMMS {
	constructor() {
		this.alias = 'SM.MS';
		this.origin = 'https://sm.ms';
		this.sizeLimit = 5128 / 2**10;
		this.types = ['jpeg', 'png', 'gif', 'bmp'];
		this.batchLimit = 10;
		if ((this.apiKey = GM_getValue('smms_api_key')) === undefined) GM_setValue('smms_api_key', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="file_id"\r\n\r\n';
			formData += index + '\r\n--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="smfile"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="format"\r\n\r\n';
			formData += 'json\r\n--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/api/v2/upload',
				headers: Object.assign({
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'X-Requested-With': "XMLHttpRequest",
				}, session),
				data: formData,
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				responseType: 'json',
				onload: response => {
					if (response.status >= 200 && response.status < 400) {
						if (response.response.success) resolve({
							original: response.response.data.url,
							share: response.response.data.page,
						}); else if (response.response.code == 'image_repeated') resolve(response.response.images);
						else reject(response.response.message);
					} else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	setSession() {
		let result = { };
		if (this.apiKey) result.Authorization = 'Basic ' + this.apiKey;
		return Promise.resolve(result);
	}
}

class CubeUpload {
	constructor() {
		this.alias = 'CubeUpload';
		this.origin = 'https://cubeupload.com';
		this.types = ['jpeg', 'png', 'gif', 'bmp'];
		this.userId = GM_getValue('cubeupload_userid');
		this.userHash = GM_getValue('cubeupload_userhash');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(session => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			Object.keys(session).forEach(key => {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += session[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="name"\r\n\r\n';
			formData += image.name + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="fileinput[0]"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({ method: 'POST', url: this.origin + '/upload_json.php',
				headers: {
				 'Content-Type': 'multipart/form-data; boundary=' + boundary,
				 'Referer': this.origin,
				 'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				responseType: 'json',
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.error) reject(response.response.status); else resolve({
						original: response.response.img_direct_link,
						thumb: response.response.img_thumb_link,
						share: response.response.img_sharing_link,
					});
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	setSession() {
		return this.userId && this.userHash ? Promise.resolve({
			userID: this.userId,
			userHash: this.userHash,
		}) : globalXHR(this.origin, { responseType: 'text' }).then(({responseText}) => {
			this.userId = /\b(?:var\s+user_id)\s*=\s*(\d+)\b/.exec(responseText);
			if (this.userId != null) GM_setValue('cubeupload_userid', this.userId = parseInt(this.userId[1]));
				else delete this.userId;
			this.userHash = /\b(?:var\s+user_hash)\s*=\s*"(\w+)"/.exec(responseText);
			if (this.userHash != null) GM_setValue('cubeupload_userhash', this.userHash = this.userHash[1]);
				else delete this.userHash;
			return this.userId && this.userHash ? Promise.resolve({
				userID: this.userId,
				userHash: this.userHash,
			}) : Promise.reject('account credentials aren\'t set, please login to your account and repeat the action');
		});
	}
}

class GooPics {
	constructor() {
		this.alias = 'GooPics';
		this.origin = 'https://goopics.net';
		this.types = ['jpeg', 'png', 'gif'];
		this.sizeLimit = 15;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="fileinput[0]"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({ method: 'POST', url: this.origin + '/upload',
				headers: {
				 'Content-Type': 'multipart/form-data; boundary=' + boundary,
				 'Referer': this.origin,
				 'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				responseType: 'document',
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					let dom = domParser.parseFromString(response.responseText, 'text/html'), result = {
						original: dom.querySelector('article input[id^="direct_"]'),
						thumb: dom.querySelector('article > a > img'),
						share: dom.querySelector('article input[id^="basic_"]'),
					};
					if (result.original != null) result.original = result.original.value;
						else reject('unexpected result structure');
					result.thumb = result.thumb != null ? result.thumb.src : undefined;
					result.share = result.share != null ? result.share.value : undefined;
					resolve(result);
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		})));
	}
}

class BilderUpload {
	constructor() {
		this.alias = 'BilderUpload';
		this.origin = 'https://www.bilder-upload.eu';
		this.types = ['jpeg', 'png', 'gif'];
		this.sizeLimit = 10;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="datei"; filename="' + image.name + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="upload"\r\n\r\n';
			formData += '1\r\n--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({ method: 'POST', url: this.origin,
				headers: {
				 'Content-Type': 'multipart/form-data; boundary=' + boundary,
				 'Referer': this.origin,
				 'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				responseType: 'document',
				binary: true,
				timeout: getUploadTimeout(images.reduce((acc, image) => acc + image.size, images.length * 1024)),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					let result = {
						thumb: response.response.querySelector('div.box img'),
						share: response.response.querySelector('textarea[name="URLCode"]'),
					};
					if (result.thumb != null) {
						result.thumb = new URL(result.thumb.src);
						result.thumb = this.origin + result.thumb.pathname;
						result.original = result.thumb.replace('/thumb/', '/upload/');
					}
					result.share = result.share != null ? result.share.value : undefined;
					if (result.original) resolve(result); else reject('unexpected result structure');
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		})));
	}
}

class Ra {
	constructor() {
		this.alias = 'Ra';
		this.origin = 'https://thesungod.xyz';
		this.types = ['gif', 'vnd.microsoft.icon', 'jpeg', 'png', 'svg+xml', 'tiff', 'webp', 'bmp'];
		this.sizeLimit = 100;
		if (this.apiKey = GM_getValue('thesungod_api_key')) {
			GM_setValue('ra_api_key', this.apiKey);
			GM_deleteValue('thesungod_api_key');
		} else if ((this.apiKey = GM_getValue('ra_api_key')) === undefined) GM_setValue('ra_api_key', '');
		this.thumbSize = 200;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(apiKey => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="api_key"\r\n\r\n';
			formData += apiKey + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="image"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/api/image/upload',
				responseType: 'text', //'json' ?
				headers: {
					'Accept': 'text/plain',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve(this.resultHandler(response.responseText));
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		return urls.length > 0 ? this.setSession().then(apiKey => Promise.all(urls.map((url, index) =>
				verifyImageUrl(url).then(imageUrl => globalXHR(this.origin + '/api/image/rehost', {
			responseType: 'text',
			timeout: urls.length * rehostTimeout * 1000,
		}, new URLSearchParams({ 'link': imageUrl, 'api_key': apiKey })).then(({responseText}) => {
			if (typeof progressHandler == 'function' && httpParser.test(responseText)) progressHandler(true);
			return this.resultHandler(responseText);
		}))))) : Promise.reject('nothing to rehost');
	}

	resultHandler(response) {
		const result = imageUrl => ['jpeg', 'png'].some(ext => imageUrl.endsWith('.' + ext)) ? {
			original: imageUrl,
			thumb: imageUrl + '?width=' + this.thumbSize + '&height=' + this.thumbSize,
		} : imageUrl;
		if (httpParser.test(response)) return result(response);
		try {
			let links = JSON.parse(response).links;
			console.assert(Array.isArray(links) && links.length == 1, 'Array.isArray(links) && links.length == 1', response);
			if (links.length > 0 && httpParser.test(links[0])) return result(links[0]);
		} catch(e) { }
		console.warn('Ra unexpected response:', response);
		return Promise.reject('void or invalid result');
	}

	setSession() {
		return this.apiKey ? Promise.resolve(this.apiKey) : globalXHR(this.origin + '/api/user/genkey', {
			responseType: 'text',
		}).then(({responseText}) => {
			if (this.apiKey = responseText) {
				GM_setValue('ra_api_key', this.apiKey);
				Promise.resolve(this.apiKey)
					.then(apiKey => { alert(`Your Ra API key [${apiKey}] was successfully generated.
For later use with different applications or browsers, it\'s saved in script\'s storage`) });
				return this.apiKey;
			}
			let counter = GM_getValue('ra_reminder_read', 0);
			if (counter < 3) {
				alert(`Ra API key could not be generated. Please login to ${this.origin}/ and redo the action.`);
				GM_setValue('ra_reminder_read', ++counter);
			}
			return Promise.reject('Ra API key not configured');
		});
	}
}

class Mobilism {
	constructor() {
		this.alias = 'Mobilism';
		this.origin = 'https://images.mobilism.org';
		this.types = ['png', 'jpeg', 'gif', 'bmp', 'psd'];
		this.sizeLimit = 1;
		this.whitelist = ['forum.mobilism.org', 'forum.mobilism.me'];
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			images.forEach((image, ndx) => {
				formData += 'Content-Disposition: form-data; name="fileName[]"\r\n\r\n';
				formData += image.name + '\r\n';
				formData += '--' + boundary + '\r\n';
				formData += 'Content-Disposition: form-data; name="file[]"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary;
				if (ndx + 1 >= images.length) formData += '--';
				formData += '\r\n';
			});
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload.php',
				responseType: 'document',
				headers: {
					'Accept': 'text/html',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'Referer': this.origin + '/index.php',
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (!response.response) return reject('void response');
					console.assert(response.response instanceof HTMLDocument, 'response.response instanceof HTMLDocument', response.response)
					let imageUrls = Array.from(response.response.querySelectorAll('input#codedirect[type="text"]'))
						.map(input => input.value);
					if (imageUrls.length < images.length) {
						console.warn('Mobilism returning incomplete list of images (', imageUrls, ')');
						return reject(`not all images uploaded (${imageUrls.length}/${images.length})`);
					}
					if (imageUrls.length > images.length)
						console.warn('Mobilism returns more links than expected (', imageUrls, images, ')');
					resolve(imageUrls);
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		});
	}

	rehost(urls) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		if (this.batchLimit && urls.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return Promise.all(urls.map(url => {
			if (!httpParser.test(url)) return Promise.reject('URL not valid (' + url + ')');
			if (!/\.(?:jpe?g|jfif|png|gif|bmp|psd)$/i.test(url))
				return verifyImageUrl(url + '#.jpg').then(finalUrl => url + '#.jpg').catch(reason => {
					let redirects = ['imgcdn', 'imgbb', 'pixhost', 'gifyu']
						.filter(alias => alias in imageHostHandlers && typeof imageHostHandlers[alias].rehost == 'function')
						.map(alias => imageHostHandlers[alias]);
					const redirect = (index = 0) => (index >= 0 && index < redirects.length ?
						redirects[index].rehost([url], null, true).catch(reason => redirect(index + 1))
							: Promise.reject('redirection failed on all hosts')).then(results => results.map(directLinkGetter));
					return redirect();
				});
			return verifyImageUrl(url);
		})).then(imageUrls => Promise.all(imageUrls.map(imageUrl => {
			let formData = new URLSearchParams({ 'imgUrl': imageUrl });
			return globalXHR(this.origin + '/upload.php', {
				responseType: 'document',
				headers: { 'Referer': this.origin + '/index.php' },
				timeout: imageUrls.length * rehostTimeout * 1000,
			}, formData).then(response => {
				if (response.status < 200 || response.status >= 400) return Promise.reject(defaultErrorHandler(response));
				if (!response.response) return Promise.reject('void response');
				console.assert(response.response instanceof HTMLDocument, 'response.response instanceof HTMLDocument', response.response)
				let imageUrls = Array.from(response.response.querySelectorAll('input#codedirect[type="text"]'))
					.map(input => input.value);
				if (imageUrls.length <= 0) return Promise.reject('image not uploaded');
				if (imageUrls.length > 1) console.warn('Mobilism returns more links than expected (', imageUrls, ')');
				return imageUrls;
			});
		})).then(results => results.flatten()));
	}
}

class PomfCat {
	constructor() {
		this.alias = 'Pomf.cat';
		this.origin = 'https://pomf.cat';
		this.types = ['jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'];
		this.sizeLimit = 75;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			images.forEach((image, index) => {
				formData += 'Content-Disposition: form-data; name="files[]"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary;
				if (index >= images.length - 1) formData += '--';
				formData += '\r\n';
			});
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/upload.php',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				binary: true,
				responseType: 'json',
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					try {
						if (!response.response.success) return reject(response.response.error);
						resolve(response.response.files.map(file => 'https://a.pomf.cat/' + file.url));
					} catch(e) {
						console.error('Pomf.cat invalid response structure:', response.response, e);
						reject(e);
					}
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		});
	}
}

class LinkPicture {
	constructor() {
		this.alias = 'Link Picture';
		this.origin = 'https://linkpicture.com';
		this.types = ['jpeg', 'png', 'gif', 'bmp', 'xbm', 'wxmb'];
		this.sizeLimit = 0;
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportedType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		if (this.batchLimit && images.length > this.batchLimit)
			return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
		return new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			images.forEach((image, index) => {
				formData += 'Content-Disposition: form-data; name="mFile[]"; filename="' + image.name + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n';
				formData += image.data + '\r\n';
				formData += '--' + boundary;
				formData += '\r\n';
			});
			formData += 'Content-Disposition: form-data; name="sozlesmeCbox"\r\n\r\n';
			formData += 'on\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="shareCbox"\r\n\r\n';
			formData += 'on\r\n';
			formData += '--' + boundary + '--\r\n';
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/en/upload.php',
				headers: {
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				binary: true,
				responseType: 'document',
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					const results = Array.from(response.document.querySelectorAll('div.card > a > img'))
						.map(img => this.origin + img.parentNode.pathname);
					if (results.length < images.length)
						return reject('Server returned less links than expected (' + results.length + ')');
					if (results.length > images.length)
						console.warn(this.alias, 'server returns more links than expected (' + results.length + ')');
					resolve(results);
				},
				onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		});
	}

	rehost(urls) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		if (urls.length <= 0) return Promise.reject('nothing to rehost');
		return verifyImageUrls(urls).then(imageUrls => Promise.all(imageUrls.map(imageUrl => {
			const payLoad = new URLSearchParams({ 'weblink': imageUrl });
			return globalXHR(this.origin + '/upload.php', {
				responseType: 'document',
				headers: { 'Referer': this.origin + '/index.php' },
				timeout: imageUrls.length * rehostTimeout * 1000,
			}, payLoad).then(response => {
				if (response.status < 200 || response.status >= 400) return Promise.reject(defaultErrorHandler(response));
				const img = response.document.querySelector('div.card > a > img');
				return img != null ? this.origin + img.parentNode.pathname : Promise.reject('Unexpected page structure');
			});
		})));
	}
}

class ImageKit {
	constructor() {
		this.alias = 'ImageKit';
		this.origin = 'https://upload.imagekit.io';
		this.types = ['jpeg', 'png', 'gif', 'tiff', 'webp', 'bmp', 'svg+xml'];
		//this.sizeLimit = 100;
		if (!(this.publicKey = GM_getValue('imagekit_public_key'))) GM_setValue('imagekit_public_key', '');
		if (!(this.privateKey = GM_getValue('imagekit_private_key'))) GM_setValue('imagekit_private_key', '');
	}

	upload(images, progressHandler = null) {
		if (!Array.isArray(images)) return Promise.reject('invalid argument');
		images = images.filter(isSupportgredType.bind(this));
		if (images.length <= 0) return Promise.reject('nothing to upload');
		if (this.sizeLimit > 0 && images.some(image => image.size > this.sizeLimit * 2**20))
			return Promise.reject('size limit exceeded by one or more images');
		return this.setSession().then(signature => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
			const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
			let formData = '--' + boundary + '\r\n';
			for (let key in signature) {
				formData += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
				formData += signature[key] + '\r\n';
				formData += '--' + boundary + '\r\n';
			}
			formData += 'Content-Disposition: form-data; name="file"; filename="' + image.name.toASCII() + '"\r\n';
			formData += 'Content-Type: ' + image.type + '\r\n\r\n';
			formData += image.data + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="fileName"\r\n\r\n';
			formData += image.name.toASCII() + '\r\n';
			formData += '--' + boundary + '\r\n';
			formData += 'Content-Disposition: form-data; name="useUniqueFileName"\r\n\r\n';
			formData += 'true\r\n';
			formData += '--' + boundary + '--\r\n';
			if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
			GM_xmlhttpRequest({
				method: 'POST',
				url: this.origin + '/api/v1/files/upload',
				responseType: 'json',
				headers: {
					'Accept': 'application/json',
					'Content-Type': 'multipart/form-data; boundary=' + boundary,
					'X-Requested-With': 'XMLHttpRequest',
				},
				data: formData,
				binary: true,
				timeout: getUploadTimeout(formData.length),
				onload: response => {
					if (response.status >= 200 && response.status < 400) resolve(ImageKit.resultHandler(response.response));
						else reject(defaultErrorHandler(response));
				},
				onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}))));
	}

	rehost(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('invalid argument');
		return urls.length > 0 ? this.setSession().then(signature => Promise.all(urls.map((url, index) =>
				verifyImageUrl(url).then(imageUrl => getRemoteFileType(imageUrl).catch(reason => 'image/jpeg').then(mimeType => {
			const payLoad = new FormData;
			payLoad.set('file', imageUrl);
			payLoad.set('fileName', 'image-' + Date.now().toString(16) + mimeType.toLowerCase().replace(/^(?:\w+)\//, '.'));
			payLoad.set('useUniqueFileName', false);
			for (let key in signature) payLoad.set(key, signature[key]);
			return globalXHR(this.origin + '/api/v1/files/upload', {
				responseType: 'json',
				timeout: urls.length * rehostTimeout * 1000,
			}, payLoad).then(response => {
				if (typeof progressHandler == 'function' && httpParser.test(response.responseText)) progressHandler(true);
				return ImageKit.resultHandler(response.response);
			});
		}))))) : Promise.reject('nothing to rehost');
	}

	static resultHandler(response) {
		return {
			original: response.url,
			thumb: response.thumbnailUrl,
		};
	}

	setSession() {
		if (!this.publicKey || !this.privateKey) return Promise.reject('ImageKit API keys not configured');
		const result = {
			publicKey: this.publicKey,
			token: uuid(),
			expire: Math.round(Date.now() / 1000) + 10 * 60,
		};
		return new Promise(function(resolve, reject) {
			if (typeof CryptoJS == 'object' && CryptoJS) return resolve(CryptoJS);
			const cryptoJS = document.createElement('SCRIPT');
			cryptoJS.type = 'text/javascript';
			cryptoJS.onload = evt => { resolve(CryptoJS) };
			cryptoJS.onerror = evt => { reject('crypto-js failed to load') };
			cryptoJS.src = 'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js';
			document.head.append(cryptoJS);
		}).then(cryptoJS => Object.assign(result,
			{ signature: cryptoJS.HmacSHA1(result.token + result.expire.toString(), this.privateKey).toString(cryptoJS.enc.Hex) }));
	}
}

function directLinkGetter(result) {
	switch (typeof result) {
		case 'string': if (httpParser.test(result)) return result;
		case 'object': if (httpParser.test(result.original)) return result.original;
	}
	if (httpParser.test(result)) return RegExp.$1;
	console.warn('directLinkGetter() invalid result of upload:', result);
	throw 'invalid result format';
}
function singleImageGetter(results) {
	if (!Array.isArray(results)) {
		console.error('singleImageGetter(', results, ')');
		throw 'invalid result format';
	}
	if (results.length > 0) return directLinkGetter(results[0]);
	throw 'empty links list';
}

var imageHostHandlers = {
	'abload' : new Abload,
	'bilderupload': new BilderUpload,
	'casimages': new CasImages,
	'catbox': new Catbox,
	'cubeupload': new CubeUpload,
	//'dropbox': new DropBox,
	'fastpic': new FastPic,
	'funkyimg': new FunkyIMG,
	'geekpic': new GeekPic,
	'getapic': new GetaPic,
	'gett': new Gett,
	'gifyu': new Chevereto('gifyu.com', 'Gifyu', ['jpeg', 'png', 'gif', 'bmp', 'webp'], 100, { sizeLimitAnonymous: 50 }),
	'googledrive': new GoogleDrive,
	'goopics': new GooPics,
	//'googlephotos': new GooglePhotos,
	'imageban': new ImageBan,
	'imagekit': new ImageKit,
	'imagevenue': new ImageVenue,
	'imgbb': new Chevereto('imgbb.com', 'ImgBB', ['jpeg', 'png', 'bmp', 'gif', 'webp', 'tiff', 'heic', 'heif'], 32,
		{ apiEndpoint: 'https://api.imgbb.com/1', apiFieldName: 'image', apiResultKey: 'data' }),
	'imgbox': new ImgBox,
	'imgur': new Imgur,
	'imgurl': new ImgURL,
	'jerking': new Chevereto('jerking.empornium.ph', 'Jerking', ['jpeg', 'png', 'bmp', 'gif', 'webp'], 5),
	'lightshot': new LightShot,
	'linkpicture': new LinkPicture,
	'mobilism': new Mobilism,
	'noelshack' : new NoelShack,
	'nwcd' : new NWCD,
	//'onedrive': new OneDrive,
	'picabox': new PicaBox,
	'pimpandhost': new PimpAndHost,
	'pixhost': new PixHost,
	'pomfcat': new PomfCat,
	'postimage': new PostImage,
	'ptpimg': new PTPimg,
	'ra': new Ra,
	'radikal': new Radikal,
	'savephoto': new SavePhoto,
	//'screencast': new ScreenCast,
	'slowpoke': new Slowpoke,
	'smms': new SMMS,
	'svgshare': new SVGshare,
	'uuploadir': new UuploadIr,
	'vgyme': new VgyMe,
	'z4a': new Chevereto('z4a.net', 'Z4A', ['jpeg', 'png', 'bmp', 'gif'], 50),
};

var siteWhitelists = {
	'notwhat.cd': ['nwcd'],
};
var siteBlacklists = {
	'passthepopcorn.me': ['imgbox', 'postimage', 'imgur', 'tinypic', 'imageshack', 'imagebam'],
};

class ImageHostManager {
	constructor(messageHandler = null, UlHostList = undefined, rhHostList = undefined) {
		this.messageHandler = messageHandler;
		if (UlHostList) this.buildUploadChain(UlHostList); else this.ulHostChain = [ ];
		if (rhHostList) this.buildRehostChain(rhHostList); else this.rhHostChain = [ ];
	}

	processLists(alias) {
		return (!Array.isArray(siteWhitelists[document.domain])
				|| siteWhitelists[document.domain].some(whiteAlias => alias == whiteAlias.toLowerCase()))
			&& (!Array.isArray(siteBlacklists[document.domain])
				|| siteBlacklists[document.domain].every(blackAlias => alias != blackAlias.toLowerCase()));
	}

	static getAliasArray(list) {
		return (Array.isArray(list) ? list : typeof list == 'string' ? list.split(/[\s\,\;\|\/]+/) : [ ]).map(alias => [
			['TheSunGod', 'Ra'],
			['NotWhat', 'NWCD'],
			['NotWhat.cd', 'NWCD'],
			['NotWhatCd', 'NWCD'],
			['Vgy.me', 'VgyMe'],
			['SM.MS', 'SMMS'],
			['Radikal.ru', 'Radikal'],
			['PTPimg.me', 'PTPimg'],
			['PostImages', 'PostImage'],
			['PostImg', 'PostImage'],
			['Empornium', 'Jerking'],
			['Bilder-Upload', 'BilderUpload'],
			['Uupload.ir', 'UuploadIr'],
		].reduce((acc, def) => acc.toLowerCase() != def[0].toLowerCase() ? acc : def[1], alias)
			.replace(nonWordStripper, '').toLowerCase());
	}

	buildUploadChain(list) {
		this.ulHostChain = ImageHostManager.getAliasArray(list)
			.filter(ImageHostManager.prototype.processLists.bind(this)).map(alias => imageHostHandlers[alias])
			.filter(handler => typeof handler == 'object' && typeof handler.upload == 'function'
				&& (!Array.isArray(handler.whitelist) || handler.whitelist.includes(document.domain))
				&& (!Array.isArray(handler.blacklist) || !handler.blacklist.includes(document.domain)));
		console.debug('Local upload hosts for ' + document.domain + ':',
			this.ulHostChain.map(handler => handler.alias).join(', '));
	}

	buildRehostChain(list) {
		this.rhHostChain = ImageHostManager.getAliasArray(list)
			.filter(ImageHostManager.prototype.processLists.bind(this)).map(alias => imageHostHandlers[alias])
			.filter(handler => typeof handler == 'object' && typeof handler.rehost == 'function'
				&& (document.domain != 'redacted.ch' || ['PTPimg'].some(alias => handler.alias == alias))
				&& (!Array.isArray(handler.whitelist) || handler.whitelist.includes(document.domain))
				&& (!Array.isArray(handler.blacklist) || !handler.blacklist.includes(document.domain)));
		console.debug('Remote upload hosts for ' + document.domain + ':',
			this.rhHostChain.map(handler => handler.alias).join(', '));
	}

	uploadImages(files, progressHandler = null) {
		if (!Array.isArray(this.ulHostChain) || this.ulHostChain.length <= 0) return Promise.reject('No hosts where to upload');
		if (typeof files != 'object') return Promise.reject('Invalid argument');
		if (!Array.isArray(files)) files = Array.from(files);
		if (files.length > 1) files.sort((a, b) => a.name.localeCompare(b.name));
		files = files.filter(file => file instanceof File && file.size > 0 && (!file.type || file.type.startsWith('image/')));
		if (files.length <= 0) return Promise.reject('Nothing to upload');
		return Promise.all(files.map(file => file.getContent())).then(images => (function uploadInternal(hostIndex = 0) {
			return hostIndex >= 0 && hostIndex < this.ulHostChain.length ? (() => {
				if (!files.every(isSupportedType.bind(this.ulHostChain[hostIndex])))
					return Promise.reject('one or more files of unsupported format');
				if (this.ulHostChain[hostIndex].sizeLimit > 0 && files.some(file => file.size > this.ulHostChain[hostIndex].sizeLimit * 2**20))
					return Promise.reject(`one or more images exceed size limit (${this.ulHostChain[hostIndex].sizeLimit}MiB)`);
				//if (this.ulHostChain[hostIndex].batchLimit && files.length > this.ulHostChain[hostIndex].batchLimit)
				//	return Promise.reject(`batch limit exceeded (${this.ulHostChain[hostIndex].batchLimit})`);
				if (typeof progressHandler == 'function') {
					progressHandler(hostIndex, null);
					var _progressHandler = (param = null, index = undefined) => progressHandler(hostIndex, param, index);
				}
				return this.ulHostChain[hostIndex].upload(this.ulHostChain[hostIndex].upload.acceptFiles ?
					files : images, _progressHandler);
			})().catch(reason => {
				console.warn('Upload to', this.ulHostChain[hostIndex].alias, 'failed:', reason);
				let msg = `Upload to ${this.ulHostChain[hostIndex].alias} failed (${reason})`;
				if (++hostIndex < this.ulHostChain.length) {
					if (typeof this.messageHandler == 'function')
						this.messageHandler(`${msg}, falling back to ${this.ulHostChain[hostIndex].alias}`);
					return uploadInternal.call(this, hostIndex);
				}
				if (typeof this.messageHandler == 'function') this.messageHandler(msg);
				return Promise.reject('Upload failed to all hosts');
			}) : Promise.reject(`Host index out of bounds (${hostIndex})`);
		}).call(this));
	}

	rehostImages(urls, progressHandler = null) {
		if (!Array.isArray(urls)) return Promise.reject('Invalid argument');
		urls = urls.filter(RegExp.prototype.test.bind(httpParser));
		if (urls.length <= 0) return Promise.reject('Nothing to rehost');
		if (!Array.isArray(this.rhHostChain) || this.rhHostChain.length <= 0) return Promise.resolve(urls);
		if (testRemoteSizes) var start = Date.now();
		return (testRemoteSizes ? Promise.all(urls.map(url => getRemoteFileSize(url).catch(reason => undefined)))
				: Promise.resolve('Size tests skipped')).then(lengths => {
			if (testRemoteSizes) console.debug('Size analysis time:', (Date.now() - start) / 1000, 's');
			try { var h2 = urls.map(url => new URL(url).hostname) } catch(e) { console.error('Assertion failed: ' + e) }
			return (function rehostInternal(hostIndex = 0) {
				if (hostIndex < 0 || hostIndex >= this.rhHostChain.length)
					return Promise.reject(`Host index out of bounds (${hostIndex})`);
				try { // don't rehost again to same site
					let h1 = new URL(this.rhHostChain[hostIndex].origin).hostname;
					if (h1 && Array.isArray(h2) && h2.every(h2 => h2.includes(h1) || h1.includes(h2))) return Promise.resolve(urls);
				} catch(e) { }
				//if (this.rhHostChain[hostIndex].batchLimit && urls.length > this.rhHostChain[hostIndex].batchLimit)
				//	return Promise.reject('batch limit exceeded (' + this.rhHostChain[hostIndex].batchLimit + ')');
				if (this.rhHostChain[hostIndex].sizeLimit > 0 && Array.isArray(lengths)
						&& !lengths.every(length => !length || length <= this.rhHostChain[hostIndex].sizeLimit * 2**20))
					return Promise.reject(`one or more images exceed size limit (${this.rhHostChain[hostIndex].sizeLimit}MiB)`);
				if (typeof progressHandler == 'function') {
					progressHandler(hostIndex, false);
					var _progressHandler = (param = true) => progressHandler(hostIndex, param);
				}
				return this.rhHostChain[hostIndex].rehost(urls, _progressHandler).catch(reason => {
					console.warn('Rehost to', this.rhHostChain[hostIndex].alias, 'failed:', reason);
					let msg = `Rehost to ${this.rhHostChain[hostIndex].alias} failed (${reason})`;
					if (++hostIndex < this.rhHostChain.length) {
						if (typeof this.messageHandler == 'function')
							this.messageHandler(`${msg}, falling back to ${this.rhHostChain[hostIndex].alias}`);
						return rehostInternal.call(this, hostIndex);
					}
					if (typeof this.messageHandler == 'function') this.messageHandler(msg);
					return Promise.reject('Rehost failed to all hosts');
				});
			}).call(this);
		});
	}
}

function forcedRehost(imageUrl) {
	const lastInstanceHosts = ['imgbb', 'pixhost', 'postimage'];
	return (function rehost(index = 0) {
		return index >= 0 && index < lastInstanceHosts.length ? imageHostHandlers[lastInstanceHosts[index]].rehost([imageUrl])
			.catch(reason => rehost(index + 1)) : Promise.reject('Rehost failed to all forced hosts');
	})().then(singleImageGetter);
}

function urlResolver(url) {
	if (Array.isArray(url)) url = url[0];
	if (!httpParser.test(url)) return Promise.reject('Invalid URL:\n\n' + url);
	try { if (!(url instanceof URL)) url = new URL(url) } catch(e) { return Promise.reject(e) }
	switch (url.hostname) {
		case 'www.google.com': case 'google.com':
			if (url.pathname == '/url') {
				let URL = url.searchParams.get('url');
				if (httpParser.test(URL)) return urlResolver(URL);
			}
			break;
		case 'rutracker.org':
			if (url.pathname != '/forum/out.php') break;
			return globalXHR(url, { method: 'HEAD' }).then(response => urlResolver(response.finalUrl));
		case 'play.qobuz.com':
			if (/^\/album\/(\w+)\b/i.test(url.pathname))
				return globalXHR('https://www.qobuz.com/album/-/' + RegExp.$1, { method: 'HEAD' })
					.then(response => response.finalUrl);
			break;
		case 'www.anonymz.com': case 'anonymz.com': case 'anonym.to': case 'dereferer.me':
			var resolved = decodeURIComponent(url.search.slice(1));
			return httpParser.test(resolved) ? urlResolver(resolved) : genericResolver();
		//case 'reho.st':
		//	resolved = (url.pathname + url.search + url.hash).slice(1);
		//	if (/\b(?:https?):\/\/(?:\w+\.)*(?:discogs\.com|omdb\.org)\//i.test(resolved)) break;
		// 	return httpParser.test(resolved) ? urlResolver(resolved) : genericResolver();
		// URL shorteners
		case 'tinyurl.com': case 'bit.ly': case 'j.mp': case 't.co': case 'apple.co': case 'flic.kr':
		case 'rebrand.ly': case 'b.link': case 't2m.io': case 'zpr.io': case 'yourls.org': case 'ibn.im':
		case 'deezer.page.link':
			return genericResolver();
	}
	if (/\b(?:goo\.gl)$/i.test(url.hostname)) return genericResolver();
	return Promise.resolve(url.href);

	function genericResolver() {
		return globalXHR(url).then(function(response) {
			let redirect = response.document.querySelector('meta[http-equiv="refresh"]');
			if (redirect != null && (redirect = redirect.content.replace(/^.*?\b(?:URL)\s*=\s*/i, '')) != url.href
				|| /^ *(?:Location) *: *(\S+) *$/im.test(response.responseHeaders) && (redirect = RegExp.$1) != url.href
				|| /^ *(?:Refresh) *: *(\d+); *url=(\S+) *$/im.test(response.responseHeaders) && (redirect = RegExp.$2) != url.href
				|| (redirect = response.finalUrl) != url.href) return urlResolver(redirect);
			return Promise.resolve(url.href);
		});
	}
}

const uaVersions = { };
function setUserAgent(params, suffixLen = 8) {
	if (params && typeof params == 'object' && httpParser.test(params.url)) try {
		const url = new URL(params.url);
		if ([document.location.hostname, 'ptpimg.me'].includes(url.hostname)) return;
		//return ['dzcdn.', 'mzstatic.com'].some(pattern => hostname.includes(pattern));
		params.anonymous = true;
		if (!navigator.userAgent) return;
		if (!uaVersions[url.hostname] || ++uaVersions[url.hostname].usageCount > 16) uaVersions[url.hostname] = {
			versionSuffix: Math.floor(Math.random() * Math.pow(2, suffixLen * 4)).toString(16).padStart(suffixLen, '0'),
			usageCount: 1,
		};
		if (!params.headers) params.headers = { };
		params.headers['User-Agent'] = navigator.userAgent.replace(/\b(Gecko|\w*WebKit|Blink|Goanna|Flow|\w*HTML|Servo|NetSurf)\/(\d+(\.\d+)*)\b/,
			(match, engine, engineVersion) => engine + '/' + engineVersion + '.' + uaVersions[url.hostname].versionSuffix);
	} catch(e) { console.warn('Invalid url:', params.url) }
}

function verifyImageUrl(url) {
	const testByXHR = (resolvedUrl, method = 'HEAD') => new Promise(function(resolve, reject) {
		const params = { method: method, url: resolvedUrl, timeout: 90e3 };
		setUserAgent(params);
		let hXHR = GM_xmlhttpRequest(Object.assign(params, {
			onreadystatechange: function(response) {
				if (response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
				const abort = () => { if (!hXHR) return; if (method != 'HEAD') hXHR.abort(); hXHR = undefined; }
				if (response.status < 200 || response.status >= 400) {
					reject(defaultErrorHandler(response));
					return abort();
				}
				// let contentType = /^(?:Content-Type)\s*:\s*(.+?)(?:\s*;(.+?))?\s*$/im.exec(response.responseHeaders);
				// if (contentType == null || !contentType[1].startsWith('image/')) {
				// 	contentType = ' (' + (contentType != null ? contentType[1] : undefined) + ')';
				// 	reject('Not an image: ' + response.finalUrl + contentType);
				// 	return abort();
				// }
				const invalidUrls = [
					'imgur.com/removed.png',
					'gtimg.cn/music/photo_new/T001M000003kfNgb0XXvgV_0.jpg',
					//'//discogs.com/8ce89316e3941a67b4829ca9778d6fc10f307715/images/spacer.gif',
					'amazon.com/images/I/31CTP6oiIBL.jpg',
					'amazon.com/images/I/31zMd62JpyL.jpg',
					'amazon.com/images/I/01RmK+J4pJL.gif',
					'/0dc61986-bccf-49d4-8fad-6b147ea8f327.jpg', // Beatport
					'/ab2d1d04-233d-4b08-8234-9782b34dcab8.jpg', // Beatport
					'postimg.cc/wkn3jcyn9/image.jpg',
					'tinyimg.io/notfound',
					'hdtracks.com/img/logo.jpg',
					'vgy.me/Dr3kmf.jpg',
					'/images/no-cover.png',
				];
				if (invalidUrls.some(invalidUrl => response.finalUrl.endsWith(invalidUrl))) {
					reject('Dummy image (placeholder): ' + response.finalUrl);
					return abort();
				}
				const invalidEtags = [
					'd835884373f4d6c8f24742ceabe74946',
					'25d628d3d3a546cc025b3685715e065f42f9cbb735688b773069e82aac16c597f03617314f78375d143876b6d8421542109f86ccd02eab6ba8b0e469b67dc953',
					'"55fade2068e7503eae8d7ddf5eb6bd09"',
					'"1580238364"',
					'"rbFK6Ned4SXbK7Fsn+EfdgKVO8HjvrmlciYi8ZvC9Mc"',
					'7ef77ea97052c1abcabeb44ad1d0c4fce4d269b8a4f439ef11050681a789a1814fc7085a96d23212af594b6b2855c99f475b8b61d790f22b9d71490425899efa',
				];
				let Etag = /^(?:Etag)\s*:\s*(.+?)\s*$/im.exec(response.responseHeaders);
				if (Etag != null && invalidEtags.some(etag => etag.toLowerCase() == Etag[1].toLowerCase())) {
					reject('Dummy image (placeholder): ' + response.finalUrl);
					return abort();
				}
				resolve(response.finalUrl); abort();
			},
			onerror: response => { reject(defaultErrorHandler(response)) },
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		}));
	});

	if (!httpParser.test(url)) return Promise.reject('Invalid URL');
	if (verifiedImageUrls[url]) return Promise.resolve(verifiedImageUrls[url]);
	return urlResolver(url).then(resolvedUrl => verifiedImageUrls[resolvedUrl] || Promise.all([
		testByXHR(resolvedUrl, 'HEAD').catch(reason => /^HTTP error (?:400|403|405|406|416)\b/.test(reason) ?
			testByXHR(resolvedUrl, 'GET') : Promise.reject(reason)),
		new Promise(function(resolve, reject) {
			const image = new Image;
			image.onload = function(evt) {
				if (evt.target.naturalWidth > 0 && evt.target.naturalHeight > 0) resolve(evt.target);
					else reject('Zero area image loaded');
			};
			image.onerror = evt => { reject(evt.message || 'Image loading error (' + resolvedUrl + ')') };
			image.loading = 'eager';
			image.referrerPolicy = 'same-origin';
			image.src = resolvedUrl;
		}),
	]).then(function(results) {
		verifiedImageUrls[url] = results[0];
		if (resolvedUrl != url) verifiedImageUrls[resolvedUrl] = results[0];
		sessionStorage.setItem('verifiedImageUrls', JSON.stringify(verifiedImageUrls));
		return results[0];
	}));
}
function verifyImageUrls(urls) {
	return Array.isArray(urls) ? Promise.all(urls.map(verifyImageUrl)) : Promise.reject('argument not an array');
}

function reduceImageSize(image, maxImageHeight, jpegQuality, progressHandler) {
	const baseUrl = 'https://dragon.img2go.com/api/jobs', referer = 'https://www.img2go.com/';
	const checkInterval = 250, async = true;
	const defaultParams = {
		url: referer,
		responseType: 'json',
		headers: { Referer: referer, Origin: 'https://www.img2go.com' },
		cookie: ['qg_locale_suggest=true', 'QGID=' + uuid(), 'qgrole=unregistered'].join('; '),
	};
	setUserAgent(defaultParams);
	delete defaultParams.url;
	const getErrorString = response => response.errors.map(error => `Error ${error.code} (${error.message})`).join(', ');
	const waitJobStatus = (id, status, async = undefined) => new Promise(function(resolve, reject) {
		let params = new URLSearchParams({ _: Date.now() });
		if (async !== undefined) params.set('async', async);
		function waitInput() {
			globalXHR(baseUrl + '/' + id + '?' + params.toString(), defaultParams).then(function({response}) {
				//console.debug(response.finalUrl, status, response.status.code, response.errors);
				if (response.status.code == status) resolve(response);
				else if (Array.isArray(response.errors) && response.errors.length > 0) reject(getErrorString(response));
				else if (response.status.code == 'failed') reject('reduceImageSize: the job failed');
				else setTimeout(waitInput, checkInterval);
			}).catch(reject);
		}
		waitInput();
	});
	return globalXHR(`${baseUrl}?async=${async}`, defaultParams, {
		operation: 'convert',
		fail_on_input_error: true,
		fail_on_conversion_error: true,
	}).then(function({response}) {
		if (response.status.code == 'failed') return Promise.reject('reduceImageSize: the job failed');
		if (response.status.code != 'init') console.warn('status.code =', response.status.code);
		if (response.sat.id_job) return waitJobStatus(response.sat.id_job, 'incomplete', async);
		console.warn('reduceImageSize: falling back to old protocol (!sat.id_job)');
		if (response.id) return response;
		return Promise.reject('reduceImageSize: missing job id');
	}).then(job => (httpParser.test(image) ? globalXHR(baseUrl + '/' + job.id + '/input', defaultParams, {
		type: 'remote',
		source: image,
		engine: 'auto',
	}).then(function({response}) {
		if (response.status != 'ready') console.warn('reduceImageSize: set remote input returns ' + response.status);
		return response;
	}) : (function() {
		if (typeof progressHandler == 'function') progressHandler(-1, null);
		if (image instanceof File) return image.getContent();
		if (image && typeof image == 'object' && /^(?:image)\//.test(image.type) && image.size >= 0 && image.data)
			return image;
		return Promise.reject('reduceImageSize: invalid input object');
	})().then(image => new Promise(function(resolve, reject) {
		const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
		let formData = '--' + boundary + '\r\n';
		formData += 'Content-Disposition: form-data; name="file[]"; filename="' + image.name.toASCII() + '"\r\n';
		formData += 'Content-Type: ' + image.type + '\r\n\r\n' + image.data + '\r\n';
		formData += '--' + boundary + '--\r\n';
		if (typeof progressHandler == 'function') progressHandler(-1, formData.length - image.size);
		GM_xmlhttpRequest({
			method: 'POST',
			url: job.server + '/upload-file/' + job.id,
			responseType: 'json',
			headers: Object.assign({
				'Accept': 'application/json',
				'Content-Type': 'multipart/form-data; boundary=' + boundary,
				'X-Oc-Token': job.token,
				'X-Oc-Upload-Uuid': uuid(),
			}, defaultParams.headers),
			data: formData,
			binary: true,
			//anonymous: true,
			timeout: getUploadTimeout(image.size),
			onload: function(response) {
				if (response.status >= 200 && response.status < 400) {
					if (!response.response.completed) console.warn('img2go upload not completed:', response.response);
					resolve(response.response);
				} else reject(defaultErrorHandler(response));
			},
			onprogress: typeof progressHandler == 'function' ? response => { progressHandler(-1, response) } : undefined,
			onerror: response => { reject(defaultErrorHandler(response)) },
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		});
	}))).then(response => new Promise(function(resolve, reject) {
		function waitInput() {
			globalXHR(baseUrl + '/' + job.id + '?_=' + Date.now(), defaultParams).then(function(response) {
// 				console.debug(response.finalUrl, response.response.status.code,
// 					response.response.input.map(input => input.status), response.response.errors);
				if (Array.isArray(response.response.input) && response.response.input.length > 0
						&& response.response.input.every(input => input.status == 'ready'))
					resolve(response.response.input);
				else if (Array.isArray(response.response.errors) && response.response.errors.length > 0)
					reject(getErrorString(response.response));
				else if (Array.isArray(response.response.input) && response.response.input.some(input => input.status == 'failed'))
					reject('One or more images failed to load');
				else setTimeout(waitInput, checkInterval);
			}).catch(reject);
		}
		waitInput();
	})).then(function(input) {
		if (!(maxImageHeight >= 0)) maxImageHeight = 1800;
		if (maxImageHeight > 0 && input[0].metadata.image_height > maxImageHeight) {
			var imageHeight = 2;
			while (input[0].metadata.image_height % imageHeight != 0
					|| input[0].metadata.image_height / imageHeight > maxImageHeight) ++imageHeight;
			imageHeight = input[0].metadata.image_height / imageHeight;
			if (imageHeight < maxImageHeight / 2) imageHeight = Math.ceil(maxImageHeight * 3/4);
		}
		return globalXHR(baseUrl + '/' + job.id + '/conversions', defaultParams, {
			category: 'image', options: {
				allow_multiple_outputs: false,
				height: imageHeight || undefined,
				quality: jpegQuality > 0 ? jpegQuality : imageHeight > 0 ? undefined : 75,
				antialias: true,
			},
			target: 'jpg',
		});
	}).then(() => waitJobStatus(job.id, 'completed').then(function(response) {
		if (response.output.length <= 0) throw 'Assertion failed: output.length == 1 (' + response.output.length + ')';
		console.assert(response.output.length == 1, 'response.output.length == 1');
		console.debug('img2go conversion result:', response.output.length == 1 ? response.output[0] : response.output);
		return response.output[0];
	})));
}

function optiPNG(urlOrFile, progressHandler = undefined) {
	return (function() {
		const url = 'https://ezgif.com/optipng';
		if (urlOrFile instanceof File) {
			if (!['image/png', 'image/apng'].includes(urlOrFile.type)) return Promise.reject('invalid format');
			if (urlOrFile.size > 35 * 2**20) return Promise.reject('image exceeding size limit');
			return urlOrFile.getContent().then(image => new Promise(function(resolve, reject) {
				const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
				let formData = '--' + boundary + '\r\n';
				formData += 'Content-Disposition: form-data; name="new-image"; filename="' + image.name.toASCII() + '"\r\n';
				formData += 'Content-Type: ' + image.type + '\r\n\r\n' + image.data + '\r\n';
				formData += '--' + boundary + '--\r\n';
				if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
				GM_xmlhttpRequest({ method: 'POST', url: url,
					headers: {
					 'Accept': 'text/html',
					 'Content-Type': 'multipart/form-data; boundary=' + boundary,
					},
					data: formData,
					binary: true,
					onload: response => {
					 if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					 resolve(domParser.parseFromString(response.responseText, 'text/html'));
					},
					onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress) : undefined,
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				});
			}));
		} else if (httpParser.test(urlOrFile)) return (getRemoteFileType(urlOrFile)).then(function(mimeType) {
			if (!['image/png', 'image/apng'].includes(mimeType)) return Promise.reject('not PNG');
			//return getRemoteFileSize(urlOrFile, false).catch(reason => undefined).then(function(remoteSize) {
			//if (remoteSize > 35 * 2**20) return Promise.reject('image exceeding size limit');
			let payLoad = new URLSearchParams({ 'new-image-url': urlOrFile });
			return globalXHR(url, undefined, payLoad).then(response => response.document);
			//});
		}); else return Promise.reject('invalid argument');
	})().then(function(dom) {
		let srcImg = dom.querySelector('img#target');
		if (srcImg != null && srcImg.src) srcImg = srcImg.src;
			else return Promise.reject('source image upload failed');
		let form = dom.querySelector('form.ajax-form');
		if (form == null) {
			console.warn('ezgif.com form not found');
			return srcImg || Promise.reject('invalid page structure');
		}
		let action = new URL(form.action);
		return globalXHR('https://ezgif.com' + action.pathname, undefined, new FormData(form)).then(function(response) {
			let optImg = response.document.querySelector('p.outfile > img');
			if (optImg != null && optImg.src) optImg = optImg.src; else {
				console.warn('ezgif.com optimisation failed');
				return srcImg || Promise.reject('optimisation failed');
			}
			let saving = response.document.querySelector('p.filestats > span:not([class])');
			if (saving != null) {
				let success = parseFloat(saving.textContent) < 0 || saving.style.color == 'green';
				console.log('optiPNG ' + (success ? 'success' : 'fail') + ':', urlOrFile, optImg, '(' + saving.textContent + ')');
				return success ? optImg : srcImg || urlOrFile;
			}
			console.warn('ezgif.com failed to extract optimisation stats fromm page');
			return urlOrFile instanceof File ? getRemoteFileSize(optImg).then(function(optSize) {
				let delta = optSize - urlOrFile.size, sign = ['-', '', '+'][Math.sign(delta) + 1];
				saving = delta * 100 / urlOrFile.size;
				console.log('optiPNG ' + (delta < 0 ? 'success' : 'fail') + ':', urlOrFile, optImg, optSize,
					'(' + sign + Math.abs(delta) + ' = ' + sign + Math.abs((Math.round(saving * 10) / 10)) + '%)');
				return delta < 0 ? optImg : srcImg || urlOrFile;
			}, function(reason) {
				console.warn('ezgif.com failed to query result image size', optImg)
				return optImg;
			}) : optImg;
		});
	});
}

function reactRequest(url, operationName, variables) {
	if (!url || !operationName) throw 'Invalid argument';
	const reactRequest = (refreshCache = false) => (function() {
		if ('discogsGraphqlHashes' in localStorage) try {
			if (refreshCache) localStorage.removeItem('discogsGraphqlHashes');
			else return Promise.resolve(JSON.parse(localStorage.getItem('discogsGraphqlHashes')));
		} catch(e) { console.warn(e) }
		return globalXHR(url, { anonymous: true }).then(function({document}) {
			const script = Array.prototype.find.call(document.getElementsByTagName('script'), function(script) {
				if (script.src) try { script = new URL(script.src) } catch(e) { return false } else return false;
				return script.hostname == 'catalog-assets.discogs.com'
					&& /^\/main\.\w+\.js$/i.test(script.pathname);
			});
			return script ? globalXHR(script.src, { responseType: 'text', anonymous: true })
				: Promise.reject('Unexpected document structure');
		}).then(function({responseText}) {
			let sha256Hashes = /\bJSON\.parse\s*\(\s*'(\{\s*"\w+Data".+?)'\);/.exec(responseText);
			if (sha256Hashes != null) sha256Hashes = JSON.parse(sha256Hashes[1]);
			else throw 'Script pattern was not located';
			if (!(sha256Hashes instanceof Object)) throw 'Malformed SHA256 hashes extracted';
			localStorage.setItem('discogsGraphqlHashes', JSON.stringify(sha256Hashes));
			refreshCache = true;
			return sha256Hashes;
		});
	})().then(function(sha256Hashes) {
		const requestUrl = new URL('/service/catalog/api/graphql', 'https://www.discogs.com');
		requestUrl.searchParams.set('operationName', operationName);
		if (variables) requestUrl.searchParams.set('variables', JSON.stringify(variables));
		if (!(sha256Hashes = sha256Hashes[operationName]))
			throw 'Assertion failed: SHA256 hash was not found';
		requestUrl.searchParams.set('extensions',
			JSON.stringify({ persistedQuery: { sha256Hash: sha256Hashes, version: 1 } }));
		return globalXHR(requestUrl, { responseType: 'json', anonymous: true, headers: { Referer: url } });
	}).then(({json}) => json.data || Promise.reject('Invalid response format')).catch(function(reason) {
		if (!refreshCache) return reactRequest(true);
		console.warn('React request error:', reason);
		return Promise.reject(reason);
	});
	return reactRequest();
}

function getDiscogsImages(entity, discogsId) { // non-API query React method
	if (entity && (discogsId = parseInt(discogsId)) > 0) entity = entity.toLowerCase();
    else throw 'Invalid argument';
	const operationName = {
		artist: 'ArtistAllImages',
		master: 'MasterReleaseAllImages',
		release: 'ReleaseAllImages',
		label: 'LabelAllImages',
	}[entity];
	if (!operationName) return Promise.reject(`Unsupported entity (${entity})`);
    return reactRequest(`https://www.discogs.com/${entity}/${discogsId}`, operationName, {
        discogsId: discogsId,
        count: 2000,
	}).then(function(data) {
    	switch(entity) {
    		case 'artist': case 'release': case 'label': var root = data[entity]; break;
    		case 'master': root = data.masterRelease.keyRelease; break;
    	}
    	console.assert(root, entity, data);
    	if (!root) return Promise.reject(`Images root not found for current entity (${entity})`);
    	if (!(root.images.totalCount > 0)) return Promise.reject('No images for entity');
    	return root.images.edges.map(edge => edge.node.fullsize.sourceUrl);
	});
}

function googlePhotosResolver(url) {
	return globalXHR(url).then(function(response) {
		var result;
		response.document.querySelectorAll('body > script[nonce]').forEach(function(script) {
			if (result) return;
			if (!/^(?:AF_initDataCallback)\(\{\s*key\s*:\s*'ds:(\d+)'.+\b(?:data:function)\(\)\s*\{\s*(?:return)\s*(\[[\S\s]+?\])\s*\}\}\);$/
				.test(script.text)) return;
			let AF_initDataCallback = eval(RegExp.$2);
			if (AF_initDataCallback.length == 14 && Array.isArray(AF_initDataCallback[0])) try {
				if (httpParser.test(AF_initDataCallback[0][1][0])) result = AF_initDataCallback[0][1][0] + '=s0';
			} catch(e) { console.warn(e, AF_initDataCallback) }
			else if (AF_initDataCallback.length == 6 && Array.isArray(AF_initDataCallback[1])) try {
				result = AF_initDataCallback[1].map(photo => photo[1][0] + '=s0');
			} catch(e) { console.warn(e, AF_initDataCallback) }
		});
		return result || Promise.reject('No image content for this URL');
	});
}

function pinterestResolver(url) {
	return globalXHR(url).then(function(response) {
		let initialState = response.document.querySelector('script#initial-state');
		if (initialState != null) try {
			initialState = JSON.parse(initialState.text);
			let images = Object.keys(initialState.pins).map(pin => initialState.pins[pin].images.orig.url);
			if (images.length == 1) return images[0]; else if (images.length > 1) return images;
			let boards = Object.keys(initialState.boards.content);
			if (boards.length > 0) {
				return Promise.all(boards.map(function(board) {
					let params = new URLSearchParams({
						source_url: response.finalUrl,
						data: JSON.stringify({ options: {
							board_id: initialState.boards.content[board].id,
							board_url: initialState.boards.content[board].url,
						} }),
						_: Date.now(),
					});
					return globalXHR(url.origin + '/resource/BoardFeedResource/get/?' + params, {
						responseType: 'json',
						headers: { Referer: response.finalUrl },
					}).then(function(response) {
						if (response.response.resource_response.status != 'success') {
							console.warn('Pinterest:', response.response.resource_response, response.finalUrl);
							return Promise.reject('Pinterest: ' + response.response.resource_response.status);
						}
						return response.response.resource_response.data.filter(it => it.type == 'pin').map(it => it.images.orig.url);
					});
				}));
			}
		} catch(e) { console.warn(e, initialState) }
		return Promise.reject('No title image for this URL');
	});
}

function _500pxUrlHandler(path) {
	return globalXHR('https://api.500px.com/v1/' + path + '&image_size[]=4096', { responseType: 'json' }).then(function({response}) {
		let results = Object.keys(response.photos).map(id => response.photos[id].image_url[0]);
		return results.length == 1 ? results[0] : results.length > 1 ? results
			: Promise.reject('No image content found on this UIRL');
	});
}

function pxhereCollectionResolver(url) {
	if (!/\/collection\/(\d+)\b/i.test(url.pathname)) return Promise.reject('Invalid URL');
	let collectionId = parseInt(RegExp.$1);
	return new Promise(function(resolve, reject) {
		let photos = [ ];
		loadPage();

		function loadPage(page = 1) {
			GM_xmlhttpRequest({ method: 'GET', url: `https://pxhere.com/en/collection/${collectionId}?page=${page}&format=json`,
				responseType: 'json',
				onload: function(response) {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response._msg != 'success') return reject(response.response._msg);
					if (!response.response.data) return resolve(photos);
					let dom = domParser.parseFromString(response.response.data, 'text/html');
					Array.prototype.push.apply(photos, Array.from(dom.querySelectorAll('div.item > a')).map(a => a.pathname));
					loadPage(page + 1);
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}
	}).then(urls => Promise.all(urls.map(url => imageUrlResolver('https://pxhere.com' + url))));
}

function unsplashCollectionResolver(url) {
	if (!/\/collections\/(\d+)\b/i.test(url.pathname)) return Promise.reject('Invalid URL');
	let collectionId = parseInt(RegExp.$1);
	return new Promise(function(resolve, reject) {
		let urls = [];
		loadPage();

		function loadPage(page = 1) {
			GM_xmlhttpRequest({ method: 'GET', url: `https://unsplash.com/napi/collections/${collectionId}/photos?page=${page}&per_page=999`,
				responseType: 'json',
				onload: function(response) {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					if (response.response.length <= 0) return resolve(urls);
					Array.prototype.push.apply(urls, response.response.map(photo => photo.urls.raw.replace(/\?.*$/, '')));
					loadPage(page + 1);
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}
	});
}

function pexelsCollectionResolver(url) {
	if (!/\/collections\/([\w\%\-]+)\//i.test(url.pathname)) return Promise.reject('Invalid URL');
	let collectionId = RegExp.$1;
	return new Promise(function(resolve, reject) {
		let urls = [ ];
		loadPage();

		function loadPage(page = 1) {
			GM_xmlhttpRequest({ method: 'GET', url: `https://www.pexels.com/collections/${collectionId}/?format=html&page=${page}`,
				onload: function(response) {
					if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
					let dom = domParser.parseFromString(response.responseText, 'text/html');
					let photos = dom.querySelectorAll('article.photo-item > a.js-photo-link');
					if (photos.length <= 0) return resolve(urls);
					Array.prototype.push.apply(urls, Array.from(photos).map(a => a.pathname));
					loadPage(page + 1);
				},
				onerror: response => { reject(defaultErrorHandler(response)) },
				ontimeout: response => { reject(defaultTimeoutHandler(response)) },
			});
		}
	}).then(urls => Promise.all(urls.map(url => imageUrlResolver('https://www.pexels.com' + url))));
}

function getRemoteFileType(url) {
	if (!httpParser.test(url)) return Promise.reject('getRemoteFileType: parameter not valid URL');
	if (fileTypeCache.has(url)) return Promise.resolve(fileTypeCache.get(url));
	const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
		const requestParams = { method: method, url: url };
		setUserAgent(requestParams);
		let contentType, hXHR = GM_xmlhttpRequest(Object.assign(requestParams, {
			onreadystatechange: function(response) {
				if (contentType !== undefined || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
				if ((contentType = /^(?:Content-Type)\s*:\s*(.+?)(?:\s*;(.+?))?\s*$/im.exec(response.responseHeaders)) != null) {
					fileTypeCache.set(url, contentType = contentType[1].toLowerCase());
					sessionStorage.setItem('fileTypeCache', JSON.stringify(Array.from(fileTypeCache)));
					resolve(contentType);
				} else reject('MIME type missing in header');
				if (method != 'HEAD') hXHR.abort();
			},
			onerror: response => { reject(defaultErrorHandler(response)) },
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		}));
	});
	return getByXHR('HEAD')
		.catch(reason => /^HTTP error (403|416)\b/.test(reason) ? getByXHR('GET') : Promise.reject(reason));
}

function getRemoteFileSize(url, forced = true) {
	if (!httpParser.test(url)) return Promise.reject('getRemoteFileSize(...): parameter not valid URL');
	if (fileSizeCache.has(url)) return Promise.resolve(fileSizeCache.get(url));
	const getByXHR = (method = 'GET') => new Promise(function(resolve, reject) {
		function success() {
			fileSizeCache.set(url, size);
			sessionStorage.setItem('fileSizeCache', JSON.stringify(Array.from(fileSizeCache)));
			resolve(size);
		}
		const requestParams = { method: method, url: url, binary: true, responseType: 'blob' };
		setUserAgent(requestParams);
		let size, hXHR = GM_xmlhttpRequest(Object.assign(requestParams, {
			onreadystatechange: function(response) {
				if (typeof size == 'number' && size >= 0 || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
				if (/*(size = response.responseText) && (size = new Blob([size]).size) >= 0
				        || */(size = response.response) && (size = size.size) >= 0) success();
					else if (!forced || method == 'HEAD') reject('Body size missing in header'); else return;
				if (method != 'HEAD') hXHR.abort();
			},
			onload: function(response) { // fail-safe
				if (typeof size == 'number' && size >= 0) return;
				else if (response.status >= 200 && response.status < 400) {
					if (/*(size = response.responseText) && (size = new Blob([size]).size) >= 0
					        || */(size = response.response) && (size = size.size) >= 0) resolve(size);
					else reject('File content not loaded');
				} else reject(defaultErrorHandler(response));
			},
			onerror: response => { reject(defaultErrorHandler(response)) },
			ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		}));
	});
	return getByXHR(forced ? 'GET' : 'HEAD')
		.catch(reason => !forced && /^HTTP error (403|416)\b/.test(reason) ? getByXHR('GET') : Promise.reject(reason));
}

function formattedSize(size) {
	const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
	const e = (size = Math.max(size, 0)) > 0 ? Math.min(Math.floor(Math.log2(size) / 10), units.length - 1) : 0;
	return `${(size / Math.pow(2, e * 10)).toFixed(Math.min(e, 3))}\xA0${units[e]}`;
}

function voidDragHandler0(evt) { return false }
function inputDropHandler(evt) {
	if (evt.currentTarget.style.backgroundColor)
		evt.currentTarget.style.backgroundColor = evt.currentTarget.dataset.backgroundColor || null;
	return !evt.shiftKey ? inputDataHandler(evt, evt.dataTransfer) : true;
}
function inputPasteHandler(evt) { return inputDataHandler(evt, evt.clipboardData) }
function inputClear(evt) { evt.currentTarget.value = '' }

function setInputHandlers(elem, highlightDragOver = '#FF04') {
	if (!(elem instanceof HTMLInputElement) || elem.disabled || elem.offsetParent == null) return;
	elem.ondragover = voidDragHandler0;
	elem.ondblclick = inputClear;
	elem.ondrop = inputDropHandler;
	elem.onpaste = inputPasteHandler;
	elem.placeholder = 'Paste/drop local or remote image';
	if (!highlightDragOver) return;
	const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
	elem.ondragenter = function(evt) {
		if (evt.currentTarget.disabled || !evt.dataTransfer.types.some(type =>
			['Files', 'text/uri-list', 'text/plain'].includes(type))) return false;
		evt.currentTarget.backgroundColor = evt.currentTarget.style.backgroundColor || null;
		evt.currentTarget.style.backgroundColor = highlightDragOver;
	};
	elem[`ondrag${isFirefox ? 'exit' : 'leave'}`] = function(evt) {
		if (!('backgroundColor' in evt.currentTarget)) return false;
		evt.currentTarget.style.backgroundColor = evt.currentTarget.backgroundColor;
		delete evt.currentTarget.backgroundColor;
	};
}

function setTextAreahandlers(elem, highlightDragOver = '#FF04') {
	if (!(elem instanceof HTMLTextAreaElement) || elem.disabled || elem.offsetParent == null) return;
	elem.ondragover = voidDragHandler0;
	elem.ondrop = function(evt) {
		if (evt.currentTarget.style.backgroundColor)
			evt.currentTarget.style.backgroundColor = evt.currentTarget.backgroundColor || null;
		return textAreaDropHandler(evt);
	}
	elem.onpaste = textAreaPasteHandler;
	if (!highlightDragOver) return;
	const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
	elem.ondragenter = function(evt) {
		if (evt.currentTarget.disabled || !evt.dataTransfer.types.some(type =>
			['Files', 'text/uri-list'].includes(type))) return false;
		evt.currentTarget.backgroundColor = evt.currentTarget.style.backgroundColor || null;
		evt.currentTarget.style.backgroundColor = highlightDragOver;
	};
	elem[`ondrag${isFirefox ? 'exit' : 'leave'}`] = function(evt) {
		if (!('backgroundColor' in evt.currentTarget)) return false;
		evt.currentTarget.style.backgroundColor = evt.currentTarget.backgroundColor;
		delete evt.currentTarget.backgroundColor;
	};
};

function randomString(length) {
	const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
	let text = "";
	for (let i = 0; i < length; ++i) text += possible.charAt(Math.floor(Math.random() * possible.length));
	return text;
}

function isSupportedType(image) {
	if (!this || typeof this != 'object' || !image || typeof image != 'object') return false;
	if (!Array.isArray(this.types) || this.types.length <= 0) return !image.type || image.type.startsWith('image/');
	return this.types.some(function(mimeType) {
		if (!mimeType) return false;
		if (image.type) return image.type == 'image/' + mimeType.toLowerCase();
		const testExt = extensions => extensions.some(ext => image.name.toLowerCase().endsWith('.' + ext.toLowerCase()));
		return image.name && testExt([mimeType]);
	});
}


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