/** * 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} * @name PrivateBin * @namespace */ /** * draghover plugin replacement * adds draghoverstart and draghoverend events to an element */ function draghover(element) { 'use strict'; let collection = new Set(); element.addEventListener('dragenter', function (e) { if (collection.size === 0) { element.dispatchEvent(new CustomEvent('draghoverstart')); } collection.add(e.target); }); element.addEventListener('dragleave', function (e) { collection.delete(e.target); if (collection.size === 0) { element.dispatchEvent(new CustomEvent('draghoverend')); } }); element.addEventListener('drop', function (e) { collection.delete(e.target); if (collection.size === 0) { element.dispatchEvent(new CustomEvent('draghoverend')); } }); } window.PrivateBin = (function () { 'use strict'; /** * zlib library interface * * @private */ let z; /** * DOMpurify settings for HTML content * * @private */ const purifyHtmlConfig = { ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i, USE_PROFILES: { html: true } }; /** * DOMpurify settings for HTML content, where only a strict subset is allowed. * * NOTE: The key {@link purifyHtmlConfig.USE_PROFILES} **must not** be included, * as otherwise `USE_PROFILES` takes precedence over {@link purifyHtmlConfigStrictSubset.ALLOWED_TAGS}. * * @private */ const purifyHtmlConfigStrictSubset = { ALLOWED_URI_REGEXP: purifyHtmlConfig.ALLOWED_URI_REGEXP, ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'], ALLOWED_ATTR: ['href', 'id'] }; /** * DOMpurify settings for SVG content * * @private */ const purifySvgConfig = { USE_PROFILES: { svg: true, svgFilters: true } }; /** * URL fragment prefix requiring load confirmation * * @private */ const loadConfirmPrefix = '#-'; /** * CryptoData class * * bundles helper functions used in both document and comment formats * * @name CryptoData * @class */ function CryptoData(data) { // store all keys in the default locations for drop-in replacement for (const key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { this[key] = data[key]; } } /** * gets the cipher data (cipher text + adata) * * @name CryptoData.getCipherData * @function * @return {Array} */ this.getCipherData = function () { return [this.ct, this.adata]; } } /** * Paste class * * bundles helper functions around the document formats * * @name Paste * @class */ function Paste(data) { // inherit constructor and methods of CryptoData CryptoData.call(this, data); /** * gets the used formatter * * @name Paste.getFormat * @function * @return {string} */ this.getFormat = function () { return this.adata[1]; } /** * gets the remaining seconds before the document expires * * returns 0 if there is no expiration * * @name Paste.getTimeToLive * @function * @return {string} */ this.getTimeToLive = function () { return this.meta.time_to_live || 0; } /** * is burn-after-reading enabled * * @name Paste.isBurnAfterReadingEnabled * @function * @return {bool} */ this.isBurnAfterReadingEnabled = function () { return this.adata[3]; } /** * are discussions enabled * * @name Paste.isDiscussionEnabled * @function * @return {bool} */ this.isDiscussionEnabled = function () { return this.adata[2]; } } /** * Comment class * * bundles helper functions around the comment formats * * @name Comment * @class */ function Comment(data) { // inherit constructor and methods of CryptoData CryptoData.call(this, data); /** * gets the UNIX timestamp of the comment creation * * @name Comment.getCreated * @function * @return {int} */ this.getCreated = function () { return this.meta['created'] || 0; } /** * gets the icon of the comment submitter * * @name Comment.getIcon * @function * @return {string} */ this.getIcon = function () { return this.meta['icon'] || ''; } } /** * static Helper methods * * @name Helper * @class */ const Helper = (function () { const me = {}; /** * character to HTML entity lookup table * * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} * @name Helper.entityMap * @private * @enum {Object} * @readonly */ const entityMap = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''', '/': '/', '`': '`', '=': '=' }; /** * number of seconds in a minute * * @name Helper.minute * @private * @enum {number} * @readonly */ const minute = 60; /** * number of seconds in an hour * * = 60 * 60 seconds * * @name Helper.minute * @private * @enum {number} * @readonly */ const hour = 3600; /** * number of seconds in a day * * = 60 * 60 * 24 seconds * * @name Helper.day * @private * @enum {number} * @readonly */ const day = 86400; /** * number of seconds in a week * * = 60 * 60 * 24 * 7 seconds * * @name Helper.week * @private * @enum {number} * @readonly */ const week = 604800; /** * number of seconds in a month (30 days, an approximation) * * = 60 * 60 * 24 * 30 seconds * * @name Helper.month * @private * @enum {number} * @readonly */ const month = 2592000; /** * number of seconds in a non-leap year * * = 60 * 60 * 24 * 365 seconds * * @name Helper.year * @private * @enum {number} * @readonly */ const year = 31536000; /** * 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 < minute) { v = Math.floor(seconds); return [v, 'second']; } if (seconds < hour) { v = Math.floor(seconds / minute); return [v, 'minute']; } if (seconds < day) { v = Math.floor(seconds / hour); return [v, 'hour']; } // If less than 2 months, display in days: if (seconds < (2 * month)) { v = Math.floor(seconds / day); return [v, 'day']; } v = Math.floor(seconds / month); return [v, 'month']; }; /** * converts a duration string into seconds * * The string is expected to be optional digits, followed by a time. * Supported times are: min, hour, day, month, year, never * Examples: 5min, 13hour, never * * @name Helper.durationToSeconds * @function * @param {String} duration * @return {number} */ me.durationToSeconds = function (duration) { let pieces = duration.split(/(\D+)/), factor = pieces[0] || 0, timespan = pieces[1] || pieces[0]; switch (timespan) { case 'min': return factor * minute; case 'hour': return factor * hour; case 'day': return factor * day; case 'week': return factor * week; case 'month': return factor * month; case 'year': return factor * year; case 'never': return 0; default: return factor; } }; /** * 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 in the provided element. * * 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 {HTMLElement} element
*/
me.urls2links = function (element) {
const raw = element.innerHTML;
element.innerHTML = DOMPurify.sanitize(
raw.replace(
/(((https?|ftp):\/\/[\w?!=&.\/;#@~%+*-]+(?![\w\s?!&.\/;#~%"=-]>))|((magnet):[\w?=&.\/;#@~%+*-]+))/ig,
'$1'
),
purifyHtmlConfigStrictSubset
);
};
/**
* 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) {
let val = args[i];
if (m === '%d') {
val = parseFloat(val);
if (isNaN(val)) {
val = 0;
}
}
++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;
};
/**
* wrap an object into a Paste, used for mocking in the unit tests
*
* @name Helper.PasteFactory
* @function
* @param {object} data
* @return {Paste}
*/
me.PasteFactory = function (data) {
return new Paste(data);
};
/**
* wrap an object into a Comment, used for mocking in the unit tests
*
* @name Helper.CommentFactory
* @function
* @param {object} data
* @return {Comment}
*/
me.CommentFactory = function (data) {
return new Comment(data);
};
/**
* convert all applicable characters to HTML entities
*
* @see {@link https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html}
* @name Helper.htmlEntities
* @function
* @param {string} str
* @return {string} escaped HTML
*/
me.htmlEntities = function (str) {
return String(str).replace(
/[&<>"'`=\/]/g, function (s) {
return entityMap[s];
}
);
};
/**
* calculate expiration date given initial date and expiration period
*
* @name Helper.calculateExpirationDate
* @function
* @param {Date} initialDate - may not be empty
* @param {string|number} expirationDisplayStringOrSecondsToExpire - may not be empty
* @return {Date}
*/
me.calculateExpirationDate = function (initialDate, expirationDisplayStringOrSecondsToExpire) {
let expirationDate = new Date(initialDate),
secondsToExpiration = expirationDisplayStringOrSecondsToExpire;
if (typeof expirationDisplayStringOrSecondsToExpire === 'string') {
secondsToExpiration = me.durationToSeconds(expirationDisplayStringOrSecondsToExpire);
}
if (typeof secondsToExpiration !== 'number') {
throw new Error('Cannot calculate expiration date.');
}
if (secondsToExpiration === 0) {
return null;
}
expirationDate = expirationDate.setUTCSeconds(expirationDate.getUTCSeconds() + secondsToExpiration);
return expirationDate;
};
/**
* Convert Bytes to kB/MB/GB/TB/PB/EB/ZB/YB
*
* @name Helper.formatBytes
* @function
*
* @param {number} bytes
* @return {string}
*/
me.formatBytes = function (bytes) {
let result = '';
const kilobyte = 1000;
const decimalPoint = 2;
const sizes = [
I18n._('B'), I18n._('kB'), I18n._('MB'), I18n._('GB'), I18n._('TB'),
I18n._('PB'), I18n._('EB'), I18n._('ZB'), I18n._('YB')
];
const index = Math.floor(Math.log(bytes) / Math.log(kilobyte));
if (bytes > 0) {
result = parseFloat((bytes / Math.pow(kilobyte, index)).toFixed(decimalPoint)) + ' ' + sizes[index];
} else {
result = `0 ${I18n._('B')}`;
}
return result;
};
/**
* resets state, used for unit testing
*
* @name Helper.reset
* @function
*/
me.reset = function () {
baseUri = null;
};
/**
* check if bootstrap5 object detected
*
* @name Helper.isBootstrap5
* @returns {Boolean}
*/
me.isBootstrap5 = function () {
return typeof bootstrap !== 'undefined';
};
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 = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fa', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', '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 {HTMLElement} 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 an HTMLElement as the first parameter, to automatically
* let the text of this element be replaced. In case the (asynchronously
* loaded) language is not downloaded yet, this will make sure the string
* is replaced when it eventually gets loaded. Using this is both simpler
* and more secure, as it avoids potential XSS when inserting text.
* The next parameter is the message ID, matching the ones found in
* the translation files under the i18n directory.
* Any additional parameters will get inserted into the message ID in
* place of %s (strings) or %d (digits), applying the appropriate plural
* in case of digits. See also Helper.sprintf().
*
* @name I18n.translate
* @function
* @param {HTMLElement} element - optional
* @param {string} messageId
* @param {...*} args - one or multiple parameters injected into placeholders
* @return {string}
*/
me.translate = function () // eslint-disable-line complexity
{
// convert parameters to array
let args = Array.prototype.slice.call(arguments),
messageId,
element = null;
// parse arguments
if (args[0] instanceof HTMLElement) {
// optional HTMLElement as first parameter
element = args[0];
args.shift();
}
// extract messageId from arguments
let usesPlurals = Array.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 element assigned
if (language === null && element !== null) {
// handle the error by attaching the language loaded event
let orgArguments = arguments;
document.addEventListener(languageLoadedEvent, function () {
// 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 && Array.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];
}
// messageID may contain HTML, but should be from a trusted source (code or translation JSON files)
let containsHtml = isStringContainsHtml(args[0]);
// prevent double encoding, when we insert into a text node
if (containsHtml || element === null) {
for (let i = 0; i < args.length; ++i) {
// parameters (i > 0) may never contain HTML as they may come from untrusted parties
if ((containsHtml ? i > 1 : i > 0) || !containsHtml) {
args[i] = Helper.htmlEntities(args[i]);
}
}
}
// format string
let output = Helper.sprintf.apply(this, args);
if (containsHtml) {
// only allow tags/attributes we actually use in translations
output = DOMPurify.sanitize(output, purifyHtmlConfigStrictSubset);
}
// if element is given, insert translation
if (element !== null) {
if (containsHtml) {
element.innerHTML = output;
} else {
// text node takes care of entity encoding
element.textContent = output;
}
return '';
}
return output;
};
/**
* get currently loaded language
*
* @name I18n.getLanguage
* @function
* @return {string}
*/
me.getLanguage = function () {
return language;
};
/**
* per language functions to use to determine the plural form
*
* @see {@link https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html}
* @name I18n.getPluralForm
* @function
* @param {int} n
* @return {int} array key
*/
me.getPluralForm = function (n) { // eslint-disable-line complexity
switch (language) {
case 'ar':
return n === 0 ? 0 : (n === 1 ? 1 : (n === 2 ? 2 : (n % 100 >= 3 && n % 100 <= 10 ? 3 : (n % 100 >= 11 ? 4 : 5))));
case 'cs':
case 'sk':
return n === 1 ? 0 : (n >= 2 && n <= 4 ? 1 : 2);
case 'co':
case 'fa':
case 'fr':
case 'oc':
case 'tr':
case 'zh':
return n > 1 ? 1 : 0;
case 'he':
return n === 1 ? 0 : (n === 2 ? 1 : ((n < 0 || n > 10) && (n % 10 === 0) ? 2 : 3));
case 'id':
case 'ja':
case 'jbo':
case 'th':
return 0;
case 'lt':
return n % 10 === 1 && n % 100 !== 11 ? 0 : ((n % 10 >= 2 && n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
case 'pl':
return n === 1 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
case 'ro':
return n === 1 ? 0 : ((n === 0 || (n % 100 > 0 && n % 100 < 20)) ? 1 : 2);
case 'ru':
case 'uk':
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));
// bg, ca, de, el, en, es, et, fi, hu, it, nl, no, pt, sv
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');
if (newLanguage.indexOf('-') > 0) {
newLanguage = newLanguage.split('-')[0];
}
}
// 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
const scriptTag = document.querySelector('script[src^="js/privatebin.js"]');
const cacheBreaker = scriptTag ? (scriptTag.getAttribute('src').split('.js')[1] || '') : '';
fetch('i18n/' + newLanguage + '.json' + cacheBreaker)
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' ' + response.statusText);
}
return response.json();
})
.then(data => {
language = newLanguage;
translations = data;
document.dispatchEvent(new CustomEvent(languageLoadedEvent));
})
.catch(error => {
console.error('Language \'%s\' could not be loaded (%s). Translation failed, fallback to English.', newLanguage, error.message);
language = 'en';
});
};
/**
* resets state, used for unit testing
*
* @name I18n.reset
* @function
*/
me.reset = function (mockLanguage, mockTranslations) {
language = mockLanguage || null;
translations = mockTranslations || {};
};
/**
* Check if string contains valid HTML code.
*
* If it is no string at all, this always returns false for compatibility.
*
* @name I18n.isStringContainsHtml
* @function
* @private
* @param {string|object} messageId
* @returns {boolean}
*/
function isStringContainsHtml(messageId) {
// message IDs are allowed to contain anchors, spans, keyboard and emphasis tags
// we can recognize all of them by only checking for anchors and keyboard tags
return typeof messageId === 'string' && (messageId.indexOf(' 0) {
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
).catch(Alert.showError);
// 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
).catch(Alert.showError);
}
/**
* 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) {
let zlib = (await z);
// AES in Galois Counter Mode, keysize 256 bit,
// authentication tag 128 bit, 10000 iterations in key derivation
const compression = (
typeof zlib === 'undefined' ?
'none' : // client lacks support for WASM
(document.body.dataset.compression || 'zlib')
),
spec = [
getRandomBytes(16), // initialization vector
getRandomBytes(8), // salt
100000, // iterations
256, // key size
128, // tag size
'aes', // algorithm
'gcm', // algorithm mode
compression // 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, compression, zlib)
).catch(Alert.showError)
)
),
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, spec, cipherMessage, plaintext;
let zlib = (await z);
if (data instanceof Array) {
// version 2
adataString = JSON.stringify(data[1]);
// clone the array instead of passing the reference
spec = (data[1][0] instanceof Array ? data[1][0] : data[1]).slice();
cipherMessage = data[0];
} else {
throw 'unsupported message format';
}
spec[0] = atob(spec[0]);
spec[1] = atob(spec[1]);
if (spec[7] === 'zlib') {
if (typeof zlib === 'undefined') {
throw 'Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.';
}
}
try {
plaintext = await window.crypto.subtle.decrypt(
cryptoSettings(adataString, spec),
await deriveKey(key, password, spec),
stringToArraybuffer(
atob(cipherMessage)
)
);
} catch (err) {
console.error(err);
return '';
}
try {
return await decompress(plaintext, spec[7], zlib);
} catch (err) {
Alert.showError(err);
return err;
}
};
/**
* 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} raw bytes
*/
me.getSymmetricKey = function () {
return getRandomBytes(32);
};
/**
* base58 encode a DOMString (UTF-16)
*
* @name CryptTool.base58encode
* @function
* @param {string} input
* @return {string} output
*/
me.base58encode = function (input) {
return base58.encode(
stringToArraybuffer(input)
);
}
/**
* base58 decode a DOMString (UTF-16)
*
* @name CryptTool.base58decode
* @function
* @param {string} input
* @return {string} output
*/
me.base58decode = function (input) {
return arraybufferToString(
base58.decode(input)
);
}
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 () {
const element = document.getElementById('pasteExpiration');
return element ? element.value : '';
};
/**
* returns the format set in the HTML
*
* @name Model.getFormatDefault
* @function
* @return string
*/
me.getFormatDefault = function () {
const element = document.getElementById('pasteFormatter');
return element ? element.value : '';
};
/**
* returns the document 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() + '?pasteid=' + me.getPasteId());
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showViewButtons();
// show error message
Alert.showError(ServerInteraction.parseUploadError(status, data, 'get document data'));
});
ServerInteraction.setSuccess(function (status, data) {
pasteData = new Paste(data);
if (typeof callback === 'function') {
return callback(pasteData);
}
});
ServerInteraction.run();
};
/**
* get the documents 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 () {
const idRegEx = /^[a-z0-9]{16}$/;
// return cached value
if (id !== null) {
return id;
}
// do use URL interface, if possible
const url = new URL(window.location);
for (const param of url.searchParams) {
const key = param[0];
const value = param[1];
if (value === '' && idRegEx.test(key)) {
// safe, as the whole regex is matched
id = key;
return key;
}
}
if (id === null) {
throw 'no document id given';
}
return id;
}
/**
* returns true, when the URL has a delete token and the current call was used for deleting a document.
*
* @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 startPos = 1;
if (window.location.hash.startsWith(loadConfirmPrefix)) {
startPos = loadConfirmPrefix.length;
}
let newKey = window.location.hash.substring(startPos);
// 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);
}
if (newKey === '') {
throw 'no encryption key given';
}
// version 2 uses base58, version 1 uses base64 without decoding
try {
// base58 encode strips NULL bytes at the beginning of the
// string, so we re-add them if necessary
symmetricKey = CryptTool.base58decode(newKey).padStart(32, '\u0000');
} catch (e) {
throw 'encryption key of unsupported format given or incomplete, mangled URL';
}
}
return symmetricKey;
};
/**
* returns a copy of the HTML template
*
* @name Model.getTemplate
* @function
* @param {string} name - the name of the template
* @return {HTMLElement}
*/
me.getTemplate = function (name) {
// find template
const template = templates.querySelector('#' + name + 'template');
if (!template) {
return null;
}
let element = template.cloneNode(true);
// change ID to avoid collisions (one ID should really be unique)
element.id = name;
return element;
};
/**
* resets state, used for unit testing
*
* @name Model.reset
* @function
*/
me.reset = function () {
pasteData = templates = id = symmetricKey = null;
};
/**
* init navigation manager
*
* preloads elements
*
* @name Model.init
* @function
*/
me.init = function () {
templates = document.getElementById('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 {HTMLElement} element The link hash to move to.
*/
me.isVisible = function (element) {
let elementTop = element.getBoundingClientRect().top + window.scrollY,
viewportTop = window.scrollY,
viewportBottom = viewportTop + window.innerHeight;
return elementTop > viewportTop && elementTop < viewportBottom;
};
/**
* scrolls to a specific element
*
* @name UiHelper.scrollTo
* @function
* @param {HTMLElement} element The link hash to move to.
* @param {(number|string)} animationDuration when set to 0 the animation is skipped
* @param {string} animationEffect ignored
* @param {function} finishedCallback function to call after animation finished
*/
me.scrollTo = function (element, animationDuration, animationEffect, finishedCallback) {
let margin = 50,
dest = 0;
// calculate destination place
let elementTop = element.getBoundingClientRect().top + window.scrollY;
// if it would scroll out of the screen at the bottom only scroll it as
// far as the screen can go
if (elementTop > document.documentElement.scrollHeight - window.innerHeight) {
dest = document.documentElement.scrollHeight - window.innerHeight;
} else {
dest = elementTop - margin;
}
// scroll to destination
window.scrollTo({
top: dest,
behavior: animationDuration === 0 ? 'auto' : 'smooth'
});
// call callback
if (typeof finishedCallback !== 'undefined') {
if (animationDuration === 0) {
finishedCallback();
} else {
// approximate time for smooth scroll
setTimeout(finishedCallback, 500);
}
}
};
/**
* 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({ originalEvent: new PopStateEvent('popstate', { state: state }), target: window });
};
/**
* initialize
*
* @name UiHelper.init
* @function
*/
me.init = function () {
// update link to home page
document.querySelectorAll('.reloadlink').forEach(link => link.href = Helper.baseUri());
window.addEventListener('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', // warning icon
'danger' // error icon
];
/**
* forwards a request to the i18n module and shows the element
*
* @name Alert.handleNotification
* @private
* @function
* @param {number} id - id of notification
* @param {HTMLElement} element - HTML element
* @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];
} else if (args instanceof Error) {
// extract message into array if needed
args = [args.message];
}
// 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 HTMLElement) {
// continue processing with new element
element = handlerResult;
icon = null; // icons not supported in this case
}
}
let translationTarget = element;
// handle icon, if template uses one
const glyphIcon = element.querySelector(':first-child');
if (glyphIcon) {
// if there is an icon, we need to provide an inner element
// to translate the message into, instead of the parent
translationTarget = document.createElement('span');
element.innerHTML = ' ';
element.prepend(glyphIcon);
element.appendChild(translationTarget);
if (icon !== null && // icon was passed
icon !== currentIcon[id] // and it differs from current icon
) {
// remove (previous) icon
glyphIcon.classList.remove(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.classList.add(currentIcon[id]);
}
}
}
// translate and insert message
I18n._(translationTarget, ...args);
// show element
element.classList.remove('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 a warning message
*
* This automatically passes the text to I18n for translation.
*
* @name Alert.showWarning
* @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.showWarning = function (message, icon) {
const glyphIcon = errorMessage.querySelector(':first-child');
if (glyphIcon) {
glyphIcon.classList.remove(currentIcon[3]);
glyphIcon.classList.add(currentIcon[2]);
}
handleNotification(2, errorMessage, 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)
document.body.classList.add('loading');
};
/**
* hides the loading message
*
* @name Alert.hideLoading
* @function
*/
me.hideLoading = function () {
loadingIndicator.classList.add('hidden');
// hide loading cursor
document.body.classList.remove('loading');
};
/**
* hides any status/error messages
*
* This does not include the loading message.
*
* @name Alert.hideMessages
* @function
*/
me.hideMessages = function () {
statusMessage.classList.add('hidden');
errorMessage.classList.add('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 (guard in test environments)
const noscriptEl = document.getElementById('noscript');
if (noscriptEl) {
noscriptEl.style.display = 'none';
}
// not a reset, but first set of the elements
errorMessage = document.getElementById('errormessage');
loadingIndicator = document.getElementById('loadingindicator');
statusMessage = document.getElementById('status');
remainingTime = document.getElementById('remainingtime');
currentIcon = [
'glyphicon-time', // loading icon
'glyphicon-info-sign', // status icon
'glyphicon-warning-sign', // warning icon
'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() {
if (shortenButton.classList.contains('buttondisabled')) {
return;
}
fetch(`${shortenButton.dataset.shortener}${encodeURIComponent(pasteUrl.href)}`, {
method: 'GET',
headers: { 'Accept': 'text/html, application/xhtml+xml, application/xml, application/json' },
credentials: 'omit',
signal: AbortSignal.timeout(10000)
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.text();
})
.then(data => PasteStatus.extractUrl(data))
.catch(error => {
console.error('Shortener error:', error);
// we don't know why it failed, could be CORS of the external
// server not setup properly, in which case we follow old
// behavior to open it in new tab
window.open(
`${shortenButton.dataset.shortener}${encodeURIComponent(pasteUrl.href)}`,
'_blank',
'noopener, noreferrer'
);
});
}
/**
* Forces opening the document 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.href) {
// if so we need to load link by reloading the current site
window.location.reload(true);
}
}
/**
* creates a notification after a successful document upload
*
* @name PasteStatus.createPasteNotification
* @function
* @param {string} url
* @param {string} deleteUrl
*/
me.createPasteNotification = function (url, deleteUrl) {
I18n._(
document.getElementById('pastelink'),
'Your document is %s (Hit Ctrl+c to copy)',
url, url
);
// save newly created element
pasteUrl = document.getElementById('pasteurl');
// and add click event
pasteUrl.addEventListener('click', pasteLinkClick);
// delete link
const deleteLink = document.getElementById('deletelink');
if (deleteLink) {
deleteLink.href = deleteUrl;
}
I18n._(document.querySelector('#deletelink span:not(.glyphicon)'), 'Delete data');
// enable shortener button
if (shortenButton) {
shortenButton.classList.remove('buttondisabled');
}
// show result
pasteSuccess.classList.remove('hidden');
// we pre-select the link so that the user only has to [Ctrl]+[c] the link
Helper.selectText(pasteUrl);
};
/**
* Checks if auto-shortening is enabled and sends the link to the shortener if it is.
*
* @name PasteStatus.checkAutoShorten
* @function
*/
me.checkAutoShorten = function () {
// check if auto-shortening is enabled
if (shortenButton && shortenButton.dataset.autoshorten === 'true') {
// if so, we send the link to the shortener
// we do not remove the button, in case shortener fails
sendToShortener();
}
}
/**
* extracts URLs from given string
*
* if at least one is found, it disables the shortener button and
* replaces the document URL
*
* @name PasteStatus.extractUrl
* @function
* @param {string} response
*/
me.extractUrl = function (response) {
if (typeof response === 'object') {
response = JSON.stringify(response);
}
if (typeof response === 'string' && response.length > 0) {
const shortUrlMatcher = /https?:\/\/[^\s"<]+/g; // JSON API will have URL in quotes, XML in tags
const shortUrl = (response.match(shortUrlMatcher) || []).filter(function (urlRegExMatch) {
if (typeof URL.canParse === 'function') {
return URL.canParse(urlRegExMatch);
}
// polyfill for older browsers (< 120) & node (< 19.9 & < 18.17)
try {
return !!new URL(urlRegExMatch);
} catch (error) {
return false;
}
}).sort(function (a, b) {
return a.length - b.length; // shortest first
})[0];
if (typeof shortUrl === 'string' && shortUrl.length > 0) {
// we disable the button to avoid calling shortener again
shortenButton.classList.add('buttondisabled');
// update link
pasteUrl.textContent = shortUrl;
pasteUrl.href = shortUrl;
// we pre-select the link so that the user only has to [Ctrl]+[c] the link
Helper.selectText(pasteUrl);
CopyToClipboard.setUrl(shortUrl);
return;
}
}
Alert.showError('Cannot parse response from URL shortener.');
};
/**
* shows the remaining time
*
* @name PasteStatus.showRemainingTime
* @function
* @param {Paste} paste
*/
me.showRemainingTime = function (paste) {
if (paste.isBurnAfterReadingEnabled()) {
// display document "for your eyes only" if it is deleted
// the document 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.classList.add('foryoureyesonly');
} else if (paste.getTimeToLive() > 0) {
// display document expiration
let expiration = Helper.secondsToHuman(paste.getTimeToLive()),
expirationLabel = [
'This document will expire in %d ' + expiration[1] + '.',
'This document will expire in %d ' + expiration[1] + 's.'
];
Alert.showRemaining([expirationLabel, expiration[0]]);
remainingTime.classList.remove('foryoureyesonly');
} else {
// never expires
return;
}
// in the end, display notification
remainingTime.classList.remove('hidden');
};
/**
* hides the remaining time and successful upload notification
*
* @name PasteStatus.hideMessages
* @function
*/
me.hideMessages = function () {
remainingTime.classList.add('hidden');
pasteSuccess.classList.add('hidden');
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name PasteStatus.init
* @function
*/
me.init = function () {
pasteSuccess = document.getElementById('pastesuccess');
// pasteUrl is saved in me.createPasteNotification() after creation
remainingTime = document.getElementById('remainingtime');
shortenButton = document.getElementById('shortenbutton');
// bind elements
if (shortenButton) {
shortenButton.addEventListener('click', sendToShortener);
}
};
return me;
})();
/**
* password prompt
*
* @name Prompt
* @class
*/
const Prompt = (function () {
const me = {};
let passwordDecrypt,
passwordModal,
bootstrap5PasswordModal = null,
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.value;
// hide modal
if (bootstrap5PasswordModal) {
bootstrap5PasswordModal.hide();
} else {
if (passwordModal) {
passwordModal.classList.remove('show');
passwordModal.style.display = 'none';
}
}
PasteDecrypter.run();
}
/**
* Request users confirmation to load possibly burn after reading document
*
* @name Prompt.requestLoadConfirmation
* @function
*/
me.requestLoadConfirmation = function () {
const loadconfirmmodal = document.getElementById('loadconfirmmodal');
const loadconfirmOpenNow = loadconfirmmodal.querySelector('#loadconfirm-open-now');
loadconfirmOpenNow.removeEventListener('click', PasteDecrypter.run);
loadconfirmOpenNow.addEventListener('click', PasteDecrypter.run);
const loadconfirmClose = loadconfirmmodal.querySelector('.close');
loadconfirmClose.removeEventListener('click', Controller.newPaste);
loadconfirmClose.addEventListener('click', Controller.newPaste);
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip && typeof bootstrap.Modal === 'function') {
(new bootstrap.Modal(loadconfirmmodal)).show();
} else {
// minimal fallback: make element visible
loadconfirmmodal.classList.add('show');
}
}
/**
* ask the user for the password and set it
*
* @name Prompt.requestPassword
* @function
*/
me.requestPassword = function () {
// show new bootstrap method (if available)
if (passwordModal !== null) {
if (bootstrap5PasswordModal) {
bootstrap5PasswordModal.show();
}
return;
}
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.value = '';
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name Prompt.init
* @function
*/
me.init = function () {
passwordDecrypt = document.getElementById('passworddecrypt');
passwordModal = document.getElementById('passwordmodal');
// bind events - handle Model password submission
if (passwordModal) {
const passwordForm = document.getElementById('passwordform');
if (passwordForm) {
passwordForm.addEventListener('submit', submitPasswordModal);
}
const disableClosingConfig = {
backdrop: 'static',
keyboard: false,
show: false
};
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip && typeof bootstrap.Modal === 'function') {
bootstrap5PasswordModal = new bootstrap.Modal(passwordModal, disableClosingConfig);
} else {
// fallback: keep modal hidden until explicitly shown
passwordModal.style.display = 'none';
}
// ensure focus when modal shown (works for bootstrap5)
passwordModal.addEventListener('shown.bs.modal', () => {
passwordDecrypt.focus();
});
}
};
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,
messageEditParent,
messagePreview,
messagePreviewParent,
messageTab,
messageTabParent,
message,
isPreview = false,
isTabSupported = true;
/**
* 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) {
// support disabling tab support using [Esc] and [Ctrl]+[m]
if (event.key === 'Escape' || (event.ctrlKey && event.key === 'm')) {
toggleTabSupport();
if (messageTab) messageTab.checked = isTabSupported;
event.preventDefault();
}
else if (isTabSupported && event.key === 'Tab') {
// 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();
}
}
/**
* toggle tab support in message textarea
*
* @name Editor.toggleTabSupport
* @private
* @function
*/
function toggleTabSupport() {
isTabSupported = !isTabSupported;
}
/**
* view the Editor tab
*
* @name Editor.viewEditor
* @function
* @param {Event} event - optional
*/
function viewEditor(event) {
// toggle buttons
messageEdit.classList.add('active');
messageEditParent.classList.add('active');
messagePreview.classList.remove('active');
messagePreviewParent.classList.remove('active');
messageEdit.setAttribute('aria-selected', 'true');
messagePreview.setAttribute('aria-selected', 'false');
PasteViewer.hide();
// reshow input
message.classList.remove('hidden');
messageTabParent.classList.remove('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.classList.remove('active');
messageEditParent.classList.remove('active');
messagePreview.classList.add('active');
messagePreviewParent.classList.add('active');
messageEdit.setAttribute('aria-selected', 'false');
messagePreview.setAttribute('aria-selected', 'true');
// hide input as now preview is shown
message.classList.add('hidden');
messageTabParent.classList.add('hidden');
// show preview
PasteViewer.setText(message.value);
if (AttachmentViewer.hasAttachmentData()) {
const attachmentsData = AttachmentViewer.getAttachmentsData();
attachmentsData.forEach(attachmentData => {
const mimeType = AttachmentViewer.getAttachmentMimeType(attachmentData);
AttachmentViewer.handleBlobAttachmentPreview(
AttachmentViewer.getAttachmentPreview(),
attachmentData, mimeType
);
});
AttachmentViewer.showAttachment();
}
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.value = '';
};
/**
* shows the Editor
*
* @name Editor.show
* @function
*/
me.show = function () {
message.classList.remove('hidden');
messageTabParent.classList.remove('hidden');
editorTabs.classList.remove('hidden');
};
/**
* hides the Editor
*
* @name Editor.hide
* @function
*/
me.hide = function () {
message.classList.add('hidden');
messageTabParent.classList.add('hidden');
editorTabs.classList.add('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.value = newText;
};
/**
* returns the current text
*
* @name Editor.getText
* @function
* @return {string}
*/
me.getText = function () {
return message.value;
};
/**
* init editor
*
* preloads jQuery elements
*
* @name Editor.init
* @function
*/
me.init = function () {
editorTabs = document.getElementById('editorTabs');
message = document.getElementById('message');
messageTab = document.getElementById('messagetab');
messageTabParent = messageTab ? messageTab.parentElement : null;
// bind events
if (message) {
message.addEventListener('keydown', supportTabs);
}
if (messageTab) {
messageTab.addEventListener('change', toggleTabSupport);
}
// bind click events to tab switchers (a), and save parents (li)
messageEdit = document.getElementById('messageedit');
if (messageEdit) {
messageEdit.addEventListener('click', viewEditor);
messageEditParent = messageEdit.parentElement;
}
messagePreview = document.getElementById('messagepreview');
if (messagePreview) {
messagePreview.addEventListener('click', viewPreview);
messagePreviewParent = messagePreview.parentElement;
}
};
return me;
})();
/**
* (view) Parse and show document.
*
* @name PasteViewer
* @class
*/
const PasteViewer = (function () {
const me = {};
let messageTabParent,
placeholder,
prettyMessage,
prettyPrintEl,
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;
}
if (format === '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.innerHTML = DOMPurify.sanitize(
converter.makeHtml(text),
purifyHtmlConfig
);
// add table classes from bootstrap css
plainText.querySelectorAll('table').forEach(t => {
t.classList.add('table-condensed', 'table-bordered');
});
} else {
if (format === 'syntaxhighlighting') {
// yes, this is really needed to initialize the environment
if (typeof prettyPrint === 'function') {
prettyPrint();
}
prettyPrintEl.innerHTML = prettyPrintOne(
Helper.htmlEntities(text), null, true
);
} else {
// = 'plaintext'
prettyPrintEl.textContent = text;
}
Helper.urls2links(prettyPrintEl);
prettyPrintEl.style.whiteSpace = 'pre-wrap';
prettyPrintEl.style.wordBreak = 'normal';
prettyPrintEl.classList.remove('prettyprint');
}
}
/**
* displays the document
*
* @name PasteViewer.showPaste
* @private
* @function
*/
function showPaste() {
// instead of "nothing" better display a placeholder
if (text === '') {
if (placeholder) placeholder.classList.remove('hidden');
return;
}
// otherwise hide the placeholder
if (placeholder) placeholder.classList.add('hidden');
if (messageTabParent) messageTabParent.classList.add('hidden');
if (format === 'markdown') {
if (plainText) plainText.classList.remove('hidden');
if (prettyMessage) prettyMessage.classList.add('hidden');
} else {
if (plainText) plainText.classList.add('hidden');
if (prettyMessage) prettyMessage.classList.remove('hidden');
}
}
/**
* 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;
// update preview
if (Editor.isPreview()) {
PasteViewer.run();
}
};
/**
* 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;
}
if (plainText) plainText.classList.add('hidden');
if (prettyMessage) prettyMessage.classList.add('hidden');
if (placeholder) placeholder.classList.add('hidden');
AttachmentViewer.hideAttachmentPreview();
isDisplayed = false;
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name PasteViewer.init
* @function
*/
me.init = function () {
const messagetab = document.getElementById('messagetab');
messageTabParent = messagetab ? messagetab.parentElement : null;
placeholder = document.getElementById('placeholder');
plainText = document.getElementById('plaintext');
prettyMessage = document.getElementById('prettymessage');
prettyPrintEl = document.getElementById('prettyprint');
// 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 attachmentPreview,
attachment,
attachmentsData = [],
files,
fileInput,
dragAndDropFileNames,
dropzone;
/**
* get blob URL from string data and mime type
*
* @name AttachmentViewer.getBlobUrl
* @private
* @function
* @param {string} data - raw data of attachment
* @param {string} data - mime type of attachment
* @return {string} objectURL
*/
function getBlobUrl(data, mimeType) {
// Transform into a Blob
const buf = new Uint8Array(data.length);
for (let i = 0; i < data.length; ++i) {
buf[i] = data.charCodeAt(i);
}
const blob = new window.Blob(
[buf],
{
type: mimeType
}
);
// Get blob URL
return window.URL.createObjectURL(blob);
}
/**
* 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) {
// skip, if attachments got disabled
if (!attachment || !attachmentPreview) return;
// data URI format: data:[' +
DOMPurify.sanitize(
Helper.htmlEntities(paste),
purifyHtmlConfig
) +
''
);
newDoc.close();
}
/**
* download text
*
* @name TopNav.downloadText
* @private
* @function
*/
function downloadText() {
const fileFormat = PasteViewer.getFormat() === 'markdown' ? '.md' : '.txt';
const filename = 'document-' + Model.getPasteId() + fileFormat;
const text = PasteViewer.getText();
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
/**
* saves the language in a cookie and reloads the page
*
* @name TopNav.setLanguage
* @private
* @function
* @param {Event} event
*/
function setLanguage(event) {
let lang = event.target.getAttribute('data-lang') || event.target.value;
document.cookie = 'lang=' + lang + '; SameSite=Lax; Secure';
window.location.reload();
event.preventDefault();
}
/**
* save the template in a cookie and reloads the page
*
* @name TopNav.setTemplate
* @private
* @function
* @param {Event} event
*/
function setTemplate(event) {
let template = event.target.getAttribute('data-template') || event.target.value;
document.cookie = 'template=' + template + '; SameSite=Lax; Secure';
window.location.reload();
event.preventDefault();
}
/**
* hides all messages and creates a new document
*
* @name TopNav.clickNewPaste
* @private
* @function
*/
function clickNewPaste() {
Controller.hideStatusMessages();
Controller.newPaste();
}
/**
* retrys some callback registered before
*
* @name TopNav.clickRetryButton
* @private
* @function
* @param {Event} event
*/
function clickRetryButton(event) {
retryButtonCallback(event);
}
/**
* removes the existing attachment
*
* @name TopNav.removeAttachment
* @private
* @function
* @param {Event} event
*/
function removeAttachment(event) {
// if custom attachment is used, remove it first
if (customAttachment && !customAttachment.classList.contains('hidden')) {
AttachmentViewer.removeAttachment();
customAttachment.classList.add('hidden');
fileWrap.classList.remove('hidden');
}
// in any case, remove saved attachment data
AttachmentViewer.removeAttachmentData();
clearAttachmentInput();
AttachmentViewer.clearDragAndDrop();
// pevent '#' from appearing in the URL
event.preventDefault();
}
/**
* Shows the QR code of the current document (URL).
*
* @name TopNav.displayQrCode
* @private
* @function
*/
function displayQrCode() {
const qrCanvas = kjua({
render: 'canvas',
text: window.location.href
});
const qrDisplay = document.getElementById('qrcode-display');
if (qrDisplay) {
qrDisplay.innerHTML = '';
qrDisplay.appendChild(qrCanvas);
}
}
/**
* Template Email body.
*
* @name TopNav.templateEmailBody
* @private
* @param {string} expirationDateString
* @param {bool} isBurnafterreading
*/
function templateEmailBody(expirationDateString, isBurnafterreading) {
const EOL = '\n';
const BULLET = ' - ';
let emailBody = '';
if (expirationDateString !== null || isBurnafterreading) {
emailBody += I18n._('Notice:');
emailBody += EOL;
if (expirationDateString !== null) {
emailBody += EOL;
emailBody += BULLET;
// avoid DOMPurify mess with forward slash in expirationDateString
emailBody += Helper.sprintf(
I18n._(
'This link will expire after %s.',
'%s'
),
expirationDateString
);
}
if (isBurnafterreading) {
emailBody += EOL;
emailBody += BULLET;
emailBody += I18n._(
'This link can only be accessed once, do not use back or refresh button in your browser.'
);
}
emailBody += EOL;
emailBody += EOL;
}
emailBody += I18n._('Link:');
emailBody += EOL;
const pasteUrlElement = document.getElementById('pasteurl');
emailBody += (pasteUrlElement && pasteUrlElement.getAttribute('href')) || window.location.href; // href is tried first as it might have been shortened
return emailBody;
}
/**
* Trigger Email send.
*
* @name TopNav.triggerEmailSend
* @private
* @param {string} emailBody
*/
function triggerEmailSend(emailBody) {
window.open(
`mailto:?body=${encodeURIComponent(emailBody)}`,
'_self',
'noopener, noreferrer'
);
}
/**
* Send Email with current document (URL).
*
* @name TopNav.sendEmail
* @private
* @function
* @param {Date|null} expirationDate date of expiration
* @param {bool} isBurnafterreading whether it is burn after reading
*/
function sendEmail(expirationDate, isBurnafterreading) {
const expirationDateRoundedToSecond = new Date(expirationDate);
// round down at least 30 seconds to make up for the delay of request
expirationDateRoundedToSecond.setUTCSeconds(
expirationDateRoundedToSecond.getUTCSeconds() - 30
);
expirationDateRoundedToSecond.setUTCSeconds(0);
const emailconfirmmodal = document.getElementById('emailconfirmmodal');
if (expirationDate !== null) {
const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
const emailconfirmTimezoneUtc = emailconfirmmodal.querySelector('#emailconfirm-timezone-utc');
let localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
const bootstrap5EmailConfirmModal = typeof bootstrap !== 'undefined' && bootstrap.Tooltip && bootstrap.Tooltip.VERSION ?
new bootstrap.Modal(emailconfirmmodal) : null;
function sendEmailAndHideModal() {
const emailBody = templateEmailBody(
// we don't use Date.prototype.toUTCString() because we would like to avoid GMT
expirationDateRoundedToSecond.toLocaleString(
[], localeConfiguration
), isBurnafterreading
);
if (bootstrap5EmailConfirmModal) {
bootstrap5EmailConfirmModal.hide();
}
triggerEmailSend(emailBody);
}
emailconfirmTimezoneCurrent.removeEventListener('click', sendEmailAndHideModal);
emailconfirmTimezoneCurrent.addEventListener('click', sendEmailAndHideModal);
emailconfirmTimezoneUtc.removeEventListener('click', sendEmailAndHideModal);
emailconfirmTimezoneUtc.addEventListener('click', () => {
localeConfiguration.timeZone = 'UTC';
sendEmailAndHideModal();
});
if (bootstrap5EmailConfirmModal) {
bootstrap5EmailConfirmModal.show();
}
} else {
triggerEmailSend(templateEmailBody(null, isBurnafterreading));
}
}
/**
* Shows all navigation elements for viewing an existing document
*
* @name TopNav.showViewButtons
* @function
*/
me.showViewButtons = function () {
if (viewButtonsDisplayed) {
return;
}
newButton.classList.remove('hidden');
cloneButton.classList.remove('hidden');
rawTextButton.classList.remove('hidden');
downloadTextButton.classList.remove('hidden');
qrCodeLink.classList.remove('hidden');
viewButtonsDisplayed = true;
};
/**
* Hides all navigation elements for viewing an existing document
*
* @name TopNav.hideViewButtons
* @function
*/
me.hideViewButtons = function () {
if (!viewButtonsDisplayed) {
return;
}
cloneButton.classList.add('hidden');
newButton.classList.add('hidden');
rawTextButton.classList.add('hidden');
downloadTextButton.classList.add('hidden');
qrCodeLink.classList.add('hidden');
me.hideEmailButton();
viewButtonsDisplayed = false;
};
/**
* Hides all elements belonging to existing documents
*
* @name TopNav.hideAllButtons
* @function
*/
me.hideAllButtons = function () {
me.hideViewButtons();
me.hideCreateButtons();
};
/**
* shows all elements needed when creating a new document
*
* @name TopNav.showCreateButtons
* @function
*/
me.showCreateButtons = function () {
if (createButtonsDisplayed) {
return;
}
if (attach) {
attach.classList.remove('hidden');
}
burnAfterReadingOption.classList.remove('hidden');
expiration.classList.remove('hidden');
formatter.classList.remove('hidden');
newButton.classList.remove('hidden');
openDiscussionOption.classList.remove('hidden');
password.classList.remove('hidden');
sendButton.classList.remove('hidden');
createButtonsDisplayed = true;
};
/**
* shows all elements needed when creating a new document
*
* @name TopNav.hideCreateButtons
* @function
*/
me.hideCreateButtons = function () {
if (!createButtonsDisplayed) {
return;
}
newButton.classList.add('hidden');
sendButton.classList.add('hidden');
expiration.classList.add('hidden');
formatter.classList.add('hidden');
burnAfterReadingOption.classList.add('hidden');
openDiscussionOption.classList.add('hidden');
password.classList.add('hidden');
if (attach) {
attach.classList.add('hidden');
}
createButtonsDisplayed = false;
};
/**
* only shows the "new document" button
*
* @name TopNav.showNewPasteButton
* @function
*/
me.showNewPasteButton = function () {
newButton.classList.remove('hidden');
};
/**
* only shows the "retry" button
*
* @name TopNav.showRetryButton
* @function
*/
me.showRetryButton = function () {
retryButton.classList.remove('hidden');
}
/**
* hides the "retry" button
*
* @name TopNav.hideRetryButton
* @function
*/
me.hideRetryButton = function () {
retryButton.classList.add('hidden');
}
/**
* show the "email" button
*
* @name TopNav.showEmailbutton
* @function
* @param {int|undefined} optionalRemainingTimeInSeconds
*/
me.showEmailButton = function (optionalRemainingTimeInSeconds) {
try {
// we cache expiration date in closure to avoid inaccurate expiration datetime
const expirationDate = Helper.calculateExpirationDate(
new Date(),
typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
);
const isBurnafterreading = TopNav.getBurnAfterReading();
emailLink.classList.remove('hidden');
emailLink.removeEventListener('click', sendEmail);
emailLink.addEventListener('click', () => {
sendEmail(expirationDate, isBurnafterreading);
});
} catch (error) {
console.error(error);
Alert.showError('Cannot calculate expiration date.');
}
}
/**
* hide the "email" button
*
* @name TopNav.hideEmailButton
* @function
*/
me.hideEmailButton = function () {
emailLink.classList.add('hidden');
emailLink.removeEventListener('click', sendEmail);
}
/**
* only hides the clone button
*
* @name TopNav.hideCloneButton
* @function
*/
me.hideCloneButton = function () {
cloneButton.classList.add('hidden');
};
/**
* only hides the raw text button
*
* @name TopNav.hideRawButton
* @function
*/
me.hideRawButton = function () {
rawTextButton.classList.add('hidden');
};
/**
* only hides the download text button
*
* @name TopNav.hideRawButton
* @function
*/
me.hideDownloadButton = function () {
downloadTextButton.classList.add('hidden');
};
/**
* only hides the qr code button
*
* @name TopNav.hideQrCodeButton
* @function
*/
me.hideQrCodeButton = function () {
qrCodeLink.classList.add('hidden');
}
/**
* hide irrelevant buttons when viewing burn after reading document
*
* @name TopNav.hideBurnAfterReadingButtons
* @function
*/
me.hideBurnAfterReadingButtons = function () {
me.hideCloneButton();
me.hideQrCodeButton();
me.hideEmailButton();
}
/**
* hides the file selector in attachment
*
* @name TopNav.hideFileSelector
* @function
*/
me.hideFileSelector = function () {
fileWrap.classList.add('hidden');
};
/**
* shows the custom attachment
*
* @name TopNav.showCustomAttachment
* @function
*/
me.showCustomAttachment = function () {
if (!customAttachment) {
return;
}
customAttachment.classList.remove('hidden');
};
/**
* hides the custom attachment
*
* @name TopNav.hideCustomAttachment
* @function
*/
me.hideCustomAttachment = function () {
if (customAttachment) {
customAttachment.classList.add('hidden');
}
if (fileWrap) {
fileWrap.classList.remove('hidden');
}
};
/**
* collapses the navigation bar, only if expanded
*
* @name TopNav.collapseBar
* @function
*/
me.collapseBar = function () {
const navbar = document.getElementById('navbar');
if (navbar && navbar.getAttribute('aria-expanded') === 'true') {
const toggle = document.querySelector('.navbar-toggle');
if (toggle) {
toggle.click();
}
}
};
/**
* Reset the top navigation back to it's default values.
*
* @name TopNav.resetInput
* @function
*/
me.resetInput = function () {
clearAttachmentInput();
clearPasswordInput();
if (burnAfterReading) {
burnAfterReading.checked = burnAfterReadingDefault;
}
if (openDiscussion) {
openDiscussion.checked = openDiscussionDefault;
}
if (openDiscussionDefault || !burnAfterReadingDefault) {
openDiscussionOption.classList.remove('buttondisabled');
}
if (burnAfterReadingDefault || !openDiscussionDefault) {
burnAfterReadingOption.classList.remove('buttondisabled');
}
pasteExpiration = Model.getExpirationDefault() || pasteExpiration;
const pasteExpirationSelect = document.getElementById('pasteExpiration');
if (pasteExpirationSelect) {
pasteExpirationSelect.querySelectorAll('option').forEach((option) => {
if (option.value === pasteExpiration) {
const display = document.getElementById('pasteExpirationDisplay');
if (display) {
display.textContent = option.textContent;
}
}
});
}
};
/**
* returns the currently set expiration time
*
* @name TopNav.getExpiration
* @function
* @return {int}
*/
me.getExpiration = function () {
return pasteExpiration;
};
/**
* returns the currently selected file(s)
*
* @name TopNav.getFileList
* @function
* @return {FileList|null}
*/
me.getFileList = function () {
const file = document.getElementById('file');
// if no file given, return null
if (!file || !file.files || !file.files.length) {
return null;
}
// ensure the selected file is still accessible
if (!file.files[0]) {
return null;
}
return file.files;
};
/**
* returns the state of the burn after reading checkbox
*
* @name TopNav.getBurnAfterReading
* @function
* @return {bool}
*/
me.getBurnAfterReading = function () {
return burnAfterReading.checked;
};
/**
* returns the state of the discussion checkbox
*
* @name TopNav.getOpenDiscussion
* @function
* @return {bool}
*/
me.getOpenDiscussion = function () {
return openDiscussion.checked;
};
/**
* returns the entered password
*
* @name TopNav.getPassword
* @function
* @return {string}
*/
me.getPassword = function () {
// when password is disabled passwordInput.value will return undefined
return passwordInput && passwordInput.value || '';
};
/**
* returns the element where custom attachments can be placed
*
* Used by AttachmentViewer when an attachment is cloned here.
*
* @name TopNav.getCustomAttachment
* @function
* @return {HTMLElement}
*/
me.getCustomAttachment = function () {
return customAttachment;
};
/**
* Set a function to call when the retry button is clicked.
*
* @name TopNav.setRetryCallback
* @function
* @param {function} callback
*/
me.setRetryCallback = function (callback) {
retryButtonCallback = callback;
}
/**
* Highlight file upload
*
* @name TopNav.highlightFileupload
* @function
*/
me.highlightFileupload = function () {
// visually indicate file uploaded
const attachDropdownToggle = attach && attach.querySelector('.dropdown-toggle');
if (attachDropdownToggle && attachDropdownToggle.getAttribute('aria-expanded') === 'false') {
if (Helper.isBootstrap5()) {
new bootstrap.Dropdown(attachDropdownToggle).toggle();
} else {
attachDropdownToggle.click();
}
}
fileWrap.classList.add('highlight');
setTimeout(function () {
fileWrap.classList.remove('highlight');
}, 300);
}
/**
* set the format on bootstrap templates in dropdown programmatically
*
* @name TopNav.setFormat
* @function
*/
me.setFormat = function (format) {
if (Helper.isBootstrap5()) {
const select = formatter.querySelector('select');
if (select) {
select.value = format;
}
} else {
const formatLink = formatter.parentElement.querySelector(`a[data-format="${format}"]`);
if (formatLink) {
formatLink.click();
}
}
}
/**
* returns if attachment dropdown is readonly, not editable
*
* @name TopNav.isAttachmentReadonly
* @function
* @return {bool}
*/
me.isAttachmentReadonly = function () {
return !createButtonsDisplayed || (attach && attach.classList.contains('hidden'));
}
/**
* init navigation manager
*
* preloads DOM elements
*
* @name TopNav.init
* @function
*/
me.init = function () {
attach = document.getElementById('attach');
burnAfterReading = document.getElementById('burnafterreading');
burnAfterReadingOption = document.getElementById('burnafterreadingoption');
cloneButton = document.getElementById('clonebutton');
customAttachment = document.getElementById('customattachment');
expiration = document.getElementById('expiration');
fileRemoveButton = document.getElementById('fileremovebutton');
fileWrap = document.getElementById('filewrap');
formatter = document.getElementById('formatter');
newButton = document.getElementById('newbutton');
openDiscussion = document.getElementById('opendiscussion');
openDiscussionOption = document.getElementById('opendiscussionoption');
password = document.getElementById('password');
passwordInput = document.getElementById('passwordinput');
rawTextButton = document.getElementById('rawtextbutton');
downloadTextButton = document.getElementById('downloadtextbutton');
retryButton = document.getElementById('retrybutton');
sendButton = document.getElementById('sendbutton');
qrCodeLink = document.getElementById('qrcodelink');
emailLink = document.getElementById('emaillink');
// bootstrap template drop down
const languageDropdown = document.getElementById('language');
if (languageDropdown) {
languageDropdown.querySelectorAll('ul.dropdown-menu li a').forEach(link => {
link.addEventListener('click', setLanguage);
});
}
// bootstrap template drop down
const templateDropdown = document.getElementById('template');
if (templateDropdown) {
templateDropdown.querySelectorAll('ul.dropdown-menu li a').forEach(link => {
link.addEventListener('click', setTemplate);
});
}
// bind events
if (burnAfterReading) {
burnAfterReading.addEventListener('change', changeBurnAfterReading);
}
if (openDiscussionOption) {
openDiscussionOption.addEventListener('change', changeOpenDiscussion);
}
if (newButton) {
newButton.addEventListener('click', clickNewPaste);
}
if (sendButton) {
sendButton.addEventListener('click', PasteEncrypter.sendPaste);
}
if (cloneButton) {
cloneButton.addEventListener('click', Controller.clonePaste);
}
if (rawTextButton) {
rawTextButton.addEventListener('click', rawText);
}
if (downloadTextButton) {
downloadTextButton.addEventListener('click', downloadText);
}
if (retryButton) {
retryButton.addEventListener('click', clickRetryButton);
}
if (fileRemoveButton) {
fileRemoveButton.addEventListener('click', removeAttachment);
}
if (qrCodeLink) {
qrCodeLink.addEventListener('click', displayQrCode);
}
// bootstrap template drop downs
if (expiration) {
expiration.parentElement.querySelectorAll('ul.dropdown-menu li a').forEach(link => {
link.addEventListener('click', updateExpiration);
});
}
if (formatter) {
formatter.parentElement.querySelectorAll('ul.dropdown-menu li a').forEach(link => {
link.addEventListener('click', updateFormat);
});
}
// bootstrap5 & page drop downs
const pasteExpirationSelect = document.getElementById('pasteExpiration');
if (pasteExpirationSelect) {
pasteExpirationSelect.addEventListener('change', function () {
pasteExpiration = Model.getExpirationDefault();
});
}
const pasteFormatterSelect = document.getElementById('pasteFormatter');
if (pasteFormatterSelect) {
pasteFormatterSelect.addEventListener('change', function () {
PasteViewer.setFormat(Model.getFormatDefault());
});
}
// initiate default state of checkboxes
changeBurnAfterReading();
changeOpenDiscussion();
// get default values from template or fall back to set value
burnAfterReadingDefault = me.getBurnAfterReading();
openDiscussionDefault = me.getOpenDiscussion();
pasteExpiration = Model.getExpirationDefault();
createButtonsDisplayed = false;
viewButtonsDisplayed = false;
};
return me;
})(window, document);
/**
* Responsible for AJAX requests, transparently handles encryption…
*
* @name ServerInteraction
* @class
*/
const ServerInteraction = (function () {
const me = {};
let successFunc = null,
failureFunc = null,
symmetricKey = null,
url,
data,
password;
/**
* public variable ('constant') for errors to prevent magic numbers
*
* @name ServerInteraction.error
* @readonly
* @enum {Object}
*/
me.error = {
okay: 0,
custom: 1,
unknown: 2,
serverError: 3
};
/**
* ajaxHeaders to send in AJAX requests
*
* @name ServerInteraction.ajaxHeaders
* @private
* @readonly
* @enum {Object}
*/
const ajaxHeaders = {
'X-Requested-With': 'JSONHttpRequest',
'Content-Type': 'application/json'
};
/**
* called after successful upload
*
* @name ServerInteraction.success
* @private
* @function
* @param {int} status
* @param {int} result - optional
*/
function success(status, result) {
if (successFunc !== null) {
// add useful data to result
result.encryptionKey = symmetricKey;
successFunc(status, result);
}
}
/**
* called after a upload failure
*
* @name ServerInteraction.fail
* @private
* @function
* @param {int} status - internal code
* @param {int} result - original error code
*/
function fail(status, result) {
if (failureFunc !== null) {
failureFunc(status, result);
}
}
/**
* actually uploads the data
*
* @name ServerInteraction.run
* @function
*/
me.run = function () {
let isPost = Object.keys(data).length > 0,
ajaxParams = {
type: isPost ? 'POST' : 'GET',
url: url,
headers: ajaxHeaders,
dataType: 'json',
success: function (result) {
if (result.status === 0) {
success(0, result);
} else if (result.status === 1) {
fail(1, result);
} else {
fail(2, result);
}
}
};
if (isPost) {
ajaxParams.data = JSON.stringify(data);
}
$.ajax(ajaxParams).fail(function (jqXHR, textStatus, errorThrown) {
console.error(textStatus, errorThrown);
fail(3, jqXHR);
});
};
/**
* return currently set data, used in unit testing
*
* @name ServerInteraction.getData
* @function
*/
me.getData = function () {
return data;
};
/**
* set success function
*
* @name ServerInteraction.setUrl
* @function
* @param {function} newUrl
*/
me.setUrl = function (newUrl) {
url = newUrl;
};
/**
* sets the password to use (first value) and optionally also the
* encryption key (not recommended, it is automatically generated).
*
* Note: Call this after prepare() as prepare() resets these values.
*
* @name ServerInteraction.setCryptValues
* @function
* @param {string} newPassword
* @param {string} newKey - optional
*/
me.setCryptParameters = function (newPassword, newKey) {
password = newPassword;
if (typeof newKey !== 'undefined') {
symmetricKey = newKey;
}
};
/**
* set success function
*
* @name ServerInteraction.setSuccess
* @function
* @param {function} func
*/
me.setSuccess = function (func) {
successFunc = func;
};
/**
* set failure function
*
* @name ServerInteraction.setFailure
* @function
* @param {function} func
*/
me.setFailure = function (func) {
failureFunc = func;
};
/**
* prepares a new upload
*
* Call this when doing a new upload to reset any data from potential
* previous uploads. Must be called before any other method of this
* module.
*
* @name ServerInteraction.prepare
* @function
* @return {object}
*/
me.prepare = function () {
// entropy should already be checked!
// reset password
password = '';
// reset key, so it a new one is generated when it is used
symmetricKey = null;
// reset data
successFunc = null;
failureFunc = null;
url = Helper.baseUri();
data = {};
};
/**
* encrypts and sets the data
*
* @name ServerInteraction.setCipherMessage
* @async
* @function
* @param {object} cipherMessage
*/
me.setCipherMessage = async function (cipherMessage) {
if (
symmetricKey === null ||
(typeof symmetricKey === 'string' && symmetricKey === '')
) {
symmetricKey = CryptTool.getSymmetricKey();
}
if (!data.hasOwnProperty('adata')) {
data['adata'] = [];
}
let cipherResult = await CryptTool.cipher(symmetricKey, password, JSON.stringify(cipherMessage), data['adata']);
data['v'] = 2;
data['ct'] = cipherResult[0];
data['adata'] = cipherResult[1];
};
/**
* set the additional metadata to send unencrypted
*
* @name ServerInteraction.setUnencryptedData
* @function
* @param {string} index
* @param {mixed} element
*/
me.setUnencryptedData = function (index, element) {
data[index] = element;
};
/**
* Helper, which parses shows a general error message based on the result of the ServerInteraction
*
* @name ServerInteraction.parseUploadError
* @function
* @param {int} status
* @param {object} data
* @param {string} doThisThing - a human description of the action, which was tried
* @return {array}
*/
me.parseUploadError = function (status, data, doThisThing) {
let errorArray;
switch (status) {
case me.error.custom:
errorArray = ['Could not ' + doThisThing + ': %s', data.message];
break;
case me.error.unknown:
errorArray = ['Could not ' + doThisThing + ': %s', I18n._('unknown status')];
break;
case me.error.serverError:
errorArray = ['Could not ' + doThisThing + ': %s', I18n._('server error or not responding')];
break;
default:
errorArray = ['Could not ' + doThisThing + ': %s', I18n._('unknown error')];
break;
}
return errorArray;
};
return me;
})();
/**
* (controller) Responsible for encrypting document and sending it to server.
*
* Does upload, encryption is done transparently by ServerInteraction.
*
* @name PasteEncrypter
* @class
*/
const PasteEncrypter = (function () {
const me = {};
/**
* called after successful document upload
*
* @name PasteEncrypter.showCreatedPaste
* @private
* @function
* @param {int} status
* @param {object} data
*/
function showCreatedPaste(status, data) {
Alert.hideLoading();
Alert.hideMessages();
// show notification
const baseUri = Helper.baseUri() + '?',
url = baseUri + data.id + (TopNav.getBurnAfterReading() ? loadConfirmPrefix : '#') + CryptTool.base58encode(data.encryptionKey),
deleteUrl = baseUri + 'pasteid=' + data.id + '&deletetoken=' + data.deletetoken;
PasteStatus.createPasteNotification(url, deleteUrl);
// show new URL in browser bar
history.pushState({ type: 'newpaste' }, document.title, url);
TopNav.showViewButtons();
CopyToClipboard.setUrl(url);
CopyToClipboard.showKeyboardShortcutHint();
// this cannot be grouped with showViewButtons due to remaining time calculation
TopNav.showEmailButton();
TopNav.hideRawButton();
TopNav.hideDownloadButton();
Editor.hide();
PasteStatus.checkAutoShorten();
// parse and show text
// (preparation already done in me.sendPaste())
PasteViewer.run();
}
/**
* called after successful comment upload
*
* @name PasteEncrypter.showUploadedComment
* @private
* @function
* @param {int} status
* @param {object} data
*/
function showUploadedComment(status, data) {
// show success message
Alert.showStatus('Comment posted.');
// reload document
Controller.refreshPaste(function () {
// highlight sent comment
DiscussionViewer.highlightComment(data.id, true);
// reset error handler
Alert.setCustomHandler(null);
});
}
/**
* send a reply in a discussion
*
* @name PasteEncrypter.sendComment
* @async
* @function
*/
me.sendComment = async function () {
Alert.hideMessages();
Alert.setCustomHandler(DiscussionViewer.handleNotification);
// UI loading state
TopNav.hideAllButtons();
Alert.showLoading('Sending comment…', 'cloud-upload');
// get data
const plainText = DiscussionViewer.getReplyMessage(),
nickname = DiscussionViewer.getReplyNickname(),
parentid = DiscussionViewer.getReplyCommentId();
// do not send if there is no data
if (plainText.length === 0) {
// revert loading status…
Alert.hideLoading();
Alert.setCustomHandler(null);
TopNav.showViewButtons();
return;
}
// prepare server interaction
ServerInteraction.prepare();
ServerInteraction.setCryptParameters(Prompt.getPassword(), Model.getPasteKey());
// set success/fail functions
ServerInteraction.setSuccess(showUploadedComment);
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showViewButtons();
// …show error message…
Alert.showError(
ServerInteraction.parseUploadError(status, data, 'post comment')
);
// …and reset error handler
Alert.setCustomHandler(null);
});
// fill it with unencrypted params
ServerInteraction.setUnencryptedData('pasteid', Model.getPasteId());
if (typeof parentid === 'undefined') {
// if parent id is not set, this is the top-most comment, so use
// document id as parent, as the root element of the discussion tree
ServerInteraction.setUnencryptedData('parentid', Model.getPasteId());
} else {
ServerInteraction.setUnencryptedData('parentid', parentid);
}
// prepare cypher message
let cipherMessage = {
'comment': plainText
};
if (nickname.length > 0) {
cipherMessage['nickname'] = nickname;
}
await ServerInteraction.setCipherMessage(cipherMessage).catch(Alert.showError);
ServerInteraction.run();
};
/**
* sends a new document to server
*
* @name PasteEncrypter.sendPaste
* @async
* @function
*/
me.sendPaste = async function () {
// hide previous (error) messages
Controller.hideStatusMessages();
// UI loading state
TopNav.hideAllButtons();
Alert.showLoading('Sending document…', 'cloud-upload');
TopNav.collapseBar();
// get data
const plainText = Editor.getText(),
format = PasteViewer.getFormat(),
// the methods may return different values if no files are attached (null, undefined or false)
files = TopNav.getFileList() || AttachmentViewer.getFiles() || AttachmentViewer.hasAttachment();
// do not send if there is no data
if (plainText.length === 0 && !files) {
// revert loading status…
Alert.hideLoading();
TopNav.showCreateButtons();
return;
}
// prepare server interaction
ServerInteraction.prepare();
ServerInteraction.setCryptParameters(TopNav.getPassword());
// set success/fail functions
ServerInteraction.setSuccess(showCreatedPaste);
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showCreateButtons();
// show error message
Alert.showError(
ServerInteraction.parseUploadError(status, data, 'create document')
);
});
// fill it with unencrypted submitted options
ServerInteraction.setUnencryptedData('adata', [
null, format,
TopNav.getOpenDiscussion() ? 1 : 0,
TopNav.getBurnAfterReading() ? 1 : 0
]);
ServerInteraction.setUnencryptedData('meta', { 'expire': TopNav.getExpiration() });
// prepare PasteViewer for later preview
PasteViewer.setText(plainText);
PasteViewer.setFormat(format);
// prepare cypher message
let attachmentsData = AttachmentViewer.getAttachmentsData(),
cipherMessage = {
'paste': plainText
};
if (attachmentsData.length) {
cipherMessage['attachment'] = attachmentsData;
cipherMessage['attachment_name'] = AttachmentViewer.getFiles().map((fileInfo => fileInfo.name));
} else if (AttachmentViewer.hasAttachment()) {
// fall back to cloned part
let attachments = AttachmentViewer.getAttachments();
cipherMessage['attachment'] = attachments.map(attachment => attachment[0]);
cipherMessage['attachment_name'] = attachments.map(attachment => attachment[1]);
cipherMessage['attachment'] = await Promise.all(cipherMessage['attachment'].map(async (attachment, i) => {
// we need to retrieve data from blob if browser already parsed it in memory
if (typeof attachment === 'string' && attachment.startsWith('blob:')) {
Alert.showStatus(
[
'Retrieving cloned file \'%s\' from memory...',
cipherMessage['attachment_name'][i]
],
'copy'
);
try {
const blobData = await $.ajax({
type: 'GET',
url: attachment,
processData: false,
timeout: 10000,
dataType: 'binary',
xhrFields: {
withCredentials: false,
responseType: 'blob'
}
});
if (blobData instanceof window.Blob) {
const fileReading = new Promise(function (resolve, reject) {
const fileReader = new FileReader();
fileReader.onload = function (event) {
resolve(event.target.result);
};
fileReader.onerror = function (error) {
reject(error);
}
fileReader.readAsDataURL(blobData);
});
return await fileReading;
} else {
const error = 'Cannot process attachment data.';
Alert.showError(error);
throw new TypeError(error);
}
} catch (error) {
Alert.showError('Cannot retrieve attachment.');
throw error;
}
}
}));
}
// encrypt message
await ServerInteraction.setCipherMessage(cipherMessage).catch(Alert.showError);
// send data
ServerInteraction.run();
};
return me;
})();
/**
* (controller) Responsible for decrypting cipherdata and passing data to view.
*
* Only decryption, no download.
*
* @name PasteDecrypter
* @class
*/
const PasteDecrypter = (function () {
const me = {};
/**
* decrypt data or prompts for password in case of failure
*
* @name PasteDecrypter.decryptOrPromptPassword
* @private
* @async
* @function
* @param {string} key
* @param {string} password - optional, may be an empty string
* @param {string} cipherdata
* @throws {string}
* @return {false|string} false, when unsuccessful or string (decrypted data)
*/
async function decryptOrPromptPassword(key, password, cipherdata) {
// try decryption without password
const plaindata = await CryptTool.decipher(key, password, cipherdata);
// if it fails, request password
if (plaindata.length === 0 && password.length === 0) {
// show prompt
Prompt.requestPassword();
// Thus, we cannot do anything yet, we need to wait for the user
// input.
return false;
}
// if all tries failed, we can only return an error
if (plaindata.length === 0) {
return false;
}
return plaindata;
}
/**
* decrypt the actual document text
*
* @name PasteDecrypter.decryptPaste
* @private
* @async
* @function
* @param {Paste} paste - document data in object form
* @param {string} key
* @param {string} password
* @throws {string}
* @return {Promise}
*/
async function decryptPaste(paste, key, password) {
const pastePlain = await decryptOrPromptPassword(
key, password,
paste.getCipherData()
);
if (pastePlain === false) {
if (password.length === 0) {
throw 'waiting on user to provide a password';
} else {
Alert.hideLoading();
// reset password, so it can be re-entered
Prompt.reset();
TopNav.showRetryButton();
throw 'Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.';
}
}
const pasteMessage = JSON.parse(pastePlain);
if (pasteMessage.hasOwnProperty('attachment') && pasteMessage.hasOwnProperty('attachment_name')) {
if (Array.isArray(pasteMessage.attachment) && Array.isArray(pasteMessage.attachment_name)) {
pasteMessage.attachment.forEach((attachment, key) => {
const attachment_name = pasteMessage.attachment_name[key];
AttachmentViewer.setAttachment(attachment, attachment_name);
});
} else {
// Continue to process attachment parameters as strings to ensure backward compatibility
AttachmentViewer.setAttachment(pasteMessage.attachment, pasteMessage.attachment_name);
}
AttachmentViewer.showAttachment();
}
PasteViewer.setFormat(paste.getFormat());
PasteViewer.setText(pasteMessage.paste);
PasteViewer.run();
}
/**
* decrypts all comments and shows them
*
* @name PasteDecrypter.decryptComments
* @private
* @async
* @function
* @param {Paste} paste - document data in object form
* @param {string} key
* @param {string} password
* @return {Promise}
*/
async function decryptComments(paste, key, password) {
// remove potential previous discussion
DiscussionViewer.prepareNewDiscussion();
const commentDecryptionPromises = [];
// iterate over comments
for (let i = 0; i < paste.comments.length; ++i) {
const comment = new Comment(paste.comments[i]),
commentPromise = CryptTool.decipher(key, password, comment.getCipherData());
paste.comments[i] = comment;
commentDecryptionPromises.push(
commentPromise.then(function (commentJson) {
const commentMessage = JSON.parse(commentJson);
return [
commentMessage.comment || '',
commentMessage.nickname || ''
];
})
);
}
return Promise.all(commentDecryptionPromises).then(function (plaintexts) {
for (let i = 0; i < paste.comments.length; ++i) {
if (plaintexts[i][0].length === 0) {
continue;
}
DiscussionViewer.addComment(
paste.comments[i],
plaintexts[i][0],
plaintexts[i][1]
);
}
document.addEventListener(I18n.languageLoadedEvent, function () {
const comentContainer = document.getElementById('commentcontainer');
if (!comentContainer) {
return;
}
comentContainer.querySelectorAll('img.vizhash')
.forEach(img => img.setAttribute('title', I18n._('Avatar generated from IP address')));
});
});
}
/**
* show decrypted text in the display area, including discussion (if open)
*
* @name PasteDecrypter.run
* @function
* @param {Paste} [paste] - (optional) object including comments to display (items = array with keys ('data','meta'))
*/
me.run = function (paste) {
Alert.hideMessages();
Alert.setCustomHandler(null);
Alert.showLoading('Decrypting document…', 'cloud-download');
if (typeof paste === 'undefined' || paste.type === 'click') {
// get cipher data and wait until it is available
Model.getPasteData(me.run);
return;
}
let key = Model.getPasteKey(),
password = Prompt.getPassword(),
decryptionPromises = [];
TopNav.setRetryCallback(function () {
TopNav.hideRetryButton();
me.run(paste);
});
// Clear attachments to prevent duplicates
AttachmentViewer.removeAttachment();
// decrypt paste & attachments
decryptionPromises.push(decryptPaste(paste, key, password));
// if the discussion is opened on this document, display it
if (paste.isDiscussionEnabled()) {
decryptionPromises.push(decryptComments(paste, key, password));
}
// shows the remaining time (until) deletion
PasteStatus.showRemainingTime(paste);
CopyToClipboard.showKeyboardShortcutHint();
Promise.all(decryptionPromises)
.then(() => {
Alert.hideLoading();
TopNav.showViewButtons();
// discourage cloning (it cannot really be prevented)
if (paste.isBurnAfterReadingEnabled()) {
TopNav.hideBurnAfterReadingButtons();
} else {
// we have to pass in remaining_time here
TopNav.showEmailButton(paste.getTimeToLive());
}
// only offer adding comments, after document was successfully decrypted
if (paste.isDiscussionEnabled()) {
DiscussionViewer.finishDiscussion();
}
})
.catch((err) => {
// wait for the user to type in the password,
// then PasteDecrypter.run will be called again
Alert.showError(err);
});
};
return me;
})();
/**
*
* @name CopyToClipboard
* @class
*/
const CopyToClipboard = (function () {
const me = {};
let copyButton,
copyLinkButton,
copyIcon,
successIcon,
shortcutHint,
url;
/**
* Handle copy to clipboard button click
*
* @name CopyToClipboard.handleCopyButtonClick
* @private
* @function
*/
function handleCopyButtonClick() {
$(copyButton).click(function () {
const text = PasteViewer.getText();
saveToClipboard(text);
toggleSuccessIcon();
showAlertMessage('Document copied to clipboard');
});
}
/**
* Handle copy link to clipboard button click
*
* @name CopyToClipboard.handleCopyLinkButtonClick
* @private
* @function
*/
function handleCopyLinkButtonClick() {
$(copyLinkButton).click(function () {
saveToClipboard(url);
showAlertMessage('Link copied to clipboard');
});
}
/**
* Handle CTRL+C/CMD+C keyboard shortcut
*
* @name CopyToClipboard.handleKeyboardShortcut
* @private
* @function
*/
function handleKeyboardShortcut() {
$(document).bind('copy', function () {
if (!isUserSelectedTextToCopy()) {
const text = PasteViewer.getText();
saveToClipboard(text);
showAlertMessage('Document copied to clipboard');
}
});
}
/**
* Check if user selected some text on the page to copy it
*
* @name CopyToClipboard.isUserSelectedTextToCopy
* @private
* @function
* @returns {boolean}
*/
function isUserSelectedTextToCopy() {
let text = '';
if (window.getSelection) {
text = window.getSelection().toString();
} else if (document.selection && document.selection.type !== 'Control') {
text = document.selection.createRange().text;
}
return text.length > 0;
}
/**
* Save text to the clipboard
*
* @name CopyToClipboard.saveToClipboard
* @private
* @param {string} text
* @function
*/
function saveToClipboard(text) {
navigator.clipboard.writeText(text);
}
/**
* Show alert message after text copy
*
* @name CopyToClipboard.showAlertMessage
* @private
* @param {string} message
* @function
*/
function showAlertMessage(message) {
Alert.showStatus(message);
}
/**
* Toogle success icon after copy
*
* @name CopyToClipboard.toggleSuccessIcon
* @private
* @function
*/
function toggleSuccessIcon() {
$(copyIcon).css('display', 'none');
$(successIcon).css('display', 'block');
setTimeout(function () {
$(copyIcon).css('display', 'block');
$(successIcon).css('display', 'none');
}, 1000);
}
/**
* Show keyboard shortcut hint
*
* @name CopyToClipboard.showKeyboardShortcutHint
* @function
*/
me.showKeyboardShortcutHint = function () {
I18n._(
shortcutHint,
'To copy document press on the copy button or use the clipboard shortcut Ctrl+c/Cmd+c'
);
};
/**
* Hide keyboard shortcut hint
*
* @name CopyToClipboard.showKeyboardShortcutHint
* @function
*/
me.hideKeyboardShortcutHint = function () {
$(shortcutHint).html('');
};
/**
* Set document url
*
* @name CopyToClipboard.setUrl
* @param {string} newUrl
* @function
*/
me.setUrl = function (newUrl) {
url = newUrl;
};
/**
* Initialize
*
* @name CopyToClipboard.init
* @function
*/
me.init = function () {
copyButton = $('#prettyMessageCopyBtn');
copyLinkButton = $('#copyLink');
copyIcon = $('#copyIcon');
successIcon = $('#copySuccessIcon');
shortcutHint = $('#copyShortcutHintText');
handleCopyButtonClick();
handleCopyLinkButtonClick();
handleKeyboardShortcut();
};
return me;
})();
/**
*
* @name PasswordPeek
* @class
*/
const PasswordPeek = (function () {
const me = {};
/**
* Switch between visible and hidden password
*
* @name PasswordPeek.handleRevealButtonClick
* @private
* @function
*/
function handleRevealButtonClick() {
const element = $(this);
const passwordInput = element.siblings('.input-password');
const isHidden = passwordInput.attr('type') === 'password';
passwordInput.attr('type', isHidden ? 'text' : 'password');
const tooltip = I18n._(isHidden ? 'Hide password' : 'Show password');
element.attr('title', tooltip);
element.attr('aria-label', tooltip);
// handle bootstrap 5 icons: eye & eye-slash
const buttonSvg = element.find('use');
if (buttonSvg.length) {
const iconHref = buttonSvg.attr('href');
if (isHidden) {
buttonSvg.attr('href', iconHref + '-slash');
} else {
buttonSvg.attr('href', iconHref.substring(0, iconHref.length - 6));
}
return;
}
// handle bootstrap 3 icons: eye-open & eye-close
const buttonSpan = element.find('span');
if (buttonSpan.length) {
if (isHidden) {
buttonSpan.addClass('glyphicon-eye-close');
buttonSpan.removeClass('glyphicon-eye-open');
} else {
buttonSpan.addClass('glyphicon-eye-open');
buttonSpan.removeClass('glyphicon-eye-close');
}
}
}
/**
* Initialize
*
* @name PasswordPeek.init
* @function
*/
me.init = function () {
const revealButton = $('.toggle-password');
revealButton.click(handleRevealButtonClick);
};
return me;
})();
/**
* (controller) main PrivateBin logic
*
* @name Controller
* @param {object} window
* @param {object} document
* @class
*/
const Controller = (function (window, document) {
const me = {};
/**
* hides all status messages no matter which module showed them
*
* @name Controller.hideStatusMessages
* @function
*/
me.hideStatusMessages = function () {
PasteStatus.hideMessages();
Alert.hideMessages();
CopyToClipboard.hideKeyboardShortcutHint();
};
/**
* creates a new document
*
* @name Controller.newPaste
* @function
*/
me.newPaste = function () {
// Important: This *must not* run Alert.hideMessages() as previous
// errors from viewing a document should be shown.
TopNav.hideAllButtons();
Alert.showLoading('Preparing new document…', 'time');
PasteStatus.hideMessages();
PasteViewer.hide();
Editor.resetInput();
Editor.show();
Editor.focusInput();
AttachmentViewer.removeAttachment();
TopNav.resetInput();
// reset format
PasteViewer.setFormat('plaintext');
TopNav.setFormat('plaintext');
TopNav.showCreateButtons();
// newPaste could be called when user is on document clone editing view
TopNav.hideCustomAttachment();
AttachmentViewer.clearDragAndDrop();
AttachmentViewer.removeAttachmentData();
Alert.hideLoading();
// only push new state if we are coming from a different one
if (Helper.baseUri() !== window.location) {
history.pushState({ type: 'create' }, document.title, Helper.baseUri());
}
// clear discussion
DiscussionViewer.prepareNewDiscussion();
};
/**
* shows the loaded document
*
* @name Controller.showPaste
* @function
*/
me.showPaste = function () {
try {
Model.getPasteKey();
} catch (err) {
console.error(err);
Alert.showError('Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)');
return;
}
// check if we should request loading confirmation
if (window.location.hash.startsWith(loadConfirmPrefix)) {
Prompt.requestLoadConfirmation();
return;
}
// show proper elements on screen
PasteDecrypter.run();
};
/**
* refreshes the loaded document to show potential new data
*
* @name Controller.refreshPaste
* @function
* @param {function} callback
*/
me.refreshPaste = function (callback) {
// save window position to restore it later
const orgPosition = window.scrollY;
Model.getPasteData(function (data) {
ServerInteraction.prepare();
ServerInteraction.setUrl(Helper.baseUri() + '?pasteid=' + Model.getPasteId());
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showViewButtons();
// show error message
Alert.showError(
ServerInteraction.parseUploadError(status, data, 'refresh display')
);
});
ServerInteraction.setSuccess(function (status, data) {
PasteDecrypter.run(new Paste(data));
// restore position
window.scrollTo(0, orgPosition);
// NOTE: could create problems as callback may be called
// asyncronously if PasteDecrypter e.g. needs to wait for a
// password being entered
callback();
});
ServerInteraction.run();
}, false); // this false is important as it circumvents the cache
}
/**
* clone the current document
*
* @name Controller.clonePaste
* @function
*/
me.clonePaste = function () {
TopNav.collapseBar();
TopNav.hideAllButtons();
// hide messages from previous document
me.hideStatusMessages();
// erase the id and the key in url
history.pushState({ type: 'clone' }, document.title, Helper.baseUri());
if (AttachmentViewer.hasAttachment()) {
const attachments = AttachmentViewer.getAttachments();
attachments.forEach(attachment => {
AttachmentViewer.moveAttachmentTo(
TopNav.getCustomAttachment(),
attachment,
'Cloned: \'%s\''
);
});
TopNav.hideFileSelector();
AttachmentViewer.hideAttachment();
// NOTE: it also looks nice without removing the attachment
// but for a consistent display we remove it…
AttachmentViewer.hideAttachmentPreview();
TopNav.showCustomAttachment();
// show another status messages to make the user aware that the
// files were cloned too!
Alert.showStatus(
[
'The cloned file \'%s\' was attached to this document.',
attachments.map(attachment => attachment[1]).join(', ')
],
'copy'
);
}
Editor.setText(PasteViewer.getText());
// also clone the format
TopNav.setFormat(PasteViewer.getFormat());
PasteViewer.hide();
Editor.show();
TopNav.showCreateButtons();
// clear discussion
DiscussionViewer.prepareNewDiscussion();
};
/**
* try initializing zlib or display a warning if it fails,
* extracted from main init to allow unit testing
*
* @name Controller.initZ
* @function
*/
me.initZ = function () {
z = zlib.catch(function () {
if (document.body.dataset.compression !== 'none') {
Alert.showWarning('Your browser doesn\'t support WebAssembly, used for zlib compression. You can create uncompressed documents, but can\'t read compressed ones.');
}
});
}
/**
* application start
*
* @name Controller.init
* @function
*/
me.init = function () {
// first load translations
I18n.loadTranslations();
// Add a hook to make all links open a new window
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
// set all elements owning target to target=_blank
if ('target' in node && node.id !== 'pasteurl') {
node.setAttribute('target', '_blank');
}
// set non-HTML/MathML links to xlink:show=new
if (!node.hasAttribute('target')
&& (node.hasAttribute('xlink:href')
|| node.hasAttribute('href'))) {
node.setAttribute('xlink:show', 'new');
}
if ('rel' in node) {
node.setAttribute('rel', 'nofollow noopener noreferrer');
}
});
// center all modals
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('show.bs.modal', function (e) {
e.target.style.display = 'flex';
});
});
// initialize other modules/"classes"
Alert.init();
Model.init();
AttachmentViewer.init();
DiscussionViewer.init();
Editor.init();
PasteStatus.init();
PasteViewer.init();
Prompt.init();
TopNav.init();
UiHelper.init();
CopyToClipboard.init();
PasswordPeek.init();
// check for legacy browsers before going any further
if (!Legacy.Check.getInit()) {
// Legacy check didn't complete, wait and try again
setTimeout(init, 500);
return;
}
if (!Legacy.Check.getStatus()) {
// something major is wrong, stop right away
return;
}
me.initZ();
// if delete token is passed (i.e. document has been deleted by this
// access), add an event listener for the 'new' document button in the alert
if (Model.hasDeleteToken()) {
const newFromAlert = document.getElementById('new-from-alert');
if (newFromAlert) {
newFromAlert.addEventListener('click', function () {
UiHelper.reloadHome();
});
}
return;
}
// check whether existing document needs to be shown
try {
Model.getPasteId();
} catch (e) {
// otherwise create a new document
return me.newPaste();
}
// always reload on back button to invalidate cache (protect burn after read document)
window.addEventListener('popstate', () => {
window.location.reload();
});
// display an existing document
return me.showPaste();
}
return me;
})(window, document);
return {
Helper: Helper,
I18n: I18n,
CryptTool: CryptTool,
Model: Model,
UiHelper: UiHelper,
Alert: Alert,
PasteStatus: PasteStatus,
Prompt: Prompt,
Editor: Editor,
PasteViewer: PasteViewer,
AttachmentViewer: AttachmentViewer,
DiscussionViewer: DiscussionViewer,
TopNav: TopNav,
ServerInteraction: ServerInteraction,
PasteEncrypter: PasteEncrypter,
PasteDecrypter: PasteDecrypter,
PasswordPeek: PasswordPeek,
CopyToClipboard: CopyToClipboard,
Controller: Controller
};
})();
// main application start, called when DOM is fully loaded
window.addEventListener('DOMContentLoaded', function () {
'use strict';
// run main controller
window.PrivateBin.Controller.init();
});