/** * PrivateBin * * a zero-knowledge paste bin * * @see {@link https://github.com/PrivateBin/PrivateBin} * @copyright 2012 Sébastien SAUVAGE ({@link http://sebsauvage.net}) * @license {@link https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License} * @version 1.3.2 * @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; /** * CryptoData class * * bundles helper fuctions 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 Paste.getCipherData * @function * @return {Array}|{string} */ this.getCipherData = function() { return this.v === 1 ? this.data : [this.ct, this.adata]; } } /** * Paste class * * bundles helper fuctions 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 fuctions 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 Paste.getCreated * @function * @return {int} */ this.getCreated = function() { return this.meta[this.v === 1 ? 'postdate' : 'created']; } /** * gets the icon of the comment submitter * * @name Paste.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 */ var entityMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=' }; /** * cache for script location * * @name Helper.baseUri * @private * @enum {string|null} */ let baseUri = null; /** * converts a duration (in seconds) into human friendly approximation * * @name Helper.secondsToHuman * @function * @param {number} seconds * @return {Array} */ me.secondsToHuman = function(seconds) { let v; if (seconds < 60) { v = Math.floor(seconds); return [v, 'second']; } if (seconds < 60 * 60) { v = Math.floor(seconds / 60); return [v, 'minute']; } if (seconds < 60 * 60 * 24) { v = Math.floor(seconds / (60 * 60)); return [v, 'hour']; } // If less than 2 months, display in days: if (seconds < 60 * 60 * 24 * 60) { v = Math.floor(seconds / (60 * 60 * 24)); return [v, 'day']; } v = Math.floor(seconds / (60 * 60 * 24 * 30)); return [v, 'month']; }; /** * text range selection * * @see {@link https://stackoverflow.com/questions/985272/jquery-selecting-text-in-an-element-akin-to-highlighting-with-your-mouse} * @name Helper.selectText * @function * @param {HTMLElement} element */ me.selectText = function(element) { let range, selection; // MS if (document.body.createTextRange) { range = document.body.createTextRange(); range.moveToElementText(element); range.select(); } else if (window.getSelection) { selection = window.getSelection(); range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } }; /** * convert URLs to clickable links. * * URLs to handle: *
         *     magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7
         *     https://example.com:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
         *     http://user:example.com@localhost:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
         * 
* * @name Helper.urls2links * @function * @param {string} html * @return {string} */ me.urls2links = function(html) { return html.replace( /(((https?|ftp):\/\/[\w?!=&.\/-;#@~%+*-]+(?![\w\s?!&.\/;#~%"=-]*>))|((magnet):[\w?=&.\/-;#@~%+*-]+))/ig, '$1' ); }; /** * minimal sprintf emulation for %s and %d formats * * Note that this function needs the parameters in the same order as the * format strings appear in the string, contrary to the original. * * @see {@link https://stackoverflow.com/questions/610406/javascript-equivalent-to-printf-string-format#4795914} * @name Helper.sprintf * @function * @param {string} format * @param {...*} args - one or multiple parameters injected into format string * @return {string} */ me.sprintf = function() { const args = Array.prototype.slice.call(arguments); let format = args[0], i = 1; return format.replace(/%(s|d)/g, function (m) { 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]; } ); } /** * resets state, used for unit testing * * @name Helper.reset * @function */ me.reset = function() { baseUri = null; }; /** * 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); const expirationDisplayStringToSecondsDict = { '5min': 300, '10min': 600, '1hour': 3500, '1day': 86400, '1week': 604800, '1month': 2592000, '1year': 31536000, 'never': 0 }; let secondsToExpiration = expirationDisplayStringOrSecondsToExpire; if (typeof expirationDisplayStringOrSecondsToExpire === 'string') { secondsToExpiration = expirationDisplayStringToSecondsDict[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; }; 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 = ['bg', 'cs', 'de', 'es', 'fr', 'it', 'hu', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sl', '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 {jQuery} $element - optional * @param {string} messageId * @param {...*} args - one or multiple parameters injected into placeholders * @return {string} */ me._ = function() { return me.translate.apply(this, arguments); }; /** * translate a string * * As the first parameter a jQuery element has to be provided, to let * the text of this element be replaced. In case the (asynchronously * loaded) language is not downloadet yet, this will make sure the string * is replaced when it is actually loaded. This also handles HTML in * secure fashion, to avoid XSS. * The second 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 {jQuery} $element * @param {string} messageId * @param {...*} args - one or multiple parameters injected into placeholders * @throws {string} */ me.translate = function() { // convert parameters to array let args = Array.prototype.slice.call(arguments), messageId, $element = null; // parse arguments if (args[0] instanceof jQuery) { // optional jQuery element as first parameter $element = args[0]; args.shift(); } else { throw 'translation requires a jQuery element to be passed, for secure insertion of messages and to avoid double encoding of HTML entities'; } // extract messageId from arguments let usesPlurals = $.isArray(args[0]); if (usesPlurals) { // use the first plural form as messageId, otherwise the singular messageId = args[0].length > 1 ? args[0][1] : args[0][0]; } else { messageId = args[0]; } if (messageId.length === 0) { return messageId; } // if no translation string cannot be found (in translations object) if (!translations.hasOwnProperty(messageId) || language === null) { // if language is still loading and we have an elemt assigned if (language === null && $element !== null) { // handle the error by attaching the language loaded event let orgArguments = arguments; $(document).on(languageLoadedEvent, function () { // re-execute this function me.translate.apply(this, orgArguments); }); // and fall back to English for now until the real language // file is loaded } // for all other languages than English for which this behaviour // is expected as it is built-in, log error if (language !== null && language !== 'en') { console.error('Missing translation for: \'' + messageId + '\' in language ' + language); // fallback to English } // save English translation (should be the same on both sides) translations[messageId] = args[0]; } // lookup plural translation if (usesPlurals && $.isArray(translations[messageId])) { let n = parseInt(args[1] || 1, 10), key = me.getPluralForm(n), maxKey = translations[messageId].length - 1; if (key > maxKey) { key = maxKey; } args[0] = translations[messageId][key]; args[1] = n; } else { // lookup singular translation args[0] = translations[messageId]; } // messageID may contain links, but should be from a trusted source (code or translation JSON files) let containsLinks = args[0].indexOf(' 0) may never contain HTML as they may come from untrusted parties if (i > 0) { args[i] = Helper.htmlEntities(args[i]); } } } // format string let output = Helper.sprintf.apply(this, args); if (containsLinks) { // only allow tags/attributes we actually use in translations output = DOMPurify.sanitize( output, { ALLOWED_TAGS: ['a', 'br', 'i', 'span'], ALLOWED_ATTR: ['href', 'id'] } ); } if (containsLinks) { $element.html(output); } else { // text node takes care of entity encoding $element.text(output); } }; /** * translate a string, outputs the result * * This function is identical to I18n.translate, but doesn't require a * jQuery element as the first parameter, instead it returns the * translated message as string. * Avoid using this function, if possible, as it may double encode your * message's HTML entities. This is done to fail safe, preventing XSS. * * @name I18n.translate2string * @function * @param {string} messageId * @param {...*} args - one or multiple parameters injected into placeholders * @throws {string} * @return {string} */ me.translate2string = function() { let args = Array.prototype.slice.call(arguments), $element = $('