Greasy Fork is available in English.

imageHostUploader

×

Tính đến 28-09-2020. Xem phiên bản mới nhất.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greasyfork.org/scripts/401726/852379/imageHostUploader.js

// ==UserScript==
// @name         imageHostUploader
// @namespace    https://greasyfork.org/cs/users/321857-anakunda
// @version      2.07
// @author       Anakunda
// @require      https://greasyfork.org/scripts/408084-xhrlib/code/xhrLib.js
// @require      https://greasyfork.org/scripts/404516-progressbars/code/progressBars.js
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

// https://funkyimg.com/ (4MB limit)
// http://ge.tt/ (no HTTPS)
// http://savephoto.ru/ (no HTTPS)

'use strict';

const minUploadSpeed = parseFloat(GM_getValue('min_upload_speed')); // in Mbit/s
const rehostTimeout = 30000;
const urlParser = /^\s*(https?:\/\/\S+)\s*$/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;
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\u0080-\uFFFF]/g, '');
};

File.prototype.toContent = 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);
  });
};

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

class PTPimg {
  constructor() {
	this.alias = 'PTPimg';
	this.origin = 'https://ptpimg.me';
	this.types = ['png', 'jpeg', 'gif', 'bmp'];
	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.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();
	  var 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,
		  'Content-Length': formData.length,
		},
		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');
		  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;
		  }));
		},
		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 this.setSession().then(apiKey => Promise.all(urls.map(url => {
	  if (!urlParser.test(url)) return Promise.reject('URL not valid (' + url + ')');
	  var hostname = new URL(url).hostname;
	  if (hostname == 'img.discogs.com' || hostname.endsWith('omdb.org')) {
		return verifyImageUrl('https://reho.st/' + url)
		  .catch(reason => imageHostHandlers.catbox.rehost([url]).then(imgUrls => imgUrls[0]))
		  .catch(reason => imageHostHandlers.pixhost.rehost([url]).then(imgUrls => imgUrls[0].original))
		  .catch(reason => this.reupload(url));
	  } else if (!['png', 'jpg', 'jpeg', 'jfif', 'gif', 'bmp'].some(ext => url.toLowerCase().endsWith('.' + ext))) {
		return verifyImageUrl(url + '#.jpg')
		  .catch(reason => imageHostHandlers.imgbb.rehost([url]).then(imgUrls => imgUrls[0].original))
		  .catch(reason => imageHostHandlers.jerking.rehost([url]).then(imgUrls => imgUrls[0].original))
		  .catch(reason => imageHostHandlers.pixhost.rehost([url]).then(imgUrls => imgUrls[0]).original)
	  }
	  return verifyImageUrl(url);
	})).then(imageUrls => {
	  console.debug('PTPimg.rehost(...) input:', imageUrls);
	  var formData = new URLSearchParams({
		'link-upload': imageUrls.join('\r\n'),
		'api_key': apiKey,
	  });
	  return globalFetch(this.origin + '/upload.php', {
		responseType: 'json',
		timeout: imageUrls.length * rehostTimeout,
	  }, formData).then(response => {
		if (!response.response) return Promise.reject('void response');
		if (response.response.length < imageUrls.length) {
		  console.warn('PTPimg returning incomplete list of images (', response.response, ')');
		  return Promise.reject(`not all images rehosted to (${response.response.length}/${imageUrls.length})`)
		}
		if (response.response.length > imageUrls.length)
		  console.warn('PTPimg returns more links than expected (', response.response, imageUrls, ')');
		return response.response.map((item, ndx) => {
		  if (!item.ext && /\.([a-z]+)(?=$|[\#\?])/i.test(imageUrls[ndx])) item.ext = RegExp.$1;
		  return this.origin + '/' + item.code + '.' + item.ext;
		});
	  });
	}));
  }

  reupload(imgUrl) {
	console.warn('PTPimg rehoster fallback to local reupload');
	return globalFetch(imgUrl, { responseType: 'blob' }).then(response => {
	  var image = {
		name: imgUrl.replace(/^.*\//, ''),
		data: response.responseText,
		size: response.responseText.length,
	  };
	  switch (imgUrl.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 this.upload([image]).then(imgUrls => imgUrls[0]);
	});
  }

  setSession() {
	return this.apiKey ? Promise.resolve(this.apiKey) : globalFetch(this.origin).then(response => {
	  var apiKey = response.document.getElementById('api_key');
	  if (apiKey == null) {
		let counter = GM_getValue('ptpimg_reminder_read', 0);
		if (counter < 3) {
		  alert(`
PTPimg API key could not be captured. Please login to ${this.origin}/ and redo the action.
If you don\'t have PTPimg account or don\'t want to use it, consider to remove PTPimg from
upload_hosts and rehost_hosts local storage entries.
`);
		  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 = '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();
	  var 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,
		  'Content-Length': formData.length,
		  '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(this.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) {
	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 => {
	  var formData = new URLSearchParams(Object.assign({
		action: 'upload',
		type: 'url',
		nsfw: 0,
		thumb_width: 200,
		//thumb_height: 200,
		format: 'json',
	  }, session));
	  formData.set(session.key && this.apiFieldName || 'source', imageUrl);
	  return globalFetch(session.key ? (this.apiEndpoint || this.origin + '/api/1') + '/upload'
			: this.jsonEndpoint || this.origin + '/json', {
		responseType: 'json',
		headers: { 'Referer': this.origin },
		timeout: urls.length * rehostTimeout,
	  }, formData).then(response => {
		if (!response.response.success)
		  return Promise.reject(`${this.alias || this.origin}: ${response.response.error.message} (${response.response.status_code})`);
		if (typeof progressHandler == 'function') progressHandler(true);
		return this.resultHandler(response.response[session.key && this.apiResultKey || 'image']);
	  });
	}))));
  }

  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 => {
	  var formData = new URLSearchParams(Object.assign({
		action: 'get-album-contents',
		albumid: albumId,
	  }, session));
	  return globalFetch(this.origin + '/json', {
		responseType: 'json',
		headers: { 'Referer': url },
	  }, formData).then(response => {
		return response.response.status_txt == 'OK' && Array.isArray(response.response.contents) ?
		  response.response.contents.map(image => image.url)
			  : Promise.reject(`${this.alias || this.origin}: ${response.response.error.message} (${response.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 = [], domParser = new DOMParser;
		getPage(url);

		function getPage(url) {
		  GM_xmlhttpRequest({ method: 'GET', url: url, headers: { Referer: url },
			onload: response => {
			  if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
			  var dom = domParser.parseFromString(response.responseText, 'text/html');
			  Array.prototype.push.apply(urls,
				  Array.from(dom.querySelectorAll('div.list-item-image > a.image-container')).map(a => a.href));
			  var next = dom.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(imageUrlResolver)));
	});
  }

  setSession(requireToken = true, requireLogin = false) {
	var 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 globalFetch(this.origin).then(response => {
	  if (/\b(?:auth_token)\s*=\s*"(\w+)"/m.test(response.responseText)) var authToken = RegExp.$1;
	  if (!authToken) {
		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');
	  var formData = new URLSearchParams({
		'login-subject': this.uid,
		'password': this.password,
		'auth_token': session.auth_token,
	  });
	  return new Promise((resolve, reject) => {
		GM_xmlhttpRequest({ method: 'POST', url: this.origin + '/login',
		  headers: {
			'Accept': '*/*',
			'Content-Type': 'application/x-www-form-urlencoded',
			'Content-Length': formData.toString().length,
			'Referer': this.origin + '/login',
		  }, data: formData.toString(),
		  onload: response => {
			if (response.status < 200 || response.status > 400) defaultErrorHandler(response);
			resolve(response.status);
		  },
		  onerror: response => {
			reject(defaultErrorHandler(response));
			//resolve(response.status);
		  },
		  ontimeout: response => {
			reject(defaultTimeoutHandler(response));
			//resolve(response.status);
		  },
		});
	  }).then(status => globalFetch(this.origin, { responseType: 'text' }).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 ? session : Promise.reject('login failed (' + reason + ')');
	  });

	  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');
	return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
	  const boundary = '--------WebKitFormBoundary-' + Date.now().toString(16).toUpperCase();
	  var formData = '--' + boundary + '\r\n';
	  formData += 'Content-Disposition: form-data; name="img"; 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';
	  if (typeof progressHandler == 'function') progressHandler(formData.length - image.size);
	  GM_xmlhttpRequest({
		method: 'POST',
		url: 'https://api.pixhost.to/images',
		responseType: 'json',
		headers: {
		  'Accept': 'application/json',
		  'Content-Type': 'multipart/form-data; boundary=' + boundary,
		  'Content-Length': formData.length,
		},
		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'));
	  var formData = new URLSearchParams({
		imgs: imageUrls.join('\r\n'),
		content_type: 0,
		tos: 'on',
	  });
	  return globalFetch(this.origin + '/remote/', {
		responseType: 'text',
		timeout: imageUrls.length * rehostTimeout,
	  }, formData).then(response => {
		if (!/\b(?:upload_results)\s*=\s*(\{.*\});$/m.test(response.responseText))
		  return Promise.reject('page parsing error');
		var images = JSON.parse(RegExp.$1).images;
		if (images.length < imageUrls.length)
		  return Promise.reject(`not all images rehosted (${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');
	return this.setSession().catch(reason => undefined).then(userHash => Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
	  var now = Date.now();
	  const boundary = '--------WebKitFormBoundary-' + now.toString(16).toUpperCase();
	  var 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,
		  'Content-Length': formData.length,
		},
		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 => {
	  var formData = new URLSearchParams({
		reqtype: 'urlupload',
		url: imageUrl,
	  });
	  if (userHash) formData.set('userhash', userHash);
	  return globalFetch(this.origin + '/user/api.php', {
		responseType: 'text',
		timeout: urls.length * rehostTimeout,
	  }, formData).then(response => {
		if (typeof progressHandler == 'function') progressHandler(true);
		return response.responseText;
	  });
	}))));
  }

  setSession() {
	return this.userHash ? Promise.resolve(this.userHash) : globalFetch(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');
	return this.setSession().then(session => 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;
	  var now = Date.now();
	  const boundary = '--------WebKitFormBoundary-' + now.toString(16).toUpperCase();
	  var 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,
		  'Content-Length': formData.length,
		  '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 globalFetch(this.origin + '/').then(response => {
	  var 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;
	  var 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',
		'Content-Length': formData.toString().length,
	  }, data: formData.toString() });
	  return new Promise((resolve, reject) => {
		setTimeout(() => { globalFetch('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 => globalFetch(this.origin + '/ajax/token/generate', {
	  method: 'POST',
	  responseType: 'json',
	  headers: { 'X-CSRF-Token': csrfToken },
	}).then(response => ({
	  csrf_token: csrfToken,
	  params: {
		token_id: response.response.token_id,
		token_secret: response.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.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');
	return this.setSession().then(clientId => 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();
	  var 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="Filedata"; filename="' + image.name.toASCII() + '"\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 + '/upload',
		//url: 'https://api.imgur.com/3/image?client_id=' + clientId,
		responseType: 'json',
		headers: {
		  'Content-Type': 'multipart/form-data; boundary=' + boundary,
		  'Content-Length': formData.length,
		  'Referer': this.origin + '/upload',
		  //'Referer': this.origin + '/upload?beta',
		},
		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));
		  resolve('https://i.imgur.com/' + response.response.data.hash + response.response.data.ext);
		  //if (!response.response.success) return reject('status:' + response.response.status);
		  //resolve(response.response.link);
		},
		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 => {
	  var formData = new URLSearchParams({ url: imageUrl });
	  return globalFetch(this.origin + '/upload', {
		responseType: 'json',
		headers: { Referer: this.origin + '/upload' },
		timeout: urls.length * rehostTimeout,
	  }, formData).then(result => {
		if (!result.response.success) return Promise.reject(result.response.status);
		if (typeof progressHandler == 'function') progressHandler(true);
		return 'https://i.imgur.com/' + result.response.data.hash + result.response.data.ext;
	  });
	}))));
  }

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

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');
	return this.setSession().then(session => 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();
	  var 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.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: {
		  'Content-Type': 'multipart/form-data; boundary=' + boundary,
		  'Content-Length': formData.length,
		  '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') return reject(response.response.status);
		  resolve(response.response.url);
		},
		onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
		onerror: response => { reject(defaultErrorHandler(response)) },
		ontimeout: response => { reject(defaultTimeoutHandler(response)) },
	  });
	}).then(PostImage.resultHandler))));
  }

  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 => {
	  var formData = new URLSearchParams(Object.assign({ url: imageUrl }, session));
	  return globalFetch(this.origin + '/json/rr', {
		responseType: 'json',
		timeout: urls.length * rehostTimeout,
	  }, formData).then(response => {
		if (response.status < 200 || response.status >= 400) return Promise.reject(defaultErrorHandler(response));
		if (response.response.status != 'OK') return Promise.reject(response.response.status);
		if (typeof progressHandler == 'function') progressHandler(true);
		return PostImage.resultHandler(response.response.url);
	  });
	}))));
  }

  static resultHandler(resultUrl) {
	return globalFetch(resultUrl).then(function(response) {
	  var thumb = response.document.querySelector('div.thumb > a.img');
	  if (thumb == null || !/\b(?:url)\("(.+)"\)/.test(thumb.style.backgroundImage)) throw 'Page parsing error';
	  thumb = RegExp.$1;
	  return {
		original: response.document.querySelector('meta[property="og:image"][content]').content,
		thumb: thumb,
		share: response.document.querySelector('meta[property="og:url"][content]').content,
	  }
	}).catch(reason => imageUrlResolver(resultUrl))
  }

  static galleryResolver(url) {
	return globalFetch(url, { responseType: 'text' }).then(function(response) {
	  if (/\b(?:var\s+embed_value)=(\{[\S\s]+?\});/.test(response.responseText)) try {
		let embed_value = JSON.parse(RegExp.$1);
		return Object.keys(embed_value).map(key => 'https://i.postimg.cc/'
			.concat(embed_value[key][2], '/', embed_value[key][0], '.', embed_value[key][1]))
	  } catch(e) { console.warn(e) }
	  return Promise.reject('URL not resolvable');
	});
  }

  setSession() {
	return globalFetch(this.origin + '/').then(response => {
	  var session = {
		session_upload: Date.now(),
		upload_session: randomString(32),
		optsize: 0,
		expire: 0,
		numfiles: 1,
		upload_referer: btoa(this.origin + '/'),
	  };
	  if (/"token","(\w+)"/.test(response.responseText)) session.token = RegExp.$1;
	  if (response.document.querySelector('nav.authorized') != null) return session;
	  if (!this.uid || !this.password) return session;
	  var formData = new URLSearchParams({
		'email': this.uid,
		'password': this.password,
	  });
	  return new Promise((resolve, reject) => GM_xmlhttpRequest({
		method: 'POST', url: this.origin + '/login',
		data: formData.toString(),
		headers: {
		  'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
		  'Content-Length': formData.toString().length,
		  'Referer': this.origin + '/login',
		},
		onload: response => {
		  if (response.status >= 200 && response.status <= 400) resolve(session);
			  else reject(defaultErrorHandler(response));
		},
		onerror: response => { reject(defaultErrorHandler(response)) },
		ontimeout: response => { reject(defaultTimeoutHandler(response)) },
	  }));
	});
  }
}

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.batchLimit && images.length > this.batchLimit)
	  return Promise.reject('batch limit exceeded (' + this.batchLimit + ')');
	return this.setSession().then(session => new Promise((resolve, reject) => {
	  var now = Date.now();
	  const boundary = '--------WebKitFormBoundary-' + now.toString(16).toUpperCase();
	  var 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,
		  'Content-Length': formData.length,
		},
		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 => globalFetch(resultUrl)).then(response => {
	  var 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 globalFetch(this.origin + '/').then(response => {
	  var 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;
	  var 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',
		'Content-Length': formData.toString().length,
	  }, data: formData.toString() });
	  return new Promise((resolve, reject) => {
		setTimeout(() => {
		  globalFetch(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 => globalFetch(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.response.data,
	  _token: csrfToken,
	})));
  }
}

class FastPic {
  constructor() {
	this.alias = 'FastPic';
	this.origin = 'https://fastpic.ru';
	this.type = ['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.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();
	  var formData = '--' + boundary + '\r\n';
	  images.forEach(image => {
		if (this.sizeLimit > 0 && image.size > this.sizeLimit * 2**20) throw 'size limit exceeded: ' + image.name;
		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,
		  'Content-Length': formData.length,
		  '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 => globalFetch(resultUrl).then(response => {
	  var 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) => globalFetch(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.upload.acceptFiles = true;
  }

  upload(files) {
	if (!Array.isArray(files)) return Promise.reject('invalid argument');
	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)));
  }

  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;
	}))));
  }

  static loadJS() {
	if (document.domain != 'notwhat.cd') return Promise.reject('uploadToImagehost not available');
	return typeof uploadToImagehost == 'function' ? Promise.resolve(uploadToImagehost) : new Promise((resolve, reject) => {
	  var 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');
	return this.setSession().then(session => 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();
	  var 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,
		  'Content-Length': formData.length,
		  '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 => {
	  var formData = new URLSearchParams(Object.assign({}, session, {
		resize: 'none',
		rules: 'on',
		gallery: '',
		upload_mapping: JSON.stringify(uploadMapping).replace(/"/g, '\\"'),
	  }));
	  return globalFetch('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 => {
	  var 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 globalFetch('https://' + session.server + '.abload.de/flashUploadFinished.php?server=' + session.server, {
		headers: { Referer: this.origin + '/' },
		timeout: imageUrls.length * rehostTimeout,
	  }, formData).then(Abload.resolveRedirect);
	}));
  }

  static resolveRedirect(response) {
	var form = response.document.querySelector('form#weiter');
	if (form == null) return Promise.reject(response.responseText);
	var formData = new FormData(form);
	formData = new URLSearchParams(formData);
	return globalFetch(form.action, { headers: { Referer: response.finalUrl } }, formData).then(response =>
		Array.from(response.document.querySelectorAll('table.image_links > tbody > tr > td > input[type="text"]'))
		  .filter(input => urlParser.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 globalFetch(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;
	  var formData = new URLSearchParams({ name: this.uid, password: this.password });
	  return globalFetch(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";
		var text = "";
		for (var 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');
	return this.setSession().then(session => 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();
	  var 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,
		  'Content-Length': formData.length,
		  '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 => {
	  var formData = new URLSearchParams({
		OriginalFileName: imageUrl,
		MaxSize: 99999,
		PrevMaxSize: 500,
		IsPublic: false,
		NeedResize: false,
		Rotate: 0,
		RotateMetadataRelative: false,
		Url: imageUrl,
	  });
	  return globalFetch(this.origin + '/Img/SaveImg2', {
		responseType: 'json',
		headers: { 'Referer': this.origin },
		cookie: 'USER_ID=' + session.Id || '',
		timeout: urls.length * rehostTimeout,
	  }, formData).then(response => {
		if (response.status < 200 || response.status >= 400) return Promise.reject(defaultErrorHandler(response));
		if (response.response.IsError)
		  return Promise.reject(`${response.response.ErrorSrvMsg} (${response.response.Errors._allerrors_.join(' / ')})`);
		if (typeof progressHandler == 'function') progressHandler(true);
		return {
		  original: response.response.Url,
		  thumb: response.response.PublicPrevUrl,
		  share: response.response.PrevPageUrl,
		};
	  });
	}))));
  }

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

	function getUserInfo(response) {
	  if (/\b(?:var\s+serverVm)\s*=\s*(\{.*\});$/m.test(response.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');
	return this.setSession().then(submitUrl => 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();
	  var 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,
		  'Content-Length': formData.length,
		  '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));
		  let domParser = new DOMParser;
		  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 globalFetch(this.origin).then(response => {
	  var 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');
	return 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();
	  var 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,
		  'Content-Length': formData.length,
		  'Referer': this.origin,
		},
		data: formData,
		binary: true,
		//fetch: 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');
	return this.setSession().then(userInfo => 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();
	  var 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,
		  'Content-Length': formData.length,
		  'Referer': this.origin,
		},
		data: formData,
		binary: true,
		//fetch: 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() {
	var params = {
	  id: 1,
	  jsonrpc: '2.0',
	  method: 'get_userinfo',
	  params: {},
	};
	return globalFetch('https://api.prntscr.com/v1/', { responseType: 'json' }, JSON.stringify(params)).then(response => {
	  if (response.response.result.success) return response.response.result;
	  // TODO: login
	  return response.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');
	return this.setSession().then(session => 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();
	  var 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,
		  'Content-Length': formData.length,
		  '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 => {
	  var formData = new URLSearchParams(Object.assign({
		u_url: imageUrls.join('\n'),
	  }, session));
	  return globalFetch(this.origin + '/urlup', {
		headers: { 'Referer': this.origin },
		timeout: imageUrls.length * rehostTimeout,
	  }, 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');
	return this.setSession().then(session => 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();
	  var 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,
		  'Content-Length': formData.length,
		  '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 => {
	  var formData = new URLSearchParams(session);
	  formData.set('ImagesForm[imageFiles][]', '');
	  formData.set('ImagesForm[file_url]', imageUrl);
	  return globalFetch(this.origin + '/image/load', {
		responseType: 'text',
		fetch: true,
		headers: { 'Referer': this.origin + '/image/load' },
		timeout: urls.length * rehostTimeout,
		cookie: '_csrf=' + session._csrf,
	  }, formData).then(PicaBox.extractLinks.bind(this)).then(results => {
		if (typeof progressHandler == 'function') progressHandler(true);
		return results[0];
	  });
	}))));
  }

  extractLinks(response) {
	var domParser = new DOMParser;
	return Promise.all(Array.from(domParser.parseFromString(response.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 globalFetch(this.origin + '/image/load').then(response => {
	  var formData = response.document.querySelector('form[name="form_image"]');
	  if (formData == null) return Promise.reject('Invalid document format');
	  formData = new FormData(formData);
	  var session = { }, val, it = formData.entries();
	  while (!(val = it.next()).done) session[val.value[0]] = val.value[1];
	  ['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');
	return this.setSession().then(session => 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();
	  var 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,
		  'Content-Length': formData.length,
		  'Referer': this.origin + '/album/' + session.albumId,
		  'X-CSRF-Token': session['csrf-token'],
		},
		data: formData,
		binary: true,
		//fetch: 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 => {
	  var formData = new URLSearchParams({
		url: imageUrl,
		field: `${index.toString().padStart(3, '0')}_${session.albumId}`,
		albumId: session.albumId,
	  });
	  return globalFetch(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,
	  }, formData).then(response => {
		if (response.response.status != 'ok') return Promise.reject(response.response.message);
		if (response.response.file.error) return Promise.reject(response.response.file.error.title);
		if (typeof progressHandler == 'function') progressHandler(true);
		return {
		  original: 'https:' + response.response.file.image[0],
		  thumb: 'https:' + response.response.file.image[180],
		  share: response.response.file.pageUrl,
		};
	  });
	}))));
  }

  setSession() {
	return globalFetch(this.origin).then(response => {
	  var meta = response.document.querySelector('meta[name="csrf-token"][content]');
	  if (meta == null) return Promise.reject('Invalid document structure');
	  var session = { 'csrf-token': meta.content };
	  return globalFetch(this.origin + '/album/create-by-uploading', {
		headers: { 'X-CSRF-Token': session['csrf-token'] },
		responseType: 'json',
	  }).then(response => {
		session.albumId = response.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) => {
	  var 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;
	var 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');
	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();
	  var 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,
		  'Content-Length': formData.length,
		  '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');
	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();
	  var 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,
		  'Content-Length': formData.length,
		  'Referer': this.origin,
		},
		data: formData,
		binary: true,
		//fetch: 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');
	return this.setSession().then(session => 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();
	  var 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,
		  'Content-Length': formData.length,
		  'Referer': this.origin,
		},
		data: formData,
		binary: true,
		//fetch: 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');
	return this.setSession().then(csrfToken => new Promise((resolve, reject) => {
	  if (this.sizeLimit > 0 && image.size > this.sizeLimit * 2**20) throw 'size limit exceeded: ' + image.name;
	  const now = Date.now(), boundary = '--------WebKitFormBoundary-' + now.toString(16).toUpperCase();
	  var 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,
		  'Content-Length': formData.length,
		  'Referer': this.origin,
		  'X-XSRF-TOKEN': csrfToken,
		},
		data: formData,
		binary: true,
		//fetch: true,
		responseType: 'text',
		timeout: getUploadTimeout(formData.length),
		onload: response => {
		  if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
		  var 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 globalFetch(this.origin + '/login').then(response => {
	  if (response.finalUrl.includes(this.origin)) return response;
	  if (!this.uid || !this.password) return globalFetch(this.origin);
	  var token = response.document.querySelector('input[name="_csrf"][value]');
	  if (token == null) return Promise.reject('invlid page structure');
	  return new Promise((resolve, reject) => {
		var 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',
			'Content-Length': formData.toString().length,
		  },
		  data: formData.toString(),
		  onload: response => {
			if (['my', 'exit'].some(p => response.finalUrl.endsWith('/' + p))) {
			  console.log('Slowpoke successfull login:', response.finalUrl);
			  resolve(globalFetch(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 globalFetch(this.origin);
	  });
	}).then(response => {
	  var 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');
	return this.setSession().then(session => 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();
	  var formData = '--' + boundary + '\r\n', params = {
		wmText: '',
		wmPos: 'TOPRIGHT',
		wmLayout: 2,
		wmFontSize: 14,
		wmTransparency: 50,
		addInfoType: 'res',
		labelText: '',
		_images: image.name,
	  };
	  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,
		  'Content-Length': formData.length,
		  'Referer': this.origin,
		},
		data: formData,
		binary: true,
		//fetch: 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 => {
	  var formData = new URLSearchParams({
		wmText: '',
		wmPos: 'TOPRIGHT',
		wmLayout: 2,
		wmFontSize: 14,
		wmTransparency: 50,
		addInfoType: 'res',
		labelText: '',
		url: imageUrl,
	  });
	  return globalFetch(this.origin + '/upload/?' + session, {
		responseType: 'json',
		headers: { 'Referer': this.origin },
		timeout: urls.length * rehostTimeout,
	  }, formData).then(response => response.response.success ? this.resultHandler(response.response.jid)
		: Promise.reject('failure'));
	}))));
  }

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

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

  setSession() {
	return Promise.resolve('fileapi' + Date.now());
  }
}

function singleImageGetter(results) {
  if (!Array.isArray(results)) throw 'Invalid result format';
  if (results.length <= 0) return null;
  if (typeof results[0] == 'string' && urlParser.test(results[0])) return results[0];
  if (typeof results[0] == 'object' && urlParser.test(results[0].original)) return results[0].original;
  throw 'Invalid result format';
}

var imageHostHandlers = {
  'abload' : new Abload,
  'catbox': new Catbox,
  //'dropbox': new DropBox,
  'fastpic': new FastPic,
  'funkyimg': new FunkyIMG,
  'geekpic': new GeekPic,
  'gifyu': new Chevereto('gifyu.com', 'Gifyu', ['jpeg', 'png', 'gif', 'bmp', 'webp'], 100, { sizeLimitAnonymous: 50 }),
  'googledrive': new GoogleDrive,
  //'googlephotos': new GooglePhotos,
  'imageban': new ImageBan,
  '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,
  'nwcd' : new NWCD,
  //'onedrive': new OneDrive,
  'picabox': new PicaBox,
  'pimpandhost': new PimpAndHost,
  'pixhost': new PixHost,
  'postimage': new PostImage,
  'ptpimg': new PTPimg,
  'radikal': new Radikal,
  //'screencast': new ScreenCast,
  'slowpoke': new Slowpoke,
  'svgshare': new SVGshare,
  '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) {
	alias = alias.replace(nonWordStripper, '').toLowerCase();
	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()));
  }

  buildUploadChain(list) {
	this.ulHostChain = (Array.isArray(list) ? list : typeof list == 'string' ? list.split(/\s*[\,\;\|\/]\s*/) : [])
		.filter(ImageHostManager.prototype.processLists.bind(this)).map(alias => imageHostHandlers[alias.toLowerCase()])
		.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 = (Array.isArray(list) ? list : typeof list == 'string' ? list.split(/\s*[\,\;\|\/]\s*/) : [])
		.filter(ImageHostManager.prototype.processLists.bind(this)).map(alias => imageHostHandlers[alias.toLowerCase()])
		.filter(handler => typeof handler == 'object' && typeof handler.rehost == 'function'
			&& (document.domain != 'redacted.ch' || handler.alias.toLowerCase() == 'ptpimg')
			&& (!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.toContent())).then(images => {
	  return uploadInternal.call(this);

	  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);
		  var 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})`);
	  }
	});
  }

  rehostImages(urls, progressHandler = null) {
	if (!Array.isArray(urls)) return Promise.reject('Invalid argument');
	urls = urls.filter(url => urlParser.test(url));
	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 rehostInternal.call(this);

	  function rehostInternal(hostIndex = 0) {
		if (hostIndex < 0 || hostIndex >= this.rhHostChain.length)
		  return Promise.reject(`Host index out of bounds (${hostIndex})`);
		try {
		  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);
		  var 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');
		});
	  }
	});
  }
}

function urlResolver(url) {
  if (!urlParser.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 'rutracker.org':
	  if (url.pathname != '/forum/out.php') break;
	  return globalFetch(url, { method: 'HEAD' }).then(response => urlResolver(response.finalUrl));
	case 'www.anonymz.com': case 'anonymz.com': case 'anonym.to': case 'dereferer.me':
	  var resolved = decodeURIComponent(url.search.slice(1));
	  return urlParser.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 urlParser.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':
	  return genericResolver();
  }
  if (/\b(?:goo\.gl)$/i.test(url.hostname)) return genericResolver();
  return Promise.resolve(url.href);

  function genericResolver() {
	return globalFetch(url).then(function(response) {
	  var 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);
	});
  }
}

function verifyImageUrl(url) {
  return urlResolver(url).then(function(url) {
	return new Promise(function(resolve, reject) {
	  var img = new Image();
	  img.onload = load => { resolve(url) };
	  img.onerror = function(error) {
		if (img.src.includes('?')) img.src = url.replace(/\?.*?(?=\#|$)/, '');
			else reject('not valid image: ' + url);
	  };
	  img.ontimeout = timeout => { reject('image load timed out:\n\n' + url) };
	  img.src = url;
	});
  });
}
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 checkInterval = 250; // in ms
  const baseUrl = 'https://dragon.img2go.com/api/jobs';
  const referer = 'https://www.img2go.com/convert-image-to-image';
  const params = () => ({
	responseType: 'json',
	headers: { 'Referer': referer },
  });
  return setJob('convert image to image').then(function(job) {
	return job.id  ? (function() {
	  return urlParser.test(image) ? setRemoteInput(image) : (function() {
		if (typeof progressHandler == 'function') progressHandler(-1, null);
		if (image instanceof File) return image.toContent();
		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';
		GM_xmlhttpRequest({
		  method: 'POST',
		  url: job.server + '/upload-file/' + job.id,
		  responseType: 'json',
		  headers: {
			'Accept': 'application/json',
			'Content-Type': 'multipart/form-data; boundary=' + boundary,
			'Content-Length': formData.length,
			'Referer': referer,
			'X-Oc-Token': job.token,
			'X-Oc-Upload-Uuid': uuid(),
		  },
		  data: formData,
		  binary: true,
		  timeout: getUploadTimeout(image.size),
		  onload: response => {
			if (response.status >= 200 && response.status < 400) {
			  if (!response.response.completed) console.warn('img2go upload not completed:', response.response);
			  resolve(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(getInputStatus).then(function(input) {
	  if (!(maxImageHeight >= 0)) maxImageHeight = 1500;
	  if (maxImageHeight > 0 && input.metadata.image_width > maxImageHeight) {
		var imageHeight = input.metadata.image_width;
		while (imageHeight > maxImageHeight) imageHeight = Math.floor(imageHeight / 2);
	  }
	  if (!(jpegQuality > 0)) jpegQuality = 85;
	  return doConversion({ category: 'image', options: {
		allow_multiple_outputs: false,
		height: imageHeight,
		quality: jpegQuality,
	  }, target: 'jpg' });
	}) : Promise.reject('invalid response (job id)');

	function setRemoteInput(imageUrl) {
	  return globalFetch(baseUrl + '/' + job.id + '/input', params(), {
		type: 'remote',
		source: imageUrl,
		engine: 'auto',
	  });
	}
	function getInputStatus() {
	  return new Promise(function(resolve, reject) {
		function waitInput() {
		  globalFetch(baseUrl + '/' + job.id, params()).then(function(response) {
			if (response.response.input[0].status == 'ready') resolve(response.response.input[0]);
				else setTimeout(waitInput, checkInterval);
		  }).catch(reject);
		}
		waitInput();
	  });
	}
	function doConversion(formData) {
	  return globalFetch(baseUrl + '/' + job.id + '/conversions', params(), formData).then(response => new Promise(function(resolve, reject) {
		function waitResult() {
		  globalFetch(baseUrl + '/' + job.id, params()).then(function(response) {
			if (response.response.status.code == 'completed') {
			  resolve(response.response.output[0]);
			  console.debug('img2go conversion result:', response.response.output[0]);
			} else setTimeout(waitResult, checkInterval);
		  }).catch(reject);
		}
		waitResult();
	  }));
	}
	function uuid() {
	  let dt = new Date().getTime();
	  let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
		var r = (dt + Math.random() * 16) % 16 | 0;
		dt = Math.floor(dt / 16);
		return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
	  });
	  return uuid;
	}
  });
  function setJob(operation) {
	return globalFetch(baseUrl, params(), { operation: operation }).then(response => response.response);
  }
}

function optiPNG(urlOrFile) {
  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');
	  return new Promise(function(resolve, reject) {
		var reader = new FileReader();
		reader.onload = function() {
		  if (reader.result.length != urlOrFile.size)
			console.warn(`FileReader: binary string read length mismatch (${reader.result.length} ≠ ${urlOrFile.size})`);
		  resolve({ name: urlOrFile.name, type: urlOrFile.type, size: reader.result.length, data: reader.result });
		};
		reader.onerror = reader.ontimeout = function() { reject(`FileReader error (${urlOrFile.name})`) };
		reader.readAsBinaryString(urlOrFile);
	  }).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';
		formData += 'Content-Disposition: form-data; name="new-image-url"\r\n\r\n';
		formData += '\r\n';
		formData += '--' + boundary + '--\r\n';
		GM_xmlhttpRequest({ method: 'POST', url: url,
		  headers: {
			'Accept': 'text/html',
			'Content-Type': 'multipart/form-data; boundary=' + boundary,
			'Content-Length': formData.length,
			'X-Requested-With': 'XMLHttpRequest',
		  },
		  data: formData,
		  fetch: true,
		  binary: true,
		  onload: response => {
			if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
			let domParser = new DOMParser;
			response.document = domParser.parseFromString(response.responseText, 'text/html');
			resolve(response);
		  },
		  //onprogress: typeof progressHandler == 'function' ? progressHandler : undefined,
		  onerror: response => { reject(defaultErrorHandler(response)) },
		  ontimeout: response => { reject(defaultTimeoutHandler(response)) },
		});
	  }));
	} else if (urlParser.test(urlOrFile)) return (getRemoteFileType(urlOrFile)).then(function(mimeType) {
	  if (!['image/png', 'image/apng'].includes(mimeType)) Promise.reject('not PNG');
	  let payLoad = new URLSearchParams({ 'new-image-url': urlOrFile });
	  return globalFetch(url, { fetch: true }, payLoad);
	}); else return Promise.reject('optiPNG: invalid argument');
  })().then(function(response) {
	let srcImg = response.document.querySelector('img#target');
	if (srcImg != null) srcImg = srcImg.src;
	let form = response.document.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 globalFetch('https://ezgif.com' + action.pathname, { fetch: true }, new FormData(form)).then(function(response) {
	  var optImg = response.document.querySelector('p.outfile > img');
	  if (optImg != null) optImg = optImg.src; else {
		console.warn('ezgif.com unexpected result format, not optimized');
		return srcImg || Promise.reject('unexpected result format, not optimized');
	  }
	  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 ' + (optSize < urlOrFile.size ? 'success' : 'fail') + ':', urlOrFile, optImg, optSize,
			'(' + sign + Math.abs(optSize - urlOrFile.size) + ' = ' + sign + Math.abs((Math.round(saving * 10) / 10)) + '%)');
		return delta < 0 ? optImg : srcImg || urlOrFile;
	  }, function(reason) {
		console.warn('ezgif.com failed to get result image size', optImg)
		return optImg;
	  }) : optImg;
	});
  });
}

function googlePhotosResolver(url) {
 return globalFetch(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;
	 var AF_initDataCallback = eval(RegExp.$2);
	 if (AF_initDataCallback.length == 14 && Array.isArray(AF_initDataCallback[0])) try {
	   if (urlParser.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 globalFetch(url).then(function(response) {
	var 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) {
		  var 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 globalFetch(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 globalFetch('https://api.500px.com/v1/' + path + '&image_size[]=4096', { responseType: 'json' }).then(function(response) {
	var results = Object.keys(response.response.photos).map(id => response.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');
  var collectionId = parseInt(RegExp.$1);
  return new Promise(function(resolve, reject) {
	var domParser = new DOMParser(), 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);
		  var 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');
  var collectionId = parseInt(RegExp.$1);
  return new Promise(function(resolve, reject) {
	var 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');
  var collectionId = RegExp.$1;
  return new Promise(function(resolve, reject) {
	var domParser = new DOMParser, 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));
		  var dom = domParser.parseFromString(response.responseText, 'text/html');
		  var 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) {
  return urlParser.test(url) ? new Promise(function(resolve, reject) {
	var contentType, abort = GM_xmlhttpRequest({ method: 'HEAD', url: url,
	  onreadystatechange: function(response) {
		if (contentType || response.readyState < XMLHttpRequest.HEADERS_RECEIVED) return;
		if (/^(?:Content-Type):\s*(.+)$/im.test(response.responseHeaders) && (contentType = RegExp.$1))
		  resolve(contentType.toLowerCase());
		else reject('empty header');
		abort.abort();
	  },
	  onerror: response => { reject('File not accessible') },
	  ontimeout: response => { reject('File not accessible') },
	});
  }) : Promise.reject('getRemoteFileType: parameter not valid URL');
}

function getRemoteFileSize(url) {
  return urlParser.test(url) ? new Promise(function(resolve, reject) {
	var imageSize, abort = GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer',
	  onreadystatechange: function(response) {
		if (imageSize || response.readyState < XMLHttpRequest.HEADERS_RECEIVED
			|| !/^(?:Content-Length):\s*(\d+)\b/im.test(response.responseHeaders)) return;
		if (!(imageSize = parseInt(RegExp.$1))) return;
		resolve(imageSize);
		abort.abort();
	  },
	  onload: function(response) { // fail-safe
		if (imageSize) return;
		if (response.status >= 200 && response.status < 400) resolve(response.responseText.length /*response.response.byteLength*/);
			else reject(new Error('File not accessible'));
	  },
	  onerror: response => { reject('File not accessible') },
	  ontimeout: response => { reject('File not accessible') },
	});
  }) : Promise.reject('getRemoteFileSize: parameter not valid URL');
}

function formattedSize(size) {
  return size < 1024**1 ? Math.round(size) + ' B'
	: size < 1024**2 ? (Math.round(size * 10 / 2**10) / 10) + ' KiB'
	: size < 1024**3 ? (Math.round(size * 100 / 2**20) / 100) + ' MiB'
	: size < 1024**4 ? (Math.round(size * 100 / 2**30) / 100) + ' GiB'
	: size < 1024**5 ? (Math.round(size * 100 / 2**40) / 100) + ' TiB'
	: (Math.round(size * 100 / 2**50) / 100) + ' PiB';
}

function voidDragHandler0(evt) { return false }
function inputDropHandler(evt) { return !evt.shiftKey ? inputDataHandler(evt, evt.dataTransfer) : true }
function inputPasteHandler(evt) { return inputDataHandler(evt, evt.clipboardData) }
function inputClear(evt) {
  evt.target.value = '';
  coverPreview(evt.target, null);
}

function setInputHandlers(node) {
  node.ondragover = voidDragHandler0;
  node.ondblclick = inputClear;
  node.ondrop = inputDropHandler;
  node.onpaste = inputPasteHandler;
  node.placeholder = 'Paste/drop local or remote image';
}

function setTextAreahandlers(node) {
  node.ondragover = voidDragHandler0;
  node.ondrop = textAreaDropHandler;
  node.onpaste = textAreaPasteHandler;
};

function randomString(length) {
  const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  var text = "";
  for (var 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;
	return image.name && testExt([mimeType]);

	function testExt(extensions) { return extensions.some(ext => image.name.toLowerCase().endsWith('.' + ext)) }
  });
}