Greasy Fork is available in English.

imageHostUploader

×

As of 12.07.2020. See ბოლო ვერსია.

ეს სკრიპტი არ უნდა იყოს პირდაპირ დაინსტალირებული. ეს ბიბლიოთეკაა, სხვა სკრიპტებისთვის უნდა ჩართეთ მეტა-დირექტივაში // @require https://update.greasyfork.org/scripts/401726/825616/imageHostUploader.js.

// ==UserScript==
// @name         imageHostUploader
// @namespace    https://greasyfork.org/cs/users/321857-anakunda
// @version      1.94
// @author       Anakunda
// @description  ×
// @require      https://greasyfork.org/scripts/404642-js-xhr/code/js-xhr.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 ulTimeFactor = GM_getValue('upload_speed', 16);
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

String.prototype.toASCII = function() {
  return this.normalize("NFKD").replace(/[\x00-\x1F\u0080-\uFFFF]/g, '');
};

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'))) 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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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('PTPimg API key not configured');
	  }
	  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, sizeLimitAnonymous = undefined,
		sizeLimit = undefined, configPrefix = undefined, apiUrl = null, apiFieldName = undefined,
		apiResultKey = undefined) {
	if (typeof hostName != 'string' || !hostName) throw 'Chevereto adapter: missing host name';
	this.hostName = hostName;
	this.alias = alias;
	if (Array.isArray(types)) this.types = types;
	this.sizeLimitAnonymous = sizeLimitAnonymous;
	this.sizeLimit = sizeLimit || sizeLimitAnonymous;
	if (alias) var al = alias.replace(nonWordStripper, '');
	if (!configPrefix && al) configPrefix = al.toLowerCase();
	if (!configPrefix && /^(?:www\.)?([\w\-]+)(?:\.[\w\-]+)+$/.test(hostName)) configPrefix = RegExp.$1.toLowerCase();
	if (this.configPrefix = 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 && apiUrl) GM_setValue(this.configPrefix + '_api_key', '');
	} else console.warn('Chevereto adapter: config prefix could not be evaluated, authorized operations not available');
	this.apiUrl = apiUrl;
	this.apiFieldName = apiFieldName;
	this.apiResultKey = 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
		  || !session.username && !!session.key && this.sizeLimitAnonymous >= 0
		  	&& image.size > this.sizeLimitAnonymous * 2**20)
		return Promise.reject(`image size exceeds upload 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.apiUrl || 'https://' + this.hostName + '/api/1') + '/upload'
			: 'https://' + this.hostName + '/json',
		responseType: 'json',
		headers: {
		  'Accept': 'application/json',
		  'Content-Type': 'multipart/form-data; boundary=' + boundary,
		  'Content-Length': formData.length,
		  'Referer': 'https://' + this.hostName,
		},
		data: formData,
		binary: true,
		//timeout: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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.apiUrl || 'https://' + this.hostName + '/api/1') + '/upload'
			: 'https://' + this.hostName + '/json', {
		responseType: 'json',
		headers: { 'Referer': 'https://' + this.hostName },
		timeout: urls.length * rehostTimeout,
	  }, formData).then(response => {
		if (!response.response.success)
		  return Promise.reject(`${this.hostName}: ${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('https://' + this.hostName + '/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.hostName}: ${response.response.error.message} (${response.response.status_code})`);
	  });
	}).catch(reason => {
	  console.warn(this.hostName, '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);
	}
	const index = 'https://' + this.hostName + '/';
	return globalFetch(index).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.hostName, '\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: index + 'login',
		  headers: {
			'Accept': '*/*',
			'Content-Type': 'application/x-www-form-urlencoded',
			'Content-Length': formData.toString().length,
			'Referer': index + '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(index, { responseType: 'text' }).then(response => {
		if (!getUser(response)) return Promise.reject('unknown reason');
		console.debug(this.hostName, '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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		onload: response => {
		  if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
		  var 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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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,
		//timeout: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		//fetch: true,
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		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,
		//timeout: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		//fetch: true,
		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'], 50, 100),
  '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, 32, undefined, 'https://api.imgbb.com/1', 'image', '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.push(files.shift());
	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 => new Promise(function(resolve, reject) {
	  var reader = new FileReader();
	  reader.onload = function() {
		if (reader.result.length != file.size)
		  console.warn(`FileReader: binary string read length mismatch (${reader.result.length} ≠ ${file.size})`);
		resolve({ name: file.name, type: file.type, size: reader.result.length, data: reader.result });
	  };
	  reader.onerror = reader.ontimeout = function() { reject(`FileReader error (${file.name})`) };
	  reader.readAsBinaryString(file);
	}))).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})`);
		var h1 = this.rhHostChain[hostIndex].hostName;
		if (!h1) try { h1 = new URL(this.rhHostChain[hostIndex].origin).hostname } catch(e) { h1 = null }
		if (h1 && Array.isArray(h2) && h2.every(h2 => h2.includes(h1) || h1.includes(h2))) return Promise.resolve(urls);
// 		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 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 getRemoteFileSize(url) {
  return 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('Image not accessible'));
	  },
	  onerror: response => { reject('Image not accessible') },
	  ontimeout: response => { reject('Image not accessible') },
	});
  });
}

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