Greasy Fork is available in English.

imageHostUploader

×

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

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greasyfork.org/scripts/401726/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);
}