Просмотр исходного кода

Merge branch 'master' into attachment-handling

# Conflicts:
#	js/privatebin.js
#	tpl/bootstrap.php
#	tpl/page.php
thororm 9 лет назад
Родитель
Сommit
096f07f86e
60 измененных файлов с 4522 добавлено и 1971 удалено
  1. 13 5
      .eslintrc
  2. 2 0
      .gitignore
  3. 15 4
      .travis.yml
  4. 3 1
      CHANGELOG.md
  5. 1 0
      CREDITS.md
  6. 1 1
      INSTALL.md
  7. 2 2
      README.md
  8. 0 1
      cfg/.gitignore
  9. 1 2
      cfg/.htaccess
  10. 3 2
      composer.json
  11. 10 0
      css/bootstrap/privatebin.css
  12. 23 19
      i18n/de.json
  13. 18 14
      i18n/es.json
  14. 20 16
      i18n/fr.json
  15. 26 22
      i18n/it.json
  16. 24 20
      i18n/no.json
  17. 17 13
      i18n/oc.json
  18. 17 13
      i18n/pl.json
  19. 155 0
      i18n/pt.json
  20. 23 17
      i18n/ru.json
  21. 16 12
      i18n/sl.json
  22. 24 20
      i18n/zh.json
  23. 3467 1435
      js/privatebin.js
  24. 275 40
      js/test.js
  25. 1 2
      lib/.htaccess
  26. 1 3
      lib/Data/AbstractData.php
  27. 4 10
      lib/Data/Database.php
  28. 23 54
      lib/Data/Filesystem.php
  29. 0 15
      lib/Filter.php
  30. 6 5
      lib/I18n.php
  31. 0 3
      lib/Model.php
  32. 0 5
      lib/Model/AbstractModel.php
  33. 0 5
      lib/Model/Comment.php
  34. 0 8
      lib/Model/Paste.php
  35. 3 8
      lib/Persistence/AbstractPersistence.php
  36. 47 0
      lib/Persistence/DataStore.php
  37. 0 2
      lib/Persistence/PurgeLimiter.php
  38. 0 1
      lib/Persistence/ServerSalt.php
  39. 0 2
      lib/Persistence/TrafficLimiter.php
  40. 25 32
      lib/PrivateBin.php
  41. 10 18
      lib/Request.php
  42. 0 2
      lib/View.php
  43. 0 2
      lib/Vizhash16x16.php
  44. 1 0
      tpl/.htaccess
  45. 63 48
      tpl/bootstrap.php
  46. 20 6
      tpl/page.php
  47. 0 1
      tst/.gitignore
  48. 0 2
      tst/.htaccess
  49. 15 13
      tst/Bootstrap.php
  50. 1 1
      tst/Data/DatabaseTest.php
  51. 13 10
      tst/Data/FilesystemTest.php
  52. 0 8
      tst/FilterTest.php
  53. 32 0
      tst/I18nTest.php
  54. 3 2
      tst/JsonApiTest.php
  55. 53 0
      tst/ModelTest.php
  56. 35 38
      tst/PrivateBinTest.php
  57. 1 0
      tst/RequestTest.php
  58. 2 0
      tst/SjclTest.php
  59. 6 6
      tst/ViewTest.php
  60. 1 0
      vendor/.htaccess

+ 13 - 5
.eslintrc

@@ -15,7 +15,9 @@ globals:
 # http://eslint.org/docs/rules/
 # http://eslint.org/docs/rules/
 rules:
 rules:
   # Possible Errors
   # Possible Errors
-  comma-dangle: [2, never]
+  comma-dangle:
+    - error
+    - never
   no-cond-assign: 2
   no-cond-assign: 2
   no-console: 0
   no-console: 0
   no-constant-condition: 2
   no-constant-condition: 2
@@ -31,7 +33,9 @@ rules:
   no-extra-parens: 0
   no-extra-parens: 0
   no-extra-semi: 2
   no-extra-semi: 2
   no-func-assign: 2
   no-func-assign: 2
-  no-inner-declarations: [2, functions]
+  no-inner-declarations:
+    - error
+    - functions
   no-invalid-regexp: 2
   no-invalid-regexp: 2
   no-irregular-whitespace: 2
   no-irregular-whitespace: 2
   no-negated-in-lhs: 2
   no-negated-in-lhs: 2
@@ -47,7 +51,9 @@ rules:
   # Best Practices
   # Best Practices
   accessor-pairs: 2
   accessor-pairs: 2
   block-scoped-var: 0
   block-scoped-var: 0
-  complexity: [2, 6]
+  complexity:
+    - error
+    - 20
   consistent-return: 0
   consistent-return: 0
   curly: 0
   curly: 0
   default-case: 0
   default-case: 0
@@ -99,7 +105,7 @@ rules:
   no-with: 2
   no-with: 2
   radix: 2
   radix: 2
   vars-on-top: 0
   vars-on-top: 0
-  wrap-iife: 2
+  wrap-iife: 0
   yoda: 0
   yoda: 0
 
 
   # Strict
   # Strict
@@ -152,7 +158,9 @@ rules:
   max-len: 0
   max-len: 0
   max-nested-callbacks: 0
   max-nested-callbacks: 0
   max-params: 0
   max-params: 0
-  max-statements: [2, 30]
+  max-statements:
+    - error
+    - 60
   new-cap: 0
   new-cap: 0
   new-parens: 0
   new-parens: 0
   newline-after-var: 0
   newline-after-var: 0

+ 2 - 0
.gitignore

@@ -1,6 +1,7 @@
 # Ignore server files for safety
 # Ignore server files for safety
 .htaccess
 .htaccess
 .htpasswd
 .htpasswd
+cfg/conf.ini
 
 
 # Ignore data/
 # Ignore data/
 data/
 data/
@@ -30,6 +31,7 @@ vendor/**/build_phar.php
 # Ignore local node modules, unit testing logs, api docs and eclipse project files
 # Ignore local node modules, unit testing logs, api docs and eclipse project files
 js/node_modules/
 js/node_modules/
 tst/log/
 tst/log/
+tst/ConfigurationCombinationsTest.php
 .settings
 .settings
 .buildpath
 .buildpath
 .project
 .project

+ 15 - 4
.travis.yml

@@ -1,15 +1,26 @@
 language: php
 language: php
 sudo: false
 sudo: false
 php:
 php:
-  - 5.5
-  - 5.6
-  - 7.0
+  - '5.4'
+  - '5.5'
+  - '5.6'
+  - '7.0'
+  - '7.1'
+
+# as this is a php project, node.js v4 (for JS unit testing) isn't installed
+install:
+  - rm -rf ~/.nvm && git clone https://github.com/creationix/nvm.git ~/.nvm && (cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`) && source ~/.nvm/nvm.sh && nvm install 4
 
 
 before_script:
 before_script:
   - composer install -n
   - composer install -n
+  - npm install -g mocha
+  - cd js
+  - npm install jsverify jsdom jsdom-global
+  - cd ..
 
 
 script:
 script:
-  - cd tst && phpunit
+  - cd tst && ../vendor/bin/phpunit
+  - cd ../js && mocha
 
 
 after_script:
 after_script:
   - cd ..
   - cd ..

+ 3 - 1
CHANGELOG.md

@@ -1,8 +1,10 @@
 # PrivateBin version history
 # PrivateBin version history
 
 
   * **next (not yet released)**
   * **next (not yet released)**
-    * ADDED: Translations for Spanish, Occitan and Norwegian
+    * ADDED: Translations for Spanish, Occitan, Norwegian and Portuguese
     * ADDED: Option in configuration to change the default "PrivateBin" title of the site
     * ADDED: Option in configuration to change the default "PrivateBin" title of the site
+    * CHANGED: Minimum required PHP version is 5.4 (#186)
+    * CHANGED: Shipped .htaccess files were updated for Apache 2.4 (#192)
     * CHANGED: Cleanup of bootstrap template variants and moved icons to `img` directory
     * CHANGED: Cleanup of bootstrap template variants and moved icons to `img` directory
   * **1.1 (2016-12-26)**
   * **1.1 (2016-12-26)**
     * ADDED: Translations for Italian and Russian
     * ADDED: Translations for Italian and Russian

+ 1 - 0
CREDITS.md

@@ -35,3 +35,4 @@ Sébastien Sauvage - original idea and main developer
 * Alfredo Fabián Altamirano Tena - Spanish
 * Alfredo Fabián Altamirano Tena - Spanish
 * Quent-in - Occitan
 * Quent-in - Occitan
 * idarlund - Norwegian
 * idarlund - Norwegian
+* Tulio Leao - Portuguese

+ 1 - 1
INSTALL.md

@@ -10,7 +10,7 @@ check the options and adjust them as you see fit.
 
 
 ### Requirements
 ### Requirements
 
 
-- PHP version 5.3 or above
+- PHP version 5.4 or above
 - _one_ of the following sources of cryptographically safe randomness is required:
 - _one_ of the following sources of cryptographically safe randomness is required:
   - PHP 7 or higher
   - PHP 7 or higher
   - [Libsodium](https://download.libsodium.org/libsodium/content/installation/) and it's [PHP extension](https://paragonie.com/book/pecl-libsodium/read/00-intro.md#installing-libsodium)
   - [Libsodium](https://download.libsodium.org/libsodium/content/installation/) and it's [PHP extension](https://paragonie.com/book/pecl-libsodium/read/00-intro.md#installing-libsodium)

+ 2 - 2
README.md

@@ -16,10 +16,10 @@ Data is encrypted/decrypted in the browser using 256bit AES in [Galois Counter m
 
 
 This is a fork of ZeroBin, originally developed by
 This is a fork of ZeroBin, originally developed by
 [Sébastien Sauvage](https://github.com/sebsauvage/ZeroBin). It was refactored
 [Sébastien Sauvage](https://github.com/sebsauvage/ZeroBin). It was refactored
-to allow easier and cleaner extensions and has now much more features than the
+to allow easier and cleaner extensions and has now many more features than the
 original. It is however still fully compatible to the original ZeroBin 0.19
 original. It is however still fully compatible to the original ZeroBin 0.19
 data storage scheme. Therefore such installations can be upgraded to this fork
 data storage scheme. Therefore such installations can be upgraded to this fork
-without loosing any data.
+without losing any data.
 
 
 ## What PrivateBin provides
 ## What PrivateBin provides
 
 

+ 0 - 1
cfg/.gitignore

@@ -1 +0,0 @@
-/conf.ini

+ 1 - 2
cfg/.htaccess

@@ -1,2 +1 @@
-Allow from none
-Deny from all
+Require all denied

+ 3 - 2
composer.json

@@ -18,13 +18,14 @@
         }
         }
     ],
     ],
     "require": {
     "require": {
-        "php": "^5.3.0 || ^7.0",
+        "php": "^5.4.0 || ^7.0",
         "paragonie/random_compat": "2.0.4",
         "paragonie/random_compat": "2.0.4",
         "yzalis/identicon": "1.1.0"
         "yzalis/identicon": "1.1.0"
     },
     },
     "require-dev": {
     "require-dev": {
         "codacy/coverage": "dev-master",
         "codacy/coverage": "dev-master",
-        "codeclimate/php-test-reporter": "dev-master"
+        "codeclimate/php-test-reporter": "dev-master",
+        "phpunit/phpunit": "^4.6 || ^5.0"
     },
     },
     "autoload": {
     "autoload": {
         "psr-4": {
         "psr-4": {

+ 10 - 0
css/bootstrap/privatebin.css

@@ -17,6 +17,10 @@ body.navbar-spacing {
 	padding-top: 70px;
 	padding-top: 70px;
 }
 }
 
 
+body.loading {
+	cursor: wait;
+}
+
 .buttondisabled {
 .buttondisabled {
 	opacity: 0.3;
 	opacity: 0.3;
 }
 }
@@ -115,6 +119,12 @@ body.navbar-spacing {
 	border-left: 1px solid #ccc;
 	border-left: 1px solid #ccc;
 	padding: 5px 0 5px 10px;
 	padding: 5px 0 5px 10px;
 	white-space: pre-wrap;
 	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 {
 footer h4 {

+ 23 - 19
i18n/de.json

@@ -7,8 +7,8 @@
     "en": "de",
     "en": "de",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Diesen Text gibt es nicht, er ist abgelaufen oder wurde gelöscht.",
         "Diesen Text gibt es nicht, er ist abgelaufen oder wurde gelöscht.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "%s benötigt PHP 5.3.0 oder höher, um zu funktionieren. Sorry.",
+    "%s requires php %s or above to work. Sorry.":
+        "%s benötigt PHP %s oder höher, um zu funktionieren. Sorry.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s benötigt den Konfigurationsabschnitt [%s] in der Konfigurationsdatei um zu funktionieren.",
         "%s benötigt den Konfigurationsabschnitt [%s] in der Konfigurationsdatei um zu funktionieren.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -83,25 +83,25 @@
     "Could not decrypt data (Wrong key?)":
     "Could not decrypt data (Wrong key?)":
         "Konnte Daten nicht entschlüsseln (Falscher Schlüssel?)",
         "Konnte Daten nicht entschlüsseln (Falscher Schlüssel?)",
     "Could not delete the paste, it was not stored in burn after reading mode.":
     "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.":
     "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?":
     "Could not decrypt comment; Wrong key?":
         "Konnte Kommentar nicht entschlüsseln; Falscher Schlüssel?",
         "Konnte Kommentar nicht entschlüsseln; Falscher Schlüssel?",
     "Reply":
     "Reply":
         "Antworten",
         "Antworten",
     "Anonymous":
     "Anonymous":
         "Anonym",
         "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":
     "Add comment":
         "Kommentar hinzufügen",
         "Kommentar hinzufügen",
-    "Optional nickname...":
-        "Optionales Pseudonym...",
+    "Optional nickname":
+        "Optionales Pseudonym",
     "Post comment":
     "Post comment":
         "Kommentar absenden",
         "Kommentar absenden",
-    "Sending comment...":
-        "Sende Kommentar...",
+    "Sending comment":
+        "Sende Kommentar",
     "Comment posted.":
     "Comment posted.":
         "Kommentar gesendet.",
         "Kommentar gesendet.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
@@ -112,24 +112,25 @@
         "Fehler auf dem Server oder keine Antwort vom Server",
         "Fehler auf dem Server oder keine Antwort vom Server",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Konnte Kommentar nicht senden: %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 <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
-        "Dein Text ist unter <a id=\"pasteurl\" href=\"%s\">%s</a> zu finden <span id=\"copyhint\">(Drücke [Strg]+[c] um den Link zu kopieren)</span>",
+        "Dein Paste ist unter <a id=\"pasteurl\" href=\"%s\">%s</a> zu finden <span id=\"copyhint\">(Drücke [Strg]+[c] um den Link zu kopieren)</span>",
     "Delete data":
     "Delete data":
         "Lösche Daten",
         "Lösche Daten",
     "Could not create paste: %s":
     "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?)":
     "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",
     "Format": "Format",
     "Plain Text": "Nur Text",
     "Plain Text": "Nur Text",
     "Source Code": "Quellcode",
     "Source Code": "Quellcode",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Anhang herunterladen",
     "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",
     "Attach a file": "Datei anhängen",
     "or drag & drop file": "oder per Drag & Drop einfügen",
     "or drag & drop file": "oder per Drag & Drop einfügen",
     "File too large, to display a preview. Please download the attachment.": "Datei zu groß, um als Vorschau angezeigt zu werden. Bitte Anhang herunterladen.",
     "File too large, to display a preview. Please download the attachment.": "Datei zu groß, um als Vorschau angezeigt zu werden. Bitte Anhang herunterladen.",
@@ -148,6 +149,9 @@
     "Enter password":
     "Enter password":
         "Passwort eingeben",
         "Passwort eingeben",
     "Loading…": "Lädt…",
     "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 <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "Wenn diese Nachricht nicht mehr verschwindet, schau bitte in <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">die FAQ</a> (englisch), um zu sehen, wie der Fehler behoben werden kann."
+        "Wenn diese Nachricht nicht mehr verschwindet, schau bitte in <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">die FAQ</a> (englisch), um zu sehen, wie der Fehler behoben werden kann.",
+    "+++ no paste text +++": "+++ kein Paste-Text +++"
 }
 }

+ 18 - 14
i18n/es.json

@@ -7,10 +7,10 @@
     "en": "es",
     "en": "es",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "El texto no existe, ha caducado o ha sido eliminado.",
         "El texto no existe, ha caducado o ha sido eliminado.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "%s requiere php 5.3.0 o superior para funcionar. Lo siento.",
+    "%s requires php %s or above to work. Sorry.":
+        "%s requiere php %s o superior para funcionar. Lo siento.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
-        "%s requiere que la sección de configuración [% s] esté presente en el archivo de configuración.",
+        "%s requiere que la sección de configuración [%s] esté presente en el archivo de configuración.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
         "Por favor espere %d segundos entre cada publicación.",
         "Por favor espere %d segundos entre cada publicación.",
     "Paste is limited to %s of encrypted data.":
     "Paste is limited to %s of encrypted data.":
@@ -92,16 +92,16 @@
         "Responder",
         "Responder",
     "Anonymous":
     "Anonymous":
         "Anónimo",
         "Anónimo",
-    "Anonymous avatar (Vizhash of the IP address)":
+    "Avatar generated from IP address":
         "Avatar anónimo (Vizhash de la dirección IP)",
         "Avatar anónimo (Vizhash de la dirección IP)",
     "Add comment":
     "Add comment":
         "Añadir comentario",
         "Añadir comentario",
-    "Optional nickname...":
-        "Seudónimo opcional...",
+    "Optional nickname":
+        "Seudónimo opcional",
     "Post comment":
     "Post comment":
         "Publicar comentario",
         "Publicar comentario",
-    "Sending comment...":
-        "Enviando comentario...",
+    "Sending comment":
+        "Enviando comentario",
     "Comment posted.":
     "Comment posted.":
         "Comentario publicado.",
         "Comentario publicado.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
@@ -112,10 +112,10 @@
         "Error del servidor o el servidor no responde",
         "Error del servidor o el servidor no responde",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "No fue posible publicar comentario: %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...",
+    "Please move your mouse for more entropy…":
+        "Por favor, mueva el ratón para mayor entropía…",
+    "Sending paste":
+        "Enviando texto",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "Su texto está en <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Presione [Ctrl]+[c] para copiar)</span>",
         "Su texto está en <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Presione [Ctrl]+[c] para copiar)</span>",
     "Delete data":
     "Delete data":
@@ -129,7 +129,8 @@
     "Source Code": "Código fuente",
     "Source Code": "Código fuente",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Descargar adjunto",
     "Download attachment": "Descargar adjunto",
-    "Cloned file attached.": "Archivo clonado adjunto.",
+    "Cloned: '%s'": "Clonado: '%s'.",
+    "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
     "Attach a file": "Adjuntar archivo",
     "Attach a file": "Adjuntar archivo",
     "Remove attachment": "Remover adjunto",
     "Remove attachment": "Remover adjunto",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -146,6 +147,9 @@
     "Enter password":
     "Enter password":
         "Ingrese contraseña",
         "Ingrese contraseña",
     "Loading…": "Cargando…",
     "Loading…": "Cargando…",
+    "Decrypting paste…": "Decrypting paste…",
+    "Preparing new paste…": "Preparing new paste…",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "En caso de que este mensaje nunca desaparezca por favor revise <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">este FAQ para obtener información para solucionar problemas</a>."
+        "En caso de que este mensaje nunca desaparezca por favor revise <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">este FAQ para obtener información para solucionar problemas</a>.",
+    "+++ no paste text +++": "+++ no paste text +++"
 }
 }

+ 20 - 16
i18n/fr.json

@@ -7,8 +7,8 @@
     "en": "fr",
     "en": "fr",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Le paste n'existe pas, a expiré, ou a été supprimé.",
         "Le paste n'existe pas, a expiré, ou a été supprimé.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "Désolé, %s nécessite php 5.3.0 ou supérieur pour fonctionner.",
+    "%s requires php %s or above to work. Sorry.":
+        "Désolé, %s nécessite php %s ou supérieur pour fonctionner.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s a besoin de la section de configuration [%s] dans le fichier de configuration pour fonctionner.",
         "%s a besoin de la section de configuration [%s] dans le fichier de configuration pour fonctionner.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -92,16 +92,16 @@
         "Répondre",
         "Répondre",
     "Anonymous":
     "Anonymous":
         "Anonyme",
         "Anonyme",
-    "Anonymous avatar (Vizhash of the IP address)":
+    "Avatar generated from IP address":
         "Avatar anonyme (Vizhash de l'adresse IP)",
         "Avatar anonyme (Vizhash de l'adresse IP)",
     "Add comment":
     "Add comment":
         "Ajouter un commentaire",
         "Ajouter un commentaire",
-    "Optional nickname...":
-        "Pseudonyme optionnel...",
+    "Optional nickname":
+        "Pseudonyme optionnel",
     "Post comment":
     "Post comment":
         "Poster le commentaire",
         "Poster le commentaire",
-    "Sending comment...":
-        "Envoi du commentaire...",
+    "Sending comment":
+        "Envoi du commentaire",
     "Comment posted.":
     "Comment posted.":
         "Commentaire posté.",
         "Commentaire posté.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
@@ -112,10 +112,10 @@
         "Le serveur ne répond pas ou a rencontré une erreur",
         "Le serveur ne répond pas ou a rencontré une erreur",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Impossible de poster le commentaire : %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...",
+    "Please move your mouse for more entropy…":
+        "Merci de bouger votre souris pour plus d'entropie…",
+    "Sending paste":
+        "Envoi du paste",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "Votre paste est disponible à l'adresse <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Appuyez sur [Ctrl]+[c] pour copier)</span>",
         "Votre paste est disponible à l'adresse <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Appuyez sur [Ctrl]+[c] pour copier)</span>",
     "Delete data":
     "Delete data":
@@ -138,7 +138,8 @@
     "Source Code": "Code source",
     "Source Code": "Code source",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Télécharger la pièce jointe",
     "Download attachment": "Télécharger la pièce jointe",
-    "Cloned file attached.": "Cloner le fichier attaché.",
+    "Cloned: '%s'": "Cloner '%s'",
+    "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
     "Attach a file": "Attacher un fichier ",
     "Attach a file": "Attacher un fichier ",
     "Remove attachment": "Enlever l'attachement",
     "Remove attachment": "Enlever l'attachement",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -149,12 +150,15 @@
     "Editor": "Éditer",
     "Editor": "Éditer",
     "Preview": "Prévisualiser",
     "Preview": "Prévisualiser",
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
-        "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.",
+        "%s requiert que le PATH se termine dans un \"%s\". Veuillez mettre à jour le PATH dans votre index.php.",
     "Decrypt":
     "Decrypt":
-        "Decrypt",
+        "Déchiffrer",
     "Enter password":
     "Enter password":
         "Entrez le mot de passe",
         "Entrez le mot de passe",
-    "Loading…": "Loading…",
+    "Loading…": "Chargement…",
+    "Decrypting paste…": "Decrypting paste…",
+    "Preparing new paste…": "Preparing new paste…",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a> (in English)."
+        "Si ce message ne disparaîssait pas, jetez un oeil à <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">cette FAQ pour des idées de résolution</a> (en Anglais).",
+    "+++ no paste text +++": "+++ no paste text +++"
 }
 }

+ 26 - 22
i18n/it.json

@@ -7,8 +7,8 @@
     "en": "it",
     "en": "it",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Questo messaggio non esiste, è scaduto o è stato cancellato.",
         "Questo messaggio non esiste, è scaduto o è stato cancellato.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "%s richiede PHP 5.3.0 o superiore.",
+    "%s requires php %s or above to work. Sorry.":
+        "%s richiede php %s o superiore per funzionare. Ci spiace.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s richiede la presenza della sezione [%s] nei file di configurazione.",
         "%s richiede la presenza della sezione [%s] nei file di configurazione.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -18,7 +18,7 @@
     "Invalid data.":
     "Invalid data.":
         "Dati non validi.",
         "Dati non validi.",
     "You are unlucky. Try again.":
     "You are unlucky. Try again.":
-        "Riprova, sarai più fortunato.",
+        "Ritenta, sarai più fortunato.",
     "Error saving comment. Sorry.":
     "Error saving comment. Sorry.":
         "Errore durante il salvataggio del commento.",
         "Errore durante il salvataggio del commento.",
     "Error saving paste. Sorry.":
     "Error saving paste. Sorry.":
@@ -67,7 +67,7 @@
     "Never":
     "Never":
         "Mai",
         "Mai",
     "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.":
     "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.":
-        "Nota: questo è un servizio di prova, i dati possono essere cancellati in qualsiasi momento. Ti preghiamo di non abusare di questo servizio, grazie.",
+        "Nota: questo è un servizio di prova, i messaggi salvati possono essere cancellati in qualsiasi momento. Moriranno dei gattini se abuserai di questo servizio.",
     "This document will expire in %d seconds.":
     "This document will expire in %d seconds.":
         ["Questo documento scadrà tra un secondo.", "Questo documento scadrà in %d secondi."],
         ["Questo documento scadrà tra un secondo.", "Questo documento scadrà in %d secondi."],
     "This document will expire in %d minutes.":
     "This document will expire in %d minutes.":
@@ -87,41 +87,41 @@
     "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.":
     "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.":
         "FOR YOUR EYES ONLY. Non chiudere questa finestra, il messaggio non può essere visualizzato una seconda volta.",
         "FOR YOUR EYES ONLY. Non chiudere questa finestra, il messaggio non può essere visualizzato una seconda volta.",
     "Could not decrypt comment; Wrong key?":
     "Could not decrypt comment; Wrong key?":
-        "Non riesco a decifrari il commento (Chiave errata?)",
+        "Non riesco a decifrare il commento (Chiave errata?)",
     "Reply":
     "Reply":
         "Rispondi",
         "Rispondi",
     "Anonymous":
     "Anonymous":
         "Anonimo",
         "Anonimo",
-    "Anonymous avatar (Vizhash of the IP address)":
-        "Avatar Anonino (Vizhash dell'indirizzo IP)",
+    "Avatar generated from IP address":
+        "Avatar generato dall'indirizzo IP)",
     "Add comment":
     "Add comment":
         "Aggiungi un commento",
         "Aggiungi un commento",
-    "Optional nickname...":
-        "Nickname opzionale...",
+    "Optional nickname":
+        "Nickname opzionale",
     "Post comment":
     "Post comment":
         "Invia commento",
         "Invia commento",
-    "Sending comment...":
-        "Commento in fase di invio...",
+    "Sending comment":
+        "Commento in fase di invio",
     "Comment posted.":
     "Comment posted.":
         "Commento inviato.",
         "Commento inviato.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
         "Non riesco ad aggiornare il display: %s",
         "Non riesco ad aggiornare il display: %s",
     "unknown status":
     "unknown status":
-        "errore sconosciuto",
+        "stato sconosciuto",
     "server error or not responding":
     "server error or not responding":
         "errore o mancata risposta dal server",
         "errore o mancata risposta dal server",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Impossibile inviare il commento: %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...",
+    "Please move your mouse for more entropy…":
+        "Muovi il mouse in modo casuale, per generare maggior entropia",
+    "Sending paste":
+        "Messaggio in fase di invio",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
-        "Il tuo messaggio è qui: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">([CTRL | CMD]+[C] per copiare il link)</span>",
+        "Il tuo messaggio è qui: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Premi [Ctrl]+[c] (Windows) o [Cmd]+[c] (Mac) per copiare il link)</span>",
     "Delete data":
     "Delete data":
         "Cancella i dati",
         "Cancella i dati",
     "Could not create paste: %s":
     "Could not create paste: %s":
-        "Non rieco a creare il messaggio: %s",
+        "Non riesco a creare il messaggio: %s",
     "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)":
     "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)":
         "Non riesco a decifrare il messaggio: manca la chiave di decifrazione nell'URL (La chiave è parte integrante dell'URL. Per caso hai usato un Redirector o un altro servizio che ha rimosso una parte dell'URL?)",
         "Non riesco a decifrare il messaggio: manca la chiave di decifrazione nell'URL (La chiave è parte integrante dell'URL. Per caso hai usato un Redirector o un altro servizio che ha rimosso una parte dell'URL?)",
     "Format": "Formato",
     "Format": "Formato",
@@ -129,7 +129,8 @@
     "Source Code": "Codice Sorgente",
     "Source Code": "Codice Sorgente",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Scarica Allegato",
     "Download attachment": "Scarica Allegato",
-    "Cloned file attached.": "Copia del file allegata.",
+    "Cloned: '%s'": "Clonato: '%s'",
+    "The cloned file '%s' was attached to this paste.": "Il file clonato '%s' era allegato a questo messaggio.",
     "Attach a file": "Allega un file",
     "Attach a file": "Allega un file",
     "Remove attachment": "Rimuovi allegato",
     "Remove attachment": "Rimuovi allegato",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -142,10 +143,13 @@
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
         "%s necessita che PATH termini con \"%s\". Aggiorna la variabile PATH nel tuo index.php.",
         "%s necessita che PATH termini con \"%s\". Aggiorna la variabile PATH nel tuo index.php.",
     "Decrypt":
     "Decrypt":
-        "Decrypt",
+        "Decifra",
     "Enter password":
     "Enter password":
         "Inserisci la password",
         "Inserisci la password",
-    "Loading…": "Loading…",
+    "Loading…": "Carico…",
+    "Decrypting paste…": "Decifro il messaggio…",
+    "Preparing new paste…": "Preparo il nuovo messaggio…",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a> (in English)."
+        "Nel caso questo messaggio non scompaia, controlla questa <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">FAQ</a> per trovare informazioni su come risolvere il problema (in Inglese).",
+    "+++ no paste text +++": "+++ nessun testo nel messaggio +++"
 }
 }

+ 24 - 20
i18n/no.json

@@ -7,8 +7,8 @@
     "en": "no",
     "en": "no",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Innlegget eksisterer ikke, er utløpt eller har blitt slettet.",
         "Innlegget eksisterer ikke, er utløpt eller har blitt slettet.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "Beklager, %s krever php 5.3.0 eller nyere for å kjøre.",
+    "%s requires php %s or above to work. Sorry.":
+        "Beklager, %s krever php %s eller nyere for å kjøre.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s krever konfigurasjonsdel [%s] å være til stede i konfigurasjonsfilen .",
         "%s krever konfigurasjonsdel [%s] å være til stede i konfigurasjonsfilen .",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -26,13 +26,13 @@
     "Invalid paste ID.":
     "Invalid paste ID.":
         "Feil innlegg ID.",
         "Feil innlegg ID.",
     "Paste is not of burn-after-reading type.":
     "Paste is not of burn-after-reading type.":
-        "Innlegg er ikke av type slett-etter-lesing.",
+        "Innlegg er ikke av typen slett etter lesing.",
     "Wrong deletion token. Paste was not deleted.":
     "Wrong deletion token. Paste was not deleted.":
         "Feil slettingsnøkkel. Innlegg ble ikke fjernet.",
         "Feil slettingsnøkkel. Innlegg ble ikke fjernet.",
     "Paste was properly deleted.":
     "Paste was properly deleted.":
         "Innlegget er slettet.",
         "Innlegget er slettet.",
     "JavaScript is required for %s to work.<br />Sorry for the inconvenience.":
     "JavaScript is required for %s to work.<br />Sorry for the inconvenience.":
-        "Javascript kreves for at %s skal fungere<br />Beklager ulempene.",
+        "Javascript kreves for at %s skal fungere<br />Beklager.",
     "%s requires a modern browser to work.":
     "%s requires a modern browser to work.":
         "%s krever en moderne nettleser for å fungere.",
         "%s krever en moderne nettleser for å fungere.",
     "Still using Internet Explorer? Do yourself a favor, switch to a modern browser:":
     "Still using Internet Explorer? Do yourself a favor, switch to a modern browser:":
@@ -83,7 +83,7 @@
     "Could not decrypt data (Wrong key?)":
     "Could not decrypt data (Wrong key?)":
         "Kunne ikke dekryptere data (Feil nøkkel?)",
         "Kunne ikke dekryptere data (Feil nøkkel?)",
     "Could not delete the paste, it was not stored in burn after reading mode.":
     "Could not delete the paste, it was not stored in burn after reading mode.":
-        "Kan ikke slette innlegget, det ble ikke lagret i slett-etter-les modus.",
+        "Kan ikke slette innlegget, det ble ikke lagret som 'slett etter les' type.",
     "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.":
     "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.":
         "KUN FOR DINE ØYNE. Ikke lukk dette vinduet, denne meldingen kan ikke bli vist igjen.",
         "KUN FOR DINE ØYNE. Ikke lukk dette vinduet, denne meldingen kan ikke bli vist igjen.",
     "Could not decrypt comment; Wrong key?":
     "Could not decrypt comment; Wrong key?":
@@ -92,30 +92,30 @@
         "Svar",
         "Svar",
     "Anonymous":
     "Anonymous":
         "Anonym",
         "Anonym",
-    "Anonymous avatar (Vizhash of the IP address)":
-        "Anonym avatar (Vizhash av IP adressen)",
+    "Avatar generated from IP address":
+        "Anonym avatar generert med data fra IP adressen)",
     "Add comment":
     "Add comment":
         "Legg til kommentar",
         "Legg til kommentar",
-    "Optional nickname...":
-        "Valgfritt kallenavn...",
+    "Optional nickname":
+        "Valgfritt kallenavn",
     "Post comment":
     "Post comment":
         "Send kommentar",
         "Send kommentar",
-    "Sending comment...":
-        "Sender Kommentar...",
+    "Sending comment":
+        "Sender Kommentar",
     "Comment posted.":
     "Comment posted.":
         "Kommentar sendt.",
         "Kommentar sendt.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
-        "Kunne ikke oppdatere skjermen: %s",
+        "Kunne ikke oppdatere bildet: %s",
     "unknown status":
     "unknown status":
         "ukjent status",
         "ukjent status",
     "server error or not responding":
     "server error or not responding":
-        "server feilet eller svarer ikke",
+        "tjener feilet eller svarer ikke",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Kunne ikke sende kommentar: %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...",
+    "Please move your mouse for more entropy…":
+        "Flytt musen for mer entropi…",
+    "Sending paste":
+        "Sender innlegg",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "Ditt innlegg er <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Trykk [Ctrl]+[c] for å kopiere)</span>",
         "Ditt innlegg er <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Trykk [Ctrl]+[c] for å kopiere)</span>",
     "Delete data":
     "Delete data":
@@ -129,14 +129,15 @@
     "Source Code": "Kildekode",
     "Source Code": "Kildekode",
     "Markdown": "Oppmerket",
     "Markdown": "Oppmerket",
     "Download attachment": "Last ned vedlegg",
     "Download attachment": "Last ned vedlegg",
-    "Cloned file attached.": "Kopier vedlegg.",
+    "Cloned: '%s'": "Kopiert: '%s'",
+    "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
     "Attach a file": "Legg til fil",
     "Attach a file": "Legg til fil",
     "Remove attachment": "Slett vedlegg",
     "Remove attachment": "Slett vedlegg",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
         "Nettleseren din støtter ikke å laste opp krypterte filer. Vennligst bruk en nyere nettleser.",
         "Nettleseren din støtter ikke å laste opp krypterte filer. Vennligst bruk en nyere nettleser.",
     "Invalid attachment.": "Ugyldig vedlegg.",
     "Invalid attachment.": "Ugyldig vedlegg.",
     "Options": "Alternativer",
     "Options": "Alternativer",
-    "Shorten URL": "Adresse-forkorter",
+    "Shorten URL": "Adresse forkorter",
     "Editor": "Rediger",
     "Editor": "Rediger",
     "Preview": "Forhåndsvis",
     "Preview": "Forhåndsvis",
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
@@ -146,6 +147,9 @@
     "Enter password":
     "Enter password":
         "Skriv inn passord",
         "Skriv inn passord",
     "Loading…": "Laster…",
     "Loading…": "Laster…",
+    "Decrypting paste…": "Dekrypterer innlegg…",
+    "Preparing new paste…": "Klargjør nytt innlegg…",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "Hvis denne meldingen ikke forsvinner kan du ta en titt på siden med <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">ofte stilte spørsmål</a> for informasjon om feilsøking."
+        "Hvis denne meldingen ikke forsvinner kan du ta en titt på siden med <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">ofte stilte spørsmål</a> for informasjon om feilsøking.",
+    "+++ no paste text +++": "+++ ingen innleggstekst +++"
 }
 }

+ 17 - 13
i18n/oc.json

@@ -7,8 +7,8 @@
     "en": "oc",
     "en": "oc",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Lo tèxte existís pas, a expirat, o es estat suprimit.",
         "Lo tèxte existís pas, a expirat, o es estat suprimit.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "O planhèm, %s necessita php 5.3.0 o superior per foncionar.",
+    "%s requires php %s or above to work. Sorry.":
+        "O planhèm, %s necessita php %s o superior per foncionar.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s fa besonh de la seccion de configuracion [%s] dins lo fichièr de configuracion per foncionar.",
         "%s fa besonh de la seccion de configuracion [%s] dins lo fichièr de configuracion per foncionar.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -92,16 +92,16 @@
         "Respondre",
         "Respondre",
     "Anonymous":
     "Anonymous":
         "Anonime",
         "Anonime",
-    "Anonymous avatar (Vizhash of the IP address)":
+    "Avatar generated from IP address":
         "Avatar anonime (Vizhash de l'adreça IP)",
         "Avatar anonime (Vizhash de l'adreça IP)",
     "Add comment":
     "Add comment":
         "Apondre un comentari",
         "Apondre un comentari",
-    "Optional nickname...":
-        "Escais opcional...",
+    "Optional nickname":
+        "Escais opcional",
     "Post comment":
     "Post comment":
         "Mandar lo comentari",
         "Mandar lo comentari",
-    "Sending comment...":
-        "Mandadís del comentari...",
+    "Sending comment":
+        "Mandadís del comentari",
     "Comment posted.":
     "Comment posted.":
         "Comentari mandat.",
         "Comentari mandat.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
@@ -112,10 +112,10 @@
         "Lo servidor respond pas o a rencontrat una error",
         "Lo servidor respond pas o a rencontrat una error",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Impossible de mandar lo comentari : %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...",
+    "Please move your mouse for more entropy…":
+        "Mercés de bolegar vòstra mirga per mai entropia…",
+    "Sending paste":
+        "Mandadís del tèxte",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "Vòstre tèxte es disponible a l'adreça <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Picatz sus [Ctrl]+[c] per copiar)</span>",
         "Vòstre tèxte es disponible a l'adreça <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Picatz sus [Ctrl]+[c] per copiar)</span>",
     "Delete data":
     "Delete data":
@@ -138,7 +138,8 @@
     "Source Code": "Còdi font",
     "Source Code": "Còdi font",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Telecargar la pèça junta",
     "Download attachment": "Telecargar la pèça junta",
-    "Cloned file attached.": "Clonar lo fichièr junt.",
+    "Cloned: '%s'": "Clonar: '%s'",
+    "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
     "Attach a file": "Juntar un fichièr ",
     "Attach a file": "Juntar un fichièr ",
     "Remove attachment": "Levar la pèca junta",
     "Remove attachment": "Levar la pèca junta",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -155,6 +156,9 @@
     "Enter password":
     "Enter password":
         "Picatz lo senhal",
         "Picatz lo senhal",
     "Loading…": "Cargament…",
     "Loading…": "Cargament…",
+    "Decrypting paste…": "Decrypting paste…",
+    "Preparing new paste…": "Preparing new paste…",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "Se per cas aqueste messatge quita pas de s'afichar mercés de gaitar <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">aquesta FAQ per las solucions</a> (en Anglés)."
+        "Se per cas aqueste messatge quita pas de s'afichar mercés de gaitar <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">aquesta FAQ per las solucions</a> (en Anglés).",
+    "+++ no paste text +++": "+++ no paste text +++"
 }
 }

+ 17 - 13
i18n/pl.json

@@ -7,8 +7,8 @@
     "en": "pl",
     "en": "pl",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Wklejka nie istnieje, wygasła albo została usunięta.",
         "Wklejka nie istnieje, wygasła albo została usunięta.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "%s wymaga PHP w wersji 5.3.0 lub nowszej, sorry.",
+    "%s requires php %s or above to work. Sorry.":
+        "%s wymaga PHP w wersji %s lub nowszej, sorry.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s wymaga obecności sekcji [%s] w pliku konfiguracyjnym.",
         "%s wymaga obecności sekcji [%s] w pliku konfiguracyjnym.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -92,16 +92,16 @@
         "Odpowiedz",
         "Odpowiedz",
     "Anonymous":
     "Anonymous":
         "Anonim",
         "Anonim",
-    "Anonymous avatar (Vizhash of the IP address)":
+    "Avatar generated from IP address":
         "Anonimowy avatar (Vizhash z adresu IP)",
         "Anonimowy avatar (Vizhash z adresu IP)",
     "Add comment":
     "Add comment":
         "Dodaj komentarz",
         "Dodaj komentarz",
-    "Optional nickname...":
-        "Opcjonalny nick...",
+    "Optional nickname":
+        "Opcjonalny nick",
     "Post comment":
     "Post comment":
         "Wyślij komentarz",
         "Wyślij komentarz",
-    "Sending comment...":
-        "Wysyłanie komentarza...",
+    "Sending comment":
+        "Wysyłanie komentarza",
     "Comment posted.":
     "Comment posted.":
         "Wysłano komentarz.",
         "Wysłano komentarz.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
@@ -112,10 +112,10 @@
         "bląd serwera lub brak odpowiedzi",
         "bląd serwera lub brak odpowiedzi",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Nie udało się wysłać komentarza: %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...",
+    "Please move your mouse for more entropy…":
+        "Proszę poruszać myszą aby uzyskać większą entropię…",
+    "Sending paste":
+        "Wysyłanie wklejki",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "Twoja wklejka to <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(wciśnij [Ctrl]+[c] aby skopiować)</span>",
         "Twoja wklejka to <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(wciśnij [Ctrl]+[c] aby skopiować)</span>",
     "Delete data":
     "Delete data":
@@ -129,7 +129,8 @@
     "Source Code": "Kod źródłowy",
     "Source Code": "Kod źródłowy",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Pobierz załącznik",
     "Download attachment": "Pobierz załącznik",
-    "Cloned file attached.": "Sklonowano załączony plik.",
+    "Cloned: '%s'": "Sklonowano: '%s'",
+    "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
     "Attach a file": "Załącz plik",
     "Attach a file": "Załącz plik",
     "Remove attachment": "Usuń załącznik",
     "Remove attachment": "Usuń załącznik",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -146,6 +147,9 @@
     "Enter password":
     "Enter password":
         "Wpisz hasło",
         "Wpisz hasło",
     "Loading…": "Loading…",
     "Loading…": "Loading…",
+    "Decrypting paste…": "Decrypting paste…",
+    "Preparing new paste…": "Preparing new paste…",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a> (in English)."
+        "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a> (in English).",
+    "+++ no paste text +++": "+++ no paste text +++"
 }
 }

+ 155 - 0
i18n/pt.json

@@ -0,0 +1,155 @@
+{
+    "PrivateBin": "PrivateBin",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.":
+        "%s é um serviço minimalista e de código aberto do tipo \"pastebin\", em que o servidor tem zero conhecimento dos dados copiados. Os dados são cifrados e decifrados <i>no navegador</i> usando 256 bits AES. Mais informações na <a href=\"https://privatebin.info/\">página do projeto</a>.",
+    "Because ignorance is bliss":
+        "Porque a ignorância é uma benção",
+    "en": "pt",
+    "Paste does not exist, has expired or has been deleted.":
+        "A cópia não existe, expirou ou já foi excluída.",
+    "%s requires php %s or above to work. Sorry.":
+        "%s requer php %s ou superior para funcionar. Desculpa.",
+    "%s requires configuration section [%s] to be present in configuration file.":
+        "%s requer que a seção de configuração [% s] esteja no arquivo de configuração.",
+    "Please wait %d seconds between each post.":
+        "Por favor espere %d segundos entre cada publicação.",
+    "Paste is limited to %s of encrypted data.":
+        "A cópia está limitada a %s de dados cifrados.",
+    "Invalid data.":
+        "Dados inválidos.",
+    "You are unlucky. Try again.":
+        "Você é azarado. Tente novamente",
+    "Error saving comment. Sorry.":
+        "Erro ao salvar comentário. Desculpa.",
+    "Error saving paste. Sorry.":
+        "Erro ao salvar cópia. Desculpa.",
+    "Invalid paste ID.":
+        "ID de cópia inválido.",
+    "Paste is not of burn-after-reading type.":
+        "Cópia não é do tipo \"queime após ler\".",
+    "Wrong deletion token. Paste was not deleted.":
+        "Token de remoção inválido. A cópia não foi excluída.",
+    "Paste was properly deleted.":
+        "A cópia foi devidamente excluída.",
+    "JavaScript is required for %s to work.<br />Sorry for the inconvenience.":
+        "JavaScript é necessário para que %s funcione.<br />Pedimos desculpas pela inconveniência.",
+    "%s requires a modern browser to work.":
+        "%s requer um navegador moderno para funcionar.",
+    "Still using Internet Explorer? Do yourself a favor, switch to a modern browser:":
+        "Ainda usando Internet Explorer? Faça-se um favor, mude para um navegador moderno:",
+    "New":
+        "Novo",
+    "Send":
+        "Enviar",
+    "Clone":
+        "Clonar",
+    "Raw text":
+        "Texto sem formato",
+    "Expires":
+        "Expirar em",
+    "Burn after reading":
+        "Queime após ler",
+    "Open discussion":
+        "Discussão aberta",
+    "Password (recommended)":
+        "Senha (recomendada)",
+    "Discussion":
+        "Discussão",
+    "Toggle navigation":
+        "Mudar navegação",
+    "%d seconds": ["%d segundo", "%d segundos"],
+    "%d minutes": ["%d minuto", "%d minutos"],
+    "%d hours": ["%d hora", "%d horas"],
+    "%d days": ["%d dia", "%d dias"],
+    "%d weeks": ["%d semana", "%d semanas"],
+    "%d months": ["%d mês", "%d meses"],
+    "%d years": ["%d ano", "%d anos"],
+    "Never":
+        "Nunca",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.":
+        "Nota: Este é um serviço de teste. Dados podem ser perdidos a qualquer momento. Gatinhos morrerão se você abusar desse serviço.",
+    "This document will expire in %d seconds.":
+        ["Este documento irá expirar em um segundo.", "Este documento irá expirar em %d segundos."],
+    "This document will expire in %d minutes.":
+        ["Este documento irá expirar em um minuto.", "Este documento irá expirar em %d minutos."],
+    "This document will expire in %d hours.":
+        ["Este documento irá expirar em uma hora.", "Este documento irá expirar em %d horas."],
+    "This document will expire in %d days.":
+        ["Este documento irá expirar em um dia.", "Este documento irá expirar em %d dias."],
+    "This document will expire in %d months.":
+        ["Este documento irá expirar em um mês.", "Este documento irá expirar em %d meses."],
+    "Please enter the password for this paste:":
+        "Por favor, digite a senha para essa cópia:",
+    "Could not decrypt data (Wrong key?)":
+        "Não foi possível decifrar os dados (Chave errada?)",
+    "Could not delete the paste, it was not stored in burn after reading mode.":
+        "Não foi possível excluir a cópia, ela não foi salva no modo de \"queime após ler\".",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.":
+        "APENAS PARA SEUS OLHOS. Não feche essa janela, essa mensagem não pode ser exibida novamente.",
+    "Could not decrypt comment; Wrong key?":
+        "Não foi possível decifrar o comentário; Chave errada?",
+    "Reply":
+        "Responder",
+    "Anonymous":
+        "Anônimo",
+    "Avatar generated from IP address":
+        "Avatar gerado à partir do endereço IP",
+    "Add comment":
+        "Adicionar comentário",
+    "Optional nickname…":
+        "Apelido opcional…",
+    "Post comment":
+        "Publicar comentário",
+    "Sending comment…":
+        "Enviando comentário…",
+    "Comment posted.":
+        "Comentário publicado.",
+    "Could not refresh display: %s":
+        "Não foi possível atualizar a tela: %s",
+    "unknown status":
+        "Estado desconhecido",
+    "server error or not responding":
+        "Servidor em erro ou não responsivo",
+    "Could not post comment: %s":
+        "Não foi possível publicar o comentário: %s",
+    "Please move your mouse for more entropy…":
+        "Por favor, mova o mouse para maior entropia…",
+    "Sending paste…":
+        "Enviando cópia…",
+    "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
+        "Sua cópia é <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Pressione [Ctrl]+[c] para copiar)</span>",
+    "Delete data":
+        "Excluir dados",
+    "Could not create paste: %s":
+        "Não foi possível criar cópia: %s",
+    "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)":
+        "Não foi possível decifrar a cópia: chave de decriptografia ausente na URL (Você utilizou um redirecionador ou encurtador de URL que removeu parte dela?)",
+    "Format": "Formato",
+    "Plain Text": "Texto sem formato",
+    "Source Code": "Código fonte",
+    "Markdown": "Markdown",
+    "Download attachment": "Baixar anexo",
+    "Cloned: '%s'": "Clonado: '%s'",
+    "The cloned file '%s' was attached to this paste.": "O arquivo clonado '%s' foi anexado a essa cópia.",
+    "Attach a file": "Anexar um arquivo",
+    "Remove attachment": "Remover anexo",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.":
+        "Seu navegador não permite subir arquivos cifrados. Por favor, utilize um navegador mais recente.",
+    "Invalid attachment.": "Anexo inválido.",
+    "Options": "Opções",
+    "Shorten URL": "Encurtar URL",
+    "Editor": "Editor",
+    "Preview": "Visualizar",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
+        "%s requer que o PATH termine em \"%s\". Por favor, atualize o PATH em seu index.php.",
+    "Decrypt":
+        "Decifrar",
+    "Enter password":
+        "Digite a senha",
+    "Loading…": "Carregando…",
+    "Decrypting paste…": "Decifrando cópia…",
+    "Preparing new paste…": "Preparando nova cópia…",
+    "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
+        "Caso essa mensagem nunca desapareça, por favor veja <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">este FAQ para saber como resolver os problemas</a>.",
+    "+++ no paste text +++": "+++ sem texto de cópia +++"
+}

+ 23 - 17
i18n/ru.json

@@ -7,8 +7,8 @@
     "en": "ru",
     "en": "ru",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Запись не существует, просрочена или была удалена.",
         "Запись не существует, просрочена или была удалена.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "Для работы %s требуется PHP 5.3.0 или выше. Извините.",
+    "%s requires php %s or above to work. Sorry.":
+        "Для работы %s требуется php %s или выше. Извините.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s необходимо наличие секции [%s] в конфигурационном файле.",
         "%s необходимо наличие секции [%s] в конфигурационном файле.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -32,7 +32,7 @@
     "Paste was properly deleted.":
     "Paste was properly deleted.":
         "Запись была успешно удалена.",
         "Запись была успешно удалена.",
     "JavaScript is required for %s to work.<br />Sorry for the inconvenience.":
     "JavaScript is required for %s to work.<br />Sorry for the inconvenience.":
-        "Для работы %s требуется включенный JavaScript.<br />Приносим извинения за неудобства..",
+        "Для работы %s требуется включенный JavaScript.<br />Приносим извинения за неудобства.",
     "%s requires a modern browser to work.":
     "%s requires a modern browser to work.":
         "Для работы %s требуется более современный браузер.",
         "Для работы %s требуется более современный браузер.",
     "Still using Internet Explorer? Do yourself a favor, switch to a modern browser:":
     "Still using Internet Explorer? Do yourself a favor, switch to a modern browser:":
@@ -92,30 +92,30 @@
         "Ответить",
         "Ответить",
     "Anonymous":
     "Anonymous":
         "Аноним",
         "Аноним",
-    "Anonymous avatar (Vizhash of the IP address)":
-        "Анонимный аватар (Vizhash IP адреса)",
+    "Avatar generated from IP address":
+        "Аватар, сгенерированный из IP-адреса",
     "Add comment":
     "Add comment":
         "Добавить комментарий",
         "Добавить комментарий",
-    "Optional nickname...":
-        "Опциональный никнейм...",
+    "Optional nickname":
+        "Опциональный никнейм",
     "Post comment":
     "Post comment":
         "Отправить комментарий",
         "Отправить комментарий",
-    "Sending comment...":
-        "Отправка комментария...",
+    "Sending comment":
+        "Отправка комментария",
     "Comment posted.":
     "Comment posted.":
         "Комментарий опубликован.",
         "Комментарий опубликован.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
-        "Невозможно обновить данные: %s",
+        "Не удалось обновить отображение: %s",
     "unknown status":
     "unknown status":
         "неизвестная причина",
         "неизвестная причина",
     "server error or not responding":
     "server error or not responding":
         "ошибка сервера или нет ответа",
         "ошибка сервера или нет ответа",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Не удалось опубликовать комментарий: %s",
         "Не удалось опубликовать комментарий: %s",
-    "Sending paste (Please move your mouse for more entropy)...":
-        "Отправка записи (Пожалуйста двигайте мышкой для большей энтропии)...",
-    "Sending paste...":
-        "Отправка записи...",
+    "Please move your mouse for more entropy…":
+        "Пожалуйста двигайте мышкой для большей энтропии",
+    "Sending paste":
+        "Отправка записи",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "Ссылка на запись <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Нажмите [Ctrl]+[c] чтобы скопировать ссылку)</span>",
         "Ссылка на запись <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Нажмите [Ctrl]+[c] чтобы скопировать ссылку)</span>",
     "Delete data":
     "Delete data":
@@ -138,7 +138,9 @@
     "Source Code": "Исходный код",
     "Source Code": "Исходный код",
     "Markdown": "Язык разметки",
     "Markdown": "Язык разметки",
     "Download attachment": "Скачать прикрепленный файл",
     "Download attachment": "Скачать прикрепленный файл",
-    "Cloned file attached.": "Дубль файла прикреплен.",
+    "Cloned: '%s'": "Дублировано: '%s'",
+    "The cloned file '%s' was attached to this paste.":
+        "Дубликат файла '%s' был прикреплен к этой записи.",
     "Attach a file": "Прикрепить файл",
     "Attach a file": "Прикрепить файл",
     "Remove attachment": "Удалить вложение",
     "Remove attachment": "Удалить вложение",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -154,6 +156,10 @@
         "Расшифровать",
         "Расшифровать",
     "Enter password":
     "Enter password":
         "Введите пароль",
         "Введите пароль",
-    "Uploading paste… Please wait.":
-        "Отправка записи... Пожалуйста подождите."
+    "Loading…": "Загрузка…",
+    "Decrypting paste…": "Расшифровка записи…",
+    "Preparing new paste…": "Подготовка новой записи…",
+    "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
+        "Если данное сообщение не исчезает длительное время, посмотрите <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">этот FAQ с информацией о возможном решении проблемы (на английском)</a>.",
+    "+++ no paste text +++": "+++ в записи нет текста +++"
 }
 }

+ 16 - 12
i18n/sl.json

@@ -7,8 +7,8 @@
     "en": "sl",
     "en": "sl",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "Prilepek ne obstaja, mu je potekla življenjska doba, ali pa je izbrisan.",
         "Prilepek ne obstaja, mu je potekla življenjska doba, ali pa je izbrisan.",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "Oprosti, %s za delovanje potrebuje vsaj php 5.3.0.",
+    "%s requires php %s or above to work. Sorry.":
+        "Oprosti, %s za delovanje potrebuje vsaj php %s.",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s potrebuje sekcijo konfiguracij [%s] v konfiguracijski datoteki.",
         "%s potrebuje sekcijo konfiguracij [%s] v konfiguracijski datoteki.",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -92,16 +92,16 @@
         "Odgovori",
         "Odgovori",
     "Anonymous":
     "Anonymous":
         "Aninomno",
         "Aninomno",
-    "Anonymous avatar (Vizhash of the IP address)":
+    "Avatar generated from IP address":
         "Anonimen avatar (Vizhash IP naslova)",
         "Anonimen avatar (Vizhash IP naslova)",
     "Add comment":
     "Add comment":
         "Dodaj komentar",
         "Dodaj komentar",
-    "Optional nickname...":
+    "Optional nickname":
         "Uporabniško ime (lahko izpustiš)",
         "Uporabniško ime (lahko izpustiš)",
     "Post comment":
     "Post comment":
         "Objavi komentar",
         "Objavi komentar",
-    "Sending comment...":
-        "Pošiljam komentar ...",
+    "Sending comment":
+        "Pošiljam komentar ",
     "Comment posted.":
     "Comment posted.":
         "Komentar poslan.",
         "Komentar poslan.",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
@@ -112,10 +112,10 @@
         "napaka na strežniku, ali pa se strežnik ne odziva",
         "napaka na strežniku, ali pa se strežnik ne odziva",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "Komentarja ni bilo mogoče objaviti : %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...",
+    "Please move your mouse for more entropy…":
+        "Prosim premakni svojo miško za več entropije…",
+    "Sending paste":
+        "Pošiljam prilepek",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "Tvoj prilepek je dostopen na naslovu: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Pritisni [Ctrl]+[c] ali [Cmd] + [c] in skopiraj)</span>",
         "Tvoj prilepek je dostopen na naslovu: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Pritisni [Ctrl]+[c] ali [Cmd] + [c] in skopiraj)</span>",
     "Delete data":
     "Delete data":
@@ -138,7 +138,8 @@
     "Source Code": "Odprta koda",
     "Source Code": "Odprta koda",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Pretoči priponko",
     "Download attachment": "Pretoči priponko",
-    "Cloned file attached.": "Pripeta datoteka klonirana",
+    "Cloned: '%s'": "'%s' klonirana",
+    "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
     "Attach a file": "Pripni datoteko",
     "Attach a file": "Pripni datoteko",
     "Remove attachment": "Odstrani priponko",
     "Remove attachment": "Odstrani priponko",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -155,6 +156,9 @@
     "Enter password":
     "Enter password":
         "Prosim vnesi geslo",
         "Prosim vnesi geslo",
     "Loading…": "Loading…",
     "Loading…": "Loading…",
+    "Decrypting paste…": "Decrypting paste…",
+    "Preparing new paste…": "Preparing new paste…",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a> (in English)."
+        "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a> (in English).",
+    "+++ no paste text +++": "+++ no paste text +++"
 }
 }

+ 24 - 20
i18n/zh.json

@@ -7,8 +7,8 @@
     "en": "zh",
     "en": "zh",
     "Paste does not exist, has expired or has been deleted.":
     "Paste does not exist, has expired or has been deleted.":
         "粘贴不存在,已过期或者已被删除。",
         "粘贴不存在,已过期或者已被删除。",
-    "%s requires php 5.3.0 or above to work. Sorry.":
-        "%s需要工作于PHP 5.3.0及以上版本,抱歉。",
+    "%s requires php %s or above to work. Sorry.":
+        "%s需要工作于PHP %s及以上版本,抱歉。",
     "%s requires configuration section [%s] to be present in configuration file.":
     "%s requires configuration section [%s] to be present in configuration file.":
         "%s需要设置配置文件中 [%s] 的部分。",
         "%s需要设置配置文件中 [%s] 的部分。",
     "Please wait %d seconds between each post.":
     "Please wait %d seconds between each post.":
@@ -92,16 +92,16 @@
         "回复",
         "回复",
     "Anonymous":
     "Anonymous":
         "匿名",
         "匿名",
-    "Anonymous avatar (Vizhash of the IP address)":
-        "匿名头像 (由IP地址生成Vizhash)",
+    "Avatar generated from IP address":
+        "由IP生成的头像",
     "Add comment":
     "Add comment":
         "添加评论",
         "添加评论",
-    "Optional nickname...":
-        "可选昵称...",
+    "Optional nickname":
+        "可选昵称",
     "Post comment":
     "Post comment":
         "评论",
         "评论",
-    "Sending comment...":
-        "评论发送中...",
+    "Sending comment":
+        "评论发送中",
     "Comment posted.":
     "Comment posted.":
         "评论已发送。",
         "评论已发送。",
     "Could not refresh display: %s":
     "Could not refresh display: %s":
@@ -112,10 +112,10 @@
         "服务器错误或无回应",
         "服务器错误或无回应",
     "Could not post comment: %s":
     "Could not post comment: %s":
         "无法发送评论: %s",
         "无法发送评论: %s",
-    "Sending paste (Please move your mouse for more entropy)...":
-        "粘贴提交中 (请移动鼠标以产生更多熵)...",
-    "Sending paste...":
-        "粘贴提交中...",
+    "Please move your mouse for more entropy…":
+        "请移动鼠标增加随机性…",
+    "Sending paste":
+        "粘贴提交中",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>":
         "您的粘贴的链接是<a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按下 [Ctrl]+[c] 以复制)</span>",
         "您的粘贴的链接是<a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按下 [Ctrl]+[c] 以复制)</span>",
     "Delete data":
     "Delete data":
@@ -129,7 +129,8 @@
     "Source Code": "源代码",
     "Source Code": "源代码",
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "下载附件",
     "Download attachment": "下载附件",
-    "Cloned file attached.": "已附加克隆的文件",
+    "Cloned: '%s'": "克隆: '%s'",
+    "The cloned file '%s' was attached to this paste.": "克隆文件 '%s' 已附加到此粘贴。",
     "Attach a file": "添加一个附件",
     "Attach a file": "添加一个附件",
     "Remove attachment": "移除附件",
     "Remove attachment": "移除附件",
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
     "Your browser does not support uploading encrypted files. Please use a newer browser.":
@@ -137,15 +138,18 @@
     "Invalid attachment.": "无效的附件",
     "Invalid attachment.": "无效的附件",
     "Options": "选项",
     "Options": "选项",
     "Shorten URL": "缩短链接",
     "Shorten URL": "缩短链接",
-    "Editor": "編輯",
-    "Preview": "預習",
+    "Editor": "编辑",
+    "Preview": "预览",
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.":
-        "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.",
+        "%s 的 PATH 变量必须结束于 \"%s\"。 请修改你的 index.php 中的 PATH 变量。",
     "Decrypt":
     "Decrypt":
-        "Decrypt",
+        "解密",
     "Enter password":
     "Enter password":
-        "Enter password",
-    "Loading…": "Loading…",
+        "输入密码",
+    "Loading…": "载入中…",
+    "Decrypting paste…": "正在解密",
+    "Preparing new paste…": "正在准备新的粘贴",
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
     "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a>.":
-        "In case this message never disappears please have a look at <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">this FAQ for information to troubleshoot</a> (in English)."
+        "如果这个消息一直不消失,请参考 <a href=\"https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away\">这里的 FAQ 进行故障排除</a> (英文版)。",
+    "+++ no paste text +++": "+++ 没有粘贴内容 +++"
 }
 }

+ 3467 - 1435
js/privatebin.js

@@ -11,7 +11,6 @@
  * @namespace
  * @namespace
  */
  */
 
 
-'use strict';
 /** global: Base64 */
 /** global: Base64 */
 /** global: FileReader */
 /** global: FileReader */
 /** global: RawDeflate */
 /** global: RawDeflate */
@@ -25,23 +24,62 @@
 // Immediately start random number generator collector.
 // Immediately start random number generator collector.
 sjcl.random.startCollectors();
 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) {
 jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) {
+    'use strict';
+
     /**
     /**
-     * static helper methods
+     * static Helper methods
      *
      *
-     * @name helper
+     * @name Helper
      * @class
      * @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 = {
+            '&': '&amp;',
+            '<': '&lt;',
+            '>': '&gt;',
+            '"': '&quot;',
+            "'": '&#39;',
+            '/': '&#x2F;',
+            '`': '&#x60;',
+            '=': '&#x3D;'
+        };
+
+        /**
+         * cache for script location
+         *
+         * @name Helper.baseUri
+         * @private
+         * @enum   {string|null}
+         */
+        var baseUri = null;
+
         /**
         /**
          * converts a duration (in seconds) into human friendly approximation
          * converts a duration (in seconds) into human friendly approximation
          *
          *
-         * @name helper.secondsToHuman
+         * @name Helper.secondsToHuman
          * @function
          * @function
          * @param  {number} seconds
          * @param  {number} seconds
          * @return {Array}
          * @return {Array}
          */
          */
-        secondsToHuman: function(seconds)
+        me.secondsToHuman = function(seconds)
         {
         {
             var v;
             var v;
             if (seconds < 60)
             if (seconds < 60)
@@ -67,84 +105,68 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) {
             }
             }
             v = Math.floor(seconds / (60 * 60 * 24 * 30));
             v = Math.floor(seconds / (60 * 60 * 24 * 30));
             return [v, 'month'];
             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
          * text range selection
          *
          *
          * @see    {@link https://stackoverflow.com/questions/985272/jquery-selecting-text-in-an-element-akin-to-highlighting-with-your-mouse}
          * @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
          * @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
             // 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();
                 range.select();
-            }
-            // all others
-            else if (window.getSelection)
-            {
+            } else if (window.getSelection){
                 selection = window.getSelection();
                 selection = window.getSelection();
-                range = doc.createRange();
-                range.selectNodeContents(text);
+                range = document.createRange();
+                range.selectNodeContents(element);
                 selection.removeAllRanges();
                 selection.removeAllRanges();
                 selection.addRange(range);
                 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
          * @function
-         * @param  {Object} element - a DOM element
+         * @param  {jQuery} $element - a jQuery element
          * @param  {string} text - the text to enter
          * @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...
             // For IE<10: Doesn't support white-space:pre-wrap; so we have to do this...
             if ($('#oldienotice').is(':visible')) {
             if ($('#oldienotice').is(':visible')) {
-                var html = this.htmlEntities(text).replace(/\n/ig, '\r\n<br>');
-                element.html('<pre>' + html + '</pre>');
+                var html = me.htmlEntities(text).replace(/\n/ig, '\r\n<br>');
+                $element.html('<pre>' + html + '</pre>');
             }
             }
             // for other (sane) browsers:
             // for other (sane) browsers:
             else
             else
             {
             {
-                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);
+                $element.text(text);
             }
             }
-        },
+        }
 
 
         /**
         /**
          * convert URLs to clickable links.
          * convert URLs to clickable links.
@@ -155,81 +177,75 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) {
          *     http://user:example.com@localhost:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
          *     http://user:example.com@localhost:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
          * </pre>
          * </pre>
          *
          *
-         * @name   helper.urls2links
+         * @name   Helper.urls2links
          * @function
          * @function
-         * @param  {Object} element - a jQuery DOM element
+         * @param  {Object} $element - a jQuery DOM element
          */
          */
-        urls2links: function(element)
+        me.urls2links = function($element)
         {
         {
             var markup = '<a href="$1" rel="nofollow">$1</a>';
             var markup = '<a href="$1" rel="nofollow">$1</a>';
-            element.html(
-                element.html().replace(
-                    /((http|https|ftp):\/\/[\w?=&.\/-;#@~%+-]+(?![\w\s?&.\/;#~%"=-]*>))/ig,
+            $element.html(
+                $element.html().replace(
+                    /((http|https|ftp):\/\/[\w?=&.\/-;#@~%+*-]+(?![\w\s?&.\/;#~%"=-]*>))/ig,
                     markup
                     markup
                 )
                 )
             );
             );
-            element.html(
-                element.html().replace(
-                    /((magnet):[\w?=&.\/-;#@~%+-]+)/ig,
+            $element.html(
+                $element.html().replace(
+                    /((magnet):[\w?=&.\/-;#@~%+*-]+)/ig,
                     markup
                     markup
                 )
                 )
             );
             );
-        },
+        }
 
 
         /**
         /**
          * minimal sprintf emulation for %s and %d formats
          * minimal sprintf emulation for %s and %d formats
          *
          *
+         * Note that this function needs the parameters in the same order as the
+         * format strings appear in the string, contrary to the original.
+         *
          * @see    {@link https://stackoverflow.com/questions/610406/javascript-equivalent-to-printf-string-format#4795914}
          * @see    {@link https://stackoverflow.com/questions/610406/javascript-equivalent-to-printf-string-format#4795914}
-         * @name   helper.sprintf
+         * @name   Helper.sprintf
          * @function
          * @function
          * @param  {string} format
          * @param  {string} format
          * @param  {...*} args - one or multiple parameters injected into format string
          * @param  {...*} args - one or multiple parameters injected into format string
          * @return {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],
             var format = args[0],
                 i = 1;
                 i = 1;
-            return format.replace(/%((%)|s|d)/g, function (m) {
+            return format.replace(/%(s|d)/g, function (m) {
                 // m is the matched format, e.g. %s, %d
                 // m is the matched format, e.g. %s, %d
-                var val;
-                if (m[2]) {
-                    val = m[2];
-                } else {
-                    val = args[i];
-                    // A switch statement so that the formatter can be extended.
-                    switch (m)
-                    {
-                        case '%d':
-                            val = parseFloat(val);
-                            if (isNaN(val)) {
-                                val = 0;
-                            }
-                            break;
-                        default:
-                            // Default is %s
-                    }
-                    ++i;
+                var val = args[i];
+                // A switch statement so that the formatter can be extended.
+                switch (m)
+                {
+                    case '%d':
+                        val = parseFloat(val);
+                        if (isNaN(val)) {
+                            val = 0;
+                        }
+                        break;
+                    default:
+                        // Default is %s
                 }
                 }
+                ++i;
                 return val;
                 return val;
             });
             });
-        },
+        }
 
 
         /**
         /**
          * get value of cookie, if it was set, empty string otherwise
          * get value of cookie, if it was set, empty string otherwise
          *
          *
          * @see    {@link http://www.w3schools.com/js/js_cookies.asp}
          * @see    {@link http://www.w3schools.com/js/js_cookies.asp}
-         * @name   helper.getCookie
+         * @name   Helper.getCookie
          * @function
          * @function
-         * @param  {string} cname
+         * @param  {string} cname - may not be empty
          * @return {string}
          * @return {string}
          */
          */
-        getCookie: function(cname) {
+        me.getCookie = function(cname) {
             var name = cname + '=',
             var name = cname + '=',
                 ca = document.cookie.split(';');
                 ca = document.cookie.split(';');
             for (var i = 0; i < ca.length; ++i) {
             for (var i = 0; i < ca.length; ++i) {
@@ -244,375 +260,236 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) {
                 }
                 }
             }
             }
             return '';
             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/
          * eg. http://example.com/path/?aaaa#bbbb --> http://example.com/path/
          *
          *
-         * @name   helper.scriptLocation
-         * @function
-         * @return {string} current script location
-         */
-        scriptLocation: 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
+         * @name   Helper.baseUri
          * @function
          * @function
-         * @return {string} key
+         * @return {string}
          */
          */
-        pageKey: function()
+        me.baseUri = 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
          * 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}
          * @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
          * @function
          * @param  {string} str
          * @param  {string} str
          * @return {string} escaped HTML
          * @return {string} escaped HTML
          */
          */
-        htmlEntities: function(str) {
+        me.htmlEntities = function(str) {
             return String(str).replace(
             return String(str).replace(
                 /[&<>"'`=\/]/g, function(s) {
                 /[&<>"'`=\/]/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: {
-            '&': '&amp;',
-            '<': '&lt;',
-            '>': '&gt;',
-            '"': '&quot;',
-            "'": '&#39;',
-            '/': '&#x2F;',
-            '`': '&#x60;',
-            '=': '&#x3D;'
+        me.reset = function()
+        {
+            baseUri = null;
         }
         }
-    };
+
+        return me;
+    })();
 
 
     /**
     /**
-     * static attachment helper methods
+     * internationalization module
      *
      *
-     * @name helper
+     * @name I18n
+     * @param  {object} window
+     * @param  {object} document
      * @class
      * @class
      */
      */
-    var attachmentHelpers = {
-        attachmentData: undefined,
-        file: undefined,
-
-        /*
-         * Read file data as dataURL using the FileReader API
-         * https://developer.mozilla.org/en-US/docs/Web/API/FileReader#readAsDataURL()
-         */
-        readFileData: function (file) {
-            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 fileReader = new FileReader();
-            if (file === undefined) {
-                file = controller.fileInput[0].files[0];
-                $('#dragAndDropFileName').text('');
-            } else {
-                $('#dragAndDropFileName').text(file.name);
-            }
-
-            attachmentHelpers.file = file;
-
-            fileReader.onload = function (event) {
-                var dataURL = event.target.result;
-                attachmentHelpers.attachmentData = dataURL;
-
-                if (controller.messagePreview.parent().hasClass('active')) {
-                    attachmentHelpers.handleFilePreviews(controller.attachmentPreview, dataURL);
-                }
-            };
-            fileReader.readAsDataURL(file);
-        },
-
-        /**
-         *  Handle the preview of files.
-         *  @argument {DOM Element} targetElement where the preview should be appended.
-         *  @argument {File Data} data of the file to be displayed.
-         */
-        handleFilePreviews: function (targetElement, data) {
-            if (data) {
-                var mimeType = this.getMimeTypeFromDataURL(data);
-
-                if (mimeType.match(/image\//i)) {
-                    this.showImagePreview(targetElement, data);
-                } else if (mimeType.match(/video\//i)) {
-                    this.showVideoPreview(targetElement, data, mimeType);
-                } else if (mimeType.match(/audio\//i)) {
-                    this.showAudioPreview(targetElement, data, mimeType);
-                } else if (mimeType.match(/\/pdf/i)) {
-                    this.showPDFPreview(targetElement, data);
-                }
-                //else {
-                //console.log("file but no image/video/audio/pdf");
-                //}
-            }
-        },
+    var I18n = (function (window, document) {
+        var me = {};
 
 
         /**
         /**
-         * Get Mime Type from a DataURL
+         * const for string of loaded language
          *
          *
-         * @param {type} dataURL
-         * @returns Mime Type from a dataURL as obtained for a file using the FileReader API https://developer.mozilla.org/en-US/docs/Web/API/FileReader#readAsDataURL()
+         * @name I18n.languageLoadedEvent
+         * @private
+         * @prop   {string}
+         * @readonly
          */
          */
-        getMimeTypeFromDataURL: function (dataURL) {
-            return dataURL.slice(dataURL.indexOf('data:') + 5, dataURL.indexOf(';base64,'));
-        },
-
-        showImagePreview: function (targetElement, image) {
-            targetElement.html(
-                    $(document.createElement('img'))
-                    .attr('src', image)
-                    .attr('class', 'img-thumbnail')
-                    );
-            targetElement.removeClass('hidden');
-        },
-
-        showVideoPreview: function (targetElement, video, mimeType) {
-            var videoPlayer = $(document.createElement('video'))
-                    .attr('controls', 'true')
-                    .attr('autoplay', 'true')
-                    .attr('loop', 'true')
-                    .attr('class', 'img-thumbnail');
-
-            videoPlayer.append($(document.createElement('source'))
-                    .attr('type', mimeType)
-                    .attr('src', video));
-            targetElement.html(videoPlayer);
-            targetElement.removeClass('hidden');
-        },
-
-        showAudioPreview: function (targetElement, audio, mimeType) {
-            var audioPlayer = $(document.createElement('audio'))
-                    .attr('controls', 'true')
-                    .attr('autoplay', 'true');
-
-            audioPlayer.append($(document.createElement('source'))
-                    .attr('type', mimeType)
-                    .attr('src', audio));
-            targetElement.html(audioPlayer);
-            targetElement.removeClass('hidden');
-        },
-
-        showPDFPreview: function (targetElement, pdf) {
-            //PDFs are only displayed if the filesize is smaller than about 1MB (after base64 encoding).
-            //Bigger filesizes currently cause crashes in various browsers.
-            //See also: https://code.google.com/p/chromium/issues/detail?id=69227
-
-            //Firefox crashes with files that are about 1.5MB
-            //The performance with 1MB files is bareable
-            if (pdf.length < 1398488) {
-
-                //Fallback for browsers, that don't support the vh unit
-                var clientHeight = $(window).height();
-
-                targetElement.html(
-                        $(document.createElement('embed'))
-                        .attr('src', pdf)
-                        .attr('type', 'application/pdf')
-                        .attr('class', 'pdfPreview')
-                        .css('height', clientHeight)
-                        );
-                targetElement.removeClass('hidden');
-            } else {
-                controller.showError(i18n._('File too large, to display a preview. Please download the attachment.'));
-            }
-        },
-
-        addDragDropHandler: function () {
-            var fileInput = controller.fileInput;
-
-            if (fileInput.length === 0) {
-                return;
-            }
-
-            function ignoreDragDrop(event) {
-                event.stopPropagation();
-                event.preventDefault();
-            }
-
-            function drop(event) {
-                event.stopPropagation();
-                event.preventDefault();
-
-                if (fileInput) {
-                    var file = event.dataTransfer.files[0];
-                    //Clear the file input:
-                    fileInput.wrap('<form>').closest('form').get(0).reset();
-                    fileInput.unwrap();
-                    //Only works in Chrome:
-                    //fileInput[0].files = e.dataTransfer.files;
+        var languageLoadedEvent = 'languageLoaded';
 
 
-                    attachmentHelpers.readFileData(file);
-                }
-            }
-
-            document.addEventListener("drop", drop, false);
-            document.addEventListener("dragenter", ignoreDragDrop, false);
-            document.addEventListener("dragover", ignoreDragDrop, false);
-            fileInput.on("change", function () {
-                attachmentHelpers.readFileData();
-            });
-        }
-    };
-
-    /**
-     * internationalization methods
-     *
-     * @name i18n
-     * @class
-     */
-    var i18n = {
         /**
         /**
          * supported languages, minus the built in 'en'
          * supported languages, minus the built in 'en'
          *
          *
-         * @name   i18n.supportedLanguages
+         * @name I18n.supportedLanguages
+         * @private
          * @prop   {string[]}
          * @prop   {string[]}
          * @readonly
          * @readonly
          */
          */
-        supportedLanguages: ['de', 'es', 'fr', 'it', 'no', 'pl', 'oc', 'ru', 'sl', 'zh'],
+        var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'pt', 'oc', 'ru', 'sl', 'zh'];
+
+        /**
+         * built in language
+         *
+         * @name I18n.language
+         * @private
+         * @prop   {string|null}
+         */
+        var language = null;
 
 
         /**
         /**
-         * translate a string, alias for i18n.translate()
+         * translation cache
+         *
+         * @name I18n.translations
+         * @private
+         * @enum   {Object}
+         */
+        var translations = {};
+
+        /**
+         * translate a string, alias for I18n.translate
          *
          *
-         * @name   i18n._
+         * @name   I18n._
          * @function
          * @function
+         * @param  {jQuery} $element - optional
          * @param  {string} messageId
          * @param  {string} messageId
          * @param  {...*} args - one or multiple parameters injected into placeholders
          * @param  {...*} args - one or multiple parameters injected into placeholders
          * @return {string}
          * @return {string}
          */
          */
-        _: function()
+        me._ = function()
         {
         {
-            return this.translate(arguments);
-        },
+            return me.translate.apply(this, arguments);
+        }
 
 
         /**
         /**
          * translate a string
          * 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
          * @function
+         * @param  {jQuery} $element - optional
          * @param  {string} messageId
          * @param  {string} messageId
          * @param  {...*} args - one or multiple parameters injected into placeholders
          * @param  {...*} args - one or multiple parameters injected into placeholders
          * @return {string}
          * @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]);
             var usesPlurals = $.isArray(args[0]);
-            if (usesPlurals)
-            {
+            if (usesPlurals) {
                 // use the first plural form as messageId, otherwise the singular
                 // use the first plural form as messageId, otherwise the singular
                 messageId = (args[0].length > 1 ? args[0][1] : args[0][0]);
                 messageId = (args[0].length > 1 ? args[0][1] : args[0][0]);
-            }
-            else
-            {
+            } else {
                 messageId = args[0];
                 messageId = args[0];
             }
             }
-            if (messageId.length === 0)
-            {
+
+            if (messageId.length === 0) {
                 return messageId;
                 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
+                }
+
+                // 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
                 }
                 }
-                this.translations[messageId] = args[0];
+
+                // 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),
                 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;
                     key = maxKey;
                 }
                 }
-                args[0] = this.translations[messageId][key];
+                args[0] = translations[messageId][key];
                 args[1] = n;
                 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
          * per language functions to use to determine the plural form
          *
          *
          * @see    {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html}
          * @see    {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html}
-         * @name   i18n.getPluralForm
+         * @name   I18n.getPluralForm
          * @function
          * @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 'fr':
                 case 'oc':
                 case 'oc':
@@ -624,1381 +501,3536 @@ jQuery.PrivateBin = function($, sjcl, Base64, RawDeflate) {
                     return (n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2));
                     return (n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2));
                 case 'sl':
                 case 'sl':
                     return (n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0)));
                     return (n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0)));
-                // de, en, es, it, no
+                // de, en, es, it, no, pt
                 default:
                 default:
                     return (n !== 1 ? 1 : 0);
                     return (n !== 1 ? 1 : 0);
             }
             }
-        },
+        }
 
 
         /**
         /**
-         * load translations into cache, then trigger controller initialization
+         * load translations into cache
          *
          *
-         * @name   i18n.loadTranslations
+         * @name   I18n.loadTranslations
          * @function
          * @function
          */
          */
-        loadTranslations: function()
+        me.loadTranslations = function()
         {
         {
-            var language = helper.getCookie('lang');
-            if (language.length === 0)
-            {
-                language = (navigator.language || navigator.userLanguage).substring(0, 2);
+            var newLanguage = Helper.getCookie('lang');
+
+            // auto-select language based on browser settings
+            if (newLanguage.length === 0) {
+                newLanguage = (navigator.language || navigator.userLanguage).substring(0, 2);
             }
             }
-            // note that 'en' is built in, so no translation is necessary
-            if (i18n.supportedLanguages.indexOf(language) === -1)
-            {
-                controller.init();
+
+            // if language is already used skip update
+            if (newLanguage === language) {
+                return;
             }
             }
-            else
-            {
-                $.getJSON('i18n/' + language + '.json', function(data) {
-                    i18n.language = language;
-                    i18n.translations = data;
-                    controller.init();
-                });
+
+            // if language is built-in (English) skip update
+            if (newLanguage === 'en') {
+                language = 'en';
+                return;
             }
             }
-        },
 
 
-        /**
-         * built in language
-         *
-         * @name   i18n.language
-         * @prop   {string}
-         */
-        language: 'en',
+            // 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;
+            }
 
 
-        /**
-         * translation cache
-         *
-         * @name   i18n.translations
-         * @enum   {Object}
-         */
-        translations: {}
-    };
+            // 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
      * @class
      */
      */
-    var filter = {
+    var CryptTool = (function () {
+        var me = {};
+
         /**
         /**
          * compress a message (deflate compression), returns base64 encoded data
          * compress a message (deflate compression), returns base64 encoded data
          *
          *
-         * @name   filter.compress
+         * @name   CryptTool.compress
          * @function
          * @function
+         * @private
          * @param  {string} message
          * @param  {string} message
          * @return {string} base64 data
          * @return {string} base64 data
          */
          */
-        compress: function(message)
+        function compress(message)
         {
         {
             return Base64.toBase64( RawDeflate.deflate( Base64.utob(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
          * @function
+         * @private
          * @param  {string} data - base64 data
          * @param  {string} data - base64 data
          * @return {string} message
          * @return {string} message
          */
          */
-        decompress: function(data)
+        function decompress(data)
         {
         {
             return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) );
             return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) );
-        },
+        }
 
 
         /**
         /**
          * compress, then encrypt message with given key and password
          * compress, then encrypt message with given key and password
          *
          *
-         * @name   filter.cipher
+         * @name   CryptTool.cipher
          * @function
          * @function
          * @param  {string} key
          * @param  {string} key
          * @param  {string} password
          * @param  {string} password
          * @param  {string} message
          * @param  {string} message
          * @return {string} data - JSON with encrypted data
          * @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
             // 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
          * decrypt message with key, then decompress
          *
          *
-         * @name   filter.decipher
+         * @name   CryptTool.decipher
          * @function
          * @function
          * @param  {string} key
          * @param  {string} key
          * @param  {string} password
          * @param  {string} password
          * @param  {string} data - JSON with encrypted data
          * @param  {string} data - JSON with encrypted data
          * @return {string} decrypted message
          * @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 '';
             return '';
         }
         }
-    };
 
 
-    /**
-     * PrivateBin logic
-     *
-     * @name controller
-     * @class
-     */
-    var controller = {
         /**
         /**
-         * headers to send in AJAX requests
+         * checks whether the crypt tool has collected enough entropy
          *
          *
-         * @name   controller.headers
-         * @enum   {Object}
+         * @name   CryptTool.isEntropyReady
+         * @function
+         * @return {bool}
          */
          */
-        headers: {'X-Requested-With': 'JSONHttpRequest'},
+        me.isEntropyReady = function()
+        {
+            return sjcl.random.isReady();
+        }
 
 
         /**
         /**
-         * URL shortners create address
+         * add a listener function, triggered when enough entropy is available
          *
          *
-         * @name   controller.shortenerUrl
-         * @prop   {string}
+         * @name   CryptTool.addEntropySeedListener
+         * @function
+         * @param {function} func
          */
          */
-        shortenerUrl: '',
+        me.addEntropySeedListener = function(func)
+        {
+            sjcl.random.addEventListener('seeded', func);
+        }
 
 
         /**
         /**
-         * URL of newly created paste
+         * returns a random symmetric key
          *
          *
-         * @name   controller.createdPasteUrl
-         * @prop   {string}
+         * @name   CryptTool.getSymmetricKey
+         * @function
+         * @return {string} func
          */
          */
-        createdPasteUrl: '',
+        me.getSymmetricKey = function()
+        {
+            return sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0);
+        }
+
+        return me;
+    })();
+
+    /**
+     * (Model) Data source (aka MVC)
+     *
+     * @name   Model
+     * @class
+     */
+    var Model = (function () {
+        var me = {};
+
+        var $cipherData,
+            $templates;
+
+        var id = null, symmetricKey = null;
 
 
         /**
         /**
-         * ask the user for the password and set it
+         * returns the expiration set in the HTML
          *
          *
-         * @name   controller.requestPassword
+         * @name   Model.getExpirationDefault
          * @function
          * @function
+         * @return string
+         * @TODO the template can be simplified as #pasteExpiration is no longer modified (only default value)
          */
          */
-        requestPassword: function()
+        me.getExpirationDefault = 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();
-            }
-        },
+            return $('#pasteExpiration').val();
+        }
 
 
         /**
         /**
-         * use given format on paste, defaults to plain text
+         * returns the format set in the HTML
          *
          *
-         * @name   controller.formatPaste
+         * @name   Model.getFormatDefault
          * @function
          * @function
-         * @param  {string} format
-         * @param  {string} text
+         * @return string
+         * @TODO the template can be simplified as #pasteFormatter is no longer modified (only default value)
          */
          */
-        formatPaste: function(format, text)
+        me.getFormatDefault = 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');
-
-                        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 $('#pasteFormatter').val();
+        }
 
 
         /**
         /**
-         * show decrypted text in the display area, including discussion (if open)
+         * check if cipher data was supplied
          *
          *
-         * @name   controller.displayMessages
+         * @name   Model.getCipherData
          * @function
          * @function
-         * @param  {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta'))
+         * @return boolean
          */
          */
-        displayMessages: function(paste)
+        me.hasCipherData = function()
         {
         {
-            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';
-                        }
+            return (me.getCipherData().length > 0);
+        }
 
 
-                        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');
-                        attachmentHelpers.handleFilePreviews(this.attachmentPreview, attachment);
+        /**
+         * returns the cipher data
+         *
+         * @name   Model.getCipherData
+         * @function
+         * @return string
+         */
+        me.getCipherData = function()
+        {
+            return $cipherData.text();
+        }
 
 
-                    }
-                    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';
-                    }
+        /**
+         * 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);
 
 
-                    this.passwordInput.val(password);
-                    if (cleartext.length > 0)
-                    {
-                        $('#pasteFormatter').val(paste.meta.formatter);
-                        this.formatPaste(paste.meta.formatter, cleartext);
-                    }
+                if (id === '') {
+                    throw 'no paste id given';
                 }
                 }
-                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 = $('<article><div class="comment" id="comment_' + comment.id
-                                   + '"><div class="commentmeta"><span class="nickname"></span>'
-                                   + '<span class="commentdate"></span></div>'
-                                   + '<div class="commentdata"></div>'
-                                   + '<button class="btn btn-default btn-sm">'
-                                   + i18n._('Reply') + '</button></div></article>'),
-                        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);
+            return id;
+        }
 
 
-                    // 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('<i>' + i18n._('Anonymous') + '</i>');
-                    }
-                    divComment.find('span.commentdate')
-                              .text(' (' + (new Date(comment.meta.postdate * 1000).toLocaleString()) + ')')
-                              .attr('title', 'CommentID: ' + comment.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 an avatar is available, display it
-                    if (comment.meta.vizhash)
-                    {
-                        divComment.find('span.nickname')
-                                  .before(
-                                    '<img src="' + comment.meta.vizhash + '" class="vizhash" title="' +
-                                    i18n._('Anonymous avatar (Vizhash of the IP address)') + '" /> '
-                                  );
-                    }
+                if (symmetricKey === '') {
+                    throw 'no encryption key given';
+                }
 
 
-                    place.append(divComment);
+                // 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);
                 }
                 }
-                var divComment = $(
-                    '<div class="comment"><button class="btn btn-default btn-sm">' +
-                    i18n._('Add comment') + '</button></div>'
-                );
-                divComment.find('button').click({commentid: helper.pasteId()}, $.proxy(this.openReply, this));
-                this.comments.append(divComment);
-                this.discussion.removeClass('hidden');
             }
             }
-        },
+
+            return symmetricKey;
+        }
 
 
         /**
         /**
-         * open the comment entry when clicking the "Reply" button of a comment
+         * returns a jQuery copy of the HTML template
          *
          *
-         * @name   controller.openReply
+         * @name Model.getTemplate
          * @function
          * @function
-         * @param  {Event} event
+         * @param  {string} name - the name of the template
+         * @return {jQuery}
          */
          */
-        openReply: function(event)
+        me.getTemplate = function(name)
         {
         {
-            event.preventDefault();
-
-            // remove any other reply area
-            $('div.reply').remove();
-
-            var source = $(event.target),
-                commentid = event.data.commentid,
-                hint = i18n._('Optional nickname...'),
-                reply = $(
-                    '<div class="reply"><input type="text" id="nickname" ' +
-                    'class="form-control" title="' + hint + '" placeholder="' +
-                    hint + '" /><textarea id="replymessage" class="replymessage ' +
-                    'form-control" cols="80" rows="7"></textarea><br />' +
-                    '<div id="replystatus"></div><button id="replybutton" ' +
-                    'class="btn btn-default btn-sm">' + i18n._('Post comment') +
-                    '</button></div>'
-                );
-            reply.find('button').click(
-                {parentid: commentid},
-                $.proxy(this.sendComment, this)
-            );
-            source.after(reply);
-            this.replyStatus = $('#replystatus');
-            $('#replymessage').focus();
-        },
+            // 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);
+        }
 
 
         /**
         /**
-         * send a reply in a discussion
+         * resets state, used for unit testing
          *
          *
-         * @name   controller.sendComment
+         * @name   Model.reset
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        sendComment: function(event)
+        me.reset = function()
         {
         {
-            event.preventDefault();
-            this.errorMessage.addClass('hidden');
-            // do not send if no data
-            var replyMessage = $('#replymessage');
-            if (replyMessage.val().length === 0)
-            {
-                return;
-            }
-
-            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
-            };
+            $cipherData = $templates = id = symmetricKey = null;
+        }
 
 
-            $.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
+         * init navigation manager
+         *
+         * preloads jQuery elements
          *
          *
-         * @name   controller.sendData
+         * @name   Model.init
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        sendData: function(event)
+        me.init = function()
         {
         {
-            event.preventDefault();
+            $cipherData = $('#cipherdata');
+            $templates = $('#templates');
+        }
 
 
-            var fileName = attachmentHelpers.file ? attachmentHelpers.file.name : this.attachmentLink.attr('download');
-            var attachmentData = attachmentHelpers.attachmentData || this.attachmentLink.attr('href');
+        return me;
+    })();
 
 
-            // do not send if no data.
-            if (this.message.val().length === 0 && !(fileName && attachmentData))
-            {
-                return;
-            }
+    /**
+     * 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 = {};
 
 
-            // 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;
+        /**
+         * handle history (pop) state changes
+         *
+         * currently this does only handle redirects to the home page.
+         *
+         * @name   UiHelper.historyChange
+         * @private
+         * @function
+         * @param  {Event} event
+         */
+        function historyChange(event)
+        {
+            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;
             }
             }
+        }
 
 
-            $('.navbar-toggle').click();
-            this.password.addClass('hidden');
-            this.showStatus(i18n._('Sending paste...'), true);
+        /**
+         * reload the page
+         *
+         * This takes the user to the PrivateBin homepage.
+         *
+         * @name   UiHelper.reloadHome
+         * @function
+         */
+        me.reloadHome = function()
+        {
+            window.location.href = Helper.baseUri();
+        }
 
 
-            this.stateSubmittingPaste();
+        /**
+         * checks whether the element is currently visible in the viewport (so
+         * the user can actually see it)
+         *
+         * @see    {@link https://stackoverflow.com/a/40658647}
+         * @name   UiHelper.isVisible
+         * @function
+         * @param  {jQuery} $element The link hash to move to.
+         */
+        me.isVisible = function($element)
+        {
+            var elementTop = $element.offset().top;
+            var viewportTop = $(window).scrollTop();
+            var viewportBottom = viewportTop + $(window).height();
 
 
-            var randomkey = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0),
-                password = this.passwordInput.val();
-            if(fileName)
-            {
-                controller.sendDataContinue(
-                    randomkey,
-                    filter.cipher(randomkey, password, attachmentData),
-                    filter.cipher(randomkey, password, fileName)
-                );
-            }
-            else
-            {
-                this.sendDataContinue(randomkey, '', '');
-            }
-        },
+            return (elementTop > viewportTop && elementTop < viewportBottom);
+        }
 
 
         /**
         /**
-         * send a new paste to server, step 2
+         * scrolls to a specific element
          *
          *
-         * @name   controller.sendDataContinue
+         * @see    {@link https://stackoverflow.com/questions/4198041/jquery-smooth-scroll-to-an-anchor#answer-12714767}
+         * @name   UiHelper.scrollTo
          * @function
          * @function
-         * @param  {string} randomkey
-         * @param  {string} cipherdata_attachment
-         * @param  {string} cipherdata_attachment_name
+         * @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
          */
          */
-        sendDataContinue: function(randomkey, cipherdata_attachment, cipherdata_attachment_name)
+        me.scrollTo = function($element, animationDuration, animationEffect, finishedCallback)
         {
         {
-            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;
-                }
+            var $body = $('html, body'),
+                margin = 50,
+                callbackCalled = false;
+
+            //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;
             }
             }
-            $.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 <a id="pasteurl" href="%s">%s</a> <span id="copyhint">(Hit [Ctrl]+[c] to copy)</span>',
-                                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('<a href="' + deleteUrl + '">' + i18n._('Delete data') + '</a>');
-                        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')));
+            // 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;
                 }
                 }
-            })
-            .fail(function()
-            {
-                // revert loading status…
-                this.stateNewPaste();
-                controller.showError(i18n._('Could not create paste: %s', i18n._('server error or not responding')));
+                next();
             });
             });
-        },
+        }
 
 
         /**
         /**
-         * check if a URL shortener was defined and create HTML containing a link to it
+         * initialize
          *
          *
-         * @name   controller.shortenUrl
+         * @name   UiHelper.init
          * @function
          * @function
-         * @param  {string} url
-         * @return {string} html
          */
          */
-        shortenUrl: function(url)
+        me.init = function()
         {
         {
-            var shortenerHtml = $('#shortenbutton');
-            if (shortenerHtml) {
-                this.shortenerUrl = shortenerHtml.data('shortener');
-                this.createdPasteUrl = url;
-                return ' ' + $('<div />').append(shortenerHtml.clone()).html();
-            }
-            return '';
-        },
-
-        /**
-         * put the screen in "New paste" mode
-         *
-         * @name   controller.stateNewPaste
+            // update link to home page
+            $('.reloadlink').prop('href', Helper.baseUri());
+
+            $(window).on('popstate', historyChange);
+        }
+
+        return me;
+    })(window, document);
+
+    /**
+     * Alert/error manager
+     *
+     * @name   Alert
+     * @class
+     */
+    var Alert = (function () {
+        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
          * @function
+         * @param  {int} id - id of notification
+         * @param  {jQuery} $element - jQuery object
+         * @param  {string|array} args
+         * @param  {string|null} icon - optional, icon
          */
          */
-        stateNewPaste: function()
+        function handleNotification(id, $element, args, icon)
         {
         {
-            this.message.text('');
-            this.attachment.addClass('hidden');
-            this.attachmentPreview.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();
-        },
+            // 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');
+        }
 
 
         /**
         /**
-         * put the screen in mode after submitting a paste
+         * display a status message
+         *
+         * This automatically passes the text to I18n for translation.
          *
          *
-         * @name   controller.stateSubmittingPaste
+         * @name   Alert.showStatus
          * @function
          * @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
          */
          */
-        stateSubmittingPaste: function()
+        me.showStatus = function(message, icon, dismissable, autoclose)
         {
         {
-            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');
+            console.log('status shown: ', message);
+            // @TODO: implement dismissable
+            // @TODO: implement autoclose
 
 
-            this.loadingIndicator.removeClass('hidden');
-        },
+            handleNotification(1, $statusMessage, message, icon);
+        }
 
 
         /**
         /**
-         * put the screen in a state where the only option is to submit a
-         * new paste
+         * display an error message
+         *
+         * This automatically passes the text to I18n for translation.
          *
          *
-         * @name   controller.stateOnlyNewPaste
+         * @name   Alert.showError
          * @function
          * @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
          */
          */
-        stateOnlyNewPaste: function()
+        me.showError = function(message, icon, dismissable, autoclose)
         {
         {
-            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');
+            console.error('error message shown: ', message);
+            // @TODO: implement dismissable (bootstrap add-on has it)
+            // @TODO: implement autoclose
 
 
-            this.newButton.removeClass('hidden');
-        },
+            handleNotification(3, $errorMessage, message, icon);
+        }
 
 
         /**
         /**
-         * put the screen in "Existing paste" mode
+         * display remaining message
+         *
+         * This automatically passes the text to I18n for translation.
          *
          *
-         * @name   controller.stateExistingPaste
+         * @name   Alert.showRemaining
          * @function
          * @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.attachmentPreview.removeClass('hidden');
-            this.pasteResult.addClass('hidden');
-            this.message.addClass('hidden');
-            this.clearText.addClass('hidden');
-            this.prettyMessage.addClass('hidden');
-            this.loadingIndicator.addClass('hidden');
-        },
+         * @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);
+        }
 
 
         /**
         /**
-         * when "burn after reading" is checked, disable discussion
+         * shows a loading message, optionally with a percentage
          *
          *
-         * @name   controller.changeBurnAfterReading
+         * This automatically passes all texts to the i10s module.
+         *
+         * @name   Alert.showLoading
          * @function
          * @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
          */
          */
-        changeBurnAfterReading: function()
+        me.showLoading = function(message, percentage, icon)
         {
         {
-            if (this.burnAfterReading.is(':checked') )
-            {
-                this.openDisc.addClass('buttondisabled');
-                this.openDiscussion.attr({checked: false, disabled: true});
+            if (typeof message !== 'undefined' && message !== null) {
+                console.log('status changed: ', message);
             }
             }
-            else
-            {
-                this.openDisc.removeClass('buttondisabled');
-                this.openDiscussion.removeAttr('disabled');
+
+            // 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');
+        }
 
 
         /**
         /**
-         * when discussion is checked, disable "burn after reading"
+         * hides the loading message
          *
          *
-         * @name   controller.changeOpenDisc
+         * @name   Alert.hideLoading
          * @function
          * @function
          */
          */
-        changeOpenDisc: function()
+        me.hideLoading = 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');
-            }
-        },
+            $loadingIndicator.addClass('hidden');
+
+            // hide loading cursor
+            $('body').removeClass('loading');
+        }
 
 
         /**
         /**
-         * forward to URL shortener
+         * hides any status/error messages
          *
          *
-         * @name   controller.sendToShortener
+         * This does not include the loading message.
+         *
+         * @name   Alert.hideMessages
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        sendToShortener: function(event)
+        me.hideMessages = function()
         {
         {
-            event.preventDefault();
-            window.location.href = this.shortenerUrl + encodeURIComponent(this.createdPasteUrl);
-        },
+            // also possible: $('.statusmessage').addClass('hidden');
+            $statusMessage.addClass('hidden');
+            $errorMessage.addClass('hidden');
+        }
 
 
         /**
         /**
-         * reload the page
+         * 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
          *
          *
-         * This takes the user to the PrivateBin home page.
+         * preloads jQuery elements
          *
          *
-         * @name   controller.reloadPage
+         * @name   Alert.init
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        reloadPage: function(event)
+        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;
+    })();
+
+    /**
+     * handles paste status/result
+     *
+     * @name   PasteStatus
+     * @param  {object} window
+     * @class
+     */
+    var PasteStatus = (function (window) {
+        var me = {};
+
+        var $pasteSuccess,
+            $pasteUrl,
+            $remainingTime,
+            $shortenButton;
+
+        /**
+         * forward to URL shortener
+         *
+         * @name   PasteStatus.sendToShortener
+         * @private
+         * @function
+         * @param  {Event} event
+         */
+        function sendToShortener(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 <a id="pasteurl" href="%s">%s</a> <span id="copyhint">(Hit [Ctrl]+[c] to copy)</span>',
+                    url, url
+                )
+            );
+            // save newly created element
+            $pasteUrl = $('#pasteurl');
+            // and add click event
+            $pasteUrl.click(pasteLinkClick);
+
+            // shorten button
+            $('#deletelink').html('<a href="' + deleteUrl + '">' + I18n._('Delete data') + '</a>');
+
+            // 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);
+
+    /**
+     * password prompt
+     *
+     * @name Prompt
+     * @class
+     */
+    var Prompt = (function () {
+        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();
             event.preventDefault();
-            window.location.href = helper.scriptLocation();
-        },
+
+            // 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;
+    })();
+
+    /**
+     * Manage paste/message input, and preview tab
+     *
+     * Note that the actual preview is handled by PasteViewer.
+     *
+     * @name   Editor
+     * @class
+     */
+    var Editor = (function () {
+        var me = {};
+
+        var $editorTabs,
+            $messageEdit,
+            $messagePreview,
+            $message;
+
+        var isPreview = false;
+
+        /**
+         * support input of tab character
+         *
+         * @name   Editor.supportTabs
+         * @function
+         * @param  {Event} event
+         * @this $message (but not used, so it is jQuery-free, possibly faster)
+         */
+        function supportTabs(event)
+        {
+            var keyCode = event.keyCode || event.which;
+            // tab was pressed
+            if (keyCode === 9) {
+                // get caret position & selection
+                var val   = this.value,
+                    start = this.selectionStart,
+                    end   = this.selectionEnd;
+                // set textarea value to: text before caret + tab + text after caret
+                this.value = val.substring(0, start) + '\t' + val.substring(end);
+                // put caret at right position again
+                this.selectionStart = this.selectionEnd = start + 1;
+                // prevent the textarea to lose focus
+                event.preventDefault();
+            }
+        }
+
+        /**
+         * view the Editor tab
+         *
+         * @name   Editor.viewEditor
+         * @function
+         * @param  {Event} event - optional
+         */
+        function viewEditor(event)
+        {
+            // 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   Editor.viewPreview
+         * @function
+         * @param  {Event} event
+         */
+        function viewPreview(event)
+        {
+            // toggle buttons
+            $messageEdit.removeClass('active');
+            $messagePreview.addClass('active');
+
+            // 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();
+            }
+        }
+
+        /**
+         * get the state of the preview
+         *
+         * @name   Editor.isPreview
+         * @function
+         */
+        me.isPreview = function()
+        {
+            return isPreview;
+        }
+
+        /**
+         * reset the Editor view
+         *
+         * @name   Editor.resetInput
+         * @function
+         */
+        me.resetInput = function()
+        {
+            // go back to input
+            if (isPreview) {
+                viewEditor();
+            }
+
+            // clear content
+            $message.val('');
+        }
+
+        /**
+         * shows the Editor
+         *
+         * @name   Editor.show
+         * @function
+         */
+        me.show = function()
+        {
+            $message.removeClass('hidden');
+            $editorTabs.removeClass('hidden');
+        }
+
+        /**
+         * hides the Editor
+         *
+         * @name   Editor.reset
+         * @function
+         */
+        me.hide = function()
+        {
+            $message.addClass('hidden');
+            $editorTabs.addClass('hidden');
+        }
+
+        /**
+         * focuses the message input
+         *
+         * @name   Editor.focusInput
+         * @function
+         */
+        me.focusInput = function()
+        {
+            $message.focus();
+        }
+
+        /**
+         * sets a new text
+         *
+         * @name   Editor.setText
+         * @function
+         * @param {string} newText
+         */
+        me.setText = function(newText)
+        {
+            $message.val(newText);
+        }
+
+        /**
+         * returns the current text
+         *
+         * @name   Editor.getText
+         * @function
+         * @return {string}
+         */
+        me.getText = function()
+        {
+            return $message.val()
+        }
+
+        /**
+         * init status manager
+         *
+         * preloads jQuery elements
+         *
+         * @name   Editor.init
+         * @function
+         */
+        me.init = function()
+        {
+            $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;
+    })();
+
+    /**
+     * (view) Parse and show paste.
+     *
+     * @name   PasteViewer
+     * @class
+     */
+    var PasteViewer = (function () {
+        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;
+            }
+
+            // 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');
+            }
+        }
+
+        /**
+         * displays the paste
+         *
+         * @name   PasteViewer.showPaste
+         * @private
+         * @function
+         */
+        function showPaste()
+        {
+            // 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()
+        {
+            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;
+    })();
+
+    /**
+     * (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 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;
+            }
+        }
+
+        /**
+         * 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()
+        {
+            var link = $attachmentLink.prop('href');
+            return (typeof link !== 'undefined' && link !== '')
+        }
+
+        /**
+         * 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('<i></i>');
+                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(
+                                '<img src="' + comment.meta.vizhash + '" class="vizhash" /> '
+                             );
+                $(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('<!DOCTYPE html><html><head>');
+            for (var i = 0; i < $head.length; i++) {
+                newDoc.write($head[i].outerHTML);
+            }
+            newDoc.write('</head><body><pre>' + Helper.htmlEntities(paste) + '</pre></body></html>');
+            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(updateExpiration);
+            $('ul.dropdown-menu li a', $('#formatter').parent()).click(updateFormat);
+
+            // initiate default state of checkboxes
+            changeBurnAfterReading();
+            changeOpenDiscussion();
+
+            // get default value from template or fall back to set value
+            pasteExpiration = Model.getExpirationDefault() || pasteExpiration;
+        }
+
+        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;
+
+        /**
+         * public variable ('constant') for errors to prevent magic numbers
+         *
+         * @name   Uploader.error
+         * @readonly
+         * @enum   {Object}
+         */
+        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()
+        {
+            // 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';
+            }
+
+            // 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
+        }
+
+        /**
+         * called after successful upload
+         *
+         * @name   Uploader.success
+         * @private
+         * @function
+         * @param {int} status
+         * @param {int} result - 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} result - 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} newUrl
+         */
+        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;
+
+            switch (status) {
+                case me.error['custom']:
+                    errorArray = ['Could not ' + doThisThing + ': %s', data.message];
+                    break;
+                case me.error['unknown']:
+                    errorArray = ['Could not ' + doThisThing + ': %s', I18n._('unknown status')];
+                    break;
+                case me.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();
+        }
 
 
         /**
         /**
-         * return raw text
+         * called after successful comment upload
          *
          *
-         * @name   controller.rawText
+         * @name PasteEncrypter.showUploadedComment
+         * @private
          * @function
          * @function
-         * @param  {Event} event
+         * @param {int} status
+         * @param {object} data
          */
          */
-        rawText: function(event)
-        {
-            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('<pre>' + helper.htmlEntities(paste) + '</pre>');
-            newDoc.close();
-        },
+        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);
+            });
+        }
 
 
         /**
         /**
-         * clone the current paste
+         * adds attachments to the Uploader
          *
          *
-         * @name   controller.clonePaste
+         * @name PasteEncrypter.encryptAttachments
+         * @private
          * @function
          * @function
-         * @param  {Event} event
+         * @param {File|null|undefined} file - optional, falls back to cloned attachment
+         * @param {function} callback - excuted when action is successful
          */
          */
-        clonePaste: function(event)
-        {
-            event.preventDefault();
-            this.stateNewPaste();
+        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;
+                }
 
 
-            // erase the id and the key in url
-            history.replaceState(null, document.title, helper.scriptLocation());
+                var reader = new FileReader();
 
 
-            this.showStatus('');
-            if (this.attachmentLink.attr('href'))
-            {
-                this.clonedFile.removeClass('hidden');
-                this.fileWrap.addClass('hidden');
+                // 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();
             }
             }
-            this.message.text(
-                $('#pasteFormatter').val() === 'markdown' ?
-                    this.prettyPrint.text() : this.clearText.text()
-            );
-            $('.navbar-toggle').click();
-        },
+        }
 
 
         /**
         /**
-         * set the expiration on bootstrap templates
+         * send a reply in a discussion
          *
          *
-         * @name   controller.setExpiration
+         * @name   PasteEncrypter.sendComment
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        setExpiration: function(event)
+        me.sendComment = function()
         {
         {
-            event.preventDefault();
-            var target = $(event.target);
-            $('#pasteExpiration').val(target.data('expiration'));
-            $('#pasteExpirationDisplay').text(target.text());
-        },
+            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();
+        }
 
 
         /**
         /**
-         * set the format on bootstrap templates
+         * sends a new paste to server
          *
          *
-         * @name   controller.setFormat
+         * @name   PasteEncrypter.sendPaste
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        setFormat: function(event)
+        me.sendPaste = function()
         {
         {
-            event.preventDefault();
-            var target = $(event.target);
-            $('#pasteFormatter').val(target.data('format'));
-            $('#pasteFormatterDisplay').text(target.text());
+            // 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);
 
 
-            if (this.messagePreview.parent().hasClass('active')) {
-                this.viewPreview(event);
+            // 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();
+                }
+            );
+        }
 
 
         /**
         /**
-         * set the language in a cookie and reload the page
+         * initialize
          *
          *
-         * @name   controller.setLanguage
+         * @name   PasteEncrypter.init
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        setLanguage: function(event)
+        me.init = function()
         {
         {
-            document.cookie = 'lang=' + $(event.target).data('lang');
-            this.reloadPage(event);
-        },
+            // nothing yet
+        }
+
+        return me;
+    })();
+
+    /**
+     * (controller) Responsible for decrypting cipherdata and passing data to view.
+     *
+     * Only decryption, no download.
+     *
+     * @name PasteDecrypter
+     * @class
+     */
+    var PasteDecrypter = (function () {
+        var me = {};
 
 
         /**
         /**
-         * support input of tab character
+         * decrypt data or prompts for password in cvase of failure
          *
          *
-         * @name   controller.supportTabs
+         * @name   PasteDecrypter.decryptOrPromptPassword
+         * @private
          * @function
          * @function
-         * @param  {Event} event
+         * @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)
          */
          */
-        supportTabs: function(event)
+        function decryptOrPromptPassword(key, password, cipherdata)
         {
         {
-            var keyCode = event.keyCode || event.which;
-            // tab was pressed
-            if (keyCode === 9)
-            {
-                // prevent the textarea to lose focus
-                event.preventDefault();
-                // get caret position & selection
-                var val   = this.value,
-                    start = this.selectionStart,
-                    end   = this.selectionEnd;
-                // set textarea value to: text before caret + tab + text after caret
-                this.value = val.substring(0, start) + '\t' + val.substring(end);
-                // put caret at right position again
-                this.selectionStart = this.selectionEnd = start + 1;
+            // 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);
             }
             }
-        },
 
 
-        /**
-         * view the editor tab
-         *
-         * @name   controller.viewEditor
-         * @function
-         * @param  {Event} event
-         */
-        viewEditor: function(event)
-        {
-            event.preventDefault();
-            this.messagePreview.parent().removeClass('active');
-            this.messageEdit.parent().addClass('active');
-            this.message.focus();
-            this.stateNewPaste();
-        },
+            // if all tries failed, we can only return an error
+            if (plaindata.length === 0) {
+                throw 'failed to decipher data';
+            }
+
+            return plaindata;
+        }
 
 
         /**
         /**
-         * view the preview tab
+         * decrypt the actual paste text
          *
          *
-         * @name   controller.viewPreview
+         * @name   PasteDecrypter.decryptOrPromptPassword
+         * @private
          * @function
          * @function
-         * @param  {Event} event
+         * @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}
          */
          */
-        viewPreview: function(event)
+        function decryptPaste(paste, key, password, ignoreError)
         {
         {
-            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());
-            attachmentHelpers.handleFilePreviews(this.attachmentPreview, attachmentHelpers.attachmentData);
-        },
+            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;
+        }
 
 
         /**
         /**
-         * handle history (pop) state changes
-         *
-         * currently this does only handle redirects to the home page.
+         * decrypts any attachment
          *
          *
-         * @name   controller.historyChange
+         * @name   PasteDecrypter.decryptAttachment
+         * @private
          * @function
          * @function
-         * @param  {Event} event
+         * @param  {object} paste - paste data in object form
+         * @param  {string} key
+         * @param  {string} password
+         * @return {bool} whether action was successful
+         * @throws {string}
          */
          */
-        historyChange: function(event)
+        function decryptAttachment(paste, key, password)
         {
         {
-            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;
+            // 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;
+        }
 
 
         /**
         /**
-         * Forces opening the paste if the link does not do this automatically.
+         * decrypts all comments and shows them
          *
          *
-         * 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   PasteDecrypter.decryptComments
+         * @private
          * @function
          * @function
-         * @param  {Event} event
+         * @param  {object} paste - paste data in object form
+         * @param  {string} key
+         * @param  {string} password
+         * @return {bool} whether action was successful
          */
          */
-        pasteLinkClick: function(event)
+        function decryptComments(paste, key, password)
         {
         {
-            // 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);
+            // 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;
+        }
 
 
         /**
         /**
-         * create a new paste
+         * show decrypted text in the display area, including discussion (if open)
          *
          *
-         * @name   controller.newPaste
+         * @name   PasteDecrypter.run
          * @function
          * @function
+         * @param  {Object} [paste] - (optional) object including comments to display (items = array with keys ('data','meta'))
          */
          */
-        newPaste: function()
+        me.run = function(paste)
         {
         {
-            this.stateNewPaste();
-            this.showStatus('');
-            this.message.text('');
-            this.changeBurnAfterReading();
-            this.changeOpenDisc();
-            attachmentHelpers.addDragDropHandler();
-        },
+            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?)');
+            }
+        }
 
 
         /**
         /**
-         * removes an attachment
+         * initialize
          *
          *
-         * @name   controller.removeAttachment
+         * @name   PasteDecrypter.init
          * @function
          * @function
          */
          */
-        removeAttachment: function()
+        me.init = 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');
+            // nothing yet
+        }
 
 
-            attachmentHelpers.file = undefined;
-            attachmentHelpers.attachmentData = undefined;
-        },
+        return me;
+    })();
+
+    /**
+     * (controller) main PrivateBin logic
+     *
+     * @name   Controller
+     * @param  {object} window
+     * @param  {object} document
+     * @class
+     */
+    var Controller = (function (window, document) {
+        var me = {};
 
 
         /**
         /**
-         * decrypt using the password from the modal dialog
+         * hides all status messages no matter which module showed them
          *
          *
-         * @name   controller.decryptPasswordModal
+         * @name   Controller.hideStatusMessages
          * @function
          * @function
          */
          */
-        decryptPasswordModal: function()
+        me.hideStatusMessages = function()
         {
         {
-            this.passwordInput.val(this.passwordDecrypt.val());
-            this.displayMessages();
-        },
+            PasteStatus.hideMessages();
+            Alert.hideMessages();
+        }
 
 
         /**
         /**
-         * submit a password in the modal dialog
+         * creates a new paste
          *
          *
-         * @name   controller.submitPasswordModal
+         * @name   Controller.newPaste
          * @function
          * @function
-         * @param  {Event} event
          */
          */
-        submitPasswordModal: function(event)
+        me.newPaste = function()
         {
         {
-            event.preventDefault();
-            this.passwordModal.modal('hide');
-        },
+            // 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();
+        }
 
 
         /**
         /**
-         * display an error message,
-         * we use the same function for paste and reply to comments
+         * shows the loaded paste
          *
          *
-         * @name   controller.showError
+         * @name   Controller.showPaste
          * @function
          * @function
-         * @param  {string} message - text to display
          */
          */
-        showError: function(message)
+        me.showPaste = 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());
+            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();
+        }
 
 
         /**
         /**
-         * display a status message,
-         * we use the same function for paste and reply to comments
+         * refreshes the loaded paste to show potential new data
          *
          *
-         * @name   controller.showStatus
+         * @name   Controller.refreshPaste
          * @function
          * @function
-         * @param  {string} message - text to display
-         * @param  {boolean} [spin=false] - (optional) tell if the "spinning" animation should be displayed, defaults to false
+         * @param  {function} callback
          */
          */
-        showStatus: function(message, spin)
+        me.refreshPaste = function(callback)
         {
         {
-            if (spin || false)
-            {
-                var img = '<img src="img/busy.gif" style="width:16px;height:9px;margin:0 4px 0 0;" />';
-                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(' ');
-                return;
-            }
-            if (message === '')
-            {
-                this.status.html(' ');
-                return;
-            }
-            this.status.removeClass('errorMessage').text(message);
-        },
+            // 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();
+        }
 
 
         /**
         /**
-         * bind events to DOM elements
+         * clone the current paste
          *
          *
-         * @name   controller.bindEvents
+         * @name   Controller.clonePaste
          * @function
          * @function
+         * @param  {Event} event
          */
          */
-        bindEvents: function()
+        me.clonePaste = function(event)
         {
         {
-            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));
+            TopNav.collapseBar();
+            TopNav.hideAllButtons();
+            Alert.showLoading('Cloning paste…', 0, 'transfer');
 
 
-            // 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));
+            // hide messages from previous paste
+            me.hideStatusMessages();
 
 
-            // page template drop down
-            $('#language select option').click($.proxy(this.setLanguage, this));
+            // erase the id and the key in url
+            history.pushState({type: 'clone'}, document.title, Helper.baseUri());
 
 
-            // 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));
+            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();
+        }
 
 
-            $(window).on('popstate', $.proxy(this.historyChange, this));
-        },
+        /**
+         * 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();
+        }
 
 
         /**
         /**
-         * main application
+         * application start
          *
          *
-         * @name   controller.init
+         * @name   Controller.init
          * @function
          * @function
          */
          */
-        init: function()
+        me.init = function()
         {
         {
-            // hide "no javascript" message
-            $('#noscript').hide();
-
-            // preload jQuery wrapped DOM elements and bind events
-            this.attach = $('#attach');
-            this.attachment = $('#attachment');
-            this.attachmentLink = $('#attachment a');
-            this.burnAfterReading = $('#burnafterreading');
-            this.burnAfterReadingOption = $('#burnafterreadingoption');
-            this.cipherData = $('#cipherdata');
-            this.clearText = $('#cleartext');
-            this.cloneButton = $('#clonebutton');
-            this.clonedFile = $('#clonedfile');
-            this.comments = $('#comments');
-            this.discussion = $('#discussion');
-            this.errorMessage = $('#errormessage');
-            this.expiration = $('#expiration');
-            this.fileRemoveButton = $('#fileremovebutton');
-            this.fileWrap = $('#filewrap');
-            this.formatter = $('#formatter');
-            this.attachmentPreview = $('#attachmentPreview');
-            this.fileInput = $('#file');
-            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;
-            }
-
-            // keep line height even if content empty
-            this.status.html(' ');
+            // 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
             // 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?)'));
-                    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();
+            if (Model.hasCipherData()) {
+                return me.showPaste();
             }
             }
+
+            // otherwise create a new paste
+            me.newPaste();
         }
         }
-    }
 
 
-    /**
-     * main application start, called when DOM is fully loaded and
-     * runs controller initalization after translations are loaded
-     */
-    $(i18n.loadTranslations);
+        return me;
+    })(window, document);
 
 
     return {
     return {
-        helper: helper,
-        attachmentHelpers: attachmentHelpers,
-        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);
 }(jQuery, sjcl, Base64, RawDeflate);

+ 275 - 40
js/test.js

@@ -11,7 +11,9 @@ var jsc = require('jsverify'),
         a2zString.map(function(c) {
         a2zString.map(function(c) {
             return c.toUpperCase();
             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.$ = global.jQuery = require('./jquery-3.1.1');
 global.sjcl = require('./sjcl-1.0.6');
 global.sjcl = require('./sjcl-1.0.6');
@@ -20,75 +22,320 @@ global.RawDeflate = require('./rawdeflate-0.5');
 require('./rawinflate-0.3');
 require('./rawinflate-0.3');
 require('./privatebin');
 require('./privatebin');
 
 
-describe('helper', function () {
+describe('Helper', function () {
     describe('secondsToHuman', function () {
     describe('secondsToHuman', function () {
         after(function () {
         after(function () {
             cleanup();
             cleanup();
         });
         });
 
 
         jsc.property('returns an array with a number and a word', 'integer', function (number) {
         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) &&
             return Array.isArray(result) &&
                 result.length === 2 &&
                 result.length === 2 &&
                 result[0] === parseInt(result[0], 10) &&
                 result[0] === parseInt(result[0], 10) &&
                 typeof result[1] === 'string';
                 typeof result[1] === 'string';
         });
         });
         jsc.property('returns seconds on the first array position', 'integer 59', function (number) {
         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) {
         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) {
         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) {
         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) {
         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) {
         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) {
         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) {
         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
         // 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) {
         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) {
         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 () {
+    // this test is not yet meaningful using jsdom, as it does not contain getSelection support.
+    // TODO: This needs to be tested using a browser.
+    describe('selectText', function () {
         jsc.property(
         jsc.property(
-            'returns the URL without query & fragment',
+            'selection contains content of given ID',
+            jsc.nearray(jsc.nearray(jsc.elements(alnumString))),
+            'nearray string',
+            function (ids, contents) {
+                var html = '',
+                    result = true;
+                ids.forEach(function(item, i) {
+                    html += '<div id="' + item.join('') + '">' + $.PrivateBin.Helper.htmlEntities(contents[i] || contents[0]) + '</div>';
+                });
+                var clean = jsdom(html);
+                ids.forEach(function(item, i) {
+                    $.PrivateBin.Helper.selectText(item.join(''));
+                    // TODO: As per https://github.com/tmpvar/jsdom/issues/321 there is no getSelection in jsdom, yet.
+                    // Once there is one, uncomment the line below to actually check the result.
+                    //result *= (contents[i] || contents[0]) === window.getSelection().toString();
+                });
+                clean();
+                return Boolean(result);
+            }
+        );
+    });
+
+    describe('setElementText', function () {
+        after(function () {
+            cleanup();
+        });
+
+        jsc.property(
+            'replaces the content of an element',
+            jsc.nearray(jsc.nearray(jsc.elements(alnumString))),
+            'nearray string',
+            'string',
+            function (ids, contents, replacingContent) {
+                var html = '',
+                    result = true;
+                ids.forEach(function(item, i) {
+                    html += '<div id="' + item.join('') + '">' + $.PrivateBin.Helper.htmlEntities(contents[i] || contents[0]) + '</div>';
+                });
+                var elements = $('<body />').html(html);
+                ids.forEach(function(item, i) {
+                    var id = item.join(''),
+                        element = elements.find('#' + id).first();
+                    $.PrivateBin.Helper.setElementText(element, replacingContent);
+                    result *= replacingContent === element.text();
+                });
+                return Boolean(result);
+            }
+        );
+    });
+
+    describe('urls2links', function () {
+        after(function () {
+            cleanup();
+        });
+
+        jsc.property(
+            'ignores non-URL content',
+            'string',
+            function (content) {
+                var element = $('<div>' + content + '</div>'),
+                    before = element.html();
+                $.PrivateBin.Helper.urls2links(element);
+                return before === element.html();
+            }
+        );
+        jsc.property(
+            'replaces URLs with anchors',
+            'string',
+            jsc.elements(['http', 'https', 'ftp']),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
+            jsc.array(jsc.elements(queryString)),
+            jsc.array(jsc.elements(queryString)),
+            'string',
+            function (prefix, schema, address, query, fragment, postfix) {
+                var query = query.join(''),
+                    fragment = fragment.join(''),
+                    url = schema + '://' + address.join('') + '/?' + query + '#' + fragment,
+                    prefix = $.PrivateBin.Helper.htmlEntities(prefix),
+                    postfix = ' ' + $.PrivateBin.Helper.htmlEntities(postfix),
+                    element = $('<div>' + prefix + url + postfix + '</div>');
+
+                // special cases: When the query string and fragment imply the beginning of an HTML entity, eg. &#0 or &#x
+                if (
+                    query.slice(-1) === '&' &&
+                    (parseInt(fragment.substring(0, 1), 10) >= 0 || fragment.charAt(0) === 'x' )
+                )
+                {
+                    url = schema + '://' + address.join('') + '/?' + query.substring(0, query.length - 1);
+                    postfix = '';
+                    element = $('<div>' + prefix + url + '</div>');
+                }
+
+                $.PrivateBin.Helper.urls2links(element);
+                return element.html() === $('<div>' + prefix + '<a href="' + url + '" rel="nofollow">' + url + '</a>' + postfix + '</div>').html();
+            }
+        );
+        jsc.property(
+            'replaces magnet links with anchors',
+            'string',
+            jsc.array(jsc.elements(queryString)),
+            'string',
+            function (prefix, query, postfix) {
+                var url = 'magnet:?' + query.join(''),
+                    prefix = $.PrivateBin.Helper.htmlEntities(prefix),
+                    postfix = $.PrivateBin.Helper.htmlEntities(postfix),
+                    element = $('<div>' + prefix + url + ' ' + postfix + '</div>');
+                $.PrivateBin.Helper.urls2links(element);
+                return element.html() === $('<div>' + prefix + '<a href="' + url + '" rel="nofollow">' + url + '</a> ' + postfix + '</div>').html();
+            }
+        );
+    });
+
+    describe('sprintf', function () {
+        after(function () {
+            cleanup();
+        });
+
+        jsc.property(
+            'replaces %s in strings with first given parameter',
+            'string',
+            '(small nearray) string',
+            'string',
+            function (prefix, params, postfix) {
+                var prefix = prefix.replace(/%(s|d)/g, '%%'),
+                    postfix = postfix.replace(/%(s|d)/g, '%%'),
+                    result = prefix + params[0] + postfix;
+                params.unshift(prefix + '%s' + postfix);
+                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
+            }
+        );
+        jsc.property(
+            'replaces %d in strings with first given parameter',
+            'string',
+            '(small nearray) nat',
+            'string',
+            function (prefix, params, postfix) {
+                var prefix = prefix.replace(/%(s|d)/g, '%%'),
+                    postfix = postfix.replace(/%(s|d)/g, '%%'),
+                    result = prefix + params[0] + postfix;
+                params.unshift(prefix + '%d' + postfix);
+                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
+            }
+        );
+        jsc.property(
+            'replaces %d in strings with 0 if first parameter is not a number',
+            'string',
+            '(small nearray) falsy',
+            'string',
+            function (prefix, params, postfix) {
+                var prefix = prefix.replace(/%(s|d)/g, '%%'),
+                    postfix = postfix.replace(/%(s|d)/g, '%%'),
+                    result = prefix + '0' + postfix;
+                params.unshift(prefix + '%d' + postfix);
+                return result === $.PrivateBin.Helper.sprintf.apply(this, params)
+            }
+        );
+        jsc.property(
+            'replaces %d and %s in strings in order',
+            'string',
+            'nat',
+            'string',
+            'string',
+            'string',
+            function (prefix, uint, middle, string, postfix) {
+                var prefix = prefix.replace(/%(s|d)/g, '%%'),
+                    postfix = postfix.replace(/%(s|d)/g, '%%'),
+                    params = [prefix + '%d' + middle + '%s' + postfix, uint, string],
+                    result = prefix + uint + middle + string + postfix;
+                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
+            }
+        );
+        jsc.property(
+            'replaces %d and %s in strings in reverse order',
+            'string',
+            'nat',
+            'string',
+            'string',
+            'string',
+            function (prefix, uint, middle, string, postfix) {
+                var prefix = prefix.replace(/%(s|d)/g, '%%'),
+                    postfix = postfix.replace(/%(s|d)/g, '%%'),
+                    params = [prefix + '%s' + middle + '%d' + postfix, string, uint],
+                    result = prefix + string + middle + uint + postfix;
+                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
+            }
+        );
+    });
+
+    describe('getCookie', function () {
+        jsc.property(
+            'returns the requested cookie',
+            'nearray asciinestring',
+            'nearray asciistring',
+            function (labels, values) {
+                var selectedKey = '', selectedValue = '',
+                    cookieArray = [],
+                    count = 0;
+                labels.forEach(function(item, i) {
+                    var key = item.replace(/[\s;,=]/g, 'x'),
+                        value = (values[i] || values[0]).replace(/[\s;,=]/g, '');
+                    cookieArray.push(key + '=' + value);
+                    if (Math.random() < 1 / i)
+                    {
+                        selectedKey = key;
+                        selectedValue = value;
+                    }
+                });
+                var clean = jsdom('', {cookie: cookieArray}),
+                    result = $.PrivateBin.Helper.getCookie(selectedKey);
+                clean();
+                return result === selectedValue;
+            }
+        );
+    });
+
+    describe('baseUri', function () {
+        before(function () {
+            $.PrivateBin.Helper.reset();
+        });
+
+        jsc.property(
+            'returns the URL without query & fragment',
+            jsc.elements(schemas),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.array(jsc.elements(queryString)),
             jsc.array(jsc.elements(queryString)),
             'string',
             'string',
             function (schema, address, query, fragment) {
             function (schema, address, query, fragment) {
-                var expected = schema.join('') + '://' + address.join('') + '/',
+                var expected = schema + '://' + address.join('') + '/',
                     clean = jsdom('', {url: expected + '?' + query.join('') + '#' + fragment}),
                     clean = jsdom('', {url: expected + '?' + query.join('') + '#' + fragment}),
-                    result = $.PrivateBin.helper.scriptLocation();
+                    result = $.PrivateBin.Helper.baseUri();
+                $.PrivateBin.Helper.reset();
                 clean();
                 clean();
                 return expected === result;
                 return expected === result;
             }
             }
         );
         );
     });
     });
 
 
-    describe('pasteId', function () {
+    describe('htmlEntities', function () {
+        after(function () {
+            cleanup();
+        });
+
+        jsc.property(
+            'removes all HTML entities from any given string',
+            'string',
+            function (string) {
+                var result = $.PrivateBin.Helper.htmlEntities(string);
+                return !(/[<>"'`=\/]/.test(result)) && !(string.indexOf('&') > -1 && !(/&amp;/.test(result)));
+            }
+        );
+    });
+});
+
+describe('Model', function () {
+    describe('getPasteId', function () {
+        before(function () {
+            $.PrivateBin.Model.reset();
+        });
+
         jsc.property(
         jsc.property(
             'returns the query string without separator, if any',
             'returns the query string without separator, if any',
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
-            jsc.array(jsc.elements(queryString)),
+            jsc.nearray(jsc.elements(queryString)),
             'string',
             'string',
             function (schema, address, query, fragment) {
             function (schema, address, query, fragment) {
                 var queryString = query.join(''),
                 var queryString = query.join(''),
@@ -96,27 +343,29 @@ describe('helper', function () {
                         url: schema.join('') + '://' + address.join('') +
                         url: schema.join('') + '://' + address.join('') +
                              '/?' + queryString + '#' + fragment
                              '/?' + queryString + '#' + fragment
                     }),
                     }),
-                    result = $.PrivateBin.helper.pasteId();
+                    result = $.PrivateBin.Model.getPasteId();
+                $.PrivateBin.Model.reset();
                 clean();
                 clean();
                 return queryString === result;
                 return queryString === result;
             }
             }
         );
         );
     });
     });
 
 
-    describe('pageKey', function () {
+    describe('getPasteKey', function () {
         jsc.property(
         jsc.property(
             'returns the fragment of the URL',
             'returns the fragment of the URL',
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.array(jsc.elements(queryString)),
             jsc.array(jsc.elements(queryString)),
-            jsc.array(jsc.elements(base64String)),
+            jsc.nearray(jsc.elements(base64String)),
             function (schema, address, query, fragment) {
             function (schema, address, query, fragment) {
                 var fragmentString = fragment.join(''),
                 var fragmentString = fragment.join(''),
                     clean = jsdom('', {
                     clean = jsdom('', {
                         url: schema.join('') + '://' + address.join('') +
                         url: schema.join('') + '://' + address.join('') +
                              '/?' + query.join('') + '#' + fragmentString
                              '/?' + query.join('') + '#' + fragmentString
                     }),
                     }),
-                    result = $.PrivateBin.helper.pageKey();
+                    result = $.PrivateBin.Model.getPasteKey();
+                $.PrivateBin.Model.reset();
                 clean();
                 clean();
                 return fragmentString === result;
                 return fragmentString === result;
             }
             }
@@ -126,7 +375,7 @@ describe('helper', function () {
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.nearray(jsc.elements(a2zString)),
             jsc.array(jsc.elements(queryString)),
             jsc.array(jsc.elements(queryString)),
-            jsc.array(jsc.elements(base64String)),
+            jsc.nearray(jsc.elements(base64String)),
             jsc.array(jsc.elements(queryString)),
             jsc.array(jsc.elements(queryString)),
             function (schema, address, query, fragment, trail) {
             function (schema, address, query, fragment, trail) {
                 var fragmentString = fragment.join(''),
                 var fragmentString = fragment.join(''),
@@ -134,25 +383,11 @@ describe('helper', function () {
                         url: schema.join('') + '://' + address.join('') + '/?' +
                         url: schema.join('') + '://' + address.join('') + '/?' +
                              query.join('') + '#' + fragmentString + '&' + trail.join('')
                              query.join('') + '#' + fragmentString + '&' + trail.join('')
                     }),
                     }),
-                    result = $.PrivateBin.helper.pageKey();
+                    result = $.PrivateBin.Model.getPasteKey();
+                $.PrivateBin.Model.reset();
                 clean();
                 clean();
                 return fragmentString === result;
                 return fragmentString === result;
             }
             }
         );
         );
     });
     });
-
-    describe('htmlEntities', function () {
-        after(function () {
-            cleanup();
-        });
-
-        jsc.property(
-            'removes all HTML entities from any given string',
-            'string',
-            function (string) {
-                var result = $.PrivateBin.helper.htmlEntities(string);
-                return !(/[<>"'`=\/]/.test(result)) && !(string.indexOf('&') > -1 && !(/&amp;/.test(result)));
-            }
-        );
-    });
 });
 });

+ 1 - 2
lib/.htaccess

@@ -1,2 +1 @@
-Allow from none
-Deny from all
+Require all denied

+ 1 - 3
lib/Data/AbstractData.php

@@ -58,7 +58,7 @@ abstract class AbstractData
      * @access public
      * @access public
      * @static
      * @static
      * @param  array $options
      * @param  array $options
-     * @return privatebin_abstract
+     * @return AbstractData
      */
      */
     public static function getInstance($options)
     public static function getInstance($options)
     {
     {
@@ -88,7 +88,6 @@ abstract class AbstractData
      *
      *
      * @access public
      * @access public
      * @param  string $pasteid
      * @param  string $pasteid
-     * @return void
      */
      */
     abstract public function delete($pasteid);
     abstract public function delete($pasteid);
 
 
@@ -147,7 +146,6 @@ abstract class AbstractData
      *
      *
      * @access public
      * @access public
      * @param  int $batchsize
      * @param  int $batchsize
-     * @return void
      */
      */
     public function purge($batchsize)
     public function purge($batchsize)
     {
     {

+ 4 - 10
lib/Data/Database.php

@@ -282,7 +282,6 @@ class Database extends AbstractData
      *
      *
      * @access public
      * @access public
      * @param  string $pasteid
      * @param  string $pasteid
-     * @return void
      */
      */
     public function delete($pasteid)
     public function delete($pasteid)
     {
     {
@@ -375,11 +374,10 @@ class Database extends AbstractData
                 $comments[$i]->data           = $row['data'];
                 $comments[$i]->data           = $row['data'];
                 $comments[$i]->meta           = new stdClass;
                 $comments[$i]->meta           = new stdClass;
                 $comments[$i]->meta->postdate = (int) $row['postdate'];
                 $comments[$i]->meta->postdate = (int) $row['postdate'];
-                if (array_key_exists('nickname', $row) && !empty($row['nickname'])) {
-                    $comments[$i]->meta->nickname = $row['nickname'];
-                }
-                if (array_key_exists('vizhash', $row) && !empty($row['vizhash'])) {
-                    $comments[$i]->meta->vizhash = $row['vizhash'];
+                foreach (array('nickname', 'vizhash') as $key) {
+                    if (array_key_exists($key, $row) && !empty($row[$key])) {
+                        $comments[$i]->meta->$key = $row[$key];
+                    }
                 }
                 }
             }
             }
             ksort($comments);
             ksort($comments);
@@ -564,7 +562,6 @@ class Database extends AbstractData
      *
      *
      * @access private
      * @access private
      * @static
      * @static
-     * @return void
      */
      */
     private static function _createPasteTable()
     private static function _createPasteTable()
     {
     {
@@ -589,7 +586,6 @@ class Database extends AbstractData
      *
      *
      * @access private
      * @access private
      * @static
      * @static
-     * @return void
      */
      */
     private static function _createCommentTable()
     private static function _createCommentTable()
     {
     {
@@ -616,7 +612,6 @@ class Database extends AbstractData
      *
      *
      * @access private
      * @access private
      * @static
      * @static
-     * @return void
      */
      */
     private static function _createConfigTable()
     private static function _createConfigTable()
     {
     {
@@ -651,7 +646,6 @@ class Database extends AbstractData
      * @access private
      * @access private
      * @static
      * @static
      * @param  string $oldversion
      * @param  string $oldversion
-     * @return void
      */
      */
     private static function _upgradeDatabase($oldversion)
     private static function _upgradeDatabase($oldversion)
     {
     {

+ 23 - 54
lib/Data/Filesystem.php

@@ -12,8 +12,8 @@
 
 
 namespace PrivateBin\Data;
 namespace PrivateBin\Data;
 
 
-use PrivateBin\Json;
 use PrivateBin\Model\Paste;
 use PrivateBin\Model\Paste;
+use PrivateBin\Persistence\DataStore;
 
 
 /**
 /**
  * Filesystem
  * Filesystem
@@ -22,15 +22,6 @@ use PrivateBin\Model\Paste;
  */
  */
 class Filesystem extends AbstractData
 class Filesystem extends AbstractData
 {
 {
-    /**
-     * directory where data is stored
-     *
-     * @access private
-     * @static
-     * @var string
-     */
-    private static $_dir = 'data/';
-
     /**
     /**
      * get instance of singleton
      * get instance of singleton
      *
      *
@@ -41,17 +32,16 @@ class Filesystem extends AbstractData
      */
      */
     public static function getInstance($options = null)
     public static function getInstance($options = null)
     {
     {
+        // if needed initialize the singleton
+        if (!(self::$_instance instanceof self)) {
+            self::$_instance = new self;
+        }
         // if given update the data directory
         // if given update the data directory
         if (
         if (
             is_array($options) &&
             is_array($options) &&
             array_key_exists('dir', $options)
             array_key_exists('dir', $options)
         ) {
         ) {
-            self::$_dir = $options['dir'] . DIRECTORY_SEPARATOR;
-        }
-        // if needed initialize the singleton
-        if (!(self::$_instance instanceof self)) {
-            self::$_instance = new self;
-            self::_init();
+            DataStore::setPath($options['dir']);
         }
         }
         return self::$_instance;
         return self::$_instance;
     }
     }
@@ -62,19 +52,19 @@ class Filesystem extends AbstractData
      * @access public
      * @access public
      * @param  string $pasteid
      * @param  string $pasteid
      * @param  array  $paste
      * @param  array  $paste
-     * @throws Exception
      * @return bool
      * @return bool
      */
      */
     public function create($pasteid, $paste)
     public function create($pasteid, $paste)
     {
     {
         $storagedir = self::_dataid2path($pasteid);
         $storagedir = self::_dataid2path($pasteid);
-        if (is_file($storagedir . $pasteid)) {
+        $file       = $storagedir . $pasteid;
+        if (is_file($file)) {
             return false;
             return false;
         }
         }
         if (!is_dir($storagedir)) {
         if (!is_dir($storagedir)) {
             mkdir($storagedir, 0700, true);
             mkdir($storagedir, 0700, true);
         }
         }
-        return (bool) file_put_contents($storagedir . $pasteid, Json::encode($paste));
+        return DataStore::store($file, $paste);
     }
     }
 
 
     /**
     /**
@@ -108,7 +98,6 @@ class Filesystem extends AbstractData
      *
      *
      * @access public
      * @access public
      * @param  string $pasteid
      * @param  string $pasteid
-     * @return void
      */
      */
     public function delete($pasteid)
     public function delete($pasteid)
     {
     {
@@ -155,20 +144,19 @@ class Filesystem extends AbstractData
      * @param  string $parentid
      * @param  string $parentid
      * @param  string $commentid
      * @param  string $commentid
      * @param  array  $comment
      * @param  array  $comment
-     * @throws Exception
      * @return bool
      * @return bool
      */
      */
     public function createComment($pasteid, $parentid, $commentid, $comment)
     public function createComment($pasteid, $parentid, $commentid, $comment)
     {
     {
         $storagedir = self::_dataid2discussionpath($pasteid);
         $storagedir = self::_dataid2discussionpath($pasteid);
-        $filename   = $pasteid . '.' . $commentid . '.' . $parentid;
-        if (is_file($storagedir . $filename)) {
+        $file       = $storagedir . $pasteid . '.' . $commentid . '.' . $parentid;
+        if (is_file($file)) {
             return false;
             return false;
         }
         }
         if (!is_dir($storagedir)) {
         if (!is_dir($storagedir)) {
             mkdir($storagedir, 0700, true);
             mkdir($storagedir, 0700, true);
         }
         }
-        return (bool) file_put_contents($storagedir . $filename, Json::encode($comment));
+        return DataStore::store($file, $comment);
     }
     }
 
 
     /**
     /**
@@ -237,8 +225,9 @@ class Filesystem extends AbstractData
     protected function _getExpiredPastes($batchsize)
     protected function _getExpiredPastes($batchsize)
     {
     {
         $pastes     = array();
         $pastes     = array();
+        $mainpath   = DataStore::getPath();
         $firstLevel = array_filter(
         $firstLevel = array_filter(
-            scandir(self::$_dir),
+            scandir($mainpath),
             'self::_isFirstLevelDir'
             'self::_isFirstLevelDir'
         );
         );
         if (count($firstLevel) > 0) {
         if (count($firstLevel) > 0) {
@@ -246,7 +235,7 @@ class Filesystem extends AbstractData
             for ($i = 0, $max = $batchsize * 10; $i < $max; ++$i) {
             for ($i = 0, $max = $batchsize * 10; $i < $max; ++$i) {
                 $firstKey    = array_rand($firstLevel);
                 $firstKey    = array_rand($firstLevel);
                 $secondLevel = array_filter(
                 $secondLevel = array_filter(
-                    scandir(self::$_dir . $firstLevel[$firstKey]),
+                    scandir($mainpath . DIRECTORY_SEPARATOR . $firstLevel[$firstKey]),
                     'self::_isSecondLevelDir'
                     'self::_isSecondLevelDir'
                 );
                 );
 
 
@@ -257,8 +246,9 @@ class Filesystem extends AbstractData
                 }
                 }
 
 
                 $secondKey = array_rand($secondLevel);
                 $secondKey = array_rand($secondLevel);
-                $path      = self::$_dir . $firstLevel[$firstKey] .
-                    DIRECTORY_SEPARATOR . $secondLevel[$secondKey];
+                $path      = $mainpath . DIRECTORY_SEPARATOR .
+                    $firstLevel[$firstKey] . DIRECTORY_SEPARATOR .
+                    $secondLevel[$secondKey];
                 if (!is_dir($path)) {
                 if (!is_dir($path)) {
                     continue;
                     continue;
                 }
                 }
@@ -292,29 +282,6 @@ class Filesystem extends AbstractData
         return $pastes;
         return $pastes;
     }
     }
 
 
-    /**
-     * initialize privatebin
-     *
-     * @access private
-     * @static
-     * @return void
-     */
-    private static function _init()
-    {
-        // Create storage directory if it does not exist.
-        if (!is_dir(self::$_dir)) {
-            mkdir(self::$_dir, 0700);
-        }
-        // Create .htaccess file if it does not exist.
-        if (!is_file(self::$_dir . '.htaccess')) {
-            file_put_contents(
-                self::$_dir . '.htaccess',
-                'Allow from none' . PHP_EOL .
-                'Deny from all' . PHP_EOL
-            );
-        }
-    }
-
     /**
     /**
      * Convert paste id to storage path.
      * Convert paste id to storage path.
      *
      *
@@ -332,8 +299,10 @@ class Filesystem extends AbstractData
      */
      */
     private static function _dataid2path($dataid)
     private static function _dataid2path($dataid)
     {
     {
-        return self::$_dir . substr($dataid, 0, 2) . DIRECTORY_SEPARATOR .
-            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR;
+        return DataStore::getPath(
+            substr($dataid, 0, 2) . DIRECTORY_SEPARATOR .
+            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR
+        );
     }
     }
 
 
     /**
     /**
@@ -363,7 +332,7 @@ class Filesystem extends AbstractData
     private static function _isFirstLevelDir($element)
     private static function _isFirstLevelDir($element)
     {
     {
         return self::_isSecondLevelDir($element) &&
         return self::_isSecondLevelDir($element) &&
-            is_dir(self::$_dir . DIRECTORY_SEPARATOR . $element);
+            is_dir(DataStore::getPath($element));
     }
     }
 
 
     /**
     /**

+ 0 - 15
lib/Filter.php

@@ -21,21 +21,6 @@ use Exception;
  */
  */
 class Filter
 class Filter
 {
 {
-    /**
-     * strips slashes deeply
-     *
-     * @access public
-     * @static
-     * @param  mixed $value
-     * @return mixed
-     */
-    public static function stripslashesDeep($value)
-    {
-        return is_array($value) ?
-            array_map('self::stripslashesDeep', $value) :
-            stripslashes($value);
-    }
-
     /**
     /**
      * format a given time string into a human readable label (localized)
      * format a given time string into a human readable label (localized)
      *
      *

+ 6 - 5
lib/I18n.php

@@ -135,15 +135,17 @@ class I18n
      *
      *
      * @access public
      * @access public
      * @static
      * @static
-     * @return void
      */
      */
     public static function loadTranslations()
     public static function loadTranslations()
     {
     {
         $availableLanguages = self::getAvailableLanguages();
         $availableLanguages = self::getAvailableLanguages();
 
 
         // check if the lang cookie was set and that language exists
         // check if the lang cookie was set and that language exists
-        if (array_key_exists('lang', $_COOKIE) && in_array($_COOKIE['lang'], $availableLanguages)) {
-            $match = $_COOKIE['lang'];
+        if (
+            array_key_exists('lang', $_COOKIE) &&
+            ($key = array_search($_COOKIE['lang'], $availableLanguages)) !== false
+        ) {
+            $match = $availableLanguages[$key];
         }
         }
         // find a translation file matching the browsers language preferences
         // find a translation file matching the browsers language preferences
         else {
         else {
@@ -256,7 +258,6 @@ class I18n
      * @access public
      * @access public
      * @static
      * @static
      * @param  string $lang
      * @param  string $lang
-     * @return void
      */
      */
     public static function setLanguageFallback($lang)
     public static function setLanguageFallback($lang)
     {
     {
@@ -304,7 +305,7 @@ class I18n
                 return $n % 10 == 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
                 return $n % 10 == 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
             case 'sl':
             case 'sl':
                 return $n % 100 == 1 ? 1 : ($n % 100 == 2 ? 2 : ($n % 100 == 3 || $n % 100 == 4 ? 3 : 0));
                 return $n % 100 == 1 ? 1 : ($n % 100 == 2 ? 2 : ($n % 100 == 3 || $n % 100 == 4 ? 3 : 0));
-            // de, en, es, it, no
+            // de, en, es, it, no, pt
             default:
             default:
                 return $n != 1 ? 1 : 0;
                 return $n != 1 ? 1 : 0;
         }
         }

+ 0 - 3
lib/Model.php

@@ -40,7 +40,6 @@ class Model
      * Factory constructor.
      * Factory constructor.
      *
      *
      * @param configuration $conf
      * @param configuration $conf
-     * @return void
      */
      */
     public function __construct(Configuration $conf)
     public function __construct(Configuration $conf)
     {
     {
@@ -64,8 +63,6 @@ class Model
 
 
     /**
     /**
      * Checks if a purge is necessary and triggers it if yes.
      * Checks if a purge is necessary and triggers it if yes.
-     *
-     * @return void
      */
      */
     public function purge()
     public function purge()
     {
     {

+ 0 - 5
lib/Model/AbstractModel.php

@@ -63,7 +63,6 @@ abstract class AbstractModel
      * @access public
      * @access public
      * @param  Configuration $configuration
      * @param  Configuration $configuration
      * @param  AbstractData $storage
      * @param  AbstractData $storage
-     * @return void
      */
      */
     public function __construct(Configuration $configuration, AbstractData $storage)
     public function __construct(Configuration $configuration, AbstractData $storage)
     {
     {
@@ -90,7 +89,6 @@ abstract class AbstractModel
      * @access public
      * @access public
      * @param string $id
      * @param string $id
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setId($id)
     public function setId($id)
     {
     {
@@ -106,7 +104,6 @@ abstract class AbstractModel
      * @access public
      * @access public
      * @param string $data
      * @param string $data
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setData($data)
     public function setData($data)
     {
     {
@@ -133,7 +130,6 @@ abstract class AbstractModel
      *
      *
      * @access public
      * @access public
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     abstract public function store();
     abstract public function store();
 
 
@@ -142,7 +138,6 @@ abstract class AbstractModel
      *
      *
      * @access public
      * @access public
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     abstract public function delete();
     abstract public function delete();
 
 

+ 0 - 5
lib/Model/Comment.php

@@ -61,7 +61,6 @@ class Comment extends AbstractModel
      *
      *
      * @access public
      * @access public
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function store()
     public function store()
     {
     {
@@ -101,7 +100,6 @@ class Comment extends AbstractModel
      *
      *
      * @access public
      * @access public
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function delete()
     public function delete()
     {
     {
@@ -129,7 +127,6 @@ class Comment extends AbstractModel
      * @access public
      * @access public
      * @param Paste $paste
      * @param Paste $paste
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setPaste(Paste $paste)
     public function setPaste(Paste $paste)
     {
     {
@@ -154,7 +151,6 @@ class Comment extends AbstractModel
      * @access public
      * @access public
      * @param string $id
      * @param string $id
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setParentId($id)
     public function setParentId($id)
     {
     {
@@ -184,7 +180,6 @@ class Comment extends AbstractModel
      * @access public
      * @access public
      * @param string $nickname
      * @param string $nickname
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setNickname($nickname)
     public function setNickname($nickname)
     {
     {

+ 0 - 8
lib/Model/Paste.php

@@ -75,7 +75,6 @@ class Paste extends AbstractModel
      *
      *
      * @access public
      * @access public
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function store()
     public function store()
     {
     {
@@ -103,7 +102,6 @@ class Paste extends AbstractModel
      *
      *
      * @access public
      * @access public
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function delete()
     public function delete()
     {
     {
@@ -183,7 +181,6 @@ class Paste extends AbstractModel
      * @access public
      * @access public
      * @param string $attachment
      * @param string $attachment
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setAttachment($attachment)
     public function setAttachment($attachment)
     {
     {
@@ -199,7 +196,6 @@ class Paste extends AbstractModel
      * @access public
      * @access public
      * @param string $attachmentname
      * @param string $attachmentname
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setAttachmentName($attachmentname)
     public function setAttachmentName($attachmentname)
     {
     {
@@ -214,7 +210,6 @@ class Paste extends AbstractModel
      *
      *
      * @access public
      * @access public
      * @param string $expiration
      * @param string $expiration
-     * @return void
      */
      */
     public function setExpiration($expiration)
     public function setExpiration($expiration)
     {
     {
@@ -236,7 +231,6 @@ class Paste extends AbstractModel
      * @access public
      * @access public
      * @param string $burnafterreading
      * @param string $burnafterreading
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setBurnafterreading($burnafterreading = '1')
     public function setBurnafterreading($burnafterreading = '1')
     {
     {
@@ -257,7 +251,6 @@ class Paste extends AbstractModel
      * @access public
      * @access public
      * @param string $opendiscussion
      * @param string $opendiscussion
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setOpendiscussion($opendiscussion = '1')
     public function setOpendiscussion($opendiscussion = '1')
     {
     {
@@ -281,7 +274,6 @@ class Paste extends AbstractModel
      * @access public
      * @access public
      * @param string $format
      * @param string $format
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function setFormatter($format)
     public function setFormatter($format)
     {
     {

+ 3 - 8
lib/Persistence/AbstractPersistence.php

@@ -36,7 +36,6 @@ abstract class AbstractPersistence
      * @access public
      * @access public
      * @static
      * @static
      * @param  string $path
      * @param  string $path
-     * @return void
      */
      */
     public static function setPath($path)
     public static function setPath($path)
     {
     {
@@ -80,27 +79,23 @@ abstract class AbstractPersistence
      * @access protected
      * @access protected
      * @static
      * @static
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     protected static function _initialize()
     protected static function _initialize()
     {
     {
         // Create storage directory if it does not exist.
         // Create storage directory if it does not exist.
         if (!is_dir(self::$_path)) {
         if (!is_dir(self::$_path)) {
-            if (!@mkdir(self::$_path)) {
+            if (!@mkdir(self::$_path, 0700)) {
                 throw new Exception('unable to create directory ' . self::$_path, 10);
                 throw new Exception('unable to create directory ' . self::$_path, 10);
             }
             }
         }
         }
-
-        // Create .htaccess file if it does not exist.
         $file = self::$_path . DIRECTORY_SEPARATOR . '.htaccess';
         $file = self::$_path . DIRECTORY_SEPARATOR . '.htaccess';
         if (!is_file($file)) {
         if (!is_file($file)) {
             $writtenBytes = @file_put_contents(
             $writtenBytes = @file_put_contents(
                 $file,
                 $file,
-                'Allow from none' . PHP_EOL .
-                'Deny from all' . PHP_EOL,
+                'Require all denied' . PHP_EOL,
                 LOCK_EX
                 LOCK_EX
             );
             );
-            if ($writtenBytes === false || $writtenBytes < 30) {
+            if ($writtenBytes === false || $writtenBytes < 19) {
                 throw new Exception('unable to write to file ' . $file, 11);
                 throw new Exception('unable to write to file ' . $file, 11);
             }
             }
         }
         }

+ 47 - 0
lib/Persistence/DataStore.php

@@ -0,0 +1,47 @@
+<?php
+/**
+ * PrivateBin
+ *
+ * a zero-knowledge paste bin
+ *
+ * @link      https://github.com/PrivateBin/PrivateBin
+ * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
+ * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
+ * @version   1.1
+ */
+
+namespace PrivateBin\Persistence;
+
+use Exception;
+use PrivateBin\Json;
+
+/**
+ * DataStore
+ *
+ * Handles data storage for Data\Filesystem.
+ */
+class DataStore extends AbstractPersistence
+{
+    /**
+     * store the data
+     *
+     * @access public
+     * @static
+     * @param  string $filename
+     * @param  array  $data
+     * @return bool
+     */
+    public static function store($filename, $data)
+    {
+        $path = self::getPath();
+        if (strpos($filename, $path) === 0) {
+            $filename = substr($filename, strlen($path));
+        }
+        try {
+            self::_store($filename, Json::encode($data));
+            return true;
+        } catch (Exception $e) {
+            return false;
+        }
+    }
+}

+ 0 - 2
lib/Persistence/PurgeLimiter.php

@@ -36,7 +36,6 @@ class PurgeLimiter extends AbstractPersistence
      * @access public
      * @access public
      * @static
      * @static
      * @param  int $limit
      * @param  int $limit
-     * @return void
      */
      */
     public static function setLimit($limit)
     public static function setLimit($limit)
     {
     {
@@ -49,7 +48,6 @@ class PurgeLimiter extends AbstractPersistence
      * @access public
      * @access public
      * @static
      * @static
      * @param Configuration $conf
      * @param Configuration $conf
-     * @return void
      */
      */
     public static function setConfiguration(Configuration $conf)
     public static function setConfiguration(Configuration $conf)
     {
     {

+ 0 - 1
lib/Persistence/ServerSalt.php

@@ -95,7 +95,6 @@ class ServerSalt extends AbstractPersistence
      * @access public
      * @access public
      * @static
      * @static
      * @param  string $path
      * @param  string $path
-     * @return void
      */
      */
     public static function setPath($path)
     public static function setPath($path)
     {
     {

+ 0 - 2
lib/Persistence/TrafficLimiter.php

@@ -45,7 +45,6 @@ class TrafficLimiter extends AbstractPersistence
      * @access public
      * @access public
      * @static
      * @static
      * @param  int $limit
      * @param  int $limit
-     * @return void
      */
      */
     public static function setLimit($limit)
     public static function setLimit($limit)
     {
     {
@@ -58,7 +57,6 @@ class TrafficLimiter extends AbstractPersistence
      * @access public
      * @access public
      * @static
      * @static
      * @param Configuration $conf
      * @param Configuration $conf
-     * @return void
      */
      */
     public static function setConfiguration(Configuration $conf)
     public static function setConfiguration(Configuration $conf)
     {
     {

+ 25 - 32
lib/PrivateBin.php

@@ -30,6 +30,13 @@ class PrivateBin
      */
      */
     const VERSION = '1.1';
     const VERSION = '1.1';
 
 
+    /**
+     * minimal required PHP version
+     *
+     * @const string
+     */
+    const MIN_PHP_VERSION = '5.4.0';
+
     /**
     /**
      * show the same error message if the paste expired or does not exist
      * show the same error message if the paste expired or does not exist
      *
      *
@@ -116,12 +123,11 @@ class PrivateBin
      *
      *
      * @access public
      * @access public
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function __construct()
     public function __construct()
     {
     {
-        if (version_compare(PHP_VERSION, '5.3.0') < 0) {
-            throw new Exception(I18n::_('%s requires php 5.3.0 or above to work. Sorry.', I18n::_('PrivateBin')), 1);
+        if (version_compare(PHP_VERSION, self::MIN_PHP_VERSION) < 0) {
+            throw new Exception(I18n::_('%s requires php %s or above to work. Sorry.', I18n::_('PrivateBin'), self::MIN_PHP_VERSION), 1);
         }
         }
         if (strlen(PATH) < 0 && substr(PATH, -1) !== DIRECTORY_SEPARATOR) {
         if (strlen(PATH) < 0 && substr(PATH, -1) !== DIRECTORY_SEPARATOR) {
             throw new Exception(I18n::_('%s requires the PATH to end in a "%s". Please update the PATH in your index.php.', I18n::_('PrivateBin'), DIRECTORY_SEPARATOR), 5);
             throw new Exception(I18n::_('%s requires the PATH to end in a "%s". Please update the PATH in your index.php.', I18n::_('PrivateBin'), DIRECTORY_SEPARATOR), 5);
@@ -164,21 +170,9 @@ class PrivateBin
      * initialize privatebin
      * initialize privatebin
      *
      *
      * @access private
      * @access private
-     * @return void
      */
      */
     private function _init()
     private function _init()
     {
     {
-        foreach (array('cfg', 'lib') as $dir) {
-            if (!is_file(PATH . $dir . DIRECTORY_SEPARATOR . '.htaccess')) {
-                file_put_contents(
-                PATH . $dir . DIRECTORY_SEPARATOR . '.htaccess',
-                'Allow from none' . PHP_EOL .
-                'Deny from all' . PHP_EOL,
-                LOCK_EX
-            );
-            }
-        }
-
         $this->_conf    = new Configuration;
         $this->_conf    = new Configuration;
         $this->_model   = new Model($this->_conf);
         $this->_model   = new Model($this->_conf);
         $this->_request = new Request;
         $this->_request = new Request;
@@ -324,7 +318,6 @@ class PrivateBin
      * @access private
      * @access private
      * @param  string $dataid
      * @param  string $dataid
      * @param  string $deletetoken
      * @param  string $deletetoken
-     * @return void
      */
      */
     private function _delete($dataid, $deletetoken)
     private function _delete($dataid, $deletetoken)
     {
     {
@@ -334,19 +327,16 @@ class PrivateBin
                 // accessing this property ensures that the paste would be
                 // accessing this property ensures that the paste would be
                 // deleted if it has already expired
                 // deleted if it has already expired
                 $burnafterreading = $paste->isBurnafterreading();
                 $burnafterreading = $paste->isBurnafterreading();
-                if ($deletetoken == 'burnafterreading') {
-                    if ($burnafterreading) {
-                        $paste->delete();
-                        $this->_return_message(0, $dataid);
-                    } else {
-                        $this->_return_message(1, 'Paste is not of burn-after-reading type.');
-                    }
+                if (
+                    ($burnafterreading && $deletetoken == 'burnafterreading') ||
+                    Filter::slowEquals($deletetoken, $paste->getDeleteToken())
+                ) {
+                    // Paste exists and deletion token is valid: Delete the paste.
+                    $paste->delete();
+                    $this->_status = 'Paste was properly deleted.';
                 } else {
                 } else {
-                    // Make sure the token is valid.
-                    if (Filter::slowEquals($deletetoken, $paste->getDeleteToken())) {
-                        // Paste exists and deletion token is valid: Delete the paste.
-                        $paste->delete();
-                        $this->_status = 'Paste was properly deleted.';
+                    if (!$burnafterreading && $deletetoken == 'burnafterreading') {
+                        $this->_error = 'Paste is not of burn-after-reading type.';
                     } else {
                     } else {
                         $this->_error = 'Wrong deletion token. Paste was not deleted.';
                         $this->_error = 'Wrong deletion token. Paste was not deleted.';
                     }
                     }
@@ -357,6 +347,13 @@ class PrivateBin
         } catch (Exception $e) {
         } catch (Exception $e) {
             $this->_error = $e->getMessage();
             $this->_error = $e->getMessage();
         }
         }
+        if ($this->_request->isJsonApiCall()) {
+            if (strlen($this->_error)) {
+                $this->_return_message(1, $this->_error);
+            } else {
+                $this->_return_message(0, $dataid);
+            }
+        }
     }
     }
 
 
     /**
     /**
@@ -364,7 +361,6 @@ class PrivateBin
      *
      *
      * @access private
      * @access private
      * @param  string $dataid
      * @param  string $dataid
-     * @return void
      */
      */
     private function _read($dataid)
     private function _read($dataid)
     {
     {
@@ -397,7 +393,6 @@ class PrivateBin
      * Display PrivateBin frontend.
      * Display PrivateBin frontend.
      *
      *
      * @access private
      * @access private
-     * @return void
      */
      */
     private function _view()
     private function _view()
     {
     {
@@ -461,7 +456,6 @@ class PrivateBin
      *
      *
      * @access private
      * @access private
      * @param string $type
      * @param string $type
-     * @return void
      */
      */
     private function _jsonld($type)
     private function _jsonld($type)
     {
     {
@@ -494,7 +488,6 @@ class PrivateBin
      * @param  int $status
      * @param  int $status
      * @param  string $message
      * @param  string $message
      * @param  array $other
      * @param  array $other
-     * @return void
      */
      */
     private function _return_message($status, $message, $other = array())
     private function _return_message($status, $message, $other = array())
     {
     {

+ 10 - 18
lib/Request.php

@@ -41,7 +41,7 @@ class Request
     const MIME_XHTML = 'application/xhtml+xml';
     const MIME_XHTML = 'application/xhtml+xml';
 
 
     /**
     /**
-     * Input stream to use for PUT parameter parsing.
+     * Input stream to use for PUT parameter parsing
      *
      *
      * @access private
      * @access private
      * @var string
      * @var string
@@ -49,7 +49,7 @@ class Request
     private static $_inputStream = 'php://input';
     private static $_inputStream = 'php://input';
 
 
     /**
     /**
-     * Operation to perform.
+     * Operation to perform
      *
      *
      * @access private
      * @access private
      * @var string
      * @var string
@@ -57,7 +57,7 @@ class Request
     private $_operation = 'view';
     private $_operation = 'view';
 
 
     /**
     /**
-     * Request parameters.
+     * Request parameters
      *
      *
      * @access private
      * @access private
      * @var array
      * @var array
@@ -65,7 +65,7 @@ class Request
     private $_params = array();
     private $_params = array();
 
 
     /**
     /**
-     * If we are in a JSON API context.
+     * If we are in a JSON API context
      *
      *
      * @access private
      * @access private
      * @var bool
      * @var bool
@@ -73,20 +73,12 @@ class Request
     private $_isJsonApi = false;
     private $_isJsonApi = false;
 
 
     /**
     /**
-     * Constructor.
+     * Constructor
      *
      *
      * @access public
      * @access public
-     * @return void
      */
      */
     public function __construct()
     public function __construct()
     {
     {
-        // in case stupid admin has left magic_quotes enabled in php.ini (for PHP < 5.4)
-        if (version_compare(PHP_VERSION, '5.4.0') < 0 && get_magic_quotes_gpc()) {
-            $_POST   = array_map('PrivateBin\\Filter::stripslashesDeep', $_POST);
-            $_GET    = array_map('PrivateBin\\Filter::stripslashesDeep', $_GET);
-            $_COOKIE = array_map('PrivateBin\\Filter::stripslashesDeep', $_COOKIE);
-        }
-
         // decide if we are in JSON API or HTML context
         // decide if we are in JSON API or HTML context
         $this->_isJsonApi = $this->_detectJsonRequest();
         $this->_isJsonApi = $this->_detectJsonRequest();
 
 
@@ -129,7 +121,7 @@ class Request
     }
     }
 
 
     /**
     /**
-     * Get current operation.
+     * Get current operation
      *
      *
      * @access public
      * @access public
      * @return string
      * @return string
@@ -140,7 +132,7 @@ class Request
     }
     }
 
 
     /**
     /**
-     * Get a request parameter.
+     * Get a request parameter
      *
      *
      * @access public
      * @access public
      * @param  string $param
      * @param  string $param
@@ -153,7 +145,7 @@ class Request
     }
     }
 
 
     /**
     /**
-     * If we are in a JSON API context.
+     * If we are in a JSON API context
      *
      *
      * @access public
      * @access public
      * @return bool
      * @return bool
@@ -164,7 +156,7 @@ class Request
     }
     }
 
 
     /**
     /**
-     * Override the default input stream source, used for unit testing.
+     * Override the default input stream source, used for unit testing
      *
      *
      * @param string $input
      * @param string $input
      */
      */
@@ -174,7 +166,7 @@ class Request
     }
     }
 
 
     /**
     /**
-     * detect the clients supported media type and decide if its a JSON API call or not
+     * Detect the clients supported media type and decide if its a JSON API call or not
      *
      *
      * Adapted from: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
      * Adapted from: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
      *
      *

+ 0 - 2
lib/View.php

@@ -35,7 +35,6 @@ class View
      * @access public
      * @access public
      * @param  string $name
      * @param  string $name
      * @param  mixed  $value
      * @param  mixed  $value
-     * @return void
      */
      */
     public function assign($name, $value)
     public function assign($name, $value)
     {
     {
@@ -48,7 +47,6 @@ class View
      * @access public
      * @access public
      * @param  string $template
      * @param  string $template
      * @throws Exception
      * @throws Exception
-     * @return void
      */
      */
     public function draw($template)
     public function draw($template)
     {
     {

+ 0 - 2
lib/Vizhash16x16.php

@@ -61,7 +61,6 @@ class Vizhash16x16
      * constructor
      * constructor
      *
      *
      * @access public
      * @access public
-     * @return void
      */
      */
     public function __construct()
     public function __construct()
     {
     {
@@ -210,7 +209,6 @@ class Vizhash16x16
      * @param  resource $image
      * @param  resource $image
      * @param  int $action
      * @param  int $action
      * @param  int $color
      * @param  int $color
-     * @return void
      */
      */
     private function drawshape($image, $action, $color)
     private function drawshape($image, $action, $color)
     {
     {

+ 1 - 0
tpl/.htaccess

@@ -0,0 +1 @@
+Require all denied

+ 63 - 48
tpl/bootstrap.php

@@ -4,7 +4,7 @@ $isCpct = substr($template, 9, 8) === '-compact';
 $isDark = substr($template, 9, 5) === '-dark';
 $isDark = substr($template, 9, 5) === '-dark';
 $isPage = substr($template, -5) === '-page';
 $isPage = substr($template, -5) === '-page';
 ?><!DOCTYPE html>
 ?><!DOCTYPE html>
-<html lang="en">
+<html>
 	<head>
 	<head>
 		<meta charset="utf-8" />
 		<meta charset="utf-8" />
 		<meta http-equiv="X-UA-Compatible" content="IE=edge">
 		<meta http-equiv="X-UA-Compatible" content="IE=edge">
@@ -69,7 +69,7 @@ if ($MARKDOWN):
 <?php
 <?php
 endif;
 endif;
 ?>
 ?>
-		<script type="text/javascript" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-/5orRNmbpvxqudg675a1LiF3wQ5DlMGoT6jI/iXDZN2x2DrLHnB3tSE0wGY6qpeY+eX9vv6mZ/6AuPm0gnU2/A==" crossorigin="anonymous"></script>
+		<script type="text/javascript" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-+/yhR0jcxCRLS10m+w8ii0BDeifeMrT7moI3gNalod6rHdgzMfc962q+2q3880fQ5XhcjF+/hx+8bKxRUMOaCg==" crossorigin="anonymous"></script>
 		<!--[if lt IE 10]>
 		<!--[if lt IE 10]>
 		<style type="text/css">body {padding-left:60px;padding-right:60px;} #ienotice {display:block;} #oldienotice {display:block;}</style>
 		<style type="text/css">body {padding-left:60px;padding-right:60px;} #ienotice {display:block;} #oldienotice {display:block;}</style>
 		<![endif]-->
 		<![endif]-->
@@ -94,7 +94,7 @@ endif;
 						<form id="passwordform" role="form">
 						<form id="passwordform" role="form">
 							<div class="form-group">
 							<div class="form-group">
 								<label for="passworddecrypt"><span class="glyphicon glyphicon-eye-open"></span> <?php echo I18n::_('Please enter the password for this paste:') ?></label>
 								<label for="passworddecrypt"><span class="glyphicon glyphicon-eye-open"></span> <?php echo I18n::_('Please enter the password for this paste:') ?></label>
-								<input id="passworddecrypt" type="password" class="form-control" placeholder="<?php echo I18n::_('Enter password') ?>" autofocus>
+								<input id="passworddecrypt" type="password" class="form-control" placeholder="<?php echo I18n::_('Enter password') ?>">
 							</div>
 							</div>
 							<button type="submit" class="btn btn-success btn-block"><span class="glyphicon glyphicon-off"></span> <?php echo I18n::_('Decrypt') ?></button>
 							<button type="submit" class="btn btn-success btn-block"><span class="glyphicon glyphicon-off"></span> <?php echo I18n::_('Decrypt') ?></button>
 						</form>
 						</form>
@@ -121,8 +121,8 @@ endif;
 			<div id="navbar" class="navbar-collapse collapse">
 			<div id="navbar" class="navbar-collapse collapse">
 				<ul class="nav navbar-nav">
 				<ul class="nav navbar-nav">
 					<li id="loadingindicator" class="navbar-text hidden">
 					<li id="loadingindicator" class="navbar-text hidden">
-						<span class="glyphicon glyphicon-upload" aria-hidden="true"></span>
-						<?php echo I18n::_('Uploading paste… Please wait.'), PHP_EOL; ?>
+						<span class="glyphicon glyphicon-time" aria-hidden="true"></span>
+						<?php echo I18n::_('Loading…'), PHP_EOL; ?>
 					</li>
 					</li>
 					<li>
 					<li>
 <?php
 <?php
@@ -132,7 +132,7 @@ if ($isPage):
 							<span class="glyphicon glyphicon-upload" aria-hidden="true"></span> <?php echo I18n::_('Send'), PHP_EOL;
 							<span class="glyphicon glyphicon-upload" aria-hidden="true"></span> <?php echo I18n::_('Send'), PHP_EOL;
 else:
 else:
 ?>
 ?>
-						<button id="newbutton" type="button" class="reloadlink hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
+						<button id="newbutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
 							<span class="glyphicon glyphicon-file" aria-hidden="true"></span> <?php echo I18n::_('New'), PHP_EOL;
 							<span class="glyphicon glyphicon-file" aria-hidden="true"></span> <?php echo I18n::_('New'), PHP_EOL;
 endif;
 endif;
 ?>
 ?>
@@ -198,7 +198,7 @@ if ($isCpct):
 <?php
 <?php
     if ($DISCUSSION):
     if ($DISCUSSION):
 ?>
 ?>
-							<li id="opendisc" class="checkbox hidden">
+							<li id="opendiscussionoption" class="checkbox hidden">
 								<label>
 								<label>
 									<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
 									<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
         if ($OPENDISCUSSION):
         if ($OPENDISCUSSION):
@@ -230,17 +230,6 @@ if ($isCpct):
 ?>
 ?>
 						</ul>
 						</ul>
 						<select id="pasteFormatter" name="pasteFormatter" class="hidden">
 						<select id="pasteFormatter" name="pasteFormatter" class="hidden">
-<?php
-    foreach ($FORMATTER as $key => $value):
-?>
-							<option value="<?php echo $key; ?>"<?php
-        if ($key == $FORMATTERDEFAULT):
-?> selected="selected"<?php
-        endif;
-?>><?php echo $value; ?></option>
-<?php
-    endforeach;
-?>
 						</select>
 						</select>
 					</li>
 					</li>
 <?php
 <?php
@@ -262,7 +251,7 @@ else:
     if ($DISCUSSION):
     if ($DISCUSSION):
 ?>
 ?>
 					<li>
 					<li>
-						<div id="opendisc" class="navbar-text checkbox hidden">
+						<div id="opendiscussionoption" class="navbar-text checkbox hidden">
 							<label>
 							<label>
 								<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
 								<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
         if ($OPENDISCUSSION):
         if ($OPENDISCUSSION):
@@ -296,6 +285,7 @@ if ($FILEUPLOAD):
 								</div>
 								</div>
 								<div id="dragAndDropFileName" class="dragAndDropFile"><?php echo I18n::_('or drag & drop file'); ?></div>
 								<div id="dragAndDropFileName" class="dragAndDropFile"><?php echo I18n::_('or drag & drop file'); ?></div>
 							</li>
 							</li>
+							<li id="customattachment" class="hidden"></li>
 							<li>
 							<li>
 								<a id="fileremovebutton"  href="#">
 								<a id="fileremovebutton"  href="#">
 									<?php echo I18n::_('Remove attachment'), PHP_EOL; ?>
 									<?php echo I18n::_('Remove attachment'), PHP_EOL; ?>
@@ -384,49 +374,57 @@ if ($isCpct):
 ?></div><?php
 ?></div><?php
 endif;
 endif;
 ?></nav>
 ?></nav>
-		<header class="container">
+		<main>
+			<section class="container">
 <?php
 <?php
 if (strlen($NOTICE)):
 if (strlen($NOTICE)):
 ?>
 ?>
 			<div role="alert" class="alert alert-info">
 			<div role="alert" class="alert alert-info">
-				<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span> <?php echo htmlspecialchars($NOTICE), PHP_EOL; ?>
+					<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
+					<?php echo htmlspecialchars($NOTICE), PHP_EOL; ?>
 			</div>
 			</div>
 <?php
 <?php
 endif;
 endif;
 ?>
 ?>
 			<div id="remainingtime" role="alert" class="hidden alert alert-info">
 			<div id="remainingtime" role="alert" class="hidden alert alert-info">
-				<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
+					<span class="glyphicon glyphicon-fire" aria-hidden="true"></span>
 			</div>
 			</div>
 <?php
 <?php
 if ($FILEUPLOAD):
 if ($FILEUPLOAD):
 ?>
 ?>
 			<div id="attachment" role="alert" class="hidden alert alert-info">
 			<div id="attachment" role="alert" class="hidden alert alert-info">
-				<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span> <a><?php echo I18n::_('Download attachment'); ?></a> <span id="clonedfile" class="hidden"><?php echo I18n::_('Cloned file attached.'); ?></span>
+					<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>
+					<a class="alert-link"><?php echo I18n::_('Download attachment'), PHP_EOL; ?></a>
 			</div>
 			</div>
 <?php
 <?php
 endif;
 endif;
-if (strlen($STATUS)):
 ?>
 ?>
-			<div id="status" role="alert" class="alert alert-success">
-				<span class="glyphicon glyphicon-ok" aria-hidden="true"></span> <?php echo htmlspecialchars($STATUS), PHP_EOL; ?>
+				<div id="status" role="alert" class="statusmessage alert alert-info<?php echo empty($STATUS) ? ' hidden' : '' ?>">
+					<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
+					<?php echo htmlspecialchars($STATUS), PHP_EOL; ?>
 			</div>
 			</div>
-<?php
-endif;
-?>
-			<div id="errormessage" role="alert" class="<?php
-if (!strlen($ERROR)):
-?>hidden <?php
-endif;
-?>alert alert-danger"><span class="glyphicon glyphicon-alert" aria-hidden="true"></span> <?php echo htmlspecialchars($ERROR); ?></div>
-			<noscript><div id="noscript" role="alert" class="nonworking alert alert-<?php echo $isDark ? 'error' : 'warning'; ?>"><span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> <?php echo I18n::_('JavaScript is required for %s to work.<br />Sorry for the inconvenience.', I18n::_($NAME)); ?></div></noscript>
-			<div id="oldienotice" role="alert" class="hidden nonworking alert alert-danger"><span class="glyphicon glyphicon-alert" aria-hidden="true"></span> <?php echo I18n::_('%s requires a modern browser to work.', I18n::_($NAME)); ?></div>
-			<div id="ienotice" role="alert" class="hidden alert alert-<?php echo $isDark ? 'error' : 'warning'; ?>"><span class="glyphicon glyphicon-question-sign" aria-hidden="true"></span> <?php echo I18n::_('Still using Internet Explorer? Do yourself a favor, switch to a modern browser:'), PHP_EOL; ?>
+				<div id="errormessage" role="alert" class="statusmessage<?php echo empty($ERROR) ? ' hidden' : '' ?> alert alert-danger">
+					<span class="glyphicon glyphicon-alert" aria-hidden="true"></span>
+					<?php echo htmlspecialchars($ERROR), PHP_EOL; ?>
+				</div>
+				<noscript>
+					<div id="noscript" role="alert" class="nonworking alert alert-<?php echo $isDark ? 'error' : 'warning'; ?>">
+						<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+						<?php echo I18n::_('JavaScript is required for %s to work.<br />Sorry for the inconvenience.', I18n::_($NAME)), PHP_EOL; ?>
+					</div>
+				</noscript>
+				<div id="oldienotice" role="alert" class="hidden nonworking alert alert-danger">
+					<span class="glyphicon glyphicon-alert" aria-hidden="true"></span>
+					<?php echo I18n::_('%s requires a modern browser to work.', I18n::_($NAME)), PHP_EOL; ?>
+				</div>
+				<div id="ienotice" role="alert" class="hidden alert alert-<?php echo $isDark ? 'error' : 'warning'; ?>">
+					<span class="glyphicon glyphicon-question-sign" aria-hidden="true"></span>
+					<?php echo I18n::_('Still using Internet Explorer? Do yourself a favor, switch to a modern browser:'), PHP_EOL; ?>
 				<a href="https://www.mozilla.org/firefox/">Firefox</a>,
 				<a href="https://www.mozilla.org/firefox/">Firefox</a>,
 				<a href="https://www.opera.com/">Opera</a>,
 				<a href="https://www.opera.com/">Opera</a>,
-				<a href="https://www.google.com/chrome">Chrome</a>,
-				<a href="https://www.apple.com/safari">Safari</a>...
+					<a href="https://www.google.com/chrome">Chrome</a>…
 			</div>
 			</div>
-			<div id="pasteresult" role="alert" class="hidden alert alert-success">
+				<div id="pasteSuccess" role="alert" class="hidden alert alert-success">
 				<span class="glyphicon glyphicon-ok" aria-hidden="true"></span>
 				<span class="glyphicon glyphicon-ok" aria-hidden="true"></span>
 				<div id="deletelink"></div>
 				<div id="deletelink"></div>
 				<div id="pastelink">
 				<div id="pastelink">
@@ -441,30 +439,32 @@ endif;
 ?>
 ?>
 				</div>
 				</div>
 			</div>
 			</div>
-			<ul id="preview" class="nav nav-tabs hidden">
+				<ul id="editorTabs" class="nav nav-tabs hidden">
 				<li role="presentation" class="active"><a id="messageedit" href="#"><?php echo I18n::_('Editor'); ?></a></li>
 				<li role="presentation" class="active"><a id="messageedit" href="#"><?php echo I18n::_('Editor'); ?></a></li>
 				<li role="presentation"><a id="messagepreview" href="#"><?php echo I18n::_('Preview'); ?></a></li>
 				<li role="presentation"><a id="messagepreview" href="#"><?php echo I18n::_('Preview'); ?></a></li>
 			</ul>
 			</ul>
-		</header>
+			</section>
 		<section class="container">
 		<section class="container">
 			<article class="row">
 			<article class="row">
-				<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>
+					<div id="placeholder" class="col-md-12 hidden"><?php echo I18n::_('+++ no paste text +++'); ?></div>
+					<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>
 				<div id="prettymessage" class="col-md-12 hidden">
 				<div id="prettymessage" class="col-md-12 hidden">
 					<pre id="prettyprint" class="col-md-12 prettyprint linenums:1"></pre>
 					<pre id="prettyprint" class="col-md-12 prettyprint linenums:1"></pre>
 				</div>
 				</div>
-				<div id="cleartext" class="col-md-12 hidden"></div>
+					<div id="plaintext" class="col-md-12 hidden"></div>
 				<p class="col-md-12"><textarea id="message" name="message" cols="80" rows="25" class="form-control hidden"></textarea></p>
 				<p class="col-md-12"><textarea id="message" name="message" cols="80" rows="25" class="form-control hidden"></textarea></p>
 			</article>
 			</article>
 		</section>
 		</section>
 		<section class="container">
 		<section class="container">
 			<div id="discussion" class="hidden">
 			<div id="discussion" class="hidden">
 				<h4><?php echo I18n::_('Discussion'); ?></h4>
 				<h4><?php echo I18n::_('Discussion'); ?></h4>
-				<div id="comments"></div>
+					<div id="commentcontainer"></div>
 			</div>
 			</div>
 		</section>
 		</section>
 		<section class="container">
 		<section class="container">
-			<div id="noscript" role="alert" class="nonworking alert alert-info noscript-hide"><span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true">
-				<span> <?php echo I18n::_('Loading…'); ?></span><br>
+				<div id="noscript" role="alert" class="nonworking alert alert-info noscript-hide">
+					<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+					<?php echo I18n::_('Loading…'); ?><br />
 				<span class="small"><?php echo I18n::_('In case this message never disappears please have a look at <a href="https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away">this FAQ for information to troubleshoot</a>.'); ?></span>
 				<span class="small"><?php echo I18n::_('In case this message never disappears please have a look at <a href="https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-not-the-loading-message-go-away">this FAQ for information to troubleshoot</a>.'); ?></span>
 			</div>
 			</div>
 		</section>
 		</section>
@@ -477,6 +477,21 @@ endif;
 				</p>
 				</p>
 			</div>
 			</div>
 		</footer>
 		</footer>
-		<div id="cipherdata" class="hidden"><?php echo htmlspecialchars($CIPHERDATA, ENT_NOQUOTES); ?></div>
+		</main>
+		<div id="serverdata" class="hidden" aria-hidden="true">
+			<div id="cipherdata"><?php echo htmlspecialchars($CIPHERDATA, ENT_NOQUOTES); ?></div>
+<?php
+if ($DISCUSSION):
+?>
+			<div id="templates">
+				<!-- @TODO: when I intend/structure this corrrectly Firefox adds whitespaces everywhere which completly destroy the layout. (same possible when you remove the template data below and show this area in the browser) -->
+				<article id="commenttemplate" class="comment"><div class="commentmeta"><span class="nickname">name</span><span class="commentdate">0000-00-00</span></div><div class="commentdata">c</div><button class="btn btn-default btn-sm"><?php echo I18n::_('Reply'); ?></button></article>
+				<p id="commenttailtemplate" class="comment"><button class="btn btn-default btn-sm"><?php echo I18n::_('Add comment'); ?></button></p>
+				<div id="replytemplate" class="reply hidden"><input type="text" id="nickname" class="form-control" title="<?php echo I18n::_('Optional nickname…'); ?>" placeholder="<?php echo I18n::_('Optional nickname…'); ?>" /><textarea id="replymessage" class="replymessage form-control" cols="80" rows="7"></textarea><br /><div id="replystatus" role="alert" class="statusmessage hidden alert"><span class="glyphicon" aria-hidden="true"></span> </div><button id="replybutton" class="btn btn-default btn-sm"><?php echo I18n::_('Post comment'); ?></button></div>
+			</div>
+<?php
+endif;
+?>
+		</div>
 	</body>
 	</body>
 </html>
 </html>

+ 20 - 6
tpl/page.php

@@ -47,7 +47,7 @@ if ($MARKDOWN):
 <?php
 <?php
 endif;
 endif;
 ?>
 ?>
-		<script type="text/javascript" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-/5orRNmbpvxqudg675a1LiF3wQ5DlMGoT6jI/iXDZN2x2DrLHnB3tSE0wGY6qpeY+eX9vv6mZ/6AuPm0gnU2/A==" crossorigin="anonymous"></script>
+		<script type="text/javascript" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-+/yhR0jcxCRLS10m+w8ii0BDeifeMrT7moI3gNalod6rHdgzMfc962q+2q3880fQ5XhcjF+/hx+8bKxRUMOaCg==" crossorigin="anonymous"></script>
 		<!--[if lt IE 10]>
 		<!--[if lt IE 10]>
 		<style type="text/css">body {padding-left:60px;padding-right:60px;} #ienotice {display:block;} #oldienotice {display:block;}</style>
 		<style type="text/css">body {padding-left:60px;padding-right:60px;} #ienotice {display:block;} #oldienotice {display:block;}</style>
 		<![endif]-->
 		<![endif]-->
@@ -79,12 +79,12 @@ endif;
 			<div id="ienotice"><?php echo I18n::_('Still using Internet Explorer? Do yourself a favor, switch to a modern browser:'), PHP_EOL; ?>
 			<div id="ienotice"><?php echo I18n::_('Still using Internet Explorer? Do yourself a favor, switch to a modern browser:'), PHP_EOL; ?>
 				<a href="https://www.mozilla.org/firefox/">Firefox</a>,
 				<a href="https://www.mozilla.org/firefox/">Firefox</a>,
 				<a href="https://www.opera.com/">Opera</a>,
 				<a href="https://www.opera.com/">Opera</a>,
-				<a href="https://www.google.com/chrome">Chrome</a>,
-				<a href="https://www.apple.com/safari">Safari</a>...
+				<a href="https://www.google.com/chrome">Chrome</a>…
 			</div>
 			</div>
 		</header>
 		</header>
 		<section>
 		<section>
 			<article>
 			<article>
+				<div id="loadingindicator" class="hidden"><?php echo I18n::_('Loading…'); ?></div>
 				<div id="status"><?php echo htmlspecialchars($STATUS); ?></div>
 				<div id="status"><?php echo htmlspecialchars($STATUS); ?></div>
 				<div id="errormessage" class="hidden"><?php echo htmlspecialchars($ERROR); ?></div>
 				<div id="errormessage" class="hidden"><?php echo htmlspecialchars($ERROR); ?></div>
 				<div id="toolbar">
 				<div id="toolbar">
@@ -125,7 +125,7 @@ endif;
 <?php
 <?php
 if ($DISCUSSION):
 if ($DISCUSSION):
 ?>
 ?>
-					<div id="opendisc" class="button hidden">
+					<div id="opendiscussionoption" class="button hidden">
 						<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
 						<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
     if ($OPENDISCUSSION):
     if ($OPENDISCUSSION):
 ?> checked="checked"<?php
 ?> checked="checked"<?php
@@ -217,17 +217,31 @@ endif;
 				<div id="prettymessage" class="hidden">
 				<div id="prettymessage" class="hidden">
 					<pre id="prettyprint" class="prettyprint linenums:1"></pre>
 					<pre id="prettyprint" class="prettyprint linenums:1"></pre>
 				</div>
 				</div>
-				<div id="cleartext" class="hidden"></div>
+				<div id="plaintext" class="hidden"></div>
 				<textarea id="message" name="message" cols="80" rows="25" class="hidden"></textarea>
 				<textarea id="message" name="message" cols="80" rows="25" class="hidden"></textarea>
 			</article>
 			</article>
 		</section>
 		</section>
 		<section>
 		<section>
 			<div id="discussion" class="hidden">
 			<div id="discussion" class="hidden">
 				<h4 class="title"><?php echo I18n::_('Discussion'); ?></h4>
 				<h4 class="title"><?php echo I18n::_('Discussion'); ?></h4>
-				<div id="comments"></div>
+				<div id="commentcontainer"></div>
 			</div>
 			</div>
 		</section>
 		</section>
+		<div id="serverdata" class="hidden" aria-hidden="true">
 		<div id="cipherdata" class="hidden"><?php echo htmlspecialchars($CIPHERDATA, ENT_NOQUOTES); ?></div>
 		<div id="cipherdata" class="hidden"><?php echo htmlspecialchars($CIPHERDATA, ENT_NOQUOTES); ?></div>
+<?php
+if ($DISCUSSION):
+?>
+			<div id="templates">
+				<!-- @TODO: when I intend/structure this corrrectly Firefox adds whitespaces everywhere which completly destroy the layout. (same possible when you remove the template data below and show this area in the browser) -->
+				<article id="commenttemplate" class="comment"><div class="commentmeta"><span class="nickname">name</span><span class="commentdate">0000-00-00</span></div><div class="commentdata">c</div><button class="btn btn-default btn-sm"><?php echo I18n::_('Reply'); ?></button></article>
+				<div id="commenttailtemplate" class="comment"><button class="btn btn-default btn-sm"><?php echo I18n::_('Add comment'); ?></button></div>
+				<div id="replytemplate" class="reply hidden"><input type="text" id="nickname" class="form-control" title="<?php echo I18n::_('Optional nickname…'); ?>" placeholder="<?php echo I18n::_('Optional nickname…'); ?>" /><textarea id="replymessage" class="replymessage form-control" cols="80" rows="7"></textarea><br /><div id="replystatus" role="alert" class="statusmessage hidden alert"><span class="glyphicon" aria-hidden="true"></span> </div><button id="replybutton" class="btn btn-default btn-sm"><?php echo I18n::_('Post comment'); ?></button></div>
+			</div>
+<?php
+endif;
+?>
+		</div>
         <section class="container">
         <section class="container">
 			<div id="noscript" role="alert" class="nonworking alert alert-info noscript-hide"><span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true">
 			<div id="noscript" role="alert" class="nonworking alert alert-info noscript-hide"><span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true">
 				<span> <?php echo I18n::_('Loading…'); ?></span><br>
 				<span> <?php echo I18n::_('Loading…'); ?></span><br>

+ 0 - 1
tst/.gitignore

@@ -1 +0,0 @@
-/ConfigurationCombinationsTest.php

+ 0 - 2
tst/.htaccess

@@ -1,2 +0,0 @@
-Allow from none
-Deny from all

+ 15 - 13
tst/Bootstrap.php

@@ -172,22 +172,24 @@ class Helper
      */
      */
     public static function rmDir($path)
     public static function rmDir($path)
     {
     {
-        $path .= DIRECTORY_SEPARATOR;
-        $dir = dir($path);
-        while (false !== ($file = $dir->read())) {
-            if ($file != '.' && $file != '..') {
-                if (is_dir($path . $file)) {
-                    self::rmDir($path . $file);
-                } elseif (is_file($path . $file)) {
-                    if (!unlink($path . $file)) {
-                        throw new Exception('Error deleting file "' . $path . $file . '".');
+        if (is_dir($path)) {
+            $path .= DIRECTORY_SEPARATOR;
+            $dir = dir($path);
+            while (false !== ($file = $dir->read())) {
+                if ($file != '.' && $file != '..') {
+                    if (is_dir($path . $file)) {
+                        self::rmDir($path . $file);
+                    } elseif (is_file($path . $file)) {
+                        if (!unlink($path . $file)) {
+                            throw new Exception('Error deleting file "' . $path . $file . '".');
+                        }
                     }
                     }
                 }
                 }
             }
             }
-        }
-        $dir->close();
-        if (!rmdir($path)) {
-            throw new Exception('Error deleting directory "' . $path . '".');
+            $dir->close();
+            if (!rmdir($path)) {
+                throw new Exception('Error deleting directory "' . $path . '".');
+            }
         }
         }
     }
     }
 
 

+ 1 - 1
tst/Data/DatabaseTest.php

@@ -311,7 +311,7 @@ class DatabaseTest extends PHPUnit_Framework_TestCase
             'vizhash BLOB, ' .
             'vizhash BLOB, ' .
             'postdate INT );'
             'postdate INT );'
         );
         );
-        $this->assertInstanceOf(Database::class, Database::getInstance($this->_options));
+        $this->assertInstanceOf('PrivateBin\\Data\\Database', Database::getInstance($this->_options));
 
 
         // check if version number was upgraded in created configuration table
         // check if version number was upgraded in created configuration table
         $statement = $db->prepare('SELECT value FROM foo_config WHERE id LIKE ?');
         $statement = $db->prepare('SELECT value FROM foo_config WHERE id LIKE ?');

+ 13 - 10
tst/Data/FilesystemTest.php

@@ -8,16 +8,26 @@ class FilesystemTest extends PHPUnit_Framework_TestCase
 
 
     private $_path;
     private $_path;
 
 
+    private $_invalidPath;
+
     public function setUp()
     public function setUp()
     {
     {
         /* Setup Routine */
         /* Setup Routine */
-        $this->_path  = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
-        $this->_model = Filesystem::getInstance(array('dir' => $this->_path));
+        $this->_path        = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
+        $this->_invalidPath = $this->_path . DIRECTORY_SEPARATOR . 'bar';
+        $this->_model       = Filesystem::getInstance(array('dir' => $this->_path));
+        if (!is_dir($this->_path)) {
+            mkdir($this->_path);
+        }
+        if (!is_dir($this->_invalidPath)) {
+            mkdir($this->_invalidPath);
+        }
     }
     }
 
 
     public function tearDown()
     public function tearDown()
     {
     {
         /* Tear Down Routine */
         /* Tear Down Routine */
+        chmod($this->_invalidPath, 0700);
         Helper::rmDir($this->_path);
         Helper::rmDir($this->_path);
     }
     }
 
 
@@ -37,6 +47,7 @@ class FilesystemTest extends PHPUnit_Framework_TestCase
         $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does not yet exist');
         $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does not yet exist');
         $this->assertTrue($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), Helper::getComment()), 'store comment');
         $this->assertTrue($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), Helper::getComment()), 'store comment');
         $this->assertTrue($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment exists after storing it');
         $this->assertTrue($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment exists after storing it');
+        $this->assertFalse($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), Helper::getComment()), 'unable to store the same comment twice');
         $comment           = json_decode(json_encode(Helper::getComment()));
         $comment           = json_decode(json_encode(Helper::getComment()));
         $comment->id       = Helper::getCommentId();
         $comment->id       = Helper::getCommentId();
         $comment->parentid = Helper::getPasteId();
         $comment->parentid = Helper::getPasteId();
@@ -99,10 +110,6 @@ class FilesystemTest extends PHPUnit_Framework_TestCase
         }
         }
     }
     }
 
 
-    /**
-     * @expectedException Exception
-     * @expectedExceptionCode 90
-     */
     public function testErrorDetection()
     public function testErrorDetection()
     {
     {
         $this->_model->delete(Helper::getPasteId());
         $this->_model->delete(Helper::getPasteId());
@@ -112,10 +119,6 @@ class FilesystemTest extends PHPUnit_Framework_TestCase
         $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does still not exist');
         $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does still not exist');
     }
     }
 
 
-    /**
-     * @expectedException Exception
-     * @expectedExceptionCode 90
-     */
     public function testCommentErrorDetection()
     public function testCommentErrorDetection()
     {
     {
         $this->_model->delete(Helper::getPasteId());
         $this->_model->delete(Helper::getPasteId());

+ 0 - 8
tst/FilterTest.php

@@ -4,14 +4,6 @@ use PrivateBin\Filter;
 
 
 class FilterTest extends PHPUnit_Framework_TestCase
 class FilterTest extends PHPUnit_Framework_TestCase
 {
 {
-    public function testFilterStripsSlashesDeeply()
-    {
-        $this->assertEquals(
-            array("f'oo", "b'ar", array("fo'o", "b'ar")),
-            Filter::stripslashesDeep(array("f\\'oo", "b\\'ar", array("fo\\'o", "b\\'ar")))
-        );
-    }
-
     public function testFilterMakesTimesHumanlyReadable()
     public function testFilterMakesTimesHumanlyReadable()
     {
     {
         $this->assertEquals('5 minutes', Filter::formatHumanReadableTime('5min'));
         $this->assertEquals('5 minutes', Filter::formatHumanReadableTime('5min'));

+ 32 - 0
tst/I18nTest.php

@@ -142,4 +142,36 @@ class I18nTest extends PHPUnit_Framework_TestCase
         I18n::loadTranslations();
         I18n::loadTranslations();
         $this->assertEquals('some string + 1', I18n::_('some %s + %d', 'string', 1), 'browser language en');
         $this->assertEquals('some string + 1', I18n::_('some %s + %d', 'string', 1), 'browser language en');
     }
     }
+
+    public function testMessageIdsExistInAllLanguages()
+    {
+        $messageIds = array();
+        $languages  = array();
+        $dir        = dir(PATH . 'i18n');
+        while (false !== ($file = $dir->read())) {
+            if (strlen($file) === 7) {
+                $language             = substr($file, 0, 2);
+                $languageMessageIds   = array_keys(
+                    json_decode(
+                        file_get_contents(PATH . 'i18n' . DIRECTORY_SEPARATOR . $file),
+                        true
+                    )
+                );
+                $messageIds           = array_unique(array_merge($messageIds, $languageMessageIds));
+                $languages[$language] = $languageMessageIds;
+            }
+        }
+        foreach ($messageIds as $messageId) {
+            foreach (array_keys($languages) as $language) {
+                // most languages don't translate the data size units, ignore those
+                if ($messageId !== 'B' && strlen($messageId) !== 3 && strpos($messageId, 'B', 2) !== 2) {
+                    $this->assertContains(
+                        $messageId,
+                        $languages[$language],
+                        "message ID '$messageId' exists in translation file $language.json"
+                    );
+                }
+            }
+        }
+    }
 }
 }

+ 3 - 2
tst/JsonApiTest.php

@@ -98,6 +98,7 @@ class JsonApiTest extends PHPUnit_Framework_TestCase
         new PrivateBin;
         new PrivateBin;
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
+        unlink($file);
         $response = json_decode($content, true);
         $response = json_decode($content, true);
         $this->assertEquals(0, $response['status'], 'outputs status');
         $this->assertEquals(0, $response['status'], 'outputs status');
         $this->assertEquals(Helper::getPasteId(), $response['id'], 'outputted paste ID matches input');
         $this->assertEquals(Helper::getPasteId(), $response['id'], 'outputted paste ID matches input');
@@ -132,6 +133,7 @@ class JsonApiTest extends PHPUnit_Framework_TestCase
         new PrivateBin;
         new PrivateBin;
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
+        unlink($file);
         $response = json_decode($content, true);
         $response = json_decode($content, true);
         $this->assertEquals(0, $response['status'], 'outputs status');
         $this->assertEquals(0, $response['status'], 'outputs status');
         $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste successfully deleted');
         $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste successfully deleted');
@@ -147,10 +149,9 @@ class JsonApiTest extends PHPUnit_Framework_TestCase
         $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists before deleting data');
         $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists before deleting data');
         $paste = $this->_model->read(Helper::getPasteId());
         $paste = $this->_model->read(Helper::getPasteId());
         $_POST = array(
         $_POST = array(
-            'action'      => 'delete',
+            'pasteid'     => Helper::getPasteId(),
             'deletetoken' => hash_hmac('sha256', Helper::getPasteId(), $paste->meta->salt),
             'deletetoken' => hash_hmac('sha256', Helper::getPasteId(), $paste->meta->salt),
         );
         );
-        $_SERVER['QUERY_STRING']          = Helper::getPasteId();
         $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
         $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
         $_SERVER['REQUEST_METHOD']        = 'POST';
         $_SERVER['REQUEST_METHOD']        = 'POST';
         ob_start();
         ob_start();

+ 53 - 0
tst/ModelTest.php

@@ -82,6 +82,7 @@ class ModelTest extends PHPUnit_Framework_TestCase
         $comment = $paste->getComment(Helper::getPasteId());
         $comment = $paste->getComment(Helper::getPasteId());
         $comment->setData($commentData['data']);
         $comment->setData($commentData['data']);
         $comment->setNickname($commentData['meta']['nickname']);
         $comment->setNickname($commentData['meta']['nickname']);
+        $comment->getParentId();
         $comment->store();
         $comment->store();
 
 
         $comment = $paste->getComment(Helper::getPasteId(), Helper::getCommentId());
         $comment = $paste->getComment(Helper::getPasteId(), Helper::getCommentId());
@@ -189,6 +190,27 @@ class ModelTest extends PHPUnit_Framework_TestCase
         $this->assertFalse(Paste::isValidId('../bar/baz'), 'path attack');
         $this->assertFalse(Paste::isValidId('../bar/baz'), 'path attack');
     }
     }
 
 
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 64
+     */
+    public function testInvalidPaste()
+    {
+        $this->_model->getPaste(Helper::getPasteId())->delete();
+        $paste = $this->_model->getPaste(Helper::getPasteId());
+        $paste->get();
+    }
+
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 61
+     */
+    public function testInvalidData()
+    {
+        $paste = $this->_model->getPaste();
+        $paste->setData('');
+    }
+
     /**
     /**
      * @expectedException Exception
      * @expectedException Exception
      * @expectedExceptionCode 62
      * @expectedExceptionCode 62
@@ -199,6 +221,37 @@ class ModelTest extends PHPUnit_Framework_TestCase
         $paste->getComment(Helper::getPasteId());
         $paste->getComment(Helper::getPasteId());
     }
     }
 
 
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 67
+     */
+    public function testInvalidCommentDeletedPaste()
+    {
+        $pasteData = Helper::getPaste();
+        $paste     = $this->_model->getPaste(Helper::getPasteId());
+        $paste->setData($pasteData['data']);
+        $paste->store();
+
+        $comment = $paste->getComment(Helper::getPasteId());
+        $paste->delete();
+        $comment->store();
+    }
+
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 68
+     */
+    public function testInvalidCommentData()
+    {
+        $pasteData = Helper::getPaste();
+        $paste     = $this->_model->getPaste(Helper::getPasteId());
+        $paste->setData($pasteData['data']);
+        $paste->store();
+
+        $comment = $paste->getComment(Helper::getPasteId());
+        $comment->store();
+    }
+
     public function testExpiration()
     public function testExpiration()
     {
     {
         $pasteData = Helper::getPaste();
         $pasteData = Helper::getPaste();

+ 35 - 38
tst/PrivateBinTest.php

@@ -140,21 +140,18 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
     public function testHtaccess()
     public function testHtaccess()
     {
     {
         $this->reset();
         $this->reset();
-        $dirs = array('cfg', 'lib');
-        foreach ($dirs as $dir) {
-            $file = PATH . $dir . DIRECTORY_SEPARATOR . '.htaccess';
-            @unlink($file);
-        }
+        $file = $this->_path . DIRECTORY_SEPARATOR . '.htaccess';
+        @unlink($file);
+
+        $_POST                            = Helper::getPaste();
+        $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
+        $_SERVER['REQUEST_METHOD']        = 'POST';
+        $_SERVER['REMOTE_ADDR']           = '::1';
         ob_start();
         ob_start();
         new PrivateBin;
         new PrivateBin;
         ob_end_clean();
         ob_end_clean();
-        foreach ($dirs as $dir) {
-            $file = PATH . $dir . DIRECTORY_SEPARATOR . '.htaccess';
-            $this->assertFileExists(
-                $file,
-                "$dir htaccess recreated"
-            );
-        }
+
+        $this->assertFileExists($file, 'htaccess recreated');
     }
     }
 
 
     /**
     /**
@@ -739,10 +736,10 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         new PrivateBin;
         new PrivateBin;
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
-        $this->assertContains(
-            '<div id="cipherdata" class="hidden">' .
-            htmlspecialchars(Helper::getPasteAsJson(), ENT_NOQUOTES) .
-            '</div>',
+        $this->assertRegExp(
+            '#<div id="cipherdata"[^>]*>' .
+            preg_quote(htmlspecialchars(Helper::getPasteAsJson(), ENT_NOQUOTES)) .
+            '</div>#',
             $content,
             $content,
             'outputs data correctly'
             'outputs data correctly'
         );
         );
@@ -760,7 +757,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="errormessage"[^>]*>.*Invalid paste ID\.</div>#',
+            '#<div[^>]*id="errormessage"[^>]*>.*Invalid paste ID\.#s',
             $content,
             $content,
             'outputs error correctly'
             'outputs error correctly'
         );
         );
@@ -778,7 +775,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist[^<]*</div>#',
+            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s',
             $content,
             $content,
             'outputs error correctly'
             'outputs error correctly'
         );
         );
@@ -798,7 +795,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist[^<]*</div>#',
+            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s',
             $content,
             $content,
             'outputs error correctly'
             'outputs error correctly'
         );
         );
@@ -818,10 +815,10 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         unset($burnPaste['meta']['salt']);
         unset($burnPaste['meta']['salt']);
-        $this->assertContains(
-            '<div id="cipherdata" class="hidden">' .
-            htmlspecialchars(Helper::getPasteAsJson($burnPaste['meta']), ENT_NOQUOTES) .
-            '</div>',
+        $this->assertRegExp(
+            '#<div id="cipherdata"[^>]*>' .
+            preg_quote(htmlspecialchars(Helper::getPasteAsJson($burnPaste['meta']), ENT_NOQUOTES)) .
+            '</div>#',
             $content,
             $content,
             'outputs data correctly'
             'outputs data correctly'
         );
         );
@@ -889,10 +886,10 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $meta['formatter'] = 'syntaxhighlighting';
         $meta['formatter'] = 'syntaxhighlighting';
-        $this->assertContains(
-            '<div id="cipherdata" class="hidden">' .
-            htmlspecialchars(Helper::getPasteAsJson($meta), ENT_NOQUOTES) .
-            '</div>',
+        $this->assertRegExp(
+            '#<div id="cipherdata"[^>]*>' .
+            preg_quote(htmlspecialchars(Helper::getPasteAsJson($meta), ENT_NOQUOTES)) .
+            '</div>#',
             $content,
             $content,
             'outputs data correctly'
             'outputs data correctly'
         );
         );
@@ -914,10 +911,10 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         ob_end_clean();
         ob_end_clean();
         $oldPaste['meta']['formatter'] = 'plaintext';
         $oldPaste['meta']['formatter'] = 'plaintext';
         unset($oldPaste['meta']['salt']);
         unset($oldPaste['meta']['salt']);
-        $this->assertContains(
-            '<div id="cipherdata" class="hidden">' .
-            htmlspecialchars(Helper::getPasteAsJson($oldPaste['meta']), ENT_NOQUOTES) .
-            '</div>',
+        $this->assertRegExp(
+            '#<div id="cipherdata"[^>]*>' .
+            preg_quote(htmlspecialchars(Helper::getPasteAsJson($oldPaste['meta']), ENT_NOQUOTES)) .
+            '</div>#',
             $content,
             $content,
             'outputs data correctly'
             'outputs data correctly'
         );
         );
@@ -939,7 +936,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="status"[^>]*>.*Paste was properly deleted[^<]*</div>#s',
+            '#<div[^>]*id="status"[^>]*>.*Paste was properly deleted\.#s',
             $content,
             $content,
             'outputs deleted status correctly'
             'outputs deleted status correctly'
         );
         );
@@ -960,7 +957,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="errormessage"[^>]*>.*Invalid paste ID\.</div>#',
+            '#<div[^>]*id="errormessage"[^>]*>.*Invalid paste ID\.#s',
             $content,
             $content,
             'outputs delete error correctly'
             'outputs delete error correctly'
         );
         );
@@ -980,7 +977,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist[^<]*</div>#',
+            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s',
             $content,
             $content,
             'outputs delete error correctly'
             'outputs delete error correctly'
         );
         );
@@ -1000,7 +997,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="errormessage"[^>]*>.*Wrong deletion token[^<]*</div>#',
+            '#<div[^>]*id="errormessage"[^>]*>.*Wrong deletion token\. Paste was not deleted\.#s',
             $content,
             $content,
             'outputs delete error correctly'
             'outputs delete error correctly'
         );
         );
@@ -1047,7 +1044,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         ob_end_clean();
         ob_end_clean();
         $response = json_decode($content, true);
         $response = json_decode($content, true);
         $this->assertEquals(1, $response['status'], 'outputs status');
         $this->assertEquals(1, $response['status'], 'outputs status');
-        $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste successfully deleted');
+        $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists after failing to delete data');
     }
     }
 
 
     /**
     /**
@@ -1067,7 +1064,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist[^<]*</div>#',
+            '#<div[^>]*id="errormessage"[^>]*>.*Paste does not exist, has expired or has been deleted\.#s',
             $content,
             $content,
             'outputs error correctly'
             'outputs error correctly'
         );
         );
@@ -1091,7 +1088,7 @@ class PrivateBinTest extends PHPUnit_Framework_TestCase
         $content = ob_get_contents();
         $content = ob_get_contents();
         ob_end_clean();
         ob_end_clean();
         $this->assertRegExp(
         $this->assertRegExp(
-            '#<div[^>]*id="status"[^>]*>.*Paste was properly deleted[^<]*</div>#s',
+            '#<div[^>]*id="status"[^>]*>.*Paste was properly deleted\.#s',
             $content,
             $content,
             'outputs deleted status correctly'
             'outputs deleted status correctly'
         );
         );

+ 1 - 0
tst/RequestTest.php

@@ -63,6 +63,7 @@ class RequestTest extends PHPUnit_Framework_TestCase
         file_put_contents($file, 'data=foo');
         file_put_contents($file, 'data=foo');
         Request::setInputStream($file);
         Request::setInputStream($file);
         $request = new Request;
         $request = new Request;
+        unlink($file);
         $this->assertTrue($request->isJsonApiCall(), 'is JSON Api call');
         $this->assertTrue($request->isJsonApiCall(), 'is JSON Api call');
         $this->assertEquals('create', $request->getOperation());
         $this->assertEquals('create', $request->getOperation());
         $this->assertEquals('foo', $request->getParam('data'));
         $this->assertEquals('foo', $request->getParam('data'));

+ 2 - 0
tst/SjclTest.php

@@ -1,11 +1,13 @@
 <?php
 <?php
 
 
+use PrivateBin\Persistence\ServerSalt;
 use PrivateBin\Sjcl;
 use PrivateBin\Sjcl;
 
 
 class SjclTest extends PHPUnit_Framework_TestCase
 class SjclTest extends PHPUnit_Framework_TestCase
 {
 {
     public function testSjclValidatorValidatesCorrectly()
     public function testSjclValidatorValidatesCorrectly()
     {
     {
+        ServerSalt::setPath(sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data');
         $paste = Helper::getPasteWithAttachment();
         $paste = Helper::getPasteWithAttachment();
         $this->assertTrue(Sjcl::isValid($paste['data']), 'valid sjcl');
         $this->assertTrue(Sjcl::isValid($paste['data']), 'valid sjcl');
         $this->assertTrue(Sjcl::isValid($paste['attachment']), 'valid sjcl');
         $this->assertTrue(Sjcl::isValid($paste['attachment']), 'valid sjcl');

+ 6 - 6
tst/ViewTest.php

@@ -96,15 +96,15 @@ class ViewTest extends PHPUnit_Framework_TestCase
     public function testTemplateRendersCorrectly()
     public function testTemplateRendersCorrectly()
     {
     {
         foreach ($this->_content as $template => $content) {
         foreach ($this->_content as $template => $content) {
-            $this->assertContains(
-                '<div id="cipherdata" class="hidden">' .
-                htmlspecialchars(Helper::getPaste()['data'], ENT_NOQUOTES) .
-                '</div>',
+            $this->assertRegExp(
+                '#<div[^>]+id="cipherdata"[^>]*>' .
+                preg_quote(htmlspecialchars(Helper::getPaste()['data'], ENT_NOQUOTES)) .
+                '</div>#',
                 $content,
                 $content,
                 $template . ': outputs data correctly'
                 $template . ': outputs data correctly'
             );
             );
             $this->assertRegExp(
             $this->assertRegExp(
-                '#<div[^>]+id="errormessage"[^>]*>.*' . self::$error . '</div>#',
+                '#<div[^>]+id="errormessage"[^>]*>.*' . self::$error . '#s',
                 $content,
                 $content,
                 $template . ': outputs error correctly'
                 $template . ': outputs error correctly'
             );
             );
@@ -119,7 +119,7 @@ class ViewTest extends PHPUnit_Framework_TestCase
                 $template . ': checked discussion if configured'
                 $template . ': checked discussion if configured'
             );
             );
             $this->assertRegExp(
             $this->assertRegExp(
-                '#<[^>]+id="opendisc"[^>]*>#',
+                '#<[^>]+id="opendiscussionoption"[^>]*>#',
                 $content,
                 $content,
                 $template . ': discussions available if configured'
                 $template . ': discussions available if configured'
             );
             );

+ 1 - 0
vendor/.htaccess

@@ -0,0 +1 @@
+Require all denied