/**
* 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
*/
// global Base64, DOMPurify, FileReader, RawDeflate, history, navigator, prettyPrint, prettyPrintOne, showdown, kjua
jQuery.fn.draghover = function() {
'use strict';
return this.each(function() {
let collection = $(),
self = $(this);
self.on('dragenter', function(e) {
if (collection.length === 0) {
self.trigger('draghoverstart');
}
collection = collection.add(e.target);
});
self.on('dragleave drop', function(e) {
collection = collection.not(e.target);
if (collection.length === 0) {
self.trigger('draghoverend');
}
});
});
};
// main application start, called when DOM is fully loaded
jQuery(document).ready(function() {
'use strict';
// run main controller
$.PrivateBin.Controller.init();
});
jQuery.PrivateBin = (function($, RawDeflate) {
'use strict';
/**
* zlib library interface
*
* @private
*/
let z;
/**
* DOMpurify settings for HTML content
*
* @private
*/
const purifyHtmlConfig = {
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
SAFE_FOR_JQUERY: true,
USE_PROFILES: {
html: true
}
};
/**
* 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 paste and comment formats
*
* @name CryptoData
* @class
*/
function CryptoData(data) {
this.v = 1;
// store all keys in the default locations for drop-in replacement
for (let key in data) {
this[key] = data[key];
}
/**
* gets the cipher data (cipher text + adata)
*
* @name CryptoData.getCipherData
* @function
* @return {Array}|{string}
*/
this.getCipherData = function()
{
return this.v === 1 ? this.data : [this.ct, this.adata];
}
}
/**
* Paste class
*
* bundles helper functions around the paste 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.v === 1 ? this.meta.formatter : this.adata[1];
}
/**
* gets the remaining seconds before the paste expires
*
* returns 0 if there is no expiration
*
* @name Paste.getTimeToLive
* @function
* @return {string}
*/
this.getTimeToLive = function()
{
return (this.v === 1 ? this.meta.remaining_time : this.meta.time_to_live) || 0;
}
/**
* is burn-after-reading enabled
*
* @name Paste.isBurnAfterReadingEnabled
* @function
* @return {bool}
*/
this.isBurnAfterReadingEnabled = function()
{
return (this.v === 1 ? this.meta.burnafterreading : this.adata[3]);
}
/**
* are discussions enabled
*
* @name Paste.isDiscussionEnabled
* @function
* @return {bool}
*/
this.isDiscussionEnabled = function()
{
return (this.v === 1 ? this.meta.opendiscussion : 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[this.v === 1 ? 'postdate' : 'created'] || 0;
}
/**
* gets the icon of the comment submitter
*
* @name Comment.getIcon
* @function
* @return {string}
*/
this.getIcon = function()
{
return this.meta[this.v === 1 ? 'vizhash' : '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:
*
.
const elementNodeType = 1;
const div = document.createElement('div');
div.innerHTML = messageId;
return Array.from(div.childNodes).some(node => node.nodeType === elementNodeType);
}
return me;
})();
/**
* handles everything related to en/decryption
*
* @name CryptTool
* @class
*/
const CryptTool = (function () {
const me = {};
/**
* base58 encoder & decoder
*
* @private
*/
let base58 = new baseX('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
/**
* convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString
*
* Iterates over the bytes of the message, converting them all hexadecimal
* percent encoded representations, then URI decodes them all
*
* @name CryptTool.utf8To16
* @function
* @private
* @param {string} message UTF-8 string
* @return {string} UTF-16 string
*/
function utf8To16(message)
{
return decodeURIComponent(
message.split('').map(
function(character)
{
return '%' + ('00' + character.charCodeAt(0).toString(16)).slice(-2);
}
).join('')
);
}
/**
* convert DOMString (UTF-16) to a UTF-8 string stored in a DOMString
*
* URI encodes the message, then finds the percent encoded characters
* and transforms these hexadecimal representation back into bytes
*
* @name CryptTool.utf16To8
* @function
* @private
* @param {string} message UTF-16 string
* @return {string} UTF-8 string
*/
function utf16To8(message)
{
return encodeURIComponent(message).replace(
/%([0-9A-F]{2})/g,
function (match, hexCharacter)
{
return String.fromCharCode('0x' + hexCharacter);
}
);
}
/**
* convert ArrayBuffer into a UTF-8 string
*
* Iterates over the bytes of the array, catenating them into a string
*
* @name CryptTool.arraybufferToString
* @function
* @private
* @param {ArrayBuffer} messageArray
* @return {string} message
*/
function arraybufferToString(messageArray)
{
const array = new Uint8Array(messageArray);
let message = '',
i = 0;
while(i < array.length) {
message += String.fromCharCode(array[i++]);
}
return message;
}
/**
* convert UTF-8 string into a Uint8Array
*
* Iterates over the bytes of the message, writing them to the array
*
* @name CryptTool.stringToArraybuffer
* @function
* @private
* @param {string} message UTF-8 string
* @return {Uint8Array} array
*/
function stringToArraybuffer(message)
{
const messageArray = new Uint8Array(message.length);
for (let i = 0; i < message.length; ++i) {
messageArray[i] = message.charCodeAt(i);
}
return messageArray;
}
/**
* compress a string (deflate compression), returns buffer
*
* @name CryptTool.compress
* @async
* @function
* @private
* @param {string} message
* @param {string} mode
* @param {object} zlib
* @throws {string}
* @return {ArrayBuffer} data
*/
async function compress(message, mode, zlib)
{
message = stringToArraybuffer(
utf16To8(message)
);
if (mode === 'zlib') {
if (typeof zlib === 'undefined') {
throw 'Error compressing paste, due to missing WebAssembly support.'
}
return zlib.deflate(message).buffer;
}
return message;
}
/**
* decompress potentially base64 encoded, deflate compressed buffer, returns string
*
* @name CryptTool.decompress
* @async
* @function
* @private
* @param {ArrayBuffer} data
* @param {string} mode
* @param {object} zlib
* @throws {string}
* @return {string} message
*/
async function decompress(data, mode, zlib)
{
if (mode === 'zlib' || mode === 'none') {
if (mode === 'zlib') {
if (typeof zlib === 'undefined') {
throw 'Error decompressing paste, your browser does not support WebAssembly. Please use another browser to view this paste.'
}
data = zlib.inflate(
new Uint8Array(data)
).buffer;
}
return utf8To16(
arraybufferToString(data)
);
}
// detect presence of Base64.js, indicating legacy ZeroBin paste
if (typeof Base64 === 'undefined') {
return utf8To16(
RawDeflate.inflate(
utf8To16(
atob(
arraybufferToString(data)
)
)
)
);
} else {
return Base64.btou(
RawDeflate.inflate(
Base64.fromBase64(
arraybufferToString(data)
)
)
);
}
}
/**
* returns specified number of random bytes
*
* @name CryptTool.getRandomBytes
* @function
* @private
* @param {int} length number of random bytes to fetch
* @throws {string}
* @return {string} random bytes
*/
function getRandomBytes(length)
{
let bytes = '';
const byteArray = new Uint8Array(length);
window.crypto.getRandomValues(byteArray);
for (let i = 0; i < length; ++i) {
bytes += String.fromCharCode(byteArray[i]);
}
return bytes;
}
/**
* derive cryptographic key from key string and password
*
* @name CryptTool.deriveKey
* @async
* @function
* @private
* @param {string} key
* @param {string} password
* @param {array} spec cryptographic specification
* @return {CryptoKey} derived key
*/
async function deriveKey(key, password, spec)
{
let keyArray = stringToArraybuffer(key);
if (password.length > 0) {
// version 1 pastes did append the passwords SHA-256 hash in hex
if (spec[7] === 'rawdeflate') {
let passwordBuffer = await window.crypto.subtle.digest(
{name: 'SHA-256'},
stringToArraybuffer(
utf16To8(password)
)
).catch(Alert.showError);
password = Array.prototype.map.call(
new Uint8Array(passwordBuffer),
x => ('00' + x.toString(16)).slice(-2)
).join('');
}
let passwordArray = stringToArraybuffer(password),
newKeyArray = new Uint8Array(keyArray.length + passwordArray.length);
newKeyArray.set(keyArray, 0);
newKeyArray.set(passwordArray, keyArray.length);
keyArray = newKeyArray;
}
// import raw key
const importedKey = await window.crypto.subtle.importKey(
'raw', // only 'raw' is allowed
keyArray,
{name: 'PBKDF2'}, // we use PBKDF2 for key derivation
false, // the key may not be exported
['deriveKey'] // we may only use it for key derivation
).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
($('body').data('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 if (typeof data === 'string') {
// version 1
let object = JSON.parse(data);
adataString = atob(object.adata);
spec = [
object.iv,
object.salt,
object.iter,
object.ks,
object.ts,
object.cipher,
object.mode,
'rawdeflate'
];
cipherMessage = object.ct;
} 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 paste, your browser does not support WebAssembly. Please use another browser to view this paste.'
}
}
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()
{
return $('#pasteExpiration').val();
};
/**
* returns the format set in the HTML
*
* @name Model.getFormatDefault
* @function
* @return string
*/
me.getFormatDefault = function()
{
return $('#pasteFormatter').val();
};
/**
* returns the paste data (including the cipher data)
*
* @name Model.getPasteData
* @function
* @param {function} callback (optional) Called when data is available
* @param {function} useCache (optional) Whether to use the cache or
* force a data reload. Default: true
* @return string
*/
me.getPasteData = function(callback, useCache)
{
// use cache if possible/allowed
if (useCache !== false && pasteData !== null) {
//execute callback
if (typeof callback === 'function') {
return callback(pasteData);
}
// alternatively just using inline
return pasteData;
}
// reload data
ServerInteraction.prepare();
ServerInteraction.setUrl(Helper.baseUri() + '?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 paste data'));
});
ServerInteraction.setSuccess(function (status, data) {
pasteData = new Paste(data);
if (typeof callback === 'function') {
return callback(pasteData);
}
});
ServerInteraction.run();
};
/**
* get the pastes unique identifier from the URL,
* eg. https://example.com/path/?c05354954c49a487#dfdsdgdgdfgdf returns c05354954c49a487
*
* @name Model.getPasteId
* @function
* @return {string} unique identifier
* @throws {string}
*/
me.getPasteId = function()
{
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 paste id given';
}
return id;
}
/**
* returns true, when the URL has a delete token and the current call was used for deleting a paste.
*
* @name Model.hasDeleteToken
* @function
* @return {bool}
*/
me.hasDeleteToken = function()
{
return window.location.search.indexOf('deletetoken') !== -1;
}
/**
* return the deciphering key stored in anchor part of the URL
*
* @name Model.getPasteKey
* @function
* @return {string|null} key
* @throws {string}
*/
me.getPasteKey = function()
{
if (symmetricKey === null) {
let 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) {
symmetricKey = newKey;
}
}
return symmetricKey;
};
/**
* returns a jQuery copy of the HTML template
*
* @name Model.getTemplate
* @function
* @param {string} name - the name of the template
* @return {jQuery}
*/
me.getTemplate = function(name)
{
// find template
let $element = $templates.find('#' + name + 'template').clone(true);
// change ID to avoid collisions (one ID should really be unique)
return $element.prop('id', name);
};
/**
* resets state, used for unit testing
*
* @name Model.reset
* @function
*/
me.reset = function()
{
pasteData = $templates = id = symmetricKey = null;
};
/**
* init navigation manager
*
* preloads jQuery elements
*
* @name Model.init
* @function
*/
me.init = function()
{
$templates = $('#templates');
};
return me;
})();
/**
* Helper functions for user interface
*
* everything directly UI-related, which fits nowhere else
*
* @name UiHelper
* @class
*/
const UiHelper = (function () {
const me = {};
/**
* handle history (pop) state changes
*
* currently this does only handle redirects to the home page.
*
* @name UiHelper.historyChange
* @private
* @function
* @param {Event} event
*/
function historyChange(event)
{
let currentLocation = Helper.baseUri();
if (event.originalEvent.state === null && // no state object passed
event.target.location.href === currentLocation && // target location is home page
window.location.href === currentLocation // and we are not already on the home page
) {
// redirect to home page
window.location.href = currentLocation;
}
}
/**
* reload the page
*
* This takes the user to the PrivateBin homepage.
*
* @name UiHelper.reloadHome
* @function
*/
me.reloadHome = function()
{
window.location.href = Helper.baseUri();
};
/**
* checks whether the element is currently visible in the viewport (so
* the user can actually see it)
*
* @see {@link https://stackoverflow.com/a/40658647}
* @name UiHelper.isVisible
* @function
* @param {jQuery} $element The link hash to move to.
*/
me.isVisible = function($element)
{
let elementTop = $element.offset().top,
viewportTop = $(window).scrollTop(),
viewportBottom = viewportTop + $(window).height();
return elementTop > viewportTop && elementTop < viewportBottom;
};
/**
* scrolls to a specific element
*
* @see {@link https://stackoverflow.com/questions/4198041/jquery-smooth-scroll-to-an-anchor#answer-12714767}
* @name UiHelper.scrollTo
* @function
* @param {jQuery} $element The link hash to move to.
* @param {(number|string)} animationDuration passed to jQuery .animate, when set to 0 the animation is skipped
* @param {string} animationEffect passed to jQuery .animate
* @param {function} finishedCallback function to call after animation finished
*/
me.scrollTo = function($element, animationDuration, animationEffect, finishedCallback)
{
let $body = $('html, body'),
margin = 50,
callbackCalled = false,
dest = 0;
// calculate destination place
// if it would scroll out of the screen at the bottom only scroll it as
// far as the screen can go
if ($element.offset().top > $(document).height() - $(window).height()) {
dest = $(document).height() - $(window).height();
} else {
dest = $element.offset().top - margin;
}
// skip animation if duration is set to 0
if (animationDuration === 0) {
window.scrollTo(0, dest);
} else {
// stop previous animation
$body.stop();
// scroll to destination
$body.animate({
scrollTop: dest
}, animationDuration, animationEffect);
}
// as we have finished we can enable scrolling again
$body.queue(function (next) {
if (!callbackCalled) {
// call user function if needed
if (typeof finishedCallback !== 'undefined') {
finishedCallback();
}
// prevent calling this function twice
callbackCalled = true;
}
next();
});
};
/**
* trigger a history (pop) state change
*
* used to test the UiHelper.historyChange private function
*
* @name UiHelper.mockHistoryChange
* @function
* @param {string} state (optional) state to mock
*/
me.mockHistoryChange = function(state)
{
if (typeof state === 'undefined') {
state = null;
}
historyChange($.Event('popstate', {originalEvent: new PopStateEvent('popstate', {state: state}), target: window}));
};
/**
* initialize
*
* @name UiHelper.init
* @function
*/
me.init = function()
{
// update link to home page
$('.reloadlink').prop('href', Helper.baseUri());
$(window).on('popstate', historyChange);
};
return me;
})();
/**
* Alert/error manager
*
* @name Alert
* @class
*/
const Alert = (function () {
const me = {};
let $errorMessage,
$loadingIndicator,
$statusMessage,
$remainingTime,
currentIcon,
customHandler;
const alertType = [
'loading', // not in bootstrap CSS, but using a plausible value here
'info', // status icon
'warning', // warning icon
'danger' // error icon
];
/**
* forwards a request to the i18n module and shows the element
*
* @name Alert.handleNotification
* @private
* @function
* @param {int} id - id of notification
* @param {jQuery} $element - jQuery object
* @param {string|array} args
* @param {string|null} icon - optional, icon
*/
function handleNotification(id, $element, args, icon)
{
// basic parsing/conversion of parameters
if (typeof icon === 'undefined') {
icon = null;
}
if (typeof args === 'undefined') {
args = null;
} else if (typeof args === 'string') {
// convert string to array if needed
args = [args];
} 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 jQuery) {
// 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.find(':first');
if ($glyphIcon.length) {
// if there is an icon, we need to provide an inner element
// to translate the message into, instead of the parent
$translationTarget = $('
');
$element.html(' ').prepend($glyphIcon).append($translationTarget);
if (icon !== null && // icon was passed
icon !== currentIcon[id] // and it differs from current icon
) {
// remove (previous) icon
$glyphIcon.removeClass(currentIcon[id]);
// any other thing as a string (e.g. 'null') (only) removes the icon
if (typeof icon === 'string') {
// set new icon
currentIcon[id] = 'glyphicon-' + icon;
$glyphIcon.addClass(currentIcon[id]);
}
}
}
// show text
if (args !== null) {
// add jQuery object to it as first parameter
args.unshift($translationTarget);
// pass it to I18n
I18n._.apply(this, args);
}
// show notification
$element.removeClass('hidden');
}
/**
* display a status message
*
* This automatically passes the text to I18n for translation.
*
* @name Alert.showStatus
* @function
* @param {string|array} message string, use an array for %s/%d options
* @param {string|null} icon optional, the icon to show,
* default: leave previous icon
*/
me.showStatus = function(message, icon)
{
handleNotification(1, $statusMessage, message, icon);
};
/**
* display 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)
{
$errorMessage.find(':first')
.removeClass(currentIcon[3])
.addClass(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)
$('body').addClass('loading');
};
/**
* hides the loading message
*
* @name Alert.hideLoading
* @function
*/
me.hideLoading = function()
{
$loadingIndicator.addClass('hidden');
// hide loading cursor
$('body').removeClass('loading');
};
/**
* hides any status/error messages
*
* This does not include the loading message.
*
* @name Alert.hideMessages
* @function
*/
me.hideMessages = function()
{
$statusMessage.addClass('hidden');
$errorMessage.addClass('hidden');
};
/**
* set a custom handler, which gets all notifications.
*
* This handler gets the following arguments:
* alertType (see array), $element, args, icon
* If it returns true, the own processing will be stopped so the message
* will not be displayed. Otherwise it will continue.
* As an aditional feature it can return q jQuery element, which will
* then be used to add the message there. Icons are not supported in
* that case and will be ignored.
* Pass 'null' to reset/delete the custom handler.
* Note that there is no notification when a message is supposed to get
* hidden.
*
* @name Alert.setCustomHandler
* @function
* @param {function|null} newHandler
*/
me.setCustomHandler = function(newHandler)
{
customHandler = newHandler;
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name Alert.init
* @function
*/
me.init = function()
{
// hide "no javascript" error message
$('#noscript').hide();
// not a reset, but first set of the elements
$errorMessage = $('#errormessage');
$loadingIndicator = $('#loadingindicator');
$statusMessage = $('#status');
$remainingTime = $('#remainingtime');
currentIcon = [
'glyphicon-time', // loading icon
'glyphicon-info-sign', // status icon
'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.hasClass('buttondisabled')) {
return;
}
$.ajax({
type: 'GET',
url: `${$shortenButton.data('shortener')}${encodeURIComponent($pasteUrl.attr('href'))}`,
headers: {'Accept': 'text/html, application/xhtml+xml, application/xml, application/json'},
processData: false,
timeout: 10000,
xhrFields: {
withCredentials: false
},
success: PasteStatus.extractUrl
})
.fail(function(data, textStatus, errorThrown) {
console.error(textStatus, errorThrown);
// 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.data('shortener')}${encodeURIComponent($pasteUrl.attr('href'))}`,
'_blank',
'noopener, noreferrer'
);
});
}
/**
* Forces opening the paste if the link does not do this automatically.
*
* This is necessary as browsers will not reload the page when it is
* already loaded (which is fake as it is set via history.pushState()).
*
* @name PasteStatus.pasteLinkClick
* @function
*/
function pasteLinkClick()
{
// check if location is (already) shown in URL bar
if (window.location.href === $pasteUrl.attr('href')) {
// if so we need to load link by reloading the current site
window.location.reload(true);
}
}
/**
* creates a notification after a successfull paste upload
*
* @name PasteStatus.createPasteNotification
* @function
* @param {string} url
* @param {string} deleteUrl
*/
me.createPasteNotification = function(url, deleteUrl)
{
I18n._(
$('#pastelink'),
'Your paste is %s (Hit Ctrl+c to copy)',
url, url
);
// save newly created element
$pasteUrl = $('#pasteurl');
// and add click event
$pasteUrl.click(pasteLinkClick);
// delete link
$('#deletelink').attr('href', deleteUrl);
I18n._($('#deletelink span').not('.glyphicon').first(), 'Delete data');
// enable shortener button
$shortenButton.removeClass('buttondisabled');
// show result
$pasteSuccess.removeClass('hidden');
// we pre-select the link so that the user only has to [Ctrl]+[c] the link
Helper.selectText($pasteUrl[0]);
};
/**
* extracts URLs from given string
*
* if at least one is found, it disables the shortener button and
* replaces the paste 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.addClass('buttondisabled');
// update link
$pasteUrl.text(shortUrl);
$pasteUrl.prop('href', shortUrl);
// we pre-select the link so that the user only has to [Ctrl]+[c] the link
Helper.selectText($pasteUrl[0]);
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 paste "for your eyes only" if it is deleted
// the paste has been deleted when the JSON with the ciphertext
// has been downloaded
Alert.showRemaining('FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.');
$remainingTime.addClass('foryoureyesonly');
} else if (paste.getTimeToLive() > 0) {
// display paste 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.removeClass('foryoureyesonly');
} else {
// never expires
return;
}
// in the end, display notification
$remainingTime.removeClass('hidden');
};
/**
* hides the remaining time and successful upload notification
*
* @name PasteStatus.hideMessages
* @function
*/
me.hideMessages = function()
{
$remainingTime.addClass('hidden');
$pasteSuccess.addClass('hidden');
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name PasteStatus.init
* @function
*/
me.init = function()
{
$pasteSuccess = $('#pastesuccess');
// $pasteUrl is saved in me.createPasteNotification() after creation
$remainingTime = $('#remainingtime');
$shortenButton = $('#shortenbutton');
// bind elements
$shortenButton.click(sendToShortener);
};
return me;
})();
/**
* password prompt
*
* @name Prompt
* @class
*/
const Prompt = (function () {
const me = {};
let $passwordDecrypt,
$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.val();
// hide modal
if (bootstrap5PasswordModal) {
bootstrap5PasswordModal.hide();
} else {
$passwordModal.modal('hide');
}
PasteDecrypter.run();
}
/**
* Request users confirmation to load possibly burn after reading paste
*
* @name Prompt.requestLoadConfirmation
* @function
*/
me.requestLoadConfirmation = function()
{
const $loadconfirmmodal = $('#loadconfirmmodal');
if ($loadconfirmmodal.length > 0) {
const $loadconfirmOpenNow = $loadconfirmmodal.find('#loadconfirm-open-now');
$loadconfirmOpenNow.off('click.loadPaste');
$loadconfirmOpenNow.on('click.loadPaste', PasteDecrypter.run);
const $loadconfirmClose = $loadconfirmmodal.find('.close');
$loadconfirmClose.off('click.close');
$loadconfirmClose.on('click.close', Controller.newPaste);
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip.VERSION) {
(new bootstrap.Modal($loadconfirmmodal[0])).show();
} else {
$loadconfirmmodal.modal('show');
}
} else {
if (window.confirm(
I18n._('This secret message can only be displayed once. Would you like to see it now?')
)) {
PasteDecrypter.run();
} else {
Controller.newPaste();
}
}
}
/**
* ask the user for the password and set it
*
* @name Prompt.requestPassword
* @function
*/
me.requestPassword = function()
{
// show new bootstrap method (if available)
if ($passwordModal.length !== 0) {
if (bootstrap5PasswordModal) {
bootstrap5PasswordModal.show();
} else {
$passwordModal.modal('show');
}
return;
}
// fallback to old method for page template
password = prompt(I18n._('Please enter the password for this paste:'), '');
if (password === null) {
throw 'password prompt canceled';
}
if (password.length === 0) {
// recurse…
return me.requestPassword();
}
PasteDecrypter.run();
};
/**
* get the cached password
*
* If you do not get a password with this function
* (returns an empty string), use requestPassword.
*
* @name Prompt.getPassword
* @function
* @return {string}
*/
me.getPassword = function()
{
return password;
};
/**
* resets the password to an empty string
*
* @name Prompt.reset
* @function
*/
me.reset = function()
{
// reset internal
password = '';
// and also reset UI
$passwordDecrypt.val('');
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name Prompt.init
* @function
*/
me.init = function()
{
$passwordDecrypt = $('#passworddecrypt');
$passwordModal = $('#passwordmodal');
// bind events - handle Model password submission
if ($passwordModal.length !== 0) {
$('#passwordform').submit(submitPasswordModal);
const disableClosingConfig = {
backdrop: 'static',
keyboard: false,
show: false
};
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip.VERSION) {
bootstrap5PasswordModal = new bootstrap.Modal($passwordModal[0], disableClosingConfig);
} else {
$passwordModal.modal(disableClosingConfig);
}
$passwordModal.on('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();
$messageTab[0].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.addClass('active');
$messageEditParent.addClass('active');
$messagePreview.removeClass('active');
$messagePreviewParent.removeClass('active');
$messageEdit.attr('aria-selected','true');
$messagePreview.attr('aria-selected','false');
PasteViewer.hide();
// reshow input
$message.removeClass('hidden');
$messageTabParent.removeClass('hidden');
me.focusInput();
// finish
isPreview = false;
// prevent jumping of page to top
if (typeof event !== 'undefined') {
event.preventDefault();
}
}
/**
* view the preview tab
*
* @name Editor.viewPreview
* @function
* @param {Event} event
*/
function viewPreview(event)
{
// toggle buttons
$messageEdit.removeClass('active');
$messageEditParent.removeClass('active');
$messagePreview.addClass('active');
$messagePreviewParent.addClass('active');
$messageEdit.attr('aria-selected','false');
$messagePreview.attr('aria-selected','true');
// hide input as now preview is shown
$message.addClass('hidden');
$messageTabParent.addClass('hidden');
// show preview
PasteViewer.setText($message.val());
if (AttachmentViewer.hasAttachmentData()) {
const attachment = AttachmentViewer.getAttachment();
AttachmentViewer.handleBlobAttachmentPreview(
AttachmentViewer.getAttachmentPreview(),
attachment[0], attachment[1]
);
}
PasteViewer.run();
// finish
isPreview = true;
// prevent jumping of page to top
if (typeof event !== 'undefined') {
event.preventDefault();
}
}
/**
* get the state of the preview
*
* @name Editor.isPreview
* @function
*/
me.isPreview = function()
{
return isPreview;
};
/**
* reset the Editor view
*
* @name Editor.resetInput
* @function
*/
me.resetInput = function()
{
// go back to input
if (isPreview) {
viewEditor();
}
// clear content
$message.val('');
};
/**
* shows the Editor
*
* @name Editor.show
* @function
*/
me.show = function()
{
$message.removeClass('hidden');
$messageTabParent.removeClass('hidden');
$editorTabs.removeClass('hidden');
};
/**
* hides the Editor
*
* @name Editor.hide
* @function
*/
me.hide = function()
{
$message.addClass('hidden');
$messageTabParent.addClass('hidden');
$editorTabs.addClass('hidden');
};
/**
* focuses the message input
*
* @name Editor.focusInput
* @function
*/
me.focusInput = function()
{
$message.focus();
};
/**
* sets a new text
*
* @name Editor.setText
* @function
* @param {string} newText
*/
me.setText = function(newText)
{
$message.val(newText);
};
/**
* returns the current text
*
* @name Editor.getText
* @function
* @return {string}
*/
me.getText = function()
{
return $message.val();
};
/**
* init editor
*
* preloads jQuery elements
*
* @name Editor.init
* @function
*/
me.init = function()
{
$editorTabs = $('#editorTabs');
$message = $('#message');
$messageTab = $('#messagetab');
$messageTabParent = $messageTab.parent();
// bind events
$message.keydown(supportTabs);
$messageTab.change(toggleTabSupport);
// bind click events to tab switchers (a), and save parents (li)
$messageEdit = $('#messageedit').click(viewEditor);
$messageEditParent = $messageEdit.parent();
$messagePreview = $('#messagepreview').click(viewPreview);
$messagePreviewParent = $messagePreview.parent();
};
return me;
})();
/**
* (view) Parse and show paste.
*
* @name PasteViewer
* @class
*/
const PasteViewer = (function () {
const me = {};
let $messageTabParent,
$placeholder,
$prettyMessage,
$prettyPrint,
$plainText,
text,
format = 'plaintext',
isDisplayed = false,
isChanged = true; // by default true as nothing was parsed yet
/**
* apply the set format on paste and displays it
*
* @name PasteViewer.parsePaste
* @private
* @function
*/
function parsePaste()
{
// skip parsing if no text is given
if (text === '') {
return;
}
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.html(
DOMPurify.sanitize(
converter.makeHtml(text),
purifyHtmlConfig
)
);
// add table classes from bootstrap css
$plainText.find('table').addClass('table-condensed table-bordered');
} else {
if (format === 'syntaxhighlighting') {
// yes, this is really needed to initialize the environment
if (typeof prettyPrint === 'function')
{
prettyPrint();
}
$prettyPrint.html(
prettyPrintOne(
Helper.htmlEntities(text), null, true
)
);
} else {
// = 'plaintext'
$prettyPrint.text(text);
}
Helper.urls2links($prettyPrint);
$prettyPrint.css('white-space', 'pre-wrap');
$prettyPrint.css('word-break', 'normal');
$prettyPrint.removeClass('prettyprint');
}
}
/**
* displays the paste
*
* @name PasteViewer.showPaste
* @private
* @function
*/
function showPaste()
{
// instead of "nothing" better display a placeholder
if (text === '') {
$placeholder.removeClass('hidden');
return;
}
// otherwise hide the placeholder
$placeholder.addClass('hidden');
$messageTabParent.addClass('hidden');
if (format === 'markdown') {
$plainText.removeClass('hidden');
$prettyMessage.addClass('hidden');
} else {
$plainText.addClass('hidden');
$prettyMessage.removeClass('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;
}
$plainText.addClass('hidden');
$prettyMessage.addClass('hidden');
$placeholder.addClass('hidden');
AttachmentViewer.hideAttachmentPreview();
isDisplayed = false;
};
/**
* init status manager
*
* preloads jQuery elements
*
* @name PasteViewer.init
* @function
*/
me.init = function()
{
$messageTabParent = $('#messagetab').parent();
$placeholder = $('#placeholder');
$plainText = $('#plaintext');
$prettyMessage = $('#prettymessage');
$prettyPrint = $('#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 $attachmentLink,
$attachmentPreview,
$attachment,
attachmentData,
file,
$fileInput,
$dragAndDropFileName,
attachmentHasPreview = false,
$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 (!$attachmentLink || !$attachmentPreview) return;
// data URI format: data:[][;base64],
// position in data URI string of where data begins
const base64Start = attachmentData.indexOf(',') + 1;
// position in data URI string of where mimeType ends
const mimeTypeEnd = attachmentData.indexOf(';');
// extract mimeType
const mimeType = attachmentData.substring(5, mimeTypeEnd);
// extract data and convert to binary
const rawData = attachmentData.substring(base64Start);
const decodedData = rawData.length > 0 ? atob(rawData) : '';
let blobUrl = getBlobUrl(decodedData, mimeType);
$attachmentLink.attr('href', blobUrl);
if (typeof fileName !== 'undefined') {
$attachmentLink.attr('download', fileName);
}
// sanitize SVG preview
// prevents executing embedded scripts when CSP is not set and user
// right-clicks/long-taps and opens the SVG in a new tab - prevented
// in the preview by use of an img tag, which disables scripts, too
if (mimeType.match(/^image\/.*svg/i)) {
const sanitizedData = DOMPurify.sanitize(
decodedData,
purifySvgConfig
);
blobUrl = getBlobUrl(sanitizedData, mimeType);
}
me.handleBlobAttachmentPreview($attachmentPreview, blobUrl, mimeType);
};
/**
* displays the attachment
*
* @name AttachmentViewer.showAttachment
* @function
*/
me.showAttachment = function()
{
// skip, if attachments got disabled
if (!$attachment || !$attachmentPreview) return;
$attachment.removeClass('hidden');
if (attachmentHasPreview) {
$attachmentPreview.removeClass('hidden');
}
};
/**
* removes the attachment
*
* This automatically hides the attachment containers too, to
* prevent an inconsistent display.
*
* @name AttachmentViewer.removeAttachment
* @function
*/
me.removeAttachment = function()
{
if (!$attachment.length) {
return;
}
me.hideAttachment();
me.hideAttachmentPreview();
$attachmentLink.removeAttr('href');
$attachmentLink.removeAttr('download');
$attachmentLink.off('click');
$attachmentPreview.html('');
$dragAndDropFileName.text('');
AttachmentViewer.removeAttachmentData();
};
/**
* removes the attachment data
*
* This removes the data, which would be uploaded otherwise.
*
* @name AttachmentViewer.removeAttachmentData
* @function
*/
me.removeAttachmentData = function()
{
file = undefined;
attachmentData = undefined;
};
/**
* Cleares the drag & drop data.
*
* @name AttachmentViewer.clearDragAndDrop
* @function
*/
me.clearDragAndDrop = function()
{
$dragAndDropFileName.text('');
};
/**
* hides the attachment
*
* This will not hide the preview (see AttachmentViewer.hideAttachmentPreview
* for that) nor will it hide the attachment link if it was moved somewhere
* else (see AttachmentViewer.moveAttachmentTo).
*
* @name AttachmentViewer.hideAttachment
* @function
*/
me.hideAttachment = function()
{
$attachment.addClass('hidden');
};
/**
* hides the attachment preview
*
* @name AttachmentViewer.hideAttachmentPreview
* @function
*/
me.hideAttachmentPreview = function()
{
if ($attachmentPreview) {
$attachmentPreview.addClass('hidden');
}
};
/**
* checks if there is an attachment displayed
*
* @name AttachmentViewer.hasAttachment
* @function
*/
me.hasAttachment = function()
{
if (!$attachment.length) {
return false;
}
const link = $attachmentLink.prop('href');
return (typeof link !== 'undefined' && link !== '');
};
/**
* checks if there is attachment data (for preview!) available
*
* It returns true, when there is data that needs to be encrypted.
*
* @name AttachmentViewer.hasAttachmentData
* @function
*/
me.hasAttachmentData = function()
{
if ($attachment.length) {
return true;
}
return false;
};
/**
* return the attachment
*
* @name AttachmentViewer.getAttachment
* @function
* @returns {array}
*/
me.getAttachment = function()
{
return [
$attachmentLink.prop('href'),
$attachmentLink.prop('download')
];
};
/**
* moves the attachment link to another element
*
* It is advisable to hide the attachment afterwards (AttachmentViewer.hideAttachment)
*
* @name AttachmentViewer.moveAttachmentTo
* @function
* @param {jQuery} $element - the wrapper/container element where this should be moved to
* @param {string} label - the text to show (%s will be replaced with the file name), will automatically be translated
*/
me.moveAttachmentTo = function($element, label)
{
// move elemement to new place
$attachmentLink.appendTo($element);
// update text - ensuring no HTML is inserted into the text node
I18n._($attachmentLink, label, $attachmentLink.attr('download'));
};
/**
* read file data as data URL using the FileReader API
*
* @name AttachmentViewer.readFileData
* @private
* @function
* @param {object} loadedFile (optional) loaded file object
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/FileReader#readAsDataURL()}
*/
function readFileData(loadedFile) {
if (typeof FileReader === 'undefined') {
// revert loading status…
me.hideAttachment();
me.hideAttachmentPreview();
Alert.showWarning('Your browser does not support uploading encrypted files. Please use a newer browser.');
return;
}
const fileReader = new FileReader();
if (loadedFile === undefined) {
loadedFile = $fileInput[0].files[0];
$dragAndDropFileName.text('');
} else {
$dragAndDropFileName.text(loadedFile.name);
}
if (typeof loadedFile !== 'undefined') {
file = loadedFile;
fileReader.onload = function (event) {
const dataURL = event.target.result;
attachmentData = dataURL;
if (Editor.isPreview()) {
me.handleAttachmentPreview($attachmentPreview, dataURL);
$attachmentPreview.removeClass('hidden');
}
TopNav.highlightFileupload();
};
fileReader.readAsDataURL(loadedFile);
} else {
me.removeAttachmentData();
}
}
/**
* handle the preview of files decoded to blob that can either be an image, video, audio or pdf element
*
* @name AttachmentViewer.handleBlobAttachmentPreview
* @function
* @argument {jQuery} $targetElement element where the preview should be appended
* @argument {string} file as a blob URL
* @argument {string} mime type
*/
me.handleBlobAttachmentPreview = function ($targetElement, blobUrl, mimeType) {
if (blobUrl) {
attachmentHasPreview = true;
if (mimeType.match(/^image\//i)) {
$targetElement.html(
$(document.createElement('img'))
.attr('src', blobUrl)
.attr('class', 'img-thumbnail')
);
} else if (mimeType.match(/^video\//i)) {
$targetElement.html(
$(document.createElement('video'))
.attr('controls', 'true')
.attr('autoplay', 'true')
.attr('class', 'img-thumbnail')
.append($(document.createElement('source'))
.attr('type', mimeType)
.attr('src', blobUrl))
);
} else if (mimeType.match(/^audio\//i)) {
$targetElement.html(
$(document.createElement('audio'))
.attr('controls', 'true')
.attr('autoplay', 'true')
.append($(document.createElement('source'))
.attr('type', mimeType)
.attr('src', blobUrl))
);
} else if (mimeType.match(/\/pdf/i)) {
// Fallback for browsers, that don't support the vh unit
const clientHeight = $(window).height();
$targetElement.html(
$(document.createElement('embed'))
.attr('src', blobUrl)
.attr('type', 'application/pdf')
.attr('class', 'pdfPreview')
.css('height', clientHeight)
);
} else {
attachmentHasPreview = false;
}
}
};
/**
* attaches the file attachment drag & drop handler to the page
*
* @name AttachmentViewer.addDragDropHandler
* @private
* @function
*/
function addDragDropHandler() {
if (typeof $fileInput === 'undefined' || $fileInput.length === 0) {
return;
}
const handleDragEnterOrOver = function(event) {
event.stopPropagation();
event.preventDefault();
return false;
};
const handleDrop = function(event) {
const evt = event.originalEvent;
evt.stopPropagation();
evt.preventDefault();
if (TopNav.isAttachmentReadonly()) {
return false;
}
if ($fileInput) {
const file = evt.dataTransfer.files[0];
//Clear the file input:
$fileInput.wrap('