/** * PrivateBin * * a zero-knowledge paste bin * * @see {@link https://github.com/PrivateBin/PrivateBin} * @copyright 2012 Sébastien SAUVAGE ({@link http://sebsauvage.net}) * @license {@link https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License} * @version 1.2.1 * @name PrivateBin * @namespace */ /** global: Base64 */ /** global: DOMPurify */ /** global: FileReader */ /** global: RawDeflate */ /** global: history */ /** global: navigator */ /** global: prettyPrint */ /** global: prettyPrintOne */ /** global: showdown */ /** global: kjua */ // main application start, called when DOM is fully loaded jQuery(document).ready(function() { 'use strict'; // run main controller $.PrivateBin.Controller.init(); }); jQuery.PrivateBin = (function($, RawDeflate) { 'use strict'; /** * zlib library interface * * @private */ let z; /** * static Helper methods * * @name Helper * @class */ const Helper = (function () { const me = {}; /** * blacklist of UserAgents (parts) known to belong to a bot * * @private * @enum {Object} * @readonly */ const BadBotUA = [ 'Bot', 'bot' ]; /** * cache for script location * * @name Helper.baseUri * @private * @enum {string|null} */ let baseUri = null; /** * converts a duration (in seconds) into human friendly approximation * * @name Helper.secondsToHuman * @function * @param {number} seconds * @return {Array} */ me.secondsToHuman = function(seconds) { let v; if (seconds < 60) { v = Math.floor(seconds); return [v, 'second']; } if (seconds < 60 * 60) { v = Math.floor(seconds / 60); return [v, 'minute']; } if (seconds < 60 * 60 * 24) { v = Math.floor(seconds / (60 * 60)); return [v, 'hour']; } // If less than 2 months, display in days: if (seconds < 60 * 60 * 24 * 60) { v = Math.floor(seconds / (60 * 60 * 24)); return [v, 'day']; } v = Math.floor(seconds / (60 * 60 * 24 * 30)); return [v, 'month']; }; /** * text range selection * * @see {@link https://stackoverflow.com/questions/985272/jquery-selecting-text-in-an-element-akin-to-highlighting-with-your-mouse} * @name Helper.selectText * @function * @param {HTMLElement} element */ me.selectText = function(element) { let range, selection; // MS if (document.body.createTextRange) { range = document.body.createTextRange(); range.moveToElementText(element); range.select(); } else if (window.getSelection) { selection = window.getSelection(); range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } }; /** * convert URLs to clickable links. * URLs to handle: *
* magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7
* https://example.com:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
* http://user:example.com@localhost:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
*
*
* @name Helper.urls2links
* @function
* @param {string} html
* @return {string}
*/
me.urls2links = function(html)
{
return html.replace(
/(((https?|ftp):\/\/[\w?!=&.\/-;#@~%+*-]+(?![\w\s?!&.\/;#~%"=-]*>))|((magnet):[\w?=&.\/-;#@~%+*-]+))/ig,
'$1'
);
};
/**
* minimal sprintf emulation for %s and %d formats
*
* Note that this function needs the parameters in the same order as the
* format strings appear in the string, contrary to the original.
*
* @see {@link https://stackoverflow.com/questions/610406/javascript-equivalent-to-printf-string-format#4795914}
* @name Helper.sprintf
* @function
* @param {string} format
* @param {...*} args - one or multiple parameters injected into format string
* @return {string}
*/
me.sprintf = function()
{
const args = Array.prototype.slice.call(arguments);
let format = args[0],
i = 1;
return format.replace(/%(s|d)/g, function (m) {
// m is the matched format, e.g. %s, %d
let val = args[i];
// A switch statement so that the formatter can be extended.
switch (m)
{
case '%d':
val = parseFloat(val);
if (isNaN(val)) {
val = 0;
}
break;
default:
// Default is %s
}
++i;
return val;
});
};
/**
* get value of cookie, if it was set, empty string otherwise
*
* @see {@link http://www.w3schools.com/js/js_cookies.asp}
* @name Helper.getCookie
* @function
* @param {string} cname - may not be empty
* @return {string}
*/
me.getCookie = function(cname) {
const name = cname + '=',
ca = document.cookie.split(';');
for (let i = 0; i < ca.length; ++i) {
let c = ca[i];
while (c.charAt(0) === ' ')
{
c = c.substring(1);
}
if (c.indexOf(name) === 0)
{
return c.substring(name.length, c.length);
}
}
return '';
};
/**
* get the current location (without search or hash part of the URL),
* eg. https://example.com/path/?aaaa#bbbb --> https://example.com/path/
*
* @name Helper.baseUri
* @function
* @return {string}
*/
me.baseUri = function()
{
// check for cached version
if (baseUri !== null) {
return baseUri;
}
baseUri = window.location.origin + window.location.pathname;
return baseUri;
};
/**
* resets state, used for unit testing
*
* @name Helper.reset
* @function
*/
me.reset = function()
{
baseUri = null;
};
/**
* checks whether this is a bot we dislike
*
* @name Helper.isBadBot
* @function
* @return {bool}
*/
me.isBadBot = function() {
// check whether a bot user agent part can be found in the current
// user agent
for (let i = 0; i < BadBotUA.length; ++i) {
if (navigator.userAgent.indexOf(BadBotUA) >= 0) {
return true;
}
}
return false;
}
return me;
})();
/**
* internationalization module
*
* @name I18n
* @class
*/
const I18n = (function () {
const me = {};
/**
* const for string of loaded language
*
* @name I18n.languageLoadedEvent
* @private
* @prop {string}
* @readonly
*/
const languageLoadedEvent = 'languageLoaded';
/**
* supported languages, minus the built in 'en'
*
* @name I18n.supportedLanguages
* @private
* @prop {string[]}
* @readonly
*/
const supportedLanguages = ['de', 'es', 'fr', 'it', 'hu', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sl', 'zh'];
/**
* built in language
*
* @name I18n.language
* @private
* @prop {string|null}
*/
let language = null;
/**
* translation cache
*
* @name I18n.translations
* @private
* @enum {Object}
*/
let translations = {};
/**
* translate a string, alias for I18n.translate
*
* @name I18n._
* @function
* @param {jQuery} $element - optional
* @param {string} messageId
* @param {...*} args - one or multiple parameters injected into placeholders
* @return {string}
*/
me._ = function()
{
return me.translate.apply(this, arguments);
};
/**
* translate a string
*
* Optionally pass a jQuery element as the first parameter, to automatically
* let the text of this element be replaced. In case the (asynchronously
* loaded) language is not downloadet yet, this will make sure the string
* is replaced when it is actually loaded.
* So for easy translations passing the jQuery object to apply it to is
* more save, especially when they are loaded in the beginning.
*
* @name I18n.translate
* @function
* @param {jQuery} $element - optional
* @param {string} messageId
* @param {...*} args - one or multiple parameters injected into placeholders
* @return {string}
*/
me.translate = function()
{
// convert parameters to array
let args = Array.prototype.slice.call(arguments),
messageId,
$element = null;
// parse arguments
if (args[0] instanceof jQuery) {
// optional jQuery element as first parameter
$element = args[0];
args.shift();
}
// extract messageId from arguments
let usesPlurals = $.isArray(args[0]);
if (usesPlurals) {
// use the first plural form as messageId, otherwise the singular
messageId = args[0].length > 1 ? args[0][1] : args[0][0];
} else {
messageId = args[0];
}
if (messageId.length === 0) {
return messageId;
}
// if no translation string cannot be found (in translations object)
if (!translations.hasOwnProperty(messageId) || language === null) {
// if language is still loading and we have an elemt assigned
if (language === null && $element !== null) {
// handle the error by attaching the language loaded event
let orgArguments = arguments;
$(document).on(languageLoadedEvent, function () {
// log to show that the previous error could be mitigated
console.warn('Fix missing translation of \'' + messageId + '\' with now loaded language ' + language);
// re-execute this function
me.translate.apply(this, orgArguments);
});
// and fall back to English for now until the real language
// file is loaded
}
// for all other languages than English for which this behaviour
// is expected as it is built-in, log error
if (language !== null && language !== 'en') {
console.error('Missing translation for: \'' + messageId + '\' in language ' + language);
// fallback to English
}
// save English translation (should be the same on both sides)
translations[messageId] = args[0];
}
// lookup plural translation
if (usesPlurals && $.isArray(translations[messageId])) {
let n = parseInt(args[1] || 1, 10),
key = me.getPluralForm(n),
maxKey = translations[messageId].length - 1;
if (key > maxKey) {
key = maxKey;
}
args[0] = translations[messageId][key];
args[1] = n;
} else {
// lookup singular translation
args[0] = translations[messageId];
}
// format string
let output = Helper.sprintf.apply(this, args);
// if $element is given, apply text to element
if ($element !== null) {
// get last text node of element
let content = $element.contents();
if (content.length > 1) {
content[content.length - 1].nodeValue = ' ' + output;
} else {
$element.text(output);
}
}
return output;
};
/**
* per language functions to use to determine the plural form
*
* @see {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html}
* @name I18n.getPluralForm
* @function
* @param {int} n
* @return {int} array key
*/
me.getPluralForm = function(n) {
switch (language)
{
case 'fr':
case 'oc':
case 'zh':
return n > 1 ? 1 : 0;
case 'pl':
return n === 1 ? 0 : (n % 10 >= 2 && n %10 <=4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
case 'ru':
return n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
case 'sl':
return n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0));
// de, en, es, hu, it, nl, no, pt
default:
return n !== 1 ? 1 : 0;
}
};
/**
* load translations into cache
*
* @name I18n.loadTranslations
* @function
*/
me.loadTranslations = function()
{
let newLanguage = Helper.getCookie('lang');
// auto-select language based on browser settings
if (newLanguage.length === 0) {
newLanguage = (navigator.language || navigator.userLanguage || 'en').substring(0, 2);
}
// if language is already used skip update
if (newLanguage === language) {
return;
}
// if language is built-in (English) skip update
if (newLanguage === 'en') {
language = 'en';
return;
}
// if language is not supported, show error
if (supportedLanguages.indexOf(newLanguage) === -1) {
console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage);
language = 'en';
return;
}
// load strings from JSON
$.getJSON('i18n/' + newLanguage + '.json', function(data) {
language = newLanguage;
translations = data;
$(document).triggerHandler(languageLoadedEvent);
}).fail(function (data, textStatus, errorMsg) {
console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg);
language = 'en';
});
};
/**
* resets state, used for unit testing
*
* @name I18n.reset
* @function
*/
me.reset = function(mockLanguage, mockTranslations)
{
language = mockLanguage || null;
translations = mockTranslations || {};
};
return me;
})();
/**
* handles everything related to en/decryption
*
* @name CryptTool
* @class
*/
const CryptTool = (function () {
const me = {};
/**
* convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString
*
* Iterates over the bytes of the message, converting them all hexadecimal
* percent encoded representations, then URI decodes them all
*
* @name CryptTool.utf8To16
* @function
* @private
* @param {string} message UTF-8 string
* @return {string} UTF-16 string
*/
function utf8To16(message)
{
return decodeURIComponent(
message.split('').map(
function(character)
{
return '%' + ('00' + character.charCodeAt(0).toString(16)).slice(-2);
}
).join('')
);
}
/**
* convert DOMString (UTF-16) to a UTF-8 string stored in a DOMString
*
* URI encodes the message, then finds the percent encoded characters
* and transforms these hexadecimal representation back into bytes
*
* @name CryptTool.utf16To8
* @function
* @private
* @param {string} message UTF-16 string
* @return {string} UTF-8 string
*/
function utf16To8(message)
{
return encodeURIComponent(message).replace(
/%([0-9A-F]{2})/g,
function (match, hexCharacter)
{
return String.fromCharCode('0x' + hexCharacter);
}
);
}
/**
* convert ArrayBuffer into a UTF-8 string
*
* Iterates over the bytes of the array, catenating them into a string
*
* @name CryptTool.arraybufferToString
* @function
* @private
* @param {ArrayBuffer} messageArray
* @return {string} message
*/
function arraybufferToString(messageArray)
{
const array = new Uint8Array(messageArray);
let message = '',
i = 0;
while(i < array.length) {
message += String.fromCharCode(array[i++]);
}
return message;
}
/**
* convert UTF-8 string into a Uint8Array
*
* Iterates over the bytes of the message, writing them to the array
*
* @name CryptTool.stringToArraybuffer
* @function
* @private
* @param {string} message UTF-8 string
* @return {Uint8Array} array
*/
function stringToArraybuffer(message)
{
const messageArray = new Uint8Array(message.length);
for (let i = 0; i < message.length; ++i) {
messageArray[i] = message.charCodeAt(i);
}
return messageArray;
}
/**
* compress a string (deflate compression), returns buffer
*
* @name CryptTool.compress
* @async
* @function
* @private
* @param {string} message
* @param {string} mode
* @return {ArrayBuffer} data
*/
async function compress(message, mode)
{
message = stringToArraybuffer(
utf16To8(message)
);
if (mode === 'zlib') {
return z.deflate(message).buffer;
}
return message;
}
/**
* decompress potentially base64 encoded, deflate compressed buffer, returns string
*
* @name CryptTool.decompress
* @async
* @function
* @private
* @param {ArrayBuffer} data
* @param {string} mode
* @return {string} message
*/
async function decompress(data, mode)
{
if (mode === 'zlib' || mode === 'none') {
if (mode === 'zlib') {
data = z.inflate(
new Uint8Array(data)
).buffer;
}
return utf8To16(
arraybufferToString(data)
);
}
// detect presence of Base64.js, indicating legacy ZeroBin paste
if (typeof Base64 === 'undefined') {
return utf8To16(
RawDeflate.inflate(
utf8To16(
atob(
arraybufferToString(data)
)
)
)
);
} else {
return Base64.btou(
RawDeflate.inflate(
Base64.fromBase64(
arraybufferToString(data)
)
)
);
}
}
/**
* returns specified number of random bytes
*
* @name CryptTool.getRandomBytes
* @function
* @private
* @param {int} length number of random bytes to fetch
* @throws {string}
* @return {string} random bytes
*/
function getRandomBytes(length)
{
if (
typeof window !== 'undefined' &&
typeof Uint8Array !== 'undefined' &&
String.fromCodePoint &&
(
typeof window.crypto !== 'undefined' ||
typeof window.msCrypto !== 'undefined'
)
) {
// modern browser environment
let bytes = '';
const byteArray = new Uint8Array(length),
crypto = window.crypto || window.msCrypto;
crypto.getRandomValues(byteArray);
for (let i = 0; i < length; ++i) {
bytes += String.fromCharCode(byteArray[i]);
}
return bytes;
} else {
// legacy browser or unsupported environment
throw 'No supported crypto API detected, you may read pastes and comments, but can\'t create pastes or add new comments.';
}
}
/**
* derive cryptographic key from key string and password
*
* @name CryptTool.deriveKey
* @async
* @function
* @private
* @param {string} key
* @param {string} password
* @param {array} spec cryptographic specification
* @return {CryptoKey} derived key
*/
async function deriveKey(key, password, spec)
{
let keyArray = stringToArraybuffer(key);
if (password.length > 0) {
// version 1 pastes did append the passwords SHA-256 hash in hex
if (spec[7] === 'rawdeflate') {
let passwordBuffer = await window.crypto.subtle.digest(
{name: 'SHA-256'},
stringToArraybuffer(
utf16To8(password)
)
);
password = Array.prototype.map.call(
new Uint8Array(passwordBuffer),
x => ('00' + x.toString(16)).slice(-2)
).join('');
}
let passwordArray = stringToArraybuffer(password),
newKeyArray = new Uint8Array(keyArray.length + passwordArray.length);
newKeyArray.set(keyArray, 0);
newKeyArray.set(passwordArray, keyArray.length);
keyArray = newKeyArray;
}
// import raw key
const importedKey = await window.crypto.subtle.importKey(
'raw', // only 'raw' is allowed
keyArray,
{name: 'PBKDF2'}, // we use PBKDF2 for key derivation
false, // the key may not be exported
['deriveKey'] // we may only use it for key derivation
);
// derive a stronger key for use with AES
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2', // we use PBKDF2 for key derivation
salt: stringToArraybuffer(spec[1]), // salt used in HMAC
iterations: spec[2], // amount of iterations to apply
hash: {name: 'SHA-256'} // can be "SHA-1", "SHA-256", "SHA-384" or "SHA-512"
},
importedKey,
{
name: 'AES-' + spec[6].toUpperCase(), // can be any supported AES algorithm ("AES-CTR", "AES-CBC", "AES-CMAC", "AES-GCM", "AES-CFB", "AES-KW", "ECDH", "DH" or "HMAC")
length: spec[3] // can be 128, 192 or 256
},
false, // the key may not be exported
['encrypt', 'decrypt'] // we may only use it for en- and decryption
);
}
/**
* gets crypto settings from specification and authenticated data
*
* @name CryptTool.cryptoSettings
* @function
* @private
* @param {string} adata authenticated data
* @param {array} spec cryptographic specification
* @return {object} crypto settings
*/
function cryptoSettings(adata, spec)
{
return {
name: 'AES-' + spec[6].toUpperCase(), // can be any supported AES algorithm ("AES-CTR", "AES-CBC", "AES-CMAC", "AES-GCM", "AES-CFB", "AES-KW", "ECDH", "DH" or "HMAC")
iv: stringToArraybuffer(spec[0]), // the initialization vector you used to encrypt
additionalData: stringToArraybuffer(adata), // the addtional data you used during encryption (if any)
tagLength: spec[4] // the length of the tag you used to encrypt (if any)
};
}
/**
* compress, then encrypt message with given key and password
*
* @name CryptTool.cipher
* @async
* @function
* @param {string} key
* @param {string} password
* @param {string} message
* @param {array} adata
* @return {array} encrypted message in base64 encoding & adata containing encryption spec
*/
me.cipher = async function(key, password, message, adata)
{
// AES in Galois Counter Mode, keysize 256 bit,
// authentication tag 128 bit, 10000 iterations in key derivation
const spec = [
getRandomBytes(16), // initialization vector
getRandomBytes(8), // salt
100000, // iterations
256, // key size
128, // tag size
'aes', // algorithm
'gcm', // algorithm mode
'zlib' // compression
], encodedSpec = [];
for (let i = 0; i < spec.length; ++i) {
encodedSpec[i] = i < 2 ? btoa(spec[i]) : spec[i];
}
if (adata.length === 0) {
// comment
adata = encodedSpec;
} else if (adata[0] === null) {
// paste
adata[0] = encodedSpec;
}
// finally, encrypt message
return [
btoa(
arraybufferToString(
await window.crypto.subtle.encrypt(
cryptoSettings(JSON.stringify(adata), spec),
await deriveKey(key, password, spec),
await compress(message, spec[7])
)
)
),
adata
];
};
/**
* decrypt message with key, then decompress
*
* @name CryptTool.decipher
* @async
* @function
* @param {string} key
* @param {string} password
* @param {string|object} data encrypted message
* @return {string} decrypted message, empty if decryption failed
*/
me.decipher = async function(key, password, data)
{
let adataString, encodedSpec, cipherMessage;
if (data instanceof Array) {
// version 2
adataString = JSON.stringify(data[1]);
encodedSpec = (data[1][0] instanceof Array ? data[1][0] : data[1]);
cipherMessage = data[0];
} else if (typeof data === 'string') {
// version 1
let object = JSON.parse(data);
adataString = atob(object.adata);
encodedSpec = [
object.iv,
object.salt,
object.iter,
object.ks,
object.ts,
object.cipher,
object.mode,
'rawdeflate'
];
cipherMessage = object.ct;
} else {
throw 'unsupported message format';
}
let spec = encodedSpec, plainText = '';
spec[0] = atob(spec[0]);
spec[1] = atob(spec[1]);
try {
return await decompress(
await window.crypto.subtle.decrypt(
cryptoSettings(adataString, spec),
await deriveKey(key, password, spec),
stringToArraybuffer(
atob(cipherMessage)
)
),
encodedSpec[7]
);
} catch(err) {
return '';
}
};
/**
* returns a random symmetric key
*
* generates 256 bit long keys (8 Bits * 32) for AES with 256 bit long blocks
*
* @name CryptTool.getSymmetricKey
* @function
* @throws {string}
* @return {string} base64 encoded key
*/
me.getSymmetricKey = function()
{
return getRandomBytes(32);
};
return me;
})();
/**
* (Model) Data source (aka MVC)
*
* @name Model
* @class
*/
const Model = (function () {
const me = {};
let id = null,
pasteData = null,
symmetricKey = null,
$templates;
/**
* returns the expiration set in the HTML
*
* @name Model.getExpirationDefault
* @function
* @return string
*/
me.getExpirationDefault = function()
{
return $('#pasteExpiration').val();
};
/**
* returns the format set in the HTML
*
* @name Model.getFormatDefault
* @function
* @return string
*/
me.getFormatDefault = function()
{
return $('#pasteFormatter').val();
};
/**
* returns the paste data (including the cipher data)
*
* @name Model.getPasteData
* @function
* @param {function} callback (optional) Called when data is available
* @param {function} useCache (optional) Whether to use the cache or
* force a data reload. Default: true
* @return string
*/
me.getPasteData = function(callback, useCache)
{
// use cache if possible/allowed
if (useCache !== false && pasteData !== null) {
//execute callback
if (typeof callback === 'function') {
return callback(pasteData);
}
// alternatively just using inline
return pasteData;
}
// reload data
ServerInteraction.prepare();
ServerInteraction.setUrl(Helper.baseUri() + '?' + me.getPasteId());
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showViewButtons();
// show error message
Alert.showError(ServerInteraction.parseUploadError(status, data, 'get paste data'));
});
ServerInteraction.setSuccess(function (status, data) {
pasteData = data;
if (typeof callback === 'function') {
return callback(data);
}
});
ServerInteraction.run();
};
/**
* get the pastes unique identifier from the URL,
* eg. https://example.com/path/?c05354954c49a487#dfdsdgdgdfgdf returns c05354954c49a487
*
* @name Model.getPasteId
* @function
* @return {string} unique identifier
* @throws {string}
*/
me.getPasteId = function()
{
if (id === null) {
// Attention: This also returns the delete token inside of the ID, if it is specified
id = window.location.search.substring(1);
if (id === '') {
throw 'no paste id given';
}
}
return id;
}
/**
* returns true, when the URL has a delete token and the current call was used for deleting a paste.
*
* @name Model.hasDeleteToken
* @function
* @return {bool}
*/
me.hasDeleteToken = function()
{
return window.location.search.indexOf('deletetoken') !== -1;
}
/**
* return the deciphering key stored in anchor part of the URL
*
* @name Model.getPasteKey
* @function
* @return {string|null} key
* @throws {string}
*/
me.getPasteKey = function()
{
if (symmetricKey === null) {
let newKey = window.location.hash.substring(1);
if (newKey === '') {
throw 'no encryption key given';
}
// Some web 2.0 services and redirectors add data AFTER the anchor
// (such as &utm_source=...). We will strip any additional data.
let ampersandPos = newKey.indexOf('&');
if (ampersandPos > -1)
{
newKey = newKey.substring(0, ampersandPos);
}
symmetricKey = atob(newKey);
}
return symmetricKey;
};
/**
* returns a jQuery copy of the HTML template
*
* @name Model.getTemplate
* @function
* @param {string} name - the name of the template
* @return {jQuery}
*/
me.getTemplate = function(name)
{
// find template
let $element = $templates.find('#' + name + 'template').clone(true);
// change ID to avoid collisions (one ID should really be unique)
return $element.prop('id', name);
};
/**
* resets state, used for unit testing
*
* @name Model.reset
* @function
*/
me.reset = function()
{
pasteData = $templates = id = symmetricKey = null;
};
/**
* init navigation manager
*
* preloads jQuery elements
*
* @name Model.init
* @function
*/
me.init = function()
{
$templates = $('#templates');
};
return me;
})();
/**
* Helper functions for user interface
*
* everything directly UI-related, which fits nowhere else
*
* @name UiHelper
* @class
*/
const UiHelper = (function () {
const me = {};
/**
* handle history (pop) state changes
*
* currently this does only handle redirects to the home page.
*
* @name UiHelper.historyChange
* @private
* @function
* @param {Event} event
*/
function historyChange(event)
{
let currentLocation = Helper.baseUri();
if (event.originalEvent.state === null && // no state object passed
event.target.location.href === currentLocation && // target location is home page
window.location.href === currentLocation // and we are not already on the home page
) {
// redirect to home page
window.location.href = currentLocation;
}
}
/**
* reload the page
*
* This takes the user to the PrivateBin homepage.
*
* @name UiHelper.reloadHome
* @function
*/
me.reloadHome = function()
{
window.location.href = Helper.baseUri();
};
/**
* checks whether the element is currently visible in the viewport (so
* the user can actually see it)
*
* @see {@link https://stackoverflow.com/a/40658647}
* @name UiHelper.isVisible
* @function
* @param {jQuery} $element The link hash to move to.
*/
me.isVisible = function($element)
{
let elementTop = $element.offset().top,
viewportTop = $(window).scrollTop(),
viewportBottom = viewportTop + $(window).height();
return elementTop > viewportTop && elementTop < viewportBottom;
};
/**
* scrolls to a specific element
*
* @see {@link https://stackoverflow.com/questions/4198041/jquery-smooth-scroll-to-an-anchor#answer-12714767}
* @name UiHelper.scrollTo
* @function
* @param {jQuery} $element The link hash to move to.
* @param {(number|string)} animationDuration passed to jQuery .animate, when set to 0 the animation is skipped
* @param {string} animationEffect passed to jQuery .animate
* @param {function} finishedCallback function to call after animation finished
*/
me.scrollTo = function($element, animationDuration, animationEffect, finishedCallback)
{
let $body = $('html, body'),
margin = 50,
callbackCalled = false,
dest = 0;
// calculate destination place
// if it would scroll out of the screen at the bottom only scroll it as
// far as the screen can go
if ($element.offset().top > $(document).height() - $(window).height()) {
dest = $(document).height() - $(window).height();
} else {
dest = $element.offset().top - margin;
}
// skip animation if duration is set to 0
if (animationDuration === 0) {
window.scrollTo(0, dest);
} else {
// stop previous animation
$body.stop();
// scroll to destination
$body.animate({
scrollTop: dest
}, animationDuration, animationEffect);
}
// as we have finished we can enable scrolling again
$body.queue(function (next) {
if (!callbackCalled) {
// call user function if needed
if (typeof finishedCallback !== 'undefined') {
finishedCallback();
}
// prevent calling this function twice
callbackCalled = true;
}
next();
});
};
/**
* trigger a history (pop) state change
*
* used to test the UiHelper.historyChange private function
*
* @name UiHelper.mockHistoryChange
* @function
* @param {string} state (optional) state to mock
*/
me.mockHistoryChange = function(state)
{
if (typeof state === 'undefined') {
state = null;
}
historyChange($.Event('popstate', {originalEvent: new PopStateEvent('popstate', {state: state}), target: window}));
};
/**
* initialize
*
* @name UiHelper.init
* @function
*/
me.init = function()
{
// update link to home page
$('.reloadlink').prop('href', Helper.baseUri());
$(window).on('popstate', historyChange);
};
return me;
})();
/**
* Alert/error manager
*
* @name Alert
* @class
*/
const Alert = (function () {
const me = {};
let $errorMessage,
$loadingIndicator,
$statusMessage,
$remainingTime,
currentIcon,
customHandler;
const alertType = [
'loading', // not in bootstrap CSS, but using a plausible value here
'info', // status icon
'warning', // not used yet
'danger' // error icon
];
/**
* forwards a request to the i18n module and shows the element
*
* @name Alert.handleNotification
* @private
* @function
* @param {int} id - id of notification
* @param {jQuery} $element - jQuery object
* @param {string|array} args
* @param {string|null} icon - optional, icon
*/
function handleNotification(id, $element, args, icon)
{
// basic parsing/conversion of parameters
if (typeof icon === 'undefined') {
icon = null;
}
if (typeof args === 'undefined') {
args = null;
} else if (typeof args === 'string') {
// convert string to array if needed
args = [args];
}
// pass to custom handler if defined
if (typeof customHandler === 'function') {
let handlerResult = customHandler(alertType[id], $element, args, icon);
if (handlerResult === true) {
// if it returns true, skip own handler
return;
}
if (handlerResult instanceof jQuery) {
// continue processing with new element
$element = handlerResult;
icon = null; // icons not supported in this case
}
}
// handle icon
if (icon !== null && // icon was passed
icon !== currentIcon[id] // and it differs from current icon
) {
let $glyphIcon = $element.find(':first');
// remove (previous) icon
$glyphIcon.removeClass(currentIcon[id]);
// any other thing as a string (e.g. 'null') (only) removes the icon
if (typeof icon === 'string') {
// set new icon
currentIcon[id] = 'glyphicon-' + icon;
$glyphIcon.addClass(currentIcon[id]);
}
}
// show text
if (args !== null) {
// add jQuery object to it as first parameter
args.unshift($element);
// pass it to I18n
I18n._.apply(this, args);
}
// show notification
$element.removeClass('hidden');
}
/**
* display a status message
*
* This automatically passes the text to I18n for translation.
*
* @name Alert.showStatus
* @function
* @param {string|array} message string, use an array for %s/%d options
* @param {string|null} icon optional, the icon to show,
* default: leave previous icon
*/
me.showStatus = function(message, icon)
{
handleNotification(1, $statusMessage, message, icon);
};
/**
* display an error message
*
* This automatically passes the text to I18n for translation.
*
* @name Alert.showError
* @function
* @param {string|array} message string, use an array for %s/%d options
* @param {string|null} icon optional, the icon to show, default:
* leave previous icon
*/
me.showError = function(message, icon)
{
handleNotification(3, $errorMessage, message, icon);
};
/**
* display remaining message
*
* This automatically passes the text to I18n for translation.
*
* @name Alert.showRemaining
* @function
* @param {string|array} message string, use an array for %s/%d options
*/
me.showRemaining = function(message)
{
handleNotification(1, $remainingTime, message);
};
/**
* shows a loading message, optionally with a percentage
*
* This automatically passes all texts to the i10s module.
*
* @name Alert.showLoading
* @function
* @param {string|array|null} message optional, use an array for %s/%d options, default: 'Loading…'
* @param {string|null} icon optional, the icon to show, default: leave previous icon
*/
me.showLoading = function(message, icon)
{
// default message text
if (typeof message === 'undefined') {
message = 'Loading…';
}
handleNotification(0, $loadingIndicator, message, icon);
// show loading status (cursor)
$('body').addClass('loading');
};
/**
* hides the loading message
*
* @name Alert.hideLoading
* @function
*/
me.hideLoading = function()
{
$loadingIndicator.addClass('hidden');
// hide loading cursor
$('body').removeClass('loading');
};
/**
* hides any status/error messages
*
* This does not include the loading message.
*
* @name Alert.hideMessages
* @function
*/
me.hideMessages = function()
{
// also possible: $('.statusmessage').addClass('hidden');
$statusMessage.addClass('hidden');
$errorMessage.addClass('hidden');
};
/**
* set a custom handler, which gets all notifications.
*
* This handler gets the following arguments:
* alertType (see array), $element, args, icon
* If it returns true, the own processing will be stopped so the message
* will not be displayed. Otherwise it will continue.
* As an aditional feature it can return q jQuery element, which will
* then be used to add the message there. Icons are not supported in
* that case and will be ignored.
* Pass 'null' to reset/delete the custom handler.
* Note that there is no notification when a message is supposed to get
* hidden.
*
* @name Alert.setCustomHandler
* @function
* @param {function|null} newHandler
*/
me.setCustomHandler = function(newHandler)
{
customHandler = newHandler;
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name Alert.init
* @function
*/
me.init = function()
{
// hide "no javascript" error message
$('#noscript').hide();
// not a reset, but first set of the elements
$errorMessage = $('#errormessage');
$loadingIndicator = $('#loadingindicator');
$statusMessage = $('#status');
$remainingTime = $('#remainingtime');
currentIcon = [
'glyphicon-time', // loading icon
'glyphicon-info-sign', // status icon
'', // reserved for warning, not used yet
'glyphicon-alert' // error icon
];
};
return me;
})();
/**
* handles paste status/result
*
* @name PasteStatus
* @class
*/
const PasteStatus = (function () {
const me = {};
let $pasteSuccess,
$pasteUrl,
$remainingTime,
$shortenButton;
/**
* forward to URL shortener
*
* @name PasteStatus.sendToShortener
* @private
* @function
*/
function sendToShortener()
{
window.location.href = $shortenButton.data('shortener') +
encodeURIComponent($pasteUrl.attr('href'));
}
/**
* Forces opening the paste if the link does not do this automatically.
*
* This is necessary as browsers will not reload the page when it is
* already loaded (which is fake as it is set via history.pushState()).
*
* @name PasteStatus.pasteLinkClick
* @function
*/
function pasteLinkClick()
{
// check if location is (already) shown in URL bar
if (window.location.href === $pasteUrl.attr('href')) {
// if so we need to load link by reloading the current site
window.location.reload(true);
}
}
/**
* creates a notification after a successfull paste upload
*
* @name PasteStatus.createPasteNotification
* @function
* @param {string} url
* @param {string} deleteUrl
*/
me.createPasteNotification = function(url, deleteUrl)
{
$('#pastelink').html(
I18n._(
'Your paste is %s (Hit [Ctrl]+[c] to copy)',
url, url
)
);
// save newly created element
$pasteUrl = $('#pasteurl');
// and add click event
$pasteUrl.click(pasteLinkClick);
// shorten button
$('#deletelink').html('' + I18n._('Delete data') + '');
// show result
$pasteSuccess.removeClass('hidden');
// we pre-select the link so that the user only has to [Ctrl]+[c] the link
Helper.selectText($pasteUrl[0]);
};
/**
* shows the remaining time
*
* @name PasteStatus.showRemainingTime
* @function
* @param {object} pasteMetaData
*/
me.showRemainingTime = function(pasteMetaData)
{
if (pasteMetaData.burnafterreading) {
// display paste "for your eyes only" if it is deleted
// the paste has been deleted when the JSON with the ciphertext
// has been downloaded
Alert.showRemaining('FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.');
$remainingTime.addClass('foryoureyesonly');
// discourage cloning (it cannot really be prevented)
TopNav.hideCloneButton();
} else if (pasteMetaData.expire_date) {
// display paste expiration
let expiration = Helper.secondsToHuman(pasteMetaData.remaining_time),
expirationLabel = [
'This document will expire in %d ' + expiration[1] + '.',
'This document will expire in %d ' + expiration[1] + 's.'
];
Alert.showRemaining([expirationLabel, expiration[0]]);
$remainingTime.removeClass('foryoureyesonly');
} else {
// never expires
return;
}
// in the end, display notification
$remainingTime.removeClass('hidden');
};
/**
* hides the remaining time and successful upload notification
*
* @name PasteStatus.hideMessages
* @function
*/
me.hideMessages = function()
{
$remainingTime.addClass('hidden');
$pasteSuccess.addClass('hidden');
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name PasteStatus.init
* @function
*/
me.init = function()
{
$pasteSuccess = $('#pastesuccess');
// $pasteUrl is saved in me.createPasteNotification() after creation
$remainingTime = $('#remainingtime');
$shortenButton = $('#shortenbutton');
// bind elements
$shortenButton.click(sendToShortener);
};
return me;
})();
/**
* password prompt
*
* @name Prompt
* @class
*/
const Prompt = (function () {
const me = {};
let $passwordDecrypt,
$passwordForm,
$passwordModal,
password = '';
/**
* submit a password in the modal dialog
*
* @name Prompt.submitPasswordModal
* @private
* @function
* @param {Event} event
*/
function submitPasswordModal(event)
{
event.preventDefault();
// get input
password = $passwordDecrypt.val();
// hide modal
$passwordModal.modal('hide');
PasteDecrypter.run();
}
/**
* ask the user for the password and set it
*
* @name Prompt.requestPassword
* @function
*/
me.requestPassword = function()
{
// show new bootstrap method (if available)
if ($passwordModal.length !== 0) {
$passwordModal.modal({
backdrop: 'static',
keyboard: false
});
return;
}
// fallback to old method for page template
password = prompt(I18n._('Please enter the password for this paste:'), '');
if (password === null) {
throw 'password prompt canceled';
}
if (password.length === 0) {
// recurse…
return me.requestPassword();
}
PasteDecrypter.run();
};
/**
* get the cached password
*
* If you do not get a password with this function
* (returns an empty string), use requestPassword.
*
* @name Prompt.getPassword
* @function
* @return {string}
*/
me.getPassword = function()
{
return password;
};
/**
* resets the password to an empty string
*
* @name Prompt.reset
* @function
*/
me.reset = function()
{
// reset internal
password = '';
// and also reset UI
$passwordDecrypt.val('');
}
/**
* init status manager
*
* preloads jQuery elements
*
* @name Prompt.init
* @function
*/
me.init = function()
{
$passwordDecrypt = $('#passworddecrypt');
$passwordForm = $('#passwordform');
$passwordModal = $('#passwordmodal');
// bind events
// focus password input when it is shown
$passwordModal.on('shown.bs.Model', function () {
$passwordDecrypt.focus();
});
// handle Model password submission
$passwordForm.submit(submitPasswordModal);
};
return me;
})();
/**
* Manage paste/message input, and preview tab
*
* Note that the actual preview is handled by PasteViewer.
*
* @name Editor
* @class
*/
const Editor = (function () {
const me = {};
let $editorTabs,
$messageEdit,
$messagePreview,
$message,
isPreview = false;
/**
* support input of tab character
*
* @name Editor.supportTabs
* @function
* @param {Event} event
* @this $message (but not used, so it is jQuery-free, possibly faster)
*/
function supportTabs(event)
{
const keyCode = event.keyCode || event.which;
// tab was pressed
if (keyCode === 9) {
// get caret position & selection
const val = this.value,
start = this.selectionStart,
end = this.selectionEnd;
// set textarea value to: text before caret + tab + text after caret
this.value = val.substring(0, start) + '\t' + val.substring(end);
// put caret at right position again
this.selectionStart = this.selectionEnd = start + 1;
// prevent the textarea to lose focus
event.preventDefault();
}
}
/**
* view the Editor tab
*
* @name Editor.viewEditor
* @function
* @param {Event} event - optional
*/
function viewEditor(event)
{
// toggle buttons
$messageEdit.addClass('active');
$messagePreview.removeClass('active');
PasteViewer.hide();
// reshow input
$message.removeClass('hidden');
me.focusInput();
// finish
isPreview = false;
// prevent jumping of page to top
if (typeof event !== 'undefined') {
event.preventDefault();
}
}
/**
* view the preview tab
*
* @name Editor.viewPreview
* @function
* @param {Event} event
*/
function viewPreview(event)
{
// toggle buttons
$messageEdit.removeClass('active');
$messagePreview.addClass('active');
// hide input as now preview is shown
$message.addClass('hidden');
// show preview
PasteViewer.setText($message.val());
if (AttachmentViewer.hasAttachmentData()) {
let attachmentData = AttachmentViewer.getAttachmentData() || AttachmentViewer.getAttachmentLink().attr('href');
AttachmentViewer.handleAttachmentPreview(AttachmentViewer.getAttachmentPreview(), attachmentData);
}
PasteViewer.run();
// finish
isPreview = true;
// prevent jumping of page to top
if (typeof event !== 'undefined') {
event.preventDefault();
}
}
/**
* get the state of the preview
*
* @name Editor.isPreview
* @function
*/
me.isPreview = function()
{
return isPreview;
};
/**
* reset the Editor view
*
* @name Editor.resetInput
* @function
*/
me.resetInput = function()
{
// go back to input
if (isPreview) {
viewEditor();
}
// clear content
$message.val('');
};
/**
* shows the Editor
*
* @name Editor.show
* @function
*/
me.show = function()
{
$message.removeClass('hidden');
$editorTabs.removeClass('hidden');
};
/**
* hides the Editor
*
* @name Editor.reset
* @function
*/
me.hide = function()
{
$message.addClass('hidden');
$editorTabs.addClass('hidden');
};
/**
* focuses the message input
*
* @name Editor.focusInput
* @function
*/
me.focusInput = function()
{
$message.focus();
};
/**
* sets a new text
*
* @name Editor.setText
* @function
* @param {string} newText
*/
me.setText = function(newText)
{
$message.val(newText);
};
/**
* returns the current text
*
* @name Editor.getText
* @function
* @return {string}
*/
me.getText = function()
{
return $message.val();
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name Editor.init
* @function
*/
me.init = function()
{
$editorTabs = $('#editorTabs');
$message = $('#message');
// bind events
$message.keydown(supportTabs);
// bind click events to tab switchers (a), but save parent of them
// (li)
$messageEdit = $('#messageedit').click(viewEditor).parent();
$messagePreview = $('#messagepreview').click(viewPreview).parent();
};
return me;
})();
/**
* (view) Parse and show paste.
*
* @name PasteViewer
* @class
*/
const PasteViewer = (function () {
const me = {};
let $placeholder,
$prettyMessage,
$prettyPrint,
$plainText,
text,
format = 'plaintext',
isDisplayed = false,
isChanged = true; // by default true as nothing was parsed yet
/**
* apply the set format on paste and displays it
*
* @name PasteViewer.parsePaste
* @private
* @function
*/
function parsePaste()
{
// skip parsing if no text is given
if (text === '') {
return;
}
// escape HTML entities, link URLs, sanitize
const escapedLinkedText = Helper.urls2links(
$('').text(text).html()
),
sanitizedLinkedText = DOMPurify.sanitize(escapedLinkedText);
$plainText.html(sanitizedLinkedText);
$prettyPrint.html(sanitizedLinkedText);
switch (format) {
case 'markdown':
const converter = new showdown.Converter({
strikethrough: true,
tables: true,
tablesHeaderId: true,
simplifiedAutoLink: true,
excludeTrailingPunctuationFromURLs: true
});
// let showdown convert the HTML and sanitize HTML *afterwards*!
$plainText.html(
DOMPurify.sanitize(
converter.makeHtml(text)
)
);
// add table classes from bootstrap css
$plainText.find('table').addClass('table-condensed table-bordered');
break;
case 'syntaxhighlighting':
// yes, this is really needed to initialize the environment
if (typeof prettyPrint === 'function')
{
prettyPrint();
}
$prettyPrint.html(
DOMPurify.sanitize(
prettyPrintOne(escapedLinkedText, null, true)
)
);
// fall through, as the rest is the same
default: // = 'plaintext'
$prettyPrint.css('white-space', 'pre-wrap');
$prettyPrint.css('word-break', 'normal');
$prettyPrint.removeClass('prettyprint');
}
}
/**
* displays the paste
*
* @name PasteViewer.showPaste
* @private
* @function
*/
function showPaste()
{
// instead of "nothing" better display a placeholder
if (text === '') {
$placeholder.removeClass('hidden');
return;
}
// otherwise hide the placeholder
$placeholder.addClass('hidden');
switch (format) {
case 'markdown':
$plainText.removeClass('hidden');
$prettyMessage.addClass('hidden');
break;
default:
$plainText.addClass('hidden');
$prettyMessage.removeClass('hidden');
break;
}
}
/**
* sets the format in which the text is shown
*
* @name PasteViewer.setFormat
* @function
* @param {string} newFormat the new format
*/
me.setFormat = function(newFormat)
{
// skip if there is no update
if (format === newFormat) {
return;
}
// needs to update display too, if we switch from or to Markdown
if (format === 'markdown' || newFormat === 'markdown') {
isDisplayed = false;
}
format = newFormat;
isChanged = true;
};
/**
* returns the current format
*
* @name PasteViewer.getFormat
* @function
* @return {string}
*/
me.getFormat = function()
{
return format;
};
/**
* returns whether the current view is pretty printed
*
* @name PasteViewer.isPrettyPrinted
* @function
* @return {bool}
*/
me.isPrettyPrinted = function()
{
return $prettyPrint.hasClass('prettyprinted');
};
/**
* sets the text to show
*
* @name PasteViewer.setText
* @function
* @param {string} newText the text to show
*/
me.setText = function(newText)
{
if (text !== newText) {
text = newText;
isChanged = true;
}
};
/**
* gets the current cached text
*
* @name PasteViewer.getText
* @function
* @return {string}
*/
me.getText = function()
{
return text;
};
/**
* show/update the parsed text (preview)
*
* @name PasteViewer.run
* @function
*/
me.run = function()
{
if (isChanged) {
parsePaste();
isChanged = false;
}
if (!isDisplayed) {
showPaste();
isDisplayed = true;
}
};
/**
* hide parsed text (preview)
*
* @name PasteViewer.hide
* @function
*/
me.hide = function()
{
if (!isDisplayed) {
return;
}
$plainText.addClass('hidden');
$prettyMessage.addClass('hidden');
$placeholder.addClass('hidden');
AttachmentViewer.hideAttachmentPreview();
isDisplayed = false;
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name PasteViewer.init
* @function
*/
me.init = function()
{
$placeholder = $('#placeholder');
$plainText = $('#plaintext');
$prettyMessage = $('#prettymessage');
$prettyPrint = $('#prettyprint');
// check requirements
if (typeof prettyPrintOne !== 'function') {
Alert.showError([
'The library %s is not available. This may cause display errors.',
'pretty print'
]);
}
if (typeof showdown !== 'object') {
Alert.showError([
'The library %s is not available. This may cause display errors.',
'showdown'
]);
}
// get default option from template/HTML or fall back to set value
format = Model.getFormatDefault() || format;
text = '';
isDisplayed = false;
isChanged = true;
};
return me;
})();
/**
* (view) Show attachment and preview if possible
*
* @name AttachmentViewer
* @class
*/
const AttachmentViewer = (function () {
const me = {};
let $attachmentLink,
$attachmentPreview,
$attachment,
attachmentData,
file,
$fileInput,
$dragAndDropFileName,
attachmentHasPreview = false;
/**
* sets the attachment but does not yet show it
*
* @name AttachmentViewer.setAttachment
* @function
* @param {string} attachmentData - base64-encoded data of file
* @param {string} fileName - optional, file name
*/
me.setAttachment = function(attachmentData, fileName)
{
// IE does not support setting a data URI on an a element
// Convert dataURI to a Blob and use msSaveBlob to download
if (window.Blob && navigator.msSaveBlob) {
$attachmentLink.off('click').on('click', function () {
// data URI format: data:[