diff --git a/.eslintrc b/.eslintrc index 97437c7..e2a42cc 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 @@ -99,7 +105,7 @@ rules: no-with: 2 radix: 2 vars-on-top: 0 - wrap-iife: 2 + wrap-iife: 0 yoda: 0 # Strict @@ -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 diff --git a/.travis.yml b/.travis.yml index c5e0d07..3ad463c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ before_script: - composer install -n script: - - cd tst && phpunit + - cd tst && ../vendor/bin/phpunit after_script: - cd .. diff --git a/composer.json b/composer.json index b92f462..632bf2b 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ }, "require-dev": { "codacy/coverage": "dev-master", - "codeclimate/php-test-reporter": "dev-master" + "codeclimate/php-test-reporter": "dev-master", + "phpunit/phpunit": "^4.6 || ^5.0" }, "autoload": { "psr-4": { diff --git a/css/bootstrap/privatebin.css b/css/bootstrap/privatebin.css index 381f72d..ded8259 100644 --- a/css/bootstrap/privatebin.css +++ b/css/bootstrap/privatebin.css @@ -17,6 +17,10 @@ body.navbar-spacing { padding-top: 70px; } +body.loading { + cursor: wait; +} + .buttondisabled { opacity: 0.3; } @@ -102,6 +106,12 @@ body.navbar-spacing { border-left: 1px solid #ccc; padding: 5px 0 5px 10px; white-space: pre-wrap; + transition: background-color 0.75s ease-out; +} + +.comment.highlight { + background-color: #ffdd86; + transition: background-color 0.2s ease-in; } footer h4 { diff --git a/i18n/de.json b/i18n/de.json index 87f55cc..9959e71 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -83,25 +83,25 @@ "Could not decrypt data (Wrong key?)": "Konnte Daten nicht entschlüsseln (Falscher Schlüssel?)", "Could not delete the paste, it was not stored in burn after reading mode.": - "Konnte den Text nicht löschen, er wurde nicht im Einmal-Modus gespeichert.", + "Konnte das Paste nicht löschen, es wurde nicht im Einmal-Modus gespeichert.", "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": - "DIESER TEXT IST NUR FÜR DICH GEDACHT. Schliesse das Fenster nicht, diese Nachricht kann nur einmal geöffnet werden.", + "DIESER TEXT IST NUR FÜR DICH GEDACHT. Schließe das Fenster nicht, diese Nachricht kann nur einmal geöffnet werden.", "Could not decrypt comment; Wrong key?": "Konnte Kommentar nicht entschlüsseln; Falscher Schlüssel?", "Reply": "Antworten", "Anonymous": "Anonym", - "Anonymous avatar (Vizhash of the IP address)": - "Anonymer Avatar (Vizhash der IP-Addresse)", + "Avatar generated from IP address": + "Avatar (generiert aus der IP-Adresse)", "Add comment": "Kommentar hinzufügen", - "Optional nickname...": - "Optionales Pseudonym...", + "Optional nickname…": + "Optionales Pseudonym…", "Post comment": "Kommentar absenden", - "Sending comment...": - "Sende Kommentar...", + "Sending comment…": + "Sende Kommentar…", "Comment posted.": "Kommentar gesendet.", "Could not refresh display: %s": @@ -112,24 +112,25 @@ "Fehler auf dem Server oder keine Antwort vom Server", "Could not post comment: %s": "Konnte Kommentar nicht senden: %s", - "Sending paste (Please move your mouse for more entropy)...": - "Sende Text (Bitte bewege Deine Maus um die Entropie zu erhöhen)...", - "Sending paste...": - "Sende Text...", + "Please move your mouse for more entropy…": + "Bitte bewege Deine Maus um die Entropie zu erhöhen…", + "Sending paste…": + "Sende Paste…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": - "Dein Text ist unter %s zu finden (Drücke [Strg]+[c] um den Link zu kopieren)", + "Dein Paste ist unter %s zu finden (Drücke [Strg]+[c] um den Link zu kopieren)", "Delete data": "Lösche Daten", "Could not create paste: %s": - "Konnte Text nicht erstellen: %s", + "Konnte Paste nicht erstellen: %s", "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": - "Konnte Text nicht entschlüsseln: Der Schlüssel fehlt in der Adresse (Hast du eine Umleitung oder einen URL-Verkürzer benutzt, der Teile der Adresse entfernt?)", + "Konnte Paste nicht entschlüsseln: Der Schlüssel fehlt in der Adresse (Hast du eine Umleitung oder einen URL-Verkürzer benutzt, der Teile der Adresse entfernt?)", "Format": "Format", "Plain Text": "Nur Text", "Source Code": "Quellcode", "Markdown": "Markdown", "Download attachment": "Anhang herunterladen", - "Cloned file attached.": "Kopierte Datei angehängt.", + "Cloned: '%s'": "Geklont: '%s'", + "The cloned file '%s' was attached to this paste.": "Die geklonte Datei '%s' wurde angehängt.", "Attach a file": "Datei anhängen", "Remove attachment": "Anhang entfernen", "Your browser does not support uploading encrypted files. Please use a newer browser.": @@ -146,6 +147,10 @@ "Enter password": "Passwort eingeben", "Loading…": "Lädt…", + "Decrypting paste…": "Entschlüssle Paste…", + "Preparing new paste…": "Bereite neues Paste vor…", "In case this message never disappears please have a look at this FAQ for information to troubleshoot.": - "Wenn diese Nachricht nicht mehr verschwindet, schau bitte in die FAQ (englisch), um zu sehen, wie der Fehler behoben werden kann." + "Wenn diese Nachricht nicht mehr verschwindet, schau bitte in die FAQ (englisch), um zu sehen, wie der Fehler behoben werden kann.", + "+++ no paste text +++": + "+++ kein Paste-Text +++" } diff --git a/i18n/es.json b/i18n/es.json index 55c0f14..1e2fd48 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -96,12 +96,12 @@ "Avatar anónimo (Vizhash de la dirección IP)", "Add comment": "Añadir comentario", - "Optional nickname...": - "Seudónimo opcional...", + "Optional nickname…": + "Seudónimo opcional…", "Post comment": "Publicar comentario", - "Sending comment...": - "Enviando comentario...", + "Sending comment…": + "Enviando comentario…", "Comment posted.": "Comentario publicado.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "Error del servidor o el servidor no responde", "Could not post comment: %s": "No fue posible publicar comentario: %s", - "Sending paste (Please move your mouse for more entropy)...": - "Enviando texto (Por favor, mueva el ratón para mayor entropía)...", - "Sending paste...": - "Enviando texto...", + "Sending paste (Please move your mouse for more entropy)…": + "Enviando texto (Por favor, mueva el ratón para mayor entropía)…", + "Sending paste…": + "Enviando texto…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Su texto está en %s (Presione [Ctrl]+[c] para copiar)", "Delete data": diff --git a/i18n/fr.json b/i18n/fr.json index 89a4504..97c4e24 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -96,12 +96,12 @@ "Avatar anonyme (Vizhash de l'adresse IP)", "Add comment": "Ajouter un commentaire", - "Optional nickname...": - "Pseudonyme optionnel...", + "Optional nickname…": + "Pseudonyme optionnel…", "Post comment": "Poster le commentaire", - "Sending comment...": - "Envoi du commentaire...", + "Sending comment…": + "Envoi du commentaire…", "Comment posted.": "Commentaire posté.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "Le serveur ne répond pas ou a rencontré une erreur", "Could not post comment: %s": "Impossible de poster le commentaire : %s", - "Sending paste (Please move your mouse for more entropy)...": - "Envoi du paste (Merci de bouger votre souris pour plus d'entropie)...", - "Sending paste...": - "Envoi du paste...", + "Sending paste (Please move your mouse for more entropy)…": + "Envoi du paste (Merci de bouger votre souris pour plus d'entropie)…", + "Sending paste…": + "Envoi du paste…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Votre paste est disponible à l'adresse %s (Appuyez sur [Ctrl]+[c] pour copier)", "Delete data": diff --git a/i18n/it.json b/i18n/it.json index 24b79ec..df5aee2 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -96,12 +96,12 @@ "Avatar Anonino (Vizhash dell'indirizzo IP)", "Add comment": "Aggiungi un commento", - "Optional nickname...": - "Nickname opzionale...", + "Optional nickname…": + "Nickname opzionale…", "Post comment": "Invia commento", - "Sending comment...": - "Commento in fase di invio...", + "Sending comment…": + "Commento in fase di invio…", "Comment posted.": "Commento inviato.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "errore o mancata risposta dal server", "Could not post comment: %s": "Impossibile inviare il commento: %s", - "Sending paste (Please move your mouse for more entropy)...": - "Invio messaggio (Muovi il mouse in modo casuale, per generare maggior entropia)...", - "Sending paste...": - "Messaggio in fase di invio...", + "Sending paste (Please move your mouse for more entropy)…": + "Invio messaggio (Muovi il mouse in modo casuale, per generare maggior entropia)…", + "Sending paste…": + "Messaggio in fase di invio…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Il tuo messaggio è qui: %s ([CTRL | CMD]+[C] per copiare il link)", "Delete data": diff --git a/i18n/no.json b/i18n/no.json index 1408b81..4d92cc8 100644 --- a/i18n/no.json +++ b/i18n/no.json @@ -96,12 +96,12 @@ "Anonym avatar (Vizhash av IP adressen)", "Add comment": "Legg til kommentar", - "Optional nickname...": - "Valgfritt kallenavn...", + "Optional nickname…": + "Valgfritt kallenavn…", "Post comment": "Send kommentar", - "Sending comment...": - "Sender Kommentar...", + "Sending comment…": + "Sender Kommentar…", "Comment posted.": "Kommentar sendt.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "server feilet eller svarer ikke", "Could not post comment: %s": "Kunne ikke sende kommentar: %s", - "Sending paste (Please move your mouse for more entropy)...": - "Sender innlegg (Flytt musen for mere entropi)...", - "Sending paste...": - "Sender innlegg...", + "Sending paste (Please move your mouse for more entropy)…": + "Sender innlegg (Flytt musen for mere entropi)…", + "Sending paste…": + "Sender innlegg…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Ditt innlegg er %s (Trykk [Ctrl]+[c] for å kopiere)", "Delete data": diff --git a/i18n/oc.json b/i18n/oc.json index efbb9b2..a29bce0 100644 --- a/i18n/oc.json +++ b/i18n/oc.json @@ -96,12 +96,12 @@ "Avatar anonime (Vizhash de l'adreça IP)", "Add comment": "Apondre un comentari", - "Optional nickname...": - "Escais opcional...", + "Optional nickname…": + "Escais opcional…", "Post comment": "Mandar lo comentari", - "Sending comment...": - "Mandadís del comentari...", + "Sending comment…": + "Mandadís del comentari…", "Comment posted.": "Comentari mandat.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "Lo servidor respond pas o a rencontrat una error", "Could not post comment: %s": "Impossible de mandar lo comentari : %s", - "Sending paste (Please move your mouse for more entropy)...": - "Mandadís del tèxte (Mercés de bolegar vòstra mirga per mai entropia)...", - "Sending paste...": - "Mandadís del tèxte...", + "Sending paste (Please move your mouse for more entropy)…": + "Mandadís del tèxte (Mercés de bolegar vòstra mirga per mai entropia)…", + "Sending paste…": + "Mandadís del tèxte…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Vòstre tèxte es disponible a l'adreça %s (Picatz sus [Ctrl]+[c] per copiar)", "Delete data": diff --git a/i18n/pl.json b/i18n/pl.json index 2757439..b9cc8f2 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -96,12 +96,12 @@ "Anonimowy avatar (Vizhash z adresu IP)", "Add comment": "Dodaj komentarz", - "Optional nickname...": - "Opcjonalny nick...", + "Optional nickname…": + "Opcjonalny nick…", "Post comment": "Wyślij komentarz", - "Sending comment...": - "Wysyłanie komentarza...", + "Sending comment…": + "Wysyłanie komentarza…", "Comment posted.": "Wysłano komentarz.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "bląd serwera lub brak odpowiedzi", "Could not post comment: %s": "Nie udało się wysłać komentarza: %s", - "Sending paste (Please move your mouse for more entropy)...": - "Wysyłanie wklejki (proszę poruszać myszą aby uzyskać większą entropię)...", - "Sending paste...": - "Wysyłanie wklejki...", + "Sending paste (Please move your mouse for more entropy)…": + "Wysyłanie wklejki (proszę poruszać myszą aby uzyskać większą entropię)…", + "Sending paste…": + "Wysyłanie wklejki…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Twoja wklejka to %s (wciśnij [Ctrl]+[c] aby skopiować)", "Delete data": diff --git a/i18n/pt.json b/i18n/pt.json index 3e41890..e00a4a1 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -92,16 +92,16 @@ "Responder", "Anonymous": "Anônimo", - "Anonymous avatar (Vizhash of the IP address)": - "Avatar anônimo (Vizhash do endereço IP)", + "Avatar generated from IP address": + "Avatar (do endereço IP)", "Add comment": "Adicionar comentário", - "Optional nickname...": - "Apelido opcional...", + "Optional nickname…": + "Apelido opcional…", "Post comment": "Publicar comentário", - "Sending comment...": - "Enviando comentário...", + "Sending comment…": + "Enviando comentário…", "Comment posted.": "Comentário publicado.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "Servidor em erro ou não responsivo", "Could not post comment: %s": "Não foi possível publicar o comentário: %s", - "Sending paste (Please move your mouse for more entropy)...": - "Enviando cópia (Por favor, mova o mouse para maior entropia)...", - "Sending paste...": - "Enviando cópia...", + "Please move your mouse for more entropy…": + "Por favor, mova o mouse para maior entropia…", + "Sending paste…": + "Enviando cópia…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Sua cópia é %s (Pressione [Ctrl]+[c] para copiar)", "Delete data": @@ -129,7 +129,7 @@ "Source Code": "Código fonte", "Markdown": "Markdown", "Download attachment": "Baixar anexo", - "Cloned file attached.": "Arquivo clonado anexado.", + "Cloned: '%s'": "Clonado: '%s'", "Attach a file": "Anexar um arquivo", "Remove attachment": "Remover anexo", "Your browser does not support uploading encrypted files. Please use a newer browser.": diff --git a/i18n/ru.json b/i18n/ru.json index de7ad05..7e92da7 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -96,12 +96,12 @@ "Анонимный аватар (Vizhash IP адреса)", "Add comment": "Добавить комментарий", - "Optional nickname...": - "Опциональный никнейм...", + "Optional nickname…": + "Опциональный никнейм…", "Post comment": "Отправить комментарий", - "Sending comment...": - "Отправка комментария...", + "Sending comment…": + "Отправка комментария…", "Comment posted.": "Комментарий опубликован.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "ошибка сервера или нет ответа", "Could not post comment: %s": "Не удалось опубликовать комментарий: %s", - "Sending paste (Please move your mouse for more entropy)...": - "Отправка записи (Пожалуйста двигайте мышкой для большей энтропии)...", - "Sending paste...": - "Отправка записи...", + "Sending paste (Please move your mouse for more entropy)…": + "Отправка записи (Пожалуйста двигайте мышкой для большей энтропии)…", + "Sending paste…": + "Отправка записи…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Ссылка на запись %s (Нажмите [Ctrl]+[c] чтобы скопировать ссылку)", "Delete data": @@ -155,5 +155,5 @@ "Enter password": "Введите пароль", "Uploading paste… Please wait.": - "Отправка записи... Пожалуйста подождите." + "Отправка записи… Пожалуйста подождите." } diff --git a/i18n/sl.json b/i18n/sl.json index 4cf3d5a..2df2608 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -96,12 +96,12 @@ "Anonimen avatar (Vizhash IP naslova)", "Add comment": "Dodaj komentar", - "Optional nickname...": + "Optional nickname…": "Uporabniško ime (lahko izpustiš)", "Post comment": "Objavi komentar", - "Sending comment...": - "Pošiljam komentar ...", + "Sending comment…": + "Pošiljam komentar …", "Comment posted.": "Komentar poslan.", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "napaka na strežniku, ali pa se strežnik ne odziva", "Could not post comment: %s": "Komentarja ni bilo mogoče objaviti : %s", - "Sending paste (Please move your mouse for more entropy)...": - "Pošiljam prilepek (prosim premakni svojo miško za več entropije) ...", - "Sending paste...": - "Pošiljam prilepek...", + "Sending paste (Please move your mouse for more entropy)…": + "Pošiljam prilepek (prosim premakni svojo miško za več entropije) …", + "Sending paste…": + "Pošiljam prilepek…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "Tvoj prilepek je dostopen na naslovu: %s (Pritisni [Ctrl]+[c] ali [Cmd] + [c] in skopiraj)", "Delete data": diff --git a/i18n/zh.json b/i18n/zh.json index a1a9b96..3ec80f1 100644 --- a/i18n/zh.json +++ b/i18n/zh.json @@ -96,12 +96,12 @@ "匿名头像 (由IP地址生成Vizhash)", "Add comment": "添加评论", - "Optional nickname...": - "可选昵称...", + "Optional nickname…": + "可选昵称…", "Post comment": "评论", - "Sending comment...": - "评论发送中...", + "Sending comment…": + "评论发送中…", "Comment posted.": "评论已发送。", "Could not refresh display: %s": @@ -112,10 +112,10 @@ "服务器错误或无回应", "Could not post comment: %s": "无法发送评论: %s", - "Sending paste (Please move your mouse for more entropy)...": - "粘贴提交中 (请移动鼠标以产生更多熵)...", - "Sending paste...": - "粘贴提交中...", + "Sending paste (Please move your mouse for more entropy)…": + "粘贴提交中 (请移动鼠标以产生更多熵)…", + "Sending paste…": + "粘贴提交中…", "Your paste is %s (Hit [Ctrl]+[c] to copy)": "您的粘贴的链接是%s (按下 [Ctrl]+[c] 以复制)", "Delete data": diff --git a/js/privatebin.js b/js/privatebin.js index 6508bec..10b6a3a 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -11,7 +11,6 @@ * @namespace */ -'use strict'; /** global: Base64 */ /** global: FileReader */ /** global: RawDeflate */ @@ -25,23 +24,62 @@ // Immediately start random number generator collector. sjcl.random.startCollectors(); +// main application start, called when DOM is fully loaded +jQuery(document).ready(function() { + // run main controller + $.PrivateBin.Controller.init(); +}); + jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { + 'use strict'; + /** - * static helper methods + * static Helper methods * - * @name helper + * @name Helper * @class */ - var helper = { + var Helper = (function () { + var me = {}; + + /** + * character to HTML entity lookup table + * + * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} + * @name Helper.entityMap + * @private + * @enum {Object} + * @readonly + */ + var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + + /** + * cache for script location + * + * @name Helper.baseUri + * @private + * @enum {string|null} + */ + var baseUri = null; + /** * converts a duration (in seconds) into human friendly approximation * - * @name helper.secondsToHuman + * @name Helper.secondsToHuman * @function * @param {number} seconds * @return {Array} */ - secondsToHuman: function(seconds) + me.secondsToHuman = function(seconds) { var v; if (seconds < 60) @@ -67,84 +105,68 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } v = Math.floor(seconds / (60 * 60 * 24 * 30)); return [v, 'month']; - }, + } + + /** + * checks if a string is valid text (and not onyl whitespace) + * + * @name Helper.isValidText + * @function + * @param {string} string + * @return {bool} + */ + me.isValidText = function(string) + { + return (string.length > 0 && $.trim(string) !== '') + } /** * 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 + * @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) - { - range = doc.body.createTextRange(); - range.moveToElementText(text); + if (document.body.createTextRange) { + range = document.body.createTextRange(); + range.moveToElementText(element); range.select(); - } - // all others - else if (window.getSelection) - { + } 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) + * set text of a jQuery element (required for IE), * - * @name helper.setElementText + * @name Helper.setElementText * @function - * @param {Object} element - a DOM element + * @param {jQuery} $element - a jQuery element * @param {string} text - the text to enter */ - 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
'); - element.html('
' + html + '
'); + var html = me.htmlEntities(text).replace(/\n/ig, '\r\n
'); + $element.html('
' + html + '
'); } // for other (sane) browsers: else { - element.text(text); + $element.text(text); } - }, - - /** - * replace last child of element with message - * - * @name helper.setMessage - * @function - * @param {Object} element - a jQuery wrapped DOM element - * @param {string} message - the message to append - */ - setMessage: function(element, message) - { - var content = element.contents(); - if (content.length > 0) - { - content[content.length - 1].nodeValue = ' ' + message; - } - else - { - this.setElementText(element, message); - } - }, + } /** * convert URLs to clickable links. @@ -155,44 +177,40 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { * http://user:example.com@localhost:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM= * * - * @name helper.urls2links + * @name Helper.urls2links * @function * @param {Object} element - a jQuery DOM element */ - urls2links: function(element) + me.urls2links = function($element) { var markup = '$1'; - element.html( - element.html().replace( + $element.html( + $element.html().replace( /((http|https|ftp):\/\/[\w?=&.\/-;#@~%+-]+(?![\w\s?&.\/;#~%"=-]*>))/ig, markup ) ); - element.html( - element.html().replace( + $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 + * @name Helper.sprintf * @function * @param {string} format * @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') - { - args = arguments[0]; - } + var args = Array.prototype.slice.call(arguments); var format = args[0], i = 1; return format.replace(/%((%)|s|d)/g, function (m) { @@ -218,18 +236,18 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } 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 + * @name Helper.getCookie * @function * @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,201 +262,236 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { } } return ''; - }, + } /** - * get the current script location (without search or hash part of the URL), + * get the current location (without search or hash part of the URL), * eg. http://example.com/path/?aaaa#bbbb --> http://example.com/path/ * - * @name helper.scriptLocation + * @name Helper.baseUri * @function - * @return {string} current script location + * @return {string} */ - scriptLocation: function() + me.baseUri = function() { - var scriptLocation = window.location.href.substring( - 0, - window.location.href.length - window.location.search.length - window.location.hash.length - ), - 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 - */ - 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 - */ - 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); + // check for cached version + if (baseUri !== null) { + return baseUri; } - return key; - }, + baseUri = window.location.origin + window.location.pathname; + return baseUri; + } /** * 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 + * @name Helper.htmlEntities * @function * @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 + * resets state, used for unit testing * - * @see {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60} - * @name helper.entityMap - * @enum {Object} - * @readonly + * @name Helper.reset + * @function */ - entityMap: { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/', - '`': '`', - '=': '=' + me.reset = function() + { + baseUri = null; } - }; + + return me; + })(); /** - * internationalization methods + * internationalization module * - * @name i18n + * @name I18n + * @param {object} window + * @param {object} document * @class */ - var i18n = { + var I18n = (function (window, document) { + var me = {}; + + /** + * const for string of loaded language + * + * @name I18n.languageLoadedEvent + * @private + * @prop {string} + * @readonly + */ + var languageLoadedEvent = 'languageLoaded'; + /** * supported languages, minus the built in 'en' * - * @name i18n.supportedLanguages + * @name I18n.supportedLanguages + * @private * @prop {string[]} * @readonly */ - supportedLanguages: ['de', 'es', 'fr', 'it', 'no', 'pl', 'pt', 'oc', 'ru', 'sl', 'zh'], + var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'pt', 'oc', 'ru', 'sl', 'zh']; /** - * translate a string, alias for i18n.translate() + * built in language * - * @name i18n._ + * @name I18n.language + * @private + * @prop {string|null} + */ + var language = null; + + /** + * translation cache + * + * @name I18n.translations + * @private + * @enum {Object} + */ + var translations = {}; + + /** + * translate a string, alias for I18n.translate + * + * @name I18n._ * @function + * @param {jQuery} $element - optional * @param {string} messageId * @param {...*} args - one or multiple parameters injected into placeholders * @return {string} */ - _: function() + me._ = function() { - return this.translate(arguments); - }, + return me.translate.apply(this, arguments); + } /** * translate a string * - * @name i18n.translate + * Optionally pass a jQuery element as the first parameter, to automatically + * let the text of this element be replaced. In case the (asynchronously + * loaded) language is not downloadet yet, this will make sure the string + * is replaced when it is actually loaded. + * So for easy translations passing the jQuery object to apply it to is + * more save, especially when they are loaded in the beginning. + * + * @name I18n.translate * @function + * @param {jQuery} $element - optional * @param {string} messageId * @param {...*} args - one or multiple parameters injected into placeholders * @return {string} */ - translate: function() + me.translate = function() { - var args = arguments, messageId; - if (typeof arguments[0] === 'object') - { - args = arguments[0]; + // convert parameters to array + var args = Array.prototype.slice.call(arguments), + messageId, + $element = null; + + // parse arguments + if (args[0] instanceof jQuery) { + // optional jQuery element as first parameter + $element = args[0]; + args.shift(); } + + // extract messageId from arguments var usesPlurals = $.isArray(args[0]); - if (usesPlurals) - { + if (usesPlurals) { // use the first plural form as messageId, otherwise the singular messageId = (args[0].length > 1 ? args[0][1] : args[0][0]); - } - else - { + } else { messageId = args[0]; } - if (messageId.length === 0) - { + + if (messageId.length === 0) { return messageId; } - if (!this.translations.hasOwnProperty(messageId)) - { - if (this.language !== 'en') - { - console.debug( - 'Missing ' + this.language + ' translation for: ' + messageId - ); + + // if no translation string cannot be found (in translations object) + if (!translations.hasOwnProperty(messageId) || language === null) { + // if language is still loading and we have an elemt assigned + if (language === null && $element !== null) { + // handle the error by attaching the language loaded event + var orgArguments = arguments; + $(document).on(languageLoadedEvent, function () { + // log to show that the previous error could be mitigated + console.log('Fix missing translation of \'' + messageId + '\' with now loaded language ' + language); + // re-execute this function + me.translate.apply(this, orgArguments); + }); + + // and fall back to English for now until the real language + // file is loaded } - this.translations[messageId] = args[0]; + + // for all other langauges than English for which this behaviour + // is expected as it is built-in, log error + if (language !== null && language !== 'en') { + console.error('Missing translation for: \'' + messageId + '\' in language ' + language); + // fallback to English + } + + // save English translation (should be the same on both sides) + translations[messageId] = args[0]; } - if (usesPlurals && $.isArray(this.translations[messageId])) - { + + // lookup plural translation + if (usesPlurals && $.isArray(translations[messageId])) { var n = parseInt(args[1] || 1, 10), - key = this.getPluralForm(n), - maxKey = this.translations[messageId].length - 1; - if (key > maxKey) - { + 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 { + // lookup singular translation + args[0] = translations[messageId]; } - else - { - args[0] = this.translations[messageId]; + + // format string + var output = Helper.sprintf.apply(this, args); + + // if $element is given, apply text to element + if ($element !== null) { + // get last text node of element + var content = $element.contents(); + if (content.length > 1) { + content[content.length - 1].nodeValue = ' ' + output; + } else { + $element.text(output); + } } - return helper.sprintf(args); - }, + + return output; + } /** * per language functions to use to determine the plural form * * @see {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html} - * @name i18n.getPluralForm + * @name I18n.getPluralForm * @function - * @param {number} n - * @return {number} array key + * @param {int} n + * @return {int} array key */ - getPluralForm: function(n) { - switch (this.language) + me.getPluralForm = function(n) { + switch (language) { case 'fr': case 'oc': @@ -454,1057 +507,1046 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { default: return (n !== 1 ? 1 : 0); } - }, + } /** - * load translations into cache, then trigger controller initialization + * load translations into cache * - * @name i18n.loadTranslations + * @name I18n.loadTranslations * @function */ - loadTranslations: function() + me.loadTranslations = function() { - var language = helper.getCookie('lang'); - if (language.length === 0) - { - language = (navigator.language || navigator.userLanguage).substring(0, 2); - } - // note that 'en' is built in, so no translation is necessary - if (i18n.supportedLanguages.indexOf(language) === -1) - { - controller.init(); - } - else - { - $.getJSON('i18n/' + language + '.json', function(data) { - i18n.language = language; - i18n.translations = data; - controller.init(); - }); - } - }, + var newLanguage = Helper.getCookie('lang'); - /** - * built in language - * - * @name i18n.language - * @prop {string} - */ - language: 'en', + // auto-select language based on browser settings + if (newLanguage.length === 0) { + newLanguage = (navigator.language || navigator.userLanguage).substring(0, 2); + } - /** - * translation cache - * - * @name i18n.translations - * @enum {Object} - */ - translations: {} - }; + // if language is already used skip update + if (newLanguage === language) { + return; + } + + // if language is built-in (English) skip update + if (newLanguage === 'en') { + language = 'en'; + return; + } + + // if language is not supported, show error + if (supportedLanguages.indexOf(newLanguage) === -1) { + console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage); + language = 'en'; + return; + } + + // load strings from JSON + $.getJSON('i18n/' + newLanguage + '.json', function(data) { + language = newLanguage; + translations = data; + $(document).triggerHandler(languageLoadedEvent); + }).fail(function (data, textStatus, errorMsg) { + console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg); + language = 'en'; + }); + } + + return me; + })(window, document); /** - * filter methods + * handles everything related to en/decryption * - * @name filter + * @name CryptTool * @class */ - var filter = { + var CryptTool = (function () { + var me = {}; + /** * compress a message (deflate compression), returns base64 encoded data * - * @name filter.compress + * @name CryptTool.compress * @function + * @private * @param {string} message * @return {string} base64 data */ - 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 CryptTool.decompress * @function + * @private * @param {string} data - base64 data * @return {string} message */ - 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 CryptTool.cipher * @function * @param {string} key * @param {string} password * @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); + var options = { + mode: 'gcm', + ks: 256, + ts: 128 + }; + + if ((password || '').trim().length === 0) { + return sjcl.encrypt(key, 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)), compress(message), options); + } /** * decrypt message with key, then decompress * - * @name filter.decipher + * @name CryptTool.decipher * @function * @param {string} key * @param {string} password * @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)); - } - catch(err) - { - try - { - return this.decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); + if (data !== undefined) { + try { + return decompress(sjcl.decrypt(key, data)); + } catch(err) { + try { + return decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); + } catch(e) { + // ignore error, because ????? @TODO } - catch(e) - {} } } return ''; } - }; + + /** + * checks whether the crypt tool has collected enough entropy + * + * @name CryptTool.isEntropyReady + * @function + * @return {bool} + */ + me.isEntropyReady = function() + { + return sjcl.random.isReady(); + } + + /** + * add a listener function, triggered when enough entropy is available + * + * @name CryptTool.addEntropySeedListener + * @function + * @param {function} func + */ + me.addEntropySeedListener = function(func) + { + sjcl.random.addEventListener('seeded', func); + } + + /** + * returns a random symmetric key + * + * @name CryptTool.getSymmetricKey + * @function + * @return {string} func + */ + me.getSymmetricKey = function(func) + { + return sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0); + } + + return me; + })(); /** - * PrivateBin logic + * (Model) Data source (aka MVC) * - * @name controller + * @name Model * @class */ - var controller = { - /** - * headers to send in AJAX requests - * - * @name controller.headers - * @enum {Object} - */ - headers: {'X-Requested-With': 'JSONHttpRequest'}, + var Model = (function () { + var me = {}; + + var $cipherData, + $templates; + + var id = null, symmetricKey = null; /** - * URL shortners create address + * returns the expiration set in the HTML * - * @name controller.shortenerUrl - * @prop {string} + * @name Model.getExpirationDefault + * @function + * @return string + * @TODO the template can be simplified as #pasteExpiration is no longer modified (only default value) */ - shortenerUrl: '', + me.getExpirationDefault = function() + { + return $('#pasteExpiration').val(); + } /** - * URL of newly created paste + * returns the format set in the HTML * - * @name controller.createdPasteUrl - * @prop {string} + * @name Model.getFormatDefault + * @function + * @return string + * @TODO the template can be simplified as #pasteFormatter is no longer modified (only default value) */ - createdPasteUrl: '', + me.getFormatDefault = function() + { + return $('#pasteFormatter').val(); + } /** - * ask the user for the password and set it + * check if cipher data was supplied * - * @name controller.requestPassword + * @name Model.getCipherData + * @function + * @return boolean + */ + me.hasCipherData = function() + { + return (me.getCipherData().length > 0); + } + + /** + * returns the cipher data + * + * @name Model.getCipherData + * @function + * @return string + */ + me.getCipherData = function() + { + return $cipherData.text(); + } + + /** + * get the pastes unique identifier from the URL, + * eg. http://example.com/path/?c05354954c49a487#dfdsdgdgdfgdf returns c05354954c49a487 + * + * @name Model.getPasteId + * @function + * @return {string} unique identifier + * @throws {string} + */ + me.getPasteId = function() + { + if (id === null) { + id = window.location.search.substring(1); + + if (id === '') { + throw 'no paste id given'; + } + } + + return id; + } + + /** + * return the deciphering key stored in anchor part of the URL + * + * @name Model.getPasteKey + * @function + * @return {string|null} key + * @throws {string} + */ + me.getPasteKey = function() + { + if (symmetricKey === null) { + symmetricKey = window.location.hash.substring(1); + + if (symmetricKey === '') { + throw 'no encryption key given'; + } + + // Some web 2.0 services and redirectors add data AFTER the anchor + // (such as &utm_source=...). We will strip any additional data. + var ampersandPos = symmetricKey.indexOf('&'); + if (ampersandPos > -1) + { + symmetricKey = symmetricKey.substring(0, ampersandPos); + } + } + + return symmetricKey; + } + + /** + * returns a jQuery copy of the HTML template + * + * @name Model.getTemplate + * @function + * @param {string} name - the name of the template + * @return {jQuery} + */ + me.getTemplate = function(name) + { + // find template + var $element = $templates.find('#' + name + 'template').clone(true); + // change ID to avoid collisions (one ID should really be unique) + return $element.prop('id', name); + } + + /** + * resets state, used for unit testing + * + * @name Model.reset * @function */ - requestPassword: function() + me.reset = function() { - if (this.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) - { - this.requestPassword(); - } else { - this.passwordInput.val(password); - this.displayMessages(); - } - } else { - this.passwordModal.modal(); - } - }, + $cipherData = $templates = id = symmetricKey = null; + } + /** - * use given format on paste, defaults to plain text + * init navigation manager * - * @name controller.formatPaste + * preloads jQuery elements + * + * @name Model.init * @function - * @param {string} format - * @param {string} text */ - formatPaste: function(format, text) + me.init = function() { - helper.setElementText(this.clearText, text); - helper.setElementText(this.prettyPrint, text); - switch (format || 'plaintext') - { - case 'markdown': - if (typeof showdown === 'object') - { - var converter = new showdown.Converter({ - strikethrough: true, - tables: true, - tablesHeaderId: true - }); - this.clearText.html( - converter.makeHtml(text) - ); - // add table classes from bootstrap css - this.clearText.find('table').addClass('table-condensed table-bordered'); + $cipherData = $('#cipherdata'); + $templates = $('#templates'); + } - this.clearText.removeClass('hidden'); - } - this.prettyMessage.addClass('hidden'); - break; - case 'syntaxhighlighting': - if (typeof prettyPrintOne === 'function') - { - if (typeof prettyPrint === 'function') - { - prettyPrint(); - } - this.prettyPrint.html( - prettyPrintOne( - helper.htmlEntities(text), null, true - ) - ); - } - // fall through, as the rest is the same - default: - // 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'); - } - }, + return me; + })(); + + /** + * Helper functions for user interface + * + * everything directly UI-related, which fits nowhere else + * + * @name UiHelper + * @param {object} window + * @param {object} document + * @class + */ + var UiHelper = (function (window, document) { + var me = {}; /** - * show decrypted text in the display area, including discussion (if open) + * handle history (pop) state changes * - * @name controller.displayMessages - * @function - * @param {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta')) - */ - displayMessages: function(paste) - { - paste = paste || $.parseJSON(this.cipherData.text()); - var key = helper.pageKey(), - password = this.passwordInput.val(); - if (!this.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) - { - this.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) - { - this.attachmentLink.attr('download', attachmentname); - } - } - this.attachmentLink.attr('href', attachment); - this.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( - $(document.createElement('img')) - .attr('src', attachment) - .attr('class', 'img-thumbnail') - ); - this.image.removeClass('hidden'); - } - } - var cleartext = filter.decipher(key, password, paste.data); - if (cleartext.length === 0 && password.length === 0 && !paste.attachment) - { - this.requestPassword(); - return; - } - if (cleartext.length === 0 && !paste.attachment) - { - throw 'failed to decipher message'; - } - - this.passwordInput.val(password); - if (cleartext.length > 0) - { - $('#pasteFormatter').val(paste.meta.formatter); - this.formatPaste(paste.meta.formatter, cleartext); - } - } - catch(err) - { - this.stateOnlyNewPaste(); - this.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(this.remainingTime, i18n._(expirationLabel, expiration[0])); - this.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: this.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._( - 'FOR YOUR EYES ONLY. Don\'t close this window, this message can\'t be displayed again.' - )); - this.remainingTime.addClass('foryoureyesonly') - .removeClass('hidden'); - // discourage cloning (as it can't really be prevented) - this.cloneButton.addClass('hidden'); - } - - // if the discussion is opened on this paste, display it - if (paste.meta.opendiscussion) - { - this.comments.html(''); - - // iterate over comments - for (var i = 0; i < paste.comments.length; ++i) - { - var place = this.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'); - - // if the element exists in page - if ($(cname).length) - { - place = $(cname); - } - divComment.find('button').click({commentid: comment.id}, $.proxy(this.openReply, this)); - 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); - } - var divComment = $( - '
' - ); - divComment.find('button').click({commentid: helper.pasteId()}, $.proxy(this.openReply, this)); - this.comments.append(divComment); - this.discussion.removeClass('hidden'); - } - }, - - /** - * open the comment entry when clicking the "Reply" button of a comment + * currently this does only handle redirects to the home page. * - * @name controller.openReply + * @name UiHelper.historyChange + * @private * @function * @param {Event} event */ - openReply: function(event) + function historyChange(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}, - $.proxy(this.sendComment, this) - ); - source.after(reply); - this.replyStatus = $('#replystatus'); - $('#replymessage').focus(); - }, - - /** - * send a reply in a discussion - * - * @name controller.sendComment - * @function - * @param {Event} event - */ - sendComment: function(event) - { - event.preventDefault(); - this.errorMessage.addClass('hidden'); - // do not send if no data - var replyMessage = $('#replymessage'); - if (replyMessage.val().length === 0) - { - return; + var currentLocation = Helper.baseUri(); + 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; } - - this.showStatus(i18n._('Sending comment...'), true); - var parentid = event.data.parentid, - key = helper.pageKey(), - cipherdata = filter.cipher(key, this.passwordInput.val(), replyMessage.val()), - ciphernickname = '', - nick = $('#nickname').val(); - if (nick.length > 0) - { - ciphernickname = filter.cipher(key, this.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: this.headers, - success: function(data) - { - if (data.status === 0) - { - controller.showStatus(i18n._('Comment posted.')); - $.ajax({ - type: 'GET', - url: helper.scriptLocation() + '?' + helper.pasteId(), - dataType: 'json', - headers: controller.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 - */ - 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])) - { - return; - } - - // 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); - sjcl.random.addEventListener('seeded', function() { - this.sendData(event); - }); - return; - } - - $('.navbar-toggle').click(); - this.password.addClass('hidden'); - this.showStatus(i18n._('Sending paste...'), true); - - this.stateSubmittingPaste(); - - var randomkey = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0), - password = this.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.')); - 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(this.attachmentLink.attr('href')) - { - this.sendDataContinue( - randomkey, - filter.cipher(randomkey, password, this.attachmentLink.attr('href')), - this.attachmentLink.attr('download') - ); - } - else - { - this.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 - */ - sendDataContinue: function(randomkey, cipherdata_attachment, cipherdata_attachment_name) - { - var cipherdata = filter.cipher(randomkey, this.passwordInput.val(), this.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 - }; - 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: this.headers, - success: function(data) - { - if (data.status === 0) { - controller.stateExistingPaste(); - var url = helper.scriptLocation() + '?' + data.id + '#' + randomkey, - deleteUrl = helper.scriptLocation() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; - controller.showStatus(''); - controller.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 - ) + controller.shortenUrl(url) - ); - // save newly created element - controller.pasteUrl = $('#pasteurl'); - // and add click event - controller.pasteUrl.click($.proxy(controller.pasteLinkClick, controller)); - - var shortenButton = $('#shortenbutton'); - if (shortenButton) { - shortenButton.click($.proxy(controller.sendToShortener, controller)); - } - $('#deletelink').html('' + i18n._('Delete data') + ''); - controller.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()); - } - 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… - this.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 - */ - shortenUrl: function(url) - { - var shortenerHtml = $('#shortenbutton'); - if (shortenerHtml) { - this.shortenerUrl = shortenerHtml.data('shortener'); - this.createdPasteUrl = url; - return ' ' + $('
').append(shortenerHtml.clone()).html(); - } - return ''; - }, - - /** - * put the screen in "New paste" mode - * - * @name controller.stateNewPaste - * @function - */ - 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(); - }, - - /** - * put the screen in mode after submitting a paste - * - * @name controller.stateSubmittingPaste - * @function - */ - 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'); - - this.loadingIndicator.removeClass('hidden'); - }, - - /** - * put the screen in a state where the only option is to submit a - * new paste - * - * @name controller.stateOnlyNewPaste - * @function - */ - 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'); - - this.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 - */ - stateExistingPaste: function(preview) - { - preview = preview || false; - - if (!preview) - { - // no "clone" for IE<10. - if ($('#oldienotice').is(":visible")) - { - this.cloneButton.addClass('hidden'); - } - else - { - this.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'); - } - - this.pasteResult.addClass('hidden'); - this.message.addClass('hidden'); - this.clearText.addClass('hidden'); - this.prettyMessage.addClass('hidden'); - this.loadingIndicator.addClass('hidden'); - }, - - /** - * when "burn after reading" is checked, disable discussion - * - * @name controller.changeBurnAfterReading - * @function - */ - changeBurnAfterReading: function() - { - if (this.burnAfterReading.is(':checked') ) - { - this.openDisc.addClass('buttondisabled'); - this.openDiscussion.attr({checked: false, disabled: true}); - } - else - { - this.openDisc.removeClass('buttondisabled'); - this.openDiscussion.removeAttr('disabled'); - } - }, - - /** - * when discussion is checked, disable "burn after reading" - * - * @name controller.changeOpenDisc - * @function - */ - changeOpenDisc: function() - { - if (this.openDiscussion.is(':checked') ) - { - this.burnAfterReadingOption.addClass('buttondisabled'); - this.burnAfterReading.attr({checked: false, disabled: true}); - } - else - { - this.burnAfterReadingOption.removeClass('buttondisabled'); - this.burnAfterReading.removeAttr('disabled'); - } - }, - - /** - * forward to URL shortener - * - * @name controller.sendToShortener - * @function - * @param {Event} event - */ - sendToShortener: function(event) - { - event.preventDefault(); - window.location.href = this.shortenerUrl + encodeURIComponent(this.createdPasteUrl); - }, + } /** * reload the page * - * This takes the user to the PrivateBin home page. + * This takes the user to the PrivateBin homepage. * - * @name controller.reloadPage + * @name UiHelper.reloadHome * @function - * @param {Event} event */ - reloadPage: function(event) + me.reloadHome = function() { - event.preventDefault(); - window.location.href = helper.scriptLocation(); - }, + window.location.href = Helper.baseUri(); + } /** - * return raw text + * checks whether the element is currently visible in the viewport (so + * the user can actually see it) * - * @name controller.rawText + * @see {@link https://stackoverflow.com/a/40658647} + * @name UiHelper.isVisible * @function - * @param {Event} event + * @param {jQuery} $element The link hash to move to. */ - rawText: function(event) + me.isVisible = function($element) { - event.preventDefault(); - var paste = $('#pasteFormatter').val() === 'markdown' ? - this.prettyPrint.text() : this.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(); - }, + var elementTop = $element.offset().top; + var elementBottom = elementTop + $element.outerHeight(); + + var viewportTop = $(window).scrollTop(); + var viewportBottom = viewportTop + $(window).height(); + + return (elementTop > viewportTop && elementTop < viewportBottom); + } /** - * clone the current paste + * scrolls to a specific element * - * @name controller.clonePaste + * @see {@link https://stackoverflow.com/questions/4198041/jquery-smooth-scroll-to-an-anchor#answer-12714767} + * @name UiHelper.scrollTo * @function - * @param {Event} event + * @param {jQuery} $element The link hash to move to. + * @param {(number|string)} animationDuration passed to jQuery .animate, when set to 0 the animation is skipped + * @param {string} animationEffect passed to jQuery .animate + * @param {function} finishedCallback function to call after animation finished */ - clonePaste: function(event) + me.scrollTo = function($element, animationDuration, animationEffect, finishedCallback) { - event.preventDefault(); - this.stateNewPaste(); + var $body = $('html, body'), + margin = 50, + callbackCalled = false; - // erase the id and the key in url - history.replaceState(null, document.title, helper.scriptLocation()); - - this.showStatus(''); - if (this.attachmentLink.attr('href')) - { - this.clonedFile.removeClass('hidden'); - this.fileWrap.addClass('hidden'); + //calculate destination place + var dest = 0; + // if it would scroll out of the screen at the bottom only scroll it as + // far as the screen can go + if ($element.offset().top > $(document).height() - $(window).height()) { + dest = $(document).height() - $(window).height(); + } else { + dest = $element.offset().top - margin; } - this.message.text( - $('#pasteFormatter').val() === 'markdown' ? - this.prettyPrint.text() : this.clearText.text() - ); - $('.navbar-toggle').click(); - }, - - /** - * set the expiration on bootstrap templates - * - * @name controller.setExpiration - * @function - * @param {Event} event - */ - 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 - */ - 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); + // skip animation if duration is set to 0 + if (animationDuration === 0) { + window.scrollTo(0, dest); + } else { + // stop previous animation + $body.stop(); + // scroll to destination + $body.animate({ + scrollTop: dest + }, animationDuration, animationEffect); } - }, + + // as we have finished we can enable scrolling again + $body.queue(function (next) { + if (!callbackCalled) { + // call user function if needed + if (typeof finishedCallback !== 'undefined') { + finishedCallback(); + } + + // prevent calling this function twice + callbackCalled = true; + } + next(); + }); + } /** - * set the language in a cookie and reload the page + * initialize * - * @name controller.setLanguage + * @name UiHelper.init + * @function + */ + me.init = function() + { + // update link to home page + $('.reloadlink').prop('href', Helper.baseUri()); + + $(window).on('popstate', historyChange); + } + + return me; + })(window, document); + + /** + * Alert/error manager + * + * @name Alert + * @param {object} window + * @param {object} document + * @class + */ + var Alert = (function (window, document) { + var me = {}; + + var $errorMessage, + $loadingIndicator, + $statusMessage, + $remainingTime; + + var currentIcon = [ + 'glyphicon-time', // loading icon + 'glyphicon-info-sign', // status icon + '', // resevered for warning, not used yet + 'glyphicon-alert' // error icon + ]; + + var alertType = [ + 'loading', // not in bootstrap, but using a good value here + 'info', // status icon + 'warning', // not used yet + 'danger' // error icon + ]; + + var customHandler; + + /** + * forwards a request to the i18n module and shows the element + * + * @name Alert.handleNotification + * @private + * @function + * @param {int} id - id of notification + * @param {jQuery} $element - jQuery object + * @param {string|array} args + * @param {string|null} icon - optional, icon + */ + function handleNotification(id, $element, args, icon) + { + // basic parsing/conversion of parameters + if (typeof icon === 'undefined') { + icon = null; + } + if (typeof args === 'undefined') { + args = null; + } else if (typeof args === 'string') { + // convert string to array if needed + args = [args]; + } + + // pass to custom handler if defined + if (typeof customHandler === 'function') { + var handlerResult = customHandler(alertType[id], $element, args, icon); + if (handlerResult === true) { + // if it returs true, skip own handler + return; + } + if (handlerResult instanceof jQuery) { + // continue processing with new element + $element = handlerResult; + icon = null; // icons not supported in this case + } + } + + // handle icon + if (icon !== null && // icon was passed + icon !== currentIcon[id] // and it differs from current icon + ) { + var $glyphIcon = $element.find(':first'); + + // remove (previous) icon + $glyphIcon.removeClass(currentIcon[id]); + + // any other thing as a string (e.g. 'null') (only) removes the icon + if (typeof icon === 'string') { + // set new icon + currentIcon[id] = 'glyphicon-' + icon; + $glyphIcon.addClass(currentIcon[id]); + } + } + + // show text + if (args !== null) { + // add jQuery object to it as first parameter + args.unshift($element); + // pass it to I18n + I18n._.apply(this, args); + } + + // show notification + $element.removeClass('hidden'); + } + + /** + * display a status message + * + * This automatically passes the text to I18n for translation. + * + * @name Alert.showStatus + * @function + * @param {string|array} message string, use an array for %s/%d options + * @param {string|null} icon optional, the icon to show, + * default: leave previous icon + * @param {bool} dismissable optional, whether the notification + * can be dismissed (closed), default: false + * @param {bool|int} autoclose optional, after how many seconds the + * notification should be hidden automatically; + * default: disabled (0); use true for default value + */ + me.showStatus = function(message, icon, dismissable, autoclose) + { + console.log('status shown: ', message); + // @TODO: implement dismissable + // @TODO: implement autoclose + + handleNotification(1, $statusMessage, message, icon); + } + + /** + * display an error message + * + * This automatically passes the text to I18n for translation. + * + * @name Alert.showError + * @function + * @param {string|array} message string, use an array for %s/%d options + * @param {string|null} icon optional, the icon to show, default: + * leave previous icon + * @param {bool} dismissable optional, whether the notification + * can be dismissed (closed), default: false + * @param {bool|int} autoclose optional, after how many seconds the + * notification should be hidden automatically; + * default: disabled (0); use true for default value + */ + me.showError = function(message, icon, dismissable, autoclose) + { + console.error('error message shown: ', message); + // @TODO: implement dismissable (bootstrap add-on has it) + // @TODO: implement autoclose + + 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.log('remaining message shown: ', message); + handleNotification(1, $remainingTime, message); + } + + /** + * shows a loading message, optionally with a percentage + * + * This automatically passes all texts to the i10s module. + * + * @name Alert.showLoading + * @function + * @param {string|array|null} message optional, use an array for %s/%d options, default: 'Loading…' + * @param {int} percentage optional, default: null + * @param {string|null} icon optional, the icon to show, default: leave previous icon + */ + me.showLoading = function(message, percentage, icon) + { + if (typeof message !== 'undefined' && message !== null) { + console.log('status changed: ', message); + } + + // default message text + if (typeof message === 'undefined') { + message = 'Loading…'; + } + + // currently percentage parameter is ignored + // // @TODO handle it here… + + handleNotification(0, $loadingIndicator, message, icon); + + // show loading status (cursor) + $('body').addClass('loading'); + } + + /** + * hides the loading message + * + * @name Alert.hideLoading + * @function + */ + me.hideLoading = function() + { + $loadingIndicator.addClass('hidden'); + + // hide loading cursor + $('body').removeClass('loading'); + } + + /** + * hides any status/error messages + * + * This does not include the loading message. + * + * @name Alert.hideMessages + * @function + */ + me.hideMessages = function() + { + // also possible: $('.statusmessage').addClass('hidden'); + $statusMessage.addClass('hidden'); + $errorMessage.addClass('hidden'); + } + + /** + * set a custom handler, which gets all notifications. + * + * This handler gets the following arguments: + * alertType (see array), $element, args, icon + * If it returns true, the own processing will be stopped so the message + * will not be displayed. Otherwise it will continue. + * As an aditional feature it can return q jQuery element, which will + * then be used to add the message there. Icons are not supported in + * that case and will be ignored. + * Pass 'null' to reset/delete the custom handler. + * Note that there is no notification when a message is supposed to get + * hidden. + * + * @name Alert.setCustomHandler + * @function + * @param {function|null} newHandler + */ + me.setCustomHandler = function(newHandler) + { + customHandler = newHandler; + } + + /** + * init status manager + * + * preloads jQuery elements + * + * @name Alert.init + * @function + */ + me.init = function() + { + // hide "no javascript" error message + $('#noscript').hide(); + + // not a reset, but first set of the elements + $errorMessage = $('#errormessage'); + $loadingIndicator = $('#loadingindicator'); + $statusMessage = $('#status'); + $remainingTime = $('#remainingtime'); + } + + return me; + })(window, document); + + /** + * handles paste status/result + * + * @name PasteStatus + * @param {object} window + * @param {object} document + * @class + */ + var PasteStatus = (function (window, document) { + var me = {}; + + var $pasteSuccess, + $pasteUrl, + $remainingTime, + $shortenButton; + + /** + * forward to URL shortener + * + * @name PasteStatus.sendToShortener + * @private * @function * @param {Event} event */ - setLanguage: function(event) + function sendToShortener(event) { - document.cookie = 'lang=' + $(event.target).data('lang'); - this.reloadPage(event); - }, + window.location.href = $shortenButton.data('shortener') + + encodeURIComponent($pasteUrl.attr('href')); + } + + /** + * Forces opening the paste if the link does not do this automatically. + * + * This is necessary as browsers will not reload the page when it is + * already loaded (which is fake as it is set via history.pushState()). + * + * @name PasteStatus.pasteLinkClick + * @function + * @param {Event} event + */ + function pasteLinkClick(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); + } + } + + /** + * creates a notification after a successfull paste upload + * + * @name PasteStatus.createPasteNotification + * @function + * @param {string} url + * @param {string} deleteUrl + */ + me.createPasteNotification = function(url, deleteUrl) + { + $('#pastelink').html( + I18n._( + 'Your paste is %s (Hit [Ctrl]+[c] to copy)', + url, url + ) + ); + // save newly created element + $pasteUrl = $('#pasteurl'); + // and add click event + $pasteUrl.click(pasteLinkClick); + + // shorten button + $('#deletelink').html('' + I18n._('Delete data') + ''); + + // show result + $pasteSuccess.removeClass('hidden'); + // we pre-select the link so that the user only has to [Ctrl]+[c] the link + Helper.selectText($pasteUrl[0]); + } + + /** + * shows the remaining time + * + * @name PasteStatus.showRemainingTime + * @function + * @param {object} pasteMetaData + */ + me.showRemainingTime = function(pasteMetaData) + { + if (pasteMetaData.burnafterreading) { + // display paste "for your eyes only" if it is deleted + + // actually remove paste, before we claim it is deleted + Controller.removePaste(Model.getPasteId(), 'burnafterreading'); + + Alert.showRemaining("FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again."); + $remainingTime.addClass('foryoureyesonly'); + + // discourage cloning (it cannot really be prevented) + TopNav.hideCloneButton(); + + } else if (pasteMetaData.expire_date) { + // display paste expiration + var expiration = Helper.secondsToHuman(pasteMetaData.remaining_time), + expirationLabel = [ + 'This document will expire in %d ' + expiration[1] + '.', + 'This document will expire in %d ' + expiration[1] + 's.' + ]; + + Alert.showRemaining([expirationLabel, expiration[0]]); + $remainingTime.removeClass('foryoureyesonly') + } else { + // never expires + return; + } + + // in the end, display notification + $remainingTime.removeClass('hidden'); + } + + /** + * hides the remaining time and successful upload notification + * + * @name PasteStatus.hideRemainingTime + * @function + */ + me.hideMessages = function() + { + $remainingTime.addClass('hidden'); + $pasteSuccess.addClass('hidden'); + } + + /** + * init status manager + * + * preloads jQuery elements + * + * @name PasteStatus.init + * @function + */ + me.init = function() + { + $pasteSuccess = $('#pasteSuccess'); + // $pasteUrl is saved in me.createPasteNotification() after creation + $remainingTime = $('#remainingtime'); + $shortenButton = $('#shortenbutton'); + + // bind elements + $shortenButton.click(sendToShortener); + } + + return me; + })(window, document); + + /** + * password prompt + * + * @name Prompt + * @param {object} window + * @param {object} document + * @class + */ + var Prompt = (function (window, document) { + var me = {}; + + var $passwordDecrypt, + $passwordForm, + $passwordModal; + + var password = ''; + + /** + * submit a password in the modal dialog + * + * @name Prompt.submitPasswordModal + * @private + * @function + * @param {Event} event + */ + function submitPasswordModal(event) + { + event.preventDefault(); + + // get input + password = $passwordDecrypt.val(); + + // hide modal + $passwordModal.modal('hide'); + + PasteDecrypter.run(); + } + + /** + * ask the user for the password and set it + * + * @name Prompt.requestPassword + * @function + */ + me.requestPassword = function() + { + // show new bootstrap method (if available) + if ($passwordModal.length !== 0) { + $passwordModal.modal({ + backdrop: 'static', + keyboard: false + }); + return; + } + + // fallback to old method for page template + var newPassword = prompt(I18n._('Please enter the password for this paste:'), ''); + if (newPassword === null) { + throw 'password prompt canceled'; + } + if (password.length === 0) { + // recursive… + return me.requestPassword(); + } + + password = newPassword; + } + + /** + * getthe cached password + * + * If you do not get a password with this function + * (returns an empty string), use requestPassword. + * + * @name Prompt.getPassword + * @function + * @return {string} + */ + me.getPassword = function() + { + return password; + } + + /** + * init status manager + * + * preloads jQuery elements + * + * @name Prompt.init + * @function + */ + me.init = function() + { + $passwordDecrypt = $('#passworddecrypt'); + $passwordForm = $('#passwordform'); + $passwordModal = $('#passwordmodal'); + + // bind events + + // focus password input when it is shown + $passwordModal.on('shown.bs.Model', function () { + $passwordDecrypt.focus(); + }); + // handle Model password submission + $passwordForm.submit(submitPasswordModal); + } + + return me; + })(window, document); + + /** + * Manage paste/message input, and preview tab + * + * Note that the actual preview is handled by PasteViewer. + * + * @name Editor + * @param {object} window + * @param {object} document + * @class + */ + var Editor = (function (window, document) { + var me = {}; + + var $editorTabs, + $messageEdit, + $messagePreview, + $message; + + var isPreview = false; /** * support input of tab character * - * @name controller.supportTabs + * @name Editor.supportTabs * @function * @param {Event} event + * @this $message (but not used, so it is jQuery-free, possibly faster) */ - supportTabs: function(event) + function supportTabs(event) { var keyCode = event.keyCode || event.which; // tab was pressed - if (keyCode === 9) - { - // prevent the textarea to lose focus - event.preventDefault(); + if (keyCode === 9) { // get caret position & selection var val = this.value, start = this.selectionStart, @@ -1513,341 +1555,2497 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) { this.value = val.substring(0, start) + '\t' + val.substring(end); // put caret at right position again this.selectionStart = this.selectionEnd = start + 1; + // prevent the textarea to lose focus + event.preventDefault(); } - }, + } /** - * view the editor tab + * view the Editor tab * - * @name controller.viewEditor + * @name Editor.viewEditor * @function - * @param {Event} event + * @param {Event} event - optional */ - viewEditor: function(event) + function viewEditor(event) { - event.preventDefault(); - this.messagePreview.parent().removeClass('active'); - this.messageEdit.parent().addClass('active'); - this.message.focus(); - this.stateNewPaste(); - }, + // toggle buttons + $messageEdit.addClass('active'); + $messagePreview.removeClass('active'); + + PasteViewer.hide(); + + // reshow input + $message.removeClass('hidden'); + + me.focusInput(); + + // finish + isPreview = false; + + // prevent jumping of page to top + if (typeof event !== 'undefined') { + event.preventDefault(); + } + } /** * view the preview tab * - * @name controller.viewPreview + * @name Editor.viewPreview * @function * @param {Event} event */ - viewPreview: function(event) + function viewPreview(event) { - 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()); - }, + // toggle buttons + $messageEdit.removeClass('active'); + $messagePreview.addClass('active'); - /** - * handle history (pop) state changes - * - * currently this does only handle redirects to the home page. - * - * @name controller.historyChange - * @function - * @param {Event} event - */ - 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; + // hide input as now preview is shown + $message.addClass('hidden'); + + // show preview + PasteViewer.setText($message.val()); + PasteViewer.run(); + + // finish + isPreview = true; + + // prevent jumping of page to top + if (typeof event !== 'undefined') { + event.preventDefault(); } - }, + } /** - * Forces opening the paste if the link does not do this automatically. + * get the state of the preview * - * 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 + * @name Editor.isPreview * @function - * @param {Event} event */ - pasteLinkClick: function(event) + me.isPreview = function() { - // check if location is (already) shown in URL bar - if (window.location.href === this.pasteUrl.attr('href')) { - // if so we need to load link by reloading the current site - window.location.reload(true); + return isPreview; + } + + /** + * reset the Editor view + * + * @name Editor.resetInput + * @function + */ + me.resetInput = function() + { + // go back to input + if (isPreview) { + viewEditor(); } - }, + + // clear content + $message.val(''); + } /** - * create a new paste + * shows the Editor * - * @name controller.newPaste + * @name Editor.show * @function */ - newPaste: function() + me.show = function() { - this.stateNewPaste(); - this.showStatus(''); - this.message.text(''); - this.changeBurnAfterReading(); - this.changeOpenDisc(); - }, + $message.removeClass('hidden'); + $editorTabs.removeClass('hidden'); + } /** - * removes an attachment + * hides the Editor * - * @name controller.removeAttachment + * @name Editor.reset * @function */ - removeAttachment: function() + me.hide = function() { - this.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'); - }, + $message.addClass('hidden'); + $editorTabs.addClass('hidden'); + } /** - * decrypt using the password from the modal dialog + * focuses the message input * - * @name controller.decryptPasswordModal + * @name Editor.focusInput * @function */ - decryptPasswordModal: function() + me.focusInput = function() { - this.passwordInput.val(this.passwordDecrypt.val()); - this.displayMessages(); - }, + $message.focus(); + } /** - * submit a password in the modal dialog + * sets a new text * - * @name controller.submitPasswordModal + * @name Editor.setText * @function - * @param {Event} event + * @param {string} newText */ - submitPasswordModal: function(event) + me.setText = function(newText) { - event.preventDefault(); - this.passwordModal.modal('hide'); - }, + $message.val(newText); + } /** - * display an error message, - * we use the same function for paste and reply to comments + * returns the current text * - * @name controller.showError + * @name Editor.getText * @function - * @param {string} message - text to display + * @return {string} */ - showError: function(message) + me.getText = function() { - if (this.status.length) - { - this.status.addClass('errorMessage').text(message); - } - else - { - this.errorMessage.removeClass('hidden'); - helper.setMessage(this.errorMessage, message); - } - if (typeof this.replyStatus !== 'undefined') { - this.replyStatus.addClass('errorMessage'); - this.replyStatus.addClass(this.errorMessage.attr('class')); - if (this.status.length) - { - this.replyStatus.html(this.status.html()); - } - else - { - this.replyStatus.html(this.errorMessage.html()); - } - } - }, + return $message.val() + } /** - * display a status message, - * we use the same function for paste and reply to comments + * init status manager * - * @name controller.showStatus + * preloads jQuery elements + * + * @name Editor.init * @function - * @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.init = function() { - if (spin || false) - { - var img = ''; - this.status.prepend(img); - if (typeof this.replyStatus !== 'undefined') { - this.replyStatus.prepend(img); - } - } - if (typeof this.replyStatus !== 'undefined') { - this.replyStatus.removeClass('errorMessage').text(message); - } - if (!message) - { - this.status.html(' '); + $editorTabs = $('#editorTabs'); + $message = $('#message'); + + // bind events + $message.keydown(supportTabs); + + // bind click events to tab switchers (a), but save parent of them + // (li) + $messageEdit = $('#messageedit').click(viewEditor).parent(); + $messagePreview = $('#messagepreview').click(viewPreview).parent(); + } + + return me; + })(window, document); + + /** + * (view) Parse and show paste. + * + * @name PasteViewer + * @param {object} window + * @param {object} document + * @class + */ + var PasteViewer = (function (window, document) { + var me = {}; + + var $placeholder, + $prettyMessage, + $prettyPrint, + $plainText; + + var text, + format = 'plaintext', + isDisplayed = false, + isChanged = true; // by default true as nothing was parsed yet + + /** + * apply the set format on paste and displays it + * + * @name PasteViewer.parsePaste + * @private + * @function + */ + function parsePaste() + { + // skip parsing if no text is given + if (text === '') { return; } - if (message === '') - { - this.status.html(' '); - return; + + // set text + Helper.setElementText($plainText, text); + Helper.setElementText($prettyPrint, text); + + switch (format) { + case 'markdown': + var converter = new showdown.Converter({ + strikethrough: true, + tables: true, + tablesHeaderId: true + }); + $plainText.html( + converter.makeHtml(text) + ); + // add table classes from bootstrap css + $plainText.find('table').addClass('table-condensed table-bordered'); + break; + case 'syntaxhighlighting': + // @TODO is this really needed or is "one" enough? + if (typeof prettyPrint === 'function') + { + prettyPrint(); + } + + $prettyPrint.html( + prettyPrintOne( + Helper.htmlEntities(text), null, true + ) + ); + // fall through, as the rest is the same + default: // = 'plaintext' + // convert URLs to clickable links + Helper.urls2links($plainText); + Helper.urls2links($prettyPrint); + + $prettyPrint.css('white-space', 'pre-wrap'); + $prettyPrint.css('word-break', 'normal'); + $prettyPrint.removeClass('prettyprint'); } - this.status.removeClass('errorMessage').text(message); - }, + } /** - * bind events to DOM elements + * displays the paste * - * @name controller.bindEvents + * @name PasteViewer.showPaste + * @private * @function */ - bindEvents: function() + function showPaste() { - 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)); + // instead of "nothing" better display a placeholder + if (text === '') { + $placeholder.removeClass('hidden') + return; + } + // otherwise hide the placeholder + $placeholder.addClass('hidden') + + switch (format) { + case 'markdown': + $plainText.removeClass('hidden'); + $prettyMessage.addClass('hidden'); + break; + default: + $plainText.addClass('hidden'); + $prettyMessage.removeClass('hidden'); + break; + } + } + + /** + * sets the format in which the text is shown + * + * @name PasteViewer.setFormat + * @function + * @param {string} newFormat the the new format + */ + me.setFormat = function(newFormat) + { + // skip if there is no update + if (format === newFormat) { + return; + } + + // needs to update display too, if from or to Markdown is switched + if (format === 'markdown' || newFormat === 'markdown') { + isDisplayed = false; + } + + format = newFormat; + isChanged = true; + } + + /** + * returns the current format + * + * @name PasteViewer.getFormat + * @function + * @return {string} + */ + me.getFormat = function() + { + return format; + } + + /** + * returns whether the current view is pretty printed + * + * @name PasteViewer.isPrettyPrinted + * @function + * @return {bool} + */ + me.isPrettyPrinted = function() + { + return $prettyPrint.hasClass('prettyprinted'); + } + + /** + * sets the text to show + * + * @name PasteViewer.setText + * @function + * @param {string} newText the text to show + */ + me.setText = function(newText) + { + if (text !== newText) { + text = newText; + isChanged = true; + } + } + + /** + * gets the current cached text + * + * @name PasteViewer.getText + * @function + * @return {string} + */ + me.getText = function(newText) + { + return text; + } + + /** + * show/update the parsed text (preview) + * + * @name PasteViewer.run + * @function + */ + me.run = function() + { + if (isChanged) { + parsePaste(); + isChanged = false; + } + + if (!isDisplayed) { + showPaste(); + isDisplayed = true; + } + } + + /** + * hide parsed text (preview) + * + * @name PasteViewer.hide + * @function + */ + me.hide = function() + { + if (!isDisplayed) { + console.warn('PasteViewer was called to hide the parsed view, but it is already hidden.'); + } + + $plainText.addClass('hidden'); + $prettyMessage.addClass('hidden'); + $placeholder.addClass('hidden'); + + isDisplayed = false; + } + + /** + * init status manager + * + * preloads jQuery elements + * + * @name PasteViewer.init + * @function + */ + me.init = function() + { + $placeholder = $('#placeholder'); + $plainText = $('#plaintext'); + $prettyMessage = $('#prettymessage'); + $prettyPrint = $('#prettyprint'); + + // check requirements + if (typeof prettyPrintOne !== 'function') { + Alert.showError([ + 'The library %s is not available. This may cause display errors.', + 'pretty print' + ]); + } + if (typeof showdown !== 'object') { + Alert.showError([ + 'The library %s is not available. This may cause display errors.', + 'showdown' + ]); + } + + // get default option from template/HTML or fall back to set value + format = Model.getFormatDefault() || format; + } + + return me; + })(window, document); + + /** + * (view) Show attachment and preview if possible + * + * @name AttachmentViewer + * @param {object} window + * @param {object} document + * @class + */ + var AttachmentViewer = (function (window, document) { + var me = {}; + + var $attachmentLink, + $attachmentPreview, + $attachment; + + var attachmentChanged = false, + attachmentHasPreview = false; + + /** + * sets the attachment but does not yet show it + * + * @name AttachmentViewer.setAttachment + * @function + * @param {string} attachmentData - base64-encoded data of file + * @param {string} fileName - optional, file name + */ + me.setAttachment = function(attachmentData, fileName) + { + var imagePrefix = 'data:image/'; + + $attachmentLink.attr('href', attachmentData); + if (typeof fileName !== 'undefined') { + $attachmentLink.attr('download', fileName); + } + + // if the attachment is an image, display it + if (attachmentData.substring(0, imagePrefix.length) === imagePrefix) { + $attachmentPreview.html( + $(document.createElement('img')) + .attr('src', attachmentData) + .attr('class', 'img-thumbnail') + ); + attachmentHasPreview = true; + } + + attachmentChanged = true; + } + + /** + * displays the attachment + * + * @name AttachmentViewer.showAttachment + * @function + */ + me.showAttachment = function() + { + $attachment.removeClass('hidden'); + + if (attachmentHasPreview) { + $attachmentPreview.removeClass('hidden'); + } + } + + /** + * removes the attachment + * + * This automatically hides the attachment containers to, to + * prevent an inconsistent display. + * + * @name AttachmentViewer.removeAttachment + * @function + */ + me.removeAttachment = function() + { + me.hideAttachment(); + me.hideAttachmentPreview(); + $attachmentLink.prop('href', ''); + $attachmentLink.prop('download', ''); + $attachmentPreview.html(''); + } + + /** + * hides the attachment + * + * This will not hide the preview (see AttachmentViewer.hideAttachmentPreview + * for that) nor will it hide the attachment link if it was moved somewhere + * else (see AttachmentViewer.moveAttachmentTo). + * + * @name AttachmentViewer.hideAttachment + * @function + */ + me.hideAttachment = function() + { + $attachment.addClass('hidden'); + } + + /** + * hides the attachment preview + * + * @name AttachmentViewer.hideAttachmentPreview + * @function + */ + me.hideAttachmentPreview = function() + { + $attachmentPreview.addClass('hidden'); + } + + /** + * checks if there is an attachment + * + * @name AttachmentViewer.hasAttachment + * @function + */ + me.hasAttachment = function() + { + return ($attachmentLink.prop('href') !== '') + } + + /** + * return the attachment + * + * @name AttachmentViewer.getAttachment + * @function + * @returns {array} + */ + me.getAttachment = function() + { + return [ + $attachmentLink.prop('href'), + $attachmentLink.prop('download') + ]; + } + + /** + * moves the attachment link to another element + * + * It is advisable to hide the attachment afterwards (AttachmentViewer.hideAttachment) + * + * @name AttachmentViewer.moveAttachmentTo + * @function + * @param {jQuery} $element - the wrapper/container element where this should be moved to + * @param {string} label - the text to show (%s will be replaced with the file name), will automatically be translated + */ + me.moveAttachmentTo = function($element, label) + { + // move elemement to new place + $attachmentLink.appendTo($element); + + // update text + I18n._($attachmentLink, label, $attachmentLink.attr('download')); + } + + /** + * initiate + * + * preloads jQuery elements + * + * @name AttachmentViewer.init + * @function + */ + me.init = function() + { + $attachment = $('#attachment'); + $attachmentLink = $('#attachment a'); + $attachmentPreview = $('#attachmentPreview'); + } + + return me; + })(window, document); + + /** + * (view) Shows discussion thread and handles replies + * + * @name DiscussionViewer + * @param {object} window + * @param {object} document + * @class + */ + var DiscussionViewer = (function (window, document) { + var me = {}; + + var $commentTail, + $discussion, + $reply, + $replyMessage, + $replyNickname, + $replyStatus, + $commentContainer; + + var replyCommentId; + + /** + * initializes the templates + * + * @name DiscussionViewer.initTemplates + * @private + * @function + */ + function initTemplates() + { + $reply = Model.getTemplate('reply'); + $replyMessage = $reply.find('#replymessage'); + $replyNickname = $reply.find('#nickname'); + $replyStatus = $reply.find('#replystatus'); + + // cache jQuery elements + $commentTail = Model.getTemplate('commenttail'); + } + + /** + * open the comment entry when clicking the "Reply" button of a comment + * + * @name DiscussionViewer.openReply + * @private + * @function + * @param {Event} event + */ + function openReply(event) + { + var $source = $(event.target); + + // clear input + $replyMessage.val(''); + $replyNickname.val(''); + + // get comment id from source element + replyCommentId = $source.parent().prop('id').split('_')[1]; + + // move to correct position + $source.after($reply); + + // show + $reply.removeClass('hidden'); + $replyMessage.focus(); + + event.preventDefault(); + } + + /** + * custom handler for displaying notifications in own status message area + * + * @name DiscussionViewer.handleNotification + * @function + * @param {string} alertType + * @param {jQuery} $element + * @param {string|array} args + * @param {string|null} icon + * @return {bool|jQuery} + */ + me.handleNotification = function(alertType, $element, args, icon) + { + // ignore loading messages + if (alertType === 'loading') { + return false; + } + + if (alertType === 'danger') { + $replyStatus.removeClass('alert-info'); + $replyStatus.addClass('alert-danger'); + $replyStatus.find(':first').removeClass('glyphicon-alert'); + $replyStatus.find(':first').addClass('glyphicon-info-sign'); + } else { + $replyStatus.removeClass('alert-danger'); + $replyStatus.addClass('alert-info'); + $replyStatus.find(':first').removeClass('glyphicon-info-sign'); + $replyStatus.find(':first').addClass('glyphicon-alert'); + } + + return $replyStatus; + } + + /** + * adds another comment + * + * @name DiscussionViewer.addComment + * @function + * @param {object} comment + * @param {string} commentText + * @param {jQuery} $place - optional, tries to find the best position otherwise + */ + me.addComment = function(comment, commentText, nickname, $place) + { + if (typeof $place === 'undefined') { + // starting point (default value/fallback) + $place = $commentContainer; + + // if parent comment exists + var $parentComment = $('#comment_' + comment.parentid); + if ($parentComment.length) { + // use parent as position for noew comment, so it shifted + // to the right + $place = $parentComment; + } + } + if (commentText === '') { + commentText = 'comment decryption failed'; + } + + // create new comment based on template + var $commentEntry = Model.getTemplate('comment'); + $commentEntry.prop('id', 'comment_' + comment.id); + var $commentEntryData = $commentEntry.find('div.commentdata'); + + // set & parse text + Helper.setElementText($commentEntryData, commentText); + Helper.urls2links($commentEntryData); + + // set nickname + if (nickname.length > 0) { + $commentEntry.find('span.nickname').text(nickname); + } else { + $commentEntry.find('span.nickname').html(''); + I18n._($commentEntry.find('span.nickname i'), 'Anonymous'); + } + + // set date + $commentEntry.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) { + $commentEntry.find('span.nickname') + .before( + ' ' + ); + $(document).on('languageLoaded', function () { + $commentEntry.find('img.vizhash') + .prop('title', I18n._('Avatar generated from IP address')); + }); + } + + // finally append comment + $place.append($commentEntry); + } + + /** + * finishes the discussion area after last comment + * + * @name DiscussionViewer.finishDiscussion + * @function + */ + me.finishDiscussion = function() + { + // add 'add new comment' area + $commentContainer.append($commentTail); + + // show discussions + $discussion.removeClass('hidden'); + } + + /** + * shows the discussion area + * + * @name DiscussionViewer.showDiscussion + * @function + */ + me.showDiscussion = function() + { + $discussion.removeClass('hidden'); + } + + /** + * removes the old discussion and prepares everything for creating a new + * one. + * + * @name DiscussionViewer.prepareNewDisucssion + * @function + */ + me.prepareNewDisucssion = function() + { + $commentContainer.html(''); + $discussion.addClass('hidden'); + + // (re-)init templates + initTemplates(); + } + + /** + * returns the user put into the reply form + * + * @name DiscussionViewer.getReplyData + * @function + * @return {array} + */ + me.getReplyData = function() + { + return [ + $replyMessage.val(), + $replyNickname.val() + ]; + } + + /** + * highlights a specific comment and scrolls to it if necessary + * + * @name DiscussionViewer.highlightComment + * @function + * @param {string} commentId + * @param {bool} fadeOut - whether to fade out the comment + */ + me.highlightComment = function(commentId, fadeOut) + { + var $comment = $('#comment_' + commentId); + // in case comment does not exist, cancel + if ($comment.length === 0) { + return; + } + + var highlightComment = function () { + $comment.addClass('highlight'); + if (fadeOut === true) { + setTimeout(function () { + $comment.removeClass('highlight'); + }, 300); + } + } + + if (UiHelper.isVisible($comment)) { + return highlightComment(); + } + + UiHelper.scrollTo($comment, 100, 'swing', highlightComment); + } + + /** + * returns the id of the parent comment the user is replying to + * + * @name DiscussionViewer.getReplyCommentId + * @function + * @return {int|undefined} + */ + me.getReplyCommentId = function() + { + return replyCommentId; + } + + /** + * initiate + * + * preloads jQuery elements + * + * @name DiscussionViewer.init + * @function + */ + me.init = function() + { + // bind events to templates (so they are later cloned) + $('#commenttailtemplate, #commenttemplate').find('button').on('click', openReply); + $('#replytemplate').find('button').on('click', PasteEncrypter.sendComment); + + $commentContainer = $('#commentcontainer'); + $discussion = $('#discussion'); + } + + return me; + })(window, document); + + /** + * Manage top (navigation) bar + * + * @name TopNav + * @param {object} window + * @param {object} document + * @class + */ + var TopNav = (function (window, document) { + var me = {}; + + var createButtonsDisplayed = false; + var viewButtonsDisplayed = false; + + var $attach, + $burnAfterReading, + $burnAfterReadingOption, + $cloneButton, + $customAttachment, + $expiration, + $fileRemoveButton, + $fileWrap, + $formatter, + $newButton, + $openDiscussion, + $openDiscussionOption, + $password, + $passwordInput, + $rawTextButton, + $sendButton; + + var pasteExpiration = '1week'; + + /** + * set the expiration on bootstrap templates in dropdown + * + * @name TopNav.updateExpiration + * @private + * @function + * @param {Event} event + */ + function updateExpiration(event) + { + // get selected option + var target = $(event.target); + + // update dropdown display and save new expiration time + $('#pasteExpirationDisplay').text(target.text()); + pasteExpiration = target.data('expiration'); + + event.preventDefault(); + } + + /** + * set the format on bootstrap templates in dropdown + * + * @name TopNav.updateFormat + * @private + * @function + * @param {Event} event + */ + function updateFormat(event) + { + // get selected option + var $target = $(event.target); + + // update dropdown display and save new format + var newFormat = $target.data('format'); + $('#pasteFormatterDisplay').text($target.text()); + PasteViewer.setFormat(newFormat); + + // update preview + if (Editor.isPreview()) { + PasteViewer.run(); + } + + event.preventDefault(); + } + + /** + * when "burn after reading" is checked, disable discussion + * + * @name TopNav.changeBurnAfterReading + * @private + * @function + */ + function changeBurnAfterReading() + { + if ($burnAfterReading.is(':checked')) { + $openDiscussionOption.addClass('buttondisabled'); + $openDiscussion.prop('checked', false); + + // if button is actually disabled, force-enable it and uncheck other button + $burnAfterReadingOption.removeClass('buttondisabled'); + } else { + $openDiscussionOption.removeClass('buttondisabled'); + } + } + + /** + * when discussion is checked, disable "burn after reading" + * + * @name TopNav.changeOpenDiscussion + * @private + * @function + */ + function changeOpenDiscussion() + { + if ($openDiscussion.is(':checked')) { + $burnAfterReadingOption.addClass('buttondisabled'); + $burnAfterReading.prop('checked', false); + + // if button is actually disabled, force-enable it and uncheck other button + $openDiscussionOption.removeClass('buttondisabled'); + } else { + $burnAfterReadingOption.removeClass('buttondisabled'); + } + } + + /** + * return raw text + * + * @name TopNav.rawText + * @private + * @function + * @param {Event} event + */ + function rawText(event) + { + TopNav.hideAllButtons(); + Alert.showLoading('Showing raw text…', 0, 'time'); + var paste = PasteViewer.getText(); + + // push a new state to allow back navigation with browser back button + history.pushState( + {type: 'raw'}, + document.title, + // recreate paste URL + Helper.baseUri() + '?' + Model.getPasteId() + '#' + + Model.getPasteKey() + ); + + // 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 $head = $('head').children().not('noscript, script, link[type="text/css"]'); + var newDoc = document.open('text/html', 'replace'); + newDoc.write(''); + for (var i = 0; i < $head.length; i++) { + newDoc.write($head[i].outerHTML); + } + newDoc.write('
' + Helper.htmlEntities(paste) + '
'); + newDoc.close(); + } + + /** + * saves the language in a cookie and reloads the page + * + * @name TopNav.setLanguage + * @private + * @function + * @param {Event} event + */ + function setLanguage(event) + { + document.cookie = 'lang=' + $(event.target).data('lang'); + UiHelper.reloadHome(); + } + + /** + * hides all messages and creates a new paste + * + * @name TopNav.clickNewPaste + * @private + * @function + * @param {Event} event + */ + function clickNewPaste(event) + { + Controller.hideStatusMessages(); + Controller.newPaste(); + } + + /** + * removes the existing attachment + * + * @name TopNav.removeAttachment + * @private + * @function + * @param {Event} event + */ + function removeAttachment(event) + { + // if custom attachment is used, remove it first + if (!$customAttachment.hasClass('hidden')) { + AttachmentViewer.removeAttachment(); + $customAttachment.addClass('hidden'); + $fileWrap.removeClass('hidden'); + } + + // our up-to-date jQuery can handle it :) + $fileWrap.find('input').val(''); + + // pevent '#' from appearing in the URL + event.preventDefault(); + } + + /** + * Shows all elements belonging to viwing an existing pastes + * + * @name TopNav.showViewButtons + * @function + */ + me.showViewButtons = function() + { + if (viewButtonsDisplayed) { + console.log('showViewButtons: view buttons are already displayed'); + return; + } + + $newButton.removeClass('hidden'); + $cloneButton.removeClass('hidden'); + $rawTextButton.removeClass('hidden'); + + viewButtonsDisplayed = true; + } + + /** + * Hides all elements belonging to existing pastes + * + * @name TopNav.hideViewButtons + * @function + */ + me.hideViewButtons = function() + { + if (!viewButtonsDisplayed) { + console.log('hideViewButtons: view buttons are already hidden'); + return; + } + + $newButton.addClass('hidden'); + $cloneButton.addClass('hidden'); + $rawTextButton.addClass('hidden'); + + viewButtonsDisplayed = false; + } + + /** + * Hides all elements belonging to existing pastes + * + * @name TopNav.hideAllButtons + * @function + */ + me.hideAllButtons = function() + { + me.hideViewButtons(); + me.hideCreateButtons(); + } + + /** + * shows all elements needed when creating a new paste + * + * @name TopNav.showCreateButtons + * @function + */ + me.showCreateButtons = function() + { + if (createButtonsDisplayed) { + console.log('showCreateButtons: create buttons are already displayed'); + return; + } + + $sendButton.removeClass('hidden'); + $expiration.removeClass('hidden'); + $formatter.removeClass('hidden'); + $burnAfterReadingOption.removeClass('hidden'); + $openDiscussionOption.removeClass('hidden'); + $newButton.removeClass('hidden'); + $password.removeClass('hidden'); + $attach.removeClass('hidden'); + + createButtonsDisplayed = true; + } + + /** + * shows all elements needed when creating a new paste + * + * @name TopNav.hideCreateButtons + * @function + */ + me.hideCreateButtons = function() + { + if (!createButtonsDisplayed) { + console.log('hideCreateButtons: create buttons are already hidden'); + return; + } + + $newButton.addClass('hidden'); + $sendButton.addClass('hidden'); + $expiration.addClass('hidden'); + $formatter.addClass('hidden'); + $burnAfterReadingOption.addClass('hidden'); + $openDiscussionOption.addClass('hidden'); + $password.addClass('hidden'); + $attach.addClass('hidden'); + + createButtonsDisplayed = false; + } + + /** + * only shows the "new paste" button + * + * @name TopNav.showNewPasteButton + * @function + */ + me.showNewPasteButton = function() + { + $newButton.removeClass('hidden'); + } + + /** + * only hides the clone button + * + * @name TopNav.hideCloneButton + * @function + */ + me.hideCloneButton = function() + { + $cloneButton.addClass('hidden'); + } + + /** + * only hides the raw text button + * + * @name TopNav.hideRawButton + * @function + */ + me.hideRawButton = function() + { + $rawTextButton.addClass('hidden'); + } + + /** + * hides the file selector in attachment + * + * @name TopNav.hideFileSelector + * @function + */ + me.hideFileSelector = function() + { + $fileWrap.addClass('hidden'); + } + + + /** + * shows the custom attachment + * + * @name TopNav.showCustomAttachment + * @function + */ + me.showCustomAttachment = function() + { + $customAttachment.removeClass('hidden'); + } + + /** + * collapses the navigation bar if nedded + * + * @name TopNav.collapseBar + * @function + */ + me.collapseBar = function() + { + var $bar = $('.navbar-toggle'); + + // check if bar is expanded + if ($bar.hasClass('collapse in')) { + // if so, toggle it + $bar.click(); + } + } + + /** + * returns the currently set expiration time + * + * @name TopNav.getExpiration + * @function + * @return {int} + */ + me.getExpiration = function() + { + return pasteExpiration; + } + + /** + * returns the currently selected file(s) + * + * @name TopNav.getFileList + * @function + * @return {FileList|null} + */ + me.getFileList = function() + { + var $file = $('#file'); + + // if no file given, return null + if (!$file.length || !$file[0].files.length) { + return null; + } + // @TODO is this really necessary + if (!($file[0].files && $file[0].files[0])) { + return null; + } + + return $file[0].files; + } + + /** + * returns the state of the burn after reading checkbox + * + * @name TopNav.getExpiration + * @function + * @return {bool} + */ + me.getBurnAfterReading = function() + { + return $burnAfterReading.is(':checked'); + } + + /** + * returns the state of the discussion checkbox + * + * @name TopNav.getOpenDiscussion + * @function + * @return {bool} + */ + me.getOpenDiscussion = function() + { + return $openDiscussion.is(':checked'); + } + + /** + * returns the entered password + * + * @name TopNav.getPassword + * @function + * @return {string} + */ + me.getPassword = function() + { + return $passwordInput.val(); + } + + /** + * returns the element where custom attachments can be placed + * + * Used by AttachmentViewer when an attachment is cloned here. + * + * @name TopNav.getCustomAttachment + * @function + * @return {jQuery} + */ + me.getCustomAttachment = function() + { + return $customAttachment; + } + + /** + * init navigation manager + * + * preloads jQuery elements + * + * @name TopNav.init + * @function + */ + me.init = function() + { + $attach = $('#attach'); + $burnAfterReading = $('#burnafterreading'); + $burnAfterReadingOption = $('#burnafterreadingoption'); + $cloneButton = $('#clonebutton'); + $customAttachment = $('#customattachment'); + $expiration = $('#expiration'); + $fileRemoveButton = $('#fileremovebutton'); + $fileWrap = $('#filewrap'); + $formatter = $('#formatter'); + $newButton = $('#newbutton'); + $openDiscussion = $('#opendiscussion'); + $openDiscussionOption = $('#opendiscussionoption'); + $password = $('#password'); + $passwordInput = $('#passwordinput'); + $rawTextButton = $('#rawtextbutton'); + $sendButton = $('#sendbutton'); + + // bootstrap template drop down + $('#language ul.dropdown-menu li a').click(setLanguage); + // page template drop down + $('#language select option').click(setLanguage); + + // bind events + $burnAfterReading.change(changeBurnAfterReading); + $openDiscussionOption.change(changeOpenDiscussion); + $newButton.click(clickNewPaste); + $sendButton.click(PasteEncrypter.sendPaste); + $cloneButton.click(Controller.clonePaste); + $rawTextButton.click(rawText); + $fileRemoveButton.click(removeAttachment); // 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(updateExpiration); + $('ul.dropdown-menu li a', $('#formatter').parent()).click(updateFormat); - // page template drop down - $('#language select option').click($.proxy(this.setLanguage, this)); + // initiate default state of checkboxes + changeBurnAfterReading(); + changeOpenDiscussion(); - // 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)); + // get default value from template or fall back to set value + pasteExpiration = Model.getExpirationDefault() || pasteExpiration; + } - $(window).on('popstate', $.proxy(this.historyChange, this)); - }, + return me; + })(window, document); + + /** + * Responsible for AJAX requests, transparently handles encryption… + * + * @name Uploader + * @class + */ + var Uploader = (function () { + var me = {}; + + var successFunc = null, + failureFunc = null, + url, + data, + symmetricKey, + password; /** - * main application + * public variable ('constant') for errors to prevent magic numbers * - * @name controller.init - * @function + * @name Uploader.error + * @readonly + * @enum {Object} */ - init: function() + me.error = { + okay: 0, + custom: 1, + unknown: 2, + serverError: 3 + }; + + /** + * ajaxHeaders to send in AJAX requests + * + * @name Uploader.ajaxHeaders + * @private + * @readonly + * @enum {Object} + */ + var ajaxHeaders = {'X-Requested-With': 'JSONHttpRequest'}; + + /** + * called after successful upload + * + * @name Uploader.checkCryptParameters + * @private + * @function + * @throws {string} + */ + function checkCryptParameters() { - // 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 - // 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(); - - // display status returned by php code, if any (eg. paste was properly deleted) - if (this.status.text().length > 0) - { - this.showStatus(this.status.text()); - return; + // workaround for this nasty 'bug' in ECMAScript + // see https://stackoverflow.com/questions/18808226/why-is-typeof-null-object + var typeOfKey = typeof symmetricKey; + if (symmetricKey === null) { + typeOfKey = 'null'; } - // keep line height even if content empty - this.status.html(' '); + // in case of missing preparation, throw error + switch (typeOfKey) { + case 'string': + // already set, all right + return; + case 'null': + // needs to be generated auto-generate + symmetricKey = CryptTool.getSymmetricKey(); + break; + default: + console.error('current invalid symmetricKey:', symmetricKey); + throw 'symmetricKey is invalid, probably the module was not prepared'; + } + // password is optional + } - // display an existing paste - if (this.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?)')); + /** + * called after successful upload + * + * @name Uploader.success + * @private + * @function + * @param {int} status + * @param {int} data - optional + */ + function success(status, result) + { + // add useful data to result + result.encryptionKey = symmetricKey; + result.requestData = data; + + if (successFunc !== null) { + successFunc(status, result); + } + } + + /** + * called after a upload failure + * + * @name Uploader.fail + * @private + * @function + * @param {int} status - internal code + * @param {int} data - original error code + */ + function fail(status, result) + { + if (failureFunc !== null) { + failureFunc(status, result); + } + } + + /** + * actually uploads the data + * + * @name Uploader.run + * @function + */ + me.run = function() + { + $.ajax({ + type: 'POST', + url: url, + data: data, + dataType: 'json', + headers: ajaxHeaders, + success: function(result) { + if (result.status === 0) { + success(0, result); + } else if (result.status === 1) { + fail(1, result); + } else { + fail(2, result); + } + } + }) + .fail(function(jqXHR, textStatus, errorThrown) { + console.error(textStatus, errorThrown); + fail(3, jqXHR); + }); + } + + /** + * set success function + * + * @name Uploader.setUrl + * @function + * @param {function} func + */ + me.setUrl = function(newUrl) + { + url = newUrl; + } + + /** + * sets the password to use (first value) and optionally also the + * encryption key (not recommend, it is automatically generated). + * + * Note: Call this after prepare() as prepare() resets these values. + * + * @name Uploader.setCryptValues + * @function + * @param {string} newPassword + * @param {string} newKey - optional + */ + me.setCryptParameters = function(newPassword, newKey) + { + password = newPassword; + + if (typeof newKey !== 'undefined') { + symmetricKey = newKey; + } + } + + /** + * set success function + * + * @name Uploader.setSuccess + * @function + * @param {function} func + */ + me.setSuccess = function(func) + { + successFunc = func; + } + + /** + * set failure function + * + * @name Uploader.setFailure + * @function + * @param {function} func + */ + me.setFailure = function(func) + { + failureFunc = func; + } + + /** + * prepares a new upload + * + * Call this when doing a new upload to reset any data from potential + * previous uploads. Must be called before any other method of this + * module. + * + * @name Uploader.prepare + * @function + * @return {object} + */ + me.prepare = function() + { + // entropy should already be checked! + + // reset password + password = ''; + + // reset key, so it a new one is generated when it is used + symmetricKey = null; + + // reset data + successFunc = null; + failureFunc = null; + url = Helper.baseUri(); + data = {}; + } + + /** + * encrypts and sets the data + * + * @name Uploader.setData + * @function + * @param {string} index + * @param {mixed} element + */ + me.setData = function(index, element) + { + checkCryptParameters(); + data[index] = CryptTool.cipher(symmetricKey, password, element); + } + + /** + * set the additional metadata to send unencrypted + * + * @name Uploader.setUnencryptedData + * @function + * @param {string} index + * @param {mixed} element + */ + me.setUnencryptedData = function(index, element) + { + data[index] = element; + } + + /** + * set the additional metadata to send unencrypted passed at once + * + * @name Uploader.setUnencryptedData + * @function + * @param {object} newData + */ + me.setUnencryptedBulkData = function(newData) + { + $.extend(data, newData); + } + + /** + * Helper, which parses shows a general error message based on the result of the Uploader + * + * @name Uploader.parseUploadError + * @function + * @param {int} status + * @param {object} data + * @param {string} doThisThing - a human description of the action, which was tried + * @return {array} + */ + me.parseUploadError = function(status, data, doThisThing) { + var errorArray = ['Error while parsing error message.']; + + switch (status) { + case Uploader.error['custom']: + errorArray = ['Could not ' + doThisThing + ': %s', data.message]; + break; + case Uploader.error['unknown']: + errorArray = ['Could not ' + doThisThing + ': %s', I18n._('unknown status')]; + break; + case Uploader.error['serverError']: + errorArray = ['Could not ' + doThisThing + ': %s', I18n._('server error or not responding')]; break; + default: + errorArray = ['Could not ' + doThisThing + ': %s', I18n._('unknown error')]; + break; + } + + return errorArray; + } + + /** + * init Uploader + * + * @name Uploader.init + * @function + */ + me.init = function() + { + // nothing yet + } + + return me; + })(); + + /** + * (controller) Responsible for encrypting paste and sending it to server. + * + * Does upload, encryption is done transparently by Uploader. + * + * @name PasteEncrypter + * @class + */ + var PasteEncrypter = (function () { + var me = {}; + + var requirementsChecked = false; + + /** + * checks whether there is a suitable amount of entrophy + * + * @name PasteEncrypter.checkRequirements + * @private + * @function + * @param {function} retryCallback - the callback to execute to retry the upload + * @return {bool} + */ + function checkRequirements(retryCallback) { + // skip double requirement checks + if (requirementsChecked === true) { + return true; + } + + if (!CryptTool.isEntropyReady()) { + // display a message and wait + Alert.showStatus('Please move your mouse for more entropy…'); + + CryptTool.addEntropySeedListener(retryCallback); + return false; + } + + requirementsChecked = true; + + return true; + } + + /** + * called after successful paste upload + * + * @name PasteEncrypter.showCreatedPaste + * @private + * @function + * @param {int} status + * @param {object} data + */ + function showCreatedPaste(status, data) { + Alert.hideLoading(); + + var url = Helper.baseUri() + '?' + data.id + '#' + data.encryptionKey, + deleteUrl = Helper.baseUri() + '?pasteid=' + data.id + '&deletetoken=' + data.deletetoken; + + Alert.hideMessages(); + + // show notification + PasteStatus.createPasteNotification(url, deleteUrl) + + // show new URL in browser bar + history.pushState({type: 'newpaste'}, document.title, url); + + TopNav.showViewButtons(); + TopNav.hideRawButton(); + Editor.hide(); + + // parse and show text + // (preparation already done in me.sendPaste()) + PasteViewer.run(); + } + + /** + * called after successful comment upload + * + * @name PasteEncrypter.showUploadedComment + * @private + * @function + * @param {int} status + * @param {object} data + */ + function showUploadedComment(status, data) { + // show success message + // Alert.showStatus('Comment posted.'); + + // reload paste + Controller.refreshPaste(function () { + // highlight sent comment + DiscussionViewer.highlightComment(data.id, true); + // reset error handler + Alert.setCustomHandler(null); + }); + } + + /** + * adds attachments to the Uploader + * + * @name PasteEncrypter.encryptAttachments + * @private + * @function + * @param {File|null|undefined} file - optional, falls back to cloned attachment + * @param {function} callback - excuted when action is successful + */ + function encryptAttachments(file, callback) { + if (typeof file !== 'undefined' && file !== null) { + // check file reader requirements for upload + if (typeof FileReader === 'undefined') { + Alert.showError('Your browser does not support uploading encrypted files. Please use a newer browser.'); + // cancels process as it does not execute callback return; } - // show proper elements on screen - this.stateExistingPaste(); - this.displayMessages(); - } - // display error message from php code - else if (this.errorMessage.text().length > 1) - { - this.showError(this.errorMessage.text()); - } - // create a new paste - else - { - this.newPaste(); + var reader = new FileReader(); + + // closure to capture the file information + reader.onload = function(event) { + Uploader.setData('attachment', event.target.result); + Uploader.setData('attachmentname', file.name); + + // run callback + return callback(); + } + + // actually read first file + reader.readAsDataURL(file); + } else if (AttachmentViewer.hasAttachment()) { + // fall back to cloned part + var attachment = AttachmentViewer.getAttachment(); + + Uploader.setData('attachment', attachment[0]); + Uploader.setData('attachmentname', attachment[1]); + return callback(); + } else { + // if there are no attachments, this is of course still successful + return callback(); } } - } + + /** + * send a reply in a discussion + * + * @name PasteEncrypter.sendComment + * @function + */ + me.sendComment = function() + { + Alert.hideMessages(); + Alert.setCustomHandler(DiscussionViewer.handleNotification); + + // UI loading state + TopNav.hideAllButtons(); + Alert.showLoading('Sending comment…', 0, 'cloud-upload'); + + // get data, note that "var [x, y] = " structures aren't supported in all JS environments + var replyData = DiscussionViewer.getReplyData(), + plainText = replyData[0], + nickname = replyData[1], + parentid = DiscussionViewer.getReplyCommentId(); + + // do not send if there is no data + if (plainText.length === 0) { + // revert loading status… + Alert.hideLoading(); + Alert.setCustomHandler(null); + TopNav.showViewButtons(); + return; + } + + // check entropy + if (!checkRequirements(function () { + me.sendComment(); + })) { + return; // to prevent multiple executions + } + Alert.showLoading(null, 10); + + // prepare Uploader + Uploader.prepare(); + Uploader.setCryptParameters(Prompt.getPassword(), Model.getPasteKey()); + + // set success/fail functions + Uploader.setSuccess(showUploadedComment); + Uploader.setFailure(function (status, data) { + // revert loading status… + Alert.hideLoading(); + TopNav.showViewButtons(); + + // show error message + Alert.showError(Uploader.parseUploadError(status, data, 'post comment')); + + // reset error handler + Alert.setCustomHandler(null); + }); + + // fill it with unencrypted params + Uploader.setUnencryptedData('pasteid', Model.getPasteId()); + if (typeof parentid === 'undefined') { + // if parent id is not set, this is the top-most comment, so use + // paste id as parent @TODO is this really good? + Uploader.setUnencryptedData('parentid', Model.getPasteId()); + } else { + Uploader.setUnencryptedData('parentid', parentid); + } + + // encrypt data + Uploader.setData('data', plainText); + + if (nickname.length > 0) { + Uploader.setData('nickname', nickname); + } + + Uploader.run(); + } + + /** + * sends a new paste to server + * + * @name PasteEncrypter.sendPaste + * @function + */ + me.sendPaste = function() + { + // hide previous (error) messages + Controller.hideStatusMessages(); + + // UI loading state + TopNav.hideAllButtons(); + Alert.showLoading('Sending paste…', 0, 'cloud-upload'); + TopNav.collapseBar(); + + // get data + var plainText = Editor.getText(), + format = PasteViewer.getFormat(), + files = TopNav.getFileList(); + + // do not send if there is no data + if (plainText.length === 0 && files === null) { + // revert loading status… + Alert.hideLoading(); + TopNav.showCreateButtons(); + return; + } + + Alert.showLoading(null, 10); + + // check entropy + if (!checkRequirements(function () { + me.sendPaste(); + })) { + return; // to prevent multiple executions + } + + // prepare Uploader + Uploader.prepare(); + Uploader.setCryptParameters(TopNav.getPassword()); + + // set success/fail functions + Uploader.setSuccess(showCreatedPaste); + Uploader.setFailure(function (status, data) { + // revert loading status… + Alert.hideLoading(); + TopNav.showCreateButtons(); + + // show error message + Alert.showError(Uploader.parseUploadError(status, data, 'create paste')); + }); + + // fill it with unencrypted submitted options + Uploader.setUnencryptedBulkData({ + expire: TopNav.getExpiration(), + formatter: format, + burnafterreading: TopNav.getBurnAfterReading() ? 1 : 0, + opendiscussion: TopNav.getOpenDiscussion() ? 1 : 0 + }); + + // prepare PasteViewer for later preview + PasteViewer.setText(plainText); + PasteViewer.setFormat(format); + + // encrypt cipher data + Uploader.setData('data', plainText); + + // encrypt attachments + encryptAttachments( + files === null ? null : files[0], + function () { + // send data + Uploader.run(); + } + ); + } + + /** + * initialize + * + * @name PasteEncrypter.init + * @function + */ + me.init = function() + { + // nothing yet + } + + return me; + })(); /** - * main application start, called when DOM is fully loaded and - * runs controller initalization after translations are loaded + * (controller) Responsible for decrypting cipherdata and passing data to view. + * + * Only decryption, no download. + * + * @name PasteDecrypter + * @class */ - $(i18n.loadTranslations); + var PasteDecrypter = (function () { + var me = {}; + + /** + * decrypt data or prompts for password in cvase of failure + * + * @name PasteDecrypter.decryptOrPromptPassword + * @private + * @function + * @param {string} key + * @param {string} password - optional, may be an empty string + * @param {string} cipherdata + * @throws {string} + * @return {false|string} false, when unsuccessful or string (decrypted data) + */ + function decryptOrPromptPassword(key, password, cipherdata) + { + // try decryption without password + var plaindata = CryptTool.decipher(key, password, cipherdata); + + // if it fails, request password + if (plaindata.length === 0 && password.length === 0) { + // try to get cached password first + password = Prompt.getPassword(); + + // if password is there, re-try + if (password.length === 0) { + password = Prompt.requestPassword(); + } + // recursive + // note: an infinite loop is prevented as the previous if + // clause checks whether a password is already set and ignores + // errors when a password has been passed + return decryptOrPromptPassword.apply(key, password, cipherdata); + } + + // if all tries failed, we can only return an error + if (plaindata.length === 0) { + throw 'failed to decipher data'; + } + + return plaindata; + } + + /** + * decrypt the actual paste text + * + * @name PasteDecrypter.decryptOrPromptPassword + * @private + * @function + * @param {object} paste - paste data in object form + * @param {string} key + * @param {string} password + * @param {bool} ignoreError - ignore decryption errors iof set to true + * @return {bool} whether action was successful + * @throws {string} + */ + function decryptPaste(paste, key, password, ignoreError) + { + var plaintext + if (ignoreError === true) { + plaintext = CryptTool.decipher(key, password, paste.data); + } else { + try { + plaintext = decryptOrPromptPassword(key, password, paste.data); + } catch (err) { + throw 'failed to decipher paste text: ' + err + } + if (plaintext === false) { + return false; + } + } + + // on success show paste + PasteViewer.setFormat(paste.meta.formatter); + PasteViewer.setText(plaintext); + // trigger to show the text (attachment loaded afterwards) + PasteViewer.run(); + + return true; + } + + /** + * decrypts any attachment + * + * @name PasteDecrypter.decryptAttachment + * @private + * @function + * @param {object} paste - paste data in object form + * @param {string} key + * @param {string} password + * @return {bool} whether action was successful + * @throws {string} + */ + function decryptAttachment(paste, key, password) + { + // decrypt attachment + try { + var attachment = decryptOrPromptPassword(key, password, paste.attachment); + } catch (err) { + throw 'failed to decipher attachment: ' + err + } + if (attachment === false) { + return false; + } + + // decrypt attachment name + var attachmentName; + if (paste.attachmentname) { + try { + attachmentName = decryptOrPromptPassword(key, password, paste.attachmentname); + } catch (err) { + throw 'failed to decipher attachment name: ' + err + } + if (attachmentName === false) { + return false; + } + } + + AttachmentViewer.setAttachment(attachment, attachmentName); + AttachmentViewer.showAttachment(); + + return true; + } + + /** + * decrypts all comments and shows them + * + * @name PasteDecrypter.decryptComments + * @private + * @function + * @param {object} paste - paste data in object form + * @param {string} key + * @param {string} password + * @return {bool} whether action was successful + */ + function decryptComments(paste, key, password) + { + // remove potentially previous discussion + DiscussionViewer.prepareNewDisucssion(); + + // iterate over comments + for (var i = 0; i < paste.comments.length; ++i) { + var comment = paste.comments[i]; + + DiscussionViewer.addComment( + comment, + CryptTool.decipher(key, password, comment.data), + CryptTool.decipher(key, password, comment.meta.nickname) + ); + } + + DiscussionViewer.finishDiscussion(); + DiscussionViewer.showDiscussion(); + return true; + } + + /** + * show decrypted text in the display area, including discussion (if open) + * + * @name PasteDecrypter.run + * @function + * @param {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta')) + */ + me.run = function(paste) + { + Alert.hideMessages(); + Alert.showLoading('Decrypting paste…', 0, 'cloud-download'); // @TODO icon maybe rotation-lock, but needs full Glyphicons + + if (typeof paste === 'undefined') { + paste = $.parseJSON(Model.getCipherData()); + } + + var key = Model.getPasteKey(), + password = Prompt.getPassword(); + + if (PasteViewer.isPrettyPrinted()) { + console.error('Too pretty! (don\'t know why this check)'); //@TODO + return; + } + + // try to decrypt the paste + try { + // decrypt attachments + if (paste.attachment) { + // try to decrypt paste and if it fails (because the password is + // missing) return to let JS continue and wait for user + if (!decryptAttachment(paste, key, password)) { + return; + } + // ignore empty paste, as this is allowed when pasting attachments + decryptPaste(paste, key, password, true); + } else { + decryptPaste(paste, key, password); + } + + + // shows the remaining time (until) deletion + PasteStatus.showRemainingTime(paste.meta); + + // if the discussion is opened on this paste, display it + if (paste.meta.opendiscussion) { + decryptComments(paste, key, password); + } + + Alert.hideLoading(); + TopNav.showViewButtons(); + } catch(err) { + Alert.hideLoading(); + + // log and show error + console.error(err); + Alert.showError('Could not decrypt data (Wrong key?)'); + } + } + + /** + * initialize + * + * @name PasteDecrypter.init + * @function + */ + me.init = function() + { + // nothing yet + } + + return me; + })(); + + /** + * (controller) main PrivateBin logic + * + * @name Controller + * @param {object} window + * @param {object} document + * @class + */ + var Controller = (function (window, document) { + var me = {}; + + /** + * hides all status messages no matter which module showed them + * + * @name Controller.hideStatusMessages + * @function + */ + me.hideStatusMessages = function() + { + PasteStatus.hideMessages(); + Alert.hideMessages(); + } + + /** + * creates a new paste + * + * @name Controller.newPaste + * @function + */ + me.newPaste = function() + { + // Important: This *must not* run Alert.hideMessages() as previous + // errors from viewing a paste should be shown. + TopNav.hideAllButtons(); + Alert.showLoading('Preparing new paste…', 0, 'time'); + + PasteStatus.hideMessages(); + PasteViewer.hide(); + Editor.resetInput(); + Editor.show(); + Editor.focusInput(); + + TopNav.showCreateButtons(); + Alert.hideLoading(); + } + + /** + * shows the loaded paste + * + * @name Controller.showPaste + * @function + */ + me.showPaste = function() + { + try { + Model.getPasteId(); + Model.getPasteKey(); + } catch (err) { + console.error(err); + + // missing decryption key (or paste ID) in URL? + if (window.location.hash.length === 0) { + Alert.showError('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)'); + // @TODO adjust error message as it is less specific now, probably include thrown exception for a detailed error + return; + } + } + + // show proper elements on screen + PasteDecrypter.run(); + return; + } + + /** + * refreshes the loaded paste to show potential new data + * + * @name Controller.refreshPaste + * @function + * @param {function} callback + */ + me.refreshPaste = function(callback) + { + // save window position to restore it later + var orgPosition = $(window).scrollTop(); + + Uploader.prepare(); + Uploader.setUrl(Helper.baseUri() + '?' + Model.getPasteId()); + + Uploader.setFailure(function (status, data) { + // revert loading status… + Alert.hideLoading(); + TopNav.showViewButtons(); + + // show error message + Alert.showError(Uploader.parseUploadError(status, data, 'refresh display')); + }) + Uploader.setSuccess(function (status, data) { + PasteDecrypter.run(data); + + // restore position + window.scrollTo(0, orgPosition); + + callback(); + }) + Uploader.run(); + } + + /** + * clone the current paste + * + * @name Controller.clonePaste + * @function + * @param {Event} event + */ + me.clonePaste = function(event) + { + TopNav.collapseBar(); + TopNav.hideAllButtons(); + Alert.showLoading('Cloning paste…', 0, 'transfer'); + + // hide messages from previous paste + me.hideStatusMessages(); + + // erase the id and the key in url + history.pushState({type: 'clone'}, document.title, Helper.baseUri()); + + if (AttachmentViewer.hasAttachment()) { + AttachmentViewer.moveAttachmentTo( + TopNav.getCustomAttachment(), + 'Cloned: \'%s\'' + ); + TopNav.hideFileSelector(); + AttachmentViewer.hideAttachment(); + // NOTE: it also looks nice without removing the attachment + // but for a consistent display we remove it… + AttachmentViewer.hideAttachmentPreview(); + TopNav.showCustomAttachment(); + + // show another status message to make the user aware that the + // file was cloned too! + Alert.showStatus( + [ + 'The cloned file \'%s\' was attached to this paste.', + AttachmentViewer.getAttachment()[1] + ], 'copy', true, true); + } + + Editor.setText(PasteViewer.getText()) + PasteViewer.hide(); + Editor.show(); + + Alert.hideLoading(); + TopNav.showCreateButtons(); + } + + /** + * removes a saved paste + * + * @name Controller.removePaste + * @function + * @param {string} pasteId + * @param {string} deleteToken + */ + me.removePaste = function(pasteId, deleteToken) { + // unfortunately many web servers don't support DELETE (and PUT) out of the box + // so we use a POST request + Uploader.prepare(); + Uploader.setUrl(Helper.baseUri() + '?' + pasteId); + Uploader.setUnencryptedData('deletetoken', deleteToken); + + Uploader.setFailure(function () { + Controller.showError(I18n._('Could not delete the paste, it was not stored in burn after reading mode.')); + }) + Uploader.run(); + } + + /** + * application start + * + * @name Controller.init + * @function + */ + me.init = function() + { + // first load translations + I18n.loadTranslations(); + + // initialize other modules/"classes" + Alert.init(); + Model.init(); + + AttachmentViewer.init(); + DiscussionViewer.init(); + Editor.init(); + PasteDecrypter.init(); + PasteEncrypter.init(); + PasteStatus.init(); + PasteViewer.init(); + Prompt.init(); + TopNav.init(); + UiHelper.init(); + Uploader.init(); + + // display an existing paste + if (Model.hasCipherData()) { + return me.showPaste(); + } + + // otherwise create a new paste + me.newPaste(); + } + + return me; + })(window, document); return { - helper: helper, - i18n: i18n, - filter: filter, - controller: controller + Helper: Helper, + I18n: I18n, + CryptTool: CryptTool, + Model: Model, + UiHelper: UiHelper, + Alert: Alert, + PasteStatus: PasteStatus, + Prompt: Prompt, + Editor: Editor, + PasteViewer: PasteViewer, + AttachmentViewer: AttachmentViewer, + DiscussionViewer: DiscussionViewer, + TopNav: TopNav, + Uploader: Uploader, + PasteEncrypter: PasteEncrypter, + PasteDecrypter: PasteDecrypter, + Controller: Controller }; }(jQuery, sjcl, Base64, RawDeflate); diff --git a/js/test.js b/js/test.js index b2d5f00..e83d43b 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'); @@ -20,127 +22,74 @@ global.RawDeflate = require('./rawdeflate-0.5'); require('./rawinflate-0.3'); require('./privatebin'); -describe('helper', function () { +describe('Helper', function () { describe('secondsToHuman', function () { after(function () { cleanup(); }); jsc.property('returns an array with a number and a word', 'integer', function (number) { - var result = $.PrivateBin.helper.secondsToHuman(number); + var result = $.PrivateBin.Helper.secondsToHuman(number); return Array.isArray(result) && result.length === 2 && result[0] === parseInt(result[0], 10) && typeof result[1] === 'string'; }); jsc.property('returns seconds on the first array position', 'integer 59', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[0] === number; + return $.PrivateBin.Helper.secondsToHuman(number)[0] === number; }); jsc.property('returns seconds on the second array position', 'integer 59', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[1] === 'second'; + return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'second'; }); jsc.property('returns minutes on the first array position', 'integer 60 3599', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / 60); + return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / 60); }); jsc.property('returns minutes on the second array position', 'integer 60 3599', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[1] === 'minute'; + return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'minute'; }); jsc.property('returns hours on the first array position', 'integer 3600 86399', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60)); + return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60)); }); jsc.property('returns hours on the second array position', 'integer 3600 86399', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[1] === 'hour'; + return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'hour'; }); jsc.property('returns days on the first array position', 'integer 86400 5184000', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24)); + return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24)); }); jsc.property('returns days on the second array position', 'integer 86400 5184000', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[1] === 'day'; + return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'day'; }); // max safe integer as per http://ecma262-5.com/ELS5_HTML.htm#Section_8.5 jsc.property('returns months on the first array position', 'integer 5184000 9007199254740991', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30)); + return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30)); }); jsc.property('returns months on the second array position', 'integer 5184000 9007199254740991', function (number) { - return $.PrivateBin.helper.secondsToHuman(number)[1] === 'month'; + return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'month'; }); }); - describe('scriptLocation', function () { + describe('baseUri', function () { + before(function () { + $.PrivateBin.Helper.reset(); + }); + 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.scriptLocation(); + result = $.PrivateBin.Helper.baseUri(); + $.PrivateBin.Helper.reset(); clean(); return expected === result; } ); }); - describe('pasteId', function () { - jsc.property( - 'returns the query string without separator, if any', - jsc.nearray(jsc.elements(a2zString)), - jsc.nearray(jsc.elements(a2zString)), - jsc.array(jsc.elements(queryString)), - 'string', - function (schema, address, query, fragment) { - var queryString = query.join(''), - clean = jsdom('', { - url: schema.join('') + '://' + address.join('') + - '/?' + queryString + '#' + fragment - }), - result = $.PrivateBin.helper.pasteId(); - clean(); - return queryString === result; - } - ); - }); - - describe('pageKey', function () { - jsc.property( - 'returns the fragment of the URL', - jsc.nearray(jsc.elements(a2zString)), - jsc.nearray(jsc.elements(a2zString)), - jsc.array(jsc.elements(queryString)), - jsc.array(jsc.elements(base64String)), - function (schema, address, query, fragment) { - var fragmentString = fragment.join(''), - clean = jsdom('', { - url: schema.join('') + '://' + address.join('') + - '/?' + query.join('') + '#' + fragmentString - }), - result = $.PrivateBin.helper.pageKey(); - clean(); - return fragmentString === result; - } - ); - jsc.property( - 'returns the fragment stripped of trailing query parts', - jsc.nearray(jsc.elements(a2zString)), - jsc.nearray(jsc.elements(a2zString)), - jsc.array(jsc.elements(queryString)), - jsc.array(jsc.elements(base64String)), - jsc.array(jsc.elements(queryString)), - function (schema, address, query, fragment, trail) { - var fragmentString = fragment.join(''), - clean = jsdom('', { - url: schema.join('') + '://' + address.join('') + '/?' + - query.join('') + '#' + fragmentString + '&' + trail.join('') - }), - result = $.PrivateBin.helper.pageKey(); - clean(); - return fragmentString === result; - } - ); - }); - describe('htmlEntities', function () { after(function () { cleanup(); @@ -150,9 +99,76 @@ describe('helper', function () { 'removes all HTML entities from any given string', 'string', function (string) { - var result = $.PrivateBin.helper.htmlEntities(string); + var result = $.PrivateBin.Helper.htmlEntities(string); return !(/[<>"'`=\/]/.test(result)) && !(string.indexOf('&') > -1 && !(/&/.test(result))); } ); }); }); + +describe('Model', function () { + describe('getPasteId', function () { + before(function () { + $.PrivateBin.Model.reset(); + }); + + jsc.property( + 'returns the query string without separator, if any', + jsc.nearray(jsc.elements(a2zString)), + jsc.nearray(jsc.elements(a2zString)), + jsc.nearray(jsc.elements(queryString)), + 'string', + function (schema, address, query, fragment) { + var queryString = query.join(''), + clean = jsdom('', { + url: schema.join('') + '://' + address.join('') + + '/?' + queryString + '#' + fragment + }), + result = $.PrivateBin.Model.getPasteId(); + $.PrivateBin.Model.reset(); + clean(); + return queryString === result; + } + ); + }); + + describe('getPasteKey', function () { + jsc.property( + 'returns the fragment of the URL', + jsc.nearray(jsc.elements(a2zString)), + jsc.nearray(jsc.elements(a2zString)), + jsc.array(jsc.elements(queryString)), + jsc.nearray(jsc.elements(base64String)), + function (schema, address, query, fragment) { + var fragmentString = fragment.join(''), + clean = jsdom('', { + url: schema.join('') + '://' + address.join('') + + '/?' + query.join('') + '#' + fragmentString + }), + result = $.PrivateBin.Model.getPasteKey(); + $.PrivateBin.Model.reset(); + clean(); + return fragmentString === result; + } + ); + jsc.property( + 'returns the fragment stripped of trailing query parts', + jsc.nearray(jsc.elements(a2zString)), + jsc.nearray(jsc.elements(a2zString)), + jsc.array(jsc.elements(queryString)), + jsc.nearray(jsc.elements(base64String)), + jsc.array(jsc.elements(queryString)), + function (schema, address, query, fragment, trail) { + var fragmentString = fragment.join(''), + clean = jsdom('', { + url: schema.join('') + '://' + address.join('') + '/?' + + query.join('') + '#' + fragmentString + '&' + trail.join('') + }), + result = $.PrivateBin.Model.getPasteKey(); + $.PrivateBin.Model.reset(); + clean(); + return fragmentString === result; + } + ); + }); +}); diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index cdd4525..a80e175 100644 --- a/tpl/bootstrap.php +++ b/tpl/bootstrap.php @@ -4,7 +4,7 @@ $isCpct = substr($template, 9, 8) === '-compact'; $isDark = substr($template, 9, 5) === '-dark'; $isPage = substr($template, -5) === '-page'; ?> - + @@ -69,7 +69,7 @@ if ($MARKDOWN): - + @@ -94,7 +94,7 @@ endif;
- +
@@ -121,8 +121,8 @@ endif; -
+
+
- + - + - - - + - - - - -
-
-
- -
+
+
+ + + + +

+
+
+
+ - -

- -
-
-
+
+ +
+ + + diff --git a/tpl/page.php b/tpl/page.php index 9e9a290..0090630 100644 --- a/tpl/page.php +++ b/tpl/page.php @@ -47,7 +47,7 @@ if ($MARKDOWN): - + @@ -84,6 +84,7 @@ endif;
+
@@ -124,7 +125,7 @@ endif; -
- +
#', + '#]*id="errormessage"[^>]*>.*Invalid paste ID\.#s', $content, 'outputs error correctly' ); @@ -778,7 +778,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="errormessage"[^>]*>.*Paste does not exist[^<]*
#', + '#]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s', $content, 'outputs error correctly' ); @@ -798,7 +798,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="errormessage"[^>]*>.*Paste does not exist[^<]*#', + '#]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s', $content, 'outputs error correctly' ); @@ -818,10 +818,10 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); unset($burnPaste['meta']['salt']); - $this->assertContains( - '', + $this->assertRegExp( + '#
]*>' . + preg_quote(htmlspecialchars(Helper::getPasteAsJson($burnPaste['meta']), ENT_NOQUOTES)) . + '
#', $content, 'outputs data correctly' ); @@ -889,10 +889,10 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $meta['formatter'] = 'syntaxhighlighting'; - $this->assertContains( - '', + $this->assertRegExp( + '#
]*>' . + preg_quote(htmlspecialchars(Helper::getPasteAsJson($meta), ENT_NOQUOTES)) . + '
#', $content, 'outputs data correctly' ); @@ -914,10 +914,10 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase ob_end_clean(); $oldPaste['meta']['formatter'] = 'plaintext'; unset($oldPaste['meta']['salt']); - $this->assertContains( - '', + $this->assertRegExp( + '#
]*>' . + preg_quote(htmlspecialchars(Helper::getPasteAsJson($oldPaste['meta']), ENT_NOQUOTES)) . + '
#', $content, 'outputs data correctly' ); @@ -939,7 +939,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="status"[^>]*>.*Paste was properly deleted[^<]*#s', + '#]*id="status"[^>]*>.*Paste was properly deleted\.#s', $content, 'outputs deleted status correctly' ); @@ -960,7 +960,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="errormessage"[^>]*>.*Invalid paste ID\.#', + '#]*id="errormessage"[^>]*>.*Invalid paste ID\.#s', $content, 'outputs delete error correctly' ); @@ -980,7 +980,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="errormessage"[^>]*>.*Paste does not exist[^<]*#', + '#]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s', $content, 'outputs delete error correctly' ); @@ -1000,7 +1000,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="errormessage"[^>]*>.*Wrong deletion token[^<]*#', + '#]*id="errormessage"[^>]*>.*Wrong deletion token\. Paste was not deleted\.#s', $content, 'outputs delete error correctly' ); @@ -1067,7 +1067,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="errormessage"[^>]*>.*Paste does not exist[^<]*#', + '#]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s', $content, 'outputs error correctly' ); @@ -1091,7 +1091,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); ob_end_clean(); $this->assertRegExp( - '#]*id="status"[^>]*>.*Paste was properly deleted[^<]*#s', + '#]*id="status"[^>]*>.*Paste was properly deleted\.#s', $content, 'outputs deleted status correctly' ); diff --git a/tst/ViewTest.php b/tst/ViewTest.php index 7d3c727..e2e014b 100644 --- a/tst/ViewTest.php +++ b/tst/ViewTest.php @@ -96,15 +96,15 @@ class ViewTest extends PHPUnit_Framework_TestCase public function testTemplateRendersCorrectly() { foreach ($this->_content as $template => $content) { - $this->assertContains( - '', + $this->assertRegExp( + '#]+id="cipherdata"[^>]*>' . + preg_quote(htmlspecialchars(Helper::getPaste()['data'], ENT_NOQUOTES)) . + '#', $content, $template . ': outputs data correctly' ); $this->assertRegExp( - '#]+id="errormessage"[^>]*>.*' . self::$error . '#', + '#]+id="errormessage"[^>]*>.*' . self::$error . '#s', $content, $template . ': outputs error correctly' ); @@ -119,7 +119,7 @@ class ViewTest extends PHPUnit_Framework_TestCase $template . ': checked discussion if configured' ); $this->assertRegExp( - '#<[^>]+id="opendisc"[^>]*>#', + '#<[^>]+id="opendiscussionoption"[^>]*>#', $content, $template . ': discussions available if configured' );