From 2ebcf60516d2991d0869d10dcaad5b2924d804e2 Mon Sep 17 00:00:00 2001 From: rugk Date: Wed, 8 Feb 2017 13:20:51 +0100 Subject: [PATCH 01/29] Use revealing module pattern ala http://www.adequatelygood.com/JavaScript-Module-Pattern-In-Depth.html Also made the loadTranslations a bit more robust with more error messaged being logged. --- .eslintrc | 2 +- js/privatebin.js | 1110 +++++++++++++++++++++++++-------------------- tpl/bootstrap.php | 2 +- 3 files changed, 613 insertions(+), 501 deletions(-) diff --git a/.eslintrc b/.eslintrc index 97437c73..a5e0c90e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -99,7 +99,7 @@ rules: no-with: 2 radix: 2 vars-on-top: 0 - wrap-iife: 2 + wrap-iife: 0 yoda: 0 # Strict diff --git a/js/privatebin.js b/js/privatebin.js index 4fd0e99b..ed3baa3b 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -25,14 +25,49 @@ // Immediately start random number generator collector. sjcl.random.startCollectors(); +// jQuery(document).ready(function() { +// // startup +// } + jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { /** * static helper methods * + * @param {object} window + * @param {object} document * @name helper * @class */ - var helper = { + var helper = (function (window, document) { + var me = {}; + + /** + * character to HTML entity lookup table + * + * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} + * @private + * @enum {Object} + * @readonly + */ + var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + + /** + * cache for script location + * + * @private + * @enum {string|null} + */ + var scriptLocation = null; + /** * converts a duration (in seconds) into human friendly approximation * @@ -41,7 +76,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {number} seconds * @return {Array} */ - secondsToHuman: function(seconds) + me.secondsToHuman = function(seconds) { var v; if (seconds < 60) @@ -67,7 +102,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } v = Math.floor(seconds / (60 * 60 * 24 * 30)); return [v, 'month']; - }, + }; /** * text range selection @@ -75,47 +110,45 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @see {@link https://stackoverflow.com/questions/985272/jquery-selecting-text-in-an-element-akin-to-highlighting-with-your-mouse} * @name helper.selectText * @function - * @param {string} element - Indentifier of the element to select (id="") + * @param {HTMLElement} element */ - selectText: function(element) + me.selectText = function(element) { - var doc = document, - text = doc.getElementById(element), - range, - selection; + var range, selection; // MS - if (doc.body.createTextRange) + if (document.body.createTextRange) { - range = doc.body.createTextRange(); - range.moveToElementText(text); + range = document.body.createTextRange(); + range.moveToElementText(element); range.select(); } // all others else if (window.getSelection) { selection = window.getSelection(); - range = doc.createRange(); - range.selectNodeContents(text); + range = document.createRange(); + range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } - }, + }; /** * set text of a DOM element (required for IE), - * this is equivalent to element.text(text) * * @name helper.setElementText * @function * @param {Object} element - a DOM element * @param {string} text - the text to enter + * @this is equivalent to element.text(text) + * @TODO check for XSS attacks, usually no CSS can prevent them so this looks weird on the first look */ - setElementText: function(element, text) + me.setElementText = function(element, text) { // For IE<10: Doesn't support white-space:pre-wrap; so we have to do this... if ($('#oldienotice').is(':visible')) { - var html = this.htmlEntities(text).replace(/\n/ig, '\r\n
'); + var html = me.htmlEntities(text).replace(/\n/ig, '\r\n
'); element.html('
' + html + '
'); } // for other (sane) browsers: @@ -123,7 +156,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { element.text(text); } - }, + }; /** * replace last child of element with message @@ -133,7 +166,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {Object} element - a jQuery wrapped DOM element * @param {string} message - the message to append */ - setMessage: function(element, message) + me.setMessage = function(element, message) { var content = element.contents(); if (content.length > 0) @@ -142,9 +175,9 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } else { - this.setElementText(element, message); + me.setElementText(element, message); } - }, + }; /** * convert URLs to clickable links. @@ -159,7 +192,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Object} element - a jQuery DOM element */ - urls2links: function(element) + me.urls2links = function(element) { var markup = '$1'; element.html( @@ -174,7 +207,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { markup ) ); - }, + }; /** * minimal sprintf emulation for %s and %d formats @@ -186,7 +219,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {...*} args - one or multiple parameters injected into format string * @return {string} */ - sprintf: function() + me.sprintf = function() { var args = arguments; if (typeof arguments[0] === 'object') @@ -218,7 +251,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } return val; }); - }, + }; /** * get value of cookie, if it was set, empty string otherwise @@ -229,7 +262,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} cname * @return {string} */ - getCookie: function(cname) { + me.getCookie = function(cname) { var name = cname + '=', ca = document.cookie.split(';'); for (var i = 0; i < ca.length; ++i) { @@ -244,7 +277,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } } return ''; - }, + }; /** * get the current script location (without search or hash part of the URL), @@ -254,19 +287,27 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @return {string} current script location */ - scriptLocation: function() + me.scriptLocation = function() { - var scriptLocation = window.location.href.substring( + // check for cached version + if (scriptLocation !== null) { + return scriptLocation; + } + + scriptLocation = window.location.href.substring( 0, window.location.href.length - window.location.search.length - window.location.hash.length - ), - hashIndex = scriptLocation.indexOf('?'); + ); + + var hashIndex = scriptLocation.indexOf('?'); + if (hashIndex !== -1) { scriptLocation = scriptLocation.substring(0, hashIndex); } + return scriptLocation; - }, + }; /** * get the pastes unique identifier from the URL, @@ -276,10 +317,10 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @return {string} unique identifier */ - pasteId: function() + me.pasteId = function() { return window.location.search.substring(1); - }, + }; /** * return the deciphering key stored in anchor part of the URL @@ -288,7 +329,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @return {string} key */ - pageKey: function() + me.pageKey = function() { var key = window.location.hash.substring(1), i = key.indexOf('&'); @@ -301,7 +342,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } return key; - }, + }; /** * convert all applicable characters to HTML entities @@ -312,48 +353,51 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} str * @return {string} escaped HTML */ - htmlEntities: function(str) { + me.htmlEntities = function(str) { return String(str).replace( /[&<>"'`=\/]/g, function(s) { - return helper.entityMap[s]; + return entityMap[s]; }); - }, + }; - /** - * character to HTML entity lookup table - * - * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} - * @name helper.entityMap - * @enum {Object} - * @readonly - */ - entityMap: { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/', - '`': '`', - '=': '=' - } - }; + return me; + })(window, document); /** * internationalization methods * + * @param {object} window + * @param {object} document * @name i18n * @class */ - var i18n = { + var i18n = (function (window, document) { + var me = {}; + /** * supported languages, minus the built in 'en' * - * @name i18n.supportedLanguages + * @private * @prop {string[]} * @readonly */ - supportedLanguages: ['de', 'es', 'fr', 'it', 'no', 'pl', 'oc', 'ru', 'sl', 'zh'], + var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'oc', 'ru', 'sl', 'zh']; + + /** + * built in language + * + * @private + * @prop {string} + */ + var language = 'en'; + + /** + * translation cache + * + * @private + * @enum {Object} + */ + var translations = {}; /** * translate a string, alias for i18n.translate() @@ -364,10 +408,10 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {...*} args - one or multiple parameters injected into placeholders * @return {string} */ - _: function() + me._ = function() { - return this.translate(arguments); - }, + return me.translate(arguments); + }; /** * translate a string @@ -378,7 +422,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {...*} args - one or multiple parameters injected into placeholders * @return {string} */ - translate: function() + me.translate = function() { var args = arguments, messageId; if (typeof arguments[0] === 'object') @@ -399,34 +443,34 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { return messageId; } - if (!this.translations.hasOwnProperty(messageId)) + if (!translations.hasOwnProperty(messageId)) { - if (this.language !== 'en') + if (language !== 'en') { - console.debug( - 'Missing ' + this.language + ' translation for: ' + messageId + console.error( + 'Missing ' + language + ' translation for: ' + messageId ); } - this.translations[messageId] = args[0]; + translations[messageId] = args[0]; } - if (usesPlurals && $.isArray(this.translations[messageId])) + if (usesPlurals && $.isArray(translations[messageId])) { var n = parseInt(args[1] || 1, 10), - key = this.getPluralForm(n), - maxKey = this.translations[messageId].length - 1; + key = me.getPluralForm(n), + maxKey = translations[messageId].length - 1; if (key > maxKey) { key = maxKey; } - args[0] = this.translations[messageId][key]; + args[0] = translations[messageId][key]; args[1] = n; } else { - args[0] = this.translations[messageId]; + args[0] = translations[messageId]; } return helper.sprintf(args); - }, + }; /** * per language functions to use to determine the plural form @@ -437,8 +481,8 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {number} n * @return {number} array key */ - getPluralForm: function(n) { - switch (this.language) + me.getPluralForm = function(n) { + switch (language) { case 'fr': case 'oc': @@ -454,7 +498,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { default: return (n !== 1 ? 1 : 0); } - }, + }; /** * load translations into cache, then trigger controller initialization @@ -462,52 +506,55 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name i18n.loadTranslations * @function */ - loadTranslations: function() + me.loadTranslations = function() { - var language = helper.getCookie('lang'); - if (language.length === 0) + var newLanguage = helper.getCookie('lang'); + + // auto-select language based on browser settings + if (newLanguage.length === 0) { - language = (navigator.language || navigator.userLanguage).substring(0, 2); + newLanguage = (navigator.language || navigator.userLanguage).substring(0, 2); } - // note that 'en' is built in, so no translation is necessary - if (i18n.supportedLanguages.indexOf(language) === -1) + + // if language is already used (e.g, default 'en'), skip update + if (newLanguage === language) { controller.init(); + return; } - else + + // if language is not supported, show error + if (supportedLanguages.indexOf(newLanguage) === -1) { - $.getJSON('i18n/' + language + '.json', function(data) { - i18n.language = language; - i18n.translations = data; - controller.init(); - }); + console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage); + controller.init(); } - }, - /** - * built in language - * - * @name i18n.language - * @prop {string} - */ - language: 'en', + // load strongs from JSON + $.getJSON('i18n/' + newLanguage + '.json', function(data) { + language = newLanguage; + translations = data; + }).fail(function (data, textStatus, errorMsg) { + console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg); + }); - /** - * translation cache - * - * @name i18n.translations - * @enum {Object} - */ - translations: {} - }; + controller.init(); + }; + + return me; + })(window, document); /** * filter methods * + * @param {object} window + * @param {object} document * @name filter * @class */ - var filter = { + var filter = (function (window, document) { + var me = {}; + /** * compress a message (deflate compression), returns base64 encoded data * @@ -516,7 +563,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} message * @return {string} base64 data */ - compress: function(message) + me.compress = function(message) { return Base64.toBase64( RawDeflate.deflate( Base64.utob(message) ) ); }, @@ -529,7 +576,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} data - base64 data * @return {string} message */ - decompress: function(data) + me.decompress = function(data) { return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) ); }, @@ -544,15 +591,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} message * @return {string} data - JSON with encrypted data */ - cipher: function(key, password, message) + me.cipher = function(key, password, message) { // Galois Counter Mode, keysize 256 bit, authentication tag 128 bit var options = {mode: 'gcm', ks: 256, ts: 128}; if ((password || '').trim().length === 0) { - return sjcl.encrypt(key, this.compress(message), options); + return sjcl.encrypt(key, me.compress(message), options); } - return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), this.compress(message), options); + return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), me.compress(message), options); }, /** @@ -565,58 +612,107 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} data - JSON with encrypted data * @return {string} decrypted message */ - decipher: function(key, password, data) + me.decipher = function(key, password, data) { if (data !== undefined) { try { - return this.decompress(sjcl.decrypt(key, data)); + return me.decompress(sjcl.decrypt(key, data)); } catch(err) { try { - return this.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); + return me.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); } catch(e) - {} + { + // ignore error, because ????? @TODO + } } } return ''; } - }; + + return me; + })(window, document); /** * PrivateBin logic * + * @param {object} window + * @param {object} document * @name controller * @class */ - var controller = { + var controller = (function (window, document) { + var me = {}; + /** * headers to send in AJAX requests * - * @name controller.headers + * @private * @enum {Object} */ - headers: {'X-Requested-With': 'JSONHttpRequest'}, + var headers = {'X-Requested-With': 'JSONHttpRequest'}; /** * URL shortners create address * - * @name controller.shortenerUrl + * @private * @prop {string} */ - shortenerUrl: '', + var shortenerUrl = ''; /** * URL of newly created paste * - * @name controller.createdPasteUrl + * @private * @prop {string} */ - createdPasteUrl: '', + var createdPasteUrl = ''; + + // jQuery pre-loaded objects + var $attach, + $attachment, + $attachmentLink, + $burnAfterReading, + $burnAfterReadingOption, + $cipherData, + $clearText, + $cloneButton, + $clonedFile, + $comments, + $discussion, + $errorMessage, + $expiration, + $fileRemoveButton, + $fileWrap, + $formatter, + $image, + $loadingIndicator, + $message, + $messageEdit, + $messagePreview, + $newButton, + $openDisc, // @TODO: rename - too similar to openDiscussion, difference unclear + $openDiscussion, + $password, + $passwordInput, + $passwordModal, + $passwordForm, + $passwordDecrypt, + $pasteResult, + $pasteUrl, + $prettyMessage, + $prettyPrint, + $preview, + $rawTextButton, + $remainingTime, + $replyStatus, + $sendButton, + $status; /** * ask the user for the password and set it @@ -624,9 +720,9 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.requestPassword * @function */ - requestPassword: function() + me.requestPassword = function() { - if (this.passwordModal.length === 0) { + if ($passwordModal.length === 0) { var password = prompt(i18n._('Please enter the password for this paste:'), ''); if (password === null) { @@ -634,15 +730,16 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } if (password.length === 0) { - this.requestPassword(); + // recursive… + me.requestPassword(); } else { - this.passwordInput.val(password); - this.displayMessages(); + $passwordInput.val(password); + me.displayMessages(); } } else { - this.passwordModal.modal(); + $passwordModal.modal(); } - }, + }; /** * use given format on paste, defaults to plain text @@ -652,13 +749,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} format * @param {string} text */ - formatPaste: function(format, text) + me.formatPaste = function(format, text) { - helper.setElementText(this.clearText, text); - helper.setElementText(this.prettyPrint, text); - switch (format || 'plaintext') - { + helper.setElementText($clearText, text); + helper.setElementText($prettyPrint, text); + + switch (format || 'plaintext') { case 'markdown': + // silently fail if showdown is not available + // @TODO: maybe better show an error message? At least a warning? if (typeof showdown === 'object') { var converter = new showdown.Converter({ @@ -666,44 +765,50 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { tables: true, tablesHeaderId: true }); - this.clearText.html( + $clearText.html( converter.makeHtml(text) ); // add table classes from bootstrap css - this.clearText.find('table').addClass('table-condensed table-bordered'); + $clearText.find('table').addClass('table-condensed table-bordered'); - this.clearText.removeClass('hidden'); + $clearText.removeClass('hidden'); + } else { + console.error('showdown is not loaded, could not parse Markdown'); } - this.prettyMessage.addClass('hidden'); + $prettyMessage.addClass('hidden'); break; case 'syntaxhighlighting': + // silently fail if prettyprint is not available + // @TODO: maybe better show an error message? At least a warning? if (typeof prettyPrintOne === 'function') { if (typeof prettyPrint === 'function') { prettyPrint(); } - this.prettyPrint.html( + $prettyPrint.html( prettyPrintOne( helper.htmlEntities(text), null, true ) ); + } else { + console.error('pretty print is not loaded, could not link '); } // fall through, as the rest is the same - default: + default: // = 'plaintext' // convert URLs to clickable links - helper.urls2links(this.clearText); - helper.urls2links(this.prettyPrint); - this.clearText.addClass('hidden'); - if (format === 'plaintext') - { - this.prettyPrint.css('white-space', 'pre-wrap'); - this.prettyPrint.css('word-break', 'normal'); - this.prettyPrint.removeClass('prettyprint'); - } - this.prettyMessage.removeClass('hidden'); + helper.urls2links($clearText); + helper.urls2links($prettyPrint); + $clearText.addClass('hidden'); + + + $prettyPrint.css('white-space', 'pre-wrap'); + $prettyPrint.css('word-break', 'normal'); + $prettyPrint.removeClass('prettyprint'); + + $prettyMessage.removeClass('hidden'); } - }, + }; /** * show decrypted text in the display area, including discussion (if open) @@ -712,12 +817,12 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta')) */ - displayMessages: function(paste) + me.displayMessages = function(paste) { - paste = paste || $.parseJSON(this.cipherData.text()); + paste = paste || $.parseJSON($cipherData.text()); var key = helper.pageKey(), - password = this.passwordInput.val(); - if (!this.prettyPrint.hasClass('prettyprinted')) { + password = $passwordInput.val(); + if (!$prettyPrint.hasClass('prettyprinted')) { // Try to decrypt the paste. try { @@ -728,7 +833,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { if (password.length === 0) { - this.requestPassword(); + me.requestPassword(); return; } attachment = filter.decipher(key, password, paste.attachment); @@ -743,28 +848,28 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { var attachmentname = filter.decipher(key, password, paste.attachmentname); if (attachmentname.length > 0) { - this.attachmentLink.attr('download', attachmentname); + $attachmentLink.attr('download', attachmentname); } } - this.attachmentLink.attr('href', attachment); - this.attachment.removeClass('hidden'); + $attachmentLink.attr('href', attachment); + $attachment.removeClass('hidden'); // if the attachment is an image, display it var imagePrefix = 'data:image/'; if (attachment.substring(0, imagePrefix.length) === imagePrefix) { - this.image.html( + $image.html( $(document.createElement('img')) .attr('src', attachment) .attr('class', 'img-thumbnail') ); - this.image.removeClass('hidden'); + $image.removeClass('hidden'); } } var cleartext = filter.decipher(key, password, paste.data); if (cleartext.length === 0 && password.length === 0 && !paste.attachment) { - this.requestPassword(); + me.requestPassword(); return; } if (cleartext.length === 0 && !paste.attachment) @@ -772,17 +877,17 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { throw 'failed to decipher message'; } - this.passwordInput.val(password); + $passwordInput.val(password); if (cleartext.length > 0) { $('#pasteFormatter').val(paste.meta.formatter); - this.formatPaste(paste.meta.formatter, cleartext); + me.formatPaste(paste.meta.formatter, cleartext); } } catch(err) { - this.stateOnlyNewPaste(); - this.showError(i18n._('Could not decrypt data (Wrong key?)')); + me.stateOnlyNewPaste(); + me.showError(i18n._('Could not decrypt data (Wrong key?)')); return; } } @@ -795,8 +900,8 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { 'This document will expire in %d ' + expiration[1] + '.', 'This document will expire in %d ' + expiration[1] + 's.' ]; - helper.setMessage(this.remainingTime, i18n._(expirationLabel, expiration[0])); - this.remainingTime.removeClass('foryoureyesonly') + helper.setMessage($remainingTime, i18n._(expirationLabel, expiration[0])); + $remainingTime.removeClass('foryoureyesonly') .removeClass('hidden'); } if (paste.meta.burnafterreading) @@ -807,29 +912,29 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { url: helper.scriptLocation() + '?' + helper.pasteId(), data: {deletetoken: 'burnafterreading'}, dataType: 'json', - headers: this.headers + headers: headers }) .fail(function() { controller.showError(i18n._('Could not delete the paste, it was not stored in burn after reading mode.')); }); - helper.setMessage(this.remainingTime, i18n._( + helper.setMessage($remainingTime, i18n._( 'FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.' )); - this.remainingTime.addClass('foryoureyesonly') + $remainingTime.addClass('foryoureyesonly') .removeClass('hidden'); // discourage cloning (as it can't really be prevented) - this.cloneButton.addClass('hidden'); + $cloneButton.addClass('hidden'); } // if the discussion is opened on this paste, display it if (paste.meta.opendiscussion) { - this.comments.html(''); + $comments.html(''); // iterate over comments for (var i = 0; i < paste.comments.length; ++i) { - var place = this.comments, + var $place = $comments, comment = paste.comments[i], commenttext = filter.decipher(key, password, comment.data), // if parent comment exists, display below (CSS will automatically shift it to the right) @@ -845,9 +950,9 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // if the element exists in page if ($(cname).length) { - place = $(cname); + $place = $(cname); } - divComment.find('button').click({commentid: comment.id}, $.proxy(this.openReply, this)); + divComment.find('button').click({commentid: comment.id}, $.proxy(me.openReply, me)); helper.setElementText(divCommentData, commenttext); helper.urls2links(divCommentData); @@ -875,17 +980,17 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { ); } - place.append(divComment); + $place.append(divComment); } var divComment = $( '
' ); - divComment.find('button').click({commentid: helper.pasteId()}, $.proxy(this.openReply, this)); - this.comments.append(divComment); - this.discussion.removeClass('hidden'); + divComment.find('button').click({commentid: helper.pasteId()}, $.proxy(me.openReply, me)); + $comments.append(divComment); + $discussion.removeClass('hidden'); } - }, + }; /** * open the comment entry when clicking the "Reply" button of a comment @@ -894,7 +999,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - openReply: function(event) + me.openReply = function(event) { event.preventDefault(); @@ -915,12 +1020,12 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { ); reply.find('button').click( {parentid: commentid}, - $.proxy(this.sendComment, this) + $.proxy(me.sendComment, me) ); source.after(reply); - this.replyStatus = $('#replystatus'); + $replyStatus = $('#replystatus'); $('#replymessage').focus(); - }, + }; /** * send a reply in a discussion @@ -929,10 +1034,10 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - sendComment: function(event) + me.sendComment = function(event) { event.preventDefault(); - this.errorMessage.addClass('hidden'); + $errorMessage.addClass('hidden'); // do not send if no data var replyMessage = $('#replymessage'); if (replyMessage.val().length === 0) @@ -940,15 +1045,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { return; } - this.showStatus(i18n._('Sending comment...'), true); + me.showStatus(i18n._('Sending comment...'), true); var parentid = event.data.parentid, key = helper.pageKey(), - cipherdata = filter.cipher(key, this.passwordInput.val(), replyMessage.val()), + cipherdata = filter.cipher(key, $passwordInput.val(), replyMessage.val()), ciphernickname = '', nick = $('#nickname').val(); if (nick.length > 0) { - ciphernickname = filter.cipher(key, this.passwordInput.val(), nick); + ciphernickname = filter.cipher(key, $passwordInput.val(), nick); } var data_to_send = { data: cipherdata, @@ -962,7 +1067,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { url: helper.scriptLocation(), data: data_to_send, dataType: 'json', - headers: this.headers, + headers: headers, success: function(data) { if (data.status === 0) @@ -972,7 +1077,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { type: 'GET', url: helper.scriptLocation() + '?' + helper.pasteId(), dataType: 'json', - headers: controller.headers, + headers: headers, success: function(data) { if (data.status === 0) @@ -1006,7 +1111,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { .fail(function() { controller.showError(i18n._('Could not post comment: %s', i18n._('server error or not responding'))); }); - }, + }; /** * send a new paste to server @@ -1015,14 +1120,14 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - sendData: function(event) + me.sendData = function(event) { event.preventDefault(); var file = document.getElementById('file'), files = (file && file.files) ? file.files : null; // FileList object // do not send if no data. - if (this.message.val().length === 0 && !(files && files[0])) + if ($message.val().length === 0 && !(files && files[0])) { return; } @@ -1030,28 +1135,28 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // if sjcl has not collected enough entropy yet, display a message if (!sjcl.random.isReady()) { - this.showStatus(i18n._('Sending paste (Please move your mouse for more entropy)...'), true); + me.showStatus(i18n._('Sending paste (Please move your mouse for more entropy)...'), true); sjcl.random.addEventListener('seeded', function() { - this.sendData(event); + me.sendData(event); }); return; } $('.navbar-toggle').click(); - this.password.addClass('hidden'); - this.showStatus(i18n._('Sending paste...'), true); + $password.addClass('hidden'); + me.showStatus(i18n._('Sending paste...'), true); - this.stateSubmittingPaste(); + me.stateSubmittingPaste(); var randomkey = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0), - password = this.passwordInput.val(); + password = $passwordInput.val(); if(files && files[0]) { if(typeof FileReader === undefined) { // revert loading status… - this.stateNewPaste(); - this.showError(i18n._('Your browser does not support uploading encrypted files. Please use a newer browser.')); + me.stateNewPaste(); + me.showError(i18n._('Your browser does not support uploading encrypted files. Please use a newer browser.')); return; } var reader = new FileReader(); @@ -1068,19 +1173,19 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { })(files[0]); reader.readAsDataURL(files[0]); } - else if(this.attachmentLink.attr('href')) + else if($attachmentLink.attr('href')) { - this.sendDataContinue( + me.sendDataContinue( randomkey, - filter.cipher(randomkey, password, this.attachmentLink.attr('href')), - this.attachmentLink.attr('download') + filter.cipher(randomkey, password, $attachmentLink.attr('href')), + $attachmentLink.attr('download') ); } else { - this.sendDataContinue(randomkey, '', ''); + me.sendDataContinue(randomkey, '', ''); } - }, + }; /** * send a new paste to server, step 2 @@ -1091,15 +1196,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} cipherdata_attachment * @param {string} cipherdata_attachment_name */ - sendDataContinue: function(randomkey, cipherdata_attachment, cipherdata_attachment_name) + me.sendDataContinue = function(randomkey, cipherdata_attachment, cipherdata_attachment_name) { - var cipherdata = filter.cipher(randomkey, this.passwordInput.val(), this.message.val()), + var cipherdata = filter.cipher(randomkey, $passwordInput.val(), $message.val()), data_to_send = { data: cipherdata, expire: $('#pasteExpiration').val(), formatter: $('#pasteFormatter').val(), - burnafterreading: this.burnAfterReading.is(':checked') ? 1 : 0, - opendiscussion: this.openDiscussion.is(':checked') ? 1 : 0 + burnafterreading: $burnAfterReading.is(':checked') ? 1 : 0, + opendiscussion: $openDiscussion.is(':checked') ? 1 : 0 }; if (cipherdata_attachment.length > 0) { @@ -1114,15 +1219,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { url: helper.scriptLocation(), data: data_to_send, dataType: 'json', - headers: this.headers, + headers: headers, success: function(data) { if (data.status === 0) { - controller.stateExistingPaste(); + me.stateExistingPaste(); var url = helper.scriptLocation() + '?' + data.id + '#' + randomkey, deleteUrl = helper.scriptLocation() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; - controller.showStatus(''); - controller.errorMessage.addClass('hidden'); + me.showStatus(''); + $errorMessage.addClass('hidden'); // show new URL in browser bar history.pushState({type: 'newpaste'}, document.title, url); @@ -1130,23 +1235,23 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { i18n._( 'Your paste is %s (Hit [Ctrl]+[c] to copy)', url, url - ) + controller.shortenUrl(url) + ) + me.shortenUrl(url) ); // save newly created element - controller.pasteUrl = $('#pasteurl'); + $pasteUrl = $('#pasteurl'); // and add click event - controller.pasteUrl.click($.proxy(controller.pasteLinkClick, controller)); + $pasteUrl.click($.proxy(me.pasteLinkClick, me)); var shortenButton = $('#shortenbutton'); if (shortenButton) { - shortenButton.click($.proxy(controller.sendToShortener, controller)); + shortenButton.click($.proxy(me.sendToShortener, me)); } $('#deletelink').html('' + i18n._('Delete data') + ''); - controller.pasteResult.removeClass('hidden'); + $pasteResult.removeClass('hidden'); // we pre-select the link so that the user only has to [Ctrl]+[c] the link - helper.selectText('pasteurl'); - controller.showStatus(''); - controller.formatPaste(data_to_send.formatter, controller.message.val()); + helper.selectText($pasteUrl[0]); + me.showStatus(''); + me.formatPaste(data_to_send.formatter, $message.val()); } else if (data.status === 1) { @@ -1165,10 +1270,10 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { .fail(function() { // revert loading status… - this.stateNewPaste(); + me.stateNewPaste(); controller.showError(i18n._('Could not create paste: %s', i18n._('server error or not responding'))); }); - }, + }; /** * check if a URL shortener was defined and create HTML containing a link to it @@ -1178,16 +1283,16 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} url * @return {string} html */ - shortenUrl: function(url) + me.shortenUrl = function(url) { var shortenerHtml = $('#shortenbutton'); if (shortenerHtml) { - this.shortenerUrl = shortenerHtml.data('shortener'); - this.createdPasteUrl = url; + shortenerUrl = shortenerHtml.data('shortener'); + createdPasteUrl = url; return ' ' + $('
').append(shortenerHtml.clone()).html(); } return ''; - }, + }; /** * put the screen in "New paste" mode @@ -1195,30 +1300,30 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.stateNewPaste * @function */ - stateNewPaste: function() + me.stateNewPaste = function() { - this.message.text(''); - this.attachment.addClass('hidden'); - this.cloneButton.addClass('hidden'); - this.rawTextButton.addClass('hidden'); - this.remainingTime.addClass('hidden'); - this.pasteResult.addClass('hidden'); - this.clearText.addClass('hidden'); - this.discussion.addClass('hidden'); - this.prettyMessage.addClass('hidden'); - this.loadingIndicator.addClass('hidden'); - this.sendButton.removeClass('hidden'); - this.expiration.removeClass('hidden'); - this.formatter.removeClass('hidden'); - this.burnAfterReadingOption.removeClass('hidden'); - this.openDisc.removeClass('hidden'); - this.newButton.removeClass('hidden'); - this.password.removeClass('hidden'); - this.attach.removeClass('hidden'); - this.message.removeClass('hidden'); - this.preview.removeClass('hidden'); - this.message.focus(); - }, + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + $sendButton.removeClass('hidden'); + $expiration.removeClass('hidden'); + $formatter.removeClass('hidden'); + $burnAfterReadingOption.removeClass('hidden'); + $openDisc.removeClass('hidden'); + $newButton.removeClass('hidden'); + $password.removeClass('hidden'); + $attach.removeClass('hidden'); + $message.removeClass('hidden'); + $preview.removeClass('hidden'); + $message.focus(); + }; /** * put the screen in mode after submitting a paste @@ -1226,30 +1331,30 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.stateSubmittingPaste * @function */ - stateSubmittingPaste: function() + me.stateSubmittingPaste = function() { - this.message.text(''); - this.attachment.addClass('hidden'); - this.cloneButton.addClass('hidden'); - this.rawTextButton.addClass('hidden'); - this.remainingTime.addClass('hidden'); - this.pasteResult.addClass('hidden'); - this.clearText.addClass('hidden'); - this.discussion.addClass('hidden'); - this.prettyMessage.addClass('hidden'); - this.sendButton.addClass('hidden'); - this.expiration.addClass('hidden'); - this.formatter.addClass('hidden'); - this.burnAfterReadingOption.addClass('hidden'); - this.openDisc.addClass('hidden'); - this.newButton.addClass('hidden'); - this.password.addClass('hidden'); - this.attach.addClass('hidden'); - this.message.addClass('hidden'); - this.preview.addClass('hidden'); + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $sendButton.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $newButton.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + $message.addClass('hidden'); + $preview.addClass('hidden'); - this.loadingIndicator.removeClass('hidden'); - }, + $loadingIndicator.removeClass('hidden'); + }; /** * put the screen in a state where the only option is to submit a @@ -1258,30 +1363,30 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.stateOnlyNewPaste * @function */ - stateOnlyNewPaste: function() + me.stateOnlyNewPaste = function() { - this.message.text(''); - this.attachment.addClass('hidden'); - this.cloneButton.addClass('hidden'); - this.rawTextButton.addClass('hidden'); - this.remainingTime.addClass('hidden'); - this.pasteResult.addClass('hidden'); - this.clearText.addClass('hidden'); - this.discussion.addClass('hidden'); - this.prettyMessage.addClass('hidden'); - this.sendButton.addClass('hidden'); - this.expiration.addClass('hidden'); - this.formatter.addClass('hidden'); - this.burnAfterReadingOption.addClass('hidden'); - this.openDisc.addClass('hidden'); - this.password.addClass('hidden'); - this.attach.addClass('hidden'); - this.message.addClass('hidden'); - this.preview.addClass('hidden'); - this.loadingIndicator.addClass('hidden'); + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $sendButton.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + $message.addClass('hidden'); + $preview.addClass('hidden'); + $loadingIndicator.addClass('hidden'); - this.newButton.removeClass('hidden'); - }, + $newButton.removeClass('hidden'); + }; /** * put the screen in "Existing paste" mode @@ -1290,7 +1395,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {boolean} [preview=false] - (optional) tell if the preview tabs should be displayed, defaults to false */ - stateExistingPaste: function(preview) + me.stateExistingPaste = function(preview) { preview = preview || false; @@ -1299,30 +1404,30 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // no "clone" for IE<10. if ($('#oldienotice').is(":visible")) { - this.cloneButton.addClass('hidden'); + $cloneButton.addClass('hidden'); } else { - this.cloneButton.removeClass('hidden'); + $cloneButton.removeClass('hidden'); } - this.rawTextButton.removeClass('hidden'); - this.sendButton.addClass('hidden'); - this.attach.addClass('hidden'); - this.expiration.addClass('hidden'); - this.formatter.addClass('hidden'); - this.burnAfterReadingOption.addClass('hidden'); - this.openDisc.addClass('hidden'); - this.newButton.removeClass('hidden'); - this.preview.addClass('hidden'); + $rawTextButton.removeClass('hidden'); + $sendButton.addClass('hidden'); + $attach.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $newButton.removeClass('hidden'); + $preview.addClass('hidden'); } - this.pasteResult.addClass('hidden'); - this.message.addClass('hidden'); - this.clearText.addClass('hidden'); - this.prettyMessage.addClass('hidden'); - this.loadingIndicator.addClass('hidden'); - }, + $pasteResult.addClass('hidden'); + $message.addClass('hidden'); + $clearText.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + }; /** * when "burn after reading" is checked, disable discussion @@ -1330,19 +1435,19 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.changeBurnAfterReading * @function */ - changeBurnAfterReading: function() + me.changeBurnAfterReading = function() { - if (this.burnAfterReading.is(':checked') ) + if ($burnAfterReading.is(':checked') ) { - this.openDisc.addClass('buttondisabled'); - this.openDiscussion.attr({checked: false, disabled: true}); + $openDisc.addClass('buttondisabled'); + $openDiscussion.attr({checked: false, disabled: true}); } else { - this.openDisc.removeClass('buttondisabled'); - this.openDiscussion.removeAttr('disabled'); + $openDisc.removeClass('buttondisabled'); + $openDiscussion.removeAttr('disabled'); } - }, + }; /** * when discussion is checked, disable "burn after reading" @@ -1350,19 +1455,19 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.changeOpenDisc * @function */ - changeOpenDisc: function() + me.changeOpenDisc = function() { - if (this.openDiscussion.is(':checked') ) + if ($openDiscussion.is(':checked') ) { - this.burnAfterReadingOption.addClass('buttondisabled'); - this.burnAfterReading.attr({checked: false, disabled: true}); + $burnAfterReadingOption.addClass('buttondisabled'); + $burnAfterReading.attr({checked: false, disabled: true}); } else { - this.burnAfterReadingOption.removeClass('buttondisabled'); - this.burnAfterReading.removeAttr('disabled'); + $burnAfterReadingOption.removeClass('buttondisabled'); + $burnAfterReading.removeAttr('disabled'); } - }, + }; /** * forward to URL shortener @@ -1371,11 +1476,11 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - sendToShortener: function(event) + me.sendToShortener = function(event) { + window.location.href = shortenerUrl + encodeURIComponent(createdPasteUrl); event.preventDefault(); - window.location.href = this.shortenerUrl + encodeURIComponent(this.createdPasteUrl); - }, + }; /** * reload the page @@ -1386,11 +1491,11 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - reloadPage: function(event) + me.reloadPage = function(event) { - event.preventDefault(); window.location.href = helper.scriptLocation(); - }, + event.preventDefault(); + }; /** * return raw text @@ -1399,11 +1504,10 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - rawText: function(event) + me.rawText = function(event) { - event.preventDefault(); var paste = $('#pasteFormatter').val() === 'markdown' ? - this.prettyPrint.text() : this.clearText.text(); + $prettyPrint.text() : $clearText.text(); history.pushState( null, document.title, helper.scriptLocation() + '?' + helper.pasteId() + '#' + helper.pageKey() @@ -1413,7 +1517,9 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { var newDoc = document.open('text/html', 'replace'); newDoc.write('
' + helper.htmlEntities(paste) + '
'); newDoc.close(); - }, + + event.preventDefault(); + }; /** * clone the current paste @@ -1422,26 +1528,26 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - clonePaste: function(event) + me.clonePaste = function(event) { event.preventDefault(); - this.stateNewPaste(); + me.stateNewPaste(); // erase the id and the key in url history.replaceState(null, document.title, helper.scriptLocation()); - this.showStatus(''); - if (this.attachmentLink.attr('href')) + me.showStatus(''); + if ($attachmentLink.attr('href')) { - this.clonedFile.removeClass('hidden'); - this.fileWrap.addClass('hidden'); + $clonedFile.removeClass('hidden'); + $fileWrap.addClass('hidden'); } - this.message.text( + $message.text( $('#pasteFormatter').val() === 'markdown' ? - this.prettyPrint.text() : this.clearText.text() + $prettyPrint.text() : $clearText.text() ); $('.navbar-toggle').click(); - }, + }; /** * set the expiration on bootstrap templates @@ -1450,13 +1556,13 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - setExpiration: function(event) + me.setExpiration = function(event) { event.preventDefault(); var target = $(event.target); $('#pasteExpiration').val(target.data('expiration')); $('#pasteExpirationDisplay').text(target.text()); - }, + }; /** * set the format on bootstrap templates @@ -1465,17 +1571,17 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - setFormat: function(event) + me.setFormat = function(event) { - event.preventDefault(); var target = $(event.target); $('#pasteFormatter').val(target.data('format')); $('#pasteFormatterDisplay').text(target.text()); - if (this.messagePreview.parent().hasClass('active')) { - this.viewPreview(event); + if ($messagePreview.parent().hasClass('active')) { + me.viewPreview(event); } - }, + event.preventDefault(); + }; /** * set the language in a cookie and reload the page @@ -1484,11 +1590,11 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - setLanguage: function(event) + me.setLanguage = function(event) { document.cookie = 'lang=' + $(event.target).data('lang'); - this.reloadPage(event); - }, + me.reloadPage(event); + }; /** * support input of tab character @@ -1496,8 +1602,9 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.supportTabs * @function * @param {Event} event + * @TODO doc what is @this here? */ - supportTabs: function(event) + me.supportTabs = function(event) { var keyCode = event.keyCode || event.which; // tab was pressed @@ -1514,7 +1621,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // put caret at right position again this.selectionStart = this.selectionEnd = start + 1; } - }, + }; /** * view the editor tab @@ -1523,14 +1630,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - viewEditor: function(event) + me.viewEditor = function(event) { + $messagePreview.parent().removeClass('active'); + $messageEdit.parent().addClass('active'); + $message.focus(); + me.stateNewPaste(); + event.preventDefault(); - this.messagePreview.parent().removeClass('active'); - this.messageEdit.parent().addClass('active'); - this.message.focus(); - this.stateNewPaste(); - }, + }; /** * view the preview tab @@ -1539,15 +1647,16 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - viewPreview: function(event) + me.viewPreview = function(event) { + $messageEdit.parent().removeClass('active'); + $messagePreview.parent().addClass('active'); + $message.focus(); + me.stateExistingPaste(true); + me.formatPaste($('#pasteFormatter').val(), $message.val()); + event.preventDefault(); - this.messageEdit.parent().removeClass('active'); - this.messagePreview.parent().addClass('active'); - this.message.focus(); - this.stateExistingPaste(true); - this.formatPaste($('#pasteFormatter').val(), this.message.val()); - }, + }; /** * handle history (pop) state changes @@ -1558,7 +1667,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - historyChange: function(event) + me.historyChange = function(event) { var currentLocation = helper.scriptLocation(); if (event.originalEvent.state === null && // no state object passed @@ -1568,7 +1677,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // redirect to home page window.location.href = currentLocation; } - }, + }; /** * Forces opening the paste if the link does not do this automatically. @@ -1580,14 +1689,14 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - pasteLinkClick: function(event) + me.pasteLinkClick = function(event) { // check if location is (already) shown in URL bar - if (window.location.href === this.pasteUrl.attr('href')) { + if (window.location.href === $pasteUrl.attr('href')) { // if so we need to load link by reloading the current site window.location.reload(true); } - }, + }; /** * create a new paste @@ -1595,14 +1704,14 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.newPaste * @function */ - newPaste: function() + me.newPaste = function() { - this.stateNewPaste(); - this.showStatus(''); - this.message.text(''); - this.changeBurnAfterReading(); - this.changeOpenDisc(); - }, + me.stateNewPaste(); + me.showStatus(''); + $message.text(''); + me.changeBurnAfterReading(); + me.changeOpenDisc(); + }; /** * removes an attachment @@ -1610,15 +1719,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.removeAttachment * @function */ - removeAttachment: function() + me.removeAttachment = function() { - this.clonedFile.addClass('hidden'); + $clonedFile.addClass('hidden'); // removes the saved decrypted file data - this.attachmentLink.attr('href', ''); - // the only way to deselect the file is to recreate the input - this.fileWrap.html(this.fileWrap.html()); - this.fileWrap.removeClass('hidden'); - }, + $attachmentLink.attr('href', ''); + // the only way to deselect the file is to recreate the input // @TODO really? + $fileWrap.html($fileWrap.html()); + $fileWrap.removeClass('hidden'); + }; /** * decrypt using the password from the modal dialog @@ -1626,11 +1735,11 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.decryptPasswordModal * @function */ - decryptPasswordModal: function() + me.decryptPasswordModal = function() { - this.passwordInput.val(this.passwordDecrypt.val()); - this.displayMessages(); - }, + $passwordInput.val($passwordDecrypt.val()); + me.displayMessages(); + }; /** * submit a password in the modal dialog @@ -1639,11 +1748,11 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {Event} event */ - submitPasswordModal: function(event) + me.submitPasswordModal = function(event) { event.preventDefault(); - this.passwordModal.modal('hide'); - }, + $passwordModal.modal('hide'); + }; /** * display an error message, @@ -1653,30 +1762,30 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @function * @param {string} message - text to display */ - showError: function(message) + me.showError = function(message) { - if (this.status.length) + if ($status.length) { - this.status.addClass('errorMessage').text(message); + $status.addClass('errorMessage').text(message); } else { - this.errorMessage.removeClass('hidden'); - helper.setMessage(this.errorMessage, message); + $errorMessage.removeClass('hidden'); + helper.setMessage($errorMessage, message); } - if (typeof this.replyStatus !== 'undefined') { - this.replyStatus.addClass('errorMessage'); - this.replyStatus.addClass(this.errorMessage.attr('class')); - if (this.status.length) + if (typeof $replyStatus !== 'undefined') { + $replyStatus.addClass('errorMessage'); + $replyStatus.addClass($errorMessage.attr('class')); + if ($status.length) { - this.replyStatus.html(this.status.html()); + $replyStatus.html($status.html()); } else { - this.replyStatus.html(this.errorMessage.html()); + $replyStatus.html($errorMessage.html()); } } - }, + }; /** * display a status message, @@ -1687,66 +1796,66 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @param {string} message - text to display * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false */ - showStatus: function(message, spin) + me.showStatus = function(message, spin) { if (spin || false) { var img = ''; - this.status.prepend(img); - if (typeof this.replyStatus !== 'undefined') { - this.replyStatus.prepend(img); + $status.prepend(img); + if (typeof $replyStatus !== 'undefined') { + $replyStatus.prepend(img); } } - if (typeof this.replyStatus !== 'undefined') { - this.replyStatus.removeClass('errorMessage').text(message); + if (typeof $replyStatus !== 'undefined') { + $replyStatus.removeClass('errorMessage').text(message); } if (!message) { - this.status.html(' '); + $status.html(' '); return; } if (message === '') { - this.status.html(' '); + $status.html(' '); return; } - this.status.removeClass('errorMessage').text(message); - }, + $status.removeClass('errorMessage').text(message); + }; /** * bind events to DOM elements * - * @name controller.bindEvents + * @private * @function */ - bindEvents: function() + function bindEvents() { - this.burnAfterReading.change($.proxy(this.changeBurnAfterReading, this)); - this.openDisc.change($.proxy(this.changeOpenDisc, this)); - this.sendButton.click($.proxy(this.sendData, this)); - this.cloneButton.click($.proxy(this.clonePaste, this)); - this.rawTextButton.click($.proxy(this.rawText, this)); - this.fileRemoveButton.click($.proxy(this.removeAttachment, this)); - $('.reloadlink').click($.proxy(this.reloadPage, this)); - this.message.keydown(this.supportTabs); - this.messageEdit.click($.proxy(this.viewEditor, this)); - this.messagePreview.click($.proxy(this.viewPreview, this)); + $burnAfterReading.change($.proxy(me.changeBurnAfterReading, me)); + $openDisc.change($.proxy(me.changeOpenDisc, me)); + $sendButton.click($.proxy(me.sendData, me)); + $cloneButton.click($.proxy(me.clonePaste, me)); + $rawTextButton.click($.proxy(me.rawText, me)); + $fileRemoveButton.click($.proxy(me.removeAttachment, me)); + $('.reloadlink').click($.proxy(me.reloadPage, me)); + $message.keydown(me.supportTabs); + $messageEdit.click($.proxy(me.viewEditor, me)); + $messagePreview.click($.proxy(me.viewPreview, me)); // bootstrap template drop downs - $('ul.dropdown-menu li a', $('#expiration').parent()).click($.proxy(this.setExpiration, this)); - $('ul.dropdown-menu li a', $('#formatter').parent()).click($.proxy(this.setFormat, this)); - $('#language ul.dropdown-menu li a').click($.proxy(this.setLanguage, this)); + $('ul.dropdown-menu li a', $('#expiration').parent()).click($.proxy(me.setExpiration, me)); + $('ul.dropdown-menu li a', $('#formatter').parent()).click($.proxy(me.setFormat, me)); + $('#language ul.dropdown-menu li a').click($.proxy(me.setLanguage, me)); // page template drop down - $('#language select option').click($.proxy(this.setLanguage, this)); + $('#language select option').click($.proxy(me.setLanguage, me)); // handle modal password request on decryption - this.passwordModal.on('shown.bs.modal', $.proxy(this.passwordDecrypt.focus, this)); - this.passwordModal.on('hidden.bs.modal', $.proxy(this.decryptPasswordModal, this)); - this.passwordForm.submit($.proxy(this.submitPasswordModal, this)); + $passwordModal.on('shown.bs.modal', $.proxy($passwordDecrypt.focus, me)); + $passwordModal.on('hidden.bs.modal', $.proxy(me.decryptPasswordModal, me)); + $passwordForm.submit($.proxy(me.submitPasswordModal, me)); - $(window).on('popstate', $.proxy(this.historyChange, this)); - }, + $(window).on('popstate', $.proxy(me.historyChange, me)); + }; /** * main application @@ -1754,89 +1863,92 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * @name controller.init * @function */ - init: function() + me.init = function() { // hide "no javascript" message $('#noscript').hide(); // preload jQuery wrapped DOM elements and bind events - this.attach = $('#attach'); - this.attachment = $('#attachment'); - this.attachmentLink = $('#attachment a'); - this.burnAfterReading = $('#burnafterreading'); - this.burnAfterReadingOption = $('#burnafterreadingoption'); - this.cipherData = $('#cipherdata'); - this.clearText = $('#cleartext'); - this.cloneButton = $('#clonebutton'); - this.clonedFile = $('#clonedfile'); - this.comments = $('#comments'); - this.discussion = $('#discussion'); - this.errorMessage = $('#errormessage'); - this.expiration = $('#expiration'); - this.fileRemoveButton = $('#fileremovebutton'); - this.fileWrap = $('#filewrap'); - this.formatter = $('#formatter'); - this.image = $('#image'); - this.loadingIndicator = $('#loadingindicator'); - this.message = $('#message'); - this.messageEdit = $('#messageedit'); - this.messagePreview = $('#messagepreview'); - this.newButton = $('#newbutton'); - this.openDisc = $('#opendisc'); - this.openDiscussion = $('#opendiscussion'); - this.password = $('#password'); - this.passwordInput = $('#passwordinput'); - this.passwordModal = $('#passwordmodal'); - this.passwordForm = $('#passwordform'); - this.passwordDecrypt = $('#passworddecrypt'); - this.pasteResult = $('#pasteresult'); - // this.pasteUrl is saved in sendDataContinue() if/after it is + $attach = $('#attach'); + $attachment = $('#attachment'); + $attachmentLink = $('#attachment a'); + $burnAfterReading = $('#burnafterreading'); + $burnAfterReadingOption = $('#burnafterreadingoption'); + $cipherData = $('#cipherdata'); + $clearText = $('#cleartext'); + $cloneButton = $('#clonebutton'); + $clonedFile = $('#clonedfile'); + $comments = $('#comments'); + $discussion = $('#discussion'); + $errorMessage = $('#errormessage'); + $expiration = $('#expiration'); + $fileRemoveButton = $('#fileremovebutton'); + $fileWrap = $('#filewrap'); + $formatter = $('#formatter'); + $image = $('#image'); + $loadingIndicator = $('#loadingindicator'); + $message = $('#message'); + $messageEdit = $('#messageedit'); + $messagePreview = $('#messagepreview'); + $newButton = $('#newbutton'); + $openDisc = $('#opendisc'); + $openDiscussion = $('#opendiscussion'); + $password = $('#password'); + $passwordInput = $('#passwordinput'); + $passwordModal = $('#passwordmodal'); + $passwordForm = $('#passwordform'); + $passwordDecrypt = $('#passworddecrypt'); + $pasteResult = $('#pasteresult'); + // $pasteUrl is saved in sendDataContinue() if/after it is // actually created - this.prettyMessage = $('#prettymessage'); - this.prettyPrint = $('#prettyprint'); - this.preview = $('#preview'); - this.rawTextButton = $('#rawtextbutton'); - this.remainingTime = $('#remainingtime'); - this.sendButton = $('#sendbutton'); - this.status = $('#status'); - this.bindEvents(); + $prettyMessage = $('#prettymessage'); + $prettyPrint = $('#prettyprint'); + $preview = $('#preview'); + $rawTextButton = $('#rawtextbutton'); + $remainingTime = $('#remainingtime'); + // $replyStatus is saved in openReply() + $sendButton = $('#sendbutton'); + $status = $('#status'); + bindEvents(); // display status returned by php code, if any (eg. paste was properly deleted) - if (this.status.text().length > 0) + if ($status.text().length > 0) { - this.showStatus(this.status.text()); + me.showStatus($status.text()); return; } // keep line height even if content empty - this.status.html(' '); + $status.html(' '); // display an existing paste - if (this.cipherData.text().length > 1) + if ($cipherData.text().length > 1) { // missing decryption key in URL? if (window.location.hash.length === 0) { - this.showError(i18n._('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)')); + me.showError(i18n._('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)')); return; } // show proper elements on screen - this.stateExistingPaste(); - this.displayMessages(); + me.stateExistingPaste(); + me.displayMessages(); } // display error message from php code - else if (this.errorMessage.text().length > 1) + else if ($errorMessage.text().length > 1) { - this.showError(this.errorMessage.text()); + me.showError($errorMessage.text()); } // create a new paste else { - this.newPaste(); + me.newPaste(); } - } - } + }; + + return me; + })(window, document); /** * main application start, called when DOM is fully loaded and diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index 60c6727d..698d3594 100644 --- a/tpl/bootstrap.php +++ b/tpl/bootstrap.php @@ -69,7 +69,7 @@ if ($MARKDOWN): - + From 4e86da8f7207608fa2cea72ac78d0a35d7270623 Mon Sep 17 00:00:00 2001 From: rugk Date: Wed, 8 Feb 2017 13:54:37 +0100 Subject: [PATCH 02/29] Remove proxy Also I kept care to (fix?) the focus of the password input. It only works in an anonymous function for some reason. --- js/privatebin.js | 47 +++++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/js/privatebin.js b/js/privatebin.js index ed3baa3b..85c71b69 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -952,7 +952,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { $place = $(cname); } - divComment.find('button').click({commentid: comment.id}, $.proxy(me.openReply, me)); + divComment.find('button').click({commentid: comment.id}, me.openReply); helper.setElementText(divCommentData, commenttext); helper.urls2links(divCommentData); @@ -986,7 +986,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { '
' ); - divComment.find('button').click({commentid: helper.pasteId()}, $.proxy(me.openReply, me)); + divComment.find('button').click({commentid: helper.pasteId()}, me.openReply); $comments.append(divComment); $discussion.removeClass('hidden'); } @@ -1020,7 +1020,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { ); reply.find('button').click( {parentid: commentid}, - $.proxy(me.sendComment, me) + me.sendComment ); source.after(reply); $replyStatus = $('#replystatus'); @@ -1240,11 +1240,11 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // save newly created element $pasteUrl = $('#pasteurl'); // and add click event - $pasteUrl.click($.proxy(me.pasteLinkClick, me)); + $pasteUrl.click(me.pasteLinkClick); var shortenButton = $('#shortenbutton'); if (shortenButton) { - shortenButton.click($.proxy(me.sendToShortener, me)); + shortenButton.click(me.sendToShortener); } $('#deletelink').html('' + i18n._('Delete data') + ''); $pasteResult.removeClass('hidden'); @@ -1830,31 +1830,34 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { */ function bindEvents() { - $burnAfterReading.change($.proxy(me.changeBurnAfterReading, me)); - $openDisc.change($.proxy(me.changeOpenDisc, me)); - $sendButton.click($.proxy(me.sendData, me)); - $cloneButton.click($.proxy(me.clonePaste, me)); - $rawTextButton.click($.proxy(me.rawText, me)); - $fileRemoveButton.click($.proxy(me.removeAttachment, me)); - $('.reloadlink').click($.proxy(me.reloadPage, me)); + $burnAfterReading.change(me.changeBurnAfterReading); + $openDisc.change(me.changeOpenDisc); + $sendButton.click(me.sendData); + $cloneButton.click(me.clonePaste); + $rawTextButton.click(me.rawText); + $fileRemoveButton.click(me.removeAttachment); + $('.reloadlink').click(me.reloadPage); $message.keydown(me.supportTabs); - $messageEdit.click($.proxy(me.viewEditor, me)); - $messagePreview.click($.proxy(me.viewPreview, me)); + $messageEdit.click(me.viewEditor); + $messagePreview.click(me.viewPreview); // bootstrap template drop downs - $('ul.dropdown-menu li a', $('#expiration').parent()).click($.proxy(me.setExpiration, me)); - $('ul.dropdown-menu li a', $('#formatter').parent()).click($.proxy(me.setFormat, me)); - $('#language ul.dropdown-menu li a').click($.proxy(me.setLanguage, me)); + $('ul.dropdown-menu li a', $('#expiration').parent()).click(me.setExpiration); + $('ul.dropdown-menu li a', $('#formatter').parent()).click(me.setFormat); + $('#language ul.dropdown-menu li a').click(me.setLanguage); // page template drop down - $('#language select option').click($.proxy(me.setLanguage, me)); + $('#language select option').click(me.setLanguage); + // focus password input when it is shown + $passwordModal.on('shown.bs.modal', function () { + $passwordDecrypt.focus(); + }); // handle modal password request on decryption - $passwordModal.on('shown.bs.modal', $.proxy($passwordDecrypt.focus, me)); - $passwordModal.on('hidden.bs.modal', $.proxy(me.decryptPasswordModal, me)); - $passwordForm.submit($.proxy(me.submitPasswordModal, me)); + $passwordModal.on('hidden.bs.modal', me.decryptPasswordModal); + $passwordForm.submit(me.submitPasswordModal); - $(window).on('popstate', $.proxy(me.historyChange, me)); + $(window).on('popstate', me.historyChange); }; /** From b01a28d5800e1c96a417d37306bd6a2d1ffed783 Mon Sep 17 00:00:00 2001 From: rugk Date: Wed, 8 Feb 2017 14:15:58 +0100 Subject: [PATCH 03/29] remove some more this, slightly change comments --- js/privatebin.js | 70 +++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/js/privatebin.js b/js/privatebin.js index 85c71b69..6322c0e8 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -135,26 +135,25 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { }; /** - * set text of a DOM element (required for IE), + * set text of a jQuery element (required for IE), * * @name helper.setElementText * @function - * @param {Object} element - a DOM element + * @param {jQuery} $element - a jQuery element * @param {string} text - the text to enter - * @this is equivalent to element.text(text) * @TODO check for XSS attacks, usually no CSS can prevent them so this looks weird on the first look */ - me.setElementText = function(element, text) + me.setElementText = function($element, text) { // For IE<10: Doesn't support white-space:pre-wrap; so we have to do this... if ($('#oldienotice').is(':visible')) { var html = me.htmlEntities(text).replace(/\n/ig, '\r\n
'); - element.html('
' + html + '
'); + $element.html('
' + html + '
'); } // for other (sane) browsers: else { - element.text(text); + $element.text(text); } }; @@ -163,19 +162,19 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * * @name helper.setMessage * @function - * @param {Object} element - a jQuery wrapped DOM element + * @param {jQuery} $element - a jQuery wrapped DOM element * @param {string} message - the message to append */ - me.setMessage = function(element, message) + me.setMessage = function($element, message) { - var content = element.contents(); + var content = $element.contents(); if (content.length > 0) { content[content.length - 1].nodeValue = ' ' + message; } else { - me.setElementText(element, message); + me.setElementText($element, message); } }; @@ -931,63 +930,68 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { $comments.html(''); + var $divComment; + // iterate over comments for (var i = 0; i < paste.comments.length; ++i) { var $place = $comments, comment = paste.comments[i], - commenttext = filter.decipher(key, password, comment.data), - // if parent comment exists, display below (CSS will automatically shift it to the right) - cname = '#comment_' + comment.parentid, - divComment = $('
' - + '
' - + '
' - + '
'), - divCommentData = divComment.find('div.commentdata'); + commentText = filter.decipher(key, password, comment.data), + $parentComment = $('#comment_' + comment.parentid); - // if the element exists in page - if ($(cname).length) + $divComment = $('
' + + '
' + + '
' + + '
'); + var $divCommentData = $divComment.find('div.commentdata'); + + // if parent comment exists + if ($parentComment.length) { - $place = $(cname); + // shift comment to the right + $place = $parentComment; } - divComment.find('button').click({commentid: comment.id}, me.openReply); - helper.setElementText(divCommentData, commenttext); - helper.urls2links(divCommentData); + $divComment.find('button').click({commentid: comment.id}, me.openReply); + helper.setElementText($divCommentData, commentText); + helper.urls2links($divCommentData); // try to get optional nickname var nick = filter.decipher(key, password, comment.meta.nickname); if (nick.length > 0) { - divComment.find('span.nickname').text(nick); + $divComment.find('span.nickname').text(nick); } else { divComment.find('span.nickname').html('' + i18n._('Anonymous') + ''); } - divComment.find('span.commentdate') + $divComment.find('span.commentdate') .text(' (' + (new Date(comment.meta.postdate * 1000).toLocaleString()) + ')') .attr('title', 'CommentID: ' + comment.id); // if an avatar is available, display it if (comment.meta.vizhash) { - divComment.find('span.nickname') + $divComment.find('span.nickname') .before( ' ' ); } - $place.append(divComment); + $place.append($divComment); } - var divComment = $( + + // add 'add new comment' area + $divComment = $( '
' ); - divComment.find('button').click({commentid: helper.pasteId()}, me.openReply); - $comments.append(divComment); + $divComment.find('button').click({commentid: helper.pasteId()}, me.openReply); + $comments.append($divComment); $discussion.removeClass('hidden'); } }; From e84cfc58a16d56b2deb9450c5ca8033a9a4b9b37 Mon Sep 17 00:00:00 2001 From: rugk Date: Wed, 8 Feb 2017 20:11:04 +0100 Subject: [PATCH 04/29] JS: tried namespaces --- js/privatebin.js | 3874 +++++++++++++++++++++++----------------------- 1 file changed, 1935 insertions(+), 1939 deletions(-) diff --git a/js/privatebin.js b/js/privatebin.js index 6322c0e8..adb26fd9 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -25,1948 +25,1944 @@ // Immediately start random number generator collector. sjcl.random.startCollectors(); -// jQuery(document).ready(function() { -// // startup -// } - -jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { - /** - * static helper methods - * - * @param {object} window - * @param {object} document - * @name helper - * @class - */ - var helper = (function (window, document) { - var me = {}; - - /** - * character to HTML entity lookup table - * - * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} - * @private - * @enum {Object} - * @readonly - */ - var entityMap = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/', - '`': '`', - '=': '=' - }; - - /** - * cache for script location - * - * @private - * @enum {string|null} - */ - var scriptLocation = null; - - /** - * converts a duration (in seconds) into human friendly approximation - * - * @name helper.secondsToHuman - * @function - * @param {number} seconds - * @return {Array} - */ - me.secondsToHuman = function(seconds) - { - var 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) - { - var range, selection; - - // MS - if (document.body.createTextRange) - { - range = document.body.createTextRange(); - range.moveToElementText(element); - range.select(); - } - // all others - else if (window.getSelection) - { - selection = window.getSelection(); - range = document.createRange(); - range.selectNodeContents(element); - selection.removeAllRanges(); - selection.addRange(range); - } - }; - - /** - * set text of a jQuery element (required for IE), - * - * @name helper.setElementText - * @function - * @param {jQuery} $element - a jQuery element - * @param {string} text - the text to enter - * @TODO check for XSS attacks, usually no CSS can prevent them so this looks weird on the first look - */ - me.setElementText = function($element, text) - { - // For IE<10: Doesn't support white-space:pre-wrap; so we have to do this... - if ($('#oldienotice').is(':visible')) { - var html = me.htmlEntities(text).replace(/\n/ig, '\r\n
'); - $element.html('
' + html + '
'); - } - // for other (sane) browsers: - else - { - $element.text(text); - } - }; - - /** - * replace last child of element with message - * - * @name helper.setMessage - * @function - * @param {jQuery} $element - a jQuery wrapped DOM element - * @param {string} message - the message to append - */ - me.setMessage = function($element, message) - { - var content = $element.contents(); - if (content.length > 0) - { - content[content.length - 1].nodeValue = ' ' + message; - } - else - { - me.setElementText($element, message); - } - }; - - /** - * convert URLs to clickable links. - * URLs to handle: - *
-         *     magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7
-         *     http://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 {Object} element - a jQuery DOM element - */ - me.urls2links = function(element) - { - var markup = '$1'; - element.html( - element.html().replace( - /((http|https|ftp):\/\/[\w?=&.\/-;#@~%+-]+(?![\w\s?&.\/;#~%"=-]*>))/ig, - markup - ) - ); - element.html( - element.html().replace( - /((magnet):[\w?=&.\/-;#@~%+-]+)/ig, - markup - ) - ); - }; - - /** - * minimal sprintf emulation for %s and %d formats - * - * @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() - { - var args = arguments; - if (typeof arguments[0] === 'object') - { - args = arguments[0]; - } - var format = args[0], - i = 1; - return format.replace(/%((%)|s|d)/g, function (m) { - // m is the matched format, e.g. %s, %d - var val; - if (m[2]) { - val = m[2]; - } else { - val = args[i]; - // A switch statement so that the formatter can be extended. - switch (m) - { - case '%d': - val = parseFloat(val); - if (isNaN(val)) { - val = 0; - } - break; - default: - // Default is %s - } - ++i; - } - return val; - }); - }; - - /** - * get value of cookie, if it was set, empty string otherwise - * - * @see {@link http://www.w3schools.com/js/js_cookies.asp} - * @name helper.getCookie - * @function - * @param {string} cname - * @return {string} - */ - me.getCookie = function(cname) { - var name = cname + '=', - ca = document.cookie.split(';'); - for (var i = 0; i < ca.length; ++i) { - var 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 script location (without search or hash part of the URL), - * eg. http://example.com/path/?aaaa#bbbb --> http://example.com/path/ - * - * @name helper.scriptLocation - * @function - * @return {string} current script location - */ - me.scriptLocation = function() - { - // check for cached version - if (scriptLocation !== null) { - return scriptLocation; - } - - scriptLocation = window.location.href.substring( - 0, - window.location.href.length - window.location.search.length - window.location.hash.length - ); - - var hashIndex = scriptLocation.indexOf('?'); - - if (hashIndex !== -1) - { - scriptLocation = scriptLocation.substring(0, hashIndex); - } - - return scriptLocation; - }; - - /** - * get the pastes unique identifier from the URL, - * eg. http://example.com/path/?c05354954c49a487#c05354954c49a487 returns c05354954c49a487 - * - * @name helper.pasteId - * @function - * @return {string} unique identifier - */ - me.pasteId = function() - { - return window.location.search.substring(1); - }; - - /** - * return the deciphering key stored in anchor part of the URL - * - * @name helper.pageKey - * @function - * @return {string} key - */ - me.pageKey = function() - { - var key = window.location.hash.substring(1), - i = key.indexOf('&'); - - // Some web 2.0 services and redirectors add data AFTER the anchor - // (such as &utm_source=...). We will strip any additional data. - if (i > -1) - { - key = key.substring(0, i); - } - - return key; - }; - - /** - * convert all applicable characters to HTML entities - * - * @see {@link https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content} - * @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]; - }); - }; - - return me; - })(window, document); - - /** - * internationalization methods - * - * @param {object} window - * @param {object} document - * @name i18n - * @class - */ - var i18n = (function (window, document) { - var me = {}; - - /** - * supported languages, minus the built in 'en' - * - * @private - * @prop {string[]} - * @readonly - */ - var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'oc', 'ru', 'sl', 'zh']; - - /** - * built in language - * - * @private - * @prop {string} - */ - var language = 'en'; - - /** - * translation cache - * - * @private - * @enum {Object} - */ - var translations = {}; - - /** - * translate a string, alias for i18n.translate() - * - * @name i18n._ - * @function - * @param {string} messageId - * @param {...*} args - one or multiple parameters injected into placeholders - * @return {string} - */ - me._ = function() - { - return me.translate(arguments); - }; - - /** - * translate a string - * - * @name i18n.translate - * @function - * @param {string} messageId - * @param {...*} args - one or multiple parameters injected into placeholders - * @return {string} - */ - me.translate = function() - { - var args = arguments, messageId; - if (typeof arguments[0] === 'object') - { - args = arguments[0]; - } - var 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 (!translations.hasOwnProperty(messageId)) - { - if (language !== 'en') - { - console.error( - 'Missing ' + language + ' translation for: ' + messageId - ); - } - translations[messageId] = args[0]; - } - if (usesPlurals && $.isArray(translations[messageId])) - { - var 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 - { - args[0] = translations[messageId]; - } - return helper.sprintf(args); - }; - - /** - * per language functions to use to determine the plural form - * - * @see {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html} - * @name i18n.getPluralForm - * @function - * @param {number} n - * @return {number} array key - */ - me.getPluralForm = function(n) { - switch (language) - { - case 'fr': - case 'oc': - case 'zh': - return (n > 1 ? 1 : 0); - case 'pl': - return (n === 1 ? 0 : (n % 10 >= 2 && n %10 <=4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); - case 'ru': - return (n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); - case 'sl': - return (n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0))); - // de, en, es, it, no - default: - return (n !== 1 ? 1 : 0); - } - }; - - /** - * load translations into cache, then trigger controller initialization - * - * @name i18n.loadTranslations - * @function - */ - me.loadTranslations = function() - { - var newLanguage = helper.getCookie('lang'); - - // auto-select language based on browser settings - if (newLanguage.length === 0) - { - newLanguage = (navigator.language || navigator.userLanguage).substring(0, 2); - } - - // if language is already used (e.g, default 'en'), skip update - if (newLanguage === language) - { - controller.init(); - 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); - controller.init(); - } - - // load strongs from JSON - $.getJSON('i18n/' + newLanguage + '.json', function(data) { - language = newLanguage; - translations = data; - }).fail(function (data, textStatus, errorMsg) { - console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg); - }); - - controller.init(); - }; - - return me; - })(window, document); - - /** - * filter methods - * - * @param {object} window - * @param {object} document - * @name filter - * @class - */ - var filter = (function (window, document) { - var me = {}; - - /** - * compress a message (deflate compression), returns base64 encoded data - * - * @name filter.compress - * @function - * @param {string} message - * @return {string} base64 data - */ - me.compress = function(message) - { - return Base64.toBase64( RawDeflate.deflate( Base64.utob(message) ) ); - }, - - /** - * decompress a message compressed with filter.compress() - * - * @name filter.decompress - * @function - * @param {string} data - base64 data - * @return {string} message - */ - me.decompress = function(data) - { - return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) ); - }, - - /** - * compress, then encrypt message with given key and password - * - * @name filter.cipher - * @function - * @param {string} key - * @param {string} password - * @param {string} message - * @return {string} data - JSON with encrypted data - */ - me.cipher = function(key, password, message) - { - // Galois Counter Mode, keysize 256 bit, authentication tag 128 bit - var options = {mode: 'gcm', ks: 256, ts: 128}; - if ((password || '').trim().length === 0) - { - return sjcl.encrypt(key, me.compress(message), options); - } - return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), me.compress(message), options); - }, - - /** - * decrypt message with key, then decompress - * - * @name filter.decipher - * @function - * @param {string} key - * @param {string} password - * @param {string} data - JSON with encrypted data - * @return {string} decrypted message - */ - me.decipher = function(key, password, data) - { - if (data !== undefined) - { - try - { - return me.decompress(sjcl.decrypt(key, data)); - } - catch(err) - { - try - { - return me.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); - } - catch(e) - { - // ignore error, because ????? @TODO - } - } - } - return ''; - } - - return me; - })(window, document); - - /** - * PrivateBin logic - * - * @param {object} window - * @param {object} document - * @name controller - * @class - */ - var controller = (function (window, document) { - var me = {}; - - /** - * headers to send in AJAX requests - * - * @private - * @enum {Object} - */ - var headers = {'X-Requested-With': 'JSONHttpRequest'}; - - /** - * URL shortners create address - * - * @private - * @prop {string} - */ - var shortenerUrl = ''; - - /** - * URL of newly created paste - * - * @private - * @prop {string} - */ - var createdPasteUrl = ''; - - // jQuery pre-loaded objects - var $attach, - $attachment, - $attachmentLink, - $burnAfterReading, - $burnAfterReadingOption, - $cipherData, - $clearText, - $cloneButton, - $clonedFile, - $comments, - $discussion, - $errorMessage, - $expiration, - $fileRemoveButton, - $fileWrap, - $formatter, - $image, - $loadingIndicator, - $message, - $messageEdit, - $messagePreview, - $newButton, - $openDisc, // @TODO: rename - too similar to openDiscussion, difference unclear - $openDiscussion, - $password, - $passwordInput, - $passwordModal, - $passwordForm, - $passwordDecrypt, - $pasteResult, - $pasteUrl, - $prettyMessage, - $prettyPrint, - $preview, - $rawTextButton, - $remainingTime, - $replyStatus, - $sendButton, - $status; - - /** - * ask the user for the password and set it - * - * @name controller.requestPassword - * @function - */ - me.requestPassword = function() - { - if ($passwordModal.length === 0) { - var password = prompt(i18n._('Please enter the password for this paste:'), ''); - if (password === null) - { - throw 'password prompt canceled'; - } - if (password.length === 0) - { - // recursive… - me.requestPassword(); - } else { - $passwordInput.val(password); - me.displayMessages(); - } - } else { - $passwordModal.modal(); - } - }; - - /** - * use given format on paste, defaults to plain text - * - * @name controller.formatPaste - * @function - * @param {string} format - * @param {string} text - */ - me.formatPaste = function(format, text) - { - helper.setElementText($clearText, text); - helper.setElementText($prettyPrint, text); - - switch (format || 'plaintext') { - case 'markdown': - // silently fail if showdown is not available - // @TODO: maybe better show an error message? At least a warning? - if (typeof showdown === 'object') - { - var converter = new showdown.Converter({ - strikethrough: true, - tables: true, - tablesHeaderId: true - }); - $clearText.html( - converter.makeHtml(text) - ); - // add table classes from bootstrap css - $clearText.find('table').addClass('table-condensed table-bordered'); - - $clearText.removeClass('hidden'); - } else { - console.error('showdown is not loaded, could not parse Markdown'); - } - $prettyMessage.addClass('hidden'); - break; - case 'syntaxhighlighting': - // silently fail if prettyprint is not available - // @TODO: maybe better show an error message? At least a warning? - if (typeof prettyPrintOne === 'function') - { - if (typeof prettyPrint === 'function') - { - prettyPrint(); - } - $prettyPrint.html( - prettyPrintOne( - helper.htmlEntities(text), null, true - ) - ); - } else { - console.error('pretty print is not loaded, could not link '); - } - // fall through, as the rest is the same - default: // = 'plaintext' - // convert URLs to clickable links - helper.urls2links($clearText); - helper.urls2links($prettyPrint); - $clearText.addClass('hidden'); - - - $prettyPrint.css('white-space', 'pre-wrap'); - $prettyPrint.css('word-break', 'normal'); - $prettyPrint.removeClass('prettyprint'); - - $prettyMessage.removeClass('hidden'); - } - }; - - /** - * show decrypted text in the display area, including discussion (if open) - * - * @name controller.displayMessages - * @function - * @param {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta')) - */ - me.displayMessages = function(paste) - { - paste = paste || $.parseJSON($cipherData.text()); - var key = helper.pageKey(), - password = $passwordInput.val(); - if (!$prettyPrint.hasClass('prettyprinted')) { - // Try to decrypt the paste. - try - { - if (paste.attachment) - { - var attachment = filter.decipher(key, password, paste.attachment); - if (attachment.length === 0) - { - if (password.length === 0) - { - me.requestPassword(); - return; - } - attachment = filter.decipher(key, password, paste.attachment); - } - if (attachment.length === 0) - { - throw 'failed to decipher attachment'; - } - - if (paste.attachmentname) - { - var attachmentname = filter.decipher(key, password, paste.attachmentname); - if (attachmentname.length > 0) - { - $attachmentLink.attr('download', attachmentname); - } - } - $attachmentLink.attr('href', attachment); - $attachment.removeClass('hidden'); - - // if the attachment is an image, display it - var imagePrefix = 'data:image/'; - if (attachment.substring(0, imagePrefix.length) === imagePrefix) - { - $image.html( - $(document.createElement('img')) - .attr('src', attachment) - .attr('class', 'img-thumbnail') - ); - $image.removeClass('hidden'); - } - } - var cleartext = filter.decipher(key, password, paste.data); - if (cleartext.length === 0 && password.length === 0 && !paste.attachment) - { - me.requestPassword(); - return; - } - if (cleartext.length === 0 && !paste.attachment) - { - throw 'failed to decipher message'; - } - - $passwordInput.val(password); - if (cleartext.length > 0) - { - $('#pasteFormatter').val(paste.meta.formatter); - me.formatPaste(paste.meta.formatter, cleartext); - } - } - catch(err) - { - me.stateOnlyNewPaste(); - me.showError(i18n._('Could not decrypt data (Wrong key?)')); - return; - } - } - - // display paste expiration / for your eyes only - if (paste.meta.expire_date) - { - var expiration = helper.secondsToHuman(paste.meta.remaining_time), - expirationLabel = [ - 'This document will expire in %d ' + expiration[1] + '.', - 'This document will expire in %d ' + expiration[1] + 's.' - ]; - helper.setMessage($remainingTime, i18n._(expirationLabel, expiration[0])); - $remainingTime.removeClass('foryoureyesonly') - .removeClass('hidden'); - } - if (paste.meta.burnafterreading) - { - // unfortunately many web servers don't support DELETE (and PUT) out of the box - $.ajax({ - type: 'POST', - url: helper.scriptLocation() + '?' + helper.pasteId(), - data: {deletetoken: 'burnafterreading'}, - dataType: 'json', - headers: headers - }) - .fail(function() { - controller.showError(i18n._('Could not delete the paste, it was not stored in burn after reading mode.')); - }); - helper.setMessage($remainingTime, i18n._( - 'FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.' - )); - $remainingTime.addClass('foryoureyesonly') - .removeClass('hidden'); - // discourage cloning (as it can't really be prevented) - $cloneButton.addClass('hidden'); - } - - // if the discussion is opened on this paste, display it - if (paste.meta.opendiscussion) - { - $comments.html(''); - - var $divComment; - - // iterate over comments - for (var i = 0; i < paste.comments.length; ++i) - { - var $place = $comments, - comment = paste.comments[i], - commentText = filter.decipher(key, password, comment.data), - $parentComment = $('#comment_' + comment.parentid); - - $divComment = $('
' - + '
' - + '
' - + '
'); - var $divCommentData = $divComment.find('div.commentdata'); - - // if parent comment exists - if ($parentComment.length) - { - // shift comment to the right - $place = $parentComment; - } - $divComment.find('button').click({commentid: comment.id}, me.openReply); - helper.setElementText($divCommentData, commentText); - helper.urls2links($divCommentData); - - // try to get optional nickname - var nick = filter.decipher(key, password, comment.meta.nickname); - if (nick.length > 0) - { - $divComment.find('span.nickname').text(nick); - } - else - { - divComment.find('span.nickname').html('' + i18n._('Anonymous') + ''); - } - $divComment.find('span.commentdate') - .text(' (' + (new Date(comment.meta.postdate * 1000).toLocaleString()) + ')') - .attr('title', 'CommentID: ' + comment.id); - - // if an avatar is available, display it - if (comment.meta.vizhash) - { - $divComment.find('span.nickname') - .before( - ' ' - ); - } - - $place.append($divComment); - } - - // add 'add new comment' area - $divComment = $( - '
' - ); - $divComment.find('button').click({commentid: helper.pasteId()}, me.openReply); - $comments.append($divComment); - $discussion.removeClass('hidden'); - } - }; - - /** - * open the comment entry when clicking the "Reply" button of a comment - * - * @name controller.openReply - * @function - * @param {Event} event - */ - me.openReply = function(event) - { - event.preventDefault(); - - // remove any other reply area - $('div.reply').remove(); - - var source = $(event.target), - commentid = event.data.commentid, - hint = i18n._('Optional nickname...'), - reply = $( - '

' + - '
' - ); - reply.find('button').click( - {parentid: commentid}, - me.sendComment - ); - source.after(reply); - $replyStatus = $('#replystatus'); - $('#replymessage').focus(); - }; - - /** - * send a reply in a discussion - * - * @name controller.sendComment - * @function - * @param {Event} event - */ - me.sendComment = function(event) - { - event.preventDefault(); - $errorMessage.addClass('hidden'); - // do not send if no data - var replyMessage = $('#replymessage'); - if (replyMessage.val().length === 0) - { - return; - } - - me.showStatus(i18n._('Sending comment...'), true); - var parentid = event.data.parentid, - key = helper.pageKey(), - cipherdata = filter.cipher(key, $passwordInput.val(), replyMessage.val()), - ciphernickname = '', - nick = $('#nickname').val(); - if (nick.length > 0) - { - ciphernickname = filter.cipher(key, $passwordInput.val(), nick); - } - var data_to_send = { - data: cipherdata, - parentid: parentid, - pasteid: helper.pasteId(), - nickname: ciphernickname - }; - - $.ajax({ - type: 'POST', - url: helper.scriptLocation(), - data: data_to_send, - dataType: 'json', - headers: headers, - success: function(data) - { - if (data.status === 0) - { - controller.showStatus(i18n._('Comment posted.')); - $.ajax({ - type: 'GET', - url: helper.scriptLocation() + '?' + helper.pasteId(), - dataType: 'json', - headers: headers, - success: function(data) - { - if (data.status === 0) - { - controller.displayMessages(data); - } - else if (data.status === 1) - { - controller.showError(i18n._('Could not refresh display: %s', data.message)); - } - else - { - controller.showError(i18n._('Could not refresh display: %s', i18n._('unknown status'))); - } - } - }) - .fail(function() { - controller.showError(i18n._('Could not refresh display: %s', i18n._('server error or not responding'))); - }); - } - else if (data.status === 1) - { - controller.showError(i18n._('Could not post comment: %s', data.message)); - } - else - { - controller.showError(i18n._('Could not post comment: %s', i18n._('unknown status'))); - } - } - }) - .fail(function() { - controller.showError(i18n._('Could not post comment: %s', i18n._('server error or not responding'))); - }); - }; - - /** - * send a new paste to server - * - * @name controller.sendData - * @function - * @param {Event} event - */ - me.sendData = function(event) - { - event.preventDefault(); - var file = document.getElementById('file'), - files = (file && file.files) ? file.files : null; // FileList object - - // do not send if no data. - if ($message.val().length === 0 && !(files && files[0])) - { - return; - } - - // if sjcl has not collected enough entropy yet, display a message - if (!sjcl.random.isReady()) - { - me.showStatus(i18n._('Sending paste (Please move your mouse for more entropy)...'), true); - sjcl.random.addEventListener('seeded', function() { - me.sendData(event); - }); - return; - } - - $('.navbar-toggle').click(); - $password.addClass('hidden'); - me.showStatus(i18n._('Sending paste...'), true); - - me.stateSubmittingPaste(); - - var randomkey = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0), - password = $passwordInput.val(); - if(files && files[0]) - { - if(typeof FileReader === undefined) - { - // revert loading status… - me.stateNewPaste(); - me.showError(i18n._('Your browser does not support uploading encrypted files. Please use a newer browser.')); - return; - } - var reader = new FileReader(); - // closure to capture the file information - reader.onload = (function(theFile) - { - return function(e) { - controller.sendDataContinue( - randomkey, - filter.cipher(randomkey, password, e.target.result), - filter.cipher(randomkey, password, theFile.name) - ); - }; - })(files[0]); - reader.readAsDataURL(files[0]); - } - else if($attachmentLink.attr('href')) - { - me.sendDataContinue( - randomkey, - filter.cipher(randomkey, password, $attachmentLink.attr('href')), - $attachmentLink.attr('download') - ); - } - else - { - me.sendDataContinue(randomkey, '', ''); - } - }; - - /** - * send a new paste to server, step 2 - * - * @name controller.sendDataContinue - * @function - * @param {string} randomkey - * @param {string} cipherdata_attachment - * @param {string} cipherdata_attachment_name - */ - me.sendDataContinue = function(randomkey, cipherdata_attachment, cipherdata_attachment_name) - { - var cipherdata = filter.cipher(randomkey, $passwordInput.val(), $message.val()), - data_to_send = { - data: cipherdata, - expire: $('#pasteExpiration').val(), - formatter: $('#pasteFormatter').val(), - burnafterreading: $burnAfterReading.is(':checked') ? 1 : 0, - opendiscussion: $openDiscussion.is(':checked') ? 1 : 0 - }; - if (cipherdata_attachment.length > 0) - { - data_to_send.attachment = cipherdata_attachment; - if (cipherdata_attachment_name.length > 0) - { - data_to_send.attachmentname = cipherdata_attachment_name; - } - } - $.ajax({ - type: 'POST', - url: helper.scriptLocation(), - data: data_to_send, - dataType: 'json', - headers: headers, - success: function(data) - { - if (data.status === 0) { - me.stateExistingPaste(); - var url = helper.scriptLocation() + '?' + data.id + '#' + randomkey, - deleteUrl = helper.scriptLocation() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; - me.showStatus(''); - $errorMessage.addClass('hidden'); - // show new URL in browser bar - history.pushState({type: 'newpaste'}, document.title, url); - - $('#pastelink').html( - i18n._( - 'Your paste is %s (Hit [Ctrl]+[c] to copy)', - url, url - ) + me.shortenUrl(url) - ); - // save newly created element - $pasteUrl = $('#pasteurl'); - // and add click event - $pasteUrl.click(me.pasteLinkClick); - - var shortenButton = $('#shortenbutton'); - if (shortenButton) { - shortenButton.click(me.sendToShortener); - } - $('#deletelink').html('' + i18n._('Delete data') + ''); - $pasteResult.removeClass('hidden'); - // we pre-select the link so that the user only has to [Ctrl]+[c] the link - helper.selectText($pasteUrl[0]); - me.showStatus(''); - me.formatPaste(data_to_send.formatter, $message.val()); - } - else if (data.status === 1) - { - // revert loading status… - controller.stateNewPaste(); - controller.showError(i18n._('Could not create paste: %s', data.message)); - } - else - { - // revert loading status… - controller.stateNewPaste(); - controller.showError(i18n._('Could not create paste: %s', i18n._('unknown status'))); - } - } - }) - .fail(function() - { - // revert loading status… - me.stateNewPaste(); - controller.showError(i18n._('Could not create paste: %s', i18n._('server error or not responding'))); - }); - }; - - /** - * check if a URL shortener was defined and create HTML containing a link to it - * - * @name controller.shortenUrl - * @function - * @param {string} url - * @return {string} html - */ - me.shortenUrl = function(url) - { - var shortenerHtml = $('#shortenbutton'); - if (shortenerHtml) { - shortenerUrl = shortenerHtml.data('shortener'); - createdPasteUrl = url; - return ' ' + $('
').append(shortenerHtml.clone()).html(); - } - return ''; - }; - - /** - * put the screen in "New paste" mode - * - * @name controller.stateNewPaste - * @function - */ - me.stateNewPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - $sendButton.removeClass('hidden'); - $expiration.removeClass('hidden'); - $formatter.removeClass('hidden'); - $burnAfterReadingOption.removeClass('hidden'); - $openDisc.removeClass('hidden'); - $newButton.removeClass('hidden'); - $password.removeClass('hidden'); - $attach.removeClass('hidden'); - $message.removeClass('hidden'); - $preview.removeClass('hidden'); - $message.focus(); - }; - - /** - * put the screen in mode after submitting a paste - * - * @name controller.stateSubmittingPaste - * @function - */ - me.stateSubmittingPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $sendButton.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $newButton.addClass('hidden'); - $password.addClass('hidden'); - $attach.addClass('hidden'); - $message.addClass('hidden'); - $preview.addClass('hidden'); - - $loadingIndicator.removeClass('hidden'); - }; - - /** - * put the screen in a state where the only option is to submit a - * new paste - * - * @name controller.stateOnlyNewPaste - * @function - */ - me.stateOnlyNewPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $sendButton.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $password.addClass('hidden'); - $attach.addClass('hidden'); - $message.addClass('hidden'); - $preview.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - - $newButton.removeClass('hidden'); - }; - - /** - * put the screen in "Existing paste" mode - * - * @name controller.stateExistingPaste - * @function - * @param {boolean} [preview=false] - (optional) tell if the preview tabs should be displayed, defaults to false - */ - me.stateExistingPaste = function(preview) - { - preview = preview || false; - - if (!preview) - { - // no "clone" for IE<10. - if ($('#oldienotice').is(":visible")) - { - $cloneButton.addClass('hidden'); - } - else - { - $cloneButton.removeClass('hidden'); - } - - $rawTextButton.removeClass('hidden'); - $sendButton.addClass('hidden'); - $attach.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $newButton.removeClass('hidden'); - $preview.addClass('hidden'); - } - - $pasteResult.addClass('hidden'); - $message.addClass('hidden'); - $clearText.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - }; - - /** - * when "burn after reading" is checked, disable discussion - * - * @name controller.changeBurnAfterReading - * @function - */ - me.changeBurnAfterReading = function() - { - if ($burnAfterReading.is(':checked') ) - { - $openDisc.addClass('buttondisabled'); - $openDiscussion.attr({checked: false, disabled: true}); - } - else - { - $openDisc.removeClass('buttondisabled'); - $openDiscussion.removeAttr('disabled'); - } - }; - - /** - * when discussion is checked, disable "burn after reading" - * - * @name controller.changeOpenDisc - * @function - */ - me.changeOpenDisc = function() - { - if ($openDiscussion.is(':checked') ) - { - $burnAfterReadingOption.addClass('buttondisabled'); - $burnAfterReading.attr({checked: false, disabled: true}); - } - else - { - $burnAfterReadingOption.removeClass('buttondisabled'); - $burnAfterReading.removeAttr('disabled'); - } - }; - - /** - * forward to URL shortener - * - * @name controller.sendToShortener - * @function - * @param {Event} event - */ - me.sendToShortener = function(event) - { - window.location.href = shortenerUrl + encodeURIComponent(createdPasteUrl); - event.preventDefault(); - }; - - /** - * reload the page - * - * This takes the user to the PrivateBin home page. - * - * @name controller.reloadPage - * @function - * @param {Event} event - */ - me.reloadPage = function(event) - { - window.location.href = helper.scriptLocation(); - event.preventDefault(); - }; - - /** - * return raw text - * - * @name controller.rawText - * @function - * @param {Event} event - */ - me.rawText = function(event) - { - var paste = $('#pasteFormatter').val() === 'markdown' ? - $prettyPrint.text() : $clearText.text(); - history.pushState( - null, document.title, helper.scriptLocation() + '?' + - helper.pasteId() + '#' + helper.pageKey() - ); - // we use text/html instead of text/plain to avoid a bug when - // reloading the raw text view (it reverts to type text/html) - var newDoc = document.open('text/html', 'replace'); - newDoc.write('
' + helper.htmlEntities(paste) + '
'); - newDoc.close(); - - event.preventDefault(); - }; - - /** - * clone the current paste - * - * @name controller.clonePaste - * @function - * @param {Event} event - */ - me.clonePaste = function(event) - { - event.preventDefault(); - me.stateNewPaste(); - - // erase the id and the key in url - history.replaceState(null, document.title, helper.scriptLocation()); - - me.showStatus(''); - if ($attachmentLink.attr('href')) - { - $clonedFile.removeClass('hidden'); - $fileWrap.addClass('hidden'); - } - $message.text( - $('#pasteFormatter').val() === 'markdown' ? - $prettyPrint.text() : $clearText.text() - ); - $('.navbar-toggle').click(); - }; - - /** - * set the expiration on bootstrap templates - * - * @name controller.setExpiration - * @function - * @param {Event} event - */ - me.setExpiration = function(event) - { - event.preventDefault(); - var target = $(event.target); - $('#pasteExpiration').val(target.data('expiration')); - $('#pasteExpirationDisplay').text(target.text()); - }; - - /** - * set the format on bootstrap templates - * - * @name controller.setFormat - * @function - * @param {Event} event - */ - me.setFormat = function(event) - { - var target = $(event.target); - $('#pasteFormatter').val(target.data('format')); - $('#pasteFormatterDisplay').text(target.text()); - - if ($messagePreview.parent().hasClass('active')) { - me.viewPreview(event); - } - event.preventDefault(); - }; - - /** - * set the language in a cookie and reload the page - * - * @name controller.setLanguage - * @function - * @param {Event} event - */ - me.setLanguage = function(event) - { - document.cookie = 'lang=' + $(event.target).data('lang'); - me.reloadPage(event); - }; - - /** - * support input of tab character - * - * @name controller.supportTabs - * @function - * @param {Event} event - * @TODO doc what is @this here? - */ - me.supportTabs = function(event) - { - var keyCode = event.keyCode || event.which; - // tab was pressed - if (keyCode === 9) - { - // prevent the textarea to lose focus - event.preventDefault(); - // get caret position & selection - var 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; - } - }; - - /** - * view the editor tab - * - * @name controller.viewEditor - * @function - * @param {Event} event - */ - me.viewEditor = function(event) - { - $messagePreview.parent().removeClass('active'); - $messageEdit.parent().addClass('active'); - $message.focus(); - me.stateNewPaste(); - - event.preventDefault(); - }; - - /** - * view the preview tab - * - * @name controller.viewPreview - * @function - * @param {Event} event - */ - me.viewPreview = function(event) - { - $messageEdit.parent().removeClass('active'); - $messagePreview.parent().addClass('active'); - $message.focus(); - me.stateExistingPaste(true); - me.formatPaste($('#pasteFormatter').val(), $message.val()); - - event.preventDefault(); - }; - - /** - * handle history (pop) state changes - * - * currently this does only handle redirects to the home page. - * - * @name controller.historyChange - * @function - * @param {Event} event - */ - me.historyChange = function(event) - { - var currentLocation = helper.scriptLocation(); - if (event.originalEvent.state === null && // no state object passed - event.originalEvent.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; - } - }; - - /** - * 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 controller.pasteLinkClick - * @function - * @param {Event} event - */ - me.pasteLinkClick = function(event) - { - // 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); - } - }; - - /** - * create a new paste - * - * @name controller.newPaste - * @function - */ - me.newPaste = function() - { - me.stateNewPaste(); - me.showStatus(''); - $message.text(''); - me.changeBurnAfterReading(); - me.changeOpenDisc(); - }; - - /** - * removes an attachment - * - * @name controller.removeAttachment - * @function - */ - me.removeAttachment = function() - { - $clonedFile.addClass('hidden'); - // removes the saved decrypted file data - $attachmentLink.attr('href', ''); - // the only way to deselect the file is to recreate the input // @TODO really? - $fileWrap.html($fileWrap.html()); - $fileWrap.removeClass('hidden'); - }; - - /** - * decrypt using the password from the modal dialog - * - * @name controller.decryptPasswordModal - * @function - */ - me.decryptPasswordModal = function() - { - $passwordInput.val($passwordDecrypt.val()); - me.displayMessages(); - }; - - /** - * submit a password in the modal dialog - * - * @name controller.submitPasswordModal - * @function - * @param {Event} event - */ - me.submitPasswordModal = function(event) - { - event.preventDefault(); - $passwordModal.modal('hide'); - }; - - /** - * display an error message, - * we use the same function for paste and reply to comments - * - * @name controller.showError - * @function - * @param {string} message - text to display - */ - me.showError = function(message) - { - if ($status.length) - { - $status.addClass('errorMessage').text(message); - } - else - { - $errorMessage.removeClass('hidden'); - helper.setMessage($errorMessage, message); - } - if (typeof $replyStatus !== 'undefined') { - $replyStatus.addClass('errorMessage'); - $replyStatus.addClass($errorMessage.attr('class')); - if ($status.length) - { - $replyStatus.html($status.html()); - } - else - { - $replyStatus.html($errorMessage.html()); - } - } - }; - - /** - * display a status message, - * we use the same function for paste and reply to comments - * - * @name controller.showStatus - * @function - * @param {string} message - text to display - * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false - */ - me.showStatus = function(message, spin) - { - if (spin || false) - { - var img = ''; - $status.prepend(img); - if (typeof $replyStatus !== 'undefined') { - $replyStatus.prepend(img); - } - } - if (typeof $replyStatus !== 'undefined') { - $replyStatus.removeClass('errorMessage').text(message); - } - if (!message) - { - $status.html(' '); - return; - } - if (message === '') - { - $status.html(' '); - return; - } - $status.removeClass('errorMessage').text(message); - }; - - /** - * bind events to DOM elements - * - * @private - * @function - */ - function bindEvents() - { - $burnAfterReading.change(me.changeBurnAfterReading); - $openDisc.change(me.changeOpenDisc); - $sendButton.click(me.sendData); - $cloneButton.click(me.clonePaste); - $rawTextButton.click(me.rawText); - $fileRemoveButton.click(me.removeAttachment); - $('.reloadlink').click(me.reloadPage); - $message.keydown(me.supportTabs); - $messageEdit.click(me.viewEditor); - $messagePreview.click(me.viewPreview); - - // bootstrap template drop downs - $('ul.dropdown-menu li a', $('#expiration').parent()).click(me.setExpiration); - $('ul.dropdown-menu li a', $('#formatter').parent()).click(me.setFormat); - $('#language ul.dropdown-menu li a').click(me.setLanguage); - - // page template drop down - $('#language select option').click(me.setLanguage); - - // focus password input when it is shown - $passwordModal.on('shown.bs.modal', function () { - $passwordDecrypt.focus(); - }); - // handle modal password request on decryption - $passwordModal.on('hidden.bs.modal', me.decryptPasswordModal); - $passwordForm.submit(me.submitPasswordModal); - - $(window).on('popstate', me.historyChange); - }; - - /** - * main application - * - * @name controller.init - * @function - */ - me.init = function() - { - // hide "no javascript" message - $('#noscript').hide(); - - // preload jQuery wrapped DOM elements and bind events - $attach = $('#attach'); - $attachment = $('#attachment'); - $attachmentLink = $('#attachment a'); - $burnAfterReading = $('#burnafterreading'); - $burnAfterReadingOption = $('#burnafterreadingoption'); - $cipherData = $('#cipherdata'); - $clearText = $('#cleartext'); - $cloneButton = $('#clonebutton'); - $clonedFile = $('#clonedfile'); - $comments = $('#comments'); - $discussion = $('#discussion'); - $errorMessage = $('#errormessage'); - $expiration = $('#expiration'); - $fileRemoveButton = $('#fileremovebutton'); - $fileWrap = $('#filewrap'); - $formatter = $('#formatter'); - $image = $('#image'); - $loadingIndicator = $('#loadingindicator'); - $message = $('#message'); - $messageEdit = $('#messageedit'); - $messagePreview = $('#messagepreview'); - $newButton = $('#newbutton'); - $openDisc = $('#opendisc'); - $openDiscussion = $('#opendiscussion'); - $password = $('#password'); - $passwordInput = $('#passwordinput'); - $passwordModal = $('#passwordmodal'); - $passwordForm = $('#passwordform'); - $passwordDecrypt = $('#passworddecrypt'); - $pasteResult = $('#pasteresult'); - // $pasteUrl is saved in sendDataContinue() if/after it is - // actually created - $prettyMessage = $('#prettymessage'); - $prettyPrint = $('#prettyprint'); - $preview = $('#preview'); - $rawTextButton = $('#rawtextbutton'); - $remainingTime = $('#remainingtime'); - // $replyStatus is saved in openReply() - $sendButton = $('#sendbutton'); - $status = $('#status'); - bindEvents(); - - // display status returned by php code, if any (eg. paste was properly deleted) - if ($status.text().length > 0) - { - me.showStatus($status.text()); - return; - } - - // keep line height even if content empty - $status.html(' '); - - // display an existing paste - if ($cipherData.text().length > 1) - { - // missing decryption key in URL? - if (window.location.hash.length === 0) - { - me.showError(i18n._('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)')); - return; - } - - // show proper elements on screen - me.stateExistingPaste(); - me.displayMessages(); - } - // display error message from php code - else if ($errorMessage.text().length > 1) - { - me.showError($errorMessage.text()); - } - // create a new paste - else - { - me.newPaste(); - } - }; - - return me; - })(window, document); - +// startup +jQuery(document).ready(function() { /** * main application start, called when DOM is fully loaded and * runs controller initalization after translations are loaded */ - $(i18n.loadTranslations); + PrivateBin.i18n.loadTranslations(); +}); - return { - helper: helper, - i18n: i18n, - filter: filter, - controller: controller +/** + * @name PrivateBin + * @namespace + */ +var PrivateBin = window.PrivateBin || {}; + +/** + * static helper methods + * + * @param {object} window + * @param {object} document + * @name helper + * @class + */ +PrivateBin.helper = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { + var me = {}; + + /** + * character to HTML entity lookup table + * + * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} + * @private + * @enum {Object} + * @readonly + */ + var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' }; -}(jQuery, sjcl, Base64, RawDeflate); + + /** + * cache for script location + * + * @private + * @enum {string|null} + */ + var scriptLocation = null; + + /** + * converts a duration (in seconds) into human friendly approximation + * + * @name helper.secondsToHuman + * @function + * @param {number} seconds + * @return {Array} + */ + me.secondsToHuman = function(seconds) + { + var 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) + { + var range, selection; + + // MS + if (document.body.createTextRange) + { + range = document.body.createTextRange(); + range.moveToElementText(element); + range.select(); + } + // all others + else if (window.getSelection) + { + selection = window.getSelection(); + range = document.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + } + }; + + /** + * set text of a jQuery element (required for IE), + * + * @name helper.setElementText + * @function + * @param {jQuery} $element - a jQuery element + * @param {string} text - the text to enter + * @TODO check for XSS attacks, usually no CSS can prevent them so this looks weird on the first look + */ + me.setElementText = function($element, text) + { + // For IE<10: Doesn't support white-space:pre-wrap; so we have to do this... + if ($('#oldienotice').is(':visible')) { + var html = me.htmlEntities(text).replace(/\n/ig, '\r\n
'); + $element.html('
' + html + '
'); + } + // for other (sane) browsers: + else + { + $element.text(text); + } + }; + + /** + * replace last child of element with message + * + * @name helper.setMessage + * @function + * @param {jQuery} $element - a jQuery wrapped DOM element + * @param {string} message - the message to append + */ + me.setMessage = function($element, message) + { + var content = $element.contents(); + if (content.length > 0) + { + content[content.length - 1].nodeValue = ' ' + message; + } + else + { + me.setElementText($element, message); + } + }; + + /** + * convert URLs to clickable links. + * URLs to handle: + *
+     *     magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7
+     *     http://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 {Object} element - a jQuery DOM element + */ + me.urls2links = function(element) + { + var markup = '$1'; + element.html( + element.html().replace( + /((http|https|ftp):\/\/[\w?=&.\/-;#@~%+-]+(?![\w\s?&.\/;#~%"=-]*>))/ig, + markup + ) + ); + element.html( + element.html().replace( + /((magnet):[\w?=&.\/-;#@~%+-]+)/ig, + markup + ) + ); + }; + + /** + * minimal sprintf emulation for %s and %d formats + * + * @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() + { + var args = arguments; + if (typeof arguments[0] === 'object') + { + args = arguments[0]; + } + var format = args[0], + i = 1; + return format.replace(/%((%)|s|d)/g, function (m) { + // m is the matched format, e.g. %s, %d + var val; + if (m[2]) { + val = m[2]; + } else { + val = args[i]; + // A switch statement so that the formatter can be extended. + switch (m) + { + case '%d': + val = parseFloat(val); + if (isNaN(val)) { + val = 0; + } + break; + default: + // Default is %s + } + ++i; + } + return val; + }); + }; + + /** + * get value of cookie, if it was set, empty string otherwise + * + * @see {@link http://www.w3schools.com/js/js_cookies.asp} + * @name helper.getCookie + * @function + * @param {string} cname + * @return {string} + */ + me.getCookie = function(cname) { + var name = cname + '=', + ca = document.cookie.split(';'); + for (var i = 0; i < ca.length; ++i) { + var 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 script location (without search or hash part of the URL), + * eg. http://example.com/path/?aaaa#bbbb --> http://example.com/path/ + * + * @name helper.scriptLocation + * @function + * @return {string} current script location + */ + me.scriptLocation = function() + { + // check for cached version + if (scriptLocation !== null) { + return scriptLocation; + } + + scriptLocation = window.location.href.substring( + 0, + window.location.href.length - window.location.search.length - window.location.hash.length + ); + + var hashIndex = scriptLocation.indexOf('?'); + + if (hashIndex !== -1) + { + scriptLocation = scriptLocation.substring(0, hashIndex); + } + + return scriptLocation; + }; + + /** + * get the pastes unique identifier from the URL, + * eg. http://example.com/path/?c05354954c49a487#c05354954c49a487 returns c05354954c49a487 + * + * @name helper.pasteId + * @function + * @return {string} unique identifier + */ + me.pasteId = function() + { + return window.location.search.substring(1); + }; + + /** + * return the deciphering key stored in anchor part of the URL + * + * @name helper.pageKey + * @function + * @return {string} key + */ + me.pageKey = function() + { + var key = window.location.hash.substring(1), + i = key.indexOf('&'); + + // Some web 2.0 services and redirectors add data AFTER the anchor + // (such as &utm_source=...). We will strip any additional data. + if (i > -1) + { + key = key.substring(0, i); + } + + return key; + }; + + /** + * convert all applicable characters to HTML entities + * + * @see {@link https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content} + * @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]; + }); + }; + + return me; +})(window, document, jQuery, sjcl, Base64, RawDeflate); + +/** + * internationalization methods + * + * @param {object} window + * @param {object} document + * @name i18n + * @class + */ +PrivateBin.i18n = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { + var me = {}; + + /** + * supported languages, minus the built in 'en' + * + * @private + * @prop {string[]} + * @readonly + */ + var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'oc', 'ru', 'sl', 'zh']; + + /** + * built in language + * + * @private + * @prop {string} + */ + var language = 'en'; + + /** + * translation cache + * + * @private + * @enum {Object} + */ + var translations = {}; + + /** + * translate a string, alias for i18n.translate() + * + * @name i18n._ + * @function + * @param {string} messageId + * @param {...*} args - one or multiple parameters injected into placeholders + * @return {string} + */ + me._ = function() + { + return me.translate(arguments); + }; + + /** + * translate a string + * + * @name i18n.translate + * @function + * @param {string} messageId + * @param {...*} args - one or multiple parameters injected into placeholders + * @return {string} + */ + me.translate = function() + { + var args = arguments, messageId; + if (typeof arguments[0] === 'object') + { + args = arguments[0]; + } + var 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 (!translations.hasOwnProperty(messageId)) + { + if (language !== 'en') + { + console.error( + 'Missing ' + language + ' translation for: ' + messageId + ); + } + translations[messageId] = args[0]; + } + if (usesPlurals && $.isArray(translations[messageId])) + { + var 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 + { + args[0] = translations[messageId]; + } + return helper.sprintf(args); + }; + + /** + * per language functions to use to determine the plural form + * + * @see {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html} + * @name i18n.getPluralForm + * @function + * @param {number} n + * @return {number} array key + */ + me.getPluralForm = function(n) { + switch (language) + { + case 'fr': + case 'oc': + case 'zh': + return (n > 1 ? 1 : 0); + case 'pl': + return (n === 1 ? 0 : (n % 10 >= 2 && n %10 <=4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); + case 'ru': + return (n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); + case 'sl': + return (n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0))); + // de, en, es, it, no + default: + return (n !== 1 ? 1 : 0); + } + }; + + /** + * load translations into cache, then trigger controller initialization + * + * @name i18n.loadTranslations + * @function + */ + me.loadTranslations = function() + { + var newLanguage = PrivateBin.helper.getCookie('lang'); + + // auto-select language based on browser settings + if (newLanguage.length === 0) + { + newLanguage = (navigator.language || navigator.userLanguage).substring(0, 2); + } + + // if language is already used (e.g, default 'en'), skip update + if (newLanguage === language) + { + controller.init(); + 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); + controller.init(); + } + + // load strongs from JSON + $.getJSON('i18n/' + newLanguage + '.json', function(data) { + language = newLanguage; + translations = data; + }).fail(function (data, textStatus, errorMsg) { + console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg); + }); + + controller.init(); + }; + + return me; +})(window, document, jQuery, sjcl, Base64, RawDeflate); + +/** + * filter methods + * + * @param {object} window + * @param {object} document + * @name filter + * @class + */ +PrivateBin.filter = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { + var me = {}; + + /** + * compress a message (deflate compression), returns base64 encoded data + * + * @name filter.compress + * @function + * @param {string} message + * @return {string} base64 data + */ + me.compress = function(message) + { + return Base64.toBase64( RawDeflate.deflate( Base64.utob(message) ) ); + }; + + /** + * decompress a message compressed with filter.compress() + * + * @name filter.decompress + * @function + * @param {string} data - base64 data + * @return {string} message + */ + me.decompress = function(data) + { + return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) ); + }; + + /** + * compress, then encrypt message with given key and password + * + * @name filter.cipher + * @function + * @param {string} key + * @param {string} password + * @param {string} message + * @return {string} data - JSON with encrypted data + */ + me.cipher = function(key, password, message) + { + // Galois Counter Mode, keysize 256 bit, authentication tag 128 bit + var options = {mode: 'gcm', ks: 256, ts: 128}; + if ((password || '').trim().length === 0) + { + return sjcl.encrypt(key, me.compress(message), options); + } + return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), me.compress(message), options); + }; + + /** + * decrypt message with key, then decompress + * + * @name filter.decipher + * @function + * @param {string} key + * @param {string} password + * @param {string} data - JSON with encrypted data + * @return {string} decrypted message + */ + me.decipher = function(key, password, data) + { + if (data !== undefined) + { + try + { + return me.decompress(sjcl.decrypt(key, data)); + } + catch(err) + { + try + { + return me.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); + } + catch(e) + { + // ignore error, because ????? @TODO + } + } + } + return ''; + }; + + return me; +})(window, document, jQuery, sjcl, Base64, RawDeflate); + +/** + * PrivateBin logic + * + * @param {object} window + * @param {object} document + * @name controller + * @class + */ +PrivateBin.controller = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { + var me = {}; + + /** + * headers to send in AJAX requests + * + * @private + * @enum {Object} + */ + var headers = {'X-Requested-With': 'JSONHttpRequest'}; + + /** + * URL shortners create address + * + * @private + * @prop {string} + */ + var shortenerUrl = ''; + + /** + * URL of newly created paste + * + * @private + * @prop {string} + */ + var createdPasteUrl = ''; + + // jQuery pre-loaded objects + var $attach, + $attachment, + $attachmentLink, + $burnAfterReading, + $burnAfterReadingOption, + $cipherData, + $clearText, + $cloneButton, + $clonedFile, + $comments, + $discussion, + $errorMessage, + $expiration, + $fileRemoveButton, + $fileWrap, + $formatter, + $image, + $loadingIndicator, + $message, + $messageEdit, + $messagePreview, + $newButton, + $openDisc, // @TODO: rename - too similar to openDiscussion, difference unclear + $openDiscussion, + $password, + $passwordInput, + $passwordModal, + $passwordForm, + $passwordDecrypt, + $pasteResult, + $pasteUrl, + $prettyMessage, + $prettyPrint, + $preview, + $rawTextButton, + $remainingTime, + $replyStatus, + $sendButton, + $status; + + /** + * ask the user for the password and set it + * + * @name controller.requestPassword + * @function + */ + me.requestPassword = function() + { + if ($passwordModal.length === 0) { + var password = prompt(i18n._('Please enter the password for this paste:'), ''); + if (password === null) + { + throw 'password prompt canceled'; + } + if (password.length === 0) + { + // recursive… + me.requestPassword(); + } else { + $passwordInput.val(password); + me.displayMessages(); + } + } else { + $passwordModal.modal(); + } + }; + + /** + * use given format on paste, defaults to plain text + * + * @name controller.formatPaste + * @function + * @param {string} format + * @param {string} text + */ + me.formatPaste = function(format, text) + { + helper.setElementText($clearText, text); + helper.setElementText($prettyPrint, text); + + switch (format || 'plaintext') { + case 'markdown': + // silently fail if showdown is not available + // @TODO: maybe better show an error message? At least a warning? + if (typeof showdown === 'object') + { + var converter = new showdown.Converter({ + strikethrough: true, + tables: true, + tablesHeaderId: true + }); + $clearText.html( + converter.makeHtml(text) + ); + // add table classes from bootstrap css + $clearText.find('table').addClass('table-condensed table-bordered'); + + $clearText.removeClass('hidden'); + } else { + console.error('showdown is not loaded, could not parse Markdown'); + } + $prettyMessage.addClass('hidden'); + break; + case 'syntaxhighlighting': + // silently fail if prettyprint is not available + // @TODO: maybe better show an error message? At least a warning? + if (typeof prettyPrintOne === 'function') + { + if (typeof prettyPrint === 'function') + { + prettyPrint(); + } + $prettyPrint.html( + prettyPrintOne( + helper.htmlEntities(text), null, true + ) + ); + } else { + console.error('pretty print is not loaded, could not link '); + } + // fall through, as the rest is the same + default: // = 'plaintext' + // convert URLs to clickable links + helper.urls2links($clearText); + helper.urls2links($prettyPrint); + $clearText.addClass('hidden'); + + + $prettyPrint.css('white-space', 'pre-wrap'); + $prettyPrint.css('word-break', 'normal'); + $prettyPrint.removeClass('prettyprint'); + + $prettyMessage.removeClass('hidden'); + } + }; + + /** + * show decrypted text in the display area, including discussion (if open) + * + * @name controller.displayMessages + * @function + * @param {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta')) + */ + me.displayMessages = function(paste) + { + paste = paste || $.parseJSON($cipherData.text()); + var key = helper.pageKey(), + password = $passwordInput.val(); + if (!$prettyPrint.hasClass('prettyprinted')) { + // Try to decrypt the paste. + try + { + if (paste.attachment) + { + var attachment = filter.decipher(key, password, paste.attachment); + if (attachment.length === 0) + { + if (password.length === 0) + { + me.requestPassword(); + return; + } + attachment = filter.decipher(key, password, paste.attachment); + } + if (attachment.length === 0) + { + throw 'failed to decipher attachment'; + } + + if (paste.attachmentname) + { + var attachmentname = filter.decipher(key, password, paste.attachmentname); + if (attachmentname.length > 0) + { + $attachmentLink.attr('download', attachmentname); + } + } + $attachmentLink.attr('href', attachment); + $attachment.removeClass('hidden'); + + // if the attachment is an image, display it + var imagePrefix = 'data:image/'; + if (attachment.substring(0, imagePrefix.length) === imagePrefix) + { + $image.html( + $(document.createElement('img')) + .attr('src', attachment) + .attr('class', 'img-thumbnail') + ); + $image.removeClass('hidden'); + } + } + var cleartext = filter.decipher(key, password, paste.data); + if (cleartext.length === 0 && password.length === 0 && !paste.attachment) + { + me.requestPassword(); + return; + } + if (cleartext.length === 0 && !paste.attachment) + { + throw 'failed to decipher message'; + } + + $passwordInput.val(password); + if (cleartext.length > 0) + { + $('#pasteFormatter').val(paste.meta.formatter); + me.formatPaste(paste.meta.formatter, cleartext); + } + } + catch(err) + { + me.stateOnlyNewPaste(); + me.showError(i18n._('Could not decrypt data (Wrong key?)')); + return; + } + } + + // display paste expiration / for your eyes only + if (paste.meta.expire_date) + { + var expiration = helper.secondsToHuman(paste.meta.remaining_time), + expirationLabel = [ + 'This document will expire in %d ' + expiration[1] + '.', + 'This document will expire in %d ' + expiration[1] + 's.' + ]; + helper.setMessage($remainingTime, i18n._(expirationLabel, expiration[0])); + $remainingTime.removeClass('foryoureyesonly') + .removeClass('hidden'); + } + if (paste.meta.burnafterreading) + { + // unfortunately many web servers don't support DELETE (and PUT) out of the box + $.ajax({ + type: 'POST', + url: helper.scriptLocation() + '?' + helper.pasteId(), + data: {deletetoken: 'burnafterreading'}, + dataType: 'json', + headers: headers + }) + .fail(function() { + controller.showError(i18n._('Could not delete the paste, it was not stored in burn after reading mode.')); + }); + helper.setMessage($remainingTime, i18n._( + 'FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.' + )); + $remainingTime.addClass('foryoureyesonly') + .removeClass('hidden'); + // discourage cloning (as it can't really be prevented) + $cloneButton.addClass('hidden'); + } + + // if the discussion is opened on this paste, display it + if (paste.meta.opendiscussion) + { + $comments.html(''); + + var $divComment; + + // iterate over comments + for (var i = 0; i < paste.comments.length; ++i) + { + var $place = $comments, + comment = paste.comments[i], + commentText = filter.decipher(key, password, comment.data), + $parentComment = $('#comment_' + comment.parentid); + + $divComment = $('
' + + '
' + + '
' + + '
'); + var $divCommentData = $divComment.find('div.commentdata'); + + // if parent comment exists + if ($parentComment.length) + { + // shift comment to the right + $place = $parentComment; + } + $divComment.find('button').click({commentid: comment.id}, me.openReply); + helper.setElementText($divCommentData, commentText); + helper.urls2links($divCommentData); + + // try to get optional nickname + var nick = filter.decipher(key, password, comment.meta.nickname); + if (nick.length > 0) + { + $divComment.find('span.nickname').text(nick); + } + else + { + divComment.find('span.nickname').html('' + i18n._('Anonymous') + ''); + } + $divComment.find('span.commentdate') + .text(' (' + (new Date(comment.meta.postdate * 1000).toLocaleString()) + ')') + .attr('title', 'CommentID: ' + comment.id); + + // if an avatar is available, display it + if (comment.meta.vizhash) + { + $divComment.find('span.nickname') + .before( + ' ' + ); + } + + $place.append($divComment); + } + + // add 'add new comment' area + $divComment = $( + '
' + ); + $divComment.find('button').click({commentid: helper.pasteId()}, me.openReply); + $comments.append($divComment); + $discussion.removeClass('hidden'); + } + }; + + /** + * open the comment entry when clicking the "Reply" button of a comment + * + * @name controller.openReply + * @function + * @param {Event} event + */ + me.openReply = function(event) + { + event.preventDefault(); + + // remove any other reply area + $('div.reply').remove(); + + var source = $(event.target), + commentid = event.data.commentid, + hint = i18n._('Optional nickname...'), + reply = $( + '

' + + '
' + ); + reply.find('button').click( + {parentid: commentid}, + me.sendComment + ); + source.after(reply); + $replyStatus = $('#replystatus'); + $('#replymessage').focus(); + }; + + /** + * send a reply in a discussion + * + * @name controller.sendComment + * @function + * @param {Event} event + */ + me.sendComment = function(event) + { + event.preventDefault(); + $errorMessage.addClass('hidden'); + // do not send if no data + var replyMessage = $('#replymessage'); + if (replyMessage.val().length === 0) + { + return; + } + + me.showStatus(i18n._('Sending comment...'), true); + var parentid = event.data.parentid, + key = helper.pageKey(), + cipherdata = filter.cipher(key, $passwordInput.val(), replyMessage.val()), + ciphernickname = '', + nick = $('#nickname').val(); + if (nick.length > 0) + { + ciphernickname = filter.cipher(key, $passwordInput.val(), nick); + } + var data_to_send = { + data: cipherdata, + parentid: parentid, + pasteid: helper.pasteId(), + nickname: ciphernickname + }; + + $.ajax({ + type: 'POST', + url: helper.scriptLocation(), + data: data_to_send, + dataType: 'json', + headers: headers, + success: function(data) + { + if (data.status === 0) + { + controller.showStatus(i18n._('Comment posted.')); + $.ajax({ + type: 'GET', + url: helper.scriptLocation() + '?' + helper.pasteId(), + dataType: 'json', + headers: headers, + success: function(data) + { + if (data.status === 0) + { + controller.displayMessages(data); + } + else if (data.status === 1) + { + controller.showError(i18n._('Could not refresh display: %s', data.message)); + } + else + { + controller.showError(i18n._('Could not refresh display: %s', i18n._('unknown status'))); + } + } + }) + .fail(function() { + controller.showError(i18n._('Could not refresh display: %s', i18n._('server error or not responding'))); + }); + } + else if (data.status === 1) + { + controller.showError(i18n._('Could not post comment: %s', data.message)); + } + else + { + controller.showError(i18n._('Could not post comment: %s', i18n._('unknown status'))); + } + } + }) + .fail(function() { + controller.showError(i18n._('Could not post comment: %s', i18n._('server error or not responding'))); + }); + }; + + /** + * send a new paste to server + * + * @name controller.sendData + * @function + * @param {Event} event + */ + me.sendData = function(event) + { + event.preventDefault(); + var file = document.getElementById('file'), + files = (file && file.files) ? file.files : null; // FileList object + + // do not send if no data. + if ($message.val().length === 0 && !(files && files[0])) + { + return; + } + + // if sjcl has not collected enough entropy yet, display a message + if (!sjcl.random.isReady()) + { + me.showStatus(i18n._('Sending paste (Please move your mouse for more entropy)...'), true); + sjcl.random.addEventListener('seeded', function() { + me.sendData(event); + }); + return; + } + + $('.navbar-toggle').click(); + $password.addClass('hidden'); + me.showStatus(i18n._('Sending paste...'), true); + + me.stateSubmittingPaste(); + + var randomkey = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0), + password = $passwordInput.val(); + if(files && files[0]) + { + if(typeof FileReader === undefined) + { + // revert loading status… + me.stateNewPaste(); + me.showError(i18n._('Your browser does not support uploading encrypted files. Please use a newer browser.')); + return; + } + var reader = new FileReader(); + // closure to capture the file information + reader.onload = (function(theFile) + { + return function(e) { + controller.sendDataContinue( + randomkey, + filter.cipher(randomkey, password, e.target.result), + filter.cipher(randomkey, password, theFile.name) + ); + }; + })(files[0]); + reader.readAsDataURL(files[0]); + } + else if($attachmentLink.attr('href')) + { + me.sendDataContinue( + randomkey, + filter.cipher(randomkey, password, $attachmentLink.attr('href')), + $attachmentLink.attr('download') + ); + } + else + { + me.sendDataContinue(randomkey, '', ''); + } + }; + + /** + * send a new paste to server, step 2 + * + * @name controller.sendDataContinue + * @function + * @param {string} randomkey + * @param {string} cipherdata_attachment + * @param {string} cipherdata_attachment_name + */ + me.sendDataContinue = function(randomkey, cipherdata_attachment, cipherdata_attachment_name) + { + var cipherdata = filter.cipher(randomkey, $passwordInput.val(), $message.val()), + data_to_send = { + data: cipherdata, + expire: $('#pasteExpiration').val(), + formatter: $('#pasteFormatter').val(), + burnafterreading: $burnAfterReading.is(':checked') ? 1 : 0, + opendiscussion: $openDiscussion.is(':checked') ? 1 : 0 + }; + if (cipherdata_attachment.length > 0) + { + data_to_send.attachment = cipherdata_attachment; + if (cipherdata_attachment_name.length > 0) + { + data_to_send.attachmentname = cipherdata_attachment_name; + } + } + $.ajax({ + type: 'POST', + url: helper.scriptLocation(), + data: data_to_send, + dataType: 'json', + headers: headers, + success: function(data) + { + if (data.status === 0) { + me.stateExistingPaste(); + var url = helper.scriptLocation() + '?' + data.id + '#' + randomkey, + deleteUrl = helper.scriptLocation() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; + me.showStatus(''); + $errorMessage.addClass('hidden'); + // show new URL in browser bar + history.pushState({type: 'newpaste'}, document.title, url); + + $('#pastelink').html( + i18n._( + 'Your paste is %s (Hit [Ctrl]+[c] to copy)', + url, url + ) + me.shortenUrl(url) + ); + // save newly created element + $pasteUrl = $('#pasteurl'); + // and add click event + $pasteUrl.click(me.pasteLinkClick); + + var shortenButton = $('#shortenbutton'); + if (shortenButton) { + shortenButton.click(me.sendToShortener); + } + $('#deletelink').html('' + i18n._('Delete data') + ''); + $pasteResult.removeClass('hidden'); + // we pre-select the link so that the user only has to [Ctrl]+[c] the link + helper.selectText($pasteUrl[0]); + me.showStatus(''); + me.formatPaste(data_to_send.formatter, $message.val()); + } + else if (data.status === 1) + { + // revert loading status… + controller.stateNewPaste(); + controller.showError(i18n._('Could not create paste: %s', data.message)); + } + else + { + // revert loading status… + controller.stateNewPaste(); + controller.showError(i18n._('Could not create paste: %s', i18n._('unknown status'))); + } + } + }) + .fail(function() + { + // revert loading status… + me.stateNewPaste(); + controller.showError(i18n._('Could not create paste: %s', i18n._('server error or not responding'))); + }); + }; + + /** + * check if a URL shortener was defined and create HTML containing a link to it + * + * @name controller.shortenUrl + * @function + * @param {string} url + * @return {string} html + */ + me.shortenUrl = function(url) + { + var shortenerHtml = $('#shortenbutton'); + if (shortenerHtml) { + shortenerUrl = shortenerHtml.data('shortener'); + createdPasteUrl = url; + return ' ' + $('
').append(shortenerHtml.clone()).html(); + } + return ''; + }; + + /** + * put the screen in "New paste" mode + * + * @name controller.stateNewPaste + * @function + */ + me.stateNewPaste = function() + { + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + $sendButton.removeClass('hidden'); + $expiration.removeClass('hidden'); + $formatter.removeClass('hidden'); + $burnAfterReadingOption.removeClass('hidden'); + $openDisc.removeClass('hidden'); + $newButton.removeClass('hidden'); + $password.removeClass('hidden'); + $attach.removeClass('hidden'); + $message.removeClass('hidden'); + $preview.removeClass('hidden'); + $message.focus(); + }; + + /** + * put the screen in mode after submitting a paste + * + * @name controller.stateSubmittingPaste + * @function + */ + me.stateSubmittingPaste = function() + { + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $sendButton.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $newButton.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + $message.addClass('hidden'); + $preview.addClass('hidden'); + + $loadingIndicator.removeClass('hidden'); + }; + + /** + * put the screen in a state where the only option is to submit a + * new paste + * + * @name controller.stateOnlyNewPaste + * @function + */ + me.stateOnlyNewPaste = function() + { + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $sendButton.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + $message.addClass('hidden'); + $preview.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + + $newButton.removeClass('hidden'); + }; + + /** + * put the screen in "Existing paste" mode + * + * @name controller.stateExistingPaste + * @function + * @param {boolean} [preview=false] - (optional) tell if the preview tabs should be displayed, defaults to false + */ + me.stateExistingPaste = function(preview) + { + preview = preview || false; + + if (!preview) + { + // no "clone" for IE<10. + if ($('#oldienotice').is(":visible")) + { + $cloneButton.addClass('hidden'); + } + else + { + $cloneButton.removeClass('hidden'); + } + + $rawTextButton.removeClass('hidden'); + $sendButton.addClass('hidden'); + $attach.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $newButton.removeClass('hidden'); + $preview.addClass('hidden'); + } + + $pasteResult.addClass('hidden'); + $message.addClass('hidden'); + $clearText.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + }; + + /** + * when "burn after reading" is checked, disable discussion + * + * @name controller.changeBurnAfterReading + * @function + */ + me.changeBurnAfterReading = function() + { + if ($burnAfterReading.is(':checked') ) + { + $openDisc.addClass('buttondisabled'); + $openDiscussion.attr({checked: false, disabled: true}); + } + else + { + $openDisc.removeClass('buttondisabled'); + $openDiscussion.removeAttr('disabled'); + } + }; + + /** + * when discussion is checked, disable "burn after reading" + * + * @name controller.changeOpenDisc + * @function + */ + me.changeOpenDisc = function() + { + if ($openDiscussion.is(':checked') ) + { + $burnAfterReadingOption.addClass('buttondisabled'); + $burnAfterReading.attr({checked: false, disabled: true}); + } + else + { + $burnAfterReadingOption.removeClass('buttondisabled'); + $burnAfterReading.removeAttr('disabled'); + } + }; + + /** + * forward to URL shortener + * + * @name controller.sendToShortener + * @function + * @param {Event} event + */ + me.sendToShortener = function(event) + { + window.location.href = shortenerUrl + encodeURIComponent(createdPasteUrl); + event.preventDefault(); + }; + + /** + * reload the page + * + * This takes the user to the PrivateBin home page. + * + * @name controller.reloadPage + * @function + * @param {Event} event + */ + me.reloadPage = function(event) + { + window.location.href = helper.scriptLocation(); + event.preventDefault(); + }; + + /** + * return raw text + * + * @name controller.rawText + * @function + * @param {Event} event + */ + me.rawText = function(event) + { + var paste = $('#pasteFormatter').val() === 'markdown' ? + $prettyPrint.text() : $clearText.text(); + history.pushState( + null, document.title, helper.scriptLocation() + '?' + + helper.pasteId() + '#' + helper.pageKey() + ); + // we use text/html instead of text/plain to avoid a bug when + // reloading the raw text view (it reverts to type text/html) + var newDoc = document.open('text/html', 'replace'); + newDoc.write('
' + helper.htmlEntities(paste) + '
'); + newDoc.close(); + + event.preventDefault(); + }; + + /** + * clone the current paste + * + * @name controller.clonePaste + * @function + * @param {Event} event + */ + me.clonePaste = function(event) + { + event.preventDefault(); + me.stateNewPaste(); + + // erase the id and the key in url + history.replaceState(null, document.title, helper.scriptLocation()); + + me.showStatus(''); + if ($attachmentLink.attr('href')) + { + $clonedFile.removeClass('hidden'); + $fileWrap.addClass('hidden'); + } + $message.text( + $('#pasteFormatter').val() === 'markdown' ? + $prettyPrint.text() : $clearText.text() + ); + $('.navbar-toggle').click(); + }; + + /** + * set the expiration on bootstrap templates + * + * @name controller.setExpiration + * @function + * @param {Event} event + */ + me.setExpiration = function(event) + { + event.preventDefault(); + var target = $(event.target); + $('#pasteExpiration').val(target.data('expiration')); + $('#pasteExpirationDisplay').text(target.text()); + }; + + /** + * set the format on bootstrap templates + * + * @name controller.setFormat + * @function + * @param {Event} event + */ + me.setFormat = function(event) + { + var target = $(event.target); + $('#pasteFormatter').val(target.data('format')); + $('#pasteFormatterDisplay').text(target.text()); + + if ($messagePreview.parent().hasClass('active')) { + me.viewPreview(event); + } + event.preventDefault(); + }; + + /** + * set the language in a cookie and reload the page + * + * @name controller.setLanguage + * @function + * @param {Event} event + */ + me.setLanguage = function(event) + { + document.cookie = 'lang=' + $(event.target).data('lang'); + me.reloadPage(event); + }; + + /** + * support input of tab character + * + * @name controller.supportTabs + * @function + * @param {Event} event + * @TODO doc what is @this here? + */ + me.supportTabs = function(event) + { + var keyCode = event.keyCode || event.which; + // tab was pressed + if (keyCode === 9) + { + // prevent the textarea to lose focus + event.preventDefault(); + // get caret position & selection + var 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; + } + }; + + /** + * view the editor tab + * + * @name controller.viewEditor + * @function + * @param {Event} event + */ + me.viewEditor = function(event) + { + $messagePreview.parent().removeClass('active'); + $messageEdit.parent().addClass('active'); + $message.focus(); + me.stateNewPaste(); + + event.preventDefault(); + }; + + /** + * view the preview tab + * + * @name controller.viewPreview + * @function + * @param {Event} event + */ + me.viewPreview = function(event) + { + $messageEdit.parent().removeClass('active'); + $messagePreview.parent().addClass('active'); + $message.focus(); + me.stateExistingPaste(true); + me.formatPaste($('#pasteFormatter').val(), $message.val()); + + event.preventDefault(); + }; + + /** + * handle history (pop) state changes + * + * currently this does only handle redirects to the home page. + * + * @name controller.historyChange + * @function + * @param {Event} event + */ + me.historyChange = function(event) + { + var currentLocation = helper.scriptLocation(); + if (event.originalEvent.state === null && // no state object passed + event.originalEvent.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; + } + }; + + /** + * 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 controller.pasteLinkClick + * @function + * @param {Event} event + */ + me.pasteLinkClick = function(event) + { + // 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); + } + }; + + /** + * create a new paste + * + * @name controller.newPaste + * @function + */ + me.newPaste = function() + { + me.stateNewPaste(); + me.showStatus(''); + $message.text(''); + me.changeBurnAfterReading(); + me.changeOpenDisc(); + }; + + /** + * removes an attachment + * + * @name controller.removeAttachment + * @function + */ + me.removeAttachment = function() + { + $clonedFile.addClass('hidden'); + // removes the saved decrypted file data + $attachmentLink.attr('href', ''); + // the only way to deselect the file is to recreate the input // @TODO really? + $fileWrap.html($fileWrap.html()); + $fileWrap.removeClass('hidden'); + }; + + /** + * decrypt using the password from the modal dialog + * + * @name controller.decryptPasswordModal + * @function + */ + me.decryptPasswordModal = function() + { + $passwordInput.val($passwordDecrypt.val()); + me.displayMessages(); + }; + + /** + * submit a password in the modal dialog + * + * @name controller.submitPasswordModal + * @function + * @param {Event} event + */ + me.submitPasswordModal = function(event) + { + event.preventDefault(); + $passwordModal.modal('hide'); + }; + + /** + * display an error message, + * we use the same function for paste and reply to comments + * + * @name controller.showError + * @function + * @param {string} message - text to display + */ + me.showError = function(message) + { + if ($status.length) + { + $status.addClass('errorMessage').text(message); + } + else + { + $errorMessage.removeClass('hidden'); + helper.setMessage($errorMessage, message); + } + if (typeof $replyStatus !== 'undefined') { + $replyStatus.addClass('errorMessage'); + $replyStatus.addClass($errorMessage.attr('class')); + if ($status.length) + { + $replyStatus.html($status.html()); + } + else + { + $replyStatus.html($errorMessage.html()); + } + } + }; + + /** + * display a status message, + * we use the same function for paste and reply to comments + * + * @name controller.showStatus + * @function + * @param {string} message - text to display + * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false + */ + me.showStatus = function(message, spin) + { + if (spin || false) + { + var img = ''; + $status.prepend(img); + if (typeof $replyStatus !== 'undefined') { + $replyStatus.prepend(img); + } + } + if (typeof $replyStatus !== 'undefined') { + $replyStatus.removeClass('errorMessage').text(message); + } + if (!message) + { + $status.html(' '); + return; + } + if (message === '') + { + $status.html(' '); + return; + } + $status.removeClass('errorMessage').text(message); + }; + + /** + * bind events to DOM elements + * + * @private + * @function + */ + function bindEvents() + { + $burnAfterReading.change(me.changeBurnAfterReading); + $openDisc.change(me.changeOpenDisc); + $sendButton.click(me.sendData); + $cloneButton.click(me.clonePaste); + $rawTextButton.click(me.rawText); + $fileRemoveButton.click(me.removeAttachment); + $('.reloadlink').click(me.reloadPage); + $message.keydown(me.supportTabs); + $messageEdit.click(me.viewEditor); + $messagePreview.click(me.viewPreview); + + // bootstrap template drop downs + $('ul.dropdown-menu li a', $('#expiration').parent()).click(me.setExpiration); + $('ul.dropdown-menu li a', $('#formatter').parent()).click(me.setFormat); + $('#language ul.dropdown-menu li a').click(me.setLanguage); + + // page template drop down + $('#language select option').click(me.setLanguage); + + // focus password input when it is shown + $passwordModal.on('shown.bs.modal', function () { + $passwordDecrypt.focus(); + }); + // handle modal password request on decryption + $passwordModal.on('hidden.bs.modal', me.decryptPasswordModal); + $passwordForm.submit(me.submitPasswordModal); + + $(window).on('popstate', me.historyChange); + } + + /** + * main application + * + * @name controller.init + * @function + */ + me.init = function() + { + // hide "no javascript" message + $('#noscript').hide(); + + // preload jQuery wrapped DOM elements and bind events + $attach = $('#attach'); + $attachment = $('#attachment'); + $attachmentLink = $('#attachment a'); + $burnAfterReading = $('#burnafterreading'); + $burnAfterReadingOption = $('#burnafterreadingoption'); + $cipherData = $('#cipherdata'); + $clearText = $('#cleartext'); + $cloneButton = $('#clonebutton'); + $clonedFile = $('#clonedfile'); + $comments = $('#comments'); + $discussion = $('#discussion'); + $errorMessage = $('#errormessage'); + $expiration = $('#expiration'); + $fileRemoveButton = $('#fileremovebutton'); + $fileWrap = $('#filewrap'); + $formatter = $('#formatter'); + $image = $('#image'); + $loadingIndicator = $('#loadingindicator'); + $message = $('#message'); + $messageEdit = $('#messageedit'); + $messagePreview = $('#messagepreview'); + $newButton = $('#newbutton'); + $openDisc = $('#opendisc'); + $openDiscussion = $('#opendiscussion'); + $password = $('#password'); + $passwordInput = $('#passwordinput'); + $passwordModal = $('#passwordmodal'); + $passwordForm = $('#passwordform'); + $passwordDecrypt = $('#passworddecrypt'); + $pasteResult = $('#pasteresult'); + // $pasteUrl is saved in sendDataContinue() if/after it is + // actually created + $prettyMessage = $('#prettymessage'); + $prettyPrint = $('#prettyprint'); + $preview = $('#preview'); + $rawTextButton = $('#rawtextbutton'); + $remainingTime = $('#remainingtime'); + // $replyStatus is saved in openReply() + $sendButton = $('#sendbutton'); + $status = $('#status'); + bindEvents(); + + // display status returned by php code, if any (eg. paste was properly deleted) + if ($status.text().length > 0) + { + me.showStatus($status.text()); + return; + } + + // keep line height even if content empty + $status.html(' '); + + // display an existing paste + if ($cipherData.text().length > 1) + { + // missing decryption key in URL? + if (window.location.hash.length === 0) + { + me.showError(i18n._('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)')); + return; + } + + // show proper elements on screen + me.stateExistingPaste(); + me.displayMessages(); + } + // display error message from php code + else if ($errorMessage.text().length > 1) + { + me.showError($errorMessage.text()); + } + // create a new paste + else + { + me.newPaste(); + } + }; + + return me; +})(window, document, jQuery, sjcl, Base64, RawDeflate); From 52f1fb143e6c869665a9e7298fe398c51f25be26 Mon Sep 17 00:00:00 2001 From: rugk Date: Wed, 8 Feb 2017 20:12:22 +0100 Subject: [PATCH 05/29] Revert "JS: tried namespaces" This reverts commit e84cfc58a16d56b2deb9450c5ca8033a9a4b9b37. --- js/privatebin.js | 3798 +++++++++++++++++++++++----------------------- 1 file changed, 1901 insertions(+), 1897 deletions(-) diff --git a/js/privatebin.js b/js/privatebin.js index adb26fd9..6322c0e8 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -25,1944 +25,1948 @@ // Immediately start random number generator collector. sjcl.random.startCollectors(); -// startup -jQuery(document).ready(function() { +// jQuery(document).ready(function() { +// // startup +// } + +jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { /** - * main application start, called when DOM is fully loaded and - * runs controller initalization after translations are loaded - */ - PrivateBin.i18n.loadTranslations(); -}); - -/** - * @name PrivateBin - * @namespace - */ -var PrivateBin = window.PrivateBin || {}; - -/** - * static helper methods - * - * @param {object} window - * @param {object} document - * @name helper - * @class - */ -PrivateBin.helper = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { - var me = {}; - - /** - * character to HTML entity lookup table + * static helper methods * - * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} - * @private - * @enum {Object} - * @readonly + * @param {object} window + * @param {object} document + * @name helper + * @class */ - var entityMap = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/', - '`': '`', - '=': '=' - }; - - /** - * cache for script location - * - * @private - * @enum {string|null} - */ - var scriptLocation = null; - - /** - * converts a duration (in seconds) into human friendly approximation - * - * @name helper.secondsToHuman - * @function - * @param {number} seconds - * @return {Array} - */ - me.secondsToHuman = function(seconds) - { - var 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) - { - var range, selection; - - // MS - if (document.body.createTextRange) - { - range = document.body.createTextRange(); - range.moveToElementText(element); - range.select(); - } - // all others - else if (window.getSelection) - { - selection = window.getSelection(); - range = document.createRange(); - range.selectNodeContents(element); - selection.removeAllRanges(); - selection.addRange(range); - } - }; - - /** - * set text of a jQuery element (required for IE), - * - * @name helper.setElementText - * @function - * @param {jQuery} $element - a jQuery element - * @param {string} text - the text to enter - * @TODO check for XSS attacks, usually no CSS can prevent them so this looks weird on the first look - */ - me.setElementText = function($element, text) - { - // For IE<10: Doesn't support white-space:pre-wrap; so we have to do this... - if ($('#oldienotice').is(':visible')) { - var html = me.htmlEntities(text).replace(/\n/ig, '\r\n
'); - $element.html('
' + html + '
'); - } - // for other (sane) browsers: - else - { - $element.text(text); - } - }; - - /** - * replace last child of element with message - * - * @name helper.setMessage - * @function - * @param {jQuery} $element - a jQuery wrapped DOM element - * @param {string} message - the message to append - */ - me.setMessage = function($element, message) - { - var content = $element.contents(); - if (content.length > 0) - { - content[content.length - 1].nodeValue = ' ' + message; - } - else - { - me.setElementText($element, message); - } - }; - - /** - * convert URLs to clickable links. - * URLs to handle: - *
-     *     magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7
-     *     http://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 {Object} element - a jQuery DOM element - */ - me.urls2links = function(element) - { - var markup = '$1'; - element.html( - element.html().replace( - /((http|https|ftp):\/\/[\w?=&.\/-;#@~%+-]+(?![\w\s?&.\/;#~%"=-]*>))/ig, - markup - ) - ); - element.html( - element.html().replace( - /((magnet):[\w?=&.\/-;#@~%+-]+)/ig, - markup - ) - ); - }; - - /** - * minimal sprintf emulation for %s and %d formats - * - * @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() - { - var args = arguments; - if (typeof arguments[0] === 'object') - { - args = arguments[0]; - } - var format = args[0], - i = 1; - return format.replace(/%((%)|s|d)/g, function (m) { - // m is the matched format, e.g. %s, %d - var val; - if (m[2]) { - val = m[2]; - } else { - val = args[i]; - // A switch statement so that the formatter can be extended. - switch (m) - { - case '%d': - val = parseFloat(val); - if (isNaN(val)) { - val = 0; - } - break; - default: - // Default is %s - } - ++i; - } - return val; - }); - }; - - /** - * get value of cookie, if it was set, empty string otherwise - * - * @see {@link http://www.w3schools.com/js/js_cookies.asp} - * @name helper.getCookie - * @function - * @param {string} cname - * @return {string} - */ - me.getCookie = function(cname) { - var name = cname + '=', - ca = document.cookie.split(';'); - for (var i = 0; i < ca.length; ++i) { - var 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 script location (without search or hash part of the URL), - * eg. http://example.com/path/?aaaa#bbbb --> http://example.com/path/ - * - * @name helper.scriptLocation - * @function - * @return {string} current script location - */ - me.scriptLocation = function() - { - // check for cached version - if (scriptLocation !== null) { - return scriptLocation; - } - - scriptLocation = window.location.href.substring( - 0, - window.location.href.length - window.location.search.length - window.location.hash.length - ); - - var hashIndex = scriptLocation.indexOf('?'); - - if (hashIndex !== -1) - { - scriptLocation = scriptLocation.substring(0, hashIndex); - } - - return scriptLocation; - }; - - /** - * get the pastes unique identifier from the URL, - * eg. http://example.com/path/?c05354954c49a487#c05354954c49a487 returns c05354954c49a487 - * - * @name helper.pasteId - * @function - * @return {string} unique identifier - */ - me.pasteId = function() - { - return window.location.search.substring(1); - }; - - /** - * return the deciphering key stored in anchor part of the URL - * - * @name helper.pageKey - * @function - * @return {string} key - */ - me.pageKey = function() - { - var key = window.location.hash.substring(1), - i = key.indexOf('&'); - - // Some web 2.0 services and redirectors add data AFTER the anchor - // (such as &utm_source=...). We will strip any additional data. - if (i > -1) - { - key = key.substring(0, i); - } - - return key; - }; - - /** - * convert all applicable characters to HTML entities - * - * @see {@link https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content} - * @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]; - }); - }; - - return me; -})(window, document, jQuery, sjcl, Base64, RawDeflate); - -/** - * internationalization methods - * - * @param {object} window - * @param {object} document - * @name i18n - * @class - */ -PrivateBin.i18n = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { - var me = {}; - - /** - * supported languages, minus the built in 'en' - * - * @private - * @prop {string[]} - * @readonly - */ - var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'oc', 'ru', 'sl', 'zh']; - - /** - * built in language - * - * @private - * @prop {string} - */ - var language = 'en'; - - /** - * translation cache - * - * @private - * @enum {Object} - */ - var translations = {}; - - /** - * translate a string, alias for i18n.translate() - * - * @name i18n._ - * @function - * @param {string} messageId - * @param {...*} args - one or multiple parameters injected into placeholders - * @return {string} - */ - me._ = function() - { - return me.translate(arguments); - }; - - /** - * translate a string - * - * @name i18n.translate - * @function - * @param {string} messageId - * @param {...*} args - one or multiple parameters injected into placeholders - * @return {string} - */ - me.translate = function() - { - var args = arguments, messageId; - if (typeof arguments[0] === 'object') - { - args = arguments[0]; - } - var 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 (!translations.hasOwnProperty(messageId)) - { - if (language !== 'en') - { - console.error( - 'Missing ' + language + ' translation for: ' + messageId - ); - } - translations[messageId] = args[0]; - } - if (usesPlurals && $.isArray(translations[messageId])) - { - var 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 - { - args[0] = translations[messageId]; - } - return helper.sprintf(args); - }; - - /** - * per language functions to use to determine the plural form - * - * @see {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html} - * @name i18n.getPluralForm - * @function - * @param {number} n - * @return {number} array key - */ - me.getPluralForm = function(n) { - switch (language) - { - case 'fr': - case 'oc': - case 'zh': - return (n > 1 ? 1 : 0); - case 'pl': - return (n === 1 ? 0 : (n % 10 >= 2 && n %10 <=4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); - case 'ru': - return (n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); - case 'sl': - return (n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0))); - // de, en, es, it, no - default: - return (n !== 1 ? 1 : 0); - } - }; - - /** - * load translations into cache, then trigger controller initialization - * - * @name i18n.loadTranslations - * @function - */ - me.loadTranslations = function() - { - var newLanguage = PrivateBin.helper.getCookie('lang'); - - // auto-select language based on browser settings - if (newLanguage.length === 0) - { - newLanguage = (navigator.language || navigator.userLanguage).substring(0, 2); - } - - // if language is already used (e.g, default 'en'), skip update - if (newLanguage === language) - { - controller.init(); - 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); - controller.init(); - } - - // load strongs from JSON - $.getJSON('i18n/' + newLanguage + '.json', function(data) { - language = newLanguage; - translations = data; - }).fail(function (data, textStatus, errorMsg) { - console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg); - }); - - controller.init(); - }; - - return me; -})(window, document, jQuery, sjcl, Base64, RawDeflate); - -/** - * filter methods - * - * @param {object} window - * @param {object} document - * @name filter - * @class - */ -PrivateBin.filter = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { - var me = {}; - - /** - * compress a message (deflate compression), returns base64 encoded data - * - * @name filter.compress - * @function - * @param {string} message - * @return {string} base64 data - */ - me.compress = function(message) - { - return Base64.toBase64( RawDeflate.deflate( Base64.utob(message) ) ); - }; - - /** - * decompress a message compressed with filter.compress() - * - * @name filter.decompress - * @function - * @param {string} data - base64 data - * @return {string} message - */ - me.decompress = function(data) - { - return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) ); - }; - - /** - * compress, then encrypt message with given key and password - * - * @name filter.cipher - * @function - * @param {string} key - * @param {string} password - * @param {string} message - * @return {string} data - JSON with encrypted data - */ - me.cipher = function(key, password, message) - { - // Galois Counter Mode, keysize 256 bit, authentication tag 128 bit - var options = {mode: 'gcm', ks: 256, ts: 128}; - if ((password || '').trim().length === 0) - { - return sjcl.encrypt(key, me.compress(message), options); - } - return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), me.compress(message), options); - }; - - /** - * decrypt message with key, then decompress - * - * @name filter.decipher - * @function - * @param {string} key - * @param {string} password - * @param {string} data - JSON with encrypted data - * @return {string} decrypted message - */ - me.decipher = function(key, password, data) - { - if (data !== undefined) - { - try - { - return me.decompress(sjcl.decrypt(key, data)); - } - catch(err) - { - try - { - return me.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); - } - catch(e) - { - // ignore error, because ????? @TODO - } - } - } - return ''; - }; - - return me; -})(window, document, jQuery, sjcl, Base64, RawDeflate); - -/** - * PrivateBin logic - * - * @param {object} window - * @param {object} document - * @name controller - * @class - */ -PrivateBin.controller = (function (window, document, jQuery, sjcl, Base64, RawDeflate) { - var me = {}; - - /** - * headers to send in AJAX requests - * - * @private - * @enum {Object} - */ - var headers = {'X-Requested-With': 'JSONHttpRequest'}; - - /** - * URL shortners create address - * - * @private - * @prop {string} - */ - var shortenerUrl = ''; - - /** - * URL of newly created paste - * - * @private - * @prop {string} - */ - var createdPasteUrl = ''; - - // jQuery pre-loaded objects - var $attach, - $attachment, - $attachmentLink, - $burnAfterReading, - $burnAfterReadingOption, - $cipherData, - $clearText, - $cloneButton, - $clonedFile, - $comments, - $discussion, - $errorMessage, - $expiration, - $fileRemoveButton, - $fileWrap, - $formatter, - $image, - $loadingIndicator, - $message, - $messageEdit, - $messagePreview, - $newButton, - $openDisc, // @TODO: rename - too similar to openDiscussion, difference unclear - $openDiscussion, - $password, - $passwordInput, - $passwordModal, - $passwordForm, - $passwordDecrypt, - $pasteResult, - $pasteUrl, - $prettyMessage, - $prettyPrint, - $preview, - $rawTextButton, - $remainingTime, - $replyStatus, - $sendButton, - $status; - - /** - * ask the user for the password and set it - * - * @name controller.requestPassword - * @function - */ - me.requestPassword = function() - { - if ($passwordModal.length === 0) { - var password = prompt(i18n._('Please enter the password for this paste:'), ''); - if (password === null) - { - throw 'password prompt canceled'; - } - if (password.length === 0) - { - // recursive… - me.requestPassword(); - } else { - $passwordInput.val(password); - me.displayMessages(); - } - } else { - $passwordModal.modal(); - } - }; - - /** - * use given format on paste, defaults to plain text - * - * @name controller.formatPaste - * @function - * @param {string} format - * @param {string} text - */ - me.formatPaste = function(format, text) - { - helper.setElementText($clearText, text); - helper.setElementText($prettyPrint, text); - - switch (format || 'plaintext') { - case 'markdown': - // silently fail if showdown is not available - // @TODO: maybe better show an error message? At least a warning? - if (typeof showdown === 'object') - { - var converter = new showdown.Converter({ - strikethrough: true, - tables: true, - tablesHeaderId: true - }); - $clearText.html( - converter.makeHtml(text) - ); - // add table classes from bootstrap css - $clearText.find('table').addClass('table-condensed table-bordered'); - - $clearText.removeClass('hidden'); - } else { - console.error('showdown is not loaded, could not parse Markdown'); - } - $prettyMessage.addClass('hidden'); - break; - case 'syntaxhighlighting': - // silently fail if prettyprint is not available - // @TODO: maybe better show an error message? At least a warning? - if (typeof prettyPrintOne === 'function') - { - if (typeof prettyPrint === 'function') - { - prettyPrint(); - } - $prettyPrint.html( - prettyPrintOne( - helper.htmlEntities(text), null, true - ) - ); - } else { - console.error('pretty print is not loaded, could not link '); - } - // fall through, as the rest is the same - default: // = 'plaintext' - // convert URLs to clickable links - helper.urls2links($clearText); - helper.urls2links($prettyPrint); - $clearText.addClass('hidden'); - - - $prettyPrint.css('white-space', 'pre-wrap'); - $prettyPrint.css('word-break', 'normal'); - $prettyPrint.removeClass('prettyprint'); - - $prettyMessage.removeClass('hidden'); - } - }; - - /** - * show decrypted text in the display area, including discussion (if open) - * - * @name controller.displayMessages - * @function - * @param {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta')) - */ - me.displayMessages = function(paste) - { - paste = paste || $.parseJSON($cipherData.text()); - var key = helper.pageKey(), - password = $passwordInput.val(); - if (!$prettyPrint.hasClass('prettyprinted')) { - // Try to decrypt the paste. - try - { - if (paste.attachment) - { - var attachment = filter.decipher(key, password, paste.attachment); - if (attachment.length === 0) - { - if (password.length === 0) - { - me.requestPassword(); - return; - } - attachment = filter.decipher(key, password, paste.attachment); - } - if (attachment.length === 0) - { - throw 'failed to decipher attachment'; - } - - if (paste.attachmentname) - { - var attachmentname = filter.decipher(key, password, paste.attachmentname); - if (attachmentname.length > 0) - { - $attachmentLink.attr('download', attachmentname); - } - } - $attachmentLink.attr('href', attachment); - $attachment.removeClass('hidden'); - - // if the attachment is an image, display it - var imagePrefix = 'data:image/'; - if (attachment.substring(0, imagePrefix.length) === imagePrefix) - { - $image.html( - $(document.createElement('img')) - .attr('src', attachment) - .attr('class', 'img-thumbnail') - ); - $image.removeClass('hidden'); - } - } - var cleartext = filter.decipher(key, password, paste.data); - if (cleartext.length === 0 && password.length === 0 && !paste.attachment) - { - me.requestPassword(); - return; - } - if (cleartext.length === 0 && !paste.attachment) - { - throw 'failed to decipher message'; - } - - $passwordInput.val(password); - if (cleartext.length > 0) - { - $('#pasteFormatter').val(paste.meta.formatter); - me.formatPaste(paste.meta.formatter, cleartext); - } - } - catch(err) - { - me.stateOnlyNewPaste(); - me.showError(i18n._('Could not decrypt data (Wrong key?)')); - return; - } - } - - // display paste expiration / for your eyes only - if (paste.meta.expire_date) - { - var expiration = helper.secondsToHuman(paste.meta.remaining_time), - expirationLabel = [ - 'This document will expire in %d ' + expiration[1] + '.', - 'This document will expire in %d ' + expiration[1] + 's.' - ]; - helper.setMessage($remainingTime, i18n._(expirationLabel, expiration[0])); - $remainingTime.removeClass('foryoureyesonly') - .removeClass('hidden'); - } - if (paste.meta.burnafterreading) - { - // unfortunately many web servers don't support DELETE (and PUT) out of the box - $.ajax({ - type: 'POST', - url: helper.scriptLocation() + '?' + helper.pasteId(), - data: {deletetoken: 'burnafterreading'}, - dataType: 'json', - headers: headers - }) - .fail(function() { - controller.showError(i18n._('Could not delete the paste, it was not stored in burn after reading mode.')); - }); - helper.setMessage($remainingTime, i18n._( - 'FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.' - )); - $remainingTime.addClass('foryoureyesonly') - .removeClass('hidden'); - // discourage cloning (as it can't really be prevented) - $cloneButton.addClass('hidden'); - } - - // if the discussion is opened on this paste, display it - if (paste.meta.opendiscussion) - { - $comments.html(''); - - var $divComment; - - // iterate over comments - for (var i = 0; i < paste.comments.length; ++i) - { - var $place = $comments, - comment = paste.comments[i], - commentText = filter.decipher(key, password, comment.data), - $parentComment = $('#comment_' + comment.parentid); - - $divComment = $('
' - + '
' - + '
' - + '
'); - var $divCommentData = $divComment.find('div.commentdata'); - - // if parent comment exists - if ($parentComment.length) - { - // shift comment to the right - $place = $parentComment; - } - $divComment.find('button').click({commentid: comment.id}, me.openReply); - helper.setElementText($divCommentData, commentText); - helper.urls2links($divCommentData); - - // try to get optional nickname - var nick = filter.decipher(key, password, comment.meta.nickname); - if (nick.length > 0) - { - $divComment.find('span.nickname').text(nick); - } - else - { - divComment.find('span.nickname').html('' + i18n._('Anonymous') + ''); - } - $divComment.find('span.commentdate') - .text(' (' + (new Date(comment.meta.postdate * 1000).toLocaleString()) + ')') - .attr('title', 'CommentID: ' + comment.id); - - // if an avatar is available, display it - if (comment.meta.vizhash) - { - $divComment.find('span.nickname') - .before( - ' ' - ); - } - - $place.append($divComment); - } - - // add 'add new comment' area - $divComment = $( - '
' - ); - $divComment.find('button').click({commentid: helper.pasteId()}, me.openReply); - $comments.append($divComment); - $discussion.removeClass('hidden'); - } - }; - - /** - * open the comment entry when clicking the "Reply" button of a comment - * - * @name controller.openReply - * @function - * @param {Event} event - */ - me.openReply = function(event) - { - event.preventDefault(); - - // remove any other reply area - $('div.reply').remove(); - - var source = $(event.target), - commentid = event.data.commentid, - hint = i18n._('Optional nickname...'), - reply = $( - '

' + - '
' - ); - reply.find('button').click( - {parentid: commentid}, - me.sendComment - ); - source.after(reply); - $replyStatus = $('#replystatus'); - $('#replymessage').focus(); - }; - - /** - * send a reply in a discussion - * - * @name controller.sendComment - * @function - * @param {Event} event - */ - me.sendComment = function(event) - { - event.preventDefault(); - $errorMessage.addClass('hidden'); - // do not send if no data - var replyMessage = $('#replymessage'); - if (replyMessage.val().length === 0) - { - return; - } - - me.showStatus(i18n._('Sending comment...'), true); - var parentid = event.data.parentid, - key = helper.pageKey(), - cipherdata = filter.cipher(key, $passwordInput.val(), replyMessage.val()), - ciphernickname = '', - nick = $('#nickname').val(); - if (nick.length > 0) - { - ciphernickname = filter.cipher(key, $passwordInput.val(), nick); - } - var data_to_send = { - data: cipherdata, - parentid: parentid, - pasteid: helper.pasteId(), - nickname: ciphernickname + var helper = (function (window, document) { + var me = {}; + + /** + * character to HTML entity lookup table + * + * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} + * @private + * @enum {Object} + * @readonly + */ + var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' }; - $.ajax({ - type: 'POST', - url: helper.scriptLocation(), - data: data_to_send, - dataType: 'json', - headers: headers, - success: function(data) + /** + * cache for script location + * + * @private + * @enum {string|null} + */ + var scriptLocation = null; + + /** + * converts a duration (in seconds) into human friendly approximation + * + * @name helper.secondsToHuman + * @function + * @param {number} seconds + * @return {Array} + */ + me.secondsToHuman = function(seconds) + { + var v; + if (seconds < 60) { - if (data.status === 0) - { - controller.showStatus(i18n._('Comment posted.')); - $.ajax({ - type: 'GET', - url: helper.scriptLocation() + '?' + helper.pasteId(), - dataType: 'json', - headers: headers, - success: function(data) - { - if (data.status === 0) - { - controller.displayMessages(data); - } - else if (data.status === 1) - { - controller.showError(i18n._('Could not refresh display: %s', data.message)); - } - else - { - controller.showError(i18n._('Could not refresh display: %s', i18n._('unknown status'))); - } - } - }) - .fail(function() { - controller.showError(i18n._('Could not refresh display: %s', i18n._('server error or not responding'))); - }); - } - else if (data.status === 1) - { - controller.showError(i18n._('Could not post comment: %s', data.message)); - } - else - { - controller.showError(i18n._('Could not post comment: %s', i18n._('unknown status'))); - } + v = Math.floor(seconds); + return [v, 'second']; } - }) - .fail(function() { - controller.showError(i18n._('Could not post comment: %s', i18n._('server error or not responding'))); - }); - }; - - /** - * send a new paste to server - * - * @name controller.sendData - * @function - * @param {Event} event - */ - me.sendData = function(event) - { - event.preventDefault(); - var file = document.getElementById('file'), - files = (file && file.files) ? file.files : null; // FileList object - - // do not send if no data. - if ($message.val().length === 0 && !(files && files[0])) - { - return; - } - - // if sjcl has not collected enough entropy yet, display a message - if (!sjcl.random.isReady()) - { - me.showStatus(i18n._('Sending paste (Please move your mouse for more entropy)...'), true); - sjcl.random.addEventListener('seeded', function() { - me.sendData(event); - }); - return; - } - - $('.navbar-toggle').click(); - $password.addClass('hidden'); - me.showStatus(i18n._('Sending paste...'), true); - - me.stateSubmittingPaste(); - - var randomkey = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0), - password = $passwordInput.val(); - if(files && files[0]) - { - if(typeof FileReader === undefined) + if (seconds < 60 * 60) { - // revert loading status… - me.stateNewPaste(); - me.showError(i18n._('Your browser does not support uploading encrypted files. Please use a newer browser.')); - return; + v = Math.floor(seconds / 60); + return [v, 'minute']; } - var reader = new FileReader(); - // closure to capture the file information - reader.onload = (function(theFile) + if (seconds < 60 * 60 * 24) { - return function(e) { - controller.sendDataContinue( - randomkey, - filter.cipher(randomkey, password, e.target.result), - filter.cipher(randomkey, password, theFile.name) - ); - }; - })(files[0]); - reader.readAsDataURL(files[0]); - } - else if($attachmentLink.attr('href')) - { - me.sendDataContinue( - randomkey, - filter.cipher(randomkey, password, $attachmentLink.attr('href')), - $attachmentLink.attr('download') - ); - } - else - { - me.sendDataContinue(randomkey, '', ''); - } - }; - - /** - * send a new paste to server, step 2 - * - * @name controller.sendDataContinue - * @function - * @param {string} randomkey - * @param {string} cipherdata_attachment - * @param {string} cipherdata_attachment_name - */ - me.sendDataContinue = function(randomkey, cipherdata_attachment, cipherdata_attachment_name) - { - var cipherdata = filter.cipher(randomkey, $passwordInput.val(), $message.val()), - data_to_send = { - data: cipherdata, - expire: $('#pasteExpiration').val(), - formatter: $('#pasteFormatter').val(), - burnafterreading: $burnAfterReading.is(':checked') ? 1 : 0, - opendiscussion: $openDiscussion.is(':checked') ? 1 : 0 - }; - if (cipherdata_attachment.length > 0) - { - data_to_send.attachment = cipherdata_attachment; - if (cipherdata_attachment_name.length > 0) - { - data_to_send.attachmentname = cipherdata_attachment_name; + v = Math.floor(seconds / (60 * 60)); + return [v, 'hour']; } - } - $.ajax({ - type: 'POST', - url: helper.scriptLocation(), - data: data_to_send, - dataType: 'json', - headers: headers, - success: function(data) + // If less than 2 months, display in days: + if (seconds < 60 * 60 * 24 * 60) { - if (data.status === 0) { - me.stateExistingPaste(); - var url = helper.scriptLocation() + '?' + data.id + '#' + randomkey, - deleteUrl = helper.scriptLocation() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; - me.showStatus(''); - $errorMessage.addClass('hidden'); - // show new URL in browser bar - history.pushState({type: 'newpaste'}, document.title, url); - - $('#pastelink').html( - i18n._( - 'Your paste is %s (Hit [Ctrl]+[c] to copy)', - url, url - ) + me.shortenUrl(url) - ); - // save newly created element - $pasteUrl = $('#pasteurl'); - // and add click event - $pasteUrl.click(me.pasteLinkClick); - - var shortenButton = $('#shortenbutton'); - if (shortenButton) { - shortenButton.click(me.sendToShortener); - } - $('#deletelink').html('' + i18n._('Delete data') + ''); - $pasteResult.removeClass('hidden'); - // we pre-select the link so that the user only has to [Ctrl]+[c] the link - helper.selectText($pasteUrl[0]); - me.showStatus(''); - me.formatPaste(data_to_send.formatter, $message.val()); - } - else if (data.status === 1) - { - // revert loading status… - controller.stateNewPaste(); - controller.showError(i18n._('Could not create paste: %s', data.message)); - } - else - { - // revert loading status… - controller.stateNewPaste(); - controller.showError(i18n._('Could not create paste: %s', i18n._('unknown status'))); - } + v = Math.floor(seconds / (60 * 60 * 24)); + return [v, 'day']; } - }) - .fail(function() + 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) { - // revert loading status… - me.stateNewPaste(); - controller.showError(i18n._('Could not create paste: %s', i18n._('server error or not responding'))); - }); - }; + var range, selection; - /** - * check if a URL shortener was defined and create HTML containing a link to it - * - * @name controller.shortenUrl - * @function - * @param {string} url - * @return {string} html - */ - me.shortenUrl = function(url) - { - var shortenerHtml = $('#shortenbutton'); - if (shortenerHtml) { - shortenerUrl = shortenerHtml.data('shortener'); - createdPasteUrl = url; - return ' ' + $('
').append(shortenerHtml.clone()).html(); - } - return ''; - }; - - /** - * put the screen in "New paste" mode - * - * @name controller.stateNewPaste - * @function - */ - me.stateNewPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - $sendButton.removeClass('hidden'); - $expiration.removeClass('hidden'); - $formatter.removeClass('hidden'); - $burnAfterReadingOption.removeClass('hidden'); - $openDisc.removeClass('hidden'); - $newButton.removeClass('hidden'); - $password.removeClass('hidden'); - $attach.removeClass('hidden'); - $message.removeClass('hidden'); - $preview.removeClass('hidden'); - $message.focus(); - }; - - /** - * put the screen in mode after submitting a paste - * - * @name controller.stateSubmittingPaste - * @function - */ - me.stateSubmittingPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $sendButton.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $newButton.addClass('hidden'); - $password.addClass('hidden'); - $attach.addClass('hidden'); - $message.addClass('hidden'); - $preview.addClass('hidden'); - - $loadingIndicator.removeClass('hidden'); - }; - - /** - * put the screen in a state where the only option is to submit a - * new paste - * - * @name controller.stateOnlyNewPaste - * @function - */ - me.stateOnlyNewPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $sendButton.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $password.addClass('hidden'); - $attach.addClass('hidden'); - $message.addClass('hidden'); - $preview.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - - $newButton.removeClass('hidden'); - }; - - /** - * put the screen in "Existing paste" mode - * - * @name controller.stateExistingPaste - * @function - * @param {boolean} [preview=false] - (optional) tell if the preview tabs should be displayed, defaults to false - */ - me.stateExistingPaste = function(preview) - { - preview = preview || false; - - if (!preview) - { - // no "clone" for IE<10. - if ($('#oldienotice').is(":visible")) + // MS + if (document.body.createTextRange) { - $cloneButton.addClass('hidden'); + range = document.body.createTextRange(); + range.moveToElementText(element); + range.select(); + } + // all others + else if (window.getSelection) + { + selection = window.getSelection(); + range = document.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + } + }; + + /** + * set text of a jQuery element (required for IE), + * + * @name helper.setElementText + * @function + * @param {jQuery} $element - a jQuery element + * @param {string} text - the text to enter + * @TODO check for XSS attacks, usually no CSS can prevent them so this looks weird on the first look + */ + me.setElementText = function($element, text) + { + // For IE<10: Doesn't support white-space:pre-wrap; so we have to do this... + if ($('#oldienotice').is(':visible')) { + var html = me.htmlEntities(text).replace(/\n/ig, '\r\n
'); + $element.html('
' + html + '
'); + } + // for other (sane) browsers: + else + { + $element.text(text); + } + }; + + /** + * replace last child of element with message + * + * @name helper.setMessage + * @function + * @param {jQuery} $element - a jQuery wrapped DOM element + * @param {string} message - the message to append + */ + me.setMessage = function($element, message) + { + var content = $element.contents(); + if (content.length > 0) + { + content[content.length - 1].nodeValue = ' ' + message; } else { - $cloneButton.removeClass('hidden'); + me.setElementText($element, message); + } + }; + + /** + * convert URLs to clickable links. + * URLs to handle: + *
+         *     magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7
+         *     http://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 {Object} element - a jQuery DOM element + */ + me.urls2links = function(element) + { + var markup = '$1'; + element.html( + element.html().replace( + /((http|https|ftp):\/\/[\w?=&.\/-;#@~%+-]+(?![\w\s?&.\/;#~%"=-]*>))/ig, + markup + ) + ); + element.html( + element.html().replace( + /((magnet):[\w?=&.\/-;#@~%+-]+)/ig, + markup + ) + ); + }; + + /** + * minimal sprintf emulation for %s and %d formats + * + * @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() + { + var args = arguments; + if (typeof arguments[0] === 'object') + { + args = arguments[0]; + } + var format = args[0], + i = 1; + return format.replace(/%((%)|s|d)/g, function (m) { + // m is the matched format, e.g. %s, %d + var val; + if (m[2]) { + val = m[2]; + } else { + val = args[i]; + // A switch statement so that the formatter can be extended. + switch (m) + { + case '%d': + val = parseFloat(val); + if (isNaN(val)) { + val = 0; + } + break; + default: + // Default is %s + } + ++i; + } + return val; + }); + }; + + /** + * get value of cookie, if it was set, empty string otherwise + * + * @see {@link http://www.w3schools.com/js/js_cookies.asp} + * @name helper.getCookie + * @function + * @param {string} cname + * @return {string} + */ + me.getCookie = function(cname) { + var name = cname + '=', + ca = document.cookie.split(';'); + for (var i = 0; i < ca.length; ++i) { + var 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 script location (without search or hash part of the URL), + * eg. http://example.com/path/?aaaa#bbbb --> http://example.com/path/ + * + * @name helper.scriptLocation + * @function + * @return {string} current script location + */ + me.scriptLocation = function() + { + // check for cached version + if (scriptLocation !== null) { + return scriptLocation; } - $rawTextButton.removeClass('hidden'); + scriptLocation = window.location.href.substring( + 0, + window.location.href.length - window.location.search.length - window.location.hash.length + ); + + var hashIndex = scriptLocation.indexOf('?'); + + if (hashIndex !== -1) + { + scriptLocation = scriptLocation.substring(0, hashIndex); + } + + return scriptLocation; + }; + + /** + * get the pastes unique identifier from the URL, + * eg. http://example.com/path/?c05354954c49a487#c05354954c49a487 returns c05354954c49a487 + * + * @name helper.pasteId + * @function + * @return {string} unique identifier + */ + me.pasteId = function() + { + return window.location.search.substring(1); + }; + + /** + * return the deciphering key stored in anchor part of the URL + * + * @name helper.pageKey + * @function + * @return {string} key + */ + me.pageKey = function() + { + var key = window.location.hash.substring(1), + i = key.indexOf('&'); + + // Some web 2.0 services and redirectors add data AFTER the anchor + // (such as &utm_source=...). We will strip any additional data. + if (i > -1) + { + key = key.substring(0, i); + } + + return key; + }; + + /** + * convert all applicable characters to HTML entities + * + * @see {@link https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content} + * @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]; + }); + }; + + return me; + })(window, document); + + /** + * internationalization methods + * + * @param {object} window + * @param {object} document + * @name i18n + * @class + */ + var i18n = (function (window, document) { + var me = {}; + + /** + * supported languages, minus the built in 'en' + * + * @private + * @prop {string[]} + * @readonly + */ + var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'oc', 'ru', 'sl', 'zh']; + + /** + * built in language + * + * @private + * @prop {string} + */ + var language = 'en'; + + /** + * translation cache + * + * @private + * @enum {Object} + */ + var translations = {}; + + /** + * translate a string, alias for i18n.translate() + * + * @name i18n._ + * @function + * @param {string} messageId + * @param {...*} args - one or multiple parameters injected into placeholders + * @return {string} + */ + me._ = function() + { + return me.translate(arguments); + }; + + /** + * translate a string + * + * @name i18n.translate + * @function + * @param {string} messageId + * @param {...*} args - one or multiple parameters injected into placeholders + * @return {string} + */ + me.translate = function() + { + var args = arguments, messageId; + if (typeof arguments[0] === 'object') + { + args = arguments[0]; + } + var 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 (!translations.hasOwnProperty(messageId)) + { + if (language !== 'en') + { + console.error( + 'Missing ' + language + ' translation for: ' + messageId + ); + } + translations[messageId] = args[0]; + } + if (usesPlurals && $.isArray(translations[messageId])) + { + var 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 + { + args[0] = translations[messageId]; + } + return helper.sprintf(args); + }; + + /** + * per language functions to use to determine the plural form + * + * @see {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html} + * @name i18n.getPluralForm + * @function + * @param {number} n + * @return {number} array key + */ + me.getPluralForm = function(n) { + switch (language) + { + case 'fr': + case 'oc': + case 'zh': + return (n > 1 ? 1 : 0); + case 'pl': + return (n === 1 ? 0 : (n % 10 >= 2 && n %10 <=4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); + case 'ru': + return (n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2)); + case 'sl': + return (n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0))); + // de, en, es, it, no + default: + return (n !== 1 ? 1 : 0); + } + }; + + /** + * load translations into cache, then trigger controller initialization + * + * @name i18n.loadTranslations + * @function + */ + me.loadTranslations = function() + { + var newLanguage = helper.getCookie('lang'); + + // auto-select language based on browser settings + if (newLanguage.length === 0) + { + newLanguage = (navigator.language || navigator.userLanguage).substring(0, 2); + } + + // if language is already used (e.g, default 'en'), skip update + if (newLanguage === language) + { + controller.init(); + 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); + controller.init(); + } + + // load strongs from JSON + $.getJSON('i18n/' + newLanguage + '.json', function(data) { + language = newLanguage; + translations = data; + }).fail(function (data, textStatus, errorMsg) { + console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg); + }); + + controller.init(); + }; + + return me; + })(window, document); + + /** + * filter methods + * + * @param {object} window + * @param {object} document + * @name filter + * @class + */ + var filter = (function (window, document) { + var me = {}; + + /** + * compress a message (deflate compression), returns base64 encoded data + * + * @name filter.compress + * @function + * @param {string} message + * @return {string} base64 data + */ + me.compress = function(message) + { + return Base64.toBase64( RawDeflate.deflate( Base64.utob(message) ) ); + }, + + /** + * decompress a message compressed with filter.compress() + * + * @name filter.decompress + * @function + * @param {string} data - base64 data + * @return {string} message + */ + me.decompress = function(data) + { + return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) ); + }, + + /** + * compress, then encrypt message with given key and password + * + * @name filter.cipher + * @function + * @param {string} key + * @param {string} password + * @param {string} message + * @return {string} data - JSON with encrypted data + */ + me.cipher = function(key, password, message) + { + // Galois Counter Mode, keysize 256 bit, authentication tag 128 bit + var options = {mode: 'gcm', ks: 256, ts: 128}; + if ((password || '').trim().length === 0) + { + return sjcl.encrypt(key, me.compress(message), options); + } + return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), me.compress(message), options); + }, + + /** + * decrypt message with key, then decompress + * + * @name filter.decipher + * @function + * @param {string} key + * @param {string} password + * @param {string} data - JSON with encrypted data + * @return {string} decrypted message + */ + me.decipher = function(key, password, data) + { + if (data !== undefined) + { + try + { + return me.decompress(sjcl.decrypt(key, data)); + } + catch(err) + { + try + { + return me.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); + } + catch(e) + { + // ignore error, because ????? @TODO + } + } + } + return ''; + } + + return me; + })(window, document); + + /** + * PrivateBin logic + * + * @param {object} window + * @param {object} document + * @name controller + * @class + */ + var controller = (function (window, document) { + var me = {}; + + /** + * headers to send in AJAX requests + * + * @private + * @enum {Object} + */ + var headers = {'X-Requested-With': 'JSONHttpRequest'}; + + /** + * URL shortners create address + * + * @private + * @prop {string} + */ + var shortenerUrl = ''; + + /** + * URL of newly created paste + * + * @private + * @prop {string} + */ + var createdPasteUrl = ''; + + // jQuery pre-loaded objects + var $attach, + $attachment, + $attachmentLink, + $burnAfterReading, + $burnAfterReadingOption, + $cipherData, + $clearText, + $cloneButton, + $clonedFile, + $comments, + $discussion, + $errorMessage, + $expiration, + $fileRemoveButton, + $fileWrap, + $formatter, + $image, + $loadingIndicator, + $message, + $messageEdit, + $messagePreview, + $newButton, + $openDisc, // @TODO: rename - too similar to openDiscussion, difference unclear + $openDiscussion, + $password, + $passwordInput, + $passwordModal, + $passwordForm, + $passwordDecrypt, + $pasteResult, + $pasteUrl, + $prettyMessage, + $prettyPrint, + $preview, + $rawTextButton, + $remainingTime, + $replyStatus, + $sendButton, + $status; + + /** + * ask the user for the password and set it + * + * @name controller.requestPassword + * @function + */ + me.requestPassword = function() + { + if ($passwordModal.length === 0) { + var password = prompt(i18n._('Please enter the password for this paste:'), ''); + if (password === null) + { + throw 'password prompt canceled'; + } + if (password.length === 0) + { + // recursive… + me.requestPassword(); + } else { + $passwordInput.val(password); + me.displayMessages(); + } + } else { + $passwordModal.modal(); + } + }; + + /** + * use given format on paste, defaults to plain text + * + * @name controller.formatPaste + * @function + * @param {string} format + * @param {string} text + */ + me.formatPaste = function(format, text) + { + helper.setElementText($clearText, text); + helper.setElementText($prettyPrint, text); + + switch (format || 'plaintext') { + case 'markdown': + // silently fail if showdown is not available + // @TODO: maybe better show an error message? At least a warning? + if (typeof showdown === 'object') + { + var converter = new showdown.Converter({ + strikethrough: true, + tables: true, + tablesHeaderId: true + }); + $clearText.html( + converter.makeHtml(text) + ); + // add table classes from bootstrap css + $clearText.find('table').addClass('table-condensed table-bordered'); + + $clearText.removeClass('hidden'); + } else { + console.error('showdown is not loaded, could not parse Markdown'); + } + $prettyMessage.addClass('hidden'); + break; + case 'syntaxhighlighting': + // silently fail if prettyprint is not available + // @TODO: maybe better show an error message? At least a warning? + if (typeof prettyPrintOne === 'function') + { + if (typeof prettyPrint === 'function') + { + prettyPrint(); + } + $prettyPrint.html( + prettyPrintOne( + helper.htmlEntities(text), null, true + ) + ); + } else { + console.error('pretty print is not loaded, could not link '); + } + // fall through, as the rest is the same + default: // = 'plaintext' + // convert URLs to clickable links + helper.urls2links($clearText); + helper.urls2links($prettyPrint); + $clearText.addClass('hidden'); + + + $prettyPrint.css('white-space', 'pre-wrap'); + $prettyPrint.css('word-break', 'normal'); + $prettyPrint.removeClass('prettyprint'); + + $prettyMessage.removeClass('hidden'); + } + }; + + /** + * show decrypted text in the display area, including discussion (if open) + * + * @name controller.displayMessages + * @function + * @param {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta')) + */ + me.displayMessages = function(paste) + { + paste = paste || $.parseJSON($cipherData.text()); + var key = helper.pageKey(), + password = $passwordInput.val(); + if (!$prettyPrint.hasClass('prettyprinted')) { + // Try to decrypt the paste. + try + { + if (paste.attachment) + { + var attachment = filter.decipher(key, password, paste.attachment); + if (attachment.length === 0) + { + if (password.length === 0) + { + me.requestPassword(); + return; + } + attachment = filter.decipher(key, password, paste.attachment); + } + if (attachment.length === 0) + { + throw 'failed to decipher attachment'; + } + + if (paste.attachmentname) + { + var attachmentname = filter.decipher(key, password, paste.attachmentname); + if (attachmentname.length > 0) + { + $attachmentLink.attr('download', attachmentname); + } + } + $attachmentLink.attr('href', attachment); + $attachment.removeClass('hidden'); + + // if the attachment is an image, display it + var imagePrefix = 'data:image/'; + if (attachment.substring(0, imagePrefix.length) === imagePrefix) + { + $image.html( + $(document.createElement('img')) + .attr('src', attachment) + .attr('class', 'img-thumbnail') + ); + $image.removeClass('hidden'); + } + } + var cleartext = filter.decipher(key, password, paste.data); + if (cleartext.length === 0 && password.length === 0 && !paste.attachment) + { + me.requestPassword(); + return; + } + if (cleartext.length === 0 && !paste.attachment) + { + throw 'failed to decipher message'; + } + + $passwordInput.val(password); + if (cleartext.length > 0) + { + $('#pasteFormatter').val(paste.meta.formatter); + me.formatPaste(paste.meta.formatter, cleartext); + } + } + catch(err) + { + me.stateOnlyNewPaste(); + me.showError(i18n._('Could not decrypt data (Wrong key?)')); + return; + } + } + + // display paste expiration / for your eyes only + if (paste.meta.expire_date) + { + var expiration = helper.secondsToHuman(paste.meta.remaining_time), + expirationLabel = [ + 'This document will expire in %d ' + expiration[1] + '.', + 'This document will expire in %d ' + expiration[1] + 's.' + ]; + helper.setMessage($remainingTime, i18n._(expirationLabel, expiration[0])); + $remainingTime.removeClass('foryoureyesonly') + .removeClass('hidden'); + } + if (paste.meta.burnafterreading) + { + // unfortunately many web servers don't support DELETE (and PUT) out of the box + $.ajax({ + type: 'POST', + url: helper.scriptLocation() + '?' + helper.pasteId(), + data: {deletetoken: 'burnafterreading'}, + dataType: 'json', + headers: headers + }) + .fail(function() { + controller.showError(i18n._('Could not delete the paste, it was not stored in burn after reading mode.')); + }); + helper.setMessage($remainingTime, i18n._( + 'FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.' + )); + $remainingTime.addClass('foryoureyesonly') + .removeClass('hidden'); + // discourage cloning (as it can't really be prevented) + $cloneButton.addClass('hidden'); + } + + // if the discussion is opened on this paste, display it + if (paste.meta.opendiscussion) + { + $comments.html(''); + + var $divComment; + + // iterate over comments + for (var i = 0; i < paste.comments.length; ++i) + { + var $place = $comments, + comment = paste.comments[i], + commentText = filter.decipher(key, password, comment.data), + $parentComment = $('#comment_' + comment.parentid); + + $divComment = $('
' + + '
' + + '
' + + '
'); + var $divCommentData = $divComment.find('div.commentdata'); + + // if parent comment exists + if ($parentComment.length) + { + // shift comment to the right + $place = $parentComment; + } + $divComment.find('button').click({commentid: comment.id}, me.openReply); + helper.setElementText($divCommentData, commentText); + helper.urls2links($divCommentData); + + // try to get optional nickname + var nick = filter.decipher(key, password, comment.meta.nickname); + if (nick.length > 0) + { + $divComment.find('span.nickname').text(nick); + } + else + { + divComment.find('span.nickname').html('' + i18n._('Anonymous') + ''); + } + $divComment.find('span.commentdate') + .text(' (' + (new Date(comment.meta.postdate * 1000).toLocaleString()) + ')') + .attr('title', 'CommentID: ' + comment.id); + + // if an avatar is available, display it + if (comment.meta.vizhash) + { + $divComment.find('span.nickname') + .before( + ' ' + ); + } + + $place.append($divComment); + } + + // add 'add new comment' area + $divComment = $( + '
' + ); + $divComment.find('button').click({commentid: helper.pasteId()}, me.openReply); + $comments.append($divComment); + $discussion.removeClass('hidden'); + } + }; + + /** + * open the comment entry when clicking the "Reply" button of a comment + * + * @name controller.openReply + * @function + * @param {Event} event + */ + me.openReply = function(event) + { + event.preventDefault(); + + // remove any other reply area + $('div.reply').remove(); + + var source = $(event.target), + commentid = event.data.commentid, + hint = i18n._('Optional nickname...'), + reply = $( + '

' + + '
' + ); + reply.find('button').click( + {parentid: commentid}, + me.sendComment + ); + source.after(reply); + $replyStatus = $('#replystatus'); + $('#replymessage').focus(); + }; + + /** + * send a reply in a discussion + * + * @name controller.sendComment + * @function + * @param {Event} event + */ + me.sendComment = function(event) + { + event.preventDefault(); + $errorMessage.addClass('hidden'); + // do not send if no data + var replyMessage = $('#replymessage'); + if (replyMessage.val().length === 0) + { + return; + } + + me.showStatus(i18n._('Sending comment...'), true); + var parentid = event.data.parentid, + key = helper.pageKey(), + cipherdata = filter.cipher(key, $passwordInput.val(), replyMessage.val()), + ciphernickname = '', + nick = $('#nickname').val(); + if (nick.length > 0) + { + ciphernickname = filter.cipher(key, $passwordInput.val(), nick); + } + var data_to_send = { + data: cipherdata, + parentid: parentid, + pasteid: helper.pasteId(), + nickname: ciphernickname + }; + + $.ajax({ + type: 'POST', + url: helper.scriptLocation(), + data: data_to_send, + dataType: 'json', + headers: headers, + success: function(data) + { + if (data.status === 0) + { + controller.showStatus(i18n._('Comment posted.')); + $.ajax({ + type: 'GET', + url: helper.scriptLocation() + '?' + helper.pasteId(), + dataType: 'json', + headers: headers, + success: function(data) + { + if (data.status === 0) + { + controller.displayMessages(data); + } + else if (data.status === 1) + { + controller.showError(i18n._('Could not refresh display: %s', data.message)); + } + else + { + controller.showError(i18n._('Could not refresh display: %s', i18n._('unknown status'))); + } + } + }) + .fail(function() { + controller.showError(i18n._('Could not refresh display: %s', i18n._('server error or not responding'))); + }); + } + else if (data.status === 1) + { + controller.showError(i18n._('Could not post comment: %s', data.message)); + } + else + { + controller.showError(i18n._('Could not post comment: %s', i18n._('unknown status'))); + } + } + }) + .fail(function() { + controller.showError(i18n._('Could not post comment: %s', i18n._('server error or not responding'))); + }); + }; + + /** + * send a new paste to server + * + * @name controller.sendData + * @function + * @param {Event} event + */ + me.sendData = function(event) + { + event.preventDefault(); + var file = document.getElementById('file'), + files = (file && file.files) ? file.files : null; // FileList object + + // do not send if no data. + if ($message.val().length === 0 && !(files && files[0])) + { + return; + } + + // if sjcl has not collected enough entropy yet, display a message + if (!sjcl.random.isReady()) + { + me.showStatus(i18n._('Sending paste (Please move your mouse for more entropy)...'), true); + sjcl.random.addEventListener('seeded', function() { + me.sendData(event); + }); + return; + } + + $('.navbar-toggle').click(); + $password.addClass('hidden'); + me.showStatus(i18n._('Sending paste...'), true); + + me.stateSubmittingPaste(); + + var randomkey = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0), + password = $passwordInput.val(); + if(files && files[0]) + { + if(typeof FileReader === undefined) + { + // revert loading status… + me.stateNewPaste(); + me.showError(i18n._('Your browser does not support uploading encrypted files. Please use a newer browser.')); + return; + } + var reader = new FileReader(); + // closure to capture the file information + reader.onload = (function(theFile) + { + return function(e) { + controller.sendDataContinue( + randomkey, + filter.cipher(randomkey, password, e.target.result), + filter.cipher(randomkey, password, theFile.name) + ); + }; + })(files[0]); + reader.readAsDataURL(files[0]); + } + else if($attachmentLink.attr('href')) + { + me.sendDataContinue( + randomkey, + filter.cipher(randomkey, password, $attachmentLink.attr('href')), + $attachmentLink.attr('download') + ); + } + else + { + me.sendDataContinue(randomkey, '', ''); + } + }; + + /** + * send a new paste to server, step 2 + * + * @name controller.sendDataContinue + * @function + * @param {string} randomkey + * @param {string} cipherdata_attachment + * @param {string} cipherdata_attachment_name + */ + me.sendDataContinue = function(randomkey, cipherdata_attachment, cipherdata_attachment_name) + { + var cipherdata = filter.cipher(randomkey, $passwordInput.val(), $message.val()), + data_to_send = { + data: cipherdata, + expire: $('#pasteExpiration').val(), + formatter: $('#pasteFormatter').val(), + burnafterreading: $burnAfterReading.is(':checked') ? 1 : 0, + opendiscussion: $openDiscussion.is(':checked') ? 1 : 0 + }; + if (cipherdata_attachment.length > 0) + { + data_to_send.attachment = cipherdata_attachment; + if (cipherdata_attachment_name.length > 0) + { + data_to_send.attachmentname = cipherdata_attachment_name; + } + } + $.ajax({ + type: 'POST', + url: helper.scriptLocation(), + data: data_to_send, + dataType: 'json', + headers: headers, + success: function(data) + { + if (data.status === 0) { + me.stateExistingPaste(); + var url = helper.scriptLocation() + '?' + data.id + '#' + randomkey, + deleteUrl = helper.scriptLocation() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; + me.showStatus(''); + $errorMessage.addClass('hidden'); + // show new URL in browser bar + history.pushState({type: 'newpaste'}, document.title, url); + + $('#pastelink').html( + i18n._( + 'Your paste is %s (Hit [Ctrl]+[c] to copy)', + url, url + ) + me.shortenUrl(url) + ); + // save newly created element + $pasteUrl = $('#pasteurl'); + // and add click event + $pasteUrl.click(me.pasteLinkClick); + + var shortenButton = $('#shortenbutton'); + if (shortenButton) { + shortenButton.click(me.sendToShortener); + } + $('#deletelink').html('' + i18n._('Delete data') + ''); + $pasteResult.removeClass('hidden'); + // we pre-select the link so that the user only has to [Ctrl]+[c] the link + helper.selectText($pasteUrl[0]); + me.showStatus(''); + me.formatPaste(data_to_send.formatter, $message.val()); + } + else if (data.status === 1) + { + // revert loading status… + controller.stateNewPaste(); + controller.showError(i18n._('Could not create paste: %s', data.message)); + } + else + { + // revert loading status… + controller.stateNewPaste(); + controller.showError(i18n._('Could not create paste: %s', i18n._('unknown status'))); + } + } + }) + .fail(function() + { + // revert loading status… + me.stateNewPaste(); + controller.showError(i18n._('Could not create paste: %s', i18n._('server error or not responding'))); + }); + }; + + /** + * check if a URL shortener was defined and create HTML containing a link to it + * + * @name controller.shortenUrl + * @function + * @param {string} url + * @return {string} html + */ + me.shortenUrl = function(url) + { + var shortenerHtml = $('#shortenbutton'); + if (shortenerHtml) { + shortenerUrl = shortenerHtml.data('shortener'); + createdPasteUrl = url; + return ' ' + $('
').append(shortenerHtml.clone()).html(); + } + return ''; + }; + + /** + * put the screen in "New paste" mode + * + * @name controller.stateNewPaste + * @function + */ + me.stateNewPaste = function() + { + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + $sendButton.removeClass('hidden'); + $expiration.removeClass('hidden'); + $formatter.removeClass('hidden'); + $burnAfterReadingOption.removeClass('hidden'); + $openDisc.removeClass('hidden'); + $newButton.removeClass('hidden'); + $password.removeClass('hidden'); + $attach.removeClass('hidden'); + $message.removeClass('hidden'); + $preview.removeClass('hidden'); + $message.focus(); + }; + + /** + * put the screen in mode after submitting a paste + * + * @name controller.stateSubmittingPaste + * @function + */ + me.stateSubmittingPaste = function() + { + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); $sendButton.addClass('hidden'); - $attach.addClass('hidden'); $expiration.addClass('hidden'); $formatter.addClass('hidden'); $burnAfterReadingOption.addClass('hidden'); $openDisc.addClass('hidden'); - $newButton.removeClass('hidden'); + $newButton.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + $message.addClass('hidden'); $preview.addClass('hidden'); - } - $pasteResult.addClass('hidden'); - $message.addClass('hidden'); - $clearText.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - }; + $loadingIndicator.removeClass('hidden'); + }; - /** - * when "burn after reading" is checked, disable discussion - * - * @name controller.changeBurnAfterReading - * @function - */ - me.changeBurnAfterReading = function() - { - if ($burnAfterReading.is(':checked') ) + /** + * put the screen in a state where the only option is to submit a + * new paste + * + * @name controller.stateOnlyNewPaste + * @function + */ + me.stateOnlyNewPaste = function() { - $openDisc.addClass('buttondisabled'); - $openDiscussion.attr({checked: false, disabled: true}); - } - else + $message.text(''); + $attachment.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + $remainingTime.addClass('hidden'); + $pasteResult.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $sendButton.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + $message.addClass('hidden'); + $preview.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + + $newButton.removeClass('hidden'); + }; + + /** + * put the screen in "Existing paste" mode + * + * @name controller.stateExistingPaste + * @function + * @param {boolean} [preview=false] - (optional) tell if the preview tabs should be displayed, defaults to false + */ + me.stateExistingPaste = function(preview) { - $openDisc.removeClass('buttondisabled'); - $openDiscussion.removeAttr('disabled'); - } - }; + preview = preview || false; - /** - * when discussion is checked, disable "burn after reading" - * - * @name controller.changeOpenDisc - * @function - */ - me.changeOpenDisc = function() - { - if ($openDiscussion.is(':checked') ) - { - $burnAfterReadingOption.addClass('buttondisabled'); - $burnAfterReading.attr({checked: false, disabled: true}); - } - else - { - $burnAfterReadingOption.removeClass('buttondisabled'); - $burnAfterReading.removeAttr('disabled'); - } - }; - - /** - * forward to URL shortener - * - * @name controller.sendToShortener - * @function - * @param {Event} event - */ - me.sendToShortener = function(event) - { - window.location.href = shortenerUrl + encodeURIComponent(createdPasteUrl); - event.preventDefault(); - }; - - /** - * reload the page - * - * This takes the user to the PrivateBin home page. - * - * @name controller.reloadPage - * @function - * @param {Event} event - */ - me.reloadPage = function(event) - { - window.location.href = helper.scriptLocation(); - event.preventDefault(); - }; - - /** - * return raw text - * - * @name controller.rawText - * @function - * @param {Event} event - */ - me.rawText = function(event) - { - var paste = $('#pasteFormatter').val() === 'markdown' ? - $prettyPrint.text() : $clearText.text(); - history.pushState( - null, document.title, helper.scriptLocation() + '?' + - helper.pasteId() + '#' + helper.pageKey() - ); - // we use text/html instead of text/plain to avoid a bug when - // reloading the raw text view (it reverts to type text/html) - var newDoc = document.open('text/html', 'replace'); - newDoc.write('
' + helper.htmlEntities(paste) + '
'); - newDoc.close(); - - event.preventDefault(); - }; - - /** - * clone the current paste - * - * @name controller.clonePaste - * @function - * @param {Event} event - */ - me.clonePaste = function(event) - { - event.preventDefault(); - me.stateNewPaste(); - - // erase the id and the key in url - history.replaceState(null, document.title, helper.scriptLocation()); - - me.showStatus(''); - if ($attachmentLink.attr('href')) - { - $clonedFile.removeClass('hidden'); - $fileWrap.addClass('hidden'); - } - $message.text( - $('#pasteFormatter').val() === 'markdown' ? - $prettyPrint.text() : $clearText.text() - ); - $('.navbar-toggle').click(); - }; - - /** - * set the expiration on bootstrap templates - * - * @name controller.setExpiration - * @function - * @param {Event} event - */ - me.setExpiration = function(event) - { - event.preventDefault(); - var target = $(event.target); - $('#pasteExpiration').val(target.data('expiration')); - $('#pasteExpirationDisplay').text(target.text()); - }; - - /** - * set the format on bootstrap templates - * - * @name controller.setFormat - * @function - * @param {Event} event - */ - me.setFormat = function(event) - { - var target = $(event.target); - $('#pasteFormatter').val(target.data('format')); - $('#pasteFormatterDisplay').text(target.text()); - - if ($messagePreview.parent().hasClass('active')) { - me.viewPreview(event); - } - event.preventDefault(); - }; - - /** - * set the language in a cookie and reload the page - * - * @name controller.setLanguage - * @function - * @param {Event} event - */ - me.setLanguage = function(event) - { - document.cookie = 'lang=' + $(event.target).data('lang'); - me.reloadPage(event); - }; - - /** - * support input of tab character - * - * @name controller.supportTabs - * @function - * @param {Event} event - * @TODO doc what is @this here? - */ - me.supportTabs = function(event) - { - var keyCode = event.keyCode || event.which; - // tab was pressed - if (keyCode === 9) - { - // prevent the textarea to lose focus - event.preventDefault(); - // get caret position & selection - var 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; - } - }; - - /** - * view the editor tab - * - * @name controller.viewEditor - * @function - * @param {Event} event - */ - me.viewEditor = function(event) - { - $messagePreview.parent().removeClass('active'); - $messageEdit.parent().addClass('active'); - $message.focus(); - me.stateNewPaste(); - - event.preventDefault(); - }; - - /** - * view the preview tab - * - * @name controller.viewPreview - * @function - * @param {Event} event - */ - me.viewPreview = function(event) - { - $messageEdit.parent().removeClass('active'); - $messagePreview.parent().addClass('active'); - $message.focus(); - me.stateExistingPaste(true); - me.formatPaste($('#pasteFormatter').val(), $message.val()); - - event.preventDefault(); - }; - - /** - * handle history (pop) state changes - * - * currently this does only handle redirects to the home page. - * - * @name controller.historyChange - * @function - * @param {Event} event - */ - me.historyChange = function(event) - { - var currentLocation = helper.scriptLocation(); - if (event.originalEvent.state === null && // no state object passed - event.originalEvent.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; - } - }; - - /** - * 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 controller.pasteLinkClick - * @function - * @param {Event} event - */ - me.pasteLinkClick = function(event) - { - // 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); - } - }; - - /** - * create a new paste - * - * @name controller.newPaste - * @function - */ - me.newPaste = function() - { - me.stateNewPaste(); - me.showStatus(''); - $message.text(''); - me.changeBurnAfterReading(); - me.changeOpenDisc(); - }; - - /** - * removes an attachment - * - * @name controller.removeAttachment - * @function - */ - me.removeAttachment = function() - { - $clonedFile.addClass('hidden'); - // removes the saved decrypted file data - $attachmentLink.attr('href', ''); - // the only way to deselect the file is to recreate the input // @TODO really? - $fileWrap.html($fileWrap.html()); - $fileWrap.removeClass('hidden'); - }; - - /** - * decrypt using the password from the modal dialog - * - * @name controller.decryptPasswordModal - * @function - */ - me.decryptPasswordModal = function() - { - $passwordInput.val($passwordDecrypt.val()); - me.displayMessages(); - }; - - /** - * submit a password in the modal dialog - * - * @name controller.submitPasswordModal - * @function - * @param {Event} event - */ - me.submitPasswordModal = function(event) - { - event.preventDefault(); - $passwordModal.modal('hide'); - }; - - /** - * display an error message, - * we use the same function for paste and reply to comments - * - * @name controller.showError - * @function - * @param {string} message - text to display - */ - me.showError = function(message) - { - if ($status.length) - { - $status.addClass('errorMessage').text(message); - } - else - { - $errorMessage.removeClass('hidden'); - helper.setMessage($errorMessage, message); - } - if (typeof $replyStatus !== 'undefined') { - $replyStatus.addClass('errorMessage'); - $replyStatus.addClass($errorMessage.attr('class')); - if ($status.length) + if (!preview) { - $replyStatus.html($status.html()); + // no "clone" for IE<10. + if ($('#oldienotice').is(":visible")) + { + $cloneButton.addClass('hidden'); + } + else + { + $cloneButton.removeClass('hidden'); + } + + $rawTextButton.removeClass('hidden'); + $sendButton.addClass('hidden'); + $attach.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $newButton.removeClass('hidden'); + $preview.addClass('hidden'); + } + + $pasteResult.addClass('hidden'); + $message.addClass('hidden'); + $clearText.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $loadingIndicator.addClass('hidden'); + }; + + /** + * when "burn after reading" is checked, disable discussion + * + * @name controller.changeBurnAfterReading + * @function + */ + me.changeBurnAfterReading = function() + { + if ($burnAfterReading.is(':checked') ) + { + $openDisc.addClass('buttondisabled'); + $openDiscussion.attr({checked: false, disabled: true}); } else { - $replyStatus.html($errorMessage.html()); + $openDisc.removeClass('buttondisabled'); + $openDiscussion.removeAttr('disabled'); } - } - }; + }; - /** - * display a status message, - * we use the same function for paste and reply to comments - * - * @name controller.showStatus - * @function - * @param {string} message - text to display - * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false - */ - me.showStatus = function(message, spin) - { - if (spin || false) + /** + * when discussion is checked, disable "burn after reading" + * + * @name controller.changeOpenDisc + * @function + */ + me.changeOpenDisc = function() { - var img = ''; - $status.prepend(img); - if (typeof $replyStatus !== 'undefined') { - $replyStatus.prepend(img); - } - } - if (typeof $replyStatus !== 'undefined') { - $replyStatus.removeClass('errorMessage').text(message); - } - if (!message) - { - $status.html(' '); - return; - } - if (message === '') - { - $status.html(' '); - return; - } - $status.removeClass('errorMessage').text(message); - }; - - /** - * bind events to DOM elements - * - * @private - * @function - */ - function bindEvents() - { - $burnAfterReading.change(me.changeBurnAfterReading); - $openDisc.change(me.changeOpenDisc); - $sendButton.click(me.sendData); - $cloneButton.click(me.clonePaste); - $rawTextButton.click(me.rawText); - $fileRemoveButton.click(me.removeAttachment); - $('.reloadlink').click(me.reloadPage); - $message.keydown(me.supportTabs); - $messageEdit.click(me.viewEditor); - $messagePreview.click(me.viewPreview); - - // bootstrap template drop downs - $('ul.dropdown-menu li a', $('#expiration').parent()).click(me.setExpiration); - $('ul.dropdown-menu li a', $('#formatter').parent()).click(me.setFormat); - $('#language ul.dropdown-menu li a').click(me.setLanguage); - - // page template drop down - $('#language select option').click(me.setLanguage); - - // focus password input when it is shown - $passwordModal.on('shown.bs.modal', function () { - $passwordDecrypt.focus(); - }); - // handle modal password request on decryption - $passwordModal.on('hidden.bs.modal', me.decryptPasswordModal); - $passwordForm.submit(me.submitPasswordModal); - - $(window).on('popstate', me.historyChange); - } - - /** - * main application - * - * @name controller.init - * @function - */ - me.init = function() - { - // hide "no javascript" message - $('#noscript').hide(); - - // preload jQuery wrapped DOM elements and bind events - $attach = $('#attach'); - $attachment = $('#attachment'); - $attachmentLink = $('#attachment a'); - $burnAfterReading = $('#burnafterreading'); - $burnAfterReadingOption = $('#burnafterreadingoption'); - $cipherData = $('#cipherdata'); - $clearText = $('#cleartext'); - $cloneButton = $('#clonebutton'); - $clonedFile = $('#clonedfile'); - $comments = $('#comments'); - $discussion = $('#discussion'); - $errorMessage = $('#errormessage'); - $expiration = $('#expiration'); - $fileRemoveButton = $('#fileremovebutton'); - $fileWrap = $('#filewrap'); - $formatter = $('#formatter'); - $image = $('#image'); - $loadingIndicator = $('#loadingindicator'); - $message = $('#message'); - $messageEdit = $('#messageedit'); - $messagePreview = $('#messagepreview'); - $newButton = $('#newbutton'); - $openDisc = $('#opendisc'); - $openDiscussion = $('#opendiscussion'); - $password = $('#password'); - $passwordInput = $('#passwordinput'); - $passwordModal = $('#passwordmodal'); - $passwordForm = $('#passwordform'); - $passwordDecrypt = $('#passworddecrypt'); - $pasteResult = $('#pasteresult'); - // $pasteUrl is saved in sendDataContinue() if/after it is - // actually created - $prettyMessage = $('#prettymessage'); - $prettyPrint = $('#prettyprint'); - $preview = $('#preview'); - $rawTextButton = $('#rawtextbutton'); - $remainingTime = $('#remainingtime'); - // $replyStatus is saved in openReply() - $sendButton = $('#sendbutton'); - $status = $('#status'); - bindEvents(); - - // display status returned by php code, if any (eg. paste was properly deleted) - if ($status.text().length > 0) - { - me.showStatus($status.text()); - return; - } - - // keep line height even if content empty - $status.html(' '); - - // display an existing paste - if ($cipherData.text().length > 1) - { - // missing decryption key in URL? - if (window.location.hash.length === 0) + if ($openDiscussion.is(':checked') ) { - me.showError(i18n._('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)')); + $burnAfterReadingOption.addClass('buttondisabled'); + $burnAfterReading.attr({checked: false, disabled: true}); + } + else + { + $burnAfterReadingOption.removeClass('buttondisabled'); + $burnAfterReading.removeAttr('disabled'); + } + }; + + /** + * forward to URL shortener + * + * @name controller.sendToShortener + * @function + * @param {Event} event + */ + me.sendToShortener = function(event) + { + window.location.href = shortenerUrl + encodeURIComponent(createdPasteUrl); + event.preventDefault(); + }; + + /** + * reload the page + * + * This takes the user to the PrivateBin home page. + * + * @name controller.reloadPage + * @function + * @param {Event} event + */ + me.reloadPage = function(event) + { + window.location.href = helper.scriptLocation(); + event.preventDefault(); + }; + + /** + * return raw text + * + * @name controller.rawText + * @function + * @param {Event} event + */ + me.rawText = function(event) + { + var paste = $('#pasteFormatter').val() === 'markdown' ? + $prettyPrint.text() : $clearText.text(); + history.pushState( + null, document.title, helper.scriptLocation() + '?' + + helper.pasteId() + '#' + helper.pageKey() + ); + // we use text/html instead of text/plain to avoid a bug when + // reloading the raw text view (it reverts to type text/html) + var newDoc = document.open('text/html', 'replace'); + newDoc.write('
' + helper.htmlEntities(paste) + '
'); + newDoc.close(); + + event.preventDefault(); + }; + + /** + * clone the current paste + * + * @name controller.clonePaste + * @function + * @param {Event} event + */ + me.clonePaste = function(event) + { + event.preventDefault(); + me.stateNewPaste(); + + // erase the id and the key in url + history.replaceState(null, document.title, helper.scriptLocation()); + + me.showStatus(''); + if ($attachmentLink.attr('href')) + { + $clonedFile.removeClass('hidden'); + $fileWrap.addClass('hidden'); + } + $message.text( + $('#pasteFormatter').val() === 'markdown' ? + $prettyPrint.text() : $clearText.text() + ); + $('.navbar-toggle').click(); + }; + + /** + * set the expiration on bootstrap templates + * + * @name controller.setExpiration + * @function + * @param {Event} event + */ + me.setExpiration = function(event) + { + event.preventDefault(); + var target = $(event.target); + $('#pasteExpiration').val(target.data('expiration')); + $('#pasteExpirationDisplay').text(target.text()); + }; + + /** + * set the format on bootstrap templates + * + * @name controller.setFormat + * @function + * @param {Event} event + */ + me.setFormat = function(event) + { + var target = $(event.target); + $('#pasteFormatter').val(target.data('format')); + $('#pasteFormatterDisplay').text(target.text()); + + if ($messagePreview.parent().hasClass('active')) { + me.viewPreview(event); + } + event.preventDefault(); + }; + + /** + * set the language in a cookie and reload the page + * + * @name controller.setLanguage + * @function + * @param {Event} event + */ + me.setLanguage = function(event) + { + document.cookie = 'lang=' + $(event.target).data('lang'); + me.reloadPage(event); + }; + + /** + * support input of tab character + * + * @name controller.supportTabs + * @function + * @param {Event} event + * @TODO doc what is @this here? + */ + me.supportTabs = function(event) + { + var keyCode = event.keyCode || event.which; + // tab was pressed + if (keyCode === 9) + { + // prevent the textarea to lose focus + event.preventDefault(); + // get caret position & selection + var 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; + } + }; + + /** + * view the editor tab + * + * @name controller.viewEditor + * @function + * @param {Event} event + */ + me.viewEditor = function(event) + { + $messagePreview.parent().removeClass('active'); + $messageEdit.parent().addClass('active'); + $message.focus(); + me.stateNewPaste(); + + event.preventDefault(); + }; + + /** + * view the preview tab + * + * @name controller.viewPreview + * @function + * @param {Event} event + */ + me.viewPreview = function(event) + { + $messageEdit.parent().removeClass('active'); + $messagePreview.parent().addClass('active'); + $message.focus(); + me.stateExistingPaste(true); + me.formatPaste($('#pasteFormatter').val(), $message.val()); + + event.preventDefault(); + }; + + /** + * handle history (pop) state changes + * + * currently this does only handle redirects to the home page. + * + * @name controller.historyChange + * @function + * @param {Event} event + */ + me.historyChange = function(event) + { + var currentLocation = helper.scriptLocation(); + if (event.originalEvent.state === null && // no state object passed + event.originalEvent.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; + } + }; + + /** + * 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 controller.pasteLinkClick + * @function + * @param {Event} event + */ + me.pasteLinkClick = function(event) + { + // 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); + } + }; + + /** + * create a new paste + * + * @name controller.newPaste + * @function + */ + me.newPaste = function() + { + me.stateNewPaste(); + me.showStatus(''); + $message.text(''); + me.changeBurnAfterReading(); + me.changeOpenDisc(); + }; + + /** + * removes an attachment + * + * @name controller.removeAttachment + * @function + */ + me.removeAttachment = function() + { + $clonedFile.addClass('hidden'); + // removes the saved decrypted file data + $attachmentLink.attr('href', ''); + // the only way to deselect the file is to recreate the input // @TODO really? + $fileWrap.html($fileWrap.html()); + $fileWrap.removeClass('hidden'); + }; + + /** + * decrypt using the password from the modal dialog + * + * @name controller.decryptPasswordModal + * @function + */ + me.decryptPasswordModal = function() + { + $passwordInput.val($passwordDecrypt.val()); + me.displayMessages(); + }; + + /** + * submit a password in the modal dialog + * + * @name controller.submitPasswordModal + * @function + * @param {Event} event + */ + me.submitPasswordModal = function(event) + { + event.preventDefault(); + $passwordModal.modal('hide'); + }; + + /** + * display an error message, + * we use the same function for paste and reply to comments + * + * @name controller.showError + * @function + * @param {string} message - text to display + */ + me.showError = function(message) + { + if ($status.length) + { + $status.addClass('errorMessage').text(message); + } + else + { + $errorMessage.removeClass('hidden'); + helper.setMessage($errorMessage, message); + } + if (typeof $replyStatus !== 'undefined') { + $replyStatus.addClass('errorMessage'); + $replyStatus.addClass($errorMessage.attr('class')); + if ($status.length) + { + $replyStatus.html($status.html()); + } + else + { + $replyStatus.html($errorMessage.html()); + } + } + }; + + /** + * display a status message, + * we use the same function for paste and reply to comments + * + * @name controller.showStatus + * @function + * @param {string} message - text to display + * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false + */ + me.showStatus = function(message, spin) + { + if (spin || false) + { + var img = ''; + $status.prepend(img); + if (typeof $replyStatus !== 'undefined') { + $replyStatus.prepend(img); + } + } + if (typeof $replyStatus !== 'undefined') { + $replyStatus.removeClass('errorMessage').text(message); + } + if (!message) + { + $status.html(' '); + return; + } + if (message === '') + { + $status.html(' '); + return; + } + $status.removeClass('errorMessage').text(message); + }; + + /** + * bind events to DOM elements + * + * @private + * @function + */ + function bindEvents() + { + $burnAfterReading.change(me.changeBurnAfterReading); + $openDisc.change(me.changeOpenDisc); + $sendButton.click(me.sendData); + $cloneButton.click(me.clonePaste); + $rawTextButton.click(me.rawText); + $fileRemoveButton.click(me.removeAttachment); + $('.reloadlink').click(me.reloadPage); + $message.keydown(me.supportTabs); + $messageEdit.click(me.viewEditor); + $messagePreview.click(me.viewPreview); + + // bootstrap template drop downs + $('ul.dropdown-menu li a', $('#expiration').parent()).click(me.setExpiration); + $('ul.dropdown-menu li a', $('#formatter').parent()).click(me.setFormat); + $('#language ul.dropdown-menu li a').click(me.setLanguage); + + // page template drop down + $('#language select option').click(me.setLanguage); + + // focus password input when it is shown + $passwordModal.on('shown.bs.modal', function () { + $passwordDecrypt.focus(); + }); + // handle modal password request on decryption + $passwordModal.on('hidden.bs.modal', me.decryptPasswordModal); + $passwordForm.submit(me.submitPasswordModal); + + $(window).on('popstate', me.historyChange); + }; + + /** + * main application + * + * @name controller.init + * @function + */ + me.init = function() + { + // hide "no javascript" message + $('#noscript').hide(); + + // preload jQuery wrapped DOM elements and bind events + $attach = $('#attach'); + $attachment = $('#attachment'); + $attachmentLink = $('#attachment a'); + $burnAfterReading = $('#burnafterreading'); + $burnAfterReadingOption = $('#burnafterreadingoption'); + $cipherData = $('#cipherdata'); + $clearText = $('#cleartext'); + $cloneButton = $('#clonebutton'); + $clonedFile = $('#clonedfile'); + $comments = $('#comments'); + $discussion = $('#discussion'); + $errorMessage = $('#errormessage'); + $expiration = $('#expiration'); + $fileRemoveButton = $('#fileremovebutton'); + $fileWrap = $('#filewrap'); + $formatter = $('#formatter'); + $image = $('#image'); + $loadingIndicator = $('#loadingindicator'); + $message = $('#message'); + $messageEdit = $('#messageedit'); + $messagePreview = $('#messagepreview'); + $newButton = $('#newbutton'); + $openDisc = $('#opendisc'); + $openDiscussion = $('#opendiscussion'); + $password = $('#password'); + $passwordInput = $('#passwordinput'); + $passwordModal = $('#passwordmodal'); + $passwordForm = $('#passwordform'); + $passwordDecrypt = $('#passworddecrypt'); + $pasteResult = $('#pasteresult'); + // $pasteUrl is saved in sendDataContinue() if/after it is + // actually created + $prettyMessage = $('#prettymessage'); + $prettyPrint = $('#prettyprint'); + $preview = $('#preview'); + $rawTextButton = $('#rawtextbutton'); + $remainingTime = $('#remainingtime'); + // $replyStatus is saved in openReply() + $sendButton = $('#sendbutton'); + $status = $('#status'); + bindEvents(); + + // display status returned by php code, if any (eg. paste was properly deleted) + if ($status.text().length > 0) + { + me.showStatus($status.text()); return; } - // show proper elements on screen - me.stateExistingPaste(); - me.displayMessages(); - } - // display error message from php code - else if ($errorMessage.text().length > 1) - { - me.showError($errorMessage.text()); - } - // create a new paste - else - { - me.newPaste(); - } - }; + // keep line height even if content empty + $status.html(' '); - return me; -})(window, document, jQuery, sjcl, Base64, RawDeflate); + // display an existing paste + if ($cipherData.text().length > 1) + { + // missing decryption key in URL? + if (window.location.hash.length === 0) + { + me.showError(i18n._('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)')); + return; + } + + // show proper elements on screen + me.stateExistingPaste(); + me.displayMessages(); + } + // display error message from php code + else if ($errorMessage.text().length > 1) + { + me.showError($errorMessage.text()); + } + // create a new paste + else + { + me.newPaste(); + } + }; + + return me; + })(window, document); + + /** + * main application start, called when DOM is fully loaded and + * runs controller initalization after translations are loaded + */ + $(i18n.loadTranslations); + + return { + helper: helper, + i18n: i18n, + filter: filter, + controller: controller + }; +}(jQuery, sjcl, Base64, RawDeflate); From dd6e426da79f00f3554df7e7700777ea3ee38e3d Mon Sep 17 00:00:00 2001 From: rugk Date: Sun, 12 Feb 2017 18:08:08 +0100 Subject: [PATCH 06/29] first round of refactoring split into modules, moved code around need to make it work --- js/privatebin.js | 1694 +++++++++++++++++++++++++-------------------- tpl/bootstrap.php | 23 +- 2 files changed, 974 insertions(+), 743 deletions(-) diff --git a/js/privatebin.js b/js/privatebin.js index 6322c0e8..0416a03f 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -11,7 +11,6 @@ * @namespace */ -'use strict'; /** global: Base64 */ /** global: FileReader */ /** global: RawDeflate */ @@ -25,17 +24,14 @@ // Immediately start random number generator collector. sjcl.random.startCollectors(); -// jQuery(document).ready(function() { -// // startup -// } - jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { + 'use strict'; + /** * static helper methods * * @param {object} window * @param {object} document - * @name helper * @class */ var helper = (function (window, document) { @@ -157,27 +153,6 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } }; - /** - * replace last child of element with message - * - * @name helper.setMessage - * @function - * @param {jQuery} $element - a jQuery wrapped DOM element - * @param {string} message - the message to append - */ - me.setMessage = function($element, message) - { - var content = $element.contents(); - if (content.length > 0) - { - content[content.length - 1].nodeValue = ' ' + message; - } - else - { - me.setElementText($element, message); - } - }; - /** * convert URLs to clickable links. * URLs to handle: @@ -367,7 +342,6 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * * @param {object} window * @param {object} document - * @name i18n * @class */ var i18n = (function (window, document) { @@ -516,17 +490,13 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } // if language is already used (e.g, default 'en'), skip update - if (newLanguage === language) - { - controller.init(); + if (newLanguage === language) { return; } // if language is not supported, show error - if (supportedLanguages.indexOf(newLanguage) === -1) - { + if (supportedLanguages.indexOf(newLanguage) === -1) { console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage); - controller.init(); } // load strongs from JSON @@ -536,54 +506,53 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { }).fail(function (data, textStatus, errorMsg) { console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg); }); - - controller.init(); }; return me; })(window, document); /** - * filter methods + * cryptTool methods * * @param {object} window * @param {object} document - * @name filter * @class */ - var filter = (function (window, document) { + var cryptTool = (function () { var me = {}; /** * compress a message (deflate compression), returns base64 encoded data * - * @name filter.compress + * @name cryptToolcompress * @function + * @private * @param {string} message * @return {string} base64 data */ - me.compress = function(message) + function compress(message) { return Base64.toBase64( RawDeflate.deflate( Base64.utob(message) ) ); - }, + } /** - * decompress a message compressed with filter.compress() + * decompress a message compressed with cryptToolcompress() * - * @name filter.decompress + * @name cryptTooldecompress * @function + * @private * @param {string} data - base64 data * @return {string} message */ - me.decompress = function(data) + function decompress(data) { return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) ); - }, + } /** * compress, then encrypt message with given key and password * - * @name filter.cipher + * @name cryptToolcipher * @function * @param {string} key * @param {string} password @@ -596,15 +565,15 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { var options = {mode: 'gcm', ks: 256, ts: 128}; if ((password || '').trim().length === 0) { - return sjcl.encrypt(key, me.compress(message), options); + return sjcl.encrypt(key, compress(message), options); } return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), me.compress(message), options); - }, + }; /** * decrypt message with key, then decompress * - * @name filter.decipher + * @name cryptTooldecipher * @function * @param {string} key * @param {string} password @@ -617,13 +586,13 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { try { - return me.decompress(sjcl.decrypt(key, data)); + return decompress(sjcl.decrypt(key, data)); } catch(err) { try { - return me.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); + return decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); } catch(e) { @@ -632,113 +601,35 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } } return ''; - } + }; return me; - })(window, document); + })(); /** - * PrivateBin logic + * User interface manager * * @param {object} window * @param {object} document - * @name controller * @class */ - var controller = (function (window, document) { + var uiMan = (function (window, document) { var me = {}; - /** - * headers to send in AJAX requests - * - * @private - * @enum {Object} - */ - var headers = {'X-Requested-With': 'JSONHttpRequest'}; - - /** - * URL shortners create address - * - * @private - * @prop {string} - */ - var shortenerUrl = ''; - - /** - * URL of newly created paste - * - * @private - * @prop {string} - */ - var createdPasteUrl = ''; - // jQuery pre-loaded objects - var $attach, - $attachment, - $attachmentLink, - $burnAfterReading, - $burnAfterReadingOption, - $cipherData, + var $cipherData, $clearText, - $cloneButton, $clonedFile, $comments, $discussion, - $errorMessage, - $expiration, - $fileRemoveButton, - $fileWrap, - $formatter, $image, - $loadingIndicator, - $message, - $messageEdit, - $messagePreview, - $newButton, - $openDisc, // @TODO: rename - too similar to openDiscussion, difference unclear - $openDiscussion, - $password, - $passwordInput, - $passwordModal, - $passwordForm, - $passwordDecrypt, $pasteResult, $pasteUrl, $prettyMessage, $prettyPrint, $preview, - $rawTextButton, $remainingTime, - $replyStatus, - $sendButton, - $status; - - /** - * ask the user for the password and set it - * - * @name controller.requestPassword - * @function - */ - me.requestPassword = function() - { - if ($passwordModal.length === 0) { - var password = prompt(i18n._('Please enter the password for this paste:'), ''); - if (password === null) - { - throw 'password prompt canceled'; - } - if (password.length === 0) - { - // recursive… - me.requestPassword(); - } else { - $passwordInput.val(password); - me.displayMessages(); - } - } else { - $passwordModal.modal(); - } - }; + $replyStatus; /** * use given format on paste, defaults to plain text @@ -827,7 +718,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { if (paste.attachment) { - var attachment = filter.decipher(key, password, paste.attachment); + var attachment = cryptTooldecipher(key, password, paste.attachment); if (attachment.length === 0) { if (password.length === 0) @@ -835,7 +726,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { me.requestPassword(); return; } - attachment = filter.decipher(key, password, paste.attachment); + attachment = cryptTooldecipher(key, password, paste.attachment); } if (attachment.length === 0) { @@ -844,7 +735,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { if (paste.attachmentname) { - var attachmentname = filter.decipher(key, password, paste.attachmentname); + var attachmentname = cryptTooldecipher(key, password, paste.attachmentname); if (attachmentname.length > 0) { $attachmentLink.attr('download', attachmentname); @@ -865,7 +756,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { $image.removeClass('hidden'); } } - var cleartext = filter.decipher(key, password, paste.data); + var cleartext = cryptTooldecipher(key, password, paste.data); if (cleartext.length === 0 && password.length === 0 && !paste.attachment) { me.requestPassword(); @@ -899,7 +790,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { 'This document will expire in %d ' + expiration[1] + '.', 'This document will expire in %d ' + expiration[1] + 's.' ]; - helper.setMessage($remainingTime, i18n._(expirationLabel, expiration[0])); + me.appendMessage($remainingTime, i18n._(expirationLabel, expiration[0])); $remainingTime.removeClass('foryoureyesonly') .removeClass('hidden'); } @@ -916,7 +807,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { .fail(function() { controller.showError(i18n._('Could not delete the paste, it was not stored in burn after reading mode.')); }); - helper.setMessage($remainingTime, i18n._( + me.appendMessage($remainingTime, i18n._( 'FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.' )); $remainingTime.addClass('foryoureyesonly') @@ -937,7 +828,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { var $place = $comments, comment = paste.comments[i], - commentText = filter.decipher(key, password, comment.data), + commentText = cryptTooldecipher(key, password, comment.data), $parentComment = $('#comment_' + comment.parentid); $divComment = $('

' + - '
' - ); - reply.find('button').click( + $reply = $('#replytemplate'); + $reply.find('button').click( {parentid: commentid}, me.sendComment ); - source.after(reply); - $replyStatus = $('#replystatus'); + source.after($reply); + $replyStatus = $('#replystatus'); // when ID --> put into HTML $('#replymessage').focus(); }; + /** + * replace last child of element with message + * + * @name me.appendMessage + * @function + * @param {jQuery} $element - a jQuery wrapped DOM element + * @param {string} message - the message to append + * @TODO: make private if possible + */ + me.appendMessage = function($element, message) + { + var content = $element.contents(); + if (content.length > 0) + { + content[content.length - 1].nodeValue = ' ' + message; + } + else + { + me.setElementText($element, message); + } + }; + + /** + * handle history (pop) state changes + * + * currently this does only handle redirects to the home page. + * + * @name controller.historyChange + * @function + * @param {Event} event + */ + me.historyChange = function(event) + { + var currentLocation = helper.scriptLocation(); + if (event.originalEvent.state === null && // no state object passed + event.originalEvent.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; + } + }; + + /** + * 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 controller.pasteLinkClick + * @function + * @param {Event} event + */ + me.pasteLinkClick = function(event) + { + // 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); + } + }; + + /** + * reload the page + * + * This takes the user to the PrivateBin home page. + * + * @name controller.reloadPage + * @function + * @param {Event} event + */ + me.reloadPage = function(event) + { + window.location.href = helper.scriptLocation(); + event.preventDefault(); + }; + + /** + * main UI manager + * + * @name controller.init + * @function + */ + me.init = function() + { + // hide "no javascript" message + $('#noscript').hide(); + + // preload jQuery elements + $cipherData = $('#cipherdata'); + $clearText = $('#cleartext'); + $clonedFile = $('#clonedfile'); + $comments = $('#comments'); + $discussion = $('#discussion'); + $errorMessage = $('#errormessage'); + $image = $('#image'); + $pasteResult = $('#pasteresult'); + // $pasteUrl is saved in sendDataContinue() if/after it is + // actually created + $prettyMessage = $('#prettymessage'); + $prettyPrint = $('#prettyprint'); + $preview = $('#preview'); + $remainingTime = $('#remainingtime'); + + // bind events + $('.reloadlink').click(me.reloadPage); + + // bootstrap template drop downs + $('ul.dropdown-menu li a', $('#expiration').parent()).click(me.setExpiration); + $('ul.dropdown-menu li a', $('#formatter').parent()).click(me.setFormat); + + $(window).on('popstate', me.historyChange); + }; + + return me; + })(window, document); + + /** + * UI state manager + * + * @param {object} window + * @param {object} document + * @class + */ + var state = (function (window, document) { + var me = {}; + + /** + * put the screen in "New paste" mode + * + * @name controller.stateNewPaste + * @function + */ + me.stateNewPaste = function() + { + $remainingTime.removeClass('hidden'); + + $loadingIndicator.addClass('hidden'); + console.error('stateNewPaste is depreciated'); + }; + + /** + * put the screen in mode after submitting a paste + * + * @name controller.stateSubmittingPaste + * @function + */ + me.stateSubmittingPaste = function() + { + console.error('stateSubmittingPaste is depreciated'); + }; + + /** + * put the screen in a state where the only option is to submit a + * new paste + * + * @name controller.stateOnlyNewPaste + * @function + */ + me.stateOnlyNewPaste = function() + { + console.error('stateOnlyNewPaste is depreciated'); + }; + + /** + * put the screen in "Existing paste" mode + * + * @name controller.stateExistingPaste + * @function + * @param {boolean} [preview=false] - (optional) tell if the preview tabs should be displayed, defaults to false + */ + me.stateExistingPaste = function(preview) + { + preview = preview || false; + console.error('stateExistingPaste is depreciated'); + + if (!preview) + { + // no "clone" for IE<10. + if ($('#oldienotice').is(":visible")) + { + $cloneButton.addClass('hidden'); + } + else + { + $cloneButton.removeClass('hidden'); + } + + console.log('show no preview'); + } + }; + + return me; + })(window, document); + + /** + * UI status/error manager + * + * @param {object} window + * @param {object} document + * @class + */ + var status = (function (window, document) { + var me = {}; + + var $errorMessage, + $status, + $loadingIndicator; + + /** + * display a status message + * + * @name controller.showStatus + * @function + * @param {string} message - text to display + * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false + */ + me.showStatus = function(message, spin) + { + // spin is ignored for now + $status.text(message); + }; + + // @TODO: add showLoading() + + /** + * display a status message for replying to comments + * + * @name controller.showStatus + * @function + * @param {string} message - text to display + * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false + */ + me.showReplyStatus = function(message, spin) + { + if (spin || false) { + $replyStatus.find('.spinner').removeClass('hidden') + } + $replyStatus.text(message); + }; + + /** + * hides any status messages + * + * @name controller.hideSTatus + * @function + */ + me.hideStatus = function() + { + $status.html(' '); + }; + + /** + * display an error message + * + * @name controller.showError + * @function + * @param {string} message - text to display + */ + me.showError = function(message) + { + $errorMessage.removeClass('hidden'); + me.appendMessage($errorMessage, message); + }; + + /** + * display an error message + * + * @name controller.showError + * @function + * @param {string} message - text to display + */ + me.showReplyError = function(message) + { + $replyStatus.addClass('alert-danger'); + $replyStatus.addClass($errorMessage.attr('class')); // @TODO ???? + + $replyStatus.text(message); + }; + + /** + * init status manager + * + * preloads jQuery elements + * + * @name controller.init + * @function + */ + me.init = function() + { + // hide "no javascript" message + $('#noscript').hide(); + + $loadingIndicator = $('#loadingindicator'); // TODO: integrate $loadingIndicator into this module or leave it in state and remove it here + $errorMessage = $('#errormessage'); + $status = $('#status'); + // @TODO $replyStatus … + + // display status returned by php code, if any (eg. paste was properly deleted) + // @TODO remove this by handling errors in a different way + if ($status.text().length > 0) + { + me.showStatus($status.text()); + return; + } + + // keep line height even if content empty + $status.html(' '); // @TODO what? remove? + }; + + return me; + })(window, document); + + /** + * Passwort modal manager + * + * @param {object} window + * @param {object} document + * @name modal + * @class + */ + var modal = (function (window, document) { + var me = {}; + + var $password, + $passwordInput, + $passwordModal, + $passwordForm, + $passwordDecrypt; + + /** + * ask the user for the password and set it + * + * @name controller.requestPassword + * @function + */ + me.requestPassword = function() + { + if ($passwordModal.length === 0) { + var password = prompt(i18n._('Please enter the password for this paste:'), ''); + if (password === null) + { + throw 'password prompt canceled'; + } + if (password.length === 0) + { + // recursive… + me.requestPassword(); + } else { + $passwordInput.val(password); + me.displayMessages(); + } + } else { + $passwordModal.modal(); + } + }; + + /** + * decrypt using the password from the modal dialog + * + * @name controller.decryptPasswordModal + * @function + */ + me.decryptPasswordModal = function() + { + $passwordInput.val($passwordDecrypt.val()); + me.displayMessages(); + }; + + /** + * submit a password in the modal dialog + * + * @name controller.submitPasswordModal + * @function + * @param {Event} event + */ + me.submitPasswordModal = function(event) + { + event.preventDefault(); + $passwordModal.modal('hide'); + }; + + + /** + * init status manager + * + * preloads jQuery elements + * + * @name controller.init + * @function + */ + me.init = function() + { + $password = $('#password'); + $passwordInput = $('#passwordinput'); + $passwordModal = $('#passwordmodal'); + $passwordForm = $('#passwordform'); + $passwordDecrypt = $('#passworddecrypt'); + + // bind events + + // focus password input when it is shown + $passwordModal.on('shown.bs.modal', function () { + $passwordDecrypt.focus(); + }); + // handle modal password request on decryption + $passwordModal.on('hidden.bs.modal', me.decryptPasswordModal); + $passwordForm.submit(me.submitPasswordModal); + }; + + return me; + })(window, document); + + /** + * Manage paste/message input + * + * @param {object} window + * @param {object} document + * @class + */ + var editor = (function (window, document) { + var me = {}; + + var $message, + $messageEdit, + $messagePreview; + + /** + * support input of tab character + * + * @name editor.supportTabs + * @function + * @param {Event} event + * @TODO doc what is @this here? + * @TODO replace this with $message ?? + */ + function supportTabs(event) + { + var keyCode = event.keyCode || event.which; + // tab was pressed + if (keyCode === 9) + { + // prevent the textarea to lose focus + event.preventDefault(); + // get caret position & selection + var 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; + } + } + + /** + * view the editor tab + * + * @name editor.viewEditor + * @function + * @param {Event} event + */ + function viewEditor(event) + { + $messagePreview.parent().removeClass('active'); + $messageEdit.parent().addClass('active'); + $message.focus(); + me.stateNewPaste(); + + event.preventDefault(); + } + + /** + * view the preview tab + * + * @name editor.viewPreview + * @function + * @param {Event} event + */ + function viewPreview(event) + { + $messageEdit.parent().removeClass('active'); + $messagePreview.parent().addClass('active'); + $message.focus(); + me.stateExistingPaste(true); + me.formatPaste($('#pasteFormatter').val(), $message.val()); + + event.preventDefault(); + } + + /** + * reset the editor view + * + * @name editor.reset + * @function + */ + me.reset = function() + { + // clear content + $message.text(''); + }; + + /** + * shows the editor + * + * @name editor.show + * @function + */ + me.show = function() + { + $attachment.removeClass('hidden'); + $clearText.removeClass('hidden'); + $discussion.removeClass('hidden'); + $pasteResult.removeClass('hidden'); //?? + // $prettyMessage.removeClass('hidden'); + $remainingTime.removeClass('hidden'); + }; + + /** + * hides the editor + * + * @name editor.reset + * @function + */ + me.hide = function() + { + $attachment.addClass('hidden'); + $clearText.addClass('hidden'); + $discussion.addClass('hidden'); + $pasteResult.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $remainingTime.addClass('hidden'); + }; + + /** + * focuses the message input + * + * @name editor.focus + * @function + */ + me.focus = function() + { + $message.focus(); + }; + + /** + * init status manager + * + * preloads jQuery elements + * + * @name editor.init + * @function + */ + me.init = function() + { + $message = $('#message'); + $messageEdit = $('#messageedit'); + $messagePreview = $('#messagepreview'); + + // bind events + $message.keydown(supportTabs); + $messageEdit.click(viewEditor); + $messagePreview.click(viewPreview); + }; + + return me; + })(window, document); + + /** + * Manage top (navigation) bar + * + * @param {object} window + * @param {object} document + * @name state + * @class + */ + var topNav = (function (window, document) { + var me = {}; + + var $attach, + $attachment, + $attachmentLink, + $burnAfterReading, + $burnAfterReadingOption, + $cloneButton, + $expiration, + $fileRemoveButton, + $fileWrap, + $formatter, + $newButton, + $openDisc, // @TODO: rename - too similar to openDiscussion, difference unclear + $openDiscussion, + $rawTextButton, + $sendButton; + + /** + * set the expiration on bootstrap templates + * + * @name topNav.setExpiration + * @function + * @param {Event} event + */ + function setExpiration(event) + { + event.preventDefault(); + var target = $(event.target); + $('#pasteExpiration').val(target.data('expiration')); + $('#pasteExpirationDisplay').text(target.text()); + } + + /** + * set the format on bootstrap templates + * + * @name topNav.setFormat + * @function + * @param {Event} event + */ + me.setFormat = function(event) + { + var target = $(event.target); + $('#pasteFormatter').val(target.data('format')); + $('#pasteFormatterDisplay').text(target.text()); + + if ($messagePreview.parent().hasClass('active')) { + me.viewPreview(event); + } + event.preventDefault(); + }; + + /** + * when "burn after reading" is checked, disable discussion + * + * @name topNav.changeBurnAfterReading + * @function + */ + function changeBurnAfterReading() + { + if ($burnAfterReading.is(':checked') ) + { + $openDisc.addClass('buttondisabled'); + $openDiscussion.attr({checked: false, disabled: true}); + } + else + { + $openDisc.removeClass('buttondisabled'); + $openDiscussion.removeAttr('disabled'); + } + } + + /** + * when discussion is checked, disable "burn after reading" + * + * @name topNav.changeOpenDisc + * @function + */ + function changeOpenDisc() + { + if ($openDiscussion.is(':checked') ) + { + $burnAfterReadingOption.addClass('buttondisabled'); + $burnAfterReading.attr({checked: false, disabled: true}); + } + else + { + $burnAfterReadingOption.removeClass('buttondisabled'); + $burnAfterReading.removeAttr('disabled'); + } + } + + /** + * return raw text + * + * @name topNav.rawText + * @function + * @param {Event} event + */ + function rawText(event) + { + var paste = $('#pasteFormatter').val() === 'markdown' ? + $prettyPrint.text() : $clearText.text(); + history.pushState( + null, document.title, helper.scriptLocation() + '?' + + helper.pasteId() + '#' + helper.pageKey() + ); + // we use text/html instead of text/plain to avoid a bug when + // reloading the raw text view (it reverts to type text/html) + var newDoc = document.open('text/html', 'replace'); + newDoc.write('
' + helper.htmlEntities(paste) + '
'); + newDoc.close(); + + event.preventDefault(); + } + + /** + * set the language in a cookie and reload the page + * + * @name topNav.setLanguage + * @function + * @param {Event} event + */ + function setLanguage(event) + { + document.cookie = 'lang=' + $(event.target).data('lang'); + me.reloadPage(event); + } + + /** + * removes an attachment + * + * @name controller.removeAttachment + * @function + */ + me.removeAttachment = function() + { + $clonedFile.addClass('hidden'); + // removes the saved decrypted file data + $attachmentLink.attr('href', ''); + // the only way to deselect the file is to recreate the input // @TODO really? + $fileWrap.html($fileWrap.html()); + $fileWrap.removeClass('hidden'); + }; + + /** + * Shows all elements belonging to viwing an existing pastes + * + * @name topNav.hideAllElem + * @function + */ + me.showViewButtons = function() + { + $cloneButton.removeClass('hidden'); + $rawTextButton.removeClass('hidden'); + }; + + /** + * Hides all elements belonging to existing pastes + * + * @name topNav.hideAllElem + * @function + */ + me.hideViewButtons = function() + { + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + }; + + /** + * shows all elements needed when creating a new paste + * + * @name topNav.setLanguage + * @function + */ + me.showCreateButtons = function() + { + $sendButton.removeClass('hidden'); + $expiration.removeClass('hidden'); + $formatter.removeClass('hidden'); + $burnAfterReadingOption.removeClass('hidden'); + $openDisc.removeClass('hidden'); + $newButton.removeClass('hidden'); + $password.removeClass('hidden'); + $attach.removeClass('hidden'); + $message.removeClass('hidden'); + $preview.removeClass('hidden'); + }; + + /** + * shows all elements needed when creating a new paste + * + * @name topNav.setLanguage + * @function + */ + me.hideCreateButtons = function() + { + $sendButton.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDisc.addClass('hidden'); + $newButton.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + $message.addClass('hidden'); + $preview.addClass('hidden'); + }; + + /** + * only shows the "new paste" button + * + * @name topNav.setLanguage + * @function + */ + me.showNewPasteButton = function() + { + $newButton.addClass('hidden'); + }; + + /** + * shows a loading message, optionally with a percentage + * + * @name topNav.showLoading + * @function + * @param {string} message + * @param {int} percentage + */ + me.showLoading = function(message, percentage) + { + // currently parameters are ignored + $loadingIndicator.removeClass('hidden'); + }; + + /** + * hides the loading message + * + * @name topNav.hideLoading + * @function + */ + me.hideLoading = function() + { + $loadingIndicator.removeClass('hidden'); + }; + + /** + * init navigation manager + * + * preloads jQuery elements + * + * @name topNav.init + * @function + */ + me.init = function() + { + $attach = $('#attach'); + $attachment = $('#attachment'); + $attachmentLink = $('#attachment a'); + $burnAfterReading = $('#burnafterreading'); + $burnAfterReadingOption = $('#burnafterreadingoption'); + $cloneButton = $('#clonebutton'); + $expiration = $('#expiration'); + $fileRemoveButton = $('#fileremovebutton'); + $fileWrap = $('#filewrap'); + $formatter = $('#formatter'); + $newButton = $('#newbutton'); + $openDisc = $('#opendisc'); + $openDiscussion = $('#opendiscussion'); + $rawTextButton = $('#rawtextbutton'); + $sendButton = $('#sendbutton'); + + // bootstrap template drop down + $('#language ul.dropdown-menu li a').click(me.setLanguage); + // page template drop down + $('#language select option').click(me.setLanguage); + + // bind events + $burnAfterReading.change(changeBurnAfterReading); + $openDisc.change(changeOpenDisc); + $sendButton.click(controller.sendData); + $cloneButton.click(controller.clonePaste); + $rawTextButton.click(me.rawText); + $fileRemoveButton.click(me.removeAttachment); + + // initiate default state of checkboxes + changeBurnAfterReading(); + changeOpenDisc(); + }; + + return me; + })(window, document); + + /** + * PrivateBin logic + * + * @param {object} window + * @param {object} document + * @name controller + * @class + */ + var controller = (function (window, document) { + var me = {}; + + /** + * headers to send in AJAX requests + * + * @private + * @enum {Object} + */ + var headers = {'X-Requested-With': 'JSONHttpRequest'}; + + /** + * URL shortners create address + * + * @private + * @prop {string} + */ + var shortenerUrl = ''; + + /** + * URL of newly created paste + * + * @private + * @prop {string} + */ + var createdPasteUrl = ''; + /** * send a reply in a discussion * @@ -1052,12 +1839,12 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { me.showStatus(i18n._('Sending comment...'), true); var parentid = event.data.parentid, key = helper.pageKey(), - cipherdata = filter.cipher(key, $passwordInput.val(), replyMessage.val()), + cipherdata = cryptToolcipher(key, $passwordInput.val(), replyMessage.val()), ciphernickname = '', nick = $('#nickname').val(); if (nick.length > 0) { - ciphernickname = filter.cipher(key, $passwordInput.val(), nick); + ciphernickname = cryptToolcipher(key, $passwordInput.val(), nick); } var data_to_send = { data: cipherdata, @@ -1170,8 +1957,8 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { return function(e) { controller.sendDataContinue( randomkey, - filter.cipher(randomkey, password, e.target.result), - filter.cipher(randomkey, password, theFile.name) + cryptToolcipher(randomkey, password, e.target.result), + cryptToolcipher(randomkey, password, theFile.name) ); }; })(files[0]); @@ -1181,7 +1968,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { { me.sendDataContinue( randomkey, - filter.cipher(randomkey, password, $attachmentLink.attr('href')), + cryptToolcipher(randomkey, password, $attachmentLink.attr('href')), $attachmentLink.attr('download') ); } @@ -1202,7 +1989,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { */ me.sendDataContinue = function(randomkey, cipherdata_attachment, cipherdata_attachment_name) { - var cipherdata = filter.cipher(randomkey, $passwordInput.val(), $message.val()), + var cipherdata = cryptToolcipher(randomkey, $passwordInput.val(), $message.val()), data_to_send = { data: cipherdata, expire: $('#pasteExpiration').val(), @@ -1230,7 +2017,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { me.stateExistingPaste(); var url = helper.scriptLocation() + '?' + data.id + '#' + randomkey, deleteUrl = helper.scriptLocation() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; - me.showStatus(''); + me.hideStatus(); $errorMessage.addClass('hidden'); // show new URL in browser bar history.pushState({type: 'newpaste'}, document.title, url); @@ -1254,7 +2041,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { $pasteResult.removeClass('hidden'); // we pre-select the link so that the user only has to [Ctrl]+[c] the link helper.selectText($pasteUrl[0]); - me.showStatus(''); + me.hideStatus(); me.formatPaste(data_to_send.formatter, $message.val()); } else if (data.status === 1) @@ -1298,181 +2085,6 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { return ''; }; - /** - * put the screen in "New paste" mode - * - * @name controller.stateNewPaste - * @function - */ - me.stateNewPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - $sendButton.removeClass('hidden'); - $expiration.removeClass('hidden'); - $formatter.removeClass('hidden'); - $burnAfterReadingOption.removeClass('hidden'); - $openDisc.removeClass('hidden'); - $newButton.removeClass('hidden'); - $password.removeClass('hidden'); - $attach.removeClass('hidden'); - $message.removeClass('hidden'); - $preview.removeClass('hidden'); - $message.focus(); - }; - - /** - * put the screen in mode after submitting a paste - * - * @name controller.stateSubmittingPaste - * @function - */ - me.stateSubmittingPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $sendButton.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $newButton.addClass('hidden'); - $password.addClass('hidden'); - $attach.addClass('hidden'); - $message.addClass('hidden'); - $preview.addClass('hidden'); - - $loadingIndicator.removeClass('hidden'); - }; - - /** - * put the screen in a state where the only option is to submit a - * new paste - * - * @name controller.stateOnlyNewPaste - * @function - */ - me.stateOnlyNewPaste = function() - { - $message.text(''); - $attachment.addClass('hidden'); - $cloneButton.addClass('hidden'); - $rawTextButton.addClass('hidden'); - $remainingTime.addClass('hidden'); - $pasteResult.addClass('hidden'); - $clearText.addClass('hidden'); - $discussion.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $sendButton.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $password.addClass('hidden'); - $attach.addClass('hidden'); - $message.addClass('hidden'); - $preview.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - - $newButton.removeClass('hidden'); - }; - - /** - * put the screen in "Existing paste" mode - * - * @name controller.stateExistingPaste - * @function - * @param {boolean} [preview=false] - (optional) tell if the preview tabs should be displayed, defaults to false - */ - me.stateExistingPaste = function(preview) - { - preview = preview || false; - - if (!preview) - { - // no "clone" for IE<10. - if ($('#oldienotice').is(":visible")) - { - $cloneButton.addClass('hidden'); - } - else - { - $cloneButton.removeClass('hidden'); - } - - $rawTextButton.removeClass('hidden'); - $sendButton.addClass('hidden'); - $attach.addClass('hidden'); - $expiration.addClass('hidden'); - $formatter.addClass('hidden'); - $burnAfterReadingOption.addClass('hidden'); - $openDisc.addClass('hidden'); - $newButton.removeClass('hidden'); - $preview.addClass('hidden'); - } - - $pasteResult.addClass('hidden'); - $message.addClass('hidden'); - $clearText.addClass('hidden'); - $prettyMessage.addClass('hidden'); - $loadingIndicator.addClass('hidden'); - }; - - /** - * when "burn after reading" is checked, disable discussion - * - * @name controller.changeBurnAfterReading - * @function - */ - me.changeBurnAfterReading = function() - { - if ($burnAfterReading.is(':checked') ) - { - $openDisc.addClass('buttondisabled'); - $openDiscussion.attr({checked: false, disabled: true}); - } - else - { - $openDisc.removeClass('buttondisabled'); - $openDiscussion.removeAttr('disabled'); - } - }; - - /** - * when discussion is checked, disable "burn after reading" - * - * @name controller.changeOpenDisc - * @function - */ - me.changeOpenDisc = function() - { - if ($openDiscussion.is(':checked') ) - { - $burnAfterReadingOption.addClass('buttondisabled'); - $burnAfterReading.attr({checked: false, disabled: true}); - } - else - { - $burnAfterReadingOption.removeClass('buttondisabled'); - $burnAfterReading.removeAttr('disabled'); - } - }; - /** * forward to URL shortener * @@ -1486,45 +2098,6 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { event.preventDefault(); }; - /** - * reload the page - * - * This takes the user to the PrivateBin home page. - * - * @name controller.reloadPage - * @function - * @param {Event} event - */ - me.reloadPage = function(event) - { - window.location.href = helper.scriptLocation(); - event.preventDefault(); - }; - - /** - * return raw text - * - * @name controller.rawText - * @function - * @param {Event} event - */ - me.rawText = function(event) - { - var paste = $('#pasteFormatter').val() === 'markdown' ? - $prettyPrint.text() : $clearText.text(); - history.pushState( - null, document.title, helper.scriptLocation() + '?' + - helper.pasteId() + '#' + helper.pageKey() - ); - // we use text/html instead of text/plain to avoid a bug when - // reloading the raw text view (it reverts to type text/html) - var newDoc = document.open('text/html', 'replace'); - newDoc.write('
' + helper.htmlEntities(paste) + '
'); - newDoc.close(); - - event.preventDefault(); - }; - /** * clone the current paste * @@ -1534,13 +2107,12 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { */ me.clonePaste = function(event) { - event.preventDefault(); me.stateNewPaste(); // erase the id and the key in url history.replaceState(null, document.title, helper.scriptLocation()); - me.showStatus(''); + status.hideStatus(); if ($attachmentLink.attr('href')) { $clonedFile.removeClass('hidden'); @@ -1551,157 +2123,10 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { $prettyPrint.text() : $clearText.text() ); $('.navbar-toggle').click(); - }; - - /** - * set the expiration on bootstrap templates - * - * @name controller.setExpiration - * @function - * @param {Event} event - */ - me.setExpiration = function(event) - { - event.preventDefault(); - var target = $(event.target); - $('#pasteExpiration').val(target.data('expiration')); - $('#pasteExpirationDisplay').text(target.text()); - }; - - /** - * set the format on bootstrap templates - * - * @name controller.setFormat - * @function - * @param {Event} event - */ - me.setFormat = function(event) - { - var target = $(event.target); - $('#pasteFormatter').val(target.data('format')); - $('#pasteFormatterDisplay').text(target.text()); - - if ($messagePreview.parent().hasClass('active')) { - me.viewPreview(event); - } - event.preventDefault(); - }; - - /** - * set the language in a cookie and reload the page - * - * @name controller.setLanguage - * @function - * @param {Event} event - */ - me.setLanguage = function(event) - { - document.cookie = 'lang=' + $(event.target).data('lang'); - me.reloadPage(event); - }; - - /** - * support input of tab character - * - * @name controller.supportTabs - * @function - * @param {Event} event - * @TODO doc what is @this here? - */ - me.supportTabs = function(event) - { - var keyCode = event.keyCode || event.which; - // tab was pressed - if (keyCode === 9) - { - // prevent the textarea to lose focus - event.preventDefault(); - // get caret position & selection - var 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; - } - }; - - /** - * view the editor tab - * - * @name controller.viewEditor - * @function - * @param {Event} event - */ - me.viewEditor = function(event) - { - $messagePreview.parent().removeClass('active'); - $messageEdit.parent().addClass('active'); - $message.focus(); - me.stateNewPaste(); event.preventDefault(); }; - /** - * view the preview tab - * - * @name controller.viewPreview - * @function - * @param {Event} event - */ - me.viewPreview = function(event) - { - $messageEdit.parent().removeClass('active'); - $messagePreview.parent().addClass('active'); - $message.focus(); - me.stateExistingPaste(true); - me.formatPaste($('#pasteFormatter').val(), $message.val()); - - event.preventDefault(); - }; - - /** - * handle history (pop) state changes - * - * currently this does only handle redirects to the home page. - * - * @name controller.historyChange - * @function - * @param {Event} event - */ - me.historyChange = function(event) - { - var currentLocation = helper.scriptLocation(); - if (event.originalEvent.state === null && // no state object passed - event.originalEvent.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; - } - }; - - /** - * 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 controller.pasteLinkClick - * @function - * @param {Event} event - */ - me.pasteLinkClick = function(event) - { - // 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); - } - }; - /** * create a new paste * @@ -1711,222 +2136,23 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { me.newPaste = function() { me.stateNewPaste(); - me.showStatus(''); + me.hideStatus(); $message.text(''); - me.changeBurnAfterReading(); - me.changeOpenDisc(); }; /** - * removes an attachment - * - * @name controller.removeAttachment - * @function - */ - me.removeAttachment = function() - { - $clonedFile.addClass('hidden'); - // removes the saved decrypted file data - $attachmentLink.attr('href', ''); - // the only way to deselect the file is to recreate the input // @TODO really? - $fileWrap.html($fileWrap.html()); - $fileWrap.removeClass('hidden'); - }; - - /** - * decrypt using the password from the modal dialog - * - * @name controller.decryptPasswordModal - * @function - */ - me.decryptPasswordModal = function() - { - $passwordInput.val($passwordDecrypt.val()); - me.displayMessages(); - }; - - /** - * submit a password in the modal dialog - * - * @name controller.submitPasswordModal - * @function - * @param {Event} event - */ - me.submitPasswordModal = function(event) - { - event.preventDefault(); - $passwordModal.modal('hide'); - }; - - /** - * display an error message, - * we use the same function for paste and reply to comments - * - * @name controller.showError - * @function - * @param {string} message - text to display - */ - me.showError = function(message) - { - if ($status.length) - { - $status.addClass('errorMessage').text(message); - } - else - { - $errorMessage.removeClass('hidden'); - helper.setMessage($errorMessage, message); - } - if (typeof $replyStatus !== 'undefined') { - $replyStatus.addClass('errorMessage'); - $replyStatus.addClass($errorMessage.attr('class')); - if ($status.length) - { - $replyStatus.html($status.html()); - } - else - { - $replyStatus.html($errorMessage.html()); - } - } - }; - - /** - * display a status message, - * we use the same function for paste and reply to comments - * - * @name controller.showStatus - * @function - * @param {string} message - text to display - * @param {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false - */ - me.showStatus = function(message, spin) - { - if (spin || false) - { - var img = ''; - $status.prepend(img); - if (typeof $replyStatus !== 'undefined') { - $replyStatus.prepend(img); - } - } - if (typeof $replyStatus !== 'undefined') { - $replyStatus.removeClass('errorMessage').text(message); - } - if (!message) - { - $status.html(' '); - return; - } - if (message === '') - { - $status.html(' '); - return; - } - $status.removeClass('errorMessage').text(message); - }; - - /** - * bind events to DOM elements - * - * @private - * @function - */ - function bindEvents() - { - $burnAfterReading.change(me.changeBurnAfterReading); - $openDisc.change(me.changeOpenDisc); - $sendButton.click(me.sendData); - $cloneButton.click(me.clonePaste); - $rawTextButton.click(me.rawText); - $fileRemoveButton.click(me.removeAttachment); - $('.reloadlink').click(me.reloadPage); - $message.keydown(me.supportTabs); - $messageEdit.click(me.viewEditor); - $messagePreview.click(me.viewPreview); - - // bootstrap template drop downs - $('ul.dropdown-menu li a', $('#expiration').parent()).click(me.setExpiration); - $('ul.dropdown-menu li a', $('#formatter').parent()).click(me.setFormat); - $('#language ul.dropdown-menu li a').click(me.setLanguage); - - // page template drop down - $('#language select option').click(me.setLanguage); - - // focus password input when it is shown - $passwordModal.on('shown.bs.modal', function () { - $passwordDecrypt.focus(); - }); - // handle modal password request on decryption - $passwordModal.on('hidden.bs.modal', me.decryptPasswordModal); - $passwordForm.submit(me.submitPasswordModal); - - $(window).on('popstate', me.historyChange); - }; - - /** - * main application + * application start * * @name controller.init * @function */ me.init = function() { - // hide "no javascript" message - $('#noscript').hide(); + // first load translations + i18n.loadTranslations(); - // preload jQuery wrapped DOM elements and bind events - $attach = $('#attach'); - $attachment = $('#attachment'); - $attachmentLink = $('#attachment a'); - $burnAfterReading = $('#burnafterreading'); - $burnAfterReadingOption = $('#burnafterreadingoption'); - $cipherData = $('#cipherdata'); - $clearText = $('#cleartext'); - $cloneButton = $('#clonebutton'); - $clonedFile = $('#clonedfile'); - $comments = $('#comments'); - $discussion = $('#discussion'); - $errorMessage = $('#errormessage'); - $expiration = $('#expiration'); - $fileRemoveButton = $('#fileremovebutton'); - $fileWrap = $('#filewrap'); - $formatter = $('#formatter'); - $image = $('#image'); - $loadingIndicator = $('#loadingindicator'); - $message = $('#message'); - $messageEdit = $('#messageedit'); - $messagePreview = $('#messagepreview'); - $newButton = $('#newbutton'); - $openDisc = $('#opendisc'); - $openDiscussion = $('#opendiscussion'); - $password = $('#password'); - $passwordInput = $('#passwordinput'); - $passwordModal = $('#passwordmodal'); - $passwordForm = $('#passwordform'); - $passwordDecrypt = $('#passworddecrypt'); - $pasteResult = $('#pasteresult'); - // $pasteUrl is saved in sendDataContinue() if/after it is - // actually created - $prettyMessage = $('#prettymessage'); - $prettyPrint = $('#prettyprint'); - $preview = $('#preview'); - $rawTextButton = $('#rawtextbutton'); - $remainingTime = $('#remainingtime'); - // $replyStatus is saved in openReply() - $sendButton = $('#sendbutton'); - $status = $('#status'); - bindEvents(); - - // display status returned by php code, if any (eg. paste was properly deleted) - if ($status.text().length > 0) - { - me.showStatus($status.text()); - return; - } - - // keep line height even if content empty - $status.html(' '); + // init UI @TODO show loading + uiMan.init(); // display an existing paste if ($cipherData.text().length > 1) @@ -1959,9 +2185,9 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { /** * main application start, called when DOM is fully loaded and - * runs controller initalization after translations are loaded + * runs controller initalization */ - $(i18n.loadTranslations); + $(controller.init); return { helper: helper, diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index 698d3594..a2f5272b 100644 --- a/tpl/bootstrap.php +++ b/tpl/bootstrap.php @@ -404,19 +404,14 @@ if ($FILEUPLOAD):
-
-
+ if ($isCpct): + ?>
+
+
- + - + - + - - - - - -
+
+if ($isCpct): +?>
- +
@@ -465,12 +464,18 @@ endif;
diff --git a/tpl/page.php b/tpl/page.php index 4ae0b6a7..d7043bcf 100644 --- a/tpl/page.php +++ b/tpl/page.php @@ -47,7 +47,7 @@ if ($MARKDOWN): - + @@ -125,7 +125,7 @@ endif; - #s', + '#]*id="status"[^>]*>.*Paste was properly deleted\.assertRegExp( - '#]*id="errormessage"[^>]*>.*Invalid paste ID\.#', + '#]*id="errormessage"[^>]*>.*Invalid paste ID\.assertRegExp( - '#]*id="errormessage"[^>]*>.*Paste does not exist[^<]*#', + '#]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.assertRegExp( - '#]*id="errormessage"[^>]*>.*Wrong deletion token[^<]*#', + '#]*id="errormessage"[^>]*>.*Wrong deletion token\. Paste was not deleted\.assertRegExp( - '#]*id="errormessage"[^>]*>.*Paste does not exist[^<]*#', + '#]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.assertRegExp( - '#]*id="status"[^>]*>.*Paste was properly deleted[^<]*#s', + '#]*id="status"[^>]*>.*Paste was properly deleted\.assertRegExp( - '#]+id="errormessage"[^>]*>.*' . self::$error . '#', + '#]+id="errormessage"[^>]*>.*' . self::$error . ' Date: Mon, 6 Mar 2017 19:48:07 +0100 Subject: [PATCH 20/29] found problem with unit test of baseUri function, makes code much simpler --- js/privatebin.js | 16 +--------------- js/test.js | 8 +++++--- tpl/bootstrap.php | 2 +- tpl/page.php | 2 +- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/js/privatebin.js b/js/privatebin.js index 573ec9f0..f43e3345 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -276,21 +276,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { return baseUri; } - // window.baseURI isn't emulated by JSdom - var loc = window.location; - baseUri = loc.href.substring( - 0, - loc.href.length - loc.search.length - loc.hash.length - ); - - // if base uri contains query string (when no base tag is present), - // it is unwanted - var queryIndex = baseUri.indexOf('?'); - if (queryIndex !== -1) { - // so we built our own baseuri - baseUri = baseUri.substring(0, queryIndex); - } - + baseUri = window.location.origin + window.location.pathname; return baseUri; } diff --git a/js/test.js b/js/test.js index 31da19fe..e83d43bb 100644 --- a/js/test.js +++ b/js/test.js @@ -11,7 +11,9 @@ var jsc = require('jsverify'), a2zString.map(function(c) { return c.toUpperCase(); }) - ); + ), + // schemas supported by the whatwg-url library + schemas = ['ftp','gopher','http','https','ws','wss']; global.$ = global.jQuery = require('./jquery-3.1.1'); global.sjcl = require('./sjcl-1.0.6'); @@ -73,12 +75,12 @@ describe('Helper', function () { jsc.property( 'returns the URL without query & fragment', - jsc.nearray(jsc.elements(a2zString)), + jsc.elements(schemas), jsc.nearray(jsc.elements(a2zString)), jsc.array(jsc.elements(queryString)), 'string', function (schema, address, query, fragment) { - var expected = schema.join('') + '://' + address.join('') + '/', + var expected = schema + '://' + address.join('') + '/', clean = jsdom('', {url: expected + '?' + query.join('') + '#' + fragment}), result = $.PrivateBin.Helper.baseUri(); $.PrivateBin.Helper.reset(); diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index 0c0e51c5..4dd63897 100644 --- a/tpl/bootstrap.php +++ b/tpl/bootstrap.php @@ -69,7 +69,7 @@ if ($MARKDOWN): - + diff --git a/tpl/page.php b/tpl/page.php index 25db1e52..8ea25d7e 100644 --- a/tpl/page.php +++ b/tpl/page.php @@ -47,7 +47,7 @@ if ($MARKDOWN): - + From 97171ec1f8920012157efbc87100ca4e489dec2d Mon Sep 17 00:00:00 2001 From: El RIDO Date: Mon, 6 Mar 2017 20:10:10 +0100 Subject: [PATCH 21/29] updated eslint config file format and loosening complexity and max-statements to reduce mail flood --- .eslintrc | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.eslintrc b/.eslintrc index a5e0c90e..e2a42cc7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,7 +15,9 @@ globals: # http://eslint.org/docs/rules/ rules: # Possible Errors - comma-dangle: [2, never] + comma-dangle: + - error + - never no-cond-assign: 2 no-console: 0 no-constant-condition: 2 @@ -31,7 +33,9 @@ rules: no-extra-parens: 0 no-extra-semi: 2 no-func-assign: 2 - no-inner-declarations: [2, functions] + no-inner-declarations: + - error + - functions no-invalid-regexp: 2 no-irregular-whitespace: 2 no-negated-in-lhs: 2 @@ -47,7 +51,9 @@ rules: # Best Practices accessor-pairs: 2 block-scoped-var: 0 - complexity: [2, 6] + complexity: + - error + - 20 consistent-return: 0 curly: 0 default-case: 0 @@ -152,7 +158,9 @@ rules: max-len: 0 max-nested-callbacks: 0 max-params: 0 - max-statements: [2, 30] + max-statements: + - error + - 60 new-cap: 0 new-parens: 0 newline-after-var: 0 From 81b00dd42246078cdd88c60ee910fc0db4a7af33 Mon Sep 17 00:00:00 2001 From: El RIDO Date: Sun, 12 Mar 2017 14:16:08 +0100 Subject: [PATCH 22/29] fixing page template, removing error messages when markdown or source are disabled in configuration, re-removing unnecessary spans --- css/bootstrap/privatebin.css | 7 ----- js/privatebin.js | 50 ++++++++++++++++++++---------------- tpl/bootstrap.php | 42 ++++++++++++++++++++---------- tpl/page.php | 23 ++++++++++++++--- tst/PrivateBinTest.php | 18 ++++++------- tst/ViewTest.php | 2 +- 6 files changed, 86 insertions(+), 56 deletions(-) diff --git a/css/bootstrap/privatebin.css b/css/bootstrap/privatebin.css index 3e8cbb2b..ded82590 100644 --- a/css/bootstrap/privatebin.css +++ b/css/bootstrap/privatebin.css @@ -102,15 +102,10 @@ body.loading { margin-bottom: 10px; } -.pl-1::before { - content: " "; -} - .comment { border-left: 1px solid #ccc; padding: 5px 0 5px 10px; white-space: pre-wrap; - transition: background-color 0.75s ease-out; } @@ -119,8 +114,6 @@ body.loading { transition: background-color 0.2s ease-in; } - - footer h4 { margin-top: 0; } diff --git a/js/privatebin.js b/js/privatebin.js index f43e3345..8d41732f 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -995,7 +995,8 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { var $errorMessage, $loadingIndicator, - $statusMessage; + $statusMessage, + $remainingTime; var currentIcon = [ 'glyphicon-time', // loading icon @@ -1036,7 +1037,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { args = [args]; } - // pass to custom handler if dfined + // pass to custom handler if defined if (typeof customHandler === 'function') { var handlerResult = customHandler(alertType[id], $element, args, icon); if (handlerResult === true) { @@ -1069,11 +1070,13 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // show text if (args !== null) { - // add jQuery object to it as first parameter - args.unshift($element.find(':last')); - - // pass it to I18n - I18n._.apply(this, args); + // get last text node of element + var content = $element.contents(); + if (content.length > 1) { + content[content.length - 1].nodeValue = ' ' + I18n._(args); + } else { + $element.text(I18n._(args)); + } } // show notification @@ -1130,6 +1133,21 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { 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) + { + console.error('remaining message shown: ', message); + handleNotification(1, $remainingTime, message); + } + /** * shows a loading message, optionally with a percentage * @@ -1230,18 +1248,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { $errorMessage = $('#errormessage'); $loadingIndicator = $('#loadingindicator'); $statusMessage = $('#status'); - - // display status returned by php code, if any (e.g. paste was properly deleted) - var serverStatus = $statusMessage.text(); - if (Helper.isValidText(serverStatus)) { - me.showStatus(); - } - - // display error message from php code - var serverError = $errorMessage.text(); - if (Helper.isValidText(serverError)) { - Alert.showError(); - } + $remainingTime = $('#remainingtime'); } return me; @@ -1339,7 +1346,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { // actually remove paste, before we claim it is deleted Controller.removePaste(Model.getPasteId(), 'burnafterreading'); - I18n._($remainingTime.find(':last'), "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again."); + Alert.showRemaining("FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again."); $remainingTime.addClass('foryoureyesonly'); // discourage cloning (it cannot really be prevented) @@ -1353,7 +1360,7 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { 'This document will expire in %d ' + expiration[1] + 's.' ]; - I18n._($remainingTime.find(':last'), expirationLabel, expiration[0]); + Alert.showRemaining(expirationLabel, expiration[0]); $remainingTime.removeClass('foryoureyesonly') } else { // never expires @@ -1620,7 +1627,6 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { $message.addClass('hidden'); // show preview - $('#errormessage').find(':last') PasteViewer.setText($message.val()); PasteViewer.run(); diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index 4dd63897..683e2342 100644 --- a/tpl/bootstrap.php +++ b/tpl/bootstrap.php @@ -69,7 +69,7 @@ if ($MARKDOWN): - + @@ -122,7 +122,7 @@ endif;