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/.gitignore b/.gitignore index e476e38..9f09f53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore server files for safety .htaccess .htpasswd +cfg/conf.ini # Ignore data/ data/ 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/CHANGELOG.md b/CHANGELOG.md index 10fab74..964d4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * ADDED: Translations for Spanish, Occitan, Norwegian and Portuguese * ADDED: Option in configuration to change the default "PrivateBin" title of the site * CHANGED: Minimum required PHP version is 5.4 (#186) + * CHANGED: Shipped .htaccess files were updated for Apache 2.4 (#192) * CHANGED: Cleanup of bootstrap template variants and moved icons to `img` directory * **1.1 (2016-12-26)** * ADDED: Translations for Italian and Russian diff --git a/README.md b/README.md index 0086f46..a259cb5 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Data is encrypted/decrypted in the browser using 256bit AES in [Galois Counter m This is a fork of ZeroBin, originally developed by [Sébastien Sauvage](https://github.com/sebsauvage/ZeroBin). It was refactored -to allow easier and cleaner extensions and has now much more features than the +to allow easier and cleaner extensions and has now many more features than the original. It is however still fully compatible to the original ZeroBin 0.19 data storage scheme. Therefore such installations can be upgraded to this fork -without loosing any data. +without losing any data. ## What PrivateBin provides diff --git a/cfg/.gitignore b/cfg/.gitignore deleted file mode 100644 index 69db020..0000000 --- a/cfg/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/conf.ini diff --git a/cfg/.htaccess b/cfg/.htaccess index b584d98..b66e808 100644 --- a/cfg/.htaccess +++ b/cfg/.htaccess @@ -1,2 +1 @@ -Allow from none -Deny from all +Require all denied 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..61e0a3c 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": @@ -149,12 +149,12 @@ "Editor": "Éditer", "Preview": "Prévisualiser", "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": - "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.", + "%s requiert que le PATH se termine dans un \"%s\". Veuillez mettre à jour le PATH dans votre index.php.", "Decrypt": - "Decrypt", + "Déchiffrer", "Enter password": "Entrez le mot de passe", - "Loading…": "Loading…", + "Loading…": "Chargement…", "In case this message never disappears please have a look at this FAQ for information to troubleshoot.": - "In case this message never disappears please have a look at this FAQ for information to troubleshoot (in English)." + "Si ce message ne disparaîssait pas, jetez un oeil à cette FAQ pour des idées de résolution (en Anglais)." } 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..41efcc4 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": @@ -137,15 +137,15 @@ "Invalid attachment.": "无效的附件", "Options": "选项", "Shorten URL": "缩短链接", - "Editor": "編輯", - "Preview": "預習", + "Editor": "编辑", + "Preview": "预览", "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": - "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.", + "%s 的 PATH 变量必须结束于 \"%s\"。 请修改你的 index.php 中的 PATH 变量。", "Decrypt": - "Decrypt", + "解密", "Enter password": - "Enter password", - "Loading…": "Loading…", + "输入密码", + "Loading…": "载入中…", "In case this message never disappears please have a look at this FAQ for information to troubleshoot.": - "In case this message never disappears please have a look at this FAQ for information to troubleshoot (in English)." + "如果这个消息一直不消失,请参考 这里的 FAQ 进行故障排除 (英文版)。" } 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/lib/.htaccess b/lib/.htaccess index b584d98..b66e808 100644 --- a/lib/.htaccess +++ b/lib/.htaccess @@ -1,2 +1 @@ -Allow from none -Deny from all +Require all denied diff --git a/tpl/.htaccess b/tpl/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/tpl/.htaccess @@ -0,0 +1 @@ +Require all denied 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' ); diff --git a/vendor/.htaccess b/vendor/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/vendor/.htaccess @@ -0,0 +1 @@ +Require all denied