imageHostUploader

×

Tính đến 18-05-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/806453/imageHostUploader.js

// ==UserScript==
// @name         imageHostUploader
// @namespace    https://greasyfork.org/cs/users/321857-anakunda
// @version      1.30
// @author       Anakunda
// @description  ×
// @require      https://greasyfork.org/scripts/401725-globalfetch/code/globalFetch.js
// @require      https://greasyfork.org/scripts/394414-ua-resource/code/UA-resource.js
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

'use strict';

const ulTimeFactor = GM_getValue('upload_speed', 128);
const rehostTimeout = 30000;
const urlParser = /^\s*(https?:\/\/\S+)\s*$/i;
const ptpimgOrigin = 'https://ptpimg.me';
const ptpSiteWhitelist = ['passthepopcorn.me', 'redacted.ch', 'orpheus.network', 'notwhat.cd', 'dicmusic.club', 'broadcasthe.net'];
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tif', 'tiff', 'heic'];
const imageHostHandlers = {
  'ptpimg': ['PtpImg', upload2PTPIMG, rehost2PTPIMG],
  'malzo': ['Malzo', (images, elem) => upload2Chevereto('malzo.com', images, elem), urls => rehost2Chevereto('malzo.com', urls)],
  'imgbb': ['ImgBB', (images, elem) => upload2Chevereto('imgbb.com', images, elem), urls => rehost2Chevereto('imgbb.com', urls)],
  'imgbox': ['ImgBox', upload2ImgBox, null],
  'pixhost': ['PixHost', upload2PixHost, rehost2PixHost],
  'catbox': ['Catbox', upload2Catbox, rehost2Catbox],
  'jerking': ['Jerking', (images, elem) => upload2Chevereto('jerking.empornium.ph', images, elem),
		urls => rehost2Chevereto('jerking.empornium.ph', urls)],
  'picload': ['PicLoad', (images, elem) => upload2Chevereto('free-picload.com', images, elem),
		urls => rehost2Chevereto('free-picload.com', urls)],
  'fastpic': ['FastPic', upload2FastPic, null],
  '24a': ['24A', (images, elem) => upload2Chevereto('z4a.net', images, elem), urls => rehost2Chevereto('z4a.net', urls)],
  'postimage': ['PostImage', upload2PostImg, rehost2PostImg],
  'imgur': ['Imgur', upload2Imgur, rehost2Imgur],
  'imagevenue': ['ImageVenue', upload2ImageVenue, null],
};

[
  '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',
].forEach(propName => { if (!GM_getValue(propName)) GM_setValue(propName, '') });
['upload_hosts', 'rehost_hosts'].forEach(propName => { if (!GM_getValue(propName)) GM_setValue(propName, [
  'PtpImg', 'Malzo', 'PixHost', 'ImgBB', 'ImgBox', 'Catbox', 'Jerking',
  'PicLoad', 'FastPic', '24A', 'PostImage', 'Imgur', 'ImageVenue',
].join(', ')) });

var ulHostChain = (GM_getValue(document.domain) || GM_getValue('upload_hosts')).split(/\s*[\,\;\|\/]\s*/)
	.map(alias => imageHostHandlers[alias.toLowerCase()])
	.filter(handler => Array.isArray(handler) && typeof handler[1] == 'function'
		&& (handler[0].toLowerCase() != 'ptpimg' || ptpSiteWhitelist.includes(document.domain)));
var rhHostChain = (GM_getValue(document.domain) || GM_getValue('rehost_hosts'))
	.split(/\s*[\,\;\|\/]\s*/).map(alias => imageHostHandlers[alias.toLowerCase()])
	.filter(handler => Array.isArray(handler) && typeof handler[2] == 'function'
		&& (handler[0].toLowerCase() == 'ptpimg' ? ptpSiteWhitelist.includes(document.domain) : document.domain != 'redacted.ch'));

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, elem) {
  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');
  var frs = files.filter(function(file) {
	return file instanceof File && imageExtensions.some(ext => file.type == 'image/' + ext);
  })/*.sort((file1, file2) => file1.name.localeCompare(file2.name))*/.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);
  }));
  if (frs.length <= 0) return Promise.reject('Nothing to upload');
  return Promise.all(frs).then(function(images) {
	return uploadInternal();

	function uploadInternal(hostIndex = 0) {
	  return hostIndex >= 0 && hostIndex < ulHostChain.length ? ulHostChain[hostIndex][1](images, elem).catch(function(reason) {
		if (++hostIndex >= ulHostChain.length) return Promise.reject('Upload failed to all hosts');
		console.warn('Upload to', ulHostChain[hostIndex - 1][0], 'failed (' + reason + '), falling back to', ulHostChain[hostIndex][0]);
		return uploadInternal(hostIndex);
	  }) : Promise.reject('Host index out of bounds ('.concat(hostIndex, ')'));
	}
  });
}

function rehostImages(urls) {
  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) {
	return hostIndex >= 0 && hostIndex < rhHostChain.length ? rhHostChain[hostIndex][2](urls).catch(function(reason) {
	  if (++hostIndex >= rhHostChain.length) return Promise.reject('Rehost failed to all hosts');
	  console.warn('Rehost to', rhHostChain[hostIndex - 1][0], 'failed (' + reason + '), falling back to', rhHostChain[hostIndex][0]);
	  return rehostInternal(hostIndex);
	}) : Promise.reject('Host index out of bounds ('.concat(hostIndex, ')'));
  }
}

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 'goo.gl': case 'apple.co': case 'flic.kr':
	case 'rebrand.ly': case 'b.link': case 't2m.io': case 'zpr.io': case 'yourls.org':
	  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:\n\n'.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 upload2PTPIMG(images, elem) {
  if (!Array.isArray(images)) return Promise.reject('invalid argument');
  if (images.length <= 0) return Promise.reject('nothing to upload');
  return getPTPIMGapiKey().then(apiKey => new Promise(function(resolve, reject) {
	var now = Date.now();
	const boundary = '----WebKitFormBoundary'.concat(now.toString(16).toUpperCase());
	var formData = '--' + boundary + '\r\n';
	images.filter(function(image) {
	  return image.data && image.name && ['png', 'jpg', 'jpeg', 'gif', 'bmp'].some(ext => image.type == 'image/'.concat(ext));
	}).forEach(function(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: ptpimgOrigin + '/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: function(response) {
		if (response.status >= 200 && response.status < 400) {
		  if (response.response) resolve(response.response.map(item => ptpimgOrigin + '/' + item.code + '.' + item.ext));
			else reject('void response');
		} else reject(defaultErrorHandler(response));
	  },
	  onprogress: elem instanceof HTMLElement && 'value' in elem ? function(progress) {
		var pct = progress.position * 100 / progress.total;
		//elem.value = 'Uploading... (' + Math.round(pct) + '%)';
	  } : undefined,
	  onerror: error => reject(defaultErrorHandler(error)),
	  ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
	});
  }));
}

function rehost2PTPIMG(urls) {
  if (!Array.isArray(urls)) return Promise.reject('Invalid parameter');
  if (urls.length <= 0) return Promise.resolve([]); //Promise.reject('Nothing to rehost');
  return Promise.all(urls.map(function(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 => rehost2Catbox([url]).then(imgUrls => imgUrls[0]))
		.catch(reason => rehost2PixHost([url]).then(imgUrls => imgUrls[0]))
		.catch(reason => reupload2PTPIMG(url));
	} else if (!['png', 'jpg', 'jpeg', 'gif', 'bmp'].some(ext => url.toLowerCase().endsWith('.'.concat(ext)))) {
	  return verifyImageUrl(url.concat('#.jpg'))
		.catch(reason => rehost2Chevereto('malzo.com', [url]).then(imgUrls => imgUrls[0]))
		.catch(reason => rehost2PixHost([url]).then(imgUrls => imgUrls[0]))
		.catch(reason => rehost2Chevereto('imgbb.com', [url]).then(imgUrls => imgUrls[0]))
		.catch(reason => rehost2Chevereto('jerking.empornium.ph', [url]).then(imgUrls => imgUrls[0]))
		.catch(reason => rehost2Chevereto('free-picload.com', [url]).then(imgUrls => imgUrls[0]));
		//.catch(reason => rehost2Imgur([url]).then(imgUrls => imgUrls[0]));
	}
	return verifyImageUrl(url);
  })).then(imageUrls => getPTPIMGapiKey().then(function(apiKey) {
	console.debug('rehost2PTPIMG(...) input:', imageUrls);
	var formData = new URLSearchParams({
	  'link-upload': imageUrls.join('\r\n'),
	  'api_key': apiKey,
	});
	return globalFetch(ptpimgOrigin + '/upload.php', {
	  responseType: 'json',
	  timeout: imageUrls.length * rehostTimeout,
	}, formData).then(function(response) {
	  if (!response.response) return Promise.reject('PTPIMG void response');
	  if (response.response.length < imageUrls.length)
		return Promise.reject(`not all images rehosted (${response.response.length}/${imageUrls.length})`);
	  return response.response.map(item => ptpimgOrigin.concat('/', item.code, '.', item.ext));
	});
  }));
}

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

function getPTPIMGapiKey() {
  var ptpimg_api_key = GM_getValue('ptpimg_api_key');
  if (ptpimg_api_key) return Promise.resolve(ptpimg_api_key);
  try {
	var apiKey = JSON.parse(window.localStorage.ptpimg_it).api_key;
	if (apiKey) {
	  GM_setValue('ptpimg_api_key', ptpimg_api_key = apiKey);
	  return Promise.resolve(apiKey);
	}
  } catch(e) { console.debug('getPTPIMGapiKey():', e) }
  return globalFetch(ptpimgOrigin).then(function(response) {
	if ((apiKey = response.document.getElementById('api_key')) == null) {
	  let counter = GM_getValue('ptpimg_reminder_read', 0);
	  if (counter < 3) {
		alert(`
PTPIMG API key could not be captured. Please login to ${ptpimgOrigin}/ 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');
	GM_setValue('ptpimg_api_key', ptpimg_api_key = apiKey.value);
	Promise.resolve(ptpimg_api_key).then(apiKey => { alert(`Your PTPIMG API key [${apiKey}] was successfully configured`) });
	return ptpimg_api_key;
  });
}

function upload2Chevereto(hostname, images, elem) {
  if (!Array.isArray(images)) return Promise.reject('invalid argument');
  if (images.length <= 0) return Promise.reject('nothing to upload');
  const anonSessionLimits = {
	'malzo.com': 2,
	'imgbb.com': 2,
	'jerking.empornium.ph': 5,
	'free-picload.com': 50,
  };
  return setCheveretoSession(hostname).then(session => Promise.all(images.map(image => new Promise(function(resolve, reject) {
	switch (hostname) {
	  case 'malzo.com':
	  case 'jerking.empornium.ph':
	  case 'free-picload.com':
		if (!['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].some(ext => image.type == 'image/'.concat(ext)))
		  throw 'MIME type not supported: '.concat(image.type);
		break;
	  case 'imgbb.com':
		if (!['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tif', 'tiff', 'heic'].some(ext => image.type == 'image/'.concat(ext)))
		  throw 'MIME type not supported: '.concat(image.type);
		break;
	}
	var anonSessionLimit = anonSessionLimits[hostname.toLowerCase()];
	if (!session.username && typeof anonSessionLimit == 'number' && image.size > anonSessionLimit * 2**20)
	  throw 'image size exceeds anonymous upload limit';
	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(function(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';
	GM_xmlhttpRequest({
	  method: 'POST',
	  url: 'https://'.concat(hostname, '/json'),
	  responseType: 'json',
	  headers: {
		'Accept': 'application/json',
		'Content-Type': 'multipart/form-data; boundary=' + boundary,
		'Content-Length': formData.length,
		'Referer': 'https://'.concat(hostname, '/'),
	  },
	  data: formData,
	  binary: true,
	  timeout: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
	  onload: function(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));
	  },
	  onerror: error => reject(defaultErrorHandler(error)),
	  ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
	});

	function formField(key, value) {
	  return 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n' + value + '\r\n--' + boundary;
	}
  }))));
}

function rehost2Chevereto(hostname, urls) {
  if (hostname.toLowerCase() == 'free-picload.com' && ['passthepopcorn.me'].some(domain => document.domain == domain))
	return Promise.reject(hostname.concat(' blacklisted for this site'));
  return setCheveretoSession(hostname).then(session => Promise.all(urls.map(url => verifyImageUrl(url).then(function(imageUrl) {
	var formData = new URLSearchParams(Object.assign({
	  action: 'upload',
	  type: 'url',
	  nsfw: 0,
	  source: imageUrl,
	}, session));
	return globalFetch('https://'.concat(hostname, '/json'), {
	  responseType: 'json',
	  headers: { 'Referer': 'https://'.concat(hostname, '/') },
	  timeout: urls.length * rehostTimeout,
	}, formData).then(function(response) {
	  return response.response.success ? response.response.image.url
		: Promise.reject(hostname.concat(': ', response.response.error.message,' (', response.response.status_code, ')'));
	});
  }))));
}

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

	  function getPage(url) {
		GM_xmlhttpRequest({ method: 'GET', url: url, headers: { Referer: url },
		  onload: function(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)));
  });
}

function setCheveretoSession(hostname) {
  const index = 'https://'.concat(hostname, '/');
  return globalFetch(index).then(function(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)) return session;
	if (hostname.toLowerCase() == 'free-picload.com') var hostPrefix = 'picload_';
		else if (/^([\w\-]+)(?:\.[\w\-]+)+$/.test(hostname)) hostPrefix = RegExp.$1.toLowerCase().concat('_');
	if (!hostPrefix) return session;
	var login = GM_getValue(hostPrefix.concat('uid')), password = GM_getValue(hostPrefix.concat('password'));
	if (!login || !password) return session;
	var formData = new URLSearchParams({
	  'login-subject': login,
	  'password': password,
	  'auth_token': session.auth_token,
	});
	return new Promise(function(resolve, reject) {
	  GM_xmlhttpRequest({ method: 'POST', url: 'https://'.concat(hostname, '/login'),
		headers: {
		  'Accept': '*/*',
		  'Content-Type': 'application/x-www-form-urlencoded',
		  'Content-Length': formData.toString().length,
		  'Referer': 'https://'.concat(hostname, '/login'),
		}, data: formData.toString(),
		onload: function(response) {
		  if (response.status < 200 || response.status > 400) defaultErrorHandler(response);
		  resolve(response.status);
		},
		onerror: function(response) {
		  reject(defaultErrorHandler(response));
		  //resolve(response.status);
		},
		ontimeout: function(response) {
		  reject(defaultTimeoutHandler(response));
		  //resolve(response.status);
		},
	  });
	}).then(status => globalFetch(index, { responseType: 'text' }).then(function(response) {
	  if (getUser(response)) console.debug(hostname, 'authorized session:', session);
	  	else console.warn(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;
	}
  });
}

function upload2PixHost(images, elem) {
  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 => new Promise(function(resolve, reject) {
	if (!['png', 'jpg', 'jpeg', 'gif'].some(ext => image.type == 'image/'.concat(ext)))
	  throw 'MIME type not supported: '.concat(image.type);
	var now = Date.now();
	const boundary = '----WebKitFormBoundary'.concat(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';
	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: function(response) {
		if (response.status >= 200 && response.status < 400) resolve(response.response.show_url);
			else reject(defaultErrorHandler(response));
	  },
	  onerror: error => reject(defaultErrorHandler(error)),
	  ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
	});
  }).then(imageUrlResolver)));
}

function rehost2PixHost(urls) {
  return verifyImageUrls(urls).then(function(imageUrls) {
	//console.debug('rehost2PixHost(...) input:', imageUrls.join('\n'));
	var formData = new URLSearchParams({
	  imgs: imageUrls.join('\r\n'),
	  content_type: 0,
	  tos: 'on',
	});
	return globalFetch('https://pixhost.to/remote/', {
	  responseType: 'text',
	  timeout: imageUrls.length * rehostTimeout,
	}, formData).then(function(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)));
	});
  });
}

function upload2Catbox(images, elem) {
  if (!Array.isArray(images)) return Promise.reject('Invalid argument');
  if (images.length <= 0) return Promise.reject('Nothing to upload or format not supported');
  return getCatboxUserHash().catch(reason => undefined).then(userHash => Promise.all(images.map(image => new Promise(function(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';
	GM_xmlhttpRequest({
	  method: 'POST',
	  url: 'https://catbox.moe/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: function(response) {
		if (response.status >= 200 && response.status < 400) resolve(response.responseText);
			else reject(defaultErrorHandler(response));
	  },
	  onerror: error => reject(defaultErrorHandler(error)),
	  ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
	});
  }))));
}

function rehost2Catbox(urls) {
  return getCatboxUserHash().catch(reason => undefined).then(userHash => Promise.all(urls.map(url => verifyImageUrl(url).then(function(imageUrl) {
	var formData = new URLSearchParams({
	  reqtype: 'urlupload',
	  url: imageUrl,
	});
	if (userHash) formData.set('userhash', userHash);
	return globalFetch('https://catbox.moe/user/api.php', {
	  responseType: 'text',
	  timeout: urls.length * rehostTimeout,
	}, formData).then(response => response.responseText);
  }))));
}

function getCatboxUserHash() {
  var catbox_userhash = GM_getValue('catbox_userhash');
  return catbox_userhash ? Promise.resolve(catbox_userhash) : globalFetch('https://catbox.moe/').then(function(response) {
	catbox_userhash = response.document.querySelector('input[name="userhash"][value]');
	return catbox_userhash != null && catbox_userhash.value || Promise.reject('Catbox.moe: not logged in or userhash not found');
  });
}

function upload2ImageVenue(images, elem) {
  if (!Array.isArray(images)) return Promise.reject('invalid argument');
  if (images.length <= 0) return Promise.reject('nothing to upload');
  return setImageVenueSession().then(session => new Promise(function(resolve, reject) {
	var now = Date.now();
	const boundary = '----WebKitFormBoundary'.concat(now.toString(16).toUpperCase());
	var formData = '--' + boundary + '\r\n';
	Object.keys(session).forEach(function(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(function(image, index, arr) {
	  if (!['png', 'jpg', 'jpeg', 'gif'].some(ext => image.type == 'image/'.concat(ext)))
		throw 'MIME type not supported: '.concat(image.type);
	  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: 'http://www.imagevenue.com/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: function(response) {
		if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
		resolve(response.response.success);
	  },
	  onerror: error => reject(defaultErrorHandler(error)),
	  ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
	});
  })).then(resultUrl => globalFetch(resultUrl)).then(function(response) {
	var links = response.document.querySelectorAll('div.row > div > a');
	return Promise.all(Array.from(links).map(a => imageUrlResolver(a.href)));
  });
}

function setImageVenueSession() {
  return globalFetch('http://www.imagevenue.com/').then(function(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: 'http://www.imagevenue.com/auth/login', headers: {
	  'Referer': 'http://www.imagevenue.com/auth/login',
	  'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
	  'Content-Length': formData.toString().length,
	}, data: formData.toString() });
	return new Promise(function(resolve, reject) {
	  setTimeout(function() {
		globalFetch('http://www.imagevenue.com/').then(function(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('http://www.imagevenue.com/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,
  })));
}

function upload2ImgBox(images, elem) {
  if (['passthepopcorn.me'].some(domain => document.domain == domain)) return Promise.reject('ImgBox blacklisted for this site');
  if (!Array.isArray(images)) return Promise.reject('invalid argument');
  if (images.length <= 0) return Promise.reject('nothing to upload');
  return setImgBoxSession().then(session => Promise.all(images.map(image => new Promise(function(resolve, reject) {
	if (!['png', 'jpg', 'jpeg', 'gif'].some(ext => image.type == 'image/'.concat(ext)))
	  throw 'MIME type not supported: '.concat(image.type);
	var now = Date.now();
	const boundary = '----WebKitFormBoundary'.concat(now.toString(16).toUpperCase());
	var formData = '--' + boundary + '\r\n';
	Object.keys(session.params).forEach(function(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';
	GM_xmlhttpRequest({
	  method: 'POST',
	  url: 'https://imgbox.com/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: function(response) {
		if (response.status < 200 || response.status >= 400) return reject(defaultErrorHandler(response));
		resolve(response.response.files[0].original_url);
	  },
	  onerror: error => reject(defaultErrorHandler(error)),
	  ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
	});
  }))));
}

function setImgBoxSession() {
  return globalFetch('https://imgbox.com/').then(function(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(function(resolve, reject) {
	  setTimeout(() => { globalFetch('http://imgbox.com/').then(function(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('https://imgbox.com/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,
	},
  })));
}

function upload2Imgur(images, elem) {
  if (['passthepopcorn.me'].some(domain => document.domain == domain)) return Promise.reject('Imgur blacklisted for this site');
  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 => new Promise(function(resolve, reject) {
	const requestUrl = 'https://imgur.com/upload';
	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';
	GM_xmlhttpRequest({
	  method: 'POST',
	  url: requestUrl,
	  responseType: 'json',
	  headers: {
		'Content-Type': 'multipart/form-data; boundary=' + boundary,
		'Content-Length': formData.length,
		'Referer': requestUrl,
	  },
	  data: formData,
	  binary: true,
	  timeout: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
	  onload: function(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));
	  },
	  onerror: error => reject(defaultErrorHandler(error)),
	  ontimeout: timeout => reject(defaultTimeoutHandler(timeout)),
	});
  })));
}

function rehost2Imgur(urls) {
  if (['passthepopcorn.me'].some(domain => document.domain == domain)) return Promise.reject('Imgur blacklisted for this site');
  return Promise.all(urls => urls.map(url => verifyImageUrl(url).then(function(imageUrl) {
	const requestUrl = 'https://imgur.com/upload';
	var formData = new URLSearchParams({ url: imageUrl });
	return globalFetch(requestUrl, {
	  responseType: 'json',
	  headers: { Referer: requestUrl },
	  timeout: urls.length * rehostTimeout,
	}, formData).then(function(result) {
	  if (!result.response.success) return Promise.reject(result.response.status);
	  return 'https://i.imgur.com/'.concat(result.response.data.hash, result.response.data.ext);
	});
  })));
}

function upload2PostImg(images, elem) {
  if (['passthepopcorn.me'].some(domain => document.domain == domain)) return Promise.reject('PostImages blacklisted for this site');
  if (!Array.isArray(images)) return Promise.reject('invalid argument');
  if (images.length <= 0) return Promise.reject('nothing to upload');
  return setPostImgSession().then(session => Promise.all(images.map(image => new Promise(function(resolve, reject) {
	var now = Date.now();
	const boundary = '----WebKitFormBoundary'.concat(now.toString(16).toUpperCase());
	var formData = '--' + boundary + '\r\n';
	Object.keys(session).forEach(function(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';
	GM_xmlhttpRequest({
	  method: 'POST',
	  url: 'https://postimages.org/json/rr',
	  responseType: 'json',
	  headers: {
		'Content-Type': 'multipart/form-data; boundary=' + boundary,
		'Content-Length': formData.length,
		//'Referer': 'https://postimages.org/',
	  },
	  data: formData,
	  binary: true,
	  timeout: Math.max(Math.ceil(formData.length / ulTimeFactor), 10000),
	  onload: function(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);
	  },
	  onerror: response => { reject(defaultErrorHandler(response)) },
	  ontimeout: response => { reject(defaultTimeoutHandler(response)) },
	});
  }).then(imageUrlResolver))));
}

function rehost2PostImg(urls) {
  if (['passthepopcorn.me'].some(domain => document.domain == domain)) return Promise.reject('PostImages blacklisted for this site');
  return setPostImgSession().then(session => Promise.all(urls => urls.map(url => verifyImageUrl(url).then(function(imageUrl) {
	var formData = new URLSearchParams(Object.assign({ url: imageUrl }, session));
	return globalFetch('https://postimages.org/json/rr', {
	  responseType: 'json',
	  timeout: urls.length * rehostTimeout,
	}, formData).then(function(response) {
	  if (response.status < 200 || response.status >= 400) return Promise.reject(defaultErrorHandler(response));
	  if (response.response.status != 'OK') return Promise.reject(response.response.status);
	  return imageUrlResolver(response.response.url);
	});
  }))));
}

function setPostImgSession() {
  return globalFetch('https://postimages.org/').then(function(response) {
	var session = {
	  session_upload: Date.now(),
	  upload_session: rand_string(32),
	  optsize: 0,
	  expire: 0,
	  numfiles: 1,
	  upload_referer: btoa('https://postimages.org/'),
	};
	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: 'https://postimages.org/login',
	  data: formData.toString(),
	  headers: {
		'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
		'Content-Length': formData.toString().length,
		//'Referer': 'https://postimages.org/login',
	  },
	  onload: function(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;
  }
}

function upload2FastPic(images, elem) {
  if (!Array.isArray(images)) return Promise.reject('invalid argument');
  if (images.length <= 0) return Promise.reject('nothing to upload');
  const boundary = '----WebKitFormBoundary'.concat(Date.now().toString(16).toUpperCase());
  var formData = '--' + boundary + '\r\n';
  images.forEach(function(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';
  return new Promise((resolve, reject) => GM_xmlhttpRequest({
	method: 'POST',
	url: 'https://fastpic.ru/uploadmulti',
	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: function(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');
	  }
	},
	onerror: response => { reject(defaultErrorHandler(response)) },
	ontimeout: response => { reject(defaultTimeoutHandler(response)) },
  })).then(resultUrl => globalFetch(resultUrl).then(function(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})`);
  }));
}

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