imageHostUploader

×

Od 07.06.2020.. Pogledajte najnovija verzija.

Ovu skriptu ne treba izravno instalirati. To je biblioteka za druge skripte koje se uključuju u meta direktivu // @require https://update.greasyfork.org/scripts/401726/813599/imageHostUploader.js

// ==UserScript==
// @name         imageHostUploader
// @namespace    https://greasyfork.org/cs/users/321857-anakunda
// @version      1.62
// @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==

'use strict';

if (document.getElementById('upload assistant') != null) return; // don't clash with Upload Assistant

const ulTimeFactor = GM_getValue('upload_speed', 16);
const rehostTimeout = 30000;
const urlParser = /^\s*(https?:\/\/\S+)\s*$/i;
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tif', 'tiff', 'heic'];

class PTPimg {
  constructor() {
	this.alias = 'PTPimg';
	this.origin = 'https://ptpimg.me';
	this.types = ['png', 'jpg', 'jpeg', 'gif', 'bmp'];
	this.whitelist = [
	  'passthepopcorn.me', 'redacted.ch', 'orpheus.network',
	  'notwhat.cd', 'dicmusic.club', 'broadcasthe.net',
	];
  }

  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('invalid argument');
	if (Array.isArray(this.types))
	  images = images.filter(image => this.types.some(type => image.type == 'image/'.concat(type.toLowerCase())));
	if (images.length <= 0) return Promise.reject('nothing to upload');
	return this.setSession().then(apiKey => new Promise((resolve, reject) => {
	  const boundary = '----WebKitFormBoundary'.concat(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.concat('/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.concat('/', 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 parameter');
	if (urls.length <= 0) return Promise.reject('Nothing to rehost');
	return Promise.all(urls.map(url => {
	  if (!urlParser.test(url)) return Promise.reject('URL not valid ('.concat(url, ')'));
	  var hostname = new URL(url).hostname;
	  if (hostname == 'img.discogs.com' || hostname.endsWith('omdb.org')) {
		return verifyImageUrl('https://reho.st/'.concat(url))
		  .catch(reason => imageHostHandlers['catbox'].rehost([url]).then(imgUrls => imgUrls[0]))
		  .catch(reason => imageHostHandlers['pixhost'].rehost([url]).then(imgUrls => imgUrls[0]))
		  .catch(reason => this.reupload(url));
	  } else if (!this.types.some(ext => url.toLowerCase().endsWith('.'.concat(ext)))) {
		return verifyImageUrl(url.concat('#.jpg'))
		  //.catch(reason => imageHostHandlers['malzo'].rehost([url]).then(imgUrls => imgUrls[0]))
		  .catch(reason => 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]))
		  .catch(reason => imageHostHandlers['picload'].rehost([url]).then(imgUrls => imgUrls[0]));
		  //.catch(reason => imageHostHandlers['imgur'].rehost([url]).then(imgUrls => imgUrls[0]));
	  }
	  return verifyImageUrl(url);
	})).then(imageUrls => this.setSession().then(apiKey => {
	  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.concat('/', 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() {
	if (this.apiKey || (this.apiKey = GM_getValue('ptpimg_api_key'))) return Promise.resolve(this.apiKey);
	try {
	  this.apiKey = JSON.parse(window.localStorage.ptpimg_it).api_key;
	  if (this.apiKey) {
		GM_setValue('ptpimg_api_key', this.apiKey);
		return Promise.resolve(this.apiKey);
	  }
	} catch(e) { console.debug('PTPimg.setSession():', e) }
	return 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, consider to remove PtpImg from upload_hosts and rehost_hosts entries in local storage.

Uploading and rehosting is still available to fallback image hosts.
`);
		  GM_setValue('ptpimg_reminder_read', ++counter);
		}
		return Promise.reject('PTPIMG API key not configured');
	  }
	  if (!apiKey.value) return Promise.reject('Assertion failed: missing PTPIMG API key');
	  Promise.resolve(this.apiKey)
		.then(apiKey => { alert(`Your PTPIMG API key [${apiKey}] was successfully configured`) });
	  GM_setValue('ptpimg_api_key', this.apiKey = apiKey.value);
	  return this.apiKey;
	});
  }
}

class Chevereto {
  constructor(hostName, alias = undefined, types = undefined, sizeLimitAnonymous = undefined, sizeLimit = undefined, configPrefix = undefined) {
	if (!hostName) throw 'Chevereto adapter: missing host name';
	this.hostName = hostName;
	if (alias) this.alias = alias;
	if (Array.isArray(types)) this.types = types;
	this.sizeLimitAnonymous = sizeLimitAnonymous;
	this.sizeLimit = sizeLimit;
	if (!configPrefix && /^([\w\-]+)(?:\.[\w\-]+)+$/.test(hostName)) configPrefix = RegExp.$1.toLowerCase();
	if (!configPrefix) console.warn('Chevereto adapter: config prefix could not be determined');
	this.configPrefix = configPrefix;
  }

  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('invalid argument');
	if (Array.isArray(this.types))
	  images = images.filter(image => this.types.some(type => image.type == 'image/'.concat(type.toLowerCase())));
	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
		  || !session.username && this.sizeLimitAnonymous > 0 && image.size > this.sizeLimitAnonymous * 2**20)
		return Promise.reject(`image size exceeds upload limit (${image.size})`);
	  const boundary = '----WebKitFormBoundary'.concat(Date.now().toString(16).toUpperCase());
	  var formData = '--' + boundary + '\r\n', params = Object.assign({
		action: 'upload',
		type: 'file',
		nsfw: 0,
	  }, 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="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: 'https://'.concat(this.hostName, '/json'),
		responseType: 'json',
		headers: {
		  'Accept': 'application/json',
		  'Content-Type': 'multipart/form-data; boundary=' + boundary,
		  'Content-Length': formData.length,
		  'Referer': 'https://'.concat(this.hostName, '/'),
		},
		data: formData,
		binary: true,
		//timeout: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
		onload: response => {
		  if (response.status >= 200 && response.status < 400) {
			if (response.response.success) resolve(response.response.image.url);
			  else reject((response.response.error ? response.response.error.message : response.response.status_txt)
					.concat(' (', response.response.status_code, ')'));
		  } 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) {
	return this.setSession().then(session => Promise.all(urls.map(url => verifyImageUrl(url).then(imageUrl => {
	  var formData = new URLSearchParams(Object.assign({
		action: 'upload',
		type: 'url',
		nsfw: 0,
		source: imageUrl,
	  }, session));
	  return globalFetch('https://'.concat(this.hostName, '/json'), {
		responseType: 'json',
		headers: { 'Referer': 'https://'.concat(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 response.response.image.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().then(session => {
	  var formData = new URLSearchParams(Object.assign({
		action: 'get-album-contents',
		albumid: albumId,
	  }, session));
	  return globalFetch('https://'.concat(this.origin, '/json'), {
		responseType: 'json',
		headers: { 'Referer': url },
	  }, formData).then(response => {
		return response.response.status_txt == 'OK' && Array.isArray(response.response.contents) ?
		  response.response.contents.map(image => image.url)
			  : Promise.reject(this.hostName.concat(': ', 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() {
	const index = 'https://'.concat(this.hostName, '/');
	return globalFetch(index).then(response => {
	  if (!/\b(?:auth_token)\s*=\s*"(\w+)"/.test(response.responseText))
		return Promise.reject('Auth token detection failure');
	  var session = {
		auth_token: RegExp.$1,
		timestamp: Date.now(),
	  };
	  if (getUser(response) || !this.configPrefix) return session;
	  if (!this.login) this.login = GM_getValue(this.configPrefix.concat('_uid'));
	  if (!this.password) this.password = GM_getValue(this.configPrefix.concat('_password'));
	  if (!this.login || !this.password) return session;
	  var formData = new URLSearchParams({
		'login-subject': this.login,
		'password': this.password,
		'auth_token': session.auth_token,
	  });
	  return new Promise((resolve, reject) => {
		GM_xmlhttpRequest({ method: 'POST', url: index.concat('login'),
		  headers: {
			'Accept': '*/*',
			'Content-Type': 'application/x-www-form-urlencoded',
			'Content-Length': formData.toString().length,
			'Referer': index.concat('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)) console.debug(this.hostName, 'authorized session:', session);
		  else console.warn(this.hostName, 'authorization failed:', status, '(continuing anonymous)');
	  })).catch(reason => { console.warn('Chevereto login failed:', reason) }).then(() => session);

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

class PixHost {
  constructor() {
	this.alias = 'PixHost';
	this.origin = 'https://pixhost.to';
  }

  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('invalid argument');
	if (Array.isArray(this.types))
	  images = images.filter(image => this.types.some(type => image.type == 'image/'.concat(type.toLowerCase())));
	if (images.length <= 0) return Promise.reject('nothing to upload');
	return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
	  if (!['png', 'jpg', 'jpeg', 'gif'].some(type => image.type == 'image/'.concat(type.toLowerCase())))
		throw 'MIME type not supported: '.concat(image.type);
	  const boundary = '----WebKitFormBoundary'.concat(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(response.response.show_url);
			  else reject(defaultErrorHandler(response));
		},
		onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
		onerror: response => { reject(defaultErrorHandler(response)) },
		ontimeout: response => { reject(defaultTimeoutHandler(response)) },
	  });
	}).then(imageUrlResolver)));
  }

  rehost(urls) {
	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(image => imageUrlResolver(image.show_url)));
	  });
	});
  }
}

class Catbox {
  constructor() {
	this.alias = 'Catbox';
	this.origin = 'https://catbox.moe';
  }

  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('Invalid argument');
	if (Array.isArray(this.types))
	  images = images.filter(image => this.types.some(type => image.type == 'image/'.concat(type.toLowerCase())));
	if (images.length <= 0) return Promise.reject('Nothing to upload or format not supported');
	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'.concat(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) {
	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() {
	if (!this.userHash) this.userHash = GM_getValue('catbox_userhash');
	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('Catbox.moe: not logged in or userhash not found');
	  GM_setValue('catbox_userhash', this.userHash = userHash.value);
	  return this.userHash;
	});
  }
}

class ImageVenue {
  constructor() {
	this.alias = 'ImageVenue';
	this.origin = 'http://www.imagevenue.com';
  }

  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('invalid argument');
	if (Array.isArray(this.types))
	  images = images.filter(image => this.types.some(type => image.type == 'image/'.concat(type.toLowerCase())));
	if (images.length <= 0) return Promise.reject('nothing to upload');
	return this.setSession().then(session => new Promise((resolve, reject) => {
	  var now = Date.now();
	  const boundary = '----WebKitFormBoundary'.concat(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 links = response.document.querySelectorAll('div.row > div > a');
	  return Promise.all(Array.from(links).map(a => imageUrlResolver(a.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;
	  var uid = GM_getValue('imagevenue_uid'), password = GM_getValue('imagevenue_password');
	  if (!uid || !password) return csrfToken.content;
	  var formData = new URLSearchParams({
		'_token': csrfToken.content,
		'email': uid,
		'password': 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 ImgBox {
  constructor() {
	this.alias = 'ImgBox';
	this.origin = 'https://imgbox.com';
	this.types = ['jpg', 'jpeg', 'gif', 'png'];
  }
  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('invalid argument');
	if (Array.isArray(this.types))
	  images = images.filter(image => this.types.some(type => image.type == 'image/'.concat(type.toLowerCase())));
	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 (image.size > 10 * 2**20) throw 'Size limit exceeded: '.concat(image.name);
	  var now = Date.now();
	  const boundary = '----WebKitFormBoundary'.concat(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) return reject(defaultErrorHandler(response));
		  resolve(response.response.files[0].original_url);
		},
		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;
	  var uid = GM_getValue('imgbox_uid'), password = GM_getValue('imgbox_password');
	  if (!uid || !password) return csrfToken.content;
	  var formData = new URLSearchParams({
		"utf8": "✓",
		"authenticity_token": csrfToken.content,
		"user[login]": uid,
		"user[password]": 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: '100c',
		gallery_id: null,
		gallery_secret: null,
		comments_enabled: 0,
	  },
	})));
  }
}

class Imgur {
  constructor() {
	this.alias = 'Imgur';
	this.origin = 'https://imgur.com';
  }

  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('invalid argument');
	if (images.length <= 0) return Promise.reject('nothing to upload');
	return Promise.all(images.map((image, index) => new Promise((resolve, reject) => {
	  var now = Date.now();
	  const boundary = '----WebKitFormBoundary'.concat(now.toString(16).toUpperCase());
	  var formData = '--' + boundary + '\r\n';
	  formData += 'Content-Disposition: form-data; name="Filedata"; 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',
		responseType: 'json',
		headers: {
		  'Content-Type': 'multipart/form-data; boundary=' + boundary,
		  'Content-Length': formData.length,
		  'Referer': this.origin + '/upload',
		},
		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/'.concat(response.response.data.hash, response.response.data.ext));
		},
		onprogress: typeof progressHandler == 'function' ? progress => progressHandler(progress, index) : undefined,
		onerror: response => { reject(defaultErrorHandler(response)) },
		ontimeout: response => { reject(defaultTimeoutHandler(response)) },
	  });
	})));
  }

  rehost(urls, progressHandler = null) {
	return 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/'.concat(result.response.data.hash, result.response.data.ext);
	  });
	})));
  }
}

class PostImage {
  constructor() {
	this.alias = 'PostImage';
	this.origin = 'https://postimages.org';
  }

  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) => {
	  var now = Date.now();
	  const boundary = '----WebKitFormBoundary'.concat(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(imageUrlResolver))));
  }

  rehost(urls, progressHandler = null) {
	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 imageUrlResolver(response.response.url);
	  });
	}))));
  }

  galleryResolver(url) {
	return globalFetch(url, { responseType: 'text' }).then(function(response) {
	  if (/\bvar\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 notFound;
	});
  }

  setSession() {
	return globalFetch(this.origin + '/').then(response => {
	  var session = {
		session_upload: Date.now(),
		upload_session: rand_string(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;
	  var uid = GM_getValue('postimg_uid'), password = GM_getValue('postimg_password');
	  if (!uid || !password) return session;
	  var formData = new URLSearchParams({
		'email': uid,
		'password': 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)) },
	  }));
	});

	function rand_string(e) {
	  for (var t = "", i = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", n = 0; n < e; n++)
		t += i.charAt(Math.floor(Math.random()*i.length));
	  return t;
	}
  }
}

class FastPic {
  constructor() {
	this.alias = 'FastPic';
	this.origin = 'https://fastpic.ru';
  }

  upload(images, progressHandler = null) {
	if (!Array.isArray(images)) return Promise.reject('invalid argument');
	if (images.length <= 0) return Promise.reject('nothing to upload');
	return new Promise((resolve, reject) => {
	  const boundary = '----WebKitFormBoundary'.concat(Date.now().toString(16).toUpperCase());
	  var formData = '--' + boundary + '\r\n';
	  images.forEach(image => {
		formData += 'Content-Disposition: form-data; name="file[]"; filename="' + image.name.toASCII() + '"\r\n';
		formData += 'Content-Type: ' + image.type + '\r\n\r\n';
		formData += image.data + '\r\n';
		formData += '--' + boundary + '\r\n';
	  });
	  formData += 'Content-Disposition: form-data; name="uploading"\r\n\r\n';
	  formData += '1\r\n';
	  formData += '--' + boundary + '--\r\n';
	  GM_xmlhttpRequest({
		method: 'POST',
		url: this.origin + '/uploadmulti',
		headers: {
		  'Content-Type': 'multipart/form-data; boundary=' + boundary,
		  '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 directLinks = response.document.querySelectorAll('ul.codes-list > li:first-of-type > input');
	  if (directLinks.length >= images.length) return Array.from(directLinks).map(directLink => directLink.value);
	  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');
	this.loadJS();
	return Promise.all(files.map(uploadToImagehost)).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');
	this.loadJS();
	return Promise.all(urls.map(url => verifyImageUrl(url).then(uploadToImagehost).then(result => {
	  if (typeof progressHandler == 'function') progressHandler(true);
	  return result.url;
	})));
  }

  static loadJS() {
	if (document.domain == 'notwhat.cd' && typeof uploadToImagehost != 'function') {
	  let imageUpload = document.createElement('script');
	  imageUpload.src = '/static/functions/image_upload.js';
	  imageUpload.type = 'text/javascript';
	  document.head.append(imageUpload);
	}
  }
}

var imageHostHandlers = {
  'ptpimg': new PTPimg,
  'malzo': new Chevereto('malzo.com', 'Malzo', ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'], 2, 2),
  'imgbb': new Chevereto('imgbb.com', 'ImgBB', ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tif', 'tiff', 'heic'], 32, 32),
  'imgbox': new ImgBox,
  'pixhost': new PixHost,
  'catbox': new Catbox,
  'jerking': new Chevereto('jerking.empornium.ph', 'Jerking', ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'], 5, 5),
  'picload': new Chevereto('free-picload.com', 'PicLoad', ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'], 50 , 96, 'picload'),
  'fastpic': new FastPic,
  '24a': new Chevereto('z4a.net', '24A', ['jpg', 'jpeg', 'png', 'bmp', 'gif'], 50),
  'postimage': new PostImage,
  'imgur': new Imgur,
  'imagevenue': new ImageVenue,
  'nwcd' : new NWCD,
};
const siteWhitelists = {
  'notwhat.cd': ['nwcd'],
};
const siteBlacklists = {
  'passthepopcorn.me': ['imgbox', 'picload', 'postimage', 'imgur', 'tinypic', 'imageshack', 'imagebam'],
};
let cheveretoCustomHosts = GM_getValue('chevereto_custom_hosts');
if (cheveretoCustomHosts) try {
  cheveretoCustomHosts = JSON.parse();
  Object.keys(cheveretoCustomHosts).forEach(function(key) {
	imageHostHandlers[key] = new Chevereto(cheveretoCustomHosts[key].host_name, cheveretoCustomHosts[key].alias,
	  cheveretoCustomHosts[key].types, cheveretoCustomHosts[key].limit_anon,
	  cheveretoCustomHosts[key].limit_authorized, cheveretoCustomHosts[key].config_prefix);
  });
} catch (e) { console.warn(e) }

[
  'ptpimg_api_key',
  'malzo_uid', 'malzo_password',
  'imgbb_uid', 'imgbb_password', 'imgbb_api_key',
  'catbox_userhash',
  'imgbox_uid', 'imgbox_password',
  'jerking_uid', 'jerking_password',
  //'picload_uid', 'picload_password',
  //'24a_uid', '24a_password',
  'postimg_uid', 'postimg_password',
  'imagevenue_uid', 'imagevenue_password',
  'chevereto_custom_hosts',
].forEach(propName => { if (!GM_getValue(propName)) GM_setValue(propName, '') });
['upload_hosts', 'rehost_hosts'].forEach(propName => { if (!GM_getValue(propName)) GM_setValue(propName, [
  'PtpImg', 'NWCD', 'PixHost', 'ImgBB', 'ImgBox', 'Catbox', 'Jerking',
  'PicLoad', 'FastPic', '24A', 'PostImage', /*'Malzo', */'Imgur', 'ImageVenue',
].join(', ')) });

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

var ulHostChain = (GM_getValue(document.domain) || GM_getValue('upload_hosts')).split(/\s*[\,\;\|\/]\s*/)
	.filter(processLists)
	.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 + ':', ulHostChain.map(handler => handler.alias).join(', '));
var rhHostChain = (GM_getValue(document.domain) || GM_getValue('rehost_hosts')).split(/\s*[\,\;\|\/]\s*/)
	.filter(processLists)
	.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 + ':', rhHostChain.map(handler => handler.alias).join(', '));

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

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

function uploadImages(files, progressHandler) {
  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());
  if (ulHostChain.length <= 0) return Promise.reject('No hosts where to upload');
  files = files.filter(file => file instanceof File && file.size && 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: file.size, data: reader.result });
	};
	reader.onerror = reader.ontimeout = function() { reject('FileReader error (' + file.name + ')') };
	reader.readAsBinaryString(file);
  }))).then(function(images) {
	return uploadInternal();

	function uploadInternal(hostIndex = 0) {
	  return hostIndex >= 0 && hostIndex < ulHostChain.length ? (function() {
		if (typeof progressHandler == 'function') {
		  progressHandler(hostIndex, null);
		  var _progressHandler = (param = null, index = undefined) => progressHandler(hostIndex, param, index);
		}
		if (Array.isArray(ulHostChain[hostIndex].types)
			&& !files.every(file => ulHostChain[hostIndex].types.some(type => file.type == 'image/'.concat(type.toLowerCase()))))
		  return Promise.reject('MIME type not accepted for all files');
		if (ulHostChain[hostIndex].sizeLimit && files.some(file => file.size > ulHostChain[hostIndex].sizeLimit * 2**20))
		  return Promise.reject('one or more images exceed size limit (' + ulHostChain[hostIndex].sizeLimit + 'MB)');
		return ulHostChain[hostIndex].upload(ulHostChain[hostIndex].upload.acceptFiles ? files : images, _progressHandler);
	  })().catch(function(reason) {
		console.warn('Upload to', ulHostChain[hostIndex].alias, 'failed:', reason);
		var msg = 'Upload to '.concat(ulHostChain[hostIndex].alias, ' failed (', reason, ')');
		if (++hostIndex < ulHostChain.length) {
		  logFail(msg.concat(', falling back to ', ulHostChain[hostIndex].alias));
		  return uploadInternal(hostIndex);
		}
		logFail(msg);
		return Promise.reject('Upload failed to all hosts');
	  }) : Promise.reject('host index out of bounds ('.concat(hostIndex, ')'));
	}
  });
}

function rehostImages(urls, progressHandler) {
  if (!Array.isArray(urls)) return Promise.reject('Invalid parameter');
  if (urls.length <= 0) return Promise.reject('Nothing to rehost');
  return rhHostChain.length > 0 ? rehostInternal() : Promise.resolve(urls);

  function rehostInternal(hostIndex = 0) {
	if (hostIndex < 0 || hostIndex >= rhHostChain.length) return Promise.reject('Host index out of bounds ('.concat(hostIndex, ')'));
	if (typeof progressHandler == 'function') {
	  var _progressHandler = (param = true) => progressHandler(hostIndex, param);
	  _progressHandler(false);
	}
	return rhHostChain[hostIndex].rehost(urls, _progressHandler).catch(function(reason) {
	  console.warn('Rehost to', rhHostChain[hostIndex].alias, 'failed:', reason);
	  var msg = 'Rehost to '.concat(rhHostChain[hostIndex].alias, ' failed (', reason, ')');
	  if (++hostIndex < rhHostChain.length) {
		logFail(msg.concat(', falling back to ', rhHostChain[hostIndex].alias));
		return rehostInternal(hostIndex);
	  }
	  logFail(msg);
	  return Promise.reject('Rehost failed to all hosts');
	});
  }
}

function urlResolver(url) {
  if (!urlParser.test(url)) return Promise.reject('Invalid URL:\n\n'.concat(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.concat(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':
	  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) {
	//if (!strict && imageExtensions.some(ext => url.toLowerCase().endsWith('.'.concat(ext)))) return Promise.resolve(url); // weak
	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: '.concat(url));
	  };
	  img.ontimeout = timeout => { reject('Image load timed out:\n\n'.concat(url)) };
	  img.src = url;
	});
  });
}
function verifyImageUrls(urls) {
  return Array.isArray(urls) ? Promise.all(urls.map(verifyImageUrl)) : Promise.reject('URLs 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].concat('=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].concat('=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.concat('/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: '.concat(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/'.concat(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'.concat(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'.concat(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 imageDropHandler(evt) { return !evt.shiftKey ? imageDataHandler(evt, evt.dataTransfer) : true }
function imagePasteHandler(evt) { return imageDataHandler(evt, evt.clipboardData) }
function imageClear(evt) {
  evt.target.value = '';
  coverPreview(evt.target, null);
}

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

function setTextAreahandlers(node) {
  node.ondragover = voidDragHandler0;
  node.ondrop = descDropHandler;
  node.onpaste = descPasteHandler;
};

function logFail(message) {
  var log = document.getElementById('ihh-console');
  if (log == null) {
	log = document.createElement('div');
	log.id = 'ihh-console';
	log.style = 'position: fixed; bottom: 20px; right: 20px; width: 64em; border: solid lightsalmon 4px;' +
	  ' background-color: antiquewhite; padding: 10px; opacity: 1;' +
	  ' transition: opacity 1000ms linear; -webkit-transition: opacity 1000ms linear;';
	document.body.append(log);
  } else if (log.hTimer) {
	clearTimeout(log.hTimer);
	log.style.opacity = 1;
  }
  var div = document.createElement('div');
  div.style = 'font: 600 9pt Verdana, sans-serif; color: red;';
  div.textContent = message;
  log.append(div);
  log.hTimer = setTimeout(function(node) {
	node.style.opacity = 0;
	node.hTimer = setTimeout(function(node) { node.remove() }, 1000, node);
  }, 30000, log);
}