diff --git a/.eslintrc b/.eslintrc
index 97437c73..e2a42cc7 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -15,7 +15,9 @@ globals:
# http://eslint.org/docs/rules/
rules:
# Possible Errors
- comma-dangle: [2, never]
+ comma-dangle:
+ - error
+ - never
no-cond-assign: 2
no-console: 0
no-constant-condition: 2
@@ -31,7 +33,9 @@ rules:
no-extra-parens: 0
no-extra-semi: 2
no-func-assign: 2
- no-inner-declarations: [2, functions]
+ no-inner-declarations:
+ - error
+ - functions
no-invalid-regexp: 2
no-irregular-whitespace: 2
no-negated-in-lhs: 2
@@ -47,7 +51,9 @@ rules:
# Best Practices
accessor-pairs: 2
block-scoped-var: 0
- complexity: [2, 6]
+ complexity:
+ - error
+ - 20
consistent-return: 0
curly: 0
default-case: 0
@@ -99,7 +105,7 @@ rules:
no-with: 2
radix: 2
vars-on-top: 0
- wrap-iife: 2
+ wrap-iife: 0
yoda: 0
# Strict
@@ -152,7 +158,9 @@ rules:
max-len: 0
max-nested-callbacks: 0
max-params: 0
- max-statements: [2, 30]
+ max-statements:
+ - error
+ - 60
new-cap: 0
new-parens: 0
newline-after-var: 0
diff --git a/.travis.yml b/.travis.yml
index c5e0d074..3ad463c9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -9,7 +9,7 @@ before_script:
- composer install -n
script:
- - cd tst && phpunit
+ - cd tst && ../vendor/bin/phpunit
after_script:
- cd ..
diff --git a/composer.json b/composer.json
index b92f4629..632bf2b7 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 381f72d7..ded82590 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 87f55ccb..9959e717 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 55c0f14d..1e2fd48f 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 89a45049..97c4e245 100644
--- a/i18n/fr.json
+++ b/i18n/fr.json
@@ -96,12 +96,12 @@
"Avatar anonyme (Vizhash de l'adresse IP)",
"Add comment":
"Ajouter un commentaire",
- "Optional nickname...":
- "Pseudonyme optionnel...",
+ "Optional nickname…":
+ "Pseudonyme optionnel…",
"Post comment":
"Poster le commentaire",
- "Sending comment...":
- "Envoi du commentaire...",
+ "Sending comment…":
+ "Envoi du commentaire…",
"Comment posted.":
"Commentaire posté.",
"Could not refresh display: %s":
@@ -112,10 +112,10 @@
"Le serveur ne répond pas ou a rencontré une erreur",
"Could not post comment: %s":
"Impossible de poster le commentaire : %s",
- "Sending paste (Please move your mouse for more entropy)...":
- "Envoi du paste (Merci de bouger votre souris pour plus d'entropie)...",
- "Sending paste...":
- "Envoi du paste...",
+ "Sending paste (Please move your mouse for more entropy)…":
+ "Envoi du paste (Merci de bouger votre souris pour plus d'entropie)…",
+ "Sending paste…":
+ "Envoi du paste…",
"Your paste is %s (Hit [Ctrl]+[c] to copy) ":
"Votre paste est disponible à l'adresse %s (Appuyez sur [Ctrl]+[c] pour copier) ",
"Delete data":
diff --git a/i18n/it.json b/i18n/it.json
index 24b79ec9..df5aee2a 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 1408b811..4d92cc83 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 efbb9b21..a29bce03 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 2757439b..b9cc8f2a 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 3e418909..e00a4a15 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 de7ad051..7e92da78 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 4cf3d5a1..2df26087 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 a1a9b960..3ec80f12 100644
--- a/i18n/zh.json
+++ b/i18n/zh.json
@@ -96,12 +96,12 @@
"匿名头像 (由IP地址生成Vizhash)",
"Add comment":
"添加评论",
- "Optional nickname...":
- "可选昵称...",
+ "Optional nickname…":
+ "可选昵称…",
"Post comment":
"评论",
- "Sending comment...":
- "评论发送中...",
+ "Sending comment…":
+ "评论发送中…",
"Comment posted.":
"评论已发送。",
"Could not refresh display: %s":
@@ -112,10 +112,10 @@
"服务器错误或无回应",
"Could not post comment: %s":
"无法发送评论: %s",
- "Sending paste (Please move your mouse for more entropy)...":
- "粘贴提交中 (请移动鼠标以产生更多熵)...",
- "Sending paste...":
- "粘贴提交中...",
+ "Sending paste (Please move your mouse for more entropy)…":
+ "粘贴提交中 (请移动鼠标以产生更多熵)…",
+ "Sending paste…":
+ "粘贴提交中…",
"Your paste is %s (Hit [Ctrl]+[c] to copy) ":
"您的粘贴的链接是%s (按下 [Ctrl]+[c] 以复制) ",
"Delete data":
diff --git a/js/privatebin.js b/js/privatebin.js
index 6508bec4..10b6a3a7 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 b2d5f005..e83d43bb 100644
--- a/js/test.js
+++ b/js/test.js
@@ -11,7 +11,9 @@ var jsc = require('jsverify'),
a2zString.map(function(c) {
return c.toUpperCase();
})
- );
+ ),
+ // schemas supported by the whatwg-url library
+ schemas = ['ftp','gopher','http','https','ws','wss'];
global.$ = global.jQuery = require('./jquery-3.1.1');
global.sjcl = require('./sjcl-1.0.6');
@@ -20,127 +22,74 @@ global.RawDeflate = require('./rawdeflate-0.5');
require('./rawinflate-0.3');
require('./privatebin');
-describe('helper', function () {
+describe('Helper', function () {
describe('secondsToHuman', function () {
after(function () {
cleanup();
});
jsc.property('returns an array with a number and a word', 'integer', function (number) {
- var result = $.PrivateBin.helper.secondsToHuman(number);
+ var result = $.PrivateBin.Helper.secondsToHuman(number);
return Array.isArray(result) &&
result.length === 2 &&
result[0] === parseInt(result[0], 10) &&
typeof result[1] === 'string';
});
jsc.property('returns seconds on the first array position', 'integer 59', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[0] === number;
+ return $.PrivateBin.Helper.secondsToHuman(number)[0] === number;
});
jsc.property('returns seconds on the second array position', 'integer 59', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[1] === 'second';
+ return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'second';
});
jsc.property('returns minutes on the first array position', 'integer 60 3599', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / 60);
+ return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / 60);
});
jsc.property('returns minutes on the second array position', 'integer 60 3599', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[1] === 'minute';
+ return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'minute';
});
jsc.property('returns hours on the first array position', 'integer 3600 86399', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60));
+ return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60));
});
jsc.property('returns hours on the second array position', 'integer 3600 86399', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[1] === 'hour';
+ return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'hour';
});
jsc.property('returns days on the first array position', 'integer 86400 5184000', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24));
+ return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24));
});
jsc.property('returns days on the second array position', 'integer 86400 5184000', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[1] === 'day';
+ return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'day';
});
// max safe integer as per http://ecma262-5.com/ELS5_HTML.htm#Section_8.5
jsc.property('returns months on the first array position', 'integer 5184000 9007199254740991', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30));
+ return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30));
});
jsc.property('returns months on the second array position', 'integer 5184000 9007199254740991', function (number) {
- return $.PrivateBin.helper.secondsToHuman(number)[1] === 'month';
+ return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'month';
});
});
- describe('scriptLocation', function () {
+ describe('baseUri', function () {
+ before(function () {
+ $.PrivateBin.Helper.reset();
+ });
+
jsc.property(
'returns the URL without query & fragment',
- jsc.nearray(jsc.elements(a2zString)),
+ jsc.elements(schemas),
jsc.nearray(jsc.elements(a2zString)),
jsc.array(jsc.elements(queryString)),
'string',
function (schema, address, query, fragment) {
- var expected = schema.join('') + '://' + address.join('') + '/',
+ var expected = schema + '://' + address.join('') + '/',
clean = jsdom('', {url: expected + '?' + query.join('') + '#' + fragment}),
- result = $.PrivateBin.helper.scriptLocation();
+ result = $.PrivateBin.Helper.baseUri();
+ $.PrivateBin.Helper.reset();
clean();
return expected === result;
}
);
});
- describe('pasteId', function () {
- jsc.property(
- 'returns the query string without separator, if any',
- jsc.nearray(jsc.elements(a2zString)),
- jsc.nearray(jsc.elements(a2zString)),
- jsc.array(jsc.elements(queryString)),
- 'string',
- function (schema, address, query, fragment) {
- var queryString = query.join(''),
- clean = jsdom('', {
- url: schema.join('') + '://' + address.join('') +
- '/?' + queryString + '#' + fragment
- }),
- result = $.PrivateBin.helper.pasteId();
- clean();
- return queryString === result;
- }
- );
- });
-
- describe('pageKey', function () {
- jsc.property(
- 'returns the fragment of the URL',
- jsc.nearray(jsc.elements(a2zString)),
- jsc.nearray(jsc.elements(a2zString)),
- jsc.array(jsc.elements(queryString)),
- jsc.array(jsc.elements(base64String)),
- function (schema, address, query, fragment) {
- var fragmentString = fragment.join(''),
- clean = jsdom('', {
- url: schema.join('') + '://' + address.join('') +
- '/?' + query.join('') + '#' + fragmentString
- }),
- result = $.PrivateBin.helper.pageKey();
- clean();
- return fragmentString === result;
- }
- );
- jsc.property(
- 'returns the fragment stripped of trailing query parts',
- jsc.nearray(jsc.elements(a2zString)),
- jsc.nearray(jsc.elements(a2zString)),
- jsc.array(jsc.elements(queryString)),
- jsc.array(jsc.elements(base64String)),
- jsc.array(jsc.elements(queryString)),
- function (schema, address, query, fragment, trail) {
- var fragmentString = fragment.join(''),
- clean = jsdom('', {
- url: schema.join('') + '://' + address.join('') + '/?' +
- query.join('') + '#' + fragmentString + '&' + trail.join('')
- }),
- result = $.PrivateBin.helper.pageKey();
- clean();
- return fragmentString === result;
- }
- );
- });
-
describe('htmlEntities', function () {
after(function () {
cleanup();
@@ -150,9 +99,76 @@ describe('helper', function () {
'removes all HTML entities from any given string',
'string',
function (string) {
- var result = $.PrivateBin.helper.htmlEntities(string);
+ var result = $.PrivateBin.Helper.htmlEntities(string);
return !(/[<>"'`=\/]/.test(result)) && !(string.indexOf('&') > -1 && !(/&/.test(result)));
}
);
});
});
+
+describe('Model', function () {
+ describe('getPasteId', function () {
+ before(function () {
+ $.PrivateBin.Model.reset();
+ });
+
+ jsc.property(
+ 'returns the query string without separator, if any',
+ jsc.nearray(jsc.elements(a2zString)),
+ jsc.nearray(jsc.elements(a2zString)),
+ jsc.nearray(jsc.elements(queryString)),
+ 'string',
+ function (schema, address, query, fragment) {
+ var queryString = query.join(''),
+ clean = jsdom('', {
+ url: schema.join('') + '://' + address.join('') +
+ '/?' + queryString + '#' + fragment
+ }),
+ result = $.PrivateBin.Model.getPasteId();
+ $.PrivateBin.Model.reset();
+ clean();
+ return queryString === result;
+ }
+ );
+ });
+
+ describe('getPasteKey', function () {
+ jsc.property(
+ 'returns the fragment of the URL',
+ jsc.nearray(jsc.elements(a2zString)),
+ jsc.nearray(jsc.elements(a2zString)),
+ jsc.array(jsc.elements(queryString)),
+ jsc.nearray(jsc.elements(base64String)),
+ function (schema, address, query, fragment) {
+ var fragmentString = fragment.join(''),
+ clean = jsdom('', {
+ url: schema.join('') + '://' + address.join('') +
+ '/?' + query.join('') + '#' + fragmentString
+ }),
+ result = $.PrivateBin.Model.getPasteKey();
+ $.PrivateBin.Model.reset();
+ clean();
+ return fragmentString === result;
+ }
+ );
+ jsc.property(
+ 'returns the fragment stripped of trailing query parts',
+ jsc.nearray(jsc.elements(a2zString)),
+ jsc.nearray(jsc.elements(a2zString)),
+ jsc.array(jsc.elements(queryString)),
+ jsc.nearray(jsc.elements(base64String)),
+ jsc.array(jsc.elements(queryString)),
+ function (schema, address, query, fragment, trail) {
+ var fragmentString = fragment.join(''),
+ clean = jsdom('', {
+ url: schema.join('') + '://' + address.join('') + '/?' +
+ query.join('') + '#' + fragmentString + '&' + trail.join('')
+ }),
+ result = $.PrivateBin.Model.getPasteKey();
+ $.PrivateBin.Model.reset();
+ clean();
+ return fragmentString === result;
+ }
+ );
+ });
+});
diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php
index cdd45258..a80e175c 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;
- $value):
-?>
- selected="selected">
-
-
-
+
+
-
-
-
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
+
-
- Sorry for the inconvenience.', I18n::_($NAME)); ?>
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Sorry for the inconvenience.', I18n::_($NAME)), PHP_EOL; ?>
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
+ this FAQ for information to troubleshoot.'); ?>
+
+
+
+
+
+
+
+
-
-
-
-
- this FAQ for information to troubleshoot.'); ?>
-
-
-
-
+
+
diff --git a/tpl/page.php b/tpl/page.php
index 9e9a2901..00906305 100644
--- a/tpl/page.php
+++ b/tpl/page.php
@@ -47,7 +47,7 @@ if ($MARKDOWN):
-
+
@@ -84,6 +84,7 @@ endif;