imageHostUploader

×

As of 2020-05-18. See the latest version.

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @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;
};